mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-02 07:50:13 -04:00
772 lines
35 KiB
Lua
772 lines
35 KiB
Lua
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
love = love or require("tests.love_stub")
|
|
|
|
local T = require("tests.harness")
|
|
local check, eq, same = T.check, T.eq, T.same
|
|
|
|
local K = require("tests.save_compat._codec")
|
|
local G2 = require("tests.fixtures.save.gen2_build")
|
|
local B = require("tests.fixtures.save.bytes")
|
|
local Gen2Save = require("src.save_convert.Gen2Save")
|
|
local Gen2State = require("src.save_convert.Gen2State")
|
|
local P = require("src.save_convert.gen2_state.progress")
|
|
local Save = require("src.core.gen2.Save")
|
|
local HallOfFame = require("src.core.gen2.HallOfFame")
|
|
local NpcTrade = require("src.core.gen2.NpcTrade")
|
|
local Decorations = require("src.core.gen2.Decorations")
|
|
local BattleTower = require("src.core.gen2.BattleTower")
|
|
local Mon = require("src.battle.gen2.Mon")
|
|
local World = require("src.world.gen2.World")
|
|
local Vm = require("src.script.gen2.Vm")
|
|
local Events = require("src.world.gen2.Events")
|
|
local Pokegear = require("src.ui.gen2.Pokegear")
|
|
local Music = require("src.core.Music")
|
|
|
|
local VERSIONS = { "gold", "silver", "crystal" }
|
|
|
|
local REGION_NAMES = {
|
|
"savedAtLeastOnce", "lastDexModeAndRegistered", "hallOfFameCount", "tradeFlags",
|
|
"decorations", "spawnAndBackupMap", "mysteryGift", "linkBattleStats", "hallOfFame",
|
|
"gsBallFlag", "gsBallFlagBackup", "crystalData", "battleTower",
|
|
}
|
|
|
|
local DATA = {
|
|
items = {},
|
|
pokemon = {
|
|
CYNDAQUIL = { index = 155, dex = 155, name = "CYNDAQUIL", genderRatio = 0x1F },
|
|
TOTODILE = { index = 158, dex = 158, name = "TOTODILE", genderRatio = 0x1F },
|
|
CHIKORITA = { index = 152, dex = 152, name = "CHIKORITA", genderRatio = 0x1F },
|
|
PIKACHU = { index = 25, dex = 25, name = "PIKACHU", genderRatio = 0x7F },
|
|
STARMIE = { index = 121, dex = 121, name = "STARMIE", genderRatio = 0xFF },
|
|
TYRANITAR = { index = 248, dex = 248, name = "TYRANITAR", genderRatio = 0x7F },
|
|
},
|
|
maps = {
|
|
[K.GEN2_MAP] = K.gen2Data.maps[K.GEN2_MAP],
|
|
TEST_CITY = { group = 3, map = 1 },
|
|
TEST_CENTER = { group = 3, map = 4 },
|
|
TEST_CENTER_2F = { group = 20, map = 1 },
|
|
TEST_CAVE = { group = 7, map = 9 },
|
|
},
|
|
}
|
|
for id, def in pairs(G2.ITEMS) do DATA.items[id] = def end
|
|
DATA.items.MAX_POTION.canSelect = true
|
|
DATA.items.POTION.canSelect = true
|
|
DATA.items.BICYCLE.canSelect = true
|
|
|
|
local function symsFor(v) return Gen2State.symsFor(v) end
|
|
local function ctxFor(v) return { S = symsFor(v), crystal = v == "crystal" } end
|
|
|
|
local function import(v, bytes)
|
|
local save, err = Gen2Save.decode(bytes, v, DATA)
|
|
check(save ~= nil, v .. " import -- " .. tostring(err))
|
|
return save
|
|
end
|
|
|
|
local function export(v, save, template)
|
|
local out, err = Gen2Save.encode(save, v, template, DATA)
|
|
check(out ~= nil, v .. " export -- " .. tostring(err))
|
|
return out, err
|
|
end
|
|
|
|
local function u8(s, at) return s:byte(at + 1) end
|
|
|
|
local function regionBytes(s, spans)
|
|
local out = {}
|
|
for _, sp in ipairs(spans) do out[#out + 1] = s:sub(sp[1] + 1, sp[1] + sp[2]) end
|
|
return table.concat(out)
|
|
end
|
|
|
|
local function hex(s) return (s:gsub(".", function(c) return ("%02X"):format(c:byte()) end)) end
|
|
|
|
local function activeRegions(v)
|
|
local out = {}
|
|
for _, region in ipairs(P.REGIONS) do
|
|
local spans = P.spansFor(ctxFor(v), region.id)
|
|
if spans then out[#out + 1] = { id = region.id, spans = spans } end
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function teamRow(b, base, win, mons)
|
|
B.fill(b, base, 0x62, 0)
|
|
B.put(b, base, win)
|
|
for i, m in ipairs(mons) do
|
|
local o = base + 1 + (i - 1) * 0x10
|
|
B.put(b, o, m[1])
|
|
B.be(b, o + 1, m[2], 2)
|
|
B.put(b, o + 3, m[3], m[4], m[5])
|
|
B.putGbName(b, o + 6, m[6], 10)
|
|
end
|
|
B.put(b, base + 1 + #mons * 0x10, 0xFF)
|
|
end
|
|
|
|
local PAINT = {}
|
|
|
|
PAINT.realistic = function(v)
|
|
return function(b, L)
|
|
local S = symsFor(v)
|
|
B.put(b, S.wSavedAtLeastOnce, 1)
|
|
B.put(b, S.wSpawnAfterChampion, 1)
|
|
B.put(b, S.wRadioTuningKnob, 16)
|
|
B.put(b, S.wLastDexMode, 2, 0)
|
|
B.put(b, S.wWhichRegisteredItem, 0x01, G2.ITEMS.POTION.index)
|
|
B.put(b, S.wHallOfFameCount, 2, 0)
|
|
teamRow(b, S.sHallOfFame, 2, { { 155, 0x1234, 0x9F, 0x6A, 50, "FLAME" }, { 25, 0x1234, 0xAA, 0xAA, 48, "SPARKY" } })
|
|
teamRow(b, S.sHallOfFame + 0x62, 1, { { 155, 0x1234, 0x9F, 0x6A, 45, "FLAME" } })
|
|
B.put(b, S.wTradeFlags, 0x05)
|
|
B.put(b, S.wMooMooBerries, 3)
|
|
B.put(b, S.wUndergroundSwitchPositions, 0x0F)
|
|
if S.wFarfetchdPosition then B.put(b, S.wFarfetchdPosition, 5) end
|
|
if S.wJackFightCount then
|
|
B.put(b, S.wJackFightCount, 1, 0, 2, 3, 0, 1, 4, 2)
|
|
B.put(b, S.wErinFightCount, 3)
|
|
end
|
|
if S.wCelebiEvent then B.put(b, S.wCelebiEvent, 0x04) end
|
|
B.put(b, S.wBikeFlags, 0x02)
|
|
B.put(b, S.wDecoBed, 3, 8, 13, 16, 22, 30, 31, 26)
|
|
B.put(b, S.wDigWarpNumber, 2, 3, 1, 1, 3, 4)
|
|
B.put(b, S.wLastSpawnMapGroup, 3, 1)
|
|
B.put(b, S.wWarpNumber, 2)
|
|
B.put(b, S.sMysteryGiftTrainerHouseFlag, 1)
|
|
B.putGbName(b, S.sMysteryGiftPartnerName, "KEN", 11)
|
|
B.put(b, S.sMysteryGiftTimer, 0, 7)
|
|
B.put(b, S.sMysteryGiftDecorationsReceived, 0x21)
|
|
B.be(b, S.sLinkBattleStats, 3, 2)
|
|
B.be(b, S.sLinkBattleStats + 2, 1, 2)
|
|
if S.sGSBallFlag then
|
|
B.put(b, S.sGSBallFlag, 0x0B)
|
|
B.put(b, S.sGSBallFlagBackup, 0x0B)
|
|
B.put(b, S.sCrystalData + 1, 21, 13, 0x12, 0x34, 0x56, 0x78)
|
|
B.put(b, S.sBattleTowerChallengeState, 2, 3, 5)
|
|
B.put(b, S.sBTTrainers, 4, 9, 12, 0xFF, 0xFF, 0xFF, 0xFF)
|
|
B.put(b, S.sBattleTowerSaveFileFlags, 3, G2.ITEMS.MAX_POTION.index)
|
|
B.put(b, S.sBTMonOfTrainers, 155, 158, 152, 25, 248, 121)
|
|
end
|
|
end
|
|
end
|
|
|
|
PAINT.glitch = function(v)
|
|
return function(b, L)
|
|
local S = symsFor(v)
|
|
B.put(b, S.wSavedAtLeastOnce, 0x07)
|
|
B.put(b, S.wSpawnAfterChampion, 0x09)
|
|
B.put(b, S.wRadioTuningKnob, 0x81)
|
|
B.put(b, S.wLastDexMode, 7, 0xAA)
|
|
B.put(b, S.wWhichRegisteredItem, 0x45, 0x99)
|
|
B.put(b, S.wHallOfFameCount, 0xC8, 0x5A)
|
|
teamRow(b, S.sHallOfFame, 0xC8, { { 252, 0xFFFF, 0x12, 0x34, 0, "AB" } })
|
|
B.put(b, S.sHallOfFame + 1 + 6 + 3, 0x7E, 0x00, 0x13)
|
|
B.put(b, S.sHallOfFame + 0x30, 0x66)
|
|
B.put(b, S.sHallOfFame + 0x62 * 3 + 5, 0x77)
|
|
B.put(b, S.wTradeFlags, 0xFF)
|
|
for i = 1, (S.wMooMooBerries - S.wTradeFlags - 1) do B.put(b, S.wTradeFlags + i, (i * 31) % 256) end
|
|
B.put(b, S.wMooMooBerries, 0xFE)
|
|
B.put(b, S.wUndergroundSwitchPositions, 0x80)
|
|
if v ~= "crystal" then B.put(b, S.wUndergroundSwitchPositions + 1, 0x11, 0x22) end
|
|
if S.wFarfetchdPosition then B.put(b, S.wFarfetchdPosition, 0x33, 0x44, 0x55) end
|
|
if S.wJackFightCount then
|
|
for i = 0, 27 do B.put(b, S.wJackFightCount + i, (i * 67 + 200) % 256) end
|
|
end
|
|
if S.wCelebiEvent then B.put(b, S.wCelebiEvent, 0xFB, 0x91) end
|
|
B.put(b, S.wBikeFlags, 0xF8, 0x3C)
|
|
B.put(b, S.wDecoBed, 0xFF, 0, 0xFE, 0, 0x80, 0x7F, 0x01, 0x99)
|
|
B.put(b, S.wDigWarpNumber, 0x90, 0x77, 0x66, 0x55, 0x77, 0x44, 0xDE, 0xAD, 0xBE)
|
|
B.put(b, S.wLastSpawnMapGroup, 0x77, 0x76)
|
|
if v ~= "crystal" then B.put(b, S.wLastSpawnMapGroup + 2, 0x12, 0x34) end
|
|
B.put(b, S.wWarpNumber, 0xEE)
|
|
for i = 0, (S.sBackupMysteryGiftItemEnd - S.sMysteryGiftData) - 1 do
|
|
B.put(b, S.sMysteryGiftData + i, (i * 53 + 7) % 256)
|
|
end
|
|
for i = 0, (S.sLinkBattleStatsEnd - S.sLinkBattleStats) - 1 do
|
|
B.put(b, S.sLinkBattleStats + i, (i * 17 + 3) % 256)
|
|
end
|
|
if S.sGSBallFlag then
|
|
B.put(b, S.sGSBallFlag, 0x05)
|
|
B.put(b, S.sGSBallFlagBackup, 0x0B)
|
|
B.put(b, S.sCrystalData + 1, 0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA)
|
|
for i = 0, 17 do B.put(b, S.sBattleTowerChallengeState + i, (i * 41 + 9) % 256) end
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, v in ipairs(VERSIONS) do
|
|
for _, kind in ipairs({ "realistic", "glitch" }) do
|
|
local bytes = G2.build({ version = v, patch = PAINT[kind](v) })
|
|
local save = import(v, bytes)
|
|
if save then
|
|
local out = export(v, save, bytes)
|
|
local fresh1, fresh2
|
|
local copy = {}
|
|
for k, x in pairs(save) do copy[k] = x end
|
|
copy.rawImport = nil
|
|
fresh1 = export(v, copy, nil)
|
|
local back = fresh1 and import(v, fresh1)
|
|
fresh2 = back and export(v, back, nil)
|
|
for _, r in ipairs(activeRegions(v)) do
|
|
local label = ("%s %s %s"):format(v, kind, r.id)
|
|
local want = regionBytes(bytes, r.spans)
|
|
if out then eq(hex(regionBytes(out, r.spans)), hex(want), label .. ": R1 over the template is byte-identical") end
|
|
if fresh1 then eq(hex(regionBytes(fresh1, r.spans)), hex(want), label .. ": templateless export reproduces the cart bytes") end
|
|
if fresh2 then eq(hex(regionBytes(fresh2, r.spans)), hex(regionBytes(fresh1, r.spans)), label .. ": templateless export is a fixed point") end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, v in ipairs(VERSIONS) do
|
|
local S = symsFor(v)
|
|
local save = import(v, G2.build({ version = v, patch = PAINT.realistic(v) }))
|
|
local gs = v ~= "crystal"
|
|
eq(save.spawnAfterChampion, "SPAWN_LANCE", v .. " wSpawnAfterChampion 1 is SPAWN_LANCE")
|
|
eq(save.radioTuningKnob, 16, v .. " wRadioTuningKnob")
|
|
eq(save.lastDexMode, "A-Z", v .. " wLastDexMode DEXMODE_ABC")
|
|
same(save.registeredItem, { id = "POTION" }, v .. " wRegisteredItem")
|
|
eq(save.hallOfFame.count, 2, v .. " wHallOfFameCount")
|
|
eq(#save.hallOfFame.teams, 2, v .. " sHallOfFame rows up to the first zero win count")
|
|
eq(save.hallOfFame.entered, true, v .. " a counted Hall of Fame has been entered")
|
|
local m = save.hallOfFame.teams[1].mons[2]
|
|
eq(m.species, "PIKACHU", v .. " HoF species")
|
|
eq(m.nickname, "SPARKY", v .. " HoF nickname")
|
|
eq(m.level, 48, v .. " HoF level")
|
|
eq(m.otId, 0x1234, v .. " HoF ID")
|
|
eq(m.shiny, true, v .. " HoF shiny from DVs")
|
|
same(m.dvs, { attack = 10, defense = 10, speed = 10, special = 10, hp = 0 }, v .. " HoF DVs")
|
|
same(save.tradeFlags, { [0] = true, [2] = true }, v .. " wTradeFlags")
|
|
local mem = gs and { [0xD6A7] = 3, [0xD6A8] = 0x0F }
|
|
or { [0xD962] = 3, [0xD963] = 0x0F, [0xD964] = 5, [0xD9F2] = 1, [0xD9F5] = 3, [0xD9F8] = 4, [0xDA0D] = 3 }
|
|
if not gs then
|
|
eq(save.scriptMem[0xD9F3], nil, v .. " a zero fight count is not stored")
|
|
eq(P.wramOf(ctxFor(v), "wJackFightCount"), 0xD9F2, v .. " wJackFightCount derives to the scripts' $D9F2")
|
|
eq(P.wramOf(ctxFor(v), "wErinFightCount"), 0xDA0D, v .. " wErinFightCount derives to the scripts' $DA0D")
|
|
end
|
|
for addr, val in pairs(mem) do eq(save.scriptMem[addr], val, ("%s scriptMem $%04X"):format(v, addr)) end
|
|
local bike = gs and 24 or 25
|
|
eq(save.engineFlags[bike], true, v .. " BIKEFLAGS_ALWAYS_ON_BIKE_F is ENGINE_ALWAYS_ON_BIKE")
|
|
if not gs then eq(save.engineFlags[100], true, v .. " ENGINE_FOREST_IS_RESTLESS") end
|
|
same(save.decorations, { bed = 3, carpet = 8, plant = 13, poster = 16, console = 22,
|
|
leftOrnament = 30, rightOrnament = 31, bigDoll = 26 }, v .. " wDeco*")
|
|
same(save.backupWarp, { warp = 1, map = "TEST_CENTER" }, v .. " wBackupWarpNumber triple")
|
|
eq(save.blackoutMap, "TEST_CITY", v .. " wLastSpawnMapGroup pair")
|
|
same(save.mysteryGift, { trainerHouse = true, partnerName = "KEN" }, v .. " sMysteryGiftTrainerHouseFlag")
|
|
if gs then
|
|
eq(save.battleTower, nil, v .. " has no Battle Tower")
|
|
eq(save.crystal, nil, v .. " has no Crystal block")
|
|
else
|
|
eq(save.crystal.gsBall, "have", v .. " sGSBallFlag GS_BALL_AVAILABLE")
|
|
same(save.battleTower, {
|
|
challenge = 2, streak = 3, levelGroup = 5, trainers = { 4, 9, 12 }, saveFileFlags = 3,
|
|
reward = "MAX_POTION", best = 0, inChallenge = false, reentry = false,
|
|
prevTeams = { prev = { "CYNDAQUIL", "TOTODILE", "CHIKORITA" }, prevPrev = { "PIKACHU", "TYRANITAR", "STARMIE" } },
|
|
}, v .. " SRAM Battle Tower")
|
|
end
|
|
check(type(save[P.CARRIER]) == "table", v .. " carries the raw region bytes")
|
|
check(S.sHallOfFame ~= nil, v .. " sHallOfFame resolves through Gen2Syms")
|
|
end
|
|
|
|
for _, v in ipairs(VERSIONS) do
|
|
local save = import(v, G2.build({ version = v, patch = PAINT.glitch(v) }))
|
|
eq(save.spawnAfterChampion, nil, v .. " an unknown wSpawnAfterChampion is an ordinary continue")
|
|
eq(save.lastDexMode, "NEW", v .. " an unknown wLastDexMode reads as DEXMODE_NEW")
|
|
same(save.registeredItem, { id = 0x99 }, v .. " an unnamed registered item keeps its raw id")
|
|
eq(save.backupWarp, nil, v .. " an unknown backup map is not handed to the engine")
|
|
eq(save.blackoutMap, nil, v .. " an unknown whiteout map is not handed to the engine")
|
|
same(save.tradeFlags, v == "crystal" and { [0] = true, [1] = true, [2] = true, [3] = true, [4] = true, [5] = true, [6] = true }
|
|
or { [0] = true, [1] = true, [2] = true, [3] = true, [4] = true, [5] = true },
|
|
v .. " wTradeFlags bits past NUM_NPC_TRADES stay raw")
|
|
eq(save.hallOfFame.teams[1].mons[1].species, 252, v .. " an unknown HoF species keeps its number")
|
|
end
|
|
|
|
local function baseSave(v)
|
|
local s = Save.newGame({ playerName = "ASH", trainerId = 0x4242 })
|
|
s.version = v
|
|
s.position = { map = K.GEN2_MAP, x = 3, y = 4 }
|
|
s.inventory = { POTION = 3, BICYCLE = 1, POKE_BALL = 5 }
|
|
s.bagOrder = { "POTION", "BICYCLE", "POKE_BALL" }
|
|
return Save.normalize(s)
|
|
end
|
|
|
|
local function partyMon(species, level, nick, dvs, otId)
|
|
local mon = { species = species, level = level, nickname = nick, otId = otId, dvs = dvs }
|
|
dvs.hp = Mon.hpDV(dvs)
|
|
return Mon.syncIdentity(mon, DATA)
|
|
end
|
|
|
|
local function drive(script)
|
|
local vm = Vm.new({ ["s:t"] = script }, {}, Events.new(), {
|
|
showText = function(_, onDone) onDone() end, waitSfx = function() return true end,
|
|
})
|
|
vm:start("s:t")
|
|
for _ = 1, 200 do
|
|
if not vm:running() then break end
|
|
vm:update()
|
|
end
|
|
return vm
|
|
end
|
|
|
|
local function fakeWorld(save)
|
|
return setmetatable({ game = { save = save, data = DATA }, maps = DATA.maps }, { __index = World })
|
|
end
|
|
|
|
local function decoView(state)
|
|
local out = {}
|
|
for _, slot in ipairs(Decorations.SLOTS) do out[slot] = (state or {})[slot] or 0 end
|
|
return out
|
|
end
|
|
|
|
local ROSTER = {
|
|
sampleTrainers = 3,
|
|
trainers = { { index = 0, name = "A" }, { index = 1, name = "B" }, { index = 2, name = "C" } },
|
|
groups = { [1] = { { species = "CYNDAQUIL" }, { species = "TOTODILE" }, { species = "CHIKORITA" },
|
|
{ species = "PIKACHU" } } },
|
|
}
|
|
|
|
local function engineBuilt(v)
|
|
local save = baseSave(v)
|
|
local crystal = v == "crystal"
|
|
local party = {
|
|
partyMon("CYNDAQUIL", 50, "FLAME", { attack = 9, defense = 15, speed = 6, special = 10 }, 0x4242),
|
|
{ species = "TOTODILE", isEgg = true, level = 5, dvs = { attack = 1, defense = 1, speed = 1, special = 1 } },
|
|
partyMon("PIKACHU", 47, "SPARKY", { attack = 10, defense = 10, speed = 10, special = 10 }, 0x0101),
|
|
}
|
|
HallOfFame.induct(save, party)
|
|
HallOfFame.induct(save, { partyMon("TYRANITAR", 55, "ROCKY", { attack = 3, defense = 4, speed = 5, special = 6 }, 0x4242) })
|
|
HallOfFame.markRedCredits(save)
|
|
NpcTrade.markDone(save, 1)
|
|
NpcTrade.markDone(save, 4)
|
|
local deco = Decorations.state(save)
|
|
Decorations.apply(deco, 4)
|
|
Decorations.apply(deco, 8)
|
|
Decorations.apply(deco, 15)
|
|
Decorations.apply(deco, 30, "left")
|
|
Decorations.clearOtherSide(deco, 30, "left")
|
|
Decorations.apply(deco, 26)
|
|
check(World.registerItem(fakeWorld(save), "BICYCLE"), v .. " World:registerItem accepts the BICYCLE")
|
|
local w = fakeWorld(save)
|
|
World.setBlackoutMap(w, 3, 1)
|
|
World.recordWarpBackup(w, "TEST_CENTER", 2, { destWarp = 0xFF }, "TEST_CENTER_2F")
|
|
save.backupWarp = w.backupWarp
|
|
local S = symsFor(v)
|
|
local berries = crystal and 0xD962 or 0xD6A7
|
|
local switches = crystal and 0xD963 or 0xD6A8
|
|
local vm = drive({
|
|
{ op = "readmem", args = { switches % 256, math.floor(switches / 256) } },
|
|
{ op = "addval", args = { 3 } },
|
|
{ op = "writemem", args = { switches % 256, math.floor(switches / 256) } },
|
|
{ op = "setval", value = 6 },
|
|
{ op = "writemem", args = { berries % 256, math.floor(berries / 256) } },
|
|
{ op = "end" },
|
|
})
|
|
if crystal then
|
|
vm.mem[0xD964] = nil
|
|
local v2 = drive({ { op = "loadmem", args = { 0x64, 0xD9, 9 } }, { op = "loadmem", args = { 0xF2, 0xD9, 4 } },
|
|
{ op = "readmem", args = { 0x0D, 0xDA } }, { op = "addval", args = { 2 } },
|
|
{ op = "writemem", args = { 0x0D, 0xDA } }, { op = "end" } })
|
|
for k, x in pairs(v2.mem) do vm.mem[k] = x end
|
|
end
|
|
save.scriptMem = vm:serializeMem()
|
|
local oldPlay, oldStop = Music.play, Music.stop
|
|
Music.play, Music.stop = function() end, function() end
|
|
local gear = Pokegear.new({ save = save, data = {}, input = { wasPressed = function() return false end } },
|
|
{ clock = { hour = 14, minute = 0, weekday = 1 } })
|
|
gear.tuningKnob = 28
|
|
gear:tuneRadio()
|
|
Music.play, Music.stop = oldPlay, oldStop
|
|
save.lastDexMode = "OLD"
|
|
local bikeId = crystal and 25 or 24
|
|
World.setEngineFlag(fakeWorld(save), bikeId, true)
|
|
save.mysteryGift = { trainerHouse = true, partnerName = "RIVAL" }
|
|
if crystal then
|
|
World.setEngineFlag(fakeWorld(save), 100, true)
|
|
Save.crystalState(save).gsBall = "given"
|
|
local seq = { 2, 1, 3, 2, 1, 1 }
|
|
local i = 0
|
|
local function random(n) i = i + 1; return math.min(seq[i] or 1, n) end
|
|
BattleTower.resetTrainers(save)
|
|
BattleTower.setChallengeState(save, BattleTower.CHALLENGE_IN_PROGRESS)
|
|
BattleTower.setSaveFileFlag(save, BattleTower.SAVEFILE_REGISTERED)
|
|
BattleTower.state(save).levelGroup = 4
|
|
BattleTower.chooseTrainer(save, ROSTER, random)
|
|
BattleTower.chooseTeam(save, ROSTER, 1, random)
|
|
BattleTower.beginBattle(save)
|
|
BattleTower.chooseTrainer(save, ROSTER, random)
|
|
BattleTower.chooseTeam(save, ROSTER, 1, random)
|
|
BattleTower.state(save).reward = "MAX_POTION"
|
|
end
|
|
return save
|
|
end
|
|
|
|
for _, v in ipairs(VERSIONS) do
|
|
local save = engineBuilt(v)
|
|
local out = export(v, save, nil)
|
|
local back = out and import(v, out)
|
|
if back then
|
|
local label = v .. " R2 "
|
|
eq(back.spawnAfterChampion, save.spawnAfterChampion, label .. "spawnAfterChampion")
|
|
eq(back.radioTuningKnob, save.radioTuningKnob, label .. "radioTuningKnob")
|
|
eq(back.lastDexMode, save.lastDexMode, label .. "lastDexMode")
|
|
same(back.registeredItem, save.registeredItem, label .. "registeredItem")
|
|
same(back.hallOfFame, save.hallOfFame, label .. "hallOfFame")
|
|
same(back.tradeFlags, save.tradeFlags, label .. "tradeFlags")
|
|
same(back.scriptMem, save.scriptMem, label .. "scriptMem")
|
|
for _, id in ipairs(v == "crystal" and { 24, 25, 26, 100 } or { 23, 24, 25 }) do
|
|
eq(back.engineFlags[id], save.engineFlags[id], label .. "engineFlags " .. id)
|
|
end
|
|
same(decoView(back.decorations), decoView(save.decorations), label .. "decorations")
|
|
same(back.backupWarp, save.backupWarp, label .. "backupWarp")
|
|
eq(back.blackoutMap, save.blackoutMap, label .. "blackoutMap")
|
|
same(back.mysteryGift, save.mysteryGift, label .. "mysteryGift")
|
|
if v == "crystal" then
|
|
check(Save.GS_BALL_STATES[back.crystal.gsBall], label .. "crystal.gsBall stays a GS Ball state")
|
|
local want = BattleTower.state(save)
|
|
local got = BattleTower.state(back)
|
|
for _, k in ipairs({ "challenge", "streak", "levelGroup", "saveFileFlags", "reward" }) do
|
|
eq(got[k], want[k], label .. "battleTower." .. k)
|
|
end
|
|
same(got.trainers, want.trainers, label .. "battleTower.trainers")
|
|
same(got.prevTeams, want.prevTeams, label .. "battleTower.prevTeams")
|
|
end
|
|
if v == "crystal" then same(back.battleTower, save.battleTower, label .. "battleTower whole") end
|
|
local r = require("tests.save_compat._gen2_reference").decode(out, v)
|
|
check(r.checksum1 and r.checksum2, label .. "the independent reader accepts both checksums")
|
|
local S = symsFor(v)
|
|
-- pokecrystal macros/ram.asm:242
|
|
eq(u8(out, S.sHallOfFame), 2, label .. "raw row 1 win count")
|
|
eq(u8(out, S.sHallOfFame + 1), 248, label .. "raw row 1 species")
|
|
eq(u8(out, S.sHallOfFame + 6), 55, label .. "raw row 1 level")
|
|
eq(u8(out, S.sHallOfFame + 17), 0xFF, label .. "raw row 1 terminator")
|
|
eq(u8(out, S.sHallOfFame + 0x62), 1, label .. "raw row 2 win count")
|
|
eq(u8(out, S.sHallOfFame + 0x62 + 17), 25, label .. "raw row 2 skips the egg")
|
|
eq(u8(out, S.sHallOfFame + 0x62 * 2), 0, label .. "raw row 3 absent")
|
|
local again = export(v, back, nil)
|
|
if again then
|
|
for _, r in ipairs(activeRegions(v)) do
|
|
eq(hex(regionBytes(again, r.spans)), hex(regionBytes(out, r.spans)), label .. r.id .. " fixed point")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, v in ipairs(VERSIONS) do
|
|
local S = symsFor(v)
|
|
local crystal = v == "crystal"
|
|
local bytes = G2.build({ version = v, patch = PAINT.realistic(v) })
|
|
local save = import(v, bytes)
|
|
save.radioTuningKnob = 40
|
|
save.lastDexMode = "OLD"
|
|
save.registeredItem = { id = "BICYCLE" }
|
|
HallOfFame.induct(save, { partyMon("TOTODILE", 61, "JAWS", { attack = 12, defense = 5, speed = 7, special = 2 }, 0xBEEF) })
|
|
HallOfFame.markRedCredits(save)
|
|
save.tradeFlags = { [1] = true, [4] = true }
|
|
save.scriptMem[crystal and 0xD962 or 0xD6A7] = 9
|
|
save.scriptMem[crystal and 0xD963 or 0xD6A8] = nil
|
|
local bike = crystal and 24 or 23
|
|
save.engineFlags[bike] = true
|
|
save.engineFlags[bike + 1] = nil
|
|
save.engineFlags[bike + 2] = true
|
|
if crystal then
|
|
save.engineFlags[100] = nil
|
|
save.scriptMem[0xD9F2] = 4
|
|
save.scriptMem[0xDA0D] = nil
|
|
end
|
|
save.decorations = { bed = 5, carpet = 0, plant = 12, poster = 17, console = 21,
|
|
leftOrnament = 0, rightOrnament = 33, bigDoll = 27 }
|
|
save.backupWarp = { warp = 3, map = "TEST_CAVE" }
|
|
save.blackoutMap = "TEST_CENTER_2F"
|
|
save.mysteryGift = { trainerHouse = false }
|
|
if crystal then
|
|
save.crystal.gsBall = nil
|
|
local tower = BattleTower.state(save)
|
|
tower.challenge, tower.streak, tower.levelGroup = 3, 7, 9
|
|
tower.trainers = { 1, 2, 3, 4, 5, 6, 7 }
|
|
tower.saveFileFlags, tower.reward = 1, "POTION"
|
|
tower.prevTeams = { prev = { "PIKACHU" }, prevPrev = {} }
|
|
end
|
|
local out = export(v, save, bytes)
|
|
if out then
|
|
local label = v .. " change "
|
|
-- pokecrystal engine/events/halloffame.asm:53
|
|
eq(u8(out, S.wSpawnAfterChampion), 2, label .. "SPAWN_RED is 2")
|
|
-- pokecrystal engine/pokegear/pokegear.asm:1399
|
|
eq(u8(out, S.wRadioTuningKnob), 40, label .. "knob byte")
|
|
-- pokecrystal engine/pokedex/pokedex.asm:61
|
|
eq(u8(out, S.wLastDexMode), 1, label .. "DEXMODE_OLD")
|
|
eq(u8(out, S.wLastDexMode + 1), 0, label .. "the byte after wLastDexMode is untouched")
|
|
-- pokecrystal engine/items/pack.asm:547
|
|
eq(u8(out, S.wWhichRegisteredItem), 0x81, label .. "KEY_ITEM_POCKET << 6 | slot 1")
|
|
eq(u8(out, S.wRegisteredItem), G2.ITEMS.BICYCLE.index, label .. "registered item id")
|
|
eq(u8(out, S.wHallOfFameCount), 3, label .. "wHallOfFameCount bumped")
|
|
local row = S.sHallOfFame
|
|
-- pokecrystal engine/events/halloffame.asm:137
|
|
eq(u8(out, row), 3, label .. "new row win count")
|
|
eq(u8(out, row + 1), 158, label .. "new row species")
|
|
eq(u8(out, row + 2) * 256 + u8(out, row + 3), 0xBEEF, label .. "new row ID big-endian")
|
|
eq(u8(out, row + 4), 0xC5, label .. "new row DV byte 1")
|
|
eq(u8(out, row + 5), 0x72, label .. "new row DV byte 2")
|
|
eq(u8(out, row + 6), 61, label .. "new row level")
|
|
eq(u8(out, row + 7), 0x89, label .. "new row nickname J")
|
|
eq(u8(out, row + 11), 0x50, label .. "new row nickname terminator")
|
|
eq(u8(out, row + 17), 0xFF, label .. "new row ends in -1")
|
|
eq(u8(out, row + 0x62), 2, label .. "AddHallOfFameEntry shifted the old first row down")
|
|
eq(hex(out:sub(row + 0x62 + 1, row + 0x62 * 3)), hex(bytes:sub(row + 1, row + 0x62 * 2)),
|
|
label .. "the old rows moved whole")
|
|
eq(u8(out, S.wTradeFlags), 0x12, label .. "trade bits 1 and 4")
|
|
eq(u8(out, S.wMooMooBerries), 9, label .. "wMooMooBerries")
|
|
eq(u8(out, S.wUndergroundSwitchPositions), 0, label .. "wUndergroundSwitchPositions cleared")
|
|
-- pokecrystal constants/ram_constants.asm:311
|
|
eq(u8(out, S.wBikeFlags), 0x05, label .. "strength and downhill bits")
|
|
if crystal then
|
|
eq(u8(out, S.wCelebiEvent), 0x00, label .. "forest no longer restless")
|
|
eq(u8(out, S.wJackFightCount), 4, label .. "scriptMem[$D9F2] is wJackFightCount")
|
|
eq(u8(out, S.wErinFightCount), 0, label .. "a cleared $DA0D zeroes wErinFightCount")
|
|
eq(u8(out, S.wJackFightCount + 2), 2, label .. "an untouched fight count keeps its byte")
|
|
end
|
|
eq(hex(out:sub(S.wDecoBed + 1, S.wDecoBed + 8)), "05000C111500211B", label .. "wDeco* in ram order")
|
|
eq(hex(out:sub(S.wDigWarpNumber + 1, S.wDigWarpNumber + 6)), "030709030709", label .. "both warp triples")
|
|
eq(hex(out:sub(S.wLastSpawnMapGroup + 1, S.wLastSpawnMapGroup + 2)), "1401", label .. "wLastSpawnMap pair")
|
|
eq(u8(out, S.sMysteryGiftTrainerHouseFlag), 0, label .. "trainer house flag cleared")
|
|
eq(hex(out:sub(S.sMysteryGiftTimer + 1, S.sMysteryGiftTimer + 2)), "0007", label .. "the rest of the gift block is kept")
|
|
if crystal then
|
|
eq(u8(out, S.sGSBallFlag), 0, label .. "sGSBallFlag")
|
|
eq(u8(out, S.sGSBallFlagBackup), 0, label .. "sGSBallFlagBackup")
|
|
eq(hex(out:sub(S.sBattleTowerChallengeState + 1, S.sBattleTowerChallengeState + 18)),
|
|
"030709010203040506070112190000000000", label .. "SRAM Battle Tower in sram.asm order")
|
|
end
|
|
local back = import(v, out)
|
|
if back then
|
|
eq(back.hallOfFame.count, 3, label .. "reimported count")
|
|
eq(back.hallOfFame.teams[1].mons[1].nickname, "JAWS", label .. "reimported row")
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, v in ipairs(VERSIONS) do
|
|
local S = symsFor(v)
|
|
local bytes = G2.build({ version = v, patch = PAINT.realistic(v),
|
|
items = { { G2.ITEMS.REPEL.index, 1 }, { G2.ITEMS.POTION.index, 3 } } })
|
|
local save = import(v, bytes)
|
|
save.registeredItem = { id = "POTION" }
|
|
local out = export(v, save, bytes)
|
|
eq(u8(out, S.wWhichRegisteredItem), 0x02, v .. " the POTION is in slot 2 now")
|
|
save.inventory.POTION = nil
|
|
save.bagOrder = { "REPEL" }
|
|
out = export(v, save, bytes)
|
|
eq(u8(out, S.wWhichRegisteredItem), 0x01, v .. " a used-up registered item keeps the cart's stale slot")
|
|
eq(u8(out, S.wRegisteredItem), G2.ITEMS.POTION.index, v .. " and its id")
|
|
end
|
|
|
|
local SWEEP = {
|
|
gs = {
|
|
[0xCE51] = "wOtherPlayerLinkMode", [0xD0D8] = "wStrengthSpecies", [0xD117] = "wTempWildMonSpecies",
|
|
[0xD6A7] = "wMooMooBerries", [0xD6A8] = "wUndergroundSwitchPositions",
|
|
},
|
|
crystal = {
|
|
[0xCF51] = "wOtherPlayerLinkMode", [0xCF64] = "wNrOfBeatenBattleTowerTrainers",
|
|
[0xD1EF] = "wStrengthSpecies", [0xD22E] = "wTempWildMonSpecies",
|
|
[0xD962] = "wMooMooBerries", [0xD963] = "wUndergroundSwitchPositions", [0xD964] = "wFarfetchdPosition",
|
|
},
|
|
}
|
|
do
|
|
local names = { "wJackFightCount", "wBeverlyFightCount", "wHueyFightCount", "wGavenFightCount",
|
|
"wBethFightCount", "wJoseFightCount", "wReenaFightCount", "wJoeyFightCount", "wWadeFightCount",
|
|
"wRalphFightCount", "wLizFightCount", "wAnthonyFightCount", "wToddFightCount", "wGinaFightCount",
|
|
"wIrwinFightCount", "wArnieFightCount", "wAlanFightCount", "wDanaFightCount", "wChadFightCount",
|
|
"wDerekFightCount", "wTullyFightCount", "wBrentFightCount", "wTiffanyFightCount", "wVanceFightCount",
|
|
"wWiltonFightCount", "wKenjiFightCount", "wParryFightCount", "wErinFightCount" }
|
|
for i, name in ipairs(names) do SWEEP.crystal[0xD9F2 + i - 1] = name end
|
|
end
|
|
|
|
local home = os.getenv("HOME") or ""
|
|
local CACHES = {
|
|
gold = os.getenv("GOLD_CACHE") or home .. "/Library/Application Support/LOVE/gold-bsa0925-gameplay/gold",
|
|
silver = os.getenv("SILVER_CACHE") or home .. "/Library/Application Support/LOVE/pokemon-love2d/silver",
|
|
crystal = os.getenv("CRYSTAL_CACHE") or home .. "/Library/Application Support/LOVE/crystal-bsa0925-gameplay/crystal",
|
|
}
|
|
local MEM_OPS = { readmem = true, writemem = true, loadmem = true }
|
|
|
|
local function cacheScripts(dir)
|
|
local out, found = {}, false
|
|
for _, f in ipairs({ "scripts.lua", "std_scripts.lua" }) do
|
|
local fn = loadfile(dir .. "/data/generated/" .. f)
|
|
if fn then found = true; out[#out + 1] = fn() end
|
|
end
|
|
return found and out or nil
|
|
end
|
|
|
|
local function walkOps(roots, fn)
|
|
local seen = {}
|
|
local function walk(t)
|
|
if type(t) ~= "table" or seen[t] then return end
|
|
seen[t] = true
|
|
if type(t.op) == "string" then fn(t) end
|
|
for _, x in pairs(t) do walk(x) end
|
|
end
|
|
for _, r in ipairs(roots) do walk(r) end
|
|
end
|
|
|
|
for _, v in ipairs(VERSIONS) do
|
|
local ctx = ctxFor(v)
|
|
local fam = v == "crystal" and "crystal" or "gs"
|
|
local lo, hi = P.savedWram(ctx, G2.layout(v))
|
|
local mapped = P.scriptMemAddresses(ctx)
|
|
for addr, name in pairs(SWEEP[fam]) do
|
|
local label = ("%s $%04X %s"):format(v, addr, name)
|
|
if addr >= lo and addr < hi then
|
|
eq(mapped[addr], name, label .. " is in the saved block and scriptMem maps it to its label")
|
|
local save = baseSave(v)
|
|
save.scriptMem[addr] = 0x5C
|
|
local out = export(v, save, nil)
|
|
if out then
|
|
eq(u8(out, ctx.S[name]), 0x5C, label .. " lands on the label's file byte")
|
|
local back = import(v, out)
|
|
eq(back and back.scriptMem[addr], 0x5C, label .. " round-trips through scriptMem")
|
|
end
|
|
else
|
|
check(addr < lo or addr >= hi, label .. " is session WRAM outside sGameData")
|
|
eq(mapped[addr], nil, label .. " is not written to the cartridge")
|
|
end
|
|
end
|
|
for addr in pairs(mapped) do
|
|
check(SWEEP[fam][addr] ~= nil, ("%s $%04X: every mapped scriptMem address is in the sweep"):format(v, addr))
|
|
end
|
|
check(BattleTower.WRAM_NR_BEATEN < lo or BattleTower.WRAM_NR_BEATEN >= hi or v ~= "crystal",
|
|
v .. " the Battle Tower's direct vm.mem byte is session WRAM")
|
|
local roots = CACHES[v] and cacheScripts(CACHES[v])
|
|
if roots then
|
|
local mems, warpmods = 0, 0
|
|
walkOps(roots, function(cmd)
|
|
if MEM_OPS[cmd.op] and cmd.args then
|
|
mems = mems + 1
|
|
local addr = (cmd.args[1] or 0) + (cmd.args[2] or 0) * 256
|
|
check(SWEEP[fam][addr] ~= nil, ("%s cache %s $%04X is in the enumerated sweep"):format(v, cmd.op, addr))
|
|
elseif cmd.op == "warpmod" then
|
|
warpmods = warpmods + 1
|
|
end
|
|
end)
|
|
check(mems > 0, v .. " the cache sweep saw the script memory ops")
|
|
eq(warpmods, 0, v .. " no extracted script runs warpmod, so save.warpMod is never written")
|
|
else
|
|
print(("[skip] %s script cache absent: the enumerated sweep still ran"):format(v))
|
|
end
|
|
end
|
|
|
|
do
|
|
local count = 0
|
|
local home = "../"
|
|
for _, repo in ipairs({ "pokegold", "pokecrystal" }) do
|
|
local p = io.popen(("grep -rln '\\bwarpmod\\b' '%s%s/maps' 2>/dev/null"):format(home, repo))
|
|
if p then
|
|
for _ in p:lines() do count = count + 1 end
|
|
p:close()
|
|
end
|
|
end
|
|
eq(count, 0, "no pret map script uses warpmod")
|
|
end
|
|
|
|
for _, v in ipairs(VERSIONS) do
|
|
local save = baseSave(v)
|
|
save.backupWarp = { warp = 2, map = "TEST_CENTER" }
|
|
save.warpMod = { warp = 2, map = "TEST_CENTER", group = 3, mapNumber = 4 }
|
|
local out = export(v, save, nil)
|
|
check(out ~= nil, v .. " a warpMod the backup triple already holds exports")
|
|
save.warpMod = { warp = 1, map = "TEST_CITY", group = 3, mapNumber = 1 }
|
|
local bad, err = Gen2Save.encode(save, v, nil, DATA)
|
|
check(bad == nil and tostring(err):find("warpmod", 1, true) ~= nil,
|
|
v .. " a warpMod the backup triple does not hold is refused -- " .. tostring(err))
|
|
end
|
|
|
|
for _, v in ipairs(VERSIONS) do
|
|
local function refused(edit, fragment, label)
|
|
local save = baseSave(v)
|
|
edit(save)
|
|
local out, err = Gen2Save.encode(save, v, nil, DATA)
|
|
check(out == nil and tostring(err):find(fragment, 1, true) ~= nil, v .. " refuses " .. label .. " -- " .. tostring(err))
|
|
end
|
|
refused(function(s) s.blackoutMap = "NOWHERE" end, "whiteout map", "an unknown whiteout map")
|
|
refused(function(s) s.backupWarp = { warp = 1, map = "NOWHERE" } end, "backup warp map", "an unknown backup map")
|
|
refused(function(s) s.tradeFlags = { [9] = true } end, "NPC trade 9", "a trade past NUM_NPC_TRADES")
|
|
refused(function(s) s.radioTuningKnob = 300 end, "radio tuning knob", "a knob past a byte")
|
|
refused(function(s) s.lastDexMode = "UNOWN" end, "Pokedex mode", "a dex mode the cart has no byte for")
|
|
refused(function(s) s.spawnAfterChampion = "SPAWN_HOME" end, "post-champion spawn", "an unknown post-champion spawn")
|
|
refused(function(s)
|
|
local teams = {}
|
|
for i = 1, 31 do teams[i] = { winCount = i, mons = {} } end
|
|
s.hallOfFame = { count = 31, teams = teams }
|
|
end, "Hall of Fame holds 31", "a 31-team roster")
|
|
refused(function(s) s.hallOfFame = { count = 1, teams = { { winCount = 0, mons = {} } } } end,
|
|
"win count of 0", "a zero win count")
|
|
if v == "crystal" then
|
|
refused(function(s) Save.crystalState(s).gsBall = "maybe" end, "GS Ball state", "an unknown GS Ball state")
|
|
refused(function(s) BattleTower.state(s).trainers = { [9] = 1 } end, "trainer slot 9", "a Battle Tower slot past 7")
|
|
end
|
|
end
|
|
|
|
do
|
|
local files = {}
|
|
local p = io.popen("find src -name '*.lua'")
|
|
for line in p:lines() do files[#files + 1] = line end
|
|
p:close()
|
|
check(#files > 100, "the source scan found the engine")
|
|
local PATTERNS = {
|
|
{ "savedAtLeastOnce", "[%w_%.]*[Ss]avedAtLeastOnce%s*=" },
|
|
{ "warpNumber", "save%.warpNumber%s*=" },
|
|
{ "linkBattleStats", "save%.linkBattle[%w_]*%s*=" },
|
|
{ "linkBattleStats", "linkBattleStats%s*=" },
|
|
{ "linkBattleStats", "linkBattleRecords?%s*=" },
|
|
{ "linkBattleStats", "save%.[%w_]*[Ww]ins%s*=" },
|
|
{ "linkBattleStats", "save%.[%w_]*[Ll]osses%s*=" },
|
|
{ "linkBattleStats", "save%.[%w_]*[Dd]raws%s*=" },
|
|
{ "crystalData", "playerAge%s*=" },
|
|
{ "crystalData", "prefecture%s*=" },
|
|
{ "crystalData", "postalCode%s*=" },
|
|
}
|
|
local SKIP = { "^src/save_convert/", "^src/core/game3/", "^src/import/gba/", "^src/ui/game3/",
|
|
"^src/save_convert/gen3", "^src/core/Game3" }
|
|
for _, path in ipairs(files) do
|
|
local skip = false
|
|
for _, pat in ipairs(SKIP) do if path:find(pat) then skip = true end end
|
|
if not skip then
|
|
local f = io.open(path, "r")
|
|
local body = f:read("*a")
|
|
f:close()
|
|
for _, row in ipairs(PATTERNS) do
|
|
local at = body:find(row[2])
|
|
check(at == nil, ("%s: %s writes %s state at byte %s"):format(row[1], path, row[1], tostring(at)))
|
|
end
|
|
end
|
|
end
|
|
for _, name in ipairs(REGION_NAMES) do check(type(name) == "string", "region " .. name) end
|
|
local ids = {}
|
|
for _, region in ipairs(P.REGIONS) do ids[region.id] = region.read == nil end
|
|
eq(ids.savedAtLeastOnce, true, "wSavedAtLeastOnce is static")
|
|
eq(ids.warpNumber, true, "wWarpNumber is static")
|
|
eq(ids.linkBattleStats, true, "sLinkBattleStats is static")
|
|
eq(ids.crystalProfile, true, "sCrystalData's mobile profile is static")
|
|
end
|
|
|
|
for _, v in ipairs(VERSIONS) do
|
|
local S = symsFor(v)
|
|
local fam = v == "crystal" and "crystal" or "gs"
|
|
local runs = {}
|
|
for i, row in ipairs(P.coverage) do
|
|
local r = row.both or row[fam]
|
|
if r then
|
|
local from = S[r[1]]
|
|
local to = r.len and from and from + r.len or S[r[2]]
|
|
check(from ~= nil and to ~= nil and to > from, ("%s coverage row %d resolves"):format(v, i))
|
|
runs[#runs + 1] = { from or 0, to or 0, i }
|
|
check(row.mode == "modeled" or (row.mode == "static" and type(row.why) == "string"),
|
|
("%s coverage row %d has a mode"):format(v, i))
|
|
end
|
|
end
|
|
table.sort(runs, function(a, b) return a[1] < b[1] end)
|
|
for i = 2, #runs do
|
|
check(runs[i][1] >= runs[i - 1][2], ("%s coverage rows %d and %d do not overlap"):format(v, runs[i - 1][3], runs[i][3]))
|
|
end
|
|
for _, run in ipairs(runs) do
|
|
check(run[1] >= symsFor(v).wGameData and run[2] <= G2.layout(v).sGameDataEnd,
|
|
("%s coverage row %d sits inside sGameData"):format(v, run[3]))
|
|
end
|
|
end
|
|
|
|
T.finish()
|