Merge upstream dev into dataset view API

This commit is contained in:
MaxTomahawk
2026-08-25 11:54:22 +00:00
346 changed files with 74098 additions and 2755 deletions
+7 -2
View File
@@ -88,8 +88,13 @@ check(destroy < destroyTeardown and destroyTeardown < destroySuper,
local mainFile = assert(io.open("main.lua", "rb"))
local main = mainFile:read("*a")
mainFile:close()
check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
"returning from a game disables mod-owned secondary output")
check(main:find("SessionLifecycle.endGameSession", 1, true),
"returning from a game goes through SessionLifecycle.endGameSession")
local lifecycleFile = assert(io.open("src/core/SessionLifecycle.lua", "rb"))
local lifecycle = lifecycleFile:read("*a")
lifecycleFile:close()
check(lifecycle:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
"endGameSession disables mod-owned secondary output")
check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR")
@@ -41,7 +41,7 @@ end
-- Mock isReady
RomImporter.isReady = function(v)
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
or v == "silver"
or v == "silver" or v == "crystal"
end
local ok = RomImporter.syncAndroidShortcuts("gold")
@@ -57,6 +57,14 @@ RomImporter.syncAndroidShortcuts("silver")
check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload")
check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first")
capturedShortcuts = nil
RomImporter.syncAndroidShortcuts("crystal")
check(#capturedShortcuts == 4, "nor does a sixth")
check(capturedShortcuts[1] == "crystal", "activeVersion 'crystal' is placed first")
check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue"
and capturedShortcuts[4] == "yellow",
"and the rest still follow GameVersion.ORDER until the cap")
-- Test with subset of ready games (e.g. only Red and Gold)
RomImporter.isReady = function(v)
return v == "red" or v == "gold"
@@ -0,0 +1,244 @@
-- The BICYCLE is used straight off the item list, with no USE/TOSS box
-- (#1705).
--
-- StartMenu_Item's .choseItem reads wCurItem and, before it ever loads
-- USE_TOSS_MENU_TEMPLATE into wTextBoxID, does `cp BICYCLE / jp z,
-- .useOrTossItem` (engine/menus/start_sub_menus.asm:340-342). So the bike
-- mounts, dismounts or refuses on one A press, and -- having no TOSS row to
-- reach -- can never be thrown away from the bag at all. Every other item,
-- key items included, still gets the option box.
--
-- The port pushed the USE/TOSS Menu for every field item, so the bike took
-- two presses and offered a TOSS the cart does not.
-- luajit tests/engine/bag_bicycle_no_options_bug1705.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
local music = {}
package.loaded["src.core.Music"] = {
playMap = function(_, mapId, biking) music[#music + 1] = { mapId, biking } end,
}
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
soundOpts = function() return nil end,
}
package.loaded["src.ui.BagMenu"] = nil
local BagMenu = require("src.ui.BagMenu")
local Menu = require("src.ui.Menu")
require("src.ui.Screens").invalidate()
local Fixtures = require("tests.modkit.fixtures")
local Bag = require("src.inventory.Bag")
local Data = Fixtures.fresh()
-- the fixture item table carries neither, and BagMenu reads only
-- name/keyItem/machine off a def; the branches under test key on the id
Data.items.BICYCLE = { id = "BICYCLE", index = 6, name = "BICYCLE", price = 0,
keyItem = true }
Data.items.TOWN_MAP = { id = "TOWN_MAP", index = 5, name = "TOWN MAP",
price = 0, keyItem = true }
Data.items.FIX_POTION = Data.items.FIX_POTION
or { id = "FIX_POTION", index = 20, name = "FIX POTION", price = 300 }
local BIKE_MAP = { id = "FIX_TOWN", def = { tileset = "OVERWORLD" } }
local function freshGame(extras)
local game = {
data = Data,
save = {
party = {},
player = { name = "RED", id = 1 },
inventory = {},
options = {},
flags = {},
money = 0,
},
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = { pressed = nil }
function game.input:wasPressed(b) return self.pressed == b end
function game.input:isDown() return false end
-- IsBikeRidingAllowed reads the tileset off the loaded map, and the
-- mount/dismount lines read the player's name off the save
game.overworld = { map = BIKE_MAP, player = { surfing = false } }
Bag.add(game.save, "FIX_POTION", 3)
Bag.add(game.save, "BICYCLE", 1)
Bag.add(game.save, "TOWN_MAP", 1)
for k, v in pairs(extras or {}) do game.save[k] = v end
return game
end
local function rowFor(list, id)
for i, r in ipairs(list.items) do
if r.value == id then return i end
end
return nil
end
local function isMenu(s) return getmetatable(s) == Menu end
local function isBox(s) return type(s) == "table" and s.textBox == true end
local function inStack(game, pred)
for _, s in ipairs(game.stack.states) do
if pred(s) then return true end
end
return false
end
-- Open the bag, put the cursor on `id` and press A once. Returns the list
-- and every state the press left on the stack above it, so a USE/TOSS box
-- that appears and is then replaced is still caught.
local function chooseOnce(game, id)
local list = BagMenu.new(game, {})
game.stack:push(list)
local row = rowFor(list, id)
if not row then return nil, nil, "no " .. id .. " row in the bag" end
list.index = row
local seen = {}
local realPush = game.stack.push
game.stack.push = function(self, s)
seen[#seen + 1] = s
return realPush(self, s)
end
game.input.pressed = "a"
list:update(1 / 60)
game.input.pressed = nil
game.stack.push = realPush
return list, seen
end
local function sawMenu(seen)
for _, s in ipairs(seen or {}) do
if isMenu(s) then return true end
end
return false
end
local function boxText(game)
local top = game.stack:top()
return isBox(top) and top.text or nil
end
-- on foot, somewhere cycling is allowed: one A press mounts
do
music = {}
local game = freshGame()
local list, seen, why = chooseOnce(game, "BICYCLE")
if check(list ~= nil, "the bag opened on the BICYCLE row: " .. tostring(why)) then
check(not sawMenu(seen),
"no USE/TOSS Menu was pushed, not even for a frame (:340-342)")
eq(game.save.onBike, true, "the single press mounted the bike")
local said = boxText(game)
if check(said ~= nil, "and printed a line") then
check(said:find("got on", 1, true) ~= nil,
"which is the mount text: " .. tostring(said))
end
eq(game.save.inventory.BICYCLE, 1, "the bike is still in the bag")
eq(#music, 1, "the bike theme was cued once")
check(music[1] and music[1][2] == true, "as the riding track")
end
end
-- already riding: the same single press dismounts
do
music = {}
local game = freshGame({ onBike = true })
local list, seen = chooseOnce(game, "BICYCLE")
if check(list ~= nil, "the bag opened while riding") then
check(not sawMenu(seen), "still no option box on the way off the bike")
eq(game.save.onBike, false, "one press dismounted")
local said = boxText(game)
if check(said ~= nil, "and printed a line") then
check(said:find("got off", 1, true) ~= nil,
"which is the dismount text: " .. tostring(said))
end
check(music[1] and music[1][2] == false, "and the map theme came back")
end
end
-- Cycling Road: the refusal is reached on the same single press, and the
-- item list stays open behind it (`jp ItemMenuLoop`, #513)
do
local game = freshGame({ onBike = true, forcedBike = true })
local list, seen = chooseOnce(game, "BICYCLE")
if check(list ~= nil, "the bag opened on the Cycling Road") then
check(not sawMenu(seen), "the refusal is not behind an option box either")
eq(game.save.onBike, true, "and the player is still riding")
local said = boxText(game)
if check(said ~= nil, "the refusal printed") then
check(said:find("get off", 1, true) ~= nil,
"with _CannotGetOffHereText: " .. tostring(said))
end
check(inStack(game, function(s) return s == list end),
"the bag list is still on the stack under it (#513)")
end
end
-- somewhere cycling is not allowed: still one press, still no box
do
local game = freshGame()
game.overworld.map = { id = "FIX_INDOORS", def = { tileset = "FIX_HOUSE" } }
local list, seen = chooseOnce(game, "BICYCLE")
if check(list ~= nil, "the bag opened indoors") then
check(not sawMenu(seen), "the no-cycling refusal skips the box too")
eq(game.save.onBike, nil, "and nothing was mounted")
local said = boxText(game)
check(said ~= nil and said:find("cycling", 1, true) ~= nil,
"NoCyclingAllowedHere printed: " .. tostring(said))
end
end
-- the control cases: everything else still gets USE/TOSS, key items included
do
local game = freshGame()
local list, seen = chooseOnce(game, "FIX_POTION")
if check(list ~= nil, "the bag opened on a plain item") then
check(sawMenu(seen), "an ordinary item still opens the option box")
check(isMenu(game.stack:top()), "and it is what the A press left on top")
end
end
do
local game = freshGame()
local list, seen = chooseOnce(game, "TOWN_MAP")
if check(list ~= nil, "the bag opened on the TOWN MAP") then
check(sawMenu(seen),
"another key item still gets the box: pokered special-cases the "
.. "BICYCLE by id, not key items as a class")
end
end
-- the box the bike no longer opens is the only route to TOSS, so the bike
-- cannot be thrown away from the bag at all
do
local game = freshGame()
local list, seen = chooseOnce(game, "BICYCLE")
if check(list ~= nil, "the bag opened on the BICYCLE row") then
local rows = {}
for _, s in ipairs(seen or {}) do
for _, it in ipairs((isMenu(s) and s.items) or {}) do
rows[#rows + 1] = tostring(it.label)
end
end
eq(#rows, 0, "the press offered no menu rows at all, TOSS included")
eq(game.save.inventory.BICYCLE, 1, "and the bike survives the press")
end
end
package.loaded["src.core.Sound"] = nil
package.loaded["src.core.Music"] = nil
package.loaded["src.render.TextBox"] = nil
package.loaded["src.ui.BagMenu"] = nil
require("src.ui.Screens").invalidate()
T.finish()
+253
View File
@@ -0,0 +1,253 @@
-- The bag list ends on the terminator's CANCEL row (#1685).
--
-- pokered's item lists are $ff-terminated. PrintListMenuEntries walks four
-- entries and, on the terminator, `jp z, .printCancelMenuItem` -- which is
-- `ld de, ListMenuCancelText / jp PlaceString`, a TAIL jump that RETURNS
-- from PrintListMenuEntries (home/list_menu.asm:371-372, 523-528). The '▼'
-- at :518-522 is only reached by the fall-through, so any page showing
-- CANCEL shows no down arrow. DisplayListMenuIDLoop compares the selection
-- against wListCount and takes ExitListMenu when it is the row past the last
-- item (:105-110), which is the same exit B takes.
--
-- The port emitted one row per bag entry and stopped, so the list had no
-- CANCEL to walk onto and printed an invented "Nothing here." when the bag
-- was empty.
-- luajit tests/engine/bag_cancel_row_bug1685.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- Font wants a real atlas and this suite is about what lands on which row,
-- so it records the calls instead (the shape tests/engine/
-- bag_item_box_bug1521.lua uses). ListMenu and Theme bind Font at require
-- time, and BagMenu binds ListMenu and TextBox at require time.
local calls = {}
package.loaded["src.render.Font"] = {
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
width = function(text) return #tostring(text) * 8 end,
-- Menu.new sizes its box off split(); reaching it at all is the failure
-- this suite reports, so the stub answers instead of erroring
split = function(text)
local spans = {}
for i = 1, #tostring(text) do spans[i] = { from = i, to = i, code = 0 } end
return spans
end,
}
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
soundOpts = function() return nil end,
}
package.loaded["src.ui.ListMenu"] = nil
package.loaded["src.ui.Theme"] = nil
package.loaded["src.ui.BagMenu"] = nil
local ListMenu = require("src.ui.ListMenu")
local Theme = require("src.ui.Theme")
local BagMenu = require("src.ui.BagMenu")
require("src.ui.Screens").invalidate()
local Fixtures = require("tests.modkit.fixtures")
local Bag = require("src.inventory.Bag")
local Strings = require("src.core.Strings")
local CANCEL = Strings("CANCEL")
local Data = Fixtures.fresh()
for i = 1, 6 do
local id = "FIXITEM_" .. i
Data.items[id] = { id = id, index = 100 + i, name = "FIX ITEM " .. i,
price = 100, tossable = true }
end
local function freshGame(count)
local game = {
data = Data,
save = {
party = {},
player = { name = "RED", id = 1 },
inventory = {},
options = {},
flags = {},
money = 0,
},
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
-- one button edge per update, the way Input reports a fixed step
game.input = { pressed = nil }
function game.input:wasPressed(b) return self.pressed == b end
function game.input:isDown() return false end
for i = 1, count do Bag.add(game.save, "FIXITEM_" .. i, i) end
return game
end
local function openBag(game, opts)
local list = BagMenu.new(game, opts)
game.stack:push(list)
return list
end
local function found(kind, pred)
for _, c in ipairs(calls) do
if c[1] == kind and pred(c) then return c end
end
return nil
end
local function drawnAt(text, x, y)
return found("draw", function(c)
return c[2] == text and c[3] == x and (y == nil or c[4] == y)
end)
end
local function press(list, btn)
list.game.input.pressed = btn
list:update(1 / 60)
list.game.input.pressed = nil
end
-- the row itself: one CANCEL after the last bag entry, carrying no id and no
-- quantity (home/list_menu.asm:523-528)
do
local game = freshGame(6)
local list = openBag(game)
eq(#list.items, 7, "six bag entries plus the terminator's CANCEL row")
local last = list.items[#list.items]
eq(last.label, CANCEL, "the last row is CANCEL")
eq(last.cancel, true, "flagged as the terminator row, not an item")
eq(last.value, nil, "with no item id behind it")
eq(last.right, nil, "and no 'xN' count (PrintListMenuEntries never gets "
.. "that far for the terminator)")
eq(CANCEL, "CANCEL", "ListMenuCancelText reads CANCEL")
for i = 1, 6 do
eq(list.items[i].value, "FIXITEM_" .. i, "row " .. i .. " is still its item")
end
end
-- page one of a long list: four real names, the '▼', no CANCEL yet
do
local game = freshGame(6)
local list = openBag(game)
calls = {}
list:draw()
for row = 1, 4 do
local y = 32 + (row - 1) * 16
check(drawnAt("FIX ITEM " .. row, 48, y) ~= nil,
"name " .. row .. " sits at (48, " .. y .. ")")
end
check(found("draw", function(c) return c[2] == CANCEL end) == nil,
"CANCEL is below the fold on page one, not printed on it")
check(found("code", function(c)
return c[2] == Theme.moreArrow and c[3] == 144 and c[4] == 88
end) ~= nil, "four real rows fill the page, so the '▼' prints (:518-522)")
end
-- walking down: the cursor reaches CANCEL and stops there, and the page it
-- lands on drops the '▼'
do
local game = freshGame(6)
local list = openBag(game)
for _ = 1, 6 do press(list, "down") end
eq(list.index, 7, "six downs put the cursor on the CANCEL row")
eq(list.scroll, 4, "with the scroll capped at wListCount - 2")
press(list, "down")
eq(list.index, 7, "and a seventh down goes nowhere: CANCEL is the last row")
eq(list.scroll, 4, "the list does not scroll past it either")
calls = {}
list:draw()
-- rows 5, 6, CANCEL, then the terminator's return
check(drawnAt("FIX ITEM 5", 48, 32) ~= nil, "FIX ITEM 5 leads the last page")
check(drawnAt(CANCEL, 48, 64) ~= nil,
"CANCEL sits one row below the last item, at the item names' x")
check(found("code", function(c)
return c[2] == Theme.cursor and c[3] == 40 and c[4] == 64
end) ~= nil, "the cursor is on it, in wTopMenuItemX's column")
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
"and the '▼' is gone: .printCancelMenuItem returns before it")
end
-- the subtle one: three items plus CANCEL fills all four printed rows, so
-- `shown == rows` alone would still paint the arrow next to CANCEL
do
local game = freshGame(3)
local list = openBag(game)
eq(#list.items, 4, "three items and CANCEL exactly fill the four rows")
calls = {}
list:draw()
check(drawnAt(CANCEL, 48, 80) ~= nil, "CANCEL is the fourth printed row")
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
"a full page whose last row is CANCEL still prints no '▼' (:371-372)")
end
-- an empty bag is a box with CANCEL alone, which is what the cart shows
do
local game = freshGame(0)
local list = openBag(game)
eq(#list.items, 1, "no items: the terminator is the whole list")
eq(list.items[1] and list.items[1].cancel, true, "and that row is CANCEL")
calls = {}
list:draw()
check(drawnAt(CANCEL, 48, 32) ~= nil, "printed on the first row")
check(found("draw", function(c)
return tostring(c[2]):find("Nothing here", 1, true) ~= nil
end) == nil, "the port's invented \"Nothing here.\" line is gone")
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
"one row, no '▼'")
end
-- A on CANCEL is ExitListMenu: the same exit B takes (:105-110)
do
local game = freshGame(6)
local cancels = 0
local list = openBag(game, { onCancel = function() cancels = cancels + 1 end })
for _ = 1, 6 do press(list, "down") end
eq(list.index, 7, "cursor on CANCEL")
press(list, "a")
check(game.stack:top() ~= list, "A on CANCEL leaves the item list")
eq(#game.stack.states, 0, "with nothing pushed over it: no USE/TOSS box, "
.. "no message")
eq(cancels, 1, "and the caller's onCancel ran, exactly as for B")
end
do
local game = freshGame(6)
local cancels = 0
local list = openBag(game, { onCancel = function() cancels = cancels + 1 end })
press(list, "b")
check(game.stack:top() ~= list, "B still exits the list")
eq(cancels, 1, "through the same callback")
end
-- SELECT swaps two bag entries; the terminator is not one
-- (engine/menus/swap_items.asm:19-22)
do
local game = freshGame(6)
local list = openBag(game)
for _ = 1, 6 do press(list, "down") end
press(list, "select")
eq(list.swapIndex, nil, "SELECT on the CANCEL row starts no swap")
press(list, "up")
press(list, "select")
eq(list.swapIndex, 6, "while SELECT on a real row still does")
end
package.loaded["src.render.Font"] = nil
package.loaded["src.core.Sound"] = nil
package.loaded["src.render.TextBox"] = nil
package.loaded["src.ui.ListMenu"] = nil
package.loaded["src.ui.Theme"] = nil
package.loaded["src.ui.BagMenu"] = nil
require("src.ui.Screens").invalidate()
T.finish()
@@ -0,0 +1,336 @@
-- Validation and fail-closed behavior for battle.field_residual descriptors.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
local Events = require("src.mods.Events")
local GameMethods = require("src.core.Game")
local Hooks = require("src.mods.Hooks")
local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TypeChart = require("src.battle.TypeChart")
local data = T.fixtures.fresh()
TypeChart.load(data)
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
local game = {
data = data,
save = save,
stack = { top = function() return nil end, push = function() end },
}
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.phase, battle.queue = "menu", {}
return battle
end
local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks,
Runtime.errors
local hooks = Hooks.new()
Runtime.install(savedEvents, hooks, {})
local battle = newBattle()
local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp
local playerType = battle.player.curTypes[1]
local cyclic = { label = "cycle-data" }
cyclic.self = cyclic
local metatableData = setmetatable({ label = "plain-data" }, {
__index = { hidden = "metatable-data" },
})
battle.field.weather = {
id = "probe", turns = 3,
callback = function() end,
handle = io.stdout,
worker = coroutine.create(function() end),
cyclic = cyclic,
metatableData = metatableData,
}
battle.field.tokens[1] = { id = "nested", turns = 2,
state = { intensity = 4 } }
battle:enter()
hooks:wrap("battle.field_residual", function(next, context)
local vanilla = next(context)
T.same(vanilla, {}, "the vanilla contribution is an empty descriptor list")
context.battlers.player.hp = 0
context.battlers.player.types[1] = "MUTATED"
T.eq(context.field.sides, nil,
"the field view has the checkpoint shape and no live side graph")
T.eq(context.field.weather.callback, nil,
"the field view omits executable values")
T.eq(context.field.weather.handle, nil,
"the field view omits userdata")
T.eq(context.field.weather.worker, nil,
"the field view omits threads")
T.eq(context.field.weather.cyclic.label, "cycle-data",
"the field view retains scalar data around a cycle")
T.eq(context.field.weather.cyclic.self, nil,
"the field view omits cyclic edges")
T.eq(getmetatable(context.field.weather.metatableData), nil,
"the field view carries no metatable")
T.eq(context.field.weather.metatableData.label, "plain-data",
"the field view retains raw data from a metatable-bearing table")
T.eq(context.field.weather.metatableData.hidden, nil,
"the field view does not expose metatable-provided values")
context.field.weather.turns = 0
context.field.tokens[1].state.intensity = 99
return {
false,
{ side = "unknown", amount = 20, message = "invalid side" },
{ side = "player", amount = "3", message = "numeric string" },
{ side = "player", amount = 0, message = "zero" },
{ side = "player", amount = -2, message = "negative" },
{ side = "player", amount = 1.5, message = "fractional" },
{ side = "player", amount = "not a number", message = "bad amount" },
{ side = "player", amount = 0 / 0, message = "not finite" },
{ side = "player", amount = math.huge, message = "non-finite" },
{ side = "player", amount = 2, message = function() end },
{ side = "enemy", amount = 3 },
}
end, 0, "validation_probe")
battle:applyFieldResiduals()
T.eq(battle.player.mon.hp, playerHp,
"a descriptor with a non-string message fails closed")
T.eq(battle.enemy.mon.hp, enemyHp - 3,
"a valid descriptor may omit its message")
T.eq(battle.player.curTypes[1], playerType,
"mutating the detached type view cannot mutate the live battler")
T.check(battle.player.mon.hp ~= 0,
"mutating detached HP cannot replace engine damage authority")
T.eq(battle.field.weather.turns, 3,
"mutating the detached weather view cannot mutate live field state")
T.eq(battle.field.tokens[1].state.intensity, 4,
"mutating nested detached token state cannot mutate live field state")
local guarded = newBattle()
local fieldReads, runtimeCalls = 0, 0
guarded.field = setmetatable({}, { __index = function()
fieldReads = fieldReads + 1
return nil
end })
local realRuntimeCall = Runtime.call
Runtime.call = function(...)
runtimeCalls = runtimeCalls + 1
return realRuntimeCall(...)
end
Runtime.install(savedEvents, Hooks.new(), {})
guarded:applyFieldResiduals()
Runtime.call = realRuntimeCall
T.eq(runtimeCalls, 0,
"a disabled field hook never enters Runtime.call")
T.eq(fieldReads, 0,
"a disabled field hook does not construct its field context")
local nilBattle = newBattle()
local nilHp = nilBattle.player.mon.hp
local nilHooks = Hooks.new()
Runtime.install(savedEvents, nilHooks, {})
nilHooks:wrap("battle.field_residual", function() return nil end,
0, "nil_probe")
nilBattle:applyFieldResiduals()
T.eq(nilBattle.player.mon.hp, nilHp,
"a non-table hook result fails closed")
local settled = newBattle()
local settledCalls = 0
local settledHooks = Hooks.new()
Runtime.install(savedEvents, settledHooks, {})
settledHooks:wrap("battle.field_residual", function(next, context)
settledCalls = settledCalls + 1
return next(context)
end, 0, "settled_probe")
settled.result = "win"
settled:endOfTurn()
T.eq(settledCalls, 0,
"a settled battle never invokes field residual policy")
local function drainQueue(battle)
local rows, guard = {}, 0
while battle.queue[1] and guard < 1000 do
guard = guard + 1
local row = table.remove(battle.queue, 1)
rows[#rows + 1] = row
if row.fn then
battle.nextInsert = 0
row.fn()
end
end
T.check(guard < 1000, "the simultaneous-faint queue completes")
return rows
end
local function simultaneousTerminal(order)
local save = SaveData.newGame()
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
local game = {
data = data,
save = save,
stack = { top = function() return nil end, push = function() end },
}
local double = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
double.phase, double.queue = "menu", {}
local originalEnemy, originalEnemyIndex = double.enemy, double.enemyIndex
local startingExp = double.player.mon.exp
local events, doubleHooks = Events.new(), Hooks.new()
local awardCalls, expEvents, switches = 0, 0, 0
events:on("battle.exp_gained", function() expEvents = expEvents + 1 end,
0, "double_probe")
events:on("battle.battler_switched", function() switches = switches + 1 end,
0, "double_probe")
Runtime.install(events, doubleHooks, {})
doubleHooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
for _, side in ipairs(order) do
rows[#rows + 1] = {
side = side,
amount = context.battlers[side].hp,
}
end
return rows
end, 0, "double_probe")
doubleHooks:wrap("battle.exp_award", function(next, context)
awardCalls = awardCalls + 1
return next(context)
end, 0, "double_probe")
double:endOfTurn()
local queued = drainQueue(double)
T.eq(double.player.mon.hp, 0,
"simultaneous residuals settle the player side")
T.eq(double.enemy.mon.hp, 0,
"simultaneous residuals settle the enemy side")
T.eq(double.result, "lose",
"a simultaneous terminal residual resolves as player blackout")
T.eq(double.afterQueue, "finish",
"the completed simultaneous-faint queue closes the battle")
T.eq(double.player.faintQueued, true,
"the terminal hook batch queues player faint authority")
T.eq(double.enemy.faintQueued, nil,
"the terminal hook batch suppresses only its enemy faint authority")
T.eq(double.player.mon.exp, startingExp,
"a blackout does not award contradictory enemy-faint EXP")
T.eq(awardCalls, 0,
"a blackout never enters the enemy EXP-award policy")
T.eq(expEvents, 0,
"a blackout emits no contradictory EXP event")
T.eq(switches, 0,
"a blackout does not send the trainer's reserve into battle")
T.eq(double.enemyIndex, originalEnemyIndex,
"a blackout leaves the enemy roster position unchanged")
T.check(double.enemy == originalEnemy,
"a blackout queues no contradictory enemy replacement")
for _, row in ipairs(queued) do
T.eq(row.ui, nil,
"a simultaneous terminal residual queues no replacement UI")
end
end
simultaneousTerminal({ "player", "enemy" })
simultaneousTerminal({ "enemy", "player" })
local timing = newBattle()
local timingOrder = {}
timing.ruleset = require("src.battle.rulesets.modern_clean")
timing.player.mon.status = "PSN"
timing.field.tokens[1] = { id = "expires", turns = 1,
onExpire = function() timingOrder[#timingOrder + 1] = "token_expired" end }
local timingEvents, timingHooks = Events.new(), Hooks.new()
timingEvents:on("battle.turn_ended", function()
timingOrder[#timingOrder + 1] = "turn_ended"
end, 0, "timing_probe")
Runtime.install(timingEvents, timingHooks, {})
local preStatusHp = timing.player.mon.hp
timingHooks:wrap("battle.field_residual", function(next, context)
timingOrder[#timingOrder + 1] = "field_residual"
T.check(context.battlers.player.hp < preStatusHp,
"the hook snapshot observes completed vanilla status residuals")
return next(context)
end, 0, "timing_probe")
timing:endOfTurn()
T.same(timingOrder,
{ "field_residual", "token_expired", "turn_ended" },
"the hook runs before token expiry and battle.turn_ended")
local oldGetState, oldSetState = love.math.getRandomState,
love.math.setRandomState
local checkpointRng = "field-residual-rng"
love.math.getRandomState = function() return checkpointRng end
love.math.setRandomState = function(state) checkpointRng = state end
local function checkpointBattle()
local save = SaveData.newGame()
save.meta.playthroughId = "field-residual-checkpoint"
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
SaveData.validate(save, data)
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.player.facing, save.player.surfing = "left", false
local stack = setmetatable({ states = {} }, { __index = StateStack })
local overworld = {
map = { id = "FIX_TOWN" },
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {},
scriptMoves = {},
}
function overworld:captureSave(target)
target.player.map = self.map.id
target.player.x, target.player.y = self.player.cellX, self.player.cellY
target.player.facing = self.player.facing
target.player.surfing = self.player.surfing and true or false
end
function overworld:restoreBattleContinuation(restored, origin)
restored.onFinish = function() end
return origin.kind == "wild_encounter" and origin.map == self.map.id
end
local game = setmetatable({ data = data, save = save, stack = stack,
overworld = overworld }, { __index = GameMethods })
stack.states[1] = overworld
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.phase, battle.queue = "menu", {}
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
battle.musicKind = battle:computeMusicKind()
battle.onFinish = function() end
battle.field.weather = { id = "checkpoint-weather", turns = 5 }
stack.states[2] = battle
return game, battle
end
local checkpointGame = checkpointBattle()
local checkpointHooks, restoredCalls = Hooks.new(), 0
Runtime.install(Events.new(), checkpointHooks, {})
checkpointHooks:wrap("battle.field_residual", function(next, context)
restoredCalls = restoredCalls + 1
T.same(context.field.weather,
{ id = "checkpoint-weather", turns = 5 },
"the enabled hook observes checkpointed field state after restore")
return next(context)
end, 0, "checkpoint_probe")
local snapshot, captureCode = Checkpoint.capture(checkpointGame)
T.check(snapshot ~= nil,
"an enabled process-local hook does not enter the checkpoint: "
.. tostring(captureCode))
if snapshot then
checkpointGame.stack:top().field.weather.turns = 1
local restored, restoreCode, restoreMessage =
Checkpoint.restore(checkpointGame, snapshot)
T.check(restored == true,
"field state reconstructs while the hook remains enabled: "
.. tostring(restoreCode or restoreMessage))
if restored then checkpointGame.stack:top():applyFieldResiduals() end
if restored then
T.same(Checkpoint.capture(checkpointGame), snapshot,
"enabled-hook field state completes capture/restore/capture round-trip")
end
end
T.eq(restoredCalls, 1,
"the process-local hook still runs after checkpoint reconstruction")
love.math.getRandomState, love.math.setRandomState = oldGetState, oldSetState
Runtime.install(savedEvents, savedHooks, savedErrors)
T.finish("battle.field_residual validation")
File diff suppressed because it is too large Load Diff
+507
View File
@@ -0,0 +1,507 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
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.harness")
local Base64 = require("src.core.Base64")
local CartManifest = require("src.carts.CartManifest")
local SaveSerializer = require("src.core.SaveSerializer")
local SHA = ("a1b2c3d4"):rep(8)
local MD5 = ("0123456789abcdef"):rep(2)
local function baseCart()
return {
id = "kanto_plus",
title = " Kanto Plus ",
version = "1.2.0",
author = "Ren",
repo = "ren/kanto-plus",
summary = "A sealed set of five",
shell = "#3FA9F5",
label = "./art/label.png",
base = "red",
engine = ">=1.4.0",
mods = {
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
version = "0.4.1", sha256 = SHA,
options = { sweetness = 3, flavour = "grape", fizzy = true } },
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
md5 = MD5 },
},
}
end
local function rejects(mutate, fragment, what)
local tbl = baseCart()
mutate(tbl)
local cart, err = CartManifest.parse(tbl)
T.eq(cart, nil, what .. " is rejected")
T.check(type(err) == "string" and err:find(fragment, 1, true) ~= nil,
("%s says why (got %s)"):format(what, tostring(err)))
end
local raw = baseCart()
local cart, err = CartManifest.parse(raw)
T.check(cart ~= nil, "a good manifest parses: " .. tostring(err))
T.eq(cart.title, "Kanto Plus", "title is trimmed")
T.eq(cart.shell, "#3fa9f5", "shell normalises to lowercase")
T.eq(cart.label, "art/label.png", "label normalises through SafePath")
T.eq(cart.seal, "sealed", "seal defaults to sealed")
T.eq(cart.base, "red", "base survives")
T.eq(cart.engine, ">=1.4.0", "engine range is kept unevaluated")
T.eq(#cart.mods, 2, "both pins survive")
T.eq(cart.mods[1].sha256, SHA, "the github pin keeps its sha256")
T.eq(cart.mods[1].repo, "ren/rare-soda", "the github pin keeps its repo")
T.eq(cart.mods[1].version, "0.4.1", "the github pin keeps its version")
T.eq(cart.mods[1].options.flavour, "grape", "frozen option values survive")
T.eq(cart.mods[2].source, "gamebanana", "the gamebanana pin keeps its source")
T.eq(cart.mods[2].mod, 4821, "the gamebanana pin keeps its mod id")
T.eq(cart.mods[2].file, 99123, "the gamebanana pin keeps its file id")
T.eq(cart.mods[2].md5, MD5, "the gamebanana pin keeps its md5")
T.eq(cart.mods[2].sha256, nil, "a gamebanana pin carries no sha256")
T.eq(cart.load_order[1], "rare_soda", "load_order defaults to the mods order")
T.eq(cart.load_order[2], "hard_mode", "load_order defaults to the mods order")
T.eq(raw.title, " Kanto Plus ", "parse does not trim the input in place")
T.eq(raw.shell, "#3FA9F5", "parse does not recolour the input in place")
T.eq(raw.load_order, nil, "parse does not add load_order to the input")
T.neq(cart.mods, raw.mods, "the parsed mods array is a fresh table")
T.neq(cart.mods[1].options, raw.mods[1].options, "options are copied")
local again = CartManifest.parse(baseCart())
T.eq(CartManifest.canonical(again), CartManifest.canonical(cart),
"two independent parses encode identically")
T.eq(CartManifest.hash(again), CartManifest.hash(cart),
"two independent parses hash identically")
T.eq(#CartManifest.hash(cart), 32, "the cart hash is an MD5 hex digest")
local bumped = baseCart()
bumped.mods[1].version = "0.4.2"
T.neq(CartManifest.hash(CartManifest.parse(bumped)), CartManifest.hash(cart),
"a bumped mod version moves the cart hash")
local retuned = baseCart()
retuned.mods[1].options.sweetness = 4
T.neq(CartManifest.hash(CartManifest.parse(retuned)), CartManifest.hash(cart),
"a changed option value moves the cart hash")
local reordered = baseCart()
reordered.load_order = { "hard_mode", "rare_soda" }
local reorderedCart = CartManifest.parse(reordered)
T.eq(reorderedCart.load_order[1], "hard_mode", "an explicit load_order is kept")
T.neq(CartManifest.hash(reorderedCart), CartManifest.hash(cart),
"a different load order moves the cart hash")
local encoded = CartManifest.encode(cart)
local decoded, decodeErr = CartManifest.decode(encoded)
T.check(decoded ~= nil, "an encoded cart decodes: " .. tostring(decodeErr))
T.same(decoded, cart, "the round trip is lossless")
T.eq(CartManifest.hash(decoded), CartManifest.hash(cart),
"the round trip keeps the cart hash")
T.eq(CartManifest.decode(nil), nil, "decode refuses a non-string")
T.eq(CartManifest.decode(""), nil, "decode refuses an empty file")
T.eq(CartManifest.decode("return { }"), nil, "decode refuses an untagged file")
T.eq(CartManifest.decode('return { format = "g1rmodlist" }'), nil,
"decode refuses another format's file")
T.eq(CartManifest.decode(
('return { format = "%s", formatVersion = 99, cart = {} }')
:format(CartManifest.FORMAT)), nil, "decode refuses an unknown schema")
T.eq(CartManifest.decode('return os.exit(1)'), nil,
"decode refuses a file that tries to call out")
T.eq(CartManifest.decode(
('return { format = "%s", formatVersion = 1, cart = { id = "x" } }')
:format(CartManifest.FORMAT)), nil, "decode validates the cart it carries")
rejects(function(c) c.id = nil end, "cart id", "a missing id")
rejects(function(c) c.id = "kanto plus" end, "cart id", "an id with a space")
rejects(function(c) c.id = ("k"):rep(65) end, "cart id", "a 65 character id")
rejects(function(c) c.title = nil end, "cart title", "a missing title")
rejects(function(c) c.title = " " end, "cart title", "a blank title")
rejects(function(c) c.title = ("T"):rep(49) end, "cart title", "a 49 character title")
rejects(function(c) c.version = nil end, "cart version", "a missing version")
rejects(function(c) c.version = "one" end, "cart version", "a non-semver version")
rejects(function(c) c.author = nil end, "cart author", "a missing author")
rejects(function(c) c.author = "" end, "cart author", "an empty author")
rejects(function(c) c.author = ("A"):rep(65) end, "cart author", "a 65 character author")
rejects(function(c) c.repo = "ren" end, "cart repo", "a repo with no owner")
rejects(function(c) c.repo = "ren/kanto/plus" end, "cart repo", "a three part repo")
rejects(function(c) c.summary = ("s"):rep(121) end, "cart summary", "a 121 character summary")
rejects(function(c) c.shell = nil end, "cart shell", "a missing shell colour")
rejects(function(c) c.shell = "3FA9F5" end, "cart shell", "a shell colour with no hash")
rejects(function(c) c.shell = "#3FA9F" end, "cart shell", "a five digit shell colour")
rejects(function(c) c.shell = "#gggggg" end, "cart shell", "a non-hex shell colour")
rejects(function(c) c.label = "../../etc/passwd" end, "cart label", "a climbing label path")
rejects(function(c) c.label = "/etc/passwd" end, "cart label", "an absolute label path")
rejects(function(c) c.label = ("a"):rep(129) end, "cart label", "a 129 character label path")
rejects(function(c) c.base = nil end, "cart base", "a missing base game")
rejects(function(c) c.base = "nonesuch" end, "cart base", "an unknown base game")
rejects(function(c) c.engine = "" end, "cart engine", "an empty engine range")
rejects(function(c) c.engine = 3 end, "cart engine", "a numeric engine range")
rejects(function(c) c.seal = "welded" end, "cart seal", "an unknown seal")
rejects(function(c) c.mods = nil end, "cart mods", "a cart with no mods array")
rejects(function(c) c.mods = {} end, "cart must pin", "a cart that pins nothing")
rejects(function(c)
for i = 1, 65 do
c.mods[i] = { id = "mod" .. i, source = "gamebanana", mod = i, file = i, md5 = MD5 }
end
end, "cart must pin", "a cart that pins 65 mods")
rejects(function(c) c.mods[1] = "rare_soda" end, "must be a table", "a string mod entry")
rejects(function(c) c.mods[1].id = nil end, "id must be", "a pin with no id")
rejects(function(c) c.mods[1].id = "rare soda" end, "id must be", "a pin id with a space")
rejects(function(c) c.mods[2].id = "rare_soda" end, "pinned twice", "the same mod pinned twice")
rejects(function(c) c.mods[1].source = nil end, "source must be", "a pin with no source")
rejects(function(c) c.mods[1].source = "dropbox" end, "source must be", "a pin from an unknown source")
rejects(function(c) c.mods[1].repo = nil end, "repo must be", "a github pin with no repo")
rejects(function(c) c.mods[1].version = "latest" end, "version must be", "a github pin with no semver")
rejects(function(c) c.mods[1].sha256 = nil end, "sha256", "a github pin with no sha256")
rejects(function(c) c.mods[1].sha256 = SHA:upper() end, "sha256", "an uppercase sha256")
rejects(function(c) c.mods[1].sha256 = SHA:sub(1, 63) end, "sha256", "a short sha256")
rejects(function(c) c.mods[2].mod = nil end, "mod must be", "a gamebanana pin with no mod id")
rejects(function(c) c.mods[2].mod = 0 end, "mod must be", "a gamebanana mod id of zero")
rejects(function(c) c.mods[2].mod = 12.5 end, "mod must be", "a fractional gamebanana mod id")
rejects(function(c) c.mods[2].file = nil end, "file must be", "a gamebanana pin with no file id")
rejects(function(c) c.mods[2].file = -3 end, "file must be", "a negative gamebanana file id")
rejects(function(c) c.mods[2].md5 = nil end, "md5", "a gamebanana pin with no md5")
rejects(function(c) c.mods[2].md5 = MD5:upper() end, "md5", "an uppercase md5")
rejects(function(c) c.mods[2].md5 = MD5 .. "00" end, "md5", "an overlong md5")
rejects(function(c) c.mods[1].options = "grape" end, "options must be a table",
"a non-table options block")
rejects(function(c) c.mods[1].options = { [("k"):rep(65)] = 1 } end,
"option keys", "a 65 character option key")
rejects(function(c) c.mods[1].options = { [1] = "grape" } end,
"option keys", "a numeric option key")
rejects(function(c) c.mods[1].options.nested = { 1, 2 } end,
"must be a string, number or boolean", "a table option value")
rejects(function(c) c.mods[1].options.flavour = ("g"):rep(257) end,
"characters or fewer", "a 257 character option value")
rejects(function(c)
local options = {}
for i = 1, 65 do options["opt" .. i] = i end
c.mods[1].options = options
end, "more than 64 options", "a pin with 65 options")
rejects(function(c) c.load_order = "rare_soda" end, "load_order must be an array",
"a string load_order")
rejects(function(c) c.load_order = { "rare_soda" } end, "exactly once",
"a load_order that drops a mod")
rejects(function(c) c.load_order = { "rare_soda", "hard_mode", "hard_mode" } end,
"exactly once", "a load_order longer than the mods array")
rejects(function(c) c.load_order = { "rare_soda", "rare_soda" } end, "twice",
"a load_order that repeats a mod")
rejects(function(c) c.load_order = { "rare_soda", "master_ball" } end,
"does not pin", "a load_order naming an unpinned mod")
rejects(function(c) c.mods[1].enabled = "no" end, "enabled must be",
"a string enabled flag")
rejects(function(c) c.mods[1].enabled = 0 end, "enabled must be",
"a numeric enabled flag")
rejects(function(c) c.options = "fast" end, "cart options must be a table",
"a non-table cart options block")
rejects(function(c) c.options = { [2] = 1 } end, "cart option keys",
"a numeric cart option key")
rejects(function(c) c.options = { textSpeed = { 1 } } end,
"must be a string, number or boolean", "a table cart option value")
T.eq(CartManifest.parse(nil), nil, "parse refuses a non-table")
-- ------- a pin the cart ships switched off
T.eq(cart.mods[1].enabled, nil, "a pin that says nothing carries no enabled flag")
T.eq(CartManifest.modEnabled(cart.mods[1]), true, "and defaults to enabled")
T.eq(CartManifest.modEnabled(nil), false, "modEnabled refuses a non-entry")
local offRaw = baseCart()
offRaw.mods[1].enabled = false
local offCart = CartManifest.parse(offRaw)
T.check(offCart ~= nil, "a pin switched off parses")
T.eq(offCart.mods[1].enabled, false, "and keeps the flag")
T.eq(CartManifest.modEnabled(offCart.mods[1]), false, "which reads back as off")
T.eq(offCart.mods[1].sha256, SHA, "a switched-off pin is pinned like any other")
T.eq(offCart.mods[1].options.flavour, "grape", "with its options captured")
T.same(CartManifest.decode(CartManifest.encode(offCart)), offCart,
"a switched-off pin survives the file round trip")
T.neq(CartManifest.hash(offCart), CartManifest.hash(cart),
"and is part of the cart hash")
local onRaw = baseCart()
onRaw.mods[1].enabled = true
T.eq(CartManifest.canonical(CartManifest.parse(onRaw)),
CartManifest.canonical(cart),
"an explicit enabled = true is the same cart as saying nothing")
-- ------- settings the cart ships
local optRaw = baseCart()
optRaw.options = { textSpeed = 1, animations = false, ruleset = "gen1_faithful" }
local optCart, optErr = CartManifest.parse(optRaw)
T.check(optCart ~= nil, "a cart that ships settings parses: " .. tostring(optErr))
T.eq(optCart.options.textSpeed, 1, "the shipped number survives")
T.eq(optCart.options.animations, false, "the shipped boolean survives")
T.eq(optCart.options.ruleset, "gen1_faithful", "the shipped string survives")
T.eq(cart.options, nil, "a cart that ships none carries no options table")
T.same(CartManifest.decode(CartManifest.encode(optCart)), optCart,
"shipped settings survive the file round trip")
T.neq(CartManifest.hash(optCart), CartManifest.hash(cart),
"and are part of the cart hash")
T.neq(optCart.options, optCart.mods[1].options,
"a cart's own settings are not a mod's")
-- ------- the sealed+ seal
local plusRaw = baseCart()
plusRaw.seal = "sealed+"
local plusCart, plusErr = CartManifest.parse(plusRaw)
T.check(plusCart ~= nil, "a sealed+ cart parses: " .. tostring(plusErr))
T.eq(plusCart.seal, "sealed+", "the seal survives")
T.same(CartManifest.decode(CartManifest.encode(plusCart)), plusCart,
"a sealed+ cart round trips through a file")
T.neq(CartManifest.hash(plusCart), CartManifest.hash(cart),
"and sealed+ is part of the cart hash")
T.eq(CartManifest.SEALS["sealed+"], true, "sealed+ is a known seal")
-- ------- the launcher fields the file round trip used to drop
local dressed = baseCart()
dressed.finish = "sparkle+holo"
dressed.speeds = { 1, 2 }
local dressedCart = CartManifest.parse(dressed)
local reread = CartManifest.decode(CartManifest.encode(dressedCart))
T.eq(reread.finish, "sparkle+holo", "a cart's finish survives the file round trip")
T.same(reread.speeds, { 1, 2 }, "and so does its speed ladder")
T.eq(CartManifest.hash(reread), CartManifest.hash(dressedCart),
"so an installed copy hashes the same as the one that was written")
-- ------- a cart that uses none of the above serializes exactly as it always did
T.eq(CartManifest.canonical(cart),
"[cart].6:author$3:Ren.4:base$3:red.6:engine$7:>=1.4.0.2:id$10:kanto_plus"
.. ".5:label$13:art/label.png.4:repo$14:ren/kanto-plus.4:seal$6:sealed"
.. ".5:shell$7:#3fa9f5.7:summary$20:A sealed set of five.5:title$10:Kanto Plus"
.. ".7:version$5:1.2.0[mods]@9:rare_soda.2:id$9:rare_soda"
.. ".4:repo$13:ren/rare-soda.6:sha256$64:" .. SHA
.. ".6:source$6:github.7:version$5:0.4.1[options].5:fizzyT.7:flavour$5:grape"
.. ".9:sweetness#3@9:hard_mode.4:file#99123.2:id$9:hard_mode.3:md5$32:" .. MD5
.. ".3:mod#4821.6:source$10:gamebanana[options][order]@9:rare_soda@9:hard_mode",
"the canonical string of a cart using no new field is byte for byte the old one")
T.eq(CartManifest.hash(cart), "2a8fbbacbd1df3f349b6a6ed387fa036",
"so its hash, and every save stamped with it, is unchanged")
local open = baseCart()
open.seal = "open"
open.repo = nil
open.summary = nil
open.label = nil
open.engine = nil
local openCart = CartManifest.parse(open)
T.check(openCart ~= nil, "an open cart with no optional fields parses")
T.eq(openCart.seal, "open", "an open seal survives")
T.eq(openCart.label, nil, "an absent label stays absent")
T.same(CartManifest.decode(CartManifest.encode(openCart)), openCart,
"an open cart round trips")
T.neq(CartManifest.hash(openCart), CartManifest.hash(cart),
"the seal is part of the cart hash")
T.eq(CartManifest.publishable(cart), true,
"a cart pinned entirely to github and gamebanana is publishable")
T.eq(CartManifest.publishable(nil), false, "publishable refuses a non-cart")
T.eq(CartManifest.publishable({}), false, "publishable refuses an unparsed cart")
local localised = baseCart()
localised.mods[1] = { id = "rare_soda", source = "local", version = "0.4.1",
repo = "ren/rare-soda", sha256 = SHA,
options = { flavour = "grape" } }
local localCart, localErr = CartManifest.parse(localised)
T.check(localCart ~= nil, "a local pin parses: " .. tostring(localErr))
T.eq(localCart.mods[1].source, "local", "the local pin keeps its source")
T.eq(localCart.mods[1].version, "0.4.1", "the local pin keeps its version")
T.eq(localCart.mods[1].repo, nil, "a local pin carries no repo")
T.eq(localCart.mods[1].sha256, nil, "a local pin carries no sha256")
T.eq(localCart.mods[1].md5, nil, "a local pin carries no md5")
T.eq(localCart.mods[1].options.flavour, "grape", "a local pin still freezes options")
T.eq(localCart.mods[2].source, "gamebanana", "the sibling pin is untouched")
T.eq(CartManifest.canonical(CartManifest.parse(localised)),
CartManifest.canonical(localCart), "two parses of a local pin encode identically")
T.eq(CartManifest.hash(CartManifest.parse(localised)), CartManifest.hash(localCart),
"two parses of a local pin hash identically")
T.neq(CartManifest.hash(localCart), CartManifest.hash(cart),
"a local pin hashes differently from the github pin it replaced")
local localBump = baseCart()
localBump.mods[1] = { id = "rare_soda", source = "local", version = "0.4.2",
options = { flavour = "grape" } }
T.neq(CartManifest.hash(CartManifest.parse(localBump)), CartManifest.hash(localCart),
"a bumped local pin version moves the cart hash")
T.same(CartManifest.decode(CartManifest.encode(localCart)), localCart,
"a cart holding a local pin round trips")
local publishableLocal, localWhy = CartManifest.publishable(localCart)
T.eq(publishableLocal, false, "a cart holding a local pin is not publishable")
T.check(type(localWhy) == "string" and localWhy:find("rare_soda", 1, true) ~= nil,
"the reason names the local pin (got " .. tostring(localWhy) .. ")")
T.check(localWhy:find("hard_mode", 1, true) == nil,
"the reason leaves the publishable pins out")
local allLocal = baseCart()
allLocal.mods = {
{ id = "rare_soda", source = "local", version = "0.4.1" },
{ id = "hard_mode", source = "local", version = "2.0.0" },
}
local _, allWhy = CartManifest.publishable(CartManifest.parse(allLocal))
T.check(allWhy:find("hard_mode", 1, true) ~= nil and allWhy:find("rare_soda", 1, true) ~= nil,
"the reason names every local pin (got " .. tostring(allWhy) .. ")")
rejects(function(c) c.mods[1] = { id = "rare_soda", source = "local" } end,
"version must be", "a local pin with no version")
rejects(function(c)
c.mods[1] = { id = "rare_soda", source = "local", version = "latest" }
end, "version must be", "a local pin with a non-semver version")
rejects(function(c) c.mods[1].source = "localhost" end, "source must be",
"a source that merely starts like local")
local VECTORS = {
{ "", "" }, { "f", "Zg==" }, { "fo", "Zm8=" }, { "foo", "Zm9v" },
{ "foob", "Zm9vYg==" }, { "fooba", "Zm9vYmE=" }, { "foobar", "Zm9vYmFy" },
{ "\0\255\0", "AP8A" }, { "\255\255\255\255", "/////w==" },
}
for _, row in ipairs(VECTORS) do
T.eq(Base64.encode(row[1]), row[2],
("base64 encodes %q as %s"):format(row[1], row[2]))
T.eq(Base64.decode(row[2]), row[1],
("base64 decodes %s back"):format(row[2] == "" and "an empty string" or row[2]))
end
local seed = 7
local function nextByte()
seed = (seed * 75 + 74) % 65537
return seed % 256
end
for n = 0, 24 do
local chunk = {}
for i = 1, n do chunk[i] = string.char(nextByte()) end
local raw = table.concat(chunk)
local text = Base64.encode(raw)
T.eq(#text % 4, 0, ("base64 pads %d bytes to a multiple of four"):format(n))
T.eq(Base64.decode(text), raw, ("base64 round trips %d random bytes"):format(n))
end
T.eq(Base64.encode(nil), nil, "base64 encode refuses a non-string")
T.eq(Base64.decode(nil), nil, "base64 decode refuses a non-string")
T.eq(Base64.decode("TWF"), nil, "base64 refuses a length that is not a multiple of four")
T.eq(Base64.decode("TW*u"), nil, "base64 refuses a character outside the alphabet")
T.eq(Base64.decode("TWFu\n\n\n\n"), nil, "base64 refuses embedded whitespace")
T.eq(Base64.decode("TW=u"), nil, "base64 refuses padding inside a group")
T.eq(Base64.decode("=WFu"), nil, "base64 refuses a leading pad character")
T.eq(Base64.decode("TWFu===="), nil, "base64 refuses a group that is all padding")
T.eq(Base64.decode("TR=="), nil, "base64 refuses one-byte padding that carries data bits")
T.eq(Base64.decode("Zm9vYmF="), nil, "base64 refuses two-byte padding that carries data bits")
T.eq(Base64.decode("TWFu"), "Man", "base64 decodes a known vector")
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRtiny label art"
local ART_DATA = Base64.encode(PNG)
local NONE = {}
local function artTable(over)
local art = { name = "label.png", encoding = "base64", bytes = #PNG,
data = ART_DATA }
for key, value in pairs(over or {}) do
if value == NONE then art[key] = nil else art[key] = value end
end
return art
end
local function bundle(body, art)
return SaveSerializer.encode({ format = CartManifest.FORMAT,
formatVersion = CartManifest.SCHEMA, cart = body, labelArt = art })
end
local arted = CartManifest.parse(baseCart())
arted.labelArt = artTable()
local artedBytes = CartManifest.encode(arted)
local artedRound, artedErr = CartManifest.decode(artedBytes)
T.check(artedRound ~= nil, "a cart with label art decodes: " .. tostring(artedErr))
T.same(artedRound, arted, "the label art survives the encode and decode round trip")
T.eq(artedRound.labelArt.data, ART_DATA, "the base64 payload is preserved verbatim")
T.eq(artedRound.labelArt.bytes, #PNG, "the declared byte count is preserved")
T.eq(artedRound.labelArt.name, "label.png", "the art name is preserved")
T.eq(CartManifest.encode(artedRound), artedBytes,
"re-encoding a decoded cart writes the same bytes")
local artBytes, artName = CartManifest.labelArtBytes(artedRound)
T.eq(artBytes, PNG, "labelArtBytes hands back the PNG that was packed")
T.eq(artName, "label.png", "labelArtBytes hands back the art name")
T.eq(CartManifest.labelArtBytes(cart), nil, "a cart with no art has no art bytes")
T.eq(CartManifest.hash(arted), CartManifest.hash(cart),
"label art is not part of the cart hash")
T.check(CartManifest.canonical(arted):find(ART_DATA, 1, true) == nil,
"the canonical form leaves the art payload out")
local repainted = CartManifest.parse(baseCart())
local REPAINT = PNG .. "repainted"
repainted.labelArt = artTable({ data = Base64.encode(REPAINT), bytes = #REPAINT })
T.eq(CartManifest.hash(repainted), CartManifest.hash(arted),
"changing only the art leaves the cart hash alone")
T.eq(CartManifest.labelArtBytes(CartManifest.decode(CartManifest.encode(repainted))),
REPAINT, "the repainted art round trips")
local plain = CartManifest.decode(CartManifest.encode(cart))
T.eq(plain.labelArt, nil, "a cart with no art still decodes without art")
T.check(CartManifest.encode(cart):find("labelArt", 1, true) == nil,
"a cart with no art writes no labelArt key")
T.same(plain, cart, "a cart with no art round trips exactly as before")
local OVERSIZE = PNG .. string.rep("\0", CartManifest.MAX_LABEL_ART)
local OVERSIZE_DATA = Base64.encode(OVERSIZE)
local function drops(art, what)
local decodedArt, dropErr = CartManifest.decode(bundle(cart, art))
T.check(decodedArt ~= nil, what .. " still loads the cart: " .. tostring(dropErr))
T.eq(decodedArt.labelArt, nil, what .. " drops the art")
T.same(decodedArt, cart, what .. " leaves the rest of the cart untouched")
end
drops("label.png", "art that is not a table")
drops(artTable({ encoding = "hex" }), "art in an encoding we do not support")
drops(artTable({ encoding = NONE }), "art with no encoding")
drops(artTable({ data = "not base64!!" }), "art whose payload is not base64")
drops(artTable({ data = NONE }), "art with no payload")
drops(artTable({ data = "" }), "art with an empty payload")
drops(artTable({ bytes = #PNG + 1 }), "art whose byte count is too high")
drops(artTable({ bytes = #PNG - 1 }), "art whose byte count is too low")
drops(artTable({ bytes = NONE }), "art with no byte count")
drops(artTable({ bytes = tostring(#PNG) }), "art whose byte count is a string")
drops(artTable({ data = Base64.encode("GIF89a not a png at all"),
bytes = #"GIF89a not a png at all" }), "art that is not a PNG")
drops(artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }), "art past the size cap")
drops(artTable({ name = "../../etc/passwd" }), "art whose name climbs out")
drops(artTable({ name = ("a"):rep(129) }), "art with a 129 character name")
drops(artTable({ name = 7 }), "art with a numeric name")
local unnamed = CartManifest.decode(bundle(cart, artTable({ name = NONE })))
T.check(unnamed ~= nil and unnamed.labelArt ~= nil, "art with no name is still kept")
T.eq(unnamed.labelArt.name, nil, "the missing art name stays missing")
T.eq(CartManifest.labelArtBytes(unnamed), PNG, "unnamed art still decodes")
T.eq(CartManifest.parseLabelArt(nil), nil, "parseLabelArt refuses an absent payload")
local _, capErr = CartManifest.parseLabelArt(
artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }))
T.check(type(capErr) == "string" and capErr:find("bytes or fewer", 1, true) ~= nil,
"the size cap says why (got " .. tostring(capErr) .. ")")
local _, pngErr = CartManifest.parseLabelArt(
artTable({ data = Base64.encode("nope"), bytes = 4 }))
T.check(type(pngErr) == "string" and pngErr:find("PNG", 1, true) ~= nil,
"a non-PNG payload says why (got " .. tostring(pngErr) .. ")")
T.finish("cart_manifest")
+353
View File
@@ -0,0 +1,353 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local realFS = love.filesystem
local function memfs(files)
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
return nil
end,
}
end
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
local function options(files)
return SaveSerializer.decode(files["options.lua"] or "") or {}
end
local function plainSave(name, hash)
return {
version = "red",
meta = hash and { cartHash = hash } or nil,
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
pokedex = { seen = {}, owned = {} },
inventory = {},
playTime = 0,
}
end
do
fresh()
T.eq(SaveData.getCart(), nil, "no cart is active by default")
T.eq(SaveData.setCart("nuzlocke", "abc123"), "nuzlocke", "setCart returns the id")
T.eq(SaveData.getCart(), "nuzlocke", "getCart reads the active cart back")
T.eq(SaveData.getCartHash(), "abc123", "the build hash rides along")
T.eq(SaveData.setCart(nil), nil, "setCart(nil) returns to vanilla play")
T.eq(SaveData.getCart(), nil, "and getCart says so")
T.eq(SaveData.getCartHash(), nil, "clearing the cart clears its build hash")
T.eq(SaveData.setCart("../evil"), nil, "a path-climbing cart id is refused")
T.eq(SaveData.setCart(".."), nil, "so is a bare parent reference")
T.eq(SaveData.setCart("has spaces"), nil, "so is a non-word id")
T.eq(SaveData.setCart(42), nil, "so is a non-string id")
T.eq(SaveData.getCart(), nil, "none of them become the active cart")
end
do
local files = fresh()
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "a cart starts with no slots")
T.eq(SaveData.activeCartSlot("nuzlocke"), nil, "and no active slot")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), nil, "and no stamped hash")
T.eq(files["options.lua"], nil, "listing an empty cart writes no options file")
T.eq(#SaveData.listCartSlots("../evil"), 0, "an unusable cart id lists nothing")
T.eq(SaveData.createCartSlot("../evil"), nil, "and can allocate no slot")
local ok, err = SaveData.deleteCartSlot("../evil", "slot1")
T.check(not ok, "and deleting from it fails")
T.check(tostring(err):find("unknown cart", 1, true) ~= nil,
"with a user-presentable reason")
end
do
local files = fresh()
T.eq(SaveData.createCartSlot("nuzlocke"), "slot1", "first cart slot is slot1")
T.eq(SaveData.createCartSlot("nuzlocke"), "slot2", "ids increment as they do per version")
T.eq(SaveData.setActiveCartSlot("nuzlocke", "slot2"), "slot2",
"setActiveCartSlot returns the chosen id")
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot2", "and the choice is live")
local opts = options(files)
T.eq(opts.cartSlots.nuzlocke.active, "slot2", "the active id persists in options.lua")
T.eq(opts.cartSlots.nuzlocke.list[1], "slot1", "the slot list persists with it")
T.eq(opts.cartSlots.nuzlocke.list[2], "slot2", "in allocation order")
T.eq(opts.saveSlots, nil, "no per-version registry is created by cart work")
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", " Hardcore "),
"renameCartSlot labels a registered slot")
T.eq(options(files).cartSlots.nuzlocke.names.slot1, "Hardcore",
"the label is trimmed and persisted")
T.eq(SaveData.listCartSlots("nuzlocke")[1].label, "Hardcore",
"listCartSlots carries the label")
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", ""), "an empty name clears it")
T.eq(options(files).cartSlots.nuzlocke.names, nil,
"the names table leaves the registry once empty")
local bad, badErr = SaveData.renameCartSlot("nuzlocke", "slot99", "x")
T.check(not bad, "renaming an unregistered cart slot fails")
T.check(tostring(badErr):find("not registered", 1, true) ~= nil,
"unknown-slot rename error is user-presentable")
T.check(SaveData.writeCartSlot("nuzlocke", "slot2", plainSave("NUZ")),
"seed the active cart slot")
T.check(files["saves/cart_nuzlocke/slot2.lua"] ~= nil,
"the bytes land in the cart's own directory")
T.check(SaveData.deleteCartSlot("nuzlocke", "slot2"), "deleteCartSlot removes it")
T.eq(files["saves/cart_nuzlocke/slot2.lua"], nil, "the slot file is gone")
opts = options(files)
T.eq(#opts.cartSlots.nuzlocke.list, 1, "the id is dropped from the registry")
T.eq(opts.cartSlots.nuzlocke.active, "slot1",
"active falls back to the remaining slot")
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot1", "and the live cache follows")
T.check(SaveData.deleteCartSlot("nuzlocke", "slot1"), "deleting the last slot works")
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "the cart is empty again")
T.eq(options(files).cartSlots.nuzlocke.active, nil, "active clears with the list")
end
do
local files = fresh()
T.eq(SaveData.createCartSlot("alpha"), "slot1", "alpha allocates its own slot1")
T.eq(SaveData.createCartSlot("beta"), "slot1", "beta allocates its own slot1")
T.check(SaveData.writeCartSlot("alpha", "slot1", plainSave("AAA", "aaa111")),
"seed alpha's slot")
T.check(SaveData.writeCartSlot("beta", "slot1", plainSave("BBB", "bbb222")),
"seed beta's slot")
T.eq(#SaveData.listCartSlots("alpha"), 1, "alpha lists only its own slot")
T.eq(#SaveData.listCartSlots("beta"), 1, "beta lists only its own slot")
T.eq(SaveData.listCartSlots("alpha")[1].name, "AAA", "alpha reads its own save")
T.eq(SaveData.listCartSlots("beta")[1].name, "BBB", "beta reads its own save")
T.check(files["saves/cart_alpha/slot1.lua"] ~= files["saves/cart_beta/slot1.lua"],
"two carts with the same slot id write different files")
T.eq(SaveData.slotCartHash("alpha", "slot1"), "aaa111", "alpha's build stamp")
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "beta's build stamp")
T.check(SaveData.deleteCartSlot("alpha", "slot1"), "delete alpha's only slot")
T.eq(#SaveData.listCartSlots("alpha"), 0, "alpha is empty")
T.eq(#SaveData.listCartSlots("beta"), 1, "beta is untouched")
T.check(files["saves/cart_beta/slot1.lua"] ~= nil, "beta's file survives")
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "as does its stamp")
end
do
local files = fresh()
T.eq(SaveData.createSlot("red"), "slot1", "red allocates a version slot")
T.eq(SaveData.createCartSlot("red"), "slot1",
"a cart may even be named after a version")
T.check(SaveData.writeSlot("red", "slot1", plainSave("VANILLA")),
"seed the version slot")
T.check(SaveData.writeCartSlot("red", "slot1", plainSave("CARTRED")),
"seed the cart slot")
T.eq(#SaveData.listSlots("red"), 1, "the version lists only its own slot")
T.eq(SaveData.listSlots("red")[1].name, "VANILLA", "with the vanilla save in it")
T.eq(#SaveData.listCartSlots("red"), 1, "the cart lists only its own slot")
T.eq(SaveData.listCartSlots("red")[1].name, "CARTRED", "with the cart save in it")
T.check(files["saves/red/slot1.lua"] ~= nil, "the version path is saves/red/")
T.check(files["saves/cart_red/slot1.lua"] ~= nil, "the cart path is saves/cart_red/")
T.eq(SaveData.listSlots("red")[1].cartHash, nil,
"a version slot carries no cart hash")
T.check(SaveData.deleteCartSlot("red", "slot1"), "uninstall-style cart slot delete")
T.eq(#SaveData.listSlots("red"), 1, "the vanilla slot is still registered")
T.check(files["saves/red/slot1.lua"] ~= nil, "and its file is not orphaned")
T.eq(options(files).saveSlots.red.list[1], "slot1",
"the version registry is independent of the cart one")
end
do
local files = fresh()
SaveData.setCart("nuzlocke", "abc123")
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
"with no cart slot yet, the flat path follows the version suffix scheme")
SaveData.createCartSlot("nuzlocke")
SaveData.setActiveCartSlot("nuzlocke", "slot1")
T.eq(SaveData.saveFilename("red"), "saves/cart_nuzlocke/slot1.lua",
"the active cart slot owns the save path")
local save = SaveData.newGame()
save.player.name = "NUZ"
T.check(SaveData.save(save, {}), "an in-game save under a cart writes")
T.check(files["saves/cart_nuzlocke/slot1.lua"] ~= nil, "into the cart's slot")
T.eq(files["save.lua"], nil, "never into the base game's flat file")
T.eq(files["saves/red/slot1.lua"], nil, "never into the base game's slot")
T.eq(#SaveData.listSlots("red"), 0, "and the base game still has no slots")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "NUZ", "load reads the cart's slot back")
T.eq(loaded.meta.cartHash, "abc123", "the save records the build it was made under")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
"and the registry mirrors it for a listing with no save decode")
T.eq(SaveData.listCartSlots("nuzlocke")[1].cartHash, "abc123",
"listCartSlots surfaces the stamp")
end
do
fresh()
SaveData.setCart("nuzlocke", "abc123")
SaveData.createCartSlot("nuzlocke")
SaveData.setActiveCartSlot("nuzlocke", "slot1")
T.check(SaveData.save(SaveData.newGame(), {}), "first save under build abc123")
local loaded = SaveData.load("red")
T.check(SaveData.save(loaded, {}), "a re-save rebuilds meta")
T.eq(SaveData.load("red").meta.cartHash, "abc123",
"buildMeta carries the stamp instead of dropping it")
SaveData.setCartHash("def456")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
"installing an update does not restamp the slot")
loaded = SaveData.load("red")
T.eq(loaded.meta.cartHash, "abc123", "nor the in-progress save")
T.check(SaveData.save(loaded, {}), "save under the new build")
T.eq(SaveData.load("red").meta.cartHash, "def456", "now the save carries it")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "def456", "and so does the registry")
local ok, err = SaveData.setSlotCartHash("nuzlocke", "slot9", "zzz")
T.check(not ok, "an unregistered slot cannot be stamped")
T.check(tostring(err):find("not registered", 1, true) ~= nil,
"with a user-presentable reason")
end
do
local files = fresh()
SaveData.createSlot("red")
SaveData.setActiveSlot("red", "slot1")
local save = SaveData.newGame()
save.player.name = "VANILLA"
T.check(SaveData.save(save, {}), "a vanilla save with no cart set")
local bytes = files["saves/red/slot1.lua"]
T.check(bytes:find("cartHash", 1, true) == nil, "records no cartHash")
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
"and options.lua grows no cart registry")
T.eq(options(files).cartSlots, nil, "which decodes as absent, not empty")
local loaded = SaveData.load("red")
T.check(SaveData.save(loaded), "re-save the migrated shape once")
local before = files["saves/red/slot1.lua"]
SaveData.setCart("nuzlocke", "abc123")
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
"the cart takes over the path while it is active")
SaveData.setCart(nil)
T.eq(SaveData.saveFilename("red"), "saves/red/slot1.lua",
"and hands it straight back")
T.check(SaveData.save(loaded), "re-save after the cart round trip")
T.eq(files["saves/red/slot1.lua"], before, "the vanilla bytes are identical")
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
"and options.lua still carries no cart registry")
local again = SaveData.load("red")
T.check(again and again.player.name == "VANILLA", "the vanilla save still loads")
T.eq(again.meta.cartHash, nil, "with no cart stamp on it")
end
-- ------- cart-scoped settings
do
local files = fresh()
local base = SaveData.loadOptions()
base.textSpeed = 5
base.musicVol = 2
base.speedBattle = 1
T.check(SaveData.saveOptions(base), "the base game writes its own settings")
local plain = files["options.lua"]
SaveData.setCart("nuzlocke", "hash1")
local inCart = SaveData.loadOptions()
T.eq(inCart.textSpeed, 5, "a cart starts from the global value")
T.eq(inCart.musicVol, 2, "for every key, scoped or not")
inCart.textSpeed = 1
inCart.musicVol = 7
inCart.speedBattle = 4
T.check(SaveData.saveOptions(inCart), "change some settings inside the cart")
local stored = options(files)
T.eq(stored.textSpeed, 5, "the global text speed is untouched")
T.eq(stored.cartOptions.nuzlocke.textSpeed, 1, "the cart keeps its own")
T.eq(stored.cartOptions.nuzlocke.speedBattle, 4, "and its own battle speed")
T.eq(stored.musicVol, 7, "an unscoped setting is written globally")
T.eq(stored.cartOptions.nuzlocke.musicVol, nil, "and never lands in the cart")
T.eq(SaveData.loadOptions().textSpeed, 1, "the cart reads its value back")
T.eq(SaveData.loadOptions().musicVol, 7, "and the machine's shared volume")
SaveData.setCart("marathon", "hash2")
T.eq(SaveData.loadOptions().textSpeed, 5,
"another cart does not see the first cart's setting")
local other = SaveData.loadOptions()
other.textSpeed = 3
T.check(SaveData.saveOptions(other), "the second cart sets its own")
T.eq(options(files).cartOptions.nuzlocke.textSpeed, 1,
"which leaves the first cart's alone")
SaveData.setCart(nil)
T.eq(SaveData.loadOptions().textSpeed, 5, "and the base game keeps the global")
T.eq(SaveData.loadOptions().speedBattle, 1, "for every scoped key")
T.check(plain:find("cartOptions", 1, true) ~= nil,
"the options file declares the overlay")
T.check(plain:find("nuzlocke", 1, true) == nil,
"but an install that never ran a cart names no cart in it")
SaveData.setCart(nil)
local vanilla = SaveData.loadOptions()
vanilla.textSpeed = 4
T.check(SaveData.saveOptions(vanilla), "a base-game write after playing carts")
T.eq(options(files).cartOptions.nuzlocke.textSpeed, 1,
"leaves every cart's overlay intact")
T.eq(options(files).cartOptions.marathon.textSpeed, 3, "for every cart")
end
do
local files = fresh()
SaveData.setCart("author_cart", "hash3")
T.eq(SaveData.seedCartOptions({ textSpeed = 1, animations = false,
musicVol = 0, nonesuch = 3 }), true, "a cart seeds its shipped settings")
local seeded = SaveData.loadOptions()
T.eq(seeded.textSpeed, 1, "the author's text speed applies")
T.eq(seeded.animations, false, "and the author's battle animation choice")
T.eq(seeded.musicVol, 7, "a machine setting is never seeded")
T.eq(options(files).cartOptions.author_cart.musicVol, nil, "not even into the cart")
T.eq(options(files).cartOptions.author_cart.nonesuch, nil,
"and a key outside the cart-scoped set is ignored")
T.eq(options(files).cartOptionsSeeded.author_cart, true, "the seed is recorded")
local player = SaveData.loadOptions()
player.textSpeed = 5
T.check(SaveData.saveOptions(player), "the player then picks their own speed")
T.eq(SaveData.seedCartOptions({ textSpeed = 1, animations = false }), false,
"a second boot re-seeds nothing")
T.eq(SaveData.loadOptions().textSpeed, 5, "so the player's change survives")
SaveData.setCart(nil)
T.eq(SaveData.loadOptions().textSpeed, 3,
"and the base game still has the shipped default of its own")
T.eq(SaveData.seedCartOptions({ textSpeed = 1 }), false,
"seeding with no cart active does nothing")
end
love.filesystem = realFS
T.finish("cart_saves")
+641
View File
@@ -0,0 +1,641 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local Loader = require("src.mods.Loader")
local CartManifest = require("src.carts.CartManifest")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local GameVersion = require("src.core.GameVersion")
local realFS = love.filesystem
local function memfs(files)
local fs
fs = {
files = files,
read = function(path) return files[path] end,
write = function(path, content) files[path] = content return true end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
load = function(path)
if not files[path] then return nil, "no file: " .. path end
return load(files[path], path)
end,
createDirectory = function() return true end,
getDirectoryItems = function(path)
local seen, items = {}, {}
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
local child = key:sub(#prefix + 1):match("^[^/]+")
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
end
table.sort(items)
return items
end,
}
return fs
end
local function manifestJson(id)
return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"}]])
:format(id, id)
end
local function entry(record)
return ([[
return function(mod)
mod.options:define({ { key = "tint", default = "base" } })
mod.content.pokemon:register("%s", { name = tostring(mod.options:get("tint")) })
end
]]):format(record)
end
local function install()
local files = {
["options.lua"] = SaveSerializer.encode({
mods = { beta = false },
modOptions = { alpha = { tint = "player" }, beta = { tint = "player" } },
}),
}
for _, id in ipairs({ "alpha", "beta", "gamma" }) do
files["mods/" .. id .. "/manifest.json"] = manifestJson(id)
files["mods/" .. id .. "/main.lua"] = entry(id:upper())
end
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
local function writeCart(files, tbl)
local cart, err = CartManifest.parse(tbl)
assert(cart, err)
files["carts/" .. tbl.id .. CartManifest.EXT] = CartManifest.encode(cart)
return cart
end
local function cartTable(id, seal, mods, order)
return { id = id, title = id, version = "1.0.0", author = "tester",
shell = "#102030", base = "red", seal = seal,
mods = mods, load_order = order }
end
local function pin(id, version, options)
return { id = id, source = "local", version = version or "1.0.0",
options = options }
end
local function offPin(id, version, options)
local p = pin(id, version, options)
p.enabled = false
return p
end
local function boot(files)
local data = { pokemon = {} }
local loader = Loader.new({ fs = memfs(files) })
local ok = loader:load(data)
return loader, data, ok
end
local function options(files)
return SaveSerializer.decode(files["options.lua"] or "") or {}
end
local function names(list)
return table.concat(list, ",")
end
-- ------- a sealed cart loads its pins, in its order, with its options
do
local files = install()
writeCart(files, cartTable("sealed", "sealed",
{ pin("alpha", "1.0.0", { tint = "cart" }), pin("beta") },
{ "beta", "alpha" }))
SaveData.setCart("sealed", "hash1")
local loader, data, ok = boot(files)
T.check(ok, "a sealed cart whose pins are all installed loads cleanly")
T.eq(names(loader.order), "beta,alpha",
"the cart's load_order beats priority and the id tie-break")
T.eq(data.pokemon.ALPHA.name, "cart",
"a frozen option overrides the player's saved value")
T.eq(data.pokemon.BETA.name, "base",
"a pin that froze no options falls to the schema default, not the player's")
T.eq(data.pokemon.GAMMA, nil, "an enabled mod the cart does not pin never runs")
T.eq(loader.mods.gamma.enabled, false, "and is reported as inactive")
T.eq(loader.mods.gamma.state, "disabled", "with the disabled row state")
T.eq(loader.mods.beta.enabled, true, "a pin the player switched off still loads")
local report = loader:cartStatus()
T.eq(report.id, "sealed", "the report names the cart")
T.eq(report.seal, "sealed", "and its seal")
T.eq(report.enforced, true, "which is enforced")
T.eq(report.refused, false, "and not refused")
T.eq(#report.missing, 0, "with no missing pins")
T.eq(#report.mismatched, 0, "and no version mismatches")
T.eq(loader:status().cart, report, "status() carries the same report")
local opts = options(files)
T.eq(SaveData.modEnabled(opts, "beta", "red"), false,
"the player's disable flag is untouched on disk")
T.eq(SaveData.modEnabled(opts, "gamma", "red") == false, false,
"and so is the enable flag of the mod the seal left out")
T.eq(opts.modOptions.alpha.tint, "player",
"the frozen option never overwrote the player's saved value")
T.eq(opts.modOptions.beta.tint, "player", "for any pinned mod")
end
-- ------- an open cart layers the player's mods on top
do
local files = install()
writeCart(files, cartTable("open", "open",
{ pin("beta", "1.0.0", { tint = "cart" }), pin("gamma", "1.0.0", { tint = "cart" }) },
{ "beta", "gamma" }))
SaveData.setCart("open", "hash2")
local loader, data, ok = boot(files)
T.check(ok, "an open cart loads")
T.eq(names(loader.order), "beta,gamma,alpha",
"the cart's mods come first in its order, then the player's own")
T.eq(data.pokemon.BETA.name, "player",
"an open cart's option is a starting value the player's own setting beats")
T.eq(data.pokemon.GAMMA.name, "cart",
"and it stands where the player set nothing")
T.eq(data.pokemon.ALPHA.name, "player", "the player's extra mod loads normally")
T.eq(loader:cartStatus().enforced, false, "an open cart enforces nothing")
end
-- ------- a pin the cart ships switched off
local function withFlags(files, flags)
local opts = SaveSerializer.decode(files["options.lua"]) or {}
opts.mods = opts.mods or {}
for id, on in pairs(flags) do opts.mods[id] = on end
files["options.lua"] = SaveSerializer.encode(opts)
return files
end
local function withCartFlags(files, cartId, flags)
local opts = SaveSerializer.decode(files["options.lua"]) or {}
opts.cartMods = opts.cartMods or {}
opts.cartMods[cartId] = opts.cartMods[cartId] or {}
for id, on in pairs(flags) do opts.cartMods[cartId][id] = on end
files["options.lua"] = SaveSerializer.encode(opts)
return files
end
do
local files = install()
writeCart(files, cartTable("shipped_off", "sealed",
{ pin("alpha"), offPin("gamma", "1.0.0", { tint = "cart" }) },
{ "alpha", "gamma" }))
SaveData.setCart("shipped_off", "hashA")
local loader, data, ok = boot(files)
T.check(ok, "a sealed cart that ships a mod switched off loads")
T.eq(names(loader.order), "alpha", "and runs only the pins it ships switched on")
T.eq(data.pokemon.GAMMA, nil, "the switched-off pin does not run")
T.eq(loader.mods.gamma.enabled, false, "and is reported as inactive")
T.eq(loader.mods.gamma.state, "disabled", "with the disabled row state")
T.check(loader.mods.gamma ~= nil, "while still being installed")
T.eq(loader:cartStatus().pins.gamma.options.tint, "cart",
"carrying the settings the cart shipped it with")
end
do
local files = withCartFlags(install(), "welded", { gamma = true })
writeCart(files, cartTable("welded", "sealed",
{ pin("alpha"), offPin("gamma") }, { "alpha", "gamma" }))
SaveData.setCart("welded", "hashB")
local loader, data, ok = boot(files)
T.check(ok, "a sealed cart loads with the player asking for the off pin")
T.eq(data.pokemon.GAMMA, nil, "but sealed refuses the toggle")
T.eq(loader.mods.gamma.enabled, false, "the pin stays exactly as the cart shipped it")
end
-- ------- sealed+ : the same fixed set, switchable
do
local files = withCartFlags(install(), "plus", { gamma = true })
writeCart(files, cartTable("plus", "sealed+",
{ pin("alpha"), offPin("gamma", "1.0.0", { tint = "cart" }) },
{ "alpha", "gamma" }))
SaveData.setCart("plus", "hashC")
local loader, data, ok = boot(files)
T.check(ok, "a sealed+ cart loads")
local report = loader:cartStatus()
T.eq(report.seal, "sealed+", "the report carries the new seal")
T.eq(report.sealed, true, "which is a sealed cart")
T.eq(report.enforced, true, "and is enforced like one")
T.eq(names(loader.order), "alpha,gamma",
"the player switched the cart's off pin on and it runs")
T.eq(data.pokemon.GAMMA.name, "cart",
"still with the cart's frozen option value, not the player's")
T.eq(data.pokemon.BETA, nil, "a mod the cart does not pin cannot be added")
T.eq(loader.mods.beta.enabled, false, "and stays switched off")
end
do
local files = withCartFlags(install(), "plus_off", { alpha = false })
writeCart(files, cartTable("plus_off", "sealed+",
{ pin("alpha"), pin("gamma") }, { "alpha", "gamma" }))
SaveData.setCart("plus_off", "hashD")
local loader, data, ok = boot(files)
T.check(ok, "a sealed+ cart loads with a pin the player switched off")
T.eq(names(loader.order), "gamma", "which does not run")
T.eq(data.pokemon.ALPHA, nil, "so its content is absent")
T.eq(loader.mods.alpha.enabled, false, "and the row reads as off")
end
do
local files = install()
writeCart(files, cartTable("plus_gap", "sealed+",
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
SaveData.setCart("plus_gap", "hashE")
local loader, _, ok = boot(files)
T.check(not ok, "sealed+ still refuses a pin that is not installed")
T.eq(#loader.order, 0, "and plays no subset of itself")
T.eq(loader:cartStatus().refused, true, "the report refuses it")
end
do
local files = install()
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
writeCart(files, cartTable("plus_seal", "sealed+",
{ pin("alpha"), offPin("gamma") }, { "alpha", "gamma" }))
SaveData.setCart("plus_seal", "hashF")
local slot = SaveData.createCartSlot("plus_seal")
SaveData.setActiveCartSlot("plus_seal", slot)
local loader = boot(files)
T.check(loader:setEnabled("gamma", true), "switch a sealed+ pin on")
T.eq(SaveData.isSealBroken(), false, "which does not break the session seal")
T.eq(SaveData.slotSealBroken("plus_seal", slot), false,
"nor mark the save slot modified")
T.eq(SaveData.adoptCartSeal("plus_seal"), false,
"so the next boot still adopts an intact seal")
local again, data, ok = boot(files)
T.check(ok, "and the cart loads again")
T.eq(names(again.order), "alpha,gamma", "with the mod the player switched on")
T.eq(data.pokemon.BETA, nil, "and still nothing the cart does not pin")
end
do
local files = withFlags(install(), { gamma = false })
writeCart(files, cartTable("plus_split", "sealed+",
{ pin("alpha"), offPin("gamma") }, { "alpha", "gamma" }))
SaveData.setCart("plus_split", "hashI")
local loader = boot(files)
T.eq(loader.mods.gamma.enabled, false, "a cart's off pin starts off")
T.check(loader:setEnabled("gamma", true), "and the player switches it on")
local opts = options(files)
T.eq(opts.cartMods.plus_split.gamma, true,
"the answer is stored under the cart that asked for it")
T.eq(SaveData.modEnabled(opts, "gamma", "red"), false,
"and the base game's own flag for that mod is untouched")
end
-- ------- an open cart hands back only the pins it ships switched off
do
local files = withCartFlags(install(), "open_off", { gamma = true })
writeCart(files, cartTable("open_off", "open",
{ pin("beta"), offPin("gamma") }, { "beta", "gamma" }))
SaveData.setCart("open_off", "hashG")
local loader, data, ok = boot(files)
T.check(ok, "an open cart with a switched-off pin loads")
T.check(data.pokemon.GAMMA ~= nil, "the player switched it on, so it runs")
T.eq(loader.mods.beta.enabled, true,
"while a pin the cart says nothing about is still forced on")
end
do
local files = install()
writeCart(files, cartTable("open_quiet", "open",
{ pin("beta"), offPin("gamma") }, { "beta", "gamma" }))
SaveData.setCart("open_quiet", "hashH")
local loader, data, ok = boot(files)
T.check(ok, "an open cart whose off pin the player never touched loads")
T.eq(data.pokemon.GAMMA, nil, "with that pin off by default")
T.eq(loader.mods.gamma.enabled, false, "and reported off")
end
-- ------- a missing pin: refusal when sealed, warning when open
do
local files = install()
writeCart(files, cartTable("gap", "sealed",
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
SaveData.setCart("gap", "hash3")
local loader, data, ok = boot(files)
T.check(not ok, "a sealed cart with an uninstalled pin fails the load")
T.eq(#loader.order, 0, "and plays no subset of itself")
T.eq(data.pokemon.ALPHA, nil, "not even the pin that is installed")
T.eq(data.pokemon.GAMMA, nil, "and certainly not the player's own mods")
local report = loader:cartStatus()
T.eq(report.refused, true, "the report refuses the cart")
T.eq(#report.missing, 1, "naming one missing pin")
T.eq(report.missing[1].id, "delta", "by id")
T.eq(report.missing[1].version, "2.0.0", "and by the version it pins")
T.eq(report.missing[1].source, "local", "with the source it would come from")
T.check(report.message:find("delta 2.0.0 is not installed", 1, true) ~= nil,
"and a message the launcher can show")
T.eq(loader.errors[1], report.message, "the refusal is on the boot error list")
local opts = options(files)
T.eq(SaveData.modEnabled(opts, "beta", "red"), false,
"a refusal still leaves the player's flags alone")
end
do
local files = install()
writeCart(files, cartTable("gap", "open",
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
SaveData.setCart("gap", "hash4")
local loader, data, ok = boot(files)
T.check(ok, "an open cart with an uninstalled pin still loads")
T.eq(names(loader.order), "alpha,gamma", "with the pins it does have, then the rest")
T.eq(data.pokemon.ALPHA.name, "player", "the surviving pin runs")
local report = loader:cartStatus()
T.eq(report.refused, false, "the missing pin is a warning, not a refusal")
T.eq(report.missing[1].id, "delta", "and is still reported by id")
end
-- ------- a pin installed at another version
do
local files = install()
writeCart(files, cartTable("skew", "sealed",
{ pin("alpha", "2.0.0") }, { "alpha" }))
SaveData.setCart("skew", "hash5")
local loader, _, ok = boot(files)
T.check(not ok, "a sealed cart refuses a pin installed at another version")
T.eq(#loader.order, 0, "and loads nothing")
local report = loader:cartStatus()
T.eq(report.mismatched[1].id, "alpha", "the mismatch names the mod")
T.eq(report.mismatched[1].version, "2.0.0", "the version the cart pins")
T.eq(report.mismatched[1].installed, "1.0.0", "and the version installed")
T.check(report.message:find("alpha is pinned at 2.0.0 but 1.0.0 is installed",
1, true) ~= nil, "with a message the launcher can show")
end
do
local files = install()
writeCart(files, cartTable("skew", "open",
{ pin("alpha", "2.0.0") }, { "alpha" }))
SaveData.setCart("skew", "hash6")
local loader, data, ok = boot(files)
T.check(ok, "an open cart warns about a version skew instead of refusing")
T.eq(data.pokemon.ALPHA.name, "player", "and loads the version that is there")
T.eq(loader:cartStatus().mismatched[1].installed, "1.0.0", "while reporting it")
end
-- ------- an unreadable cart
do
local files = install()
SaveData.setCart("ghost", "hash7")
local loader, data, ok = boot(files)
T.check(not ok, "a cart that is not installed cannot be played as that cart")
T.eq(data.pokemon.GAMMA, nil, "so nothing loads under its name")
T.check(loader:cartStatus().message:find("ghost", 1, true) ~= nil,
"and the report names the cart that went missing")
end
-- ------- breaking the seal downgrades a sealed cart to the open answer
do
local files = install()
writeCart(files, cartTable("sealed", "sealed",
{ pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") },
{ "beta", "delta" }))
SaveData.setCart("sealed", "hash8")
SaveData.breakSeal()
local loader, data, ok = boot(files)
T.check(ok, "a broken seal no longer refuses over a missing pin")
T.eq(names(loader.order), "beta,alpha,gamma",
"the player's own mods load alongside the cart's")
T.eq(data.pokemon.BETA.name, "player",
"and the player's option values come back with them")
local report = loader:cartStatus()
T.eq(report.broken, true, "the report says the seal is broken")
T.eq(report.enforced, false, "so the seal enforces nothing")
end
-- ------- vanilla is untouched when no cart is active
do
local files = install()
SaveData.resetSlotState()
local loader, data, ok = boot(files)
T.check(ok, "a vanilla boot with no cart loads")
T.eq(loader:cartStatus(), nil, "and reports no cart at all")
T.eq(names(loader.order), "alpha,gamma", "with the player's enabled set, in id order")
T.eq(data.pokemon.ALPHA.name, "player", "and the player's option values")
T.eq(data.pokemon.BETA, nil, "the mod the player switched off stays off")
end
-- ------- planCart on its own
do
local report = Loader.planCart(nil, {})
T.eq(report.refused, true, "planCart refuses a cart it was handed nothing for")
T.check(report.message:find("not installed", 1, true) ~= nil,
"with a presentable reason")
local unpinned = Loader.planCart(
cartTable("c", "sealed", { pin("gamma", "0.0.0") }, { "gamma" }),
{ { id = "gamma", version = "whatever" } })
T.eq(#unpinned.mismatched, 0,
"a local pin captured with no semantic version makes no version claim")
T.eq(unpinned.refused, false, "so it cannot refuse over one")
local skew = Loader.planCart(
cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }),
{ gamma = { manifest = { id = "gamma", version = "1.0.0-beta" } } })
T.eq(skew.mismatched[1].installed, "1.0.0-beta",
"a prerelease is a different version to a sealed cart")
local broken = Loader.planCart(
cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }), {}, true)
T.eq(broken.refused, false, "a broken seal downgrades the refusal to a warning")
T.eq(broken.missing[1].id, "gamma", "while still reporting the missing pin")
end
-- ------- the broken-seal stamp
local function plainSave(name)
return {
version = "red",
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
pokedex = { seen = {}, owned = {} },
inventory = {},
playTime = 0,
}
end
do
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
T.eq(SaveData.isSealBroken(), false, "a fresh session has no broken seal")
local save = plainSave("INTACT")
T.eq(SaveData.isSealBroken(save), false, "and neither does a fresh save")
T.check(SaveData.save(save, {}), "write a save under an intact seal")
T.eq(SaveData.load("red").meta.sealBroken, nil, "which carries no stamp")
local loaded = SaveData.load("red")
T.check(SaveData.breakSeal(loaded), "breaking the seal stamps the save")
T.eq(SaveData.isSealBroken(loaded), true, "the save reads back as modified")
T.eq(SaveData.isSealBroken(), true, "and the session is armed")
T.check(SaveData.save(loaded, {}), "save the stamped file")
T.eq(SaveData.load("red").meta.sealBroken, true,
"the stamp survives a save/load round trip")
local again = SaveData.load("red")
T.check(SaveData.save(again, {}), "re-save with a rebuilt meta stamp")
T.eq(SaveData.load("red").meta.sealBroken, true, "buildMeta carries the stamp")
T.eq(SaveData.unbreakSeal, nil, "there is no public unset")
T.eq(SaveData.clearSeal, nil, "under any spelling")
T.eq(SaveData.setSealBroken, nil, "and no setter that takes a value")
T.check(SaveData.breakSeal(again, false), "the setter takes no argument that clears")
T.eq(SaveData.isSealBroken(again), true, "so the stamp is still there")
SaveData.resetSlotState()
T.eq(SaveData.isSealBroken(), false, "a new session starts unarmed")
local reread = SaveData.load("red")
T.eq(reread.meta.sealBroken, true, "but the file it stamped is modified for good")
T.check(SaveData.save(reread, {}), "and re-saving it under a fresh session")
T.eq(SaveData.load("red").meta.sealBroken, true, "does not un-modify it")
local fresh = plainSave("FRESH")
T.check(SaveData.save(fresh, {}), "a save written while the session is unarmed")
T.eq(SaveData.load("red").meta.sealBroken, nil, "carries no stamp of its own")
end
-- ------- the durable per-slot broken mark
do
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
local first = SaveData.createCartSlot("kanto")
T.eq(first, "slot1", "a cart's first save slot")
T.eq(SaveData.slotSealBroken("kanto", first), false, "starts sealed")
T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, false,
"which its launcher row reports without loading a save")
T.check(SaveData.markSlotSealBroken("kanto", first), "break that slot's seal")
T.eq(SaveData.slotSealBroken("kanto", first), true, "the slot reads as broken")
T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, true,
"and the launcher row carries it")
SaveData.resetSlotState()
T.eq(SaveData.slotSealBroken("kanto", first), true,
"the mark survives a restart")
local second = SaveData.createCartSlot("kanto")
T.eq(SaveData.slotSealBroken("kanto", second), false,
"a new slot under the same cart starts sealed again")
T.eq(SaveData.clearSlotSealBroken, nil, "there is no public unset")
T.eq(SaveData.unmarkSlotSealBroken, nil, "under any spelling")
T.eq(SaveData.setSlotSealBroken, nil, "and no setter that takes a value")
T.check(SaveData.markSlotSealBroken("kanto", first, false),
"the setter takes no argument that clears")
T.eq(SaveData.slotSealBroken("kanto", first), true, "so the mark stands")
T.eq(SaveData.markSlotSealBroken("kanto", "slot9"), false,
"a slot that is not registered cannot be marked")
SaveData.setCart("kanto", "hash9")
SaveData.setActiveCartSlot("kanto", second)
T.eq(SaveData.adoptCartSeal("kanto"), false,
"booting an unmarked slot leaves the session sealed")
T.eq(SaveData.isSealBroken(), false, "so the loader still enforces the cart")
SaveData.setActiveCartSlot("kanto", first)
T.eq(SaveData.adoptCartSeal("kanto"), true,
"booting the marked slot breaks the seal for the session")
T.eq(SaveData.isSealBroken(), true, "which is what the loader reads")
SaveData.deleteCartSlot("kanto", first)
T.eq(SaveData.slotSealBroken("kanto", first), false,
"deleting the playthrough takes its mark with it")
end
-- ------- a marked slot loads the cart's pins first, then the player's mods
do
local files = install()
writeCart(files, cartTable("marked", "sealed",
{ pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") },
{ "beta", "delta" }))
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
SaveData.setCart("marked", "hash10")
local slot = SaveData.createCartSlot("marked")
SaveData.setActiveCartSlot("marked", slot)
local intact, _, intactOk = boot(files)
T.check(not intactOk, "an unmarked slot still refuses the missing pin")
T.eq(intact:cartStatus().refused, true, "with the refusal on its report")
SaveData.resetSlotState()
SaveData.setCart("marked", "hash10")
T.check(SaveData.markSlotSealBroken("marked", slot), "mark that slot broken")
T.check(SaveData.adoptCartSeal("marked"), "boot adopts the mark")
local loader, data, ok = boot(files)
T.check(ok, "and the cart loads")
T.eq(names(loader.order), "beta,alpha,gamma",
"the cart's pins load first, then the player's own enabled mods")
T.eq(data.pokemon.BETA.name, "player",
"with the player's own option values back")
T.eq(loader:cartStatus().broken, true, "the report says the seal is broken")
local stamped = plainSave("BROKEN")
T.check(SaveData.save(stamped, {}), "a save written under the adopted mark")
T.eq(SaveData.load("red").meta.sealBroken, true,
"carries the save's own permanent stamp")
end
love.filesystem = realFS
T.finish("cart_seal")
+433
View File
@@ -0,0 +1,433 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
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.harness")
love = love or require("tests.love_stub")
local Base64 = require("src.core.Base64")
local SaveData = require("src.core.SaveData")
local CartManifest = require("src.carts.CartManifest")
local CartStore = require("src.carts.CartStore")
local SHA = ("a1b2c3d4"):rep(8)
local SHA2 = ("beefcafe"):rep(8)
local MD5 = ("0123456789abcdef"):rep(2)
local function memfs()
local files, dirs = {}, {}
local fs
fs = {
files = files,
write = function(path, body) files[path] = body return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
createDirectory = function(path) dirs[path] = true return true end,
getInfo = function(path)
if files[path] ~= nil then return { type = "file" } end
if dirs[path] then return { type = "directory" } end
for name in pairs(files) do
if name:sub(1, #path + 1) == path .. "/" then return { type = "directory" } end
end
return nil
end,
getDirectoryItems = function(path)
local prefix = (path == "" or path == nil) and "" or (path .. "/")
local out, seen = {}, {}
for name in pairs(files) do
if name:sub(1, #prefix) == prefix then
local child = name:sub(#prefix + 1):match("^([^/]+)")
if child and not seen[child] then
seen[child] = true
out[#out + 1] = child
end
end
end
table.sort(out)
return out
end,
}
return fs
end
local function cartTable(over)
local tbl = {
id = "kanto_plus",
title = "Kanto Plus",
version = "1.2.0",
author = "Ren",
shell = "#3fa9f5",
base = "red",
seal = "sealed",
mods = {
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
version = "0.4.1", sha256 = SHA,
options = { flavour = "grape", sweetness = 3 } },
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
md5 = MD5 },
},
}
for key, value in pairs(over or {}) do tbl[key] = value end
return tbl
end
local function bytesOf(over)
local cart, err = CartManifest.parse(cartTable(over))
if not cart then error("fixture does not parse: " .. tostring(err)) end
return CartManifest.encode(cart), cart
end
local fs = memfs()
local bytes, fixture = bytesOf()
local installed, hash = CartStore.install(bytes, fs)
T.check(installed ~= nil, "a good cart installs: " .. tostring(hash))
T.eq(installed.id, "kanto_plus", "install returns the parsed cart")
T.eq(hash, CartManifest.hash(fixture), "install returns the cart hash")
T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil,
"install writes carts/<id>.g1rcart")
local reg = SaveData.loadOptions(fs).carts
T.check(type(reg) == "table" and type(reg.kanto_plus) == "table",
"install registers the cart in options.carts")
T.eq(reg.kanto_plus.title, "Kanto Plus", "the registry carries the title")
T.eq(reg.kanto_plus.base, "red", "the registry carries the base game")
T.eq(reg.kanto_plus.version, "1.2.0", "the registry carries the cart version")
T.eq(reg.kanto_plus.hash, hash, "the registry carries the cart hash")
T.eq(reg.kanto_plus.file, "carts/kanto_plus.g1rcart",
"the registry names the cart file")
local index = CartStore.index(fs)
T.eq(#index, 1, "index lists the registry without reading the files")
T.eq(index[1].base, "red", "an index row carries the base game")
local rows = CartStore.list(fs)
T.eq(#rows, 1, "list returns the installed cart")
T.eq(rows[1].id, "kanto_plus", "the row names the cart")
T.eq(rows[1].base, "red", "the row carries the base")
T.eq(rows[1].cartHash, hash, "the row carries the cart hash")
T.check(type(rows[1].cart) == "table" and rows[1].cart.mods ~= nil,
"the row carries the parsed cart")
T.eq(rows[1].cart.mods[1].sha256, SHA, "the parsed cart keeps its pins")
local got, gotHash = CartStore.get("kanto_plus", fs)
T.check(got ~= nil, "get returns the cart")
T.eq(gotHash, hash, "get returns the cart hash")
T.same(got, fixture, "get returns exactly what was installed")
T.eq(CartStore.get("nothing_here", fs), nil, "get refuses an unknown id")
T.eq(CartStore.get("../etc/passwd", fs), nil, "get refuses a climbing id")
local exported, exportHash = CartStore.export("kanto_plus", fs)
T.check(type(exported) == "string", "export hands back bytes")
T.eq(exportHash, hash, "export reports the cart hash")
T.same(CartManifest.decode(exported), fixture, "the exported bytes decode back")
T.eq(CartStore.export("nothing_here", fs), nil, "export refuses an unknown id")
local blueBytes = bytesOf({ id = "johto_lite", title = "Aaa Johto Lite",
base = "blue" })
T.check(CartStore.install(blueBytes, fs) ~= nil, "a blue cart installs")
T.eq(#CartStore.list(fs), 2, "list returns both carts")
T.eq(CartStore.list(fs)[1].id, "johto_lite", "list sorts by title")
T.eq(#CartStore.listFor("red", fs), 1, "listFor red returns one cart")
T.eq(CartStore.listFor("red", fs)[1].id, "kanto_plus", "listFor red picks the red cart")
T.eq(#CartStore.listFor("blue", fs), 1, "listFor blue returns one cart")
T.eq(#CartStore.listFor("yellow", fs), 0, "listFor yellow returns nothing")
local newer, newerErr = CartStore.install(
bytesOf({ version = "1.3.0", title = "Kanto Plus" }), fs)
T.check(newer ~= nil, "a newer cart replaces the installed one: " .. tostring(newerErr))
T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0",
"the newer version is what is installed")
T.eq(#CartStore.list(fs), 2, "replacing does not add a second row")
T.eq(SaveData.loadOptions(fs).carts.kanto_plus.version, "1.3.0",
"the registry follows the replacement")
local same, sameErr = CartStore.install(bytesOf({ version = "1.3.0" }), fs)
T.check(same ~= nil, "the same version reinstalls: " .. tostring(sameErr))
local older, olderErr = CartStore.install(bytesOf({ version = "1.1.0" }), fs)
T.eq(older, nil, "an older cart is refused")
T.check(type(olderErr) == "string" and olderErr:find("older", 1, true) ~= nil,
"the refusal says why (got " .. tostring(olderErr) .. ")")
T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0",
"the refused install leaves the newer cart in place")
T.eq(CartStore.install("return { }", fs), nil, "install refuses an untagged file")
T.eq(CartStore.install(nil, fs), nil, "install refuses a non-string")
T.eq(CartStore.install("\1\2\3 not lua", fs), nil, "install refuses noise")
fs.files["saves/cart_kanto_plus/slot1.lua"] = "return { player = { name = \"RED\" } }"
local opts = SaveData.loadOptions(fs)
opts.cartSlots = { kanto_plus = { list = { "slot1" }, active = "slot1" } }
SaveData.saveOptions(opts, fs)
T.check(CartStore.uninstall("kanto_plus", fs), "uninstall reports success")
T.eq(fs.files["carts/kanto_plus.g1rcart"], nil, "uninstall removes the cart file")
T.eq(SaveData.loadOptions(fs).carts.kanto_plus, nil,
"uninstall clears the registry entry")
T.eq(#CartStore.list(fs), 1, "the uninstalled cart is gone from the list")
T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil,
"uninstall leaves the cart's save file alone")
local slots = SaveData.loadOptions(fs).cartSlots
T.check(type(slots) == "table" and type(slots.kanto_plus) == "table",
"uninstall leaves the cart's slot registry alone")
T.eq(slots.kanto_plus.active, "slot1", "the active slot survives an uninstall")
local gone, goneErr = CartStore.uninstall("kanto_plus", fs)
T.eq(gone, nil, "uninstalling twice is refused")
T.check(type(goneErr) == "string" and goneErr:find("not installed", 1, true) ~= nil,
"the second uninstall says why (got " .. tostring(goneErr) .. ")")
T.eq(CartStore.uninstall("../etc/passwd", fs), nil, "uninstall refuses a climbing id")
T.check(CartStore.install(bytes, fs) ~= nil, "the cart reinstalls after removal")
T.same(CartStore.get("kanto_plus", fs), fixture, "reinstalling restores the cart")
T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil,
"the old playthrough is still there for the reinstalled cart")
fs.files["carts/kanto_plus.g1rcart"] = "return { format = \"nonsense\" }"
local damaged = CartStore.list(fs)
T.eq(#damaged, 1, "a corrupt cart file is skipped and the rest still list")
T.eq(damaged[1].id, "johto_lite", "the healthy cart survives a corrupt sibling")
T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil,
"listing never deletes the file it could not read")
T.eq(CartStore.get("kanto_plus", fs), nil, "get reports the corrupt cart as unreadable")
fs.files["carts/kanto_plus.g1rcart"] = nil
opts = SaveData.loadOptions(fs)
opts.carts = opts.carts or {}
opts.carts.ghost = { id = "ghost", title = "Ghost", base = "red",
version = "1.0.0", file = "carts/ghost.g1rcart" }
SaveData.saveOptions(opts, fs)
local haunted = CartStore.list(fs)
T.eq(#haunted, 1, "a registry entry with no file is skipped")
T.eq(haunted[1].id, "johto_lite", "the rest of the list still comes back")
T.eq(SaveData.loadOptions(fs).carts.ghost, nil,
"listing prunes the registry entry whose file is gone")
local strayCart = select(2, bytesOf({ id = "wanderer", title = "Zzz Wanderer" }))
fs.files["carts/wanderer.g1rcart"] = CartManifest.encode(strayCart)
local adopted = CartStore.list(fs)
T.eq(#adopted, 2, "a cart file with no registry entry is still listed")
T.eq(adopted[2].id, "wanderer", "the stray cart sorts in by title")
T.eq(adopted[2].cartHash, CartManifest.hash(strayCart),
"the stray cart is hashed from its own file")
T.check(SaveData.loadOptions(fs).carts.wanderer ~= nil,
"listing registers the stray cart it adopted")
fs.files["carts/readme.txt"] = "hello"
fs.files["carts/half written.g1rcart"] = "return { }"
T.eq(#CartStore.list(fs), 2, "junk in carts/ is ignored")
local empty = memfs()
T.eq(#CartStore.list(empty), 0, "a fresh install lists no carts")
T.eq(#CartStore.listFor("red", empty), 0, "listFor is empty on a fresh install")
-- ------- the fields the registry rows have to carry through the store
local dressedFs = memfs()
local dressed = select(2, bytesOf({ id = "dressed", title = "Dressed",
finish = "holo", speeds = { 1, 2 }, seal = "sealed+",
options = { textSpeed = 1 } }))
local dressedCart, dressedHash = CartStore.install(CartManifest.encode(dressed),
dressedFs)
T.check(dressedCart ~= nil, "a cart with a finish, speeds and settings installs")
T.eq(dressedHash, CartManifest.hash(dressed), "hashing the same as it was written")
T.same(CartStore.get("dressed", dressedFs), dressed,
"and coming back off disk unchanged")
local dressedReg = SaveData.loadOptions(dressedFs).carts.dressed
T.eq(dressedReg.finish, "holo", "the registry row carries the finish")
T.same(dressedReg.speeds, { 1, 2 }, "and the speed ladder")
T.eq(dressedReg.seal, "sealed+", "and the seal, spelled in full")
local dressedRow = CartStore.list(dressedFs)[1]
T.eq(dressedRow.finish, "holo", "the list row carries the finish")
T.same(dressedRow.speeds, { 1, 2 }, "and the speed ladder")
T.eq(dressedRow.seal, "sealed+", "and the seal")
T.eq(dressedRow.cart.options.textSpeed, 1,
"and the settings the cart ships, on the parsed cart")
T.eq(SaveData.loadOptions(dressedFs).carts.dressed.hash, dressedHash,
"listing does not rewrite the registry it already agrees with")
local function rowSet()
return {
{ id = "hard_mode", name = "Hard Mode", version = "2.0.0", enabled = true,
github = "ren/hard-mode",
manifest = { id = "hard_mode", version = "2.0.0",
github = "ren/hard-mode", sha256 = SHA } },
{ id = "off_mode", name = "Off Mode", version = "1.0.0", enabled = false,
github = "ren/off-mode",
manifest = { id = "off_mode", version = "1.0.0",
github = "ren/off-mode", sha256 = SHA2 } },
{ id = "rare_soda", name = "Rare Soda", version = "0.4.1", enabled = true,
github = "ren/rare-soda",
manifest = { id = "rare_soda", version = "0.4.1",
github = "ren/rare-soda" } },
{ id = "sprite_pack", name = "Sprite Pack", version = "beta", enabled = true,
manifest = { id = "sprite_pack", version = "beta" } },
}
end
local identity = { id = "my_cart", title = "My Cart", version = "0.1.0",
author = "Ren", base = "red", shell = "#FF8800",
seal = "open", summary = "Built in the launcher" }
local modOptions = {
rare_soda = { flavour = "grape", sweetness = 3, nested = { 1, 2 } },
off_mode = { unused = true },
}
local captured, unresolved = CartStore.capture(identity, rowSet(), modOptions)
T.check(captured ~= nil, "capture builds a cart: " .. tostring(unresolved))
T.eq(captured.id, "my_cart", "the captured cart keeps the identity id")
T.eq(captured.title, "My Cart", "the captured cart keeps the title")
T.eq(captured.shell, "#ff8800", "the captured shell normalises")
T.eq(captured.seal, "open", "the captured seal is the author's choice")
T.eq(captured.base, "red", "the captured base is the identity's")
T.eq(#captured.mods, 4, "every installed mod is pinned, switched on or off")
T.eq(captured.load_order[1], "hard_mode", "load order follows the row order")
T.eq(captured.load_order[2], "off_mode", "including the row that is switched off")
T.eq(captured.load_order[3], "rare_soda", "load order follows the row order")
T.eq(captured.load_order[4], "sprite_pack", "load order follows the row order")
T.eq(captured.mods[1].source, "github", "a mod with repo, version and hash pins to github")
T.eq(captured.mods[1].repo, "ren/hard-mode", "the github pin keeps the repo")
T.eq(captured.mods[1].sha256, SHA, "the github pin keeps the recorded hash")
T.eq(captured.mods[1].enabled, nil, "an enabled mod pins with no enabled field")
T.eq(CartManifest.modEnabled(captured.mods[1]), true, "and reads back as enabled")
T.eq(captured.mods[2].id, "off_mode", "the switched-off mod is pinned in place")
T.eq(captured.mods[2].enabled, false, "and is pinned switched off")
T.eq(CartManifest.modEnabled(captured.mods[2]), false, "which reads back as off")
T.eq(captured.mods[2].source, "github", "a disabled pin still resolves its source")
T.eq(captured.mods[2].sha256, SHA2, "and still carries its archive hash")
T.eq(captured.mods[2].options.unused, true,
"a disabled mod's options are captured like any other")
T.eq(captured.mods[3].source, "local", "a mod with no archive hash pins locally")
T.eq(captured.mods[3].version, "0.4.1", "the local pin keeps the installed version")
T.eq(captured.mods[3].repo, nil, "a local pin carries no repo")
T.eq(captured.mods[3].sha256, nil, "a local pin carries no hash")
T.eq(captured.mods[3].options.flavour, "grape", "the author's option values are frozen in")
T.eq(captured.mods[3].options.sweetness, 3, "every scalar option is frozen in")
T.eq(captured.mods[3].options.nested, nil, "a table option value is dropped")
T.eq(captured.mods[4].source, "local", "a mod with no repo pins locally")
T.eq(captured.mods[4].version, "0.0.0", "an unparsable version pins as 0.0.0")
T.eq(captured.mods[1].options, nil, "a mod with no options freezes none")
T.eq(#unresolved, 2, "capture reports every locally pinned mod")
T.eq(unresolved[1].id, "rare_soda", "the first unresolved mod is named")
T.eq(unresolved[1].name, "Rare Soda", "the unresolved row carries the mod name")
T.check(unresolved[1].reason:find("archive hash", 1, true) ~= nil,
"a missing hash is the reason (got " .. tostring(unresolved[1].reason) .. ")")
T.eq(unresolved[2].id, "sprite_pack", "the second unresolved mod is named")
T.check(unresolved[2].reason:find("GitHub repo", 1, true) ~= nil,
"a missing repo is a reason (got " .. tostring(unresolved[2].reason) .. ")")
T.check(unresolved[2].reason:find("semantic version", 1, true) ~= nil,
"an unpinnable version is a reason (got " .. tostring(unresolved[2].reason) .. ")")
local publishable, why = CartManifest.publishable(captured)
T.eq(publishable, false, "a captured cart with local pins cannot be published")
T.check(why:find("rare_soda", 1, true) ~= nil, "the reason names rare_soda")
T.check(why:find("sprite_pack", 1, true) ~= nil, "the reason names sprite_pack")
T.check(why:find("hard_mode", 1, true) == nil, "the reason leaves the pinned mod out")
local storeFs = memfs()
local roundTrip, roundHash = CartStore.install(CartManifest.encode(captured), storeFs)
T.check(roundTrip ~= nil, "a captured cart installs: " .. tostring(roundHash))
T.same(roundTrip, captured, "a captured cart survives the file round trip")
T.eq(roundHash, CartManifest.hash(captured), "a captured cart hashes the same on disk")
local pinned = rowSet()
pinned[3].manifest.sha256 = SHA2
pinned[4] = nil
local full, fullUnresolved = CartStore.capture(identity, pinned, modOptions)
T.check(full ~= nil, "a fully pinned capture builds a cart")
T.eq(#fullUnresolved, 0, "a fully pinned capture reports nothing unresolved")
T.eq(full.mods[3].source, "github", "a recorded hash promotes the pin to github")
T.eq(full.mods[3].sha256, SHA2, "the promoted pin uses the recorded hash")
T.eq(CartManifest.publishable(full), true, "a fully pinned cart is publishable")
local offOnly = CartStore.capture(identity, { rowSet()[2] }, modOptions)
T.check(offOnly ~= nil, "a capture of nothing but switched-off mods still builds")
T.eq(#offOnly.mods, 1, "pinning the one mod it was given")
T.eq(offOnly.mods[1].enabled, false, "switched off")
T.eq(offOnly.mods[1].options.unused, true, "with its settings kept")
local noMods, noModsErr = CartStore.capture(identity, {}, modOptions)
T.eq(noMods, nil, "a capture with no mods at all is refused")
T.check(type(noModsErr) == "string" and noModsErr:find("cart must pin", 1, true) ~= nil,
"the empty capture says why (got " .. tostring(noModsErr) .. ")")
T.eq(CartStore.capture({ id = "bad id" }, rowSet(), modOptions), nil,
"a capture with a bad identity is refused")
T.eq(CartStore.capture(nil, rowSet(), modOptions), nil,
"a capture with no identity is refused")
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRa cart label"
local ART_DATA = Base64.encode(PNG)
local function artedCart()
local plain = select(2, bytesOf({ id = "art_cart", title = "Art Cart",
label = "label.png" }))
plain.labelArt = { name = "label.png", encoding = "base64", bytes = #PNG,
data = ART_DATA }
return plain
end
local artFs = memfs()
local artFixture = artedCart()
local packed = CartManifest.encode(artFixture)
local artInstalled, artHash = CartStore.install(packed, artFs)
T.check(artInstalled ~= nil, "a cart with label art installs: " .. tostring(artHash))
T.eq(artInstalled.labelArt.data, ART_DATA, "install returns the cart with its art")
T.check(artFs.files["carts/art_cart.g1rcart"]:find(ART_DATA, 1, true) ~= nil,
"install writes the art payload to the cart file")
T.same(CartStore.get("art_cart", artFs), artFixture,
"the installed cart reads back with its art")
local artless = select(2, bytesOf({ id = "art_cart", title = "Art Cart",
label = "label.png" }))
T.eq(artHash, CartManifest.hash(artless),
"the art is not part of the hash a save pins itself to")
local artExported, artExportHash = CartStore.export("art_cart", artFs)
T.eq(artExportHash, artHash, "export reports the same hash for an arted cart")
T.eq(artExported, packed, "export hands back the bytes that were packed")
local reread = CartManifest.decode(artExported)
T.same(reread, artFixture, "the exported bytes decode to the same cart and art")
T.eq(reread.labelArt.data, ART_DATA, "the exported payload is byte identical")
local sharedFs = memfs()
T.check(CartStore.install(artExported, sharedFs) ~= nil,
"an exported cart installs somewhere else")
T.same(CartStore.get("art_cart", sharedFs), artFixture,
"pack, install, export and install again leaves the cart unchanged")
local shownBytes, shownName = CartStore.labelArt("art_cart", sharedFs)
T.eq(shownBytes, PNG, "labelArt hands back the decoded PNG")
T.eq(shownName, "label.png", "labelArt hands back the art name")
T.eq(CartStore.labelArt("nothing_here", sharedFs), nil,
"labelArt refuses an unknown id")
T.eq(CartStore.labelArt("../etc/passwd", sharedFs), nil,
"labelArt refuses a climbing id")
local plainFs = memfs()
T.check(CartStore.install(CartManifest.encode(artless), plainFs) ~= nil,
"a cart with no art still installs")
T.eq(CartStore.labelArt("art_cart", plainFs), nil,
"a cart with no art has no label art")
T.same(CartStore.get("art_cart", plainFs), artless,
"a cart with no art round trips exactly as before")
local tamperedBytes = (packed:gsub("bytes = " .. #PNG, "bytes = " .. (#PNG + 1), 1))
T.neq(tamperedBytes, packed, "the tampered bundle really changed")
local tamperedFs = memfs()
local tampered, tamperedErr = CartStore.install(tamperedBytes, tamperedFs)
T.check(tampered ~= nil, "bad art never fails the install: " .. tostring(tamperedErr))
T.eq(tampered.labelArt, nil, "the bad art is dropped")
T.eq(CartStore.labelArt("art_cart", tamperedFs), nil,
"the installed cart shows no art")
T.eq(tamperedFs.files["carts/art_cart.g1rcart"], CartManifest.encode(artless),
"the bad art is not written back to disk")
T.eq(#CartStore.list(tamperedFs), 1, "the cart still lists without its art")
T.finish("cart_store")
@@ -0,0 +1,45 @@
-- Crystal's boot copyright card is white text on black: SplashScreen sets
-- SCGB_GAMEFREAK_LOGO before calling Copyright, so the card runs on
-- PREDEFPAL_GAMEFREAK_LOGO_BG rather than the default BGP Gold's DMG boot
-- leaves up (../pokecrystal/engine/movie/splash.asm:21-30). The card used to
-- hardcode a white fill and Crystal shipped no extracted splash at all, so the
-- first screen of the game was blank white.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local CopyrightSplash = require("src.ui.gen2.CopyrightSplash")
local function colorsEq(got, want, message)
T.eq(#got, 3, message .. " (three channels)")
for index = 1, 3 do
T.eq(got[index], want[index], message .. " channel " .. index)
end
end
local gold = CopyrightSplash.new({}, { title = {} })
colorsEq(gold.backdrop, { 1, 1, 1 }, "Gold keeps the white card")
colorsEq(gold.ink, { 0, 0, 0 }, "Gold keeps black text")
local crystal = CopyrightSplash.new({}, {
title = {
copyrightBackdrop = { 0, 0, 0 },
copyrightInk = { 1, 1, 1 },
},
})
colorsEq(crystal.backdrop, { 0, 0, 0 }, "Crystal's card is black")
colorsEq(crystal.ink, { 1, 1, 1 }, "Crystal's text is white")
-- The extractor has to emit the image too, or the card is an empty backdrop.
local CrystalMovie = require("src.import.CrystalMovie")
T.eq(type(CrystalMovie.extractTitle), "function",
"CrystalMovie still owns the Crystal title stage")
local CacheContract = require("src.import.CacheContract")
local required = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.crystal
local found = false
for _, path in ipairs(required) do
if path == "assets/generated/title/copyright_splash.png" then found = true end
end
T.eq(found, true, "a Crystal cache without the splash is rebuilt")
T.finish("copyright splash backdrop")
+260
View File
@@ -0,0 +1,260 @@
-- Crystal registration: VERSIONS row, engine lineage, ORDER slot, sha1
-- routing, the importer's required-file override and the script dialect.
-- luajit tests/engine/crystal_version_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("crystal version registration")
local check = S.check
local eq = S.eq
local GameVersion = require("src.core.GameVersion")
local Opcodes = require("src.script.gen2.Opcodes")
-- ------- 1. the VERSIONS row
local row = GameVersion.VERSIONS.crystal
check(row ~= nil, "GameVersion.VERSIONS carries a crystal row")
eq(row.id, "crystal", "row id")
eq(row.label, "Crystal", "row label")
eq(row.displayName, "Pokemon Crystal", "row display name")
eq(row.sha1, "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", "retail Crystal sha1")
eq(row.manifest, "tools/rom_manifest_crystal.json", "row manifest path")
eq(row.cachePrefix, "crystal/", "cache prefix")
eq(row.saveSuffix, "_crystal", "save suffix")
eq(GameVersion.cachePrefix("crystal"), "crystal/", "cachePrefix() agrees")
eq(GameVersion.saveSuffix("crystal"), "_crystal", "saveSuffix() agrees")
local prefixes, suffixes = {}, {}
for _, id in ipairs(GameVersion.ORDER) do
local info = GameVersion.info(id)
eq(prefixes[info.cachePrefix], nil, id .. " cache prefix is unique")
eq(suffixes[info.saveSuffix], nil, id .. " save suffix is unique")
prefixes[info.cachePrefix] = id
suffixes[info.saveSuffix] = id
end
-- ------- 2. generation and engine lineage
eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2")
eq(GameVersion.engine("crystal"), "crystal", "Crystal's engine lineage")
eq(GameVersion.engine("gold"), "gs", "Gold is the gs lineage")
eq(GameVersion.engine("silver"), "gs", "and so is Silver")
eq(GameVersion.engine("red"), "gen1", "Red is gen1")
eq(GameVersion.engine("blue"), "gen1", "Blue is gen1")
eq(GameVersion.engine("yellow"), "gen1", "Yellow is gen1")
local LINEAGES = { gen1 = true, gs = true, crystal = true }
for _, id in ipairs(GameVersion.ORDER) do
check(LINEAGES[GameVersion.engine(id)] == true,
id .. " reports a known engine lineage")
end
eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
eq(GameVersion.generation("red"), 1, "Red is Gen 1")
-- ------- 3. launcher ORDER
local index
for i, id in ipairs(GameVersion.ORDER) do
if id == "crystal" then index = i end
end
eq(index, 6, "crystal is ORDER slot 6")
eq(#GameVersion.ORDER, 6, "ORDER is six games")
eq(GameVersion.ORDER[4], "gold", "gold keeps slot 4")
eq(GameVersion.ORDER[5], "silver", "silver keeps slot 5")
-- ------- 4. sha1 routing
eq(GameVersion.forSha1("f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"), "crystal",
"the retail Crystal 1.0 sha1 resolves to crystal")
eq(GameVersion.forSha1("f2f52230b536214ef7c9924f483392993e226cfb"), "crystal",
"the retail Crystal 1.1 sha1 also resolves to crystal")
eq(GameVersion.forSha1("d8b8a3600a465308c9953dfa04f0081c05bdcb94"), "gold",
"Gold's sha1 still resolves to gold")
eq(GameVersion.forSha1("deadbeef"), nil, "an unknown ROM resolves to nothing")
-- ------- 4b. revisions / acceptsSha1 / revisionLabel
local crystalRevisions = GameVersion.revisions("crystal")
eq(#crystalRevisions, 2, "crystal lists both accepted revisions")
eq(crystalRevisions[1].sha1, "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
"revision 1 is the 1.0 sha1")
eq(crystalRevisions[1].label, "1.0", "revision 1 is labeled 1.0")
eq(crystalRevisions[2].sha1, "f2f52230b536214ef7c9924f483392993e226cfb",
"revision 2 is the 1.1 sha1")
eq(crystalRevisions[2].label, "1.1", "revision 2 is labeled 1.1")
check(GameVersion.acceptsSha1("crystal", "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"),
"crystal accepts the 1.0 sha1")
check(GameVersion.acceptsSha1("crystal", "f2f52230b536214ef7c9924f483392993e226cfb"),
"crystal accepts the 1.1 sha1")
check(not GameVersion.acceptsSha1("crystal", "deadbeef"),
"crystal rejects an unknown sha1")
eq(GameVersion.revisionLabel("crystal", "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"),
"1.0", "revisionLabel resolves the 1.0 hash")
eq(GameVersion.revisionLabel("crystal", "f2f52230b536214ef7c9924f483392993e226cfb"),
"1.1", "revisionLabel resolves the 1.1 hash")
eq(GameVersion.revisionLabel("crystal", "deadbeef"), nil,
"revisionLabel is nil for an unrecognized hash")
local goldRevisions = GameVersion.revisions("gold")
eq(#goldRevisions, 1, "gold synthesizes a single revision entry")
eq(goldRevisions[1].sha1, GameVersion.info("gold").sha1,
"the synthesized entry carries gold's canonical sha1")
check(GameVersion.acceptsSha1("gold", GameVersion.info("gold").sha1),
"gold accepts its own canonical sha1")
check(not GameVersion.acceptsSha1("gold", "deadbeef"),
"gold rejects an unknown sha1")
eq(GameVersion.revisionLabel("gold", GameVersion.info("gold").sha1), nil,
"gold's synthesized entry carries no label")
local redRevisions = GameVersion.revisions("red")
eq(#redRevisions, 1, "red synthesizes a single revision entry")
check(GameVersion.acceptsSha1("red", GameVersion.info("red").sha1),
"red accepts its own canonical sha1")
eq(GameVersion.forSha1(GameVersion.info("red").sha1), "red",
"and forSha1 still resolves red through the synthesized entry")
-- ------- 5. set / get round trip
local savedCurrent = GameVersion.get()
eq(GameVersion.set("crystal"), "crystal", "set('crystal') is accepted")
eq(GameVersion.get(), "crystal", "and becomes current")
eq(GameVersion.engine(), "crystal", "engine() with no argument reads current")
check(not GameVersion.isGold(), "isGold() stays false on Crystal")
GameVersion.set(savedCurrent)
-- ------- 6. the importer's required-file list
local CacheContract = require("src.import.CacheContract")
local function requiredSet(version)
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
local required, isOverride = CacheContract.requiredFilesFor(version)
local seen, count = {}, 0
local function add(path)
if not seen[prefix .. path] then count = count + 1 end
seen[prefix .. path] = true
end
for _, path in ipairs(required) do add(path) end
if not isOverride then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
add(path)
end
end
return seen, count
end
local crystalSeen, crystalCount = requiredSet("crystal")
local redSeen = requiredSet("red")
check(select(2, CacheContract.requiredFilesFor("crystal")) == true,
"crystal has its own required-file override, not the Gen 1 list")
check(crystalCount > 20,
("the crystal list is substantial (%d entries)"):format(crystalCount))
for _, path in ipairs({
"crystal/data/generated/encounters.lua",
"crystal/data/generated/landmarks.lua",
"crystal/data/generated/title.lua",
"crystal/data/generated/intro.lua",
"crystal/assets/generated/title/crystal_logo.png",
"crystal/assets/generated/title/crystal_wordmark.png",
"crystal/assets/generated/title/crystal_suicune.png",
"crystal/assets/generated/splash/ditto.png",
"crystal/assets/generated/intro/chris.png",
"crystal/assets/generated/intro/kris.png",
"crystal/assets/generated/battle/front/wooper.png",
}) do
check(crystalSeen[path] == true, "crystal requires " .. path)
end
for _, path in ipairs({
"crystal/assets/generated/battle/anims/move_anim_0.png",
"crystal/assets/generated/battle/anims/move_anim_1.png",
"crystal/data/generated/battle_anims.lua",
"crystal/assets/generated/trade/game_boy.png",
}) do
eq(crystalSeen[path], nil, "crystal does not wait on " .. path)
end
check(redSeen["assets/generated/battle/anims/move_anim_0.png"] == true,
"red still requires the Gen 1 battle anim sheet")
eq(redSeen["assets/generated/title/crystal_logo.png"], nil,
"and none of Crystal's title art")
-- ------- 7. script dialect
local crystalTable = Opcodes.forEdition("crystal")
local goldTable = Opcodes.forEdition("gold")
check(crystalTable ~= goldTable,
"forEdition('crystal') is not the Gold table")
eq(Opcodes.forEdition("silver"), goldTable, "Silver shares Gold's table")
eq(goldTable, Opcodes, "and Gold's table is the module itself")
eq(Opcodes.forEdition(nil), Opcodes, "an absent edition falls back to Gold")
-- pokecrystal/macros/scripts/events.asm:541
eq(crystalTable[0x52] and crystalTable[0x52].name, "farjumptext",
"Crystal $52 is farjumptext")
eq(goldTable[0x52] and goldTable[0x52].name, "jumptext",
"Gold $52 is jumptext")
eq(crystalTable[0x53] and crystalTable[0x53].name, "jumptext",
"and Crystal's jumptext moved to $53")
local function count(tbl)
local n = 0
for key in pairs(tbl) do if type(key) == "number" then n = n + 1 end end
return n
end
eq(count(crystalTable), 170, "Crystal names 170 commands")
eq(count(goldTable), 162, "Gold names 162")
local diverged
for byte = 0x00, 0x51 do
local a = crystalTable[byte] and crystalTable[byte].name
local b = goldTable[byte] and goldTable[byte].name
if a ~= b then diverged = byte; break end
end
eq(diverged, nil, "$00-$51 are the same commands in both dialects")
check(Opcodes.TERMINATORS.farjumptext == true,
"farjumptext is a terminator")
-- ------- 8. the launcher reaches the crystal tab
local RomImporter = require("src.import.RomImporter")
local visited = {}
local fake = setmetatable({ tab = GameVersion.ORDER[1] }, RomImporter)
fake._switchTab = function(self, id)
self.tab = id
visited[#visited + 1] = id
end
for _ = 1, 12 do fake:_cycleTab(1) end
local sawCrystal, sawMods, sawBug = false, false, false
for _, id in ipairs(visited) do
if id == "crystal" then sawCrystal = true end
if id == "mods" then sawMods = true end
if id == "bug" then sawBug = true end
end
check(sawCrystal, "cycling the launcher tabs reaches crystal")
check(sawMods and sawBug, "and still reaches the mods and bug tabs")
local seen, cycle = {}, 0
fake.tab = "crystal"
repeat
seen[fake.tab] = true
fake:_cycleTab(1)
cycle = cycle + 1
until fake.tab == "crystal" or cycle > 40
eq(cycle, #GameVersion.ORDER + 4,
"the ring is the six games plus mods/find/skins/bug")
fake.tab = "crystal"
fake:_cycleTab(-1)
eq(fake.tab, "silver", "and stepping back off crystal lands on silver")
S.finish()
@@ -193,4 +193,31 @@ do
eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu")
end
-- A cart may narrow the ladder (CartManifest's `speeds`), and returning to
-- the launcher must put it back.
do
local GameSpeed = require("src.core.GameSpeed")
eq(#GameSpeed.allowed(), #GameSpeed.LEVELS, "no cart means the full ladder")
check(not GameSpeed.isLocked(), "and nothing is pinned")
GameSpeed.setAllowed({ 1, 2 })
eq(#GameSpeed.allowed(), 2, "a cart narrows the ladder")
eq(GameSpeed.cycle(1, 1), 2, "cycling stays inside the cart's levels")
eq(GameSpeed.cycle(2, 1), 1, "and wraps within them")
eq(GameSpeed.clamp(100), 2, "a value past the cart's top clamps into it")
check(not GameSpeed.isLocked(), "two levels is narrowed, not pinned")
GameSpeed.setAllowed({ 1 })
check(GameSpeed.isLocked(), "one level reads as pinned")
eq(GameSpeed.cycle(1, 1), 1, "and cycling cannot leave it")
GameSpeed.setAllowed({ 1, 7 })
eq(#GameSpeed.allowed(), 1, "a level that is not on the ladder is dropped")
GameSpeed.setAllowed(nil)
eq(#GameSpeed.allowed(), #GameSpeed.LEVELS,
"leaving the cart restores the full ladder")
eq(GameSpeed.cycle(4, 1), 10, "and cycling reaches the levels again")
end
T.finish("game_speed_categories")
+1
View File
@@ -24,6 +24,7 @@ T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
T.eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2")
-- ------- 2. manifest: gen2compat is opt-in and defaults off
@@ -0,0 +1,133 @@
-- Chrome.printThrough (src/ui/gen2/Chrome.lua) used to run every string
-- through the GbcPalette shade-remap shader whenever a palette was given,
-- with no regard for whether the glyph it was about to draw came from a
-- tile page or from a TTF. The shader recovers a shade by reading the
-- RED CHANNEL of an already-rasterized 2bpp tile pixel (SHADER_SOURCE in
-- src/render/GbcPalette.lua); a TTF glyph is LÖVE's own anti-aliased
-- coverage mask, drawn as plain white with the current tint carrying the
-- ink colour, which that same channel read always reports as shade 0 --
-- painting every character the SAME colour as the paper rect printThrough
-- had just drawn behind it, i.e. invisible. Reported against a real Gold
-- build running a TTF translation mod: the naming screen's keyboard,
-- Diploma and Pokegear text all vanish, since all three draw through this
-- one routine (gen1recomp#1642).
--
-- The switch is per GLYPH, not per string: a TTF-mod build still keeps
-- multi-byte charmap sequences (the naming screen's own <PK>/<MN> cells,
-- the 'd/'l/'s ligatures) and anything a mod names in ttf.tiles on their ROM
-- tiles (src/render/Font.lua's Font.split), so one call can mix both kinds
-- of glyph and each must take its own path.
--
-- No real shader runs headless (love_stub does not stub newShader), so this
-- cannot check a rendered pixel. Font.encode/drawCode/advanceOf/width are
-- replaced with fakes that hand printThrough a fixed list of glyph codes,
-- so what is checked is the two things that decide the outcome: which
-- glyphs skip the shader, and what colour is active when each one draws.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local Chrome = require("src.ui.gen2.Chrome")
local GbcPalette = require("src.render.GbcPalette")
local Font = require("src.render.Font")
-- Palette arrays are 1-indexed shade 0..3, same order GbcPalette.useRaw
-- reads (src/render/GbcPalette.lua channel()).
local PALETTE = { { 200, 220, 255 }, { 150, 170, 220 }, { 90, 100, 160 }, { 10, 10, 30 } }
local TILE_CODE = 0x80
local TTF_CODE = Font.TTF_BASE + 65 -- 'A', were it decoded
-- "text" is a plain list of glyph codes for this suite; Font.width/encode
-- pass it straight through, matching how a real caller's string would
-- decode to a list of codes.
Font.width = function(codes) return #codes * 8 end
Font.encode = function(codes) return codes end
Font.advanceOf = function(_code) return 8 end
local drawn
Font.drawCode = function(code, x, y)
drawn[#drawn + 1] = { code = code, x = x, y = y, color = { love.graphics.getColor() } }
end
local useRawCalls
local realUseRaw = GbcPalette.useRaw
GbcPalette.useRaw = function(...)
useRawCalls = useRawCalls + 1
return realUseRaw(...)
end
GbcPalette.available = function() return true end
local function colorsEq(a, b)
return math.abs(a[1] - b[1]) < 1e-9 and math.abs(a[2] - b[2]) < 1e-9
and math.abs(a[3] - b[3]) < 1e-9
end
-- ------------------------------------------------------- all tile glyphs
do
useRawCalls = 0
drawn = {}
Chrome.printThrough({ TILE_CODE, TILE_CODE }, 0, 0, PALETTE)
T.eq(useRawCalls, 1, "one shaded run binds the shader once, not per glyph")
T.eq(#drawn, 2, "both glyphs drew")
for i, d in ipairs(drawn) do
T.check(colorsEq(d.color, { 1, 1, 1, 1 }),
("tile glyph %d is tinted white, letting the shader pick the colour"):format(i))
end
end
-- -------------------------------------------------------- all TTF glyphs
do
useRawCalls = 0
drawn = {}
Chrome.printThrough({ TTF_CODE, TTF_CODE }, 0, 0, PALETTE)
T.eq(useRawCalls, 0, "a TTF glyph never binds the shade-remap shader")
T.eq(#drawn, 2, "both glyphs drew")
local ink = Chrome.throughPalette(PALETTE, false)[4]
for i, d in ipairs(drawn) do
T.check(colorsEq(d.color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
("TTF glyph %d is tinted with the palette's own ink colour"):format(i))
end
end
-- --------------------------------------------- mixed: tile, TTF, then tile
do
useRawCalls = 0
drawn = {}
Chrome.printThrough({ TILE_CODE, TTF_CODE, TILE_CODE }, 0, 0, PALETTE)
T.eq(useRawCalls, 2,
"the shader re-binds once per return to a tile glyph, not once for the whole string")
T.eq(#drawn, 3, "all three glyphs drew")
local ink = Chrome.throughPalette(PALETTE, false)[4]
T.check(colorsEq(drawn[1].color, { 1, 1, 1, 1 }), "1st (tile) glyph: white/shaded")
T.check(colorsEq(drawn[2].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
"2nd (TTF) glyph, mid-string, still gets the ink tint")
T.check(colorsEq(drawn[3].color, { 1, 1, 1, 1 }), "3rd (tile) glyph: shaded again")
end
-- ---------------------------------------------------- inverted TTF ink
do
drawn = {}
Chrome.printThrough({ TTF_CODE }, 0, 0, PALETTE, true)
local ink = Chrome.throughPalette(PALETTE, true)[4]
T.check(colorsEq(drawn[1].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
"an inverted call tints TTF ink with the inverted palette's own shade-3 entry")
end
-- ---------------------------------------------- DMG mode's own TTF ink
do
GbcPalette.setMode("dmg")
drawn = {}
Chrome.printThrough({ TTF_CODE }, 0, 0, PALETTE)
local ink = Chrome.throughPalette(PALETTE, false)[4]
T.check(colorsEq(drawn[1].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
"DMG mode's own resolved palette (four grey hardware shades) still reaches the TTF ink")
GbcPalette.setMode("gbc")
end
T.finish("gen2_chrome_print_through_ttf_test")
@@ -0,0 +1,119 @@
-- Gold's title/main menu (src/ui/gen2/MainMenu.lua) drew every row label
-- (CONTINUE/NEW GAME/OPTION/EXIT GAME), the clock box's AM/PM half, and the
-- CONTINUE save-summary panel's labels (PLAYER <name>/BADGES/POKéDEX/TIME,
-- or NO SAVE FILE) as bare literals, invisible to a translation mod's
-- `strings` registry -- unlike the Gen 1 port's own title menu
-- (src/ui/TitleState.lua/StartMenu.lua), which already routes the same rows
-- through Strings(). Drives MainMenu:drawPanel()/:drawSavePanel() with a
-- mod-loaded Strings catalog and checks the translated text reaches
-- Font.draw, same technique as
-- tests/engine/gen2_naming_screen_translation_test.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local drawn
package.loaded["src.render.Font"] = {
draw = function(text, x, y)
drawn[#drawn + 1] = { text = text, x = x, y = y }
end,
drawCode = function() end,
drawBox = function() end,
width = function() return 0 end,
}
local MainMenu = require("src.ui.gen2.MainMenu")
local Strings = require("src.core.Strings")
local function drawnAt(x, y)
for _, d in ipairs(drawn) do
if d.x == x and d.y == y then return d.text end
end
return nil
end
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua).
-- List item 1 lands at (self.x, self.y) = (2, 2); the clock box's day name
-- at (1, 14) and the hour:minute half at (4, 16); the save panel's PLAYER
-- row at (5, 2).
local FIRST_ITEM_X, FIRST_ITEM_Y = 2 * 8, 2 * 8
local CLOCK_HALF_X, CLOCK_HALF_Y = 4 * 8, 16 * 8
local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8
local SAVE = { player = { name = "GOLD" } }
local CLOCK = { hour = 13, minute = 5, weekday = 1 } -- 1 PM, SUNDAY
-- ---------------------------------------------- vanilla: no mod catalog
do
local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK })
drawn = {}
menu:drawPanel()
T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUE",
"the title menu's first row draws in English with no mod loaded")
T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM",
"and the clock box's AM/PM half")
drawn = {}
menu:drawSavePanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD",
"the CONTINUE save-summary panel too")
local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK })
drawn = {}
noSaveMenu:drawSavePanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "NO SAVE FILE",
"and its no-summary fallback")
end
-- ------------------------------------------------- a translation mod's turn
do
Strings.load({
strings = {
["CONTINUE"] = "CONTINUAR",
["NEW GAME"] = "NUEVA PARTIDA",
["OPTION"] = "OPCIÓN",
["EXIT GAME"] = "SALIR",
["PM"] = "PM_ES",
["PLAYER %s"] = "JUGADOR %s",
["BADGES"] = "MEDALLAS",
["POKéDEX"] = "POKéDEX_ES",
["TIME"] = "TIEMPO",
["NO SAVE FILE"] = "SIN PARTIDA",
},
})
local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK })
drawn = {}
menu:drawPanel()
T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUAR",
"a mod catalog reaches the title menu's first row")
T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM_ES",
"and the clock box's AM/PM half")
drawn = {}
menu:drawSavePanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JUGADOR GOLD",
"the save-summary panel's PLAYER row takes the mod's own word order")
T.eq(drawnAt(5 * 8, 4 * 8), "MEDALLAS", "and BADGES")
T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_ES", "and POKéDEX")
T.eq(drawnAt(5 * 8, 8 * 8), "TIEMPO", "and TIME")
local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK })
drawn = {}
noSaveMenu:drawSavePanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "SIN PARTIDA",
"and the no-summary fallback")
-- Module state is process-global (see tests/gen2_clock_test.lua's own
-- note); this suite gets its own process from tests/tier_runner.lua, but
-- leaving the catalog loaded past this point would still mistranslate
-- every check below it in this file.
Strings.load({})
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end
T.finish("gen2_main_menu_translation_test")
@@ -0,0 +1,100 @@
-- Gold's naming/keyboard screen (src/ui/gen2/NamingScreen.lua) had zero
-- Strings() calls: every prompt (YOUR NAME?/RIVAL'S NAME?/MOTHER'S NAME?/
-- BOX NAME?/NICKNAME?), the on-screen keyboard's own letters, and the
-- lower/UPPER/DEL/END bottom-row labels were bare literals, invisible to a
-- translation mod's `strings` registry (reported against a real Gold build,
-- gen1recomp#1642). The Gen 1 naming screen (src/ui/NamingScreen.lua) already
-- routes its title and every keyboard cell through Strings().
--
-- GbcPalette.available() is false headless (no real shader compiles), so
-- Chrome.printThrough already falls back to the plain, unshaded Chrome.print
-- -- this drives that path directly and checks the translated text reaches
-- Font.draw, the same technique
-- tests/engine/gen2_options_menu_translation_test.lua uses for the OPTION
-- screen.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local drawn
package.loaded["src.render.Font"] = {
draw = function(text, x, y)
drawn[#drawn + 1] = { text = text, x = x, y = y }
end,
drawCode = function() end,
drawBox = function() end,
}
local NamingScreen = require("src.ui.gen2.NamingScreen")
local Strings = require("src.core.Strings")
local function drawnAt(x, y)
for _, d in ipairs(drawn) do
if d.x == x and d.y == y then return d.text end
end
return nil
end
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua);
-- the prompt lands at tile (5, 2), the keyboard's first cell at (2, 8).
local PROMPT_X, PROMPT_Y = 5 * 8, 2 * 8
local FIRST_CELL_X, FIRST_CELL_Y = 2 * 8, 8 * 8
-- ---------------------------------------------- vanilla: no mod catalog
do
local screen = NamingScreen.new({}, { type = "player" })
drawn = {}
screen:drawPanel()
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "YOUR NAME?",
"the player-name prompt draws in English with no mod loaded")
T.eq(drawnAt(FIRST_CELL_X, FIRST_CELL_Y), "A",
"and the keyboard's first cell too")
end
-- ------------------------------------------------- a translation mod's turn
do
Strings.load({
strings = {
["YOUR NAME?"] = "TON NOM?",
["A"] = "À",
["lower"] = "minusc",
["END"] = "FIN",
["%s'S"] = "DE %s",
["NICKNAME?"] = "SURNOM?",
},
})
local screen = NamingScreen.new({}, { type = "player" })
drawn = {}
screen:drawPanel()
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "TON NOM?",
"a mod catalog reaches the prompt")
T.eq(drawnAt(FIRST_CELL_X, FIRST_CELL_Y), "À",
"and a keyboard cell")
-- The bottom row: lower/DEL/END at tile y = keyboardTop + bottom*2.
local bottomY = (screen:keyboardTop() + screen:bottomRow() * 2) * 8
T.eq(drawnAt(2 * 8, bottomY), "minusc", "the case-switch label is translated")
T.eq(drawnAt(15 * 8, bottomY), "FIN", "and END, the way out of the screen")
-- The nickname header: two lines, the mon name folded into the first.
local nickScreen = NamingScreen.new({}, { type = "nickname", monName = "BULBASAUR" })
drawn = {}
nickScreen:drawPanel()
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "DE BULBASAUR",
"the nickname header's first line takes the mod's own word order")
T.eq(drawnAt(PROMPT_X, 4 * 8), "SURNOM?", "and its second line")
-- Module state is process-global (see tests/gen2_clock_test.lua's own
-- note); this suite gets its own process from tests/tier_runner.lua, but
-- leaving the catalog loaded past this point would still mistranslate
-- every check below it in this file.
Strings.load({})
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end
T.finish("gen2_naming_screen_translation_test")
@@ -0,0 +1,121 @@
-- Gold's OPTION screen (src/ui/gen2/OptionsMenu.lua) used to draw every row
-- label -- and the cart-original value strings (FAST/MID/SLOW, ON/OFF,
-- SHIFT/SET, ...) -- as bare literals baked into the module-level ROWS
-- table, invisible to a translation mod's `strings` registry (reported
-- against a real Gold build, gen1recomp#1642). This drives
-- OptionsMenu:drawPanel() with a mod-loaded Strings catalog and checks the
-- translated text reaches Font.draw, for both a cart-original row (label +
-- display value) and a port-added row (label only -- its value already
-- comes pre-translated from the shared module it calls, same as the Gen 1
-- OPTION screen's equivalent rows), plus a vanilla no-mod case proving the
-- fallback is unchanged.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
-- Chrome.print (the only draw call this screen makes) goes straight to
-- Font.draw, so recording that call is enough to see exactly what text
-- reached the screen -- same technique as
-- tests/engine/status_abbreviation_translation_test.lua. Stubbed before
-- OptionsMenu (and the Chrome module it requires) ever loads, so Chrome's
-- own `local Font = require(...)` captures this stub instead of the real
-- module.
local drawn
package.loaded["src.render.Font"] = {
draw = function(text, x, y)
drawn[#drawn + 1] = { text = text, x = x, y = y }
end,
drawCode = function() end,
drawBox = function() end,
}
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local Strings = require("src.core.Strings")
local function drawnAt(x, y)
for _, d in ipairs(drawn) do
if d.x == x and d.y == y then return d.text end
end
return nil
end
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua);
-- drawPanel puts labels at tile x=2 and values at tile x=11.
local LABEL_X = 2 * 8
local VALUE_X = 11 * 8
local function rowIndex(rows, id)
for i, row in ipairs(rows) do
if row.id == id then return i end
end
end
-- ---------------------------------------------- vanilla: no mod catalog
do
local menu = OptionsMenu.new({})
drawn = {}
menu:drawPanel()
T.eq(drawnAt(LABEL_X, 2 * 8), "TEXT SPEED",
"row 1's label draws in English with no mod loaded")
-- Save.DEFAULT_OPTIONS.textSpeed is "MID", the cart's own default.
T.eq(drawnAt(VALUE_X, 3 * 8), "MID ",
"and its cart-original display value too")
end
-- ------------------------------------------------- a translation mod's turn
do
Strings.load({
strings = {
["TEXT SPEED"] = "VITESSE TEXTE",
["MID "] = "MOY ",
["CONTROLS"] = "COMMANDES",
["CANCEL"] = "ANNULER",
},
})
local menu = OptionsMenu.new({})
drawn = {}
menu:drawPanel()
T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE TEXTE",
"a mod catalog reaches a cart-original row's label")
T.eq(drawnAt(VALUE_X, 3 * 8), "MOY ",
"and its cart-original display value")
-- CONTROLS is the first port-added row; scroll to it so it lands in the
-- VISIBLE_ROWS=7 window drawPanel actually draws.
local index = rowIndex(menu.rows, "controls")
T.check(index ~= nil, "CONTROLS is one of the rows")
menu.index = index
menu:ensureVisible()
drawn = {}
menu:drawPanel()
local slot = index - menu.scroll
T.eq(drawnAt(LABEL_X, (2 + (slot - 1) * 2) * 8), "COMMANDES",
"and a port-added row's label is translated too")
-- CANCEL is the last row, built into ROWS like any other -- there is no
-- separate hook to fall through if this one row is missed.
local cancelMenu = OptionsMenu.new({})
local cancelIndex = #cancelMenu.rows
T.check(cancelMenu.rows[cancelIndex].cancel, "the last row is CANCEL")
cancelMenu.index = cancelIndex
cancelMenu:ensureVisible()
drawn = {}
cancelMenu:drawPanel()
local cancelSlot = cancelIndex - cancelMenu.scroll
T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "ANNULER",
"CANCEL, the way out of the menu, is translated too")
-- Module state is process-global (see tests/gen2_clock_test.lua's own
-- note); this suite gets its own process from tests/tier_runner.lua, but
-- leaving the catalog loaded past this point would still mistranslate
-- every check below it in this file.
Strings.load({})
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end
T.finish("gen2_options_menu_translation_test")
@@ -0,0 +1,159 @@
-- The PC's CHANGE BOX save flow (src/ui/gen2/PcMenu.lua:savePrompt()) used to
-- draw its overwrite/saving/done prompts and its YES/NO choice as bare
-- literals, invisible to a translation mod's `strings` registry, even though
-- the overwrite/saving prompts are the exact same two cart messages Gold's
-- SAVE screen (src/ui/gen2/SaveMenu.lua) already routes through Strings().
-- Same technique as tests/engine/gen2_save_menu_translation_test.lua: drives
-- PcMenu:drawPanel() directly at each save phase with a mod-loaded Strings
-- catalog and checks the translated text reaches Font.draw.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local drawn
package.loaded["src.render.Font"] = {
draw = function(text, x, y)
drawn[#drawn + 1] = { text = text, x = x, y = y }
end,
drawCode = function() end,
drawBox = function() end,
width = function() return 0 end,
}
local PcMenu = require("src.ui.gen2.PcMenu")
local Strings = require("src.core.Strings")
local function drawnAt(x, y)
for _, d in ipairs(drawn) do
if d.x == x and d.y == y then return d.text end
end
return nil
end
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua).
-- The save-prompt box sits at the same (0,12) origin SaveMenu.lua's does, so
-- its two lines print at the same (1,14)/(1,16); PcMenu's own YESNO_X/Y
-- (14,7) differ from SaveMenu's (0,7), so YES/NO print at (16,8)/(16,10).
local PROMPT1_X, PROMPT1_Y = 1 * 8, 14 * 8
local PROMPT2_X, PROMPT2_Y = 1 * 8, 16 * 8
local YES_X, YES_Y = 16 * 8, 8 * 8
local NO_X, NO_Y = 16 * 8, 10 * 8
-- One party mon so Boxes.canUsePc doesn't refuse to open the PC at all.
local SAVE = { player = { name = "GOLD" }, party = { {} } }
local function newMenu()
return PcMenu.new({}, {
save = SAVE,
saveExists = false,
writer = function() return true end,
})
end
-- ---------------------------------------------- vanilla: no mod catalog
do
local menu = newMenu()
menu.picking = true
menu.pickIndex = 1
menu.savePhase = "confirm"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "#MON BOX, data", "the confirm prompt draws in English with no mod loaded")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "will be saved. OK?", "and its second line")
T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES")
T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO")
menu.savePhase = "overwrite"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a",
"the overwrite prompt, the same cart message SaveMenu.lua's SAVE screen shares")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "its second line")
menu.savePhase = "saving"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAVING… DON'T TURN", "the saving message")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OFF THE POWER.", "its second line")
menu.savePhase, menu.saved = "done", true
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD saved", "the saved message")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "the game.", "its second line")
menu.savePhase, menu.saved = "done", false
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message")
end
-- ------------------------------------------------- a translation mod's turn
--
-- Same catalog values as gen2_save_menu_translation_test.lua's own
-- translated block: the overwrite/saving prompts, the saved/failed messages,
-- and YES/NO are the exact same keys both screens read, so one translation
-- covers both without a PcMenu-specific fork. Only the CHANGE BOX confirm
-- prompt's key is new here.
do
Strings.load({
strings = {
["YES"] = "OUI",
["NO"] = "NON",
["#MON BOX, data\nwill be saved. OK?"] = "Les donnees de la\nBOITE seront sauv.",
["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce",
["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.",
["%s saved\nthe game."] = "%s a sauvegarde\nla partie.",
["Could not save."] = "Echec de sauvegarde.",
},
})
local menu = newMenu()
menu.picking = true
menu.pickIndex = 1
menu.savePhase = "confirm"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Les donnees de la", "the confirm prompt")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "BOITE seront sauv.", "its second line")
T.eq(drawnAt(YES_X, YES_Y), "OUI", "and YES")
T.eq(drawnAt(NO_X, NO_Y), "NON", "and NO")
menu.savePhase = "overwrite"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Un fichier existe",
"the overwrite prompt, translated with no PcMenu-specific key")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "deja. Est-ce", "its second line")
menu.savePhase = "saving"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAUVEGARDE...", "the saving message")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "N'ETEIGNEZ PAS.", "its second line")
menu.savePhase, menu.saved = "done", true
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD a sauvegarde",
"the saved message folds the player name into the mod's own word order")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "la partie.", "its second line")
menu.savePhase, menu.saved = "done", false
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Echec de sauvegarde.", "the failed-save message")
-- Module state is process-global (see tests/gen2_clock_test.lua's own
-- note); this suite gets its own process from tests/tier_runner.lua, but
-- leaving the catalog loaded past this point would still mistranslate
-- every check below it in this file.
Strings.load({})
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end
T.finish("gen2_pcmenu_changebox_save_translation_test")
@@ -0,0 +1,204 @@
-- Gold's SAVE screen (src/ui/gen2/SaveMenu.lua) drew every prompt ("Would
-- you like to save the game?", the overwrite/saving/saved messages), the
-- YES/NO choice, and the summary panel's labels (PLAYER <name>/BADGES/
-- POKéDEX/TIME) as bare literals, invisible to a translation mod's
-- `strings` registry -- unlike the Gen 1 port's own SAVE screen
-- (src/ui/StartMenu.lua), which already routes the same rows through
-- Strings(). Drives SaveMenu:drawPanel() directly at each phase with a
-- mod-loaded Strings catalog and checks the translated text reaches
-- Font.draw, same technique as
-- tests/engine/gen2_naming_screen_translation_test.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local drawn
package.loaded["src.render.Font"] = {
draw = function(text, x, y)
drawn[#drawn + 1] = { text = text, x = x, y = y }
end,
drawCode = function() end,
drawBox = function() end,
width = function() return 0 end,
}
local SaveMenu = require("src.ui.gen2.SaveMenu")
local Strings = require("src.core.Strings")
local function drawnAt(x, y)
for _, d in ipairs(drawn) do
if d.x == x and d.y == y then return d.text end
end
return nil
end
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua).
-- PLAYER row at (5, 2); the two prompt lines at (1, 14)/(1, 16); YES/NO at
-- (2, 8)/(2, 10) (YESNO_X + 2, YESNO_Y + 1 / + 3).
local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8
local PROMPT1_X, PROMPT1_Y = 1 * 8, 14 * 8
local PROMPT2_X, PROMPT2_Y = 1 * 8, 16 * 8
local YES_X, YES_Y = 2 * 8, 8 * 8
local NO_X, NO_Y = 2 * 8, 10 * 8
local SAVE = { player = { name = "GOLD" } }
-- ---------------------------------------------- vanilla: no mod catalog
do
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD",
"the summary panel draws in English with no mod loaded")
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Would you like to",
"and the confirm prompt's first line")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save the game?", "and its second line")
T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES")
T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO")
menu.phase = "overwrite"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", "the overwrite prompt")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "its second line")
menu.phase = "saving"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAVING… DON'T TURN", "the saving message")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OFF THE POWER.", "its second line")
menu.phase, menu.saved = "done", true
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD saved", "the saved message")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "the game.", "its second line")
menu.phase, menu.saved = "done", false
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message")
end
-- ------------------------------------------------- a translation mod's turn
do
Strings.load({
strings = {
["PLAYER %s"] = "JOUEUR %s",
["BADGES"] = "BADGES_FR",
["POKéDEX"] = "POKéDEX_FR",
["TIME"] = "TEMPS",
["YES"] = "OUI",
["NO"] = "NON",
["Would you like to\nsave the game?"] = "Voulez-vous\nsauvegarder ?",
["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce",
["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.",
["%s saved\nthe game."] = "%s a sauvegarde\nla partie.",
["Could not save."] = "Echec de sauvegarde.",
},
})
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JOUEUR GOLD",
"the summary panel's PLAYER row takes the mod's own word order")
T.eq(drawnAt(5 * 8, 4 * 8), "BADGES_FR", "and BADGES")
T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_FR", "and POKéDEX")
T.eq(drawnAt(5 * 8, 8 * 8), "TEMPS", "and TIME")
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Voulez-vous", "the confirm prompt")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "sauvegarder ?", "its second line")
T.eq(drawnAt(YES_X, YES_Y), "OUI", "and YES")
T.eq(drawnAt(NO_X, NO_Y), "NON", "and NO")
menu.phase = "overwrite"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Un fichier existe", "the overwrite prompt")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "deja. Est-ce", "its second line")
menu.phase = "saving"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAUVEGARDE...", "the saving message")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "N'ETEIGNEZ PAS.", "its second line")
menu.phase, menu.saved = "done", true
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD a sauvegarde",
"the saved message folds the player name into the mod's own word order")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "la partie.", "its second line")
menu.phase, menu.saved = "done", false
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Echec de sauvegarde.", "the failed-save message")
-- Module state is process-global (see tests/gen2_clock_test.lua's own
-- note); this suite gets its own process from tests/tier_runner.lua, but
-- leaving the catalog loaded past this point would still mistranslate
-- every check below it in this file.
Strings.load({})
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end
-- src/ui/gen2/PcMenu.lua:savePrompt() shares SaveMenu's overwrite/saving
-- prompts through SaveMenu.OVERWRITE_PROMPT_SOURCE/SAVING_PROMPT_SOURCE and
-- SaveMenu.twoLines(), rather than duplicating them -- both exported below,
-- both used by PcMenu's own translation test
-- (tests/engine/gen2_pcmenu_changebox_save_translation_test.lua). Checked
-- here that they stay callable the shape twoLines() expects: a table in,
-- one \n-joined string out with the split back on load.
do
T.eq(SaveMenu.OVERWRITE_PROMPT_SOURCE, "There is already a\nsave file. Is it",
"OVERWRITE_PROMPT_SOURCE stays the cart's own \\n-joined text")
T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[1], "There is already a",
"and twoLines() splits its untranslated fallback back to the first line")
T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[2], "save file. Is it",
"and its second line")
T.eq(SaveMenu.SAVING_PROMPT_SOURCE, "SAVING… DON'T TURN\nOFF THE POWER.",
"SAVING_PROMPT_SOURCE stays the cart's own \\n-joined text")
T.eq(SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))[1], "SAVING… DON'T TURN",
"and twoLines() splits its untranslated fallback back to the first line")
T.eq(SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))[2], "OFF THE POWER.",
"and its second line")
end
-- A translation with a THIRD line (a second embedded "\n") has nowhere on
-- screen to go -- drawPanel's box has room for exactly two Chrome.print
-- calls -- so it must not silently draw the literal newline byte as glyph
-- garbage on the second line, and should warn so a translator notices.
do
Strings.load({
strings = {
["Would you like to\nsave the game?"] = "Ligne un\nLigne deux\nLigne trois",
},
})
local warned = {}
require("src.core.Logger").warn = function(fmt, ...)
warned[#warned + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
end
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne un", "only the first line reaches the box")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "Ligne deux\nLigne trois",
"the rest lands in the second slot rather than vanishing")
T.check(#warned == 1, "and a single warning is logged")
drawn = {}
menu:drawPanel()
T.check(#warned == 1, "the warning does not repeat for the same text")
require("src.core.Logger").warn = function() end
Strings.load({})
end
T.finish("gen2_save_menu_translation_test")
+292
View File
@@ -0,0 +1,292 @@
-- Gen 2 script bytecode dialects: Gold/Silver vs Crystal.
--
-- Crystal inserts farjumptext at $52 and pushes every later opcode up by one
-- (pokecrystal/macros/scripts/events.asm:541). Decoding a Crystal script with
-- the Gold table is silent: a Crystal $53 `jumptext` (2 operand bytes) reads as
-- Gold's `waitbutton` (0), the pointer walk desynchronises, and the extractor
-- emits plausible garbage rather than an error. So the expected tables below
-- are transcribed from the two macro files by hand and pinned here.
-- luajit tests/engine/gen2_script_opcodes_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local Opcodes = require("src.script.gen2.Opcodes")
-- pokegold/macros/scripts/events.asm:1-1015, in const order from $00.
-- Sizes are the macro body minus its own `db <name>_command`: db 1, dw 2,
-- dba 3, bigdt 3, map_id 2 (pokegold/macros/scripts/maps.asm:1-6).
-- `givepoke` is the one variable-length row: the macro emits 8 bytes when the
-- trainer argument is non-zero (events.asm:352-365) and the table declares the
-- 4-byte base, which src/import/RomExtractorGen2.lua re-measures per call site.
local GOLD_EXPECTED = {
"scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3",
"memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2",
"ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3",
"special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3",
"checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1",
"checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1",
"writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1",
"givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2",
"takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1",
"checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4",
"giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2",
"clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2",
"wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2",
"warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2",
"getitemname 2", "getcurlandmarkname 1", "gettrainername 3",
"getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0",
"reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3",
"writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2",
"closewindow 0", "jumptextfaceplayer 2", "jumptext 2", "waitbutton 0",
"promptbutton 0", "pokepic 1", "closepokepic 0", "_2dmenu 0",
"verticalmenu 0", "loadpikachudata 0", "randomwildmon 0",
"loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0",
"reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1",
"trainerflagaction 1", "winlosstext 4", "scripttalkafter 0",
"endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1",
"applymovement 3", "applymovementlasttalked 2", "faceplayer 0",
"faceobject 2", "variablesprite 2", "disappear 1", "appear 1",
"follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1",
"loademote 1", "showemote 3", "turnobject 2", "follownotexact 2",
"earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0",
"refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2",
"encountermusic 0", "musicfadeout 3", "playmapmusic 0",
"dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0",
"warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1",
"pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0",
"stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0",
"pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1",
"phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1",
"specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2", "swarm 2",
"halloffame 0", "credits 0", "warpfacing 5",
}
-- pokecrystal/macros/scripts/events.asm:1-1068, same transcription rules.
-- Cross-checked row for row against ScriptCommandTable
-- (pokecrystal/engine/overworld/scripting.asm:64-237), which is the table the
-- hardware actually indexes and therefore the authority over the macro file.
local CRYSTAL_EXPECTED = {
"scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3",
"memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2",
"ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3",
"special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3",
"checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1",
"checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1",
"writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1",
"givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2",
"takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1",
"checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4",
"giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2",
"clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2",
"wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2",
"warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2",
"getitemname 2", "getcurlandmarkname 1", "gettrainername 3",
"getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0",
"reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3",
"writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2",
"closewindow 0", "jumptextfaceplayer 2", "farjumptext 3", "jumptext 2",
"waitbutton 0", "promptbutton 0", "pokepic 1", "closepokepic 0",
"_2dmenu 0", "verticalmenu 0", "loadpikachudata 0", "randomwildmon 0",
"loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0",
"reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1",
"trainerflagaction 1", "winlosstext 4", "scripttalkafter 0",
"endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1",
"applymovement 3", "applymovementlasttalked 2", "faceplayer 0",
"faceobject 2", "variablesprite 2", "disappear 1", "appear 1",
"follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1",
"loademote 1", "showemote 3", "turnobject 2", "follownotexact 2",
"earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0",
"refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2",
"encountermusic 0", "musicfadeout 3", "playmapmusic 0",
"dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0",
"warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1",
"pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0",
"stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0",
"pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1",
"phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1",
"specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2",
"verbosegiveitemvar 2", "swarm 3", "halloffame 0", "credits 0",
"warpfacing 5", "battletowertext 1", "getlandmarkname 2",
"gettrainerclassname 2", "getname 3", "wait 1", "checksave 0",
}
local gold = Opcodes.forEdition("gold")
local crystal = Opcodes.forEdition("crystal")
-- CT-1: gold and silver share one dialect, and Opcodes[byte] keeps answering.
check(Opcodes.forEdition("silver") == gold, "silver resolves to the Gold table")
check(gold == Opcodes, "the Gold table is the module itself (Opcodes[byte])")
check(crystal ~= gold, "crystal resolves to a different table")
check(Opcodes.forEdition(nil) == gold, "an unknown edition falls back to Gold")
eq(Opcodes[0x52] and Opcodes[0x52].name, "jumptext",
"Opcodes[0x52] is unchanged for existing callers")
local function auditTable(label, tbl, expected)
local holes, wrong = {}, {}
for i, want in ipairs(expected) do
local byte = i - 1
local name, size = want:match("^(%S+) (%d+)$")
local row = tbl[byte]
if not row then
holes[#holes + 1] = ("$%02x"):format(byte)
elseif row.name ~= name or row.size ~= tonumber(size) then
wrong[#wrong + 1] = ("$%02x %s/%d wanted %s/%s")
:format(byte, tostring(row.name), row.size or -1, name, size)
end
end
eq(#holes, 0, label .. " has no missing opcode (" .. table.concat(holes, " ")
.. ")")
eq(#wrong, 0, label .. " matches events.asm (" .. table.concat(wrong, "; ")
.. ")")
local extra = {}
for byte = 0x00, 0xff do
if tbl[byte] and byte >= #expected then
extra[#extra + 1] = ("$%02x %s"):format(byte, tbl[byte].name)
end
end
eq(#extra, 0, label .. " declares nothing past the last command ("
.. table.concat(extra, " ") .. ")")
end
auditTable("gold", gold, GOLD_EXPECTED)
auditTable("crystal", crystal, CRYSTAL_EXPECTED)
-- (a) $00-$51 is byte-identical between the dialects.
local drift = {}
for byte = 0x00, 0x51 do
local g, c = gold[byte], crystal[byte]
if not (g and c and g.name == c.name and g.size == c.size) then
drift[#drift + 1] = ("$%02x"):format(byte)
end
end
eq(#drift, 0, "$00-$51 is identical in both dialects ("
.. table.concat(drift, " ") .. ")")
-- and the two dialects agree on NOTHING from $52 up, because the whole tail is
-- shifted by one. farjumptext is the wedge.
eq(crystal[0x52].name, "farjumptext", "$52 is farjumptext on Crystal")
eq(crystal[0x52].size, 3, "farjumptext carries a dba, so 3 operand bytes")
eq(crystal[0x53].name, "jumptext", "Gold's $52 jumptext moved to $53")
eq(crystal[0xa0].size, 3,
"Crystal swarm gained a leading flag byte (events.asm:1003-1008)")
eq(gold[0x9e].size, 2, "Gold swarm is still a bare map_id")
-- (c) NUM_EVENT_COMMANDS, both as the declared constant and as the row count.
local function rowCount(tbl)
local n = 0
for byte = 0x00, 0xff do if tbl[byte] then n = n + 1 end end
return n
end
eq(Opcodes.NUM_EVENT_COMMANDS, 162,
"pokegold events.asm:1015 NUM_EVENT_COMMANDS = $a2")
eq(crystal.NUM_EVENT_COMMANDS, 170,
"pokecrystal events.asm:1068 NUM_EVENT_COMMANDS = $aa")
eq(rowCount(gold), Opcodes.NUM_EVENT_COMMANDS,
"the Gold table is dense up to NUM_EVENT_COMMANDS")
eq(rowCount(crystal), crystal.NUM_EVENT_COMMANDS,
"the Crystal table is dense up to NUM_EVENT_COMMANDS")
-- (d) farjumptext ends the walk the same way jumptext does.
check(Opcodes.TERMINATORS.farjumptext,
"farjumptext is a TERMINATOR (jp ScriptJump, scripting.asm:318-327)")
check(Opcodes.TERMINATORS.jumptext, "jumptext still is")
check(crystal.TERMINATORS == Opcodes.TERMINATORS,
"the Crystal table answers TERMINATORS too")
eq(crystal.key, Opcodes.key, "and key(), so a resolved table is self-sufficient")
-- (e) MOD_COMMAND is reachable only by name: no byte in EITHER dialect decodes
-- to it, so ROM data can never be mistaken for a mod verb.
local collide = {}
for byte = 0x00, 0xff do
if gold[byte] and gold[byte].name == Opcodes.MOD_COMMAND then
collide[#collide + 1] = ("gold $%02x"):format(byte)
end
if crystal[byte] and crystal[byte].name == Opcodes.MOD_COMMAND then
collide[#collide + 1] = ("crystal $%02x"):format(byte)
end
end
eq(#collide, 0, "MOD_COMMAND has no byte in either dialect ("
.. table.concat(collide, " ") .. ")")
check(type(Opcodes.MOD_COMMAND) == "string" and Opcodes.MOD_COMMAND ~= "",
"MOD_COMMAND is a name, not a byte")
-- The Vm side of the dialect: every Crystal-only verb has a branch, and the
-- shifted `swarm` reads its flag rather than its map group.
love = require("tests.love_stub")
local Vm = require("src.script.gen2.Vm")
local Events = require("src.world.gen2.Events")
do
local seen, swarmArgs = {}, nil
local events = Events.new()
local vm = Vm.new({
generation = 2,
["s:crystal"] = {
-- pokecrystal/macros/scripts/events.asm:1003-1008: flag, then map_id.
{ op = "swarm", args = { 1, 24, 3 } },
{ op = "checksave" },
{ op = "wait", args = { 2 } },
{ op = "getlandmarkname", args = { 5, 3 } },
{ op = "gettrainerclassname", args = { 9, 3 } },
{ op = "getname", args = { 1, 152, 3 } },
{ op = "verbosegiveitemvar", args = { 20, 7 } },
{ op = "farjumptext", text = "t:far" },
{ op = "setevent", event = 1 },
},
}, { ["t:far"] = "Far text." }, events, {
setSwarm = function(group, mapNum, kind)
swarmArgs = { group, mapNum, kind }
end,
checkSave = function() return true end,
getLandmarkName = function(id) seen.landmark = id return "RUINS" end,
getTrainerClassName = function(id) seen.class = id return "SAGE" end,
getMonName = function(id) seen.mon = id return "CHIKORITA" end,
readVar = function(id) seen.var = id return 4 end,
giveItem = function(item, qty) seen.give = { item, qty } return true end,
getItemName = function() return "REPEL" end,
showText = function(body, onDone) seen.text = body onDone() end,
})
check(vm:start("s:crystal"), "a Crystal-shaped script starts")
for _ = 1, 40 do vm:update() end
check(not vm:running(), "and runs to completion")
eq(swarmArgs and swarmArgs[1], 24, "swarm reads the map group from args[2]")
eq(swarmArgs and swarmArgs[2], 3, "and the map number from args[3]")
eq(swarmArgs and swarmArgs[3], 1, "and passes SWARM_YANMA through as the kind")
eq(seen.landmark, 5, "getlandmarkname passes its landmark id to the hook")
eq(seen.class, 9, "gettrainerclassname passes the trainer group")
eq(seen.mon, 152, "getname with MON_NAME routes to the mon-name hook")
eq(seen.var, 7, "verbosegiveitemvar reads the quantity out of a var")
eq(seen.give and seen.give[1], 20, "and gives the item the first byte names")
eq(seen.give and seen.give[2], 4, "at the quantity the var held")
eq(seen.text, "Far text.", "farjumptext prints its text")
eq(next(vm.unknownOps or {}), nil,
"no Crystal verb in the script fell through to the unknown ledger")
-- Script_farjumptext ends on `jp ScriptJump`, so the setevent after it never
-- runs -- the same reading Opcodes.TERMINATORS encodes for the extractor.
check(not events:get(1),
"farjumptext ended the script before the setevent below it")
events:set(1, true)
check(events:get(1), "and the event really is observable when it is set")
end
do
-- Script_wait is SIX frames per unit (scripting.asm:2336-2347), not
-- Script_pause's two.
local vm = Vm.new({ generation = 2,
["s:wait"] = { { op = "wait", args = { 3 } }, { op = "end" } },
}, {}, nil, {})
check(vm:start("s:wait"), "a lone `wait` starts")
local frames = 0
while vm:running() and frames < 100 do
vm:update()
frames = frames + 1
end
eq(frames, 18, "`wait 3` holds the script for 3 * 6 frames")
end
T.finish()
+16 -1
View File
@@ -21,6 +21,21 @@ for p = 1, 3 do
end
end
-- gfx/stats/stats.pal, the colour LoadStatsScreenPals drops into colour 0 of
-- BG palettes 0 and 2 (engine/gfx/color.asm:386-390)
local ROM_TINTS = { { 31, 19, 31 }, { 21, 31, 14 }, { 17, 31, 31 } }
for p = 1, 3 do
for ch = 1, 3 do
T.eq(SummaryMenu.PAGE_TINTS[p][ch], up(ROM_TINTS[p][ch]),
("stats.pal tint %d channel %d"):format(p, ch))
end
local page = setmetatable({ page = p }, { __index = SummaryMenu })
local lower = page:lowerColors()
T.eq(lower[1][1], SummaryMenu.PAGE_TINTS[p][1],
("lower half palette %d takes the page tint as colour 0"):format(p))
T.eq(lower[4][1], 0, ("lower half palette %d keeps black ink"):format(p))
end
-- the quads: 17 tiles from $31, one 8x8 cell each off the 136x8 sheet
love.graphics = love.graphics or {}
local realNewQuad = love.graphics.newQuad
@@ -48,4 +63,4 @@ local old = setmetatable({ menuGfx = {} }, { __index = SummaryMenu })
T.check(old:statsTiles() == nil, "no menu_gfx.stats falls back, not crashes")
love.graphics.newQuad = realNewQuad
T.finish("gen2 stats tiles and page palettes (#1558)")
T.finish("gen2 stats tiles and page palettes (#1558, #1693)")
+4 -1
View File
@@ -39,7 +39,7 @@ end
local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end }
for _, version in ipairs({ "gold", "silver" }) do
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local model = LauncherSettings.open(hooks, version)
check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD")
check(has(model, "VIBRATION"), version .. " and VIBRATION")
@@ -82,6 +82,7 @@ for _, version in ipairs({ "gold", "silver" }) do
version .. " leaving the flat Gen 1 key alone")
eq(model.opts.silver, nil,
version .. " and inventing no second Gen 2 block beside it")
eq(model.opts.crystal, nil, version .. " nor a third")
local buzz = findRow(model, "VIBRATION")
local buzzBefore = buzz.value()
@@ -114,6 +115,8 @@ eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false,
"nor on Silver")
eq(has(LauncherSettings.open(nil, "crystal"), "TOUCH CONTROLS"), false,
"nor on Crystal")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH
+3 -2
View File
@@ -75,7 +75,8 @@ end
do
local ri = launcher()
check(ri:_findStats(entry("nulled", nil, "someone/nulled")) == nil,
"a null count is not an answer; the repo is still consulted")
"a null count is not an answer")
ri:_requestFindStats(entry("nulled", nil, "someone/nulled"))
eq(#fetched, 1, "which is the fetch the panel already made for dates")
local bare = launcher()
@@ -83,7 +84,7 @@ do
check(stats ~= nil and stats.total == nil,
"a listing with neither counts nor a repo is resolved-but-unknown")
check(stats.recent == nil, "and has nothing to trend on")
eq(#fetched, 1, "and queues nothing of its own")
eq(#fetched, 1, "and the explicit scheduler queues nothing without a repo")
end
-- A real zero is not unknown: the index has seen the releases and counted
+88
View File
@@ -0,0 +1,88 @@
-- Launcher navigation performance seams. MOD INDEX must not pay the full
-- MODS validation pass, background index prefetch must not create a blocking
-- overlay, and visible-row enrichment must be scheduled from update state
-- rather than from immediate-mode draw calls.
-- luajit tests/engine/launcher_navigation_perf.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.harness")
local check, eq = T.check, T.eq
local LauncherMods = require("src.mods.LauncherMods")
local ModIndex = require("src.mods.ModIndex")
local RomImporter = require("src.import.RomImporter")
check(type(LauncherMods.installedVersions) == "function",
"LauncherMods exposes a lightweight installed-version scan")
do
local old = LauncherMods.installedVersions
local called = 0
LauncherMods.installedVersions = function()
called = called + 1
return { alpha = "1.2.3" }
end
local imp = setmetatable({}, RomImporter)
local installed = imp:_findInstalledMap()
eq(called, 1, "MOD INDEX asks for the lightweight scan when MODS is cold")
eq(installed.alpha, "1.2.3",
"the lightweight scan supplies installed versions")
LauncherMods.installedVersions = old
end
do
local imp = setmetatable({ tab = "find", findLoaded = false }, RomImporter)
imp._refreshFindSources = function(self)
self.findSources = { { feed = "https://example.invalid/index.json" } }
end
local oldBegin = ModIndex.beginFetch
ModIndex.beginFetch = function() return { test = true } end
imp:_refreshFind(false)
check(imp._findFetch ~= nil, "background index refresh starts asynchronously")
eq(imp._busy, nil,
"background index refresh does not block navigation with a loader")
imp:_clearBusy()
imp._findFetch = nil
ModIndex.beginFetch = oldBegin
end
do
local requests = 0
local imp = setmetatable({ tab = "find", findLoaded = true,
_findVisibleEntries = {
{ id = "one", thumbnail = "one.png", github = "a/one" },
{ id = "two", thumbnail = "two.png", github = "a/two" },
{ id = "three", thumbnail = "three.png", github = "a/three" },
} }, RomImporter)
imp._findThumb = function() return nil end
imp._findThumbPending = function() return false end
imp._findStatsCached = function() return nil end
imp._startFindThumb = function() requests = requests + 1 end
imp._requestFindStats = function() requests = requests + 1 end
imp:_queueFindEnrichment()
eq(requests, 4,
"update schedules a bounded thumbnail and stats batch for visible rows")
end
do
local requests = 0
local imp = setmetatable({}, RomImporter)
imp._findStatsCached = function() return nil end
imp._requestFindStats = function() requests = requests + 1 end
imp:_findStats({ id = "draw-only", github = "a/draw-only" })
eq(requests, 0, "reading row stats during draw never starts a request")
end
do
local f = assert(io.open("src/import/LauncherView.lua", "rb"))
local src = f:read("*a")
f:close()
local start = assert(src:find("local function buildFindPanel", 1, true))
local finish = assert(src:find("\nlocal function ", start + 1, true))
local panel = src:sub(start, finish - 1)
check(not panel:find("imp:_ensureMods()", 1, true),
"MOD INDEX panel does not force the full MODS list")
end
T.finish("launcher_navigation_perf")
+94 -27
View File
@@ -1,5 +1,5 @@
-- In-process launcher session teardown: Game:reset, Renderer canvas release,
-- Runtime/Assets/LegacyCompat cleanup, and editor package.loaded discovery flush.
-- SessionLifecycle mount/game tiers, and editor package.loaded discovery flush.
-- luajit tests/engine/launcher_session_teardown_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -12,8 +12,12 @@ local Runtime = require("src.mods.Runtime")
local Assets = require("src.render.Assets")
local LegacyCompat = require("src.mods.LegacyCompat")
local Game = require("src.core.Game")
local Game2 = require("src.core.Game2")
local Renderer = require("src.render.Renderer")
local StateStack = require("src.core.StateStack")
local SessionLifecycle = require("src.core.SessionLifecycle")
local MapLoader = require("src.world.MapLoader")
local World = require("src.world.gen2.World")
-- ---- Game:reset drops instance state, keeps methods ----------------------
do
@@ -38,6 +42,35 @@ do
check(StateStack:top() == nil, "Game:reset cleared the shared StateStack")
end
-- ---- Game2:reset releases world GPU and present canvases ------------------
do
local game2 = Game2.new()
local canvas = love.graphics.newCanvas(4, 4)
game2.world = World.new({})
game2.world.mapImages = { ["MAP|d|1"] = canvas }
game2._canvases = { love.graphics.newCanvas(8, 8) }
game2:reset()
check(canvas.released == true, "Game2:reset releases World mapImages")
check(game2.world == nil, "Game2:reset clears world reference")
check(game2._canvases == nil, "Game2:reset clears _canvases")
end
-- ---- World:release frees owned GPU caches --------------------------------
do
local world = World.new({})
local bake = love.graphics.newCanvas(16, 16)
local strip = love.graphics.newCanvas(8, 64)
local tilt = love.graphics.newCanvas(160, 144)
world.mapImages = { ["R1|DAY|1"] = bake }
world.scrollStrips = { ["TS|1|0,0"] = strip }
world.tiltCanvas = tilt
world:release()
check(bake.released == true, "World:release frees map bake canvases")
check(strip.released == true, "World:release frees scroll strips")
check(tilt.released == true, "World:release frees tiltCanvas")
eq(next(world.mapImages), nil, "World:release clears mapImages table")
end
-- ---- Renderer:init releases prior canvases before realloc ----------------
do
local first = love.graphics.newCanvas(16, 16)
@@ -52,14 +85,13 @@ do
"Renderer:init allocates a fresh primary canvas")
check(Renderer.canvas.released ~= true,
"the new primary canvas is not released")
-- second init also releases the one just created
local second = Renderer.canvas
Renderer:init()
check(second.released == true,
"a second Renderer:init releases the canvas from the prior init")
end
-- ---- Shared singleton teardown contract (closeEditor / returnToLauncher)
-- ---- SessionLifecycle.endMountedSession (closeEditor / returnToLauncher) --
do
Runtime.install({ emit = function() end }, { call = function() end }, { "e" })
Assets.installLoader({
@@ -68,44 +100,79 @@ do
})
LegacyCompat.reports = { some_mod = { order = {} } }
-- Mirrors main.lua teardownMountedSession without mounting CacheFs.
require("src.core.Data"):unloadGenerated()
Runtime.reset()
Assets.installLoader(nil)
LegacyCompat.reset()
SessionLifecycle.endMountedSession(nil)
check(Runtime.errors == nil, "teardown clears Runtime.errors")
check(Assets.loader == nil, "teardown clears Assets.loader")
eq(next(LegacyCompat.reports), nil, "teardown clears LegacyCompat.reports")
check(Runtime.errors == nil, "endMountedSession clears Runtime.errors")
check(Assets.loader == nil, "endMountedSession clears Assets.loader")
eq(next(LegacyCompat.reports), nil, "endMountedSession clears LegacyCompat.reports")
end
-- ---- Editor package.loaded discovery flush (no panel whitelist) ---------
-- ---- releaseSession empties MapLoader via releaseAll, not flush -------------
do
local data = {
maps = { T1 = { id = "T1", tileset = "TS", width = 1, height = 1,
blocks = { 0 }, borderBlock = 0, objects = {}, warps = {}, signs = {} } },
tilesets = { TS = { id = "TS", image = "assets/generated/t.png",
walkable = {}, blocks = { { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 } },
tilesPerRow = 1 } },
}
MapLoader.load(data, "T1")
check(MapLoader.cached("T1") ~= nil, "MapLoader holds a map before release")
Assets.releaseSession()
check(MapLoader.cached("T1") == nil,
"releaseSession evicts MapLoader via releaseAll")
end
-- ---- Editor package.loaded discovery flush via endEditorSession -----------
do
love.filesystem.write("tools/save-editor/App.lua", "return {}")
love.filesystem.write("tools/save-editor/panels/NewPanel.lua", "return {}")
package.loaded["App"] = { stale = true }
package.loaded["NewPanel"] = { stale = true }
package.loaded["src.core.Data"] = package.loaded["src.core.Data"] -- keep
package.loaded["src.core.Data"] = package.loaded["src.core.Data"]
local function isEditorFlat(name)
if name:find("[./]") then return false end
return love.filesystem.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
or love.filesystem.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
end
for k in pairs(package.loaded) do
if type(k) == "string"
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
package.loaded[k] = nil
end
end
SessionLifecycle.endEditorSession({ version = nil, app = nil })
check(package.loaded["App"] == nil, "discovery flush drops flat App")
check(package.loaded["App"] == nil, "endEditorSession drops flat App")
check(package.loaded["NewPanel"] == nil,
"discovery flush drops a new panel without a hardcoded list")
"endEditorSession drops a new panel without a hardcoded list")
check(package.loaded["src.core.Data"] ~= nil,
"discovery flush leaves engine modules alone")
"endEditorSession leaves engine modules alone")
love.filesystem.remove("tools/save-editor/App.lua")
love.filesystem.remove("tools/save-editor/panels/NewPanel.lua")
end
-- ---- Fetch shutdown clears ready so Play-again can respawn workers ---------
do
local Fetch = require("src.net.Fetch")
local spawnAttempts = 0
love.thread = love.thread or {}
local savedNewThread = love.thread.newThread
local savedGetChannel = love.thread.getChannel
love.thread.getChannel = function()
return {
clear = function() end,
push = function() end,
pop = function() return nil end,
demand = function() end,
}
end
love.thread.newThread = function()
spawnAttempts = spawnAttempts + 1
return {
start = function() end,
wait = function() end,
getError = function() return nil end,
}
end
Fetch.available()
local afterFirst = spawnAttempts
Fetch.shutdown()
Fetch.available()
check(spawnAttempts > afterFirst,
"Fetch.available retries worker spawn after shutdown (ready=nil)")
love.thread.newThread = savedNewThread
love.thread.getChannel = savedGetChannel
end
T.finish("launcher_session_teardown_test")
+15 -3
View File
@@ -90,11 +90,23 @@ check(imp:find('if self.tab == "skins" then', 1, true) ~= nil,
check(imp:find("_installSkinZip", 1, true) ~= nil, "skin zip installer exists")
check(imp:find("_installMod", 1, true) ~= nil,
"and a zip elsewhere still installs a mod")
local cycle = imp:match("local order = %{(.-)%}")
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
local RomImporter = require("src.import.RomImporter")
local GameVersion = require("src.core.GameVersion")
local cycled, probe = {}, nil
probe = setmetatable({ tab = GameVersion.ORDER[1] }, { __index = RomImporter })
probe._switchTab = function(self, id) self.tab = id; cycled[#cycled + 1] = id end
for _ = 1, #GameVersion.ORDER + 3 do RomImporter._cycleTab(probe, 1) end
local reached = " " .. table.concat(cycled, " ") .. " "
check(reached:find(" skins ", 1, true) ~= nil,
"shoulder-button tab cycling reaches the skins tab")
check(cycle and cycle:find('"bug"', 1, true) ~= nil,
check(reached:find(" bug ", 1, true) ~= nil,
"shoulder-button tab cycling reaches the bug tab")
for _, id in ipairs(GameVersion.ORDER) do
if id ~= GameVersion.ORDER[1] then
check(reached:find(" " .. id .. " ", 1, true) ~= nil,
"shoulder-button tab cycling reaches " .. id)
end
end
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
"switching to the tab re-reads the skin list")
+28
View File
@@ -265,4 +265,32 @@ check(update and update:find("_pumpSkinFetch", 1, true) ~= nil,
check(TouchSkin.ARCHIVE_EXTS.deltaskin == true,
"and the installer accepts the extension")
-- Turning skins off must leave the pad on. `enabled` is the pad's own switch,
-- and the button that calls this only exists while a skin is active, which
-- already requires the pad to be enabled.
do
local SaveData = require("src.core.SaveData")
local TouchControls = require("src.core.TouchControls")
local opts = SaveData.loadOptions()
opts.touchControls = { enabled = true, skin = "some_skin" }
SaveData.saveOptions(opts)
local imp = RomImporter.new(function() end, { launcher = true })
imp:_disableSkins()
local after = SaveData.loadOptions().touchControls
eq(after.skin, nil, "turning skins off clears the selected skin")
check(after.enabled ~= false, "and leaves the touch pad enabled")
local cfg = TouchControls.normalizeConfig(after)
check(cfg.enabled, "so the pad is on after normalizing")
eq(cfg.skin, nil, "with no skin behind it")
check(imp._skinNotice ~= nil and imp._skinNotice.ok == true,
"and the notice reports success")
check(tostring(imp._skinNotice.text):find("built-in pad", 1, true) ~= nil,
"promising the built-in pad, which is now what happens")
end
T.finish("launcher_skins_ux")
@@ -0,0 +1,49 @@
-- Static privacy gate for the narrow Mew dock engine branch. It checks the
-- Git publication set, not ignored local imports: user ROMs and progress
-- saves may exist on a developer machine but must never become tracked files.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local pipe = io.popen("git ls-files 2>" .. (package.config:sub(1, 1) == "\\" and "nul" or "/dev/null"))
local paths = {}
if pipe then
for path in pipe:lines() do paths[#paths + 1] = path:gsub("\\", "/") end
pipe:close()
end
T.check(#paths > 100,
"privacy gate inspects a real Git publication set instead of passing vacuously")
local forbiddenExtensions = {
gb = true, gbc = true, gba = true, sav = true, srm = true,
rom = true, z64 = true, v64 = true, n64 = true, nds = true,
sfc = true, smc = true,
}
local forbiddenRuntimeRoots = {
["save.lua"] = true,
["save_blue.lua"] = true,
["save_yellow.lua"] = true,
["save_gold.lua"] = true,
["options.lua"] = true,
}
local binaryLeaks, runtimeLeaks = {}, {}
for _, path in ipairs(paths) do
local lower = path:lower()
local ext = lower:match("%.([^./\\]+)$")
if forbiddenExtensions[ext] then binaryLeaks[#binaryLeaks + 1] = path end
if forbiddenRuntimeRoots[lower]
or lower:match("^saves/")
or lower:match("^imports/")
or lower:match("^mods%-data/") then
runtimeLeaks[#runtimeLeaks + 1] = path
end
end
T.eq(#binaryLeaks, 0,
"tracked publication contains no ROM/save binaries: " .. table.concat(binaryLeaks, ", "))
T.eq(#runtimeLeaks, 0,
"tracked publication contains no runtime save/import data: " .. table.concat(runtimeLeaks, ", "))
T.finish("mew dock private artifact gate")
+428
View File
@@ -0,0 +1,428 @@
-- Contract gate for the two narrow Gen 1 seams added for a composable
-- post-departure S.S. Anne dock mod. The suite is ROM-free: it drives the
-- real hook bus and WorldAPI against hand-written maps and save snapshots.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local WorldAPI = require("src.world.WorldAPI")
local oldHooks = Runtime.hooks
local oldTextBox = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
local story = dofile("data/scripts/story3.lua")
local VERSIONS = { "red", "blue", "yellow" }
local function newDock(version, wrappers)
local blocks, pushes, warps = {}, {}, {}
local rebuilds = 0
local save = {
version = version,
flags = { EVENT_SS_ANNE_LEFT = true },
marker = "save-must-not-change",
}
local map = {
id = "VERMILION_DOCK",
setBlock = function(_, bx, by, block)
blocks[bx .. "," .. by] = block
end,
renderer = { rebuild = function() rebuilds = rebuilds + 1 end },
}
local game = {
save = save,
data = { text = {
_VermilionCitySailor1ShipSetSailText = "The ship set sail.",
} },
stack = { push = function(_, value) pushes[#pushes + 1] = value end },
}
local ow = {
map = map,
player = { cellX = 14, cellY = 2, facing = "down" },
startWarpTo = function(_, ...)
warps[#warps + 1] = { ... }
end,
}
local hooks = Hooks.new()
Runtime.hooks = hooks
for _, entry in ipairs(wrappers or {}) do
hooks:wrap("map.occupancy_allowed", entry.fn, entry.priority or 0,
entry.owner)
end
story.VERMILION_DOCK.onEnter(game, ow)
if pushes[1] and pushes[1].done then pushes[1].done() end
return {
blocks = blocks, pushes = pushes, warps = warps, rebuilds = rebuilds,
save = save, map = map, game = game, ow = ow, hooks = hooks,
}
end
local function allowedWrapper(owner, inspect)
return {
owner = owner,
fn = function(nextFn, game, ctx)
if inspect then inspect(game, ctx) end
local downstream = nextFn(game, ctx)
local ownClaim = true
return downstream == true or ownClaim == true
end,
}
end
-- With no subscriber, the post-departure branch remains byte-for-byte
-- vanilla in effect: erase the ship, show its line, and eject the player.
do
local run = newDock("red")
T.eq(run.rebuilds, 1, "vanilla re-entry rebuilds the erased dock")
T.eq(run.blocks["5,1"], 1, "vanilla re-entry erases the upper hull")
T.eq(run.blocks["8,2"], 13, "vanilla re-entry erases the lower hull")
T.eq(#run.pushes, 1, "vanilla re-entry shows the ship-set-sail line")
T.eq(run.pushes[1].text, "The ship set sail.",
"vanilla re-entry preserves its dialogue")
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
"vanilla re-entry ejects to Vermilion City")
end
-- The new permission seam is post-departure only. An ordinary HM01 exit
-- must not invoke it or change the existing departure-script path.
do
local hookCalls, queued = 0, nil
local hooks = Hooks.new()
Runtime.hooks = hooks
hooks:wrap("map.occupancy_allowed", function(nextFn, game, ctx)
hookCalls = hookCalls + 1
return nextFn(game, ctx)
end, 0, "must_not_run")
local savedMusic = package.loaded["src.core.Music"]
package.loaded["src.core.Music"] = {
stop = function() end,
play = function() end,
}
local game = {
save = { version = "red", flags = { EVENT_GOT_HM01 = true } },
data = {},
}
local ow = {
player = { cellX = 14, cellY = 2 },
startDustAnim = function(_, _, _, done) if done then done() end end,
queueScript = function(_, rows) queued = rows end,
}
story.VERMILION_DOCK.onEnter(game, ow)
package.loaded["src.core.Music"] = savedMusic
T.eq(hookCalls, 0, "normal HM01 departure never calls the occupancy seam")
T.eq(game.save.flags.EVENT_SS_ANNE_LEFT, true,
"normal HM01 departure still sets the vanilla event flag")
T.check(type(queued) == "table" and #queued > 0,
"normal HM01 departure still queues its sail-away script")
end
-- One cooperative claimant may permit occupancy. The callback receives a
-- detached data snapshot, not the live overworld, map, player, or save.
do
local seenGame, seenCtx
local run = newDock("red", { allowedWrapper("mew_fixture", function(game, ctx)
seenGame = game
seenCtx = {
mapId = ctx.mapId, reason = ctx.reason, gameVersion = ctx.gameVersion,
x = ctx.x, y = ctx.y,
}
ctx.mapId, ctx.x, ctx.y = "MUTATED", -1, -1
end) })
T.eq(seenGame, run.game, "occupancy callback receives the live game explicitly")
T.same(seenCtx, {
mapId = "VERMILION_DOCK", reason = "ss_anne_departed",
gameVersion = "red", x = 14, y = 2,
}, "occupancy context is the exact detached Red dock snapshot")
T.eq(run.ow.map.id, "VERMILION_DOCK", "context mutation cannot change the map")
T.eq(run.ow.player.cellX, 14, "context mutation cannot change player X")
T.eq(run.ow.player.cellY, 2, "context mutation cannot change player Y")
T.eq(run.save.marker, "save-must-not-change", "permission check does not mutate save")
T.same(run.save, {
version = "red", flags = { EVENT_SS_ANNE_LEFT = true },
marker = "save-must-not-change",
}, "permission check preserves the complete save snapshot")
T.eq(#run.pushes, 0, "an exact true suppresses the vanilla rejection dialog")
T.eq(#run.warps, 0, "an exact true permits post-departure dock occupancy")
T.eq(run.blocks["5,1"], 1, "permitted occupancy still erases the departed ship")
T.eq(run.rebuilds, 1, "permitted occupancy still rebuilds the water layout")
end
-- Standard hook composition also means a non-cooperative false/no-next
-- wrapper can suppress downstream claims. This remains safe because false
-- is denial; it cannot accidentally grant occupancy.
do
local downstreamCalls = 0
local run = newDock("red", {
{
owner = "denier", priority = 10,
fn = function() return false end,
},
{
owner = "unreached_claimant", priority = 0,
fn = function()
downstreamCalls = downstreamCalls + 1
return true
end,
},
})
T.eq(downstreamCalls, 0, "no-next denial suppresses downstream by hook semantics")
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
"non-cooperative false remains fail-closed")
end
-- Cooperative peers all run through next(). A lower-priority peer claim is
-- preserved by a higher-priority peer that has no claim of its own.
do
local calls, contextIdentity = {}, nil
local run = newDock("blue", {
{
owner = "peer_high", priority = 10,
fn = function(nextFn, game, ctx)
calls[#calls + 1] = "high-before"
contextIdentity = ctx
local allowed = nextFn(game, ctx)
calls[#calls + 1] = "high-after"
return allowed == true or false
end,
},
{
owner = "peer_low", priority = 0,
fn = function(nextFn, game, ctx)
calls[#calls + 1] = "low"
T.eq(ctx, contextIdentity, "peer wrappers share one detached snapshot instance")
local allowed = nextFn(game, ctx)
local ownClaim = true
return allowed == true or ownClaim == true
end,
},
})
T.same(calls, { "high-before", "low", "high-after" },
"multiple peer handlers preserve hook-chain order")
T.eq(#run.warps, 0, "a cooperative peer claim survives the whole chain")
end
-- Absent, throwing, or malformed callbacks fail closed. Only boolean true
-- can turn off ejection; truthy strings/tables/numbers do not grant access.
do
local malformed = {
{ label = "nil", value = nil },
{ label = "false", value = false },
{ label = "string", value = "yes" },
{ label = "number", value = 1 },
{ label = "table", value = {} },
}
for _, case in ipairs(malformed) do
local run = newDock("red", { {
owner = "malformed_" .. case.label,
fn = function() return case.value end,
} })
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
"malformed " .. case.label .. " permission fails closed")
end
local beforeNext = newDock("red", { {
owner = "throws_before_next",
fn = function() error("fixture throws before next", 0) end,
} })
T.eq(beforeNext.warps[1] and beforeNext.warps[1][1], "VERMILION_CITY",
"throwing callback before next fails closed")
local afterNext = newDock("red", { {
owner = "throws_after_next",
fn = function(nextFn, game, ctx)
nextFn(game, ctx)
error("fixture throws after next", 0)
end,
} })
T.eq(afterNext.warps[1] and afterNext.warps[1][1], "VERMILION_CITY",
"throwing callback after next keeps the downstream denial")
end
-- The context carries one version only. A Red-only claimant must not leak
-- access into Blue or Yellow, and each call gets its own snapshot.
do
local contexts = {}
for _, version in ipairs(VERSIONS) do
local run = newDock(version, { {
owner = "red_only",
fn = function(nextFn, game, ctx)
contexts[#contexts + 1] = ctx
local downstream = nextFn(game, ctx)
return downstream == true or ctx.gameVersion == "red"
end,
} })
T.eq(#run.warps == 0, version == "red",
version .. " occupancy is decided only by its own version context")
end
T.eq(contexts[1].gameVersion, "red", "Red context stays Red")
T.eq(contexts[2].gameVersion, "blue", "Blue context stays Blue")
T.eq(contexts[3].gameVersion, "yellow", "Yellow context stays Yellow")
T.check(contexts[1] ~= contexts[2] and contexts[2] ~= contexts[3],
"Red, Blue, and Yellow calls do not share context tables")
end
-- Removing the owner is the engine's disable/uninstall path. It restores
-- vanilla denial immediately and leaves no save flag or serialized state.
do
local run = newDock("yellow", { allowedWrapper("removable") })
T.eq(#run.warps, 0, "installed owner may grant Yellow dock occupancy")
run.hooks:removeOwner("removable")
local pushes, warps = {}, {}
run.game.stack.push = function(_, value) pushes[#pushes + 1] = value end
run.ow.startWarpTo = function(_, ... ) warps[#warps + 1] = { ... } end
story.VERMILION_DOCK.onEnter(run.game, run.ow)
if pushes[1] and pushes[1].done then pushes[1].done() end
T.eq(warps[1] and warps[1][1], "VERMILION_CITY",
"disabling the owner restores vanilla ejection")
T.eq(run.hooks.chains["map.occupancy_allowed"], nil,
"uninstall removes the occupancy chain itself")
T.eq(run.save.marker, "save-must-not-change",
"disable/uninstall writes no persistent permission state")
T.same(run.save, {
version = "yellow", flags = { EVENT_SS_ANNE_LEFT = true },
marker = "save-must-not-change",
}, "disable/uninstall preserves the complete Yellow save snapshot")
end
-- activeBlockAt is read-only and fail-closed. A successful call exposes
-- only one scalar from the active runtime layout, never its backing table.
local function blockApi(version, blockAt)
local backing = { 4, 5, 6, 8, 9, 10 }
local map = {
id = "VERMILION_DOCK",
def = { width = 3, height = 2, blocks = backing },
blockAt = blockAt or function(_, bx, by)
return backing[by * 3 + bx + 1]
end,
}
local world = { isOverworld = true, map = map }
local game = {
save = { version = version },
stack = { states = { world } },
overworld = world,
}
return WorldAPI.new(game, "contract_fixture"), backing, game, map
end
do
for _, version in ipairs(VERSIONS) do
local api, backing = blockApi(version)
local block, err = api:activeBlockAt("VERMILION_DOCK", 1, 0)
T.eq(block, 5, version .. " reads its active dock block")
T.eq(err, nil, version .. " valid active block has no error")
block = 99
T.eq(backing[2], 5, version .. " scalar result cannot mutate the map")
end
local api = blockApi("red")
local wrong, wrongErr = api:activeBlockAt("VERMILION_CITY", 1, 0)
T.eq(wrong, nil, "wrong map has no block result")
T.eq(wrongErr, "map is not active", "wrong map fails closed explicitly")
local invalid = {
{ "nil x", nil, 0 }, { "string x", "1", 0 }, { "table x", {}, 0 },
{ "fraction x", 0.5, 0 }, { "negative infinity x", -math.huge, 0 },
{ "infinity y", 0, math.huge }, { "NaN y", 0, 0 / 0 },
}
for _, case in ipairs(invalid) do
local value, err = api:activeBlockAt("VERMILION_DOCK", case[2], case[3])
T.eq(value, nil, case[1] .. " returns no block")
T.eq(err, "invalid block coordinates", case[1] .. " is rejected by type")
end
for _, coords in ipairs({ { -1, 0 }, { 0, -1 }, { 3, 0 }, { 0, 2 } }) do
local value, err = api:activeBlockAt("VERMILION_DOCK", coords[1], coords[2])
T.eq(value, nil, "out-of-bounds coordinate returns no block")
T.eq(err, "block coordinates out of bounds", "bounds fail closed explicitly")
end
end
do
local malformed = {
{ label = "nil", get = function() return nil end },
{ label = "negative", get = function() return -1 end },
{ label = "fractional", get = function() return 1.5 end },
{ label = "infinite", get = function() return math.huge end },
{ label = "NaN", get = function() return 0 / 0 end },
{ label = "string", get = function() return "4" end },
{ label = "table", get = function() return {} end },
{ label = "throwing", get = function() error("bad map", 0) end },
}
for _, case in ipairs(malformed) do
local api = blockApi("red", case.get)
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "malformed active block " .. case.label .. " returns no value")
T.eq(err, "block unavailable",
"malformed active block " .. case.label .. " fails closed")
end
end
-- Invalid map shapes are untrusted runtime data too. None may escape as a
-- block or raise through the mod facade.
do
local badDefs = {
{ label = "missing def", value = nil },
{ label = "missing width", value = { height = 2, blocks = {} } },
{ label = "string width", value = { width = "3", height = 2, blocks = {} } },
{ label = "fractional width", value = { width = 1.5, height = 2, blocks = {} } },
{ label = "nonpositive width", value = { width = 0, height = 2, blocks = {} } },
{ label = "infinite height", value = { width = 3, height = math.huge, blocks = {} } },
{ label = "missing blocks", value = { width = 3, height = 2 } },
{ label = "scalar blocks", value = { width = 3, height = 2, blocks = 4 } },
}
for _, case in ipairs(badDefs) do
local api, _, _, map = blockApi("red")
map.def = case.value
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, case.label .. " returns no block")
T.eq(err, "block unavailable", case.label .. " fails closed")
end
local api, _, _, map = blockApi("red")
map.blockAt = nil
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "missing blockAt returns no block")
T.eq(err, "block unavailable", "missing blockAt fails closed")
map.blockAt = "not a function"
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "malformed blockAt returns no block")
T.eq(err, "block unavailable", "malformed blockAt fails closed")
api, _, _, map = blockApi("red")
map.def.blocks = {}
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "sparse stored block slot returns no block")
T.eq(err, "block unavailable", "sparse stored block slot fails closed")
api, _, _, map = blockApi("red")
map.def.blocks[1] = "4"
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "malformed stored block slot returns no block")
T.eq(err, "block unavailable", "malformed stored block slot fails closed")
api, _, _, map = blockApi("red", function() return 5 end)
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "stored/accessor mismatch returns no block")
T.eq(err, "block unavailable", "stored/accessor mismatch fails closed")
end
do
local api = WorldAPI.new({ stack = { states = {} } }, "contract_fixture")
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "no-overworld lookup returns no block")
T.eq(err, "no overworld", "no-overworld lookup reports its state")
end
Runtime.hooks = oldHooks
package.loaded["src.render.TextBox"] = oldTextBox
T.finish("mew dock seam contract")
@@ -0,0 +1,66 @@
-- No-mod and API-v1 parity for the additive mod.developer surface.
--
-- The production break this catches is a developer-mode loader path that
-- mutates vanilla data, creates mod state, or changes existing API-v1
-- behavior merely because the new public signal exists.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local function pristine()
return {
pokemon = { KEEP = { hp = 7 } },
moves = {},
}
end
for _, dev in ipairs({ false, true }) do
local data = pristine()
local files = {}
local run = T.sdk.loadNone({
data = data,
fs = T.sdk.memfs(files),
dev = dev,
})
T.eq(#run.errors, 0,
"no-mod load stays clean with developer=" .. tostring(dev))
T.eq(next(run.loader.mods), nil,
"no-mod load discovers nothing with developer=" .. tostring(dev))
T.eq(data.pokemon.KEEP.hp, 7,
"no-mod load preserves vanilla data with developer=" .. tostring(dev))
T.eq(next(files), nil,
"no-mod load creates no files with developer=" .. tostring(dev))
run.release()
end
local V1 = {
["mods/v1_probe/manifest.json"] = [[{
"id": "v1_probe",
"name": "V1 Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 1
}]],
["mods/v1_probe/main.lua"] = [[
local mod = ...
mod.exports.identity = mod.id .. "@" .. mod.version
mod.exports.payload = mod:read("payload.txt")
mod.options:define({ { key = "enabled", type = "toggle", default = true } })
mod.exports.defaultOption = mod.options:get("enabled")
]],
["mods/v1_probe/payload.txt"] = "unchanged-v1",
}
local legacy = T.sdk.loadMods({ "mods/v1_probe" }, {
fs = T.sdk.memfs(V1),
dev = false,
})
T.eq(#legacy.errors, 0, "existing API-v1 mod loads unchanged")
local out = legacy.loader.exports.v1_probe
T.eq(out.identity, "v1_probe@1.0.0", "API-v1 identity stays unchanged")
T.eq(out.payload, "unchanged-v1", "API-v1 mod:read stays unchanged")
T.eq(out.defaultOption, true, "API-v1 options stay unchanged")
legacy.release()
T.finish("mod developer mode parity")
+102
View File
@@ -0,0 +1,102 @@
-- Public load-time developer-mode signal for sandboxed mods.
--
-- The production break this catches is a loader that computes dev mode but
-- does not expose the same fixed answer to the public mod object before the
-- entry chunk runs. It also protects the data-only contract: the public
-- value is a boolean snapshot, not a live loader or environment handle.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local FILES = {
["mods/dev_probe/manifest.json"] = [[{
"id": "dev_probe",
"name": "Developer Mode Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"games": ["all"]
}]],
["mods/dev_probe/main.lua"] = [[
local mod = ...
mod.exports.seenAtLoad = mod.developer
mod.exports.kind = type(mod.developer)
if mod.developer then
mod.commands:register("dev_probe:diagnostics", function() return true end)
end
]],
}
local function load(dev, generation)
return T.sdk.loadMods({ "mods/dev_probe" }, {
fs = T.sdk.memfs(FILES),
dev = dev,
generation = generation,
})
end
do
local run = load(true)
T.eq(#run.errors, 0, "developer-mode public probe loads clean")
local out = run.loader.exports.dev_probe
T.eq(out.seenAtLoad, true,
"sandboxed entry code sees developer mode at load time")
T.eq(out.kind, "boolean", "developer mode is exposed as plain data")
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
"entry code can register diagnostics only in developer mode")
run.release()
end
do
local run = load(false)
T.eq(#run.errors, 0, "production-mode public probe loads clean")
local out = run.loader.exports.dev_probe
T.eq(out.seenAtLoad, false,
"sandboxed entry code sees production mode at load time")
T.eq(out.kind, "boolean", "production mode is exposed as plain data")
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
"production load does not register developer diagnostics")
run.release()
end
for _, provided in ipairs({ "yes", 1 }) do
local run = load(provided)
T.eq(#run.errors, 0,
"non-boolean developer-mode probe loads clean: " .. tostring(provided))
local out = run.loader.exports.dev_probe
T.eq(out.seenAtLoad, false,
"non-boolean opts.dev is false in mod.developer: " .. tostring(provided))
T.eq(out.kind, "boolean",
"non-boolean opts.dev stays a strict public boolean: " .. tostring(provided))
T.eq(run.loader.dev, false,
"non-boolean opts.dev is false in loader.dev: " .. tostring(provided))
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
"non-boolean opts.dev cannot register developer diagnostics: " .. tostring(provided))
run.release()
end
do
local run = load(true, 2)
T.eq(#run.errors, 0, "Gen 2 developer-mode public probe loads clean")
local out = run.loader.exports.dev_probe
T.eq(out.seenAtLoad, true,
"Gen 2 entry code sees the same developer-mode answer")
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
"Gen 2 entry code can gate diagnostics on the same signal")
run.release()
end
do
local saved = _G.POKEPORT_DEV_MODE
_G.POKEPORT_DEV_MODE = true
local ok, run = pcall(load, nil)
_G.POKEPORT_DEV_MODE = saved
if not ok then error(run, 0) end
T.eq(#run.errors, 0, "command-line developer-mode probe loads clean")
T.eq(run.loader.exports.dev_probe.seenAtLoad, true,
"the --developer boot decision reaches the public signal")
run.release()
end
T.finish("mod developer mode public API")
+148
View File
@@ -385,4 +385,152 @@ do
eq(cats[1], "GAMEPLAY", "and they keep the feed's declared order")
end
-- ------- carts: a second array on the same feed, at the same schema_version
--
-- The published index added carts without bumping schema_version, so an older
-- build has to keep reading the feed and this one has to read both halves.
-- The fixture below is the shape carts/<Author>@<id>/meta.json validates to.
local OMEGA = {
folder = "bryanthaboi@omega_random_competition",
id = "omega_random_competition",
title = "OMEGA RANDOM COMPETITION",
author = "bryanthaboi",
summary = "Bois Club Randomizer as its own cartridge.",
version = "1.0.0",
base = "red",
seal = "sealed",
shell = "#7B1B22",
finish = "holo",
speeds = { 1, 2 },
tags = { "randomizer", "competition" },
repo = "https://github.com/bryanthaboi/omega-random-competition",
github = "bryanthaboi/omega-random-competition",
automatic_version_check = true,
mods = { { id = "bcr", source = "github", repo = "bryanthaboi/bcr",
version = "1.0.0", sha256 = ("83a111b4"):rep(8) } },
load_order = { "bcr" },
license = "MIT",
description_url = "data/carts/bryanthaboi@omega_random_competition/description.md",
update_check = "pending",
}
local JOHTO_CART = {
id = "johto_run", title = "Johto Run", author = "Ren", version = "2.1.0",
base = "gold", seal = "open",
repo = "https://github.com/ren/johto-run",
github = "ren/johto-run",
mods = { { id = "steps", source = "gamebanana", mod = 42, file = 99,
md5 = ("ab"):rep(16), enabled = false,
options = { pace = "fast" } } },
update_check = "ok",
latest = { version = "2.1.0", tag = "v2.1.0",
zip = { name = "johto_run-2.1.0.zip",
url = "https://example.test/johto_run-2.1.0.zip" } },
}
local function cartFeed(carts, overrides)
local doc = { schema_version = 1, count = 1, cart_count = #carts,
categories = { "GAMEPLAY" },
base_games = { "red", "blue", "yellow", "gold", "silver" },
mods = { NUZLOCKE }, carts = carts }
for k, v in pairs(overrides or {}) do doc[k] = v end
return Json.encode(doc)
end
do
local index, err = ModIndex.parse(cartFeed({ OMEGA, JOHTO_CART }))
check(index ~= nil, "a feed carrying carts parses: " .. tostring(err))
eq(index.schemaVersion, 1, "carts arrive at schema_version 1, unbumped")
eq(#index.mods, 1, "the mods array still parses")
eq(#index.carts, 2, "and the carts array parses beside it")
local c = index.carts[1]
eq(c.id, "omega_random_competition", "cart id")
eq(c.title, "OMEGA RANDOM COMPETITION", "cart title")
eq(c.base, "red", "the game the cart plays as")
eq(c.seal, "sealed", "its seal")
eq(c.shell, "#7B1B22", "its shell colour")
eq(c.finish, "holo", "its finish")
eq(c.speeds[2], 2, "its speed ladder")
eq(c.tags[1], "randomizer", "its tags")
eq(c.license, "MIT", "its license")
eq(c.load_order[1], "bcr", "its load order")
eq(#c.mods, 1, "its pinned mod set")
eq(c.mods[1].source, "github", "a github pin keeps its source")
eq(c.mods[1].repo, "bryanthaboi/bcr", "with the repo it comes from")
eq(c.mods[1].version, "1.0.0", "the exact pinned version")
eq(#c.mods[1].sha256, 64, "and the digest that gates it")
check(ModIndex.isCart(c), "a parsed cart is marked as one")
check(not ModIndex.isCart(index.mods[1]), "a mod is not")
local g = index.carts[2]
eq(g.mods[1].source, "gamebanana", "a gamebanana pin keeps its source")
eq(g.mods[1].mod, 42, "with its mod page id")
eq(g.mods[1].file, 99, "and its file id")
eq(#g.mods[1].md5, 32, "and the digest GameBanana reports")
eq(g.mods[1].enabled, false, "a pin shipped switched off stays off")
eq(g.mods[1].options.pace, "fast", "frozen options survive")
eq(ModIndex.installUrl(g), "https://example.test/johto_run-2.1.0.zip",
"a cart resolves its release asset the same way a mod does")
end
-- the old-feed case: no carts key at all
do
local index, err = ModIndex.parse(feed({ NUZLOCKE }))
check(index ~= nil, "a feed with no carts key still parses: " .. tostring(err))
eq(#index.mods, 1, "its mods are unaffected")
eq(type(index.carts), "table", "and carts is a list, never nil")
eq(#index.carts, 0, "an absent carts array is an empty one")
end
-- a broken cart row costs itself, not the whole feed
do
local noBase = {}
for k, v in pairs(OMEGA) do noBase[k] = v end
noBase.base = nil
local noMods = {}
for k, v in pairs(JOHTO_CART) do noMods[k] = v end
noMods.id, noMods.mods = "empty_pins", {}
local index, err = ModIndex.parse(cartFeed({
noBase, "not even an object", { id = "bare" }, noMods, OMEGA }))
check(index ~= nil, "a feed with malformed carts still parses: " .. tostring(err))
eq(#index.carts, 1, "only the well-formed cart is listed")
eq(index.carts[1].id, "omega_random_competition", "and it is the intact one")
eq(#index.mods, 1, "the mods array is untouched by a bad cart")
end
-- search and filter: matches() already spans title / author / summary / id,
-- so the cart half reuses it wholesale. The category filter does not apply
-- (a cart has none); base does.
do
local index = ModIndex.parse(cartFeed({ OMEGA, JOHTO_CART }))
local carts = index.carts
eq(#ModIndex.filter(carts, {}), 2, "no filter keeps every cart")
eq(ModIndex.filter(carts, { query = "omega" })[1].id,
"omega_random_competition", "search matches a cart by title")
eq(ModIndex.filter(carts, { query = "Ren" })[1].id, "johto_run",
"search matches a cart by author")
eq(ModIndex.filter(carts, { query = "randomizer" })[1].id,
"omega_random_competition", "and by summary")
eq(#ModIndex.filter(carts, { query = "omega johto" }), 0,
"cart search terms are ANDed too")
eq(ModIndex.filter(carts, { base = "gold" })[1].id, "johto_run",
"filtering by base game keeps the carts for that game")
eq(#ModIndex.filter(carts, { base = "RED" }), 1,
"and compares case-insensitively")
eq(#ModIndex.filter(carts, { base = "silver" }), 0,
"a base no cart plays as filters everything out")
eq(#ModIndex.filter(carts, { category = "GAMEPLAY" }), 0,
"a cart carries no categories, so a category filter never matches one")
eq(ModIndex.filter(carts, { tag = "competition" })[1].id,
"omega_random_competition", "cart tags filter")
local bases = ModIndex.baseGamesIn(index)
eq(#bases, 2, "only base games a cart actually plays as are offered")
eq(bases[1], "red", "and they keep the feed's declared base_games order")
eq(bases[2], "gold", "in that order")
eq(#ModIndex.baseGamesIn(ModIndex.parse(feed({ NUZLOCKE }))), 0,
"a cartless feed offers no base games")
end
print("ok mod_index_tests")
+20 -12
View File
@@ -27,18 +27,25 @@ end
-- ------- tokens expand off GameVersion, never a literal list
-- "nonesuch" is the deliberate never-a-game token for every unknown-version
-- case below. A real version id was used here twice ("gold", then "crystal")
-- and both had to be swapped the day that game shipped; this one never will.
local NO_SUCH_GAME = "nonesuch"
do
eq(table.concat(ModTargets.expand("red"), ","), "red",
"a version id names exactly that game")
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
"gen1 is every Gen 1 game, case-insensitive")
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver",
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver,crystal",
"gen2 is every Gen 2 game")
eq(table.concat(ModTargets.expand("silver"), ","), "silver",
"and each of them names itself")
eq(table.concat(ModTargets.expand("crystal"), ","), "crystal",
"Crystal included, the day its VERSIONS row landed")
eq(table.concat(ModTargets.expand("all"), ","),
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for")
eq(ModTargets.expand(NO_SUCH_GAME), nil, "a game this engine has no cache for")
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
end
@@ -48,9 +55,9 @@ do
eq(table.concat(versions, ","), "red,gold",
"normalize dedupes and sorts into GameVersion.ORDER")
eq(#unknown, 0, "known tokens leave nothing unreported")
local _, bad = ModTargets.normalize({ "crystal", "gen1" })
local _, bad = ModTargets.normalize({ NO_SUCH_GAME, "gen1" })
eq(#bad, 1, "an unknown token comes back for the caller to report")
eq(bad[1], "crystal", "by name")
eq(bad[1], NO_SUCH_GAME, "by name")
end
-- ------- the legacy reading: gen2compat only ever ADDS Gen 2
@@ -58,7 +65,7 @@ end
do
eq(list(mf({})), "red,blue,yellow",
"a manifest with no games key is Gen 1, which is what it was tested as")
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver",
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver,crystal",
"gen2compat keeps Gen 1 and adds Gen 2")
eq(mf({}).gen2compat, false, "and the derived flag agrees")
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
@@ -69,23 +76,23 @@ end
do
local gen2 = mf({ games = { "gen2" } })
eq(list(gen2), "gold,silver", "games can name Gen 2 alone")
eq(list(gen2), "gold,silver,crystal", "games can name Gen 2 alone")
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
local both = mf({ games = { "gen1", "gen2" } })
eq(list(both), "red,blue,yellow,gold,silver", "or both generations")
eq(list(both), "red,blue,yellow,gold,silver,crystal", "or both generations")
local one = mf({ games = { "blue" } })
eq(list(one), "blue", "or one single game")
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver",
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver,crystal",
"an old gen2compat beside a new games list still adds its game")
end
do
-- vocabulary: api 1 warns and keeps loading, api 2 refuses, exactly like
-- every other manifest vocabulary (Manifest.violation)
local lenient = mf({ games = { "crystal", "red" } })
local lenient = mf({ games = { NO_SUCH_GAME, "red" } })
eq(list(lenient), "red", "api 1 drops the unknown game and keeps the rest")
check(not pcall(mf, { api = 2, games = { "crystal" } }),
check(not pcall(mf, { api = 2, games = { NO_SUCH_GAME } }),
"api 2 refuses a game it does not have")
check(not pcall(mf, { games = "gen1" }),
"games must be an array, not a bare string")
@@ -226,12 +233,13 @@ do
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
format = "g1rmodlist", formatVersion = 1,
profile = { name = "P", enabledByVersion = {
gold = { a = true }, silver = { a = true }, crystal = { a = true },
gold = { a = true }, silver = { a = true },
[NO_SUCH_GAME] = { a = true },
red = "nope" } },
}))
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first")
eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion[NO_SUCH_GAME], nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
end
@@ -0,0 +1,222 @@
-- The Pewter museum ticket clerk dispatches on the player's coordinates
-- before it looks at the ticket flag (#1690). scripts/Museum1F.asm:45
-- reads wYCoord/wXCoord first: (13,4) and (12,3) are behind the counter
-- (the AMBER question), Y==4 otherwise is the ticket path, and anything
-- else is the "go to the other side" brush-off. The port only ever had
-- the middle branch, so two thirds of the NPC never fired.
--
-- tests/engine/museum_money_box_bug1335.lua and museum_1f_clerk_
-- translation_test.lua both call the clerk with ow == nil, which stays on
-- the ticket path either way, so neither can see the coordinate branches.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local M = assert(loadfile("data/scripts/story2.lua"))()
local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1
-- data/maps/objects/Museum1F.asm: MUSEUM1F_SCIENTIST1 stands at (12,4)
-- with the counter column at x==11. (13,4) is the cell east of him and
-- (12,3) the cell north of him, both reachable only from the back door;
-- (10,4) talks to him across the counter from the public side.
local BEHIND = { { 13, 4 }, { 12, 3 } }
local FRONT = { 10, 4 }
local pushed
local function mkGame(cash)
pushed = {}
return {
data = { text = {} },
save = { money = cash, flags = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
end
local function mkOw(x, y)
return { player = { cellX = x, cellY = y } }
end
-- nil-tolerant: a branch that pushes nothing at all is a failure to
-- report, not a crash that hides every check after it
local function has(box, needle)
return box ~= nil and tostring(box.text):find(needle, 1, true) ~= nil
end
-- answering a box that never offered a choice is the failure mode itself,
-- so report it rather than dying on a nil index and hiding the rest
local function answer(box, yes)
if box and box.opts and box.opts.choice then box.opts.choice(yes) end
end
-- ------------------------------------------------ behind the counter
for _, cell in ipairs(BEHIND) do
local where = ("(%d,%d)"):format(cell[1], cell[2])
local g = mkGame(3000)
clerk(g, mkOw(cell[1], cell[2]), nil, function() end)
local ask = pushed[1]
T.check(has(ask, "sneak"),
where .. " opens the can't-sneak-in-the-back-way box")
T.check(ask.opts ~= nil and ask.opts.choice ~= nil,
where .. " carries the AMBER yes/no choice")
T.check(ask.opts.money == nil,
where .. " raises no money window behind the AMBER question")
T.check(ask.onDone == nil, where .. " chains through the choice, not onDone")
-- YES: .TheresALabSomewhereText
answer(ask, true)
T.check(has(pushed[2], "lab"), where .. " YES gives the resurrection lab line")
-- NO: .AmberIsFossilizedTreeSapText
g = mkGame(3000)
clerk(g, mkOw(cell[1], cell[2]), nil, function() end)
answer(pushed[1], false)
T.check(has(pushed[2], "tree sap"), where .. " NO explains AMBER is tree sap")
T.eq(g.save.money, 3000, where .. " never charges the player")
T.check(not g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET,
where .. " never hands out a ticket")
end
-- the coordinate check runs BEFORE the ticket check, so a ticket holder
-- who wanders round the back still gets told off (asm:45 precedes asm:59)
for _, cell in ipairs(BEHIND) do
local where = ("(%d,%d)"):format(cell[1], cell[2])
local g = mkGame(3000)
g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g, mkOw(cell[1], cell[2]), nil, function() end)
T.check(has(pushed[1], "sneak"),
where .. " with a ticket is still the back-way box, not take-your-time")
answer(pushed[1], false)
T.check(has(pushed[2], "tree sap"),
where .. " with a ticket still answers NO with the tree sap line")
end
-- the done callback reaches the second box on both answers
local g = mkGame(3000)
local doneFired = false
clerk(g, mkOw(13, 4), nil, function() doneFired = true end)
answer(pushed[1], true)
T.check(pushed[2].onDone ~= nil, "the AMBER answer box carries the done callback")
pushed[2].onDone()
T.check(doneFired, "and it chains back out of the conversation")
-- ------------------------------------------------ the brush-off
-- scripts/Museum1F.asm:58: no ticket and not on row 4 means "other side"
local OFF_ROW = { { 12, 5 }, { 13, 3 }, { 12, 2 }, { 11, 3 } }
for _, cell in ipairs(OFF_ROW) do
local where = ("(%d,%d)"):format(cell[1], cell[2])
local gg = mkGame(3000)
clerk(gg, mkOw(cell[1], cell[2]), nil, function() end)
T.check(has(pushed[1], "other side"), where .. " gets the brush-off line")
T.check(pushed[1].opts == nil, where .. " raises no money window")
T.eq(gg.save.money, 3000, where .. " never charges the player")
end
-- with a ticket, off-row talk falls through to take-your-time instead
-- (asm:59 CheckEvent gates the brush-off)
local g2 = mkGame(3000)
g2.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g2, mkOw(12, 5), nil, function() end)
T.check(has(pushed[1], "Take your time"),
"(12,5) with a ticket gets take-your-time, not the brush-off")
-- ------------------------------------------------ the ticket path holds
-- across the counter from the public side is row 4: the money box
local g3 = mkGame(3000)
clerk(g3, mkOw(FRONT[1], FRONT[2]), nil, function() end)
T.check(has(pushed[1], "50"), "(10,4) still opens the child's ticket ask")
T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil,
"and the ask still raises the money window")
answer(pushed[1], true)
T.eq(g3.save.money, 2950, "buying from the front still costs 50")
T.check(g3.save.flags.EVENT_BOUGHT_MUSEUM_TICKET, "and still sets the ticket flag")
local g4 = mkGame(3000)
g4.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g4, mkOw(FRONT[1], FRONT[2]), nil, function() end)
T.check(has(pushed[1], "Take your time"),
"(10,4) with a ticket still gets take-your-time")
T.check(pushed[1].opts == nil, "and no money window on that branch")
-- a caller with no overworld (the two older suites, and any script that
-- talks to the clerk out of band) must keep the pre-#1690 ticket path
local g5 = mkGame(3000)
clerk(g5, nil, nil, function() end)
T.check(has(pushed[1], "50"), "ow == nil still opens the ticket ask")
T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil,
"ow == nil still raises the money window")
-- ------------------------------------------------ the rope onStep path
-- Museum1FDefaultScript calls the clerk over when the player steps onto
-- (9,4) or (10,4); OverworldController passes the player's own cell, so
-- the brush-off must not swallow the ask there
for _, x in ipairs({ 9, 10 }) do
local where = ("(%d,4)"):format(x)
local g6 = mkGame(3000)
local moved = false
local ow = mkOw(x, 4)
ow.scriptMove = function() moved = true end
local handled = M.MUSEUM_1F.onStep(g6, ow, x, 4)
T.check(handled, where .. " on the rope is handled by onStep")
T.check(has(pushed[1], "50"), where .. " on the rope opens the ticket ask")
T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil,
where .. " on the rope keeps the money window")
answer(pushed[1], false)
T.check(has(pushed[2], "Come again"), where .. " declining says come again")
pushed[2].onDone()
T.check(moved, where .. " declining still shoves the player back south (#151)")
end
-- a ticket holder walking the rope is not stopped at all
local g7 = mkGame(3000)
g7.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
local ow7 = mkOw(9, 4)
ow7.scriptMove = function() end
T.check(not M.MUSEUM_1F.onStep(g7, ow7, 9, 4),
"a ticket holder crosses the rope without being stopped")
T.eq(#pushed, 0, "and no box is pushed")
-- ------------------------------------------------ translation reaches it
-- the two new branches must read game.data.text, not bare literals
local TRANSLATED = {
_Museum1FScientist1DoYouKnowWhatAmberIsText = "Pas par derriere !\nL'AMBRE, tu connais ?",
_Museum1FScientist1TheresALabSomewhereText = "Un labo essaie de\nles ressusciter.",
_Museum1FScientist1AmberIsFossilizedTreeSapText = "C'est de la resine\nfossilisee.",
_Museum1FScientist1GoToOtherSideText = "Passe de l'autre\ncote !",
}
local function mkTranslated()
pushed = {}
return {
data = { text = TRANSLATED },
save = { money = 3000, flags = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
end
local g8 = mkTranslated()
clerk(g8, mkOw(13, 4), nil, function() end)
T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1DoYouKnowWhatAmberIsText,
"the back-way box uses the translated AMBER question")
answer(pushed[1], true)
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1TheresALabSomewhereText,
"YES uses the translated lab line")
g8 = mkTranslated()
clerk(g8, mkOw(12, 3), nil, function() end)
answer(pushed[1], false)
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1AmberIsFossilizedTreeSapText,
"NO uses the translated tree sap line")
g8 = mkTranslated()
clerk(g8, mkOw(12, 5), nil, function() end)
T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1GoToOtherSideText,
"the brush-off uses the translated other-side line")
T.finish("museum_clerk_back_entrance_bug1690")
+6 -4
View File
@@ -84,12 +84,14 @@ T.eq(Performance.label(nil), "AUTO", "label nil -> AUTO")
-- -------------------------------------------------------------------- caps
local hi, lo = Performance.CAPS.high, Performance.CAPS.low
T.check(hi.tilt and hi.gbcfx and hi.survey and not hi.fpsMax, "high caps: all on, no fps ceiling")
T.check((not lo.tilt) and (not lo.gbcfx) and (not lo.survey), "low caps: heavy extras off")
T.check(hi.tilt and hi.survey and hi.shaderfx and not hi.fpsMax,
"high caps: all on, no fps ceiling")
T.check((not lo.tilt) and (not lo.survey) and (not lo.shaderfx),
"low caps: heavy extras off")
T.eq(lo.fpsMax, 60, "low caps: FPS ceiling of 60")
local bal = Performance.CAPS.balanced
T.check((not bal.tilt) and (not bal.gbcfx) and bal.survey and not bal.fpsMax,
"balanced caps: no tilt/gbcfx, survey kept, no fps ceiling")
T.check((not bal.tilt) and bal.survey and (not bal.shaderfx) and not bal.fpsMax,
"balanced caps: no tilt, no shaderfx, survey kept, no fps ceiling")
device("Linux", "arm64", 4)
T.eq(Performance.caps("auto"), Performance.CAPS.low, "caps(auto) resolves through detect")
+14 -5
View File
@@ -182,11 +182,20 @@ check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil,
local mainSrc = source("main.lua")
local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n")
check(quitHook ~= nil, "love.quit is still a single top-level function")
quitHook = quitHook or ""
check(quitHook:find('package.loaded["src.core.ChipAudio"].shutdown', 1, true) ~= nil,
"love.quit shuts the chip worker down")
check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= nil,
"love.quit shuts the update worker down")
check(mainSrc:find("SessionLifecycle.endProcess()", 1, true) ~= nil,
"love.quit shuts workers down via SessionLifecycle.endProcess")
local lifecycleSrc = source("src/core/SessionLifecycle.lua")
check(lifecycleSrc:find("registerProcessShutdown", 1, true) ~= nil,
"SessionLifecycle exposes registerProcessShutdown")
check(lifecycleSrc:find("function SessionLifecycle.endProcess()", 1, true) ~= nil,
"SessionLifecycle.endProcess fans out registered hooks")
check(source("src/core/ChipAudio.lua"):find("registerProcessShutdown(ChipAudio.shutdown)", 1, true) ~= nil,
"ChipAudio registers its shutdown hook at load")
check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdown)", 1, true) ~= nil,
"Check registers its shutdown hook at load")
check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
"Fetch registers its shutdown hook at load")
-- The Android half: LOVE keeps the JVM process after the native main returns,
-- so the quit event exits the process outright. It has to sit after the
+231
View File
@@ -0,0 +1,231 @@
-- The cache contract is the shared Lua-side publication boundary. A writer
-- may stage outputs in any order, but readiness is published only after the
-- version-specific required set exists.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local CacheContract = require("src.import.CacheContract")
local fs = { prefix = "initial/", files = {} }
local writes = {}
function fs.exists(path)
return fs.files[fs.prefix .. path] ~= nil
end
function fs.read(path)
return fs.files[fs.prefix .. path]
end
function fs.write(path, value)
writes[#writes + 1] = fs.prefix .. path
fs.files[fs.prefix .. path] = value
return true
end
function fs.remove(path)
fs.files[fs.prefix .. path] = nil
end
local required, isOverride = CacheContract.requiredFilesFor("red")
check(not isOverride, "Red uses the shared required-file list")
check(#required > 0, "Red has required outputs")
eq(CacheContract.markerFor("red"),
CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
"marker contains format and Red SHA-1")
for index = 1, #required - 1 do
fs.files["red/" .. required[index]] = true
end
local complete, missing = CacheContract.allRequiredFilesExist("red", fs)
check(not complete, "missing output keeps cache incomplete")
eq(missing, required[#required], "missing output is reported")
local published, publishError = CacheContract.publish("red", fs)
check(not published, "incomplete cache is not published")
check(publishError ~= nil, "incomplete publication explains the missing output")
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
"incomplete cache has no completion marker")
-- Publication must remove a stale marker left by an interrupted replacement,
-- and must restore the caller prefix on both the success and failure paths.
fs.files["red/" .. CacheContract.MARKER_PATH] = "old-marker"
local removedMarker = CacheContract.publish("red", fs)
check(not removedMarker, "incomplete retry is still rejected")
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
"incomplete retry removes a stale completion marker")
eq(fs.prefix, "initial/", "incomplete publication restores the caller prefix")
fs.files["red/" .. required[#required]] = true
fs.prefix = "caller/prefix/"
fs.files["red/" .. required[#required]] = true
for _, path in ipairs(required) do fs.files["red/" .. path] = true end
published, publishError = CacheContract.publish("red", fs)
check(published, "complete cache is published")
eq(publishError, nil, "complete publication has no error")
eq(fs.prefix, "caller/prefix/", "publication restores the caller prefix")
eq(fs.files["red/" .. CacheContract.MARKER_PATH], CacheContract.markerFor("red"),
"marker is written under the version prefix")
local marker = CacheContract.readMarker("red", fs)
eq(marker, CacheContract.markerFor("red"), "marker reads through the version prefix")
eq(writes[#writes], "red/" .. CacheContract.MARKER_PATH,
"the marker is the only publication write and comes last")
-- Every supported version gets its own marker and complete cache semantics;
-- Yellow adds its three outputs, while Gold/Silver replace the Gen 1 set.
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
local versionFiles, override = CacheContract.requiredFilesFor(version)
for _, path in ipairs(versionFiles) do
fs.files[version .. "/" .. path] = true
end
if not override then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
fs.files[version .. "/" .. path] = true
end
end
local ready, missing = CacheContract.allRequiredFilesExist(version, fs)
check(ready, version .. " complete cache is ready (" .. tostring(missing) .. ")")
local didPublish = CacheContract.publish(version, fs)
check(didPublish, version .. " complete cache publishes")
eq(fs.files[version .. "/" .. CacheContract.MARKER_PATH],
CacheContract.markerFor(version), version .. " marker is version-scoped")
eq(fs.prefix, "caller/prefix/", version .. " publication restores prefix")
check(CacheContract.isReady(version, fs), version .. " complete cache is ready")
end
local gold, goldOverride = CacheContract.requiredFilesFor("gold")
check(goldOverride, "Gold uses a version-specific required set")
local goldSet = {}
for _, path in ipairs(gold) do goldSet[path] = true end
check(goldSet["assets/generated/battle/hud/balls.png"],
"Gold required set includes trainer HUD art")
check(not goldSet["assets/generated/trade/game_boy.png"],
"Gold required set excludes Gen 1 trade art")
check(goldSet["data/generated/rom_text.lua"],
"Gold required set includes the Gen 2 engine text table")
local silver = CacheContract.requiredFilesFor("silver")
local silverSet = {}
for _, path in ipairs(silver) do silverSet[path] = true end
check(silverSet["data/generated/rom_text.lua"],
"Silver required set includes the Gen 2 engine text table")
check(not silverSet["assets/generated/trade/game_boy.png"],
"Silver required set excludes Gen 1 trade art")
check(CacheContract.VERSION_REQUIRED_FILES.yellow ~= nil,
"Yellow has version-specific required outputs")
-- Revisioned cache markers: Crystal accepts either the 1.0 or the 1.1 cart.
local CRYSTAL_1_0 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"
local CRYSTAL_1_1 = "f2f52230b536214ef7c9924f483392993e226cfb"
local CRYSTAL_FORMAT = CacheContract.formatFor("crystal")
eq(CacheContract.markerFor("crystal", CRYSTAL_1_1),
CRYSTAL_FORMAT .. CRYSTAL_1_1,
"markerFor with an explicit sha1 uses that sha1, not the canonical one")
eq(CacheContract.markerFor("crystal"), CRYSTAL_FORMAT .. CRYSTAL_1_0,
"markerFor with no sha1 still defaults to the canonical (1.0) sha1")
check(CacheContract.markerMatches("crystal", CRYSTAL_FORMAT .. CRYSTAL_1_0),
"a marker written from the 1.0 hash matches crystal")
check(CacheContract.markerMatches("crystal", CRYSTAL_FORMAT .. CRYSTAL_1_1),
"a marker written from the 1.1 hash also matches crystal")
check(not CacheContract.markerMatches("crystal",
CRYSTAL_FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a"),
"a marker written from Red's hash does not match crystal")
check(not CacheContract.markerMatches("red", CRYSTAL_FORMAT .. CRYSTAL_1_1),
"a marker written from Crystal's 1.1 hash does not match red")
check(not CacheContract.markerMatches("crystal",
CacheContract.FORMAT .. CRYSTAL_1_0),
"a crystal marker written on the shared format no longer matches")
local crystalFs = { prefix = "crystal-marker-test/", files = {} }
function crystalFs.exists(path) return crystalFs.files[crystalFs.prefix .. path] ~= nil end
function crystalFs.read(path) return crystalFs.files[crystalFs.prefix .. path] end
function crystalFs.write(path, value)
crystalFs.files[crystalFs.prefix .. path] = value
return true
end
function crystalFs.remove(path) crystalFs.files[crystalFs.prefix .. path] = nil end
local crystalRequired = CacheContract.requiredFilesFor("crystal")
for _, path in ipairs(crystalRequired) do
crystalFs.files["crystal/" .. path] = true
end
local published11 = CacheContract.publish("crystal", crystalFs, CRYSTAL_1_1)
check(published11, "publishing with the 1.1 sha1 succeeds")
eq(crystalFs.files["crystal/" .. CacheContract.MARKER_PATH],
CRYSTAL_FORMAT .. CRYSTAL_1_1, "the marker records the 1.1 sha1")
check(CacheContract.isReady("crystal", crystalFs),
"a cache published with the 1.1 sha1 still reads ready for crystal")
crystalFs.files["crystal/" .. CacheContract.MARKER_PATH] =
CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
check(not CacheContract.isReady("crystal", crystalFs),
"a marker from another version's hash does not read ready for crystal")
check(CacheContract.markerMatches("blue", CacheContract.markerFor("blue")),
"blue's own marker still matches blue")
check(not CacheContract.markerMatches("blue", CacheContract.markerFor("red")),
"red's marker still does not match blue")
-- A throwing adapter must not strand the process in its temporary prefix.
local throwingFs = { prefix = "before/" }
function throwingFs.exists() error("probe failed") end
local probed, probeError = CacheContract.allRequiredFilesExist("blue", throwingFs)
check(not probed and probeError ~= nil, "filesystem probe errors are returned")
eq(throwingFs.prefix, "before/", "probe errors restore the caller prefix")
function throwingFs.write() error("write failed") end
function throwingFs.remove() end
for _, path in ipairs(CacheContract.REQUIRED_FILES) do
throwingFs.files = throwingFs.files or {}
throwingFs.files["blue/" .. path] = true
end
function throwingFs.exists(path)
return throwingFs.files[throwingFs.prefix .. path] ~= nil
end
local wrote = CacheContract.publish("blue", throwingFs)
check(not wrote, "write errors are returned")
eq(throwingFs.prefix, "before/", "write errors restore the caller prefix")
-- Source-tree readiness must use the same version lists and reject a cache
-- when LÖVE cannot identify a real source directory.
local oldLove = love
love = nil
check(not CacheContract.sourceTreeHasData("red"),
"source-tree check is safe without LÖVE")
local sourceFiles = {}
love = {
filesystem = {
getRealDirectory = function(path) return sourceFiles[path] end,
getSource = function() return "/source" end,
getInfo = function(path)
return sourceFiles[path] and { type = "file" } or nil
end,
},
}
for _, path in ipairs(CacheContract.REQUIRED_FILES) do sourceFiles[path] = "/source" end
check(CacheContract.sourceTreeHasData("red"),
"Red source tree uses the shared required set")
sourceFiles[CacheContract.REQUIRED_FILES[2]] = "/save"
check(not CacheContract.sourceTreeHasData("red"),
"source-tree readiness rejects a cache-overlaid required file")
sourceFiles = {}
local goldFiles = CacheContract.requiredFilesFor("gold")
for _, path in ipairs(goldFiles) do sourceFiles["gold/" .. path] = "/source" end
check(CacheContract.sourceTreeHasData("gold"),
"Gold source tree uses its override set")
love = oldLove
-- Both importer completion paths must call the shared publication boundary.
local importerFile = assert(io.open("src/import/RomImporter.lua", "r"))
local importerSource = importerFile:read("*a")
importerFile:close()
local completionCalls = 0
for _ in importerSource:gmatch("CacheContract%.publish%(%s*version") do
completionCalls = completionCalls + 1
end
eq(completionCalls, 1,
"both thread and coroutine paths converge on one publishing helper")
check(importerSource:find("self:_completeImport%(version, prefix, displayName%)")
~= nil, "coroutine completion uses the shared helper")
check(importerSource:find("pcall%(self%._completeImport") ~= nil,
"thread completion uses the shared helper")
T.finish("rom cache contract")
@@ -57,9 +57,10 @@ end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
local GameVersion = require("src.core.GameVersion")
local function clearSavesInbox()
for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
for _, ver in ipairs(GameVersion.ORDER) do
local dir = "imports/saves/" .. ver
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name)
+24 -26
View File
@@ -1,36 +1,34 @@
-- Shared cache readiness uses the selected version's source-tree contract.
-- sourceTreeHasData must use the engine-owned cache contract. Gold's cache has
-- no Gen 1 trade art; validating it against the Gen 1 list made a Gold source
-- tree look incomplete forever.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local CacheContract = require("src.import.CacheContract")
local GameVersion = require("src.core.GameVersion")
local present = {}
for _, path in ipairs(CacheContract.requiredFiles("gold")) do
present[GameVersion.cachePrefix("gold") .. path] = true
end
local fs = {
read = function() return nil end,
getInfo = function(path) return present[path] and { type = "file" } or nil end,
getRealDirectory = function(path) return present[path] and "/source" or nil end,
getSource = function() return "/source" end,
}
local f = assert(io.open("src/import/RomImporter.lua", "r"))
local src = f:read("*a")
f:close()
local inspected = CacheContract.inspect("gold", fs, { allowSource = true })
T.eq(inspected and inspected.kind, "source",
"Gold source tree uses Gold's required-file contract")
local readyStart = src:find("function RomImporter.isReady", 1, true)
check(readyStart ~= nil, "isReady is defined")
local readyEnd = src:find("\nfunction RomImporter.syncAndroidShortcuts", readyStart, true)
check(readyEnd ~= nil, "isReady ends before the next importer helper")
local readyBody = src:sub(readyStart, readyEnd)
present["gold/assets/generated/battle/hud/balls.png"] = nil
T.eq(CacheContract.inspect("gold", fs, { allowSource = true }), nil,
"missing Gold-only trainer HUD asset makes the source tree incomplete")
check(readyBody:find("CacheContract.isReady", 1, true) ~= nil,
"isReady delegates source-tree and cache readiness to the contract")
check(readyBody:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
"isReady does not iterate the Gen 1 REQUIRED_FILES list raw")
local required = {}
for _, path in ipairs(CacheContract.requiredFiles("gold")) do required[path] = true end
T.eq(required["data/generated/rom_text.lua"], true,
"Gold requires generated ROM text")
T.eq(required["assets/generated/pc/mail_item.png"], true,
"Gold requires PC mail art")
T.eq(required["assets/generated/trade/game_boy.png"], nil,
"Gold does not inherit the Gen 1 trade-art contract")
local required, isOverride = CacheContract.requiredFilesFor("gold")
check(isOverride, "Gold uses the override required-file list")
local requiredSet = {}
for _, path in ipairs(required) do requiredSet[path] = true end
check(requiredSet["assets/generated/battle/hud/balls.png"],
"Gold caches require the trainer HUD ball sheet")
check(not requiredSet["assets/generated/trade/game_boy.png"],
"Gold does not inherit the Gen 1 trade-art requirement")
T.finish()
+11 -7
View File
@@ -83,12 +83,16 @@ if extractor then
"field.lua publishes tradeArt")
end
-- a cache imported before #750 has none of the art; the shared readiness
-- contract is what makes it re-import.
local CacheContract = require("src.import.CacheContract")
local required = {}
for _, path in ipairs(CacheContract.requiredFiles("red")) do required[path] = true end
T.eq(required["assets/generated/trade/game_boy.png"], true,
"required-file contract makes pre-#750 caches re-import the trade art")
-- a cache imported before #750 has none of the art; listing one of the
-- files in the engine-owned cache contract is what makes it re-import
local contract = readFile("src/import/CacheContract.lua")
T.check(contract ~= nil, "src/import/CacheContract.lua is readable")
if contract then
local required = contract:match("CacheContract.REQUIRED_FILES = {(.-)\n}")
T.check(required ~= nil, "CacheContract.REQUIRED_FILES parses")
T.check(required ~= nil and required:find(
'"assets/generated/trade/game_boy.png"', 1, true) ~= nil,
"cache contract makes pre-#750 caches re-import the trade art")
end
T.finish("trade art import")
+2 -2
View File
@@ -82,7 +82,7 @@ local function importer(ready)
end
local allReady = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
silver = true, crystal = true })
allReady:_queueBaseRomScan()
eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery")
eq(listings, 0, "ready launcher does not enumerate baseroms")
@@ -125,7 +125,7 @@ missing:choose("red")
eq(picks, 1, "the next import attempt falls back to the native picker")
local rescanned = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
silver = true, crystal = true })
rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
rescanned:reimport("red")
check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM")