mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
294 lines
12 KiB
Lua
294 lines
12 KiB
Lua
-- A Gen 2 export that moved maps carries the new map's object window
|
|
-- (data/maps/setup_scripts.asm MapSetupScript_Continue, home/map.asm
|
|
-- ReadObjectEvents). Same-map exports leave the template's window alone,
|
|
-- byte for byte, which is the #1852 guarantee these tests also pin.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local T = require("tests.modkit")
|
|
local Gen2Save = require("src.save_convert.Gen2Save")
|
|
local Gen2Layout = require("src.save_convert.Gen2Layout")
|
|
local Gen2MapContext = require("src.save_convert.Gen2MapContext")
|
|
|
|
local SIZE = Gen2Save.SAVE_SIZE
|
|
local MAPOBJECT = Gen2MapContext.MAPOBJECT_LENGTH
|
|
local STRUCT = Gen2MapContext.OBJECT_LENGTH
|
|
|
|
-- A sealed template standing on a chosen map, with a recognizable pattern
|
|
-- across its whole object window so both preservation and rebuild are
|
|
-- visible in the bytes. Synthesized, not checked in: a real .sav is
|
|
-- personal data, same rule tests/engine/gen2_save_import.lua follows.
|
|
local function template(L, O, group, number)
|
|
local b = {}
|
|
for i = 0, SIZE - 1 do b[i] = 0 end
|
|
b[L.wMapGroup], b[L.wMapNumber] = group, number
|
|
for i = 0, Gen2MapContext.NUM_OBJECTS * MAPOBJECT - 1 do
|
|
b[O.mapObjects + i] = 0xA0 + i % 16
|
|
end
|
|
for i = 0, Gen2MapContext.NUM_OBJECT_STRUCTS * STRUCT - 1 do
|
|
b[O.objectStructs + i] = 0xB0 + i % 16
|
|
end
|
|
for i = 0, Gen2MapContext.NUM_OBJECTS - 1 do b[O.objectMasks + i] = 0xC0 end
|
|
b[O.objectFollow], b[O.objectFollow + 1] = 3, 4
|
|
b[O.objectEventCount] = 9
|
|
b[L.sCheckValue1] = 0x63
|
|
b[L.sCheckValue2] = 0x7F
|
|
local sum = 0
|
|
for i = L.sGameData, L.sGameDataEnd - 1 do sum = (sum + b[i]) % 65536 end
|
|
b[L.sChecksum] = sum % 256
|
|
b[L.sChecksum + 1] = math.floor(sum / 256) % 256
|
|
local out = {}
|
|
for i = 0, SIZE - 1 do out[i + 1] = string.char(b[i]) end
|
|
return table.concat(out)
|
|
end
|
|
|
|
-- The map the moved save lands on: two objects, one carrying every field a
|
|
-- real object_event can, one minimal.
|
|
local function fixtureData(group, number)
|
|
return {
|
|
pokemon = {}, moves = {}, items = {},
|
|
maps = {
|
|
FIX_HOUSE = {
|
|
group = group, map = number,
|
|
objectEventsAddr = 0x5A17,
|
|
width = 4, height = 3,
|
|
blocks = {
|
|
0x01, 0x02, 0x03, 0x04,
|
|
0x11, 0x12, 0x13, 0x14,
|
|
0x21, 0x22, 0x23, 0x24,
|
|
},
|
|
objects = {
|
|
{
|
|
index = 1, spriteId = 0x2F, x = 3, y = 5, movement = 0x07,
|
|
radius = { y = 2, x = 1 }, hours = { -1, 20 },
|
|
palette = 4, type = 2, sight = 0,
|
|
script = 0x6BCD, eventFlag = 0x02A5,
|
|
},
|
|
{
|
|
index = 2, spriteId = 0x11, x = 0, y = 0, movement = 0x01,
|
|
radius = { y = 0, x = 0 }, hours = { -1, -1 },
|
|
palette = 0, type = 5, sight = 3,
|
|
script = 0x6BE0,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
end
|
|
|
|
local function save(group, number, x, y)
|
|
return {
|
|
player = { name = "TESTER" },
|
|
position = { mapGroup = group, mapNumber = number, x = x, y = y },
|
|
}
|
|
end
|
|
|
|
local function u8(bytes, at) return bytes:byte(at + 1) end
|
|
|
|
for _, spec in ipairs({
|
|
{ version = "crystal", L = Gen2Layout.crystal, O = Gen2MapContext.OFFSETS.crystal },
|
|
{ version = "gold", L = Gen2Layout.goldSilver, O = Gen2MapContext.OFFSETS.goldSilver },
|
|
}) do
|
|
local L, O = spec.L, spec.O
|
|
local tpl = template(L, O, 21, 14)
|
|
|
|
-- Same map: the template's whole object window survives untouched.
|
|
local bytes, err = Gen2Save.encode(save(21, 14, 6, 1), spec.version, tpl,
|
|
fixtureData(21, 14))
|
|
T.check(bytes, spec.version .. " same-map export succeeds: " .. tostring(err))
|
|
local windowSame = true
|
|
for i = 0, Gen2MapContext.NUM_OBJECTS * MAPOBJECT - 1 do
|
|
if u8(bytes, O.mapObjects + i) ~= u8(tpl, O.mapObjects + i) then
|
|
windowSame = false
|
|
break
|
|
end
|
|
end
|
|
T.check(windowSame, spec.version .. ": same-map export leaves wMapObjects byte-identical")
|
|
T.eq(u8(bytes, O.objectEventCount), 9,
|
|
spec.version .. ": same-map export leaves the object count alone")
|
|
|
|
-- Moved: the window is the NEW map's, exactly as ReadObjectEvents lays
|
|
-- it out.
|
|
bytes, err = Gen2Save.encode(save(21, 15, 6, 1), spec.version, tpl,
|
|
fixtureData(21, 15))
|
|
T.check(bytes, spec.version .. " moved export succeeds: " .. tostring(err))
|
|
|
|
-- home/map.asm:941 ReadObjectEvents (pokecrystal home/map.asm:572)
|
|
local slot1 = O.mapObjects + O.firstObjectSlot * MAPOBJECT
|
|
local expected = {
|
|
0xFF, 0x2F, 5 + 4, 3 + 4, 0x07,
|
|
2 * 16 + 1, 0xFF, 20, 4 * 16 + 2, 0x00,
|
|
0xCD, 0x6B, 0xA5, 0x02, 0, 0,
|
|
}
|
|
for i, want in ipairs(expected) do
|
|
T.eq(u8(bytes, slot1 + i - 1), want,
|
|
("%s: object 1 byte %d"):format(spec.version, i))
|
|
end
|
|
T.eq(u8(bytes, slot1 + MAPOBJECT + 1), 0x11, spec.version .. ": object 2 sprite")
|
|
-- An object with no event flag writes the -1 the game uses for "none".
|
|
T.eq(u8(bytes, slot1 + MAPOBJECT + 12), 0xFF, spec.version .. ": no-flag object writes $FFFF")
|
|
T.eq(u8(bytes, slot1 + MAPOBJECT + 13), 0xFF, spec.version .. ": no-flag object writes $FFFF hi")
|
|
local empty = slot1 + 2 * MAPOBJECT
|
|
T.eq(u8(bytes, empty), 0, spec.version .. ": empty slot struct id")
|
|
T.eq(u8(bytes, empty + 1), 0, spec.version .. ": empty slot sprite")
|
|
T.eq(u8(bytes, empty + 2), 0xFF, spec.version .. ": empty slot y coord")
|
|
if O.firstObjectSlot > 1 then
|
|
T.eq(u8(bytes, O.mapObjects + MAPOBJECT + 1), 0,
|
|
spec.version .. ": the slot Gold and Silver skip stays empty")
|
|
end
|
|
|
|
local screen = {
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
0x02, 0x03, 0x04, 0x00, 0x00, 0x00,
|
|
0x12, 0x13, 0x14, 0x00, 0x00, 0x00,
|
|
0x22, 0x23, 0x24, 0x00, 0x00, 0x00,
|
|
}
|
|
for i, want in ipairs(screen) do
|
|
T.eq(u8(bytes, O.screenSave + i - 1), want,
|
|
("%s: screen block %d"):format(spec.version, i))
|
|
end
|
|
|
|
-- The player: map object coordinates re-anchored, sprite untouched.
|
|
T.eq(u8(bytes, O.mapObjects + 2), 1 + 4, spec.version .. ": player map object y")
|
|
T.eq(u8(bytes, O.mapObjects + 3), 6 + 4, spec.version .. ": player map object x")
|
|
T.eq(u8(bytes, O.mapObjects + 1), u8(tpl, O.mapObjects + 1),
|
|
spec.version .. ": player map object sprite keeps the template's byte")
|
|
|
|
-- The player struct moves with them; the NPC structs are cleared.
|
|
T.eq(u8(bytes, O.objectStructs + Gen2MapContext.STRUCT_MAP_X), 6 + 4,
|
|
spec.version .. ": player struct map x")
|
|
T.eq(u8(bytes, O.objectStructs + Gen2MapContext.STRUCT_MAP_Y), 1 + 4,
|
|
spec.version .. ": player struct map y")
|
|
T.eq(u8(bytes, O.objectStructs + STRUCT), 0, spec.version .. ": NPC struct 1 cleared")
|
|
T.eq(u8(bytes, O.objectStructs + 12 * STRUCT + STRUCT - 1), 0,
|
|
spec.version .. ": NPC struct 12 cleared to its last byte")
|
|
|
|
-- Masks, follow, count and pointer.
|
|
T.eq(u8(bytes, O.objectMasks), 0, spec.version .. ": object masks cleared")
|
|
T.eq(u8(bytes, O.objectMasks + 15), 0, spec.version .. ": all sixteen of them")
|
|
T.eq(u8(bytes, O.objectFollow), 0xFF, spec.version .. ": follow leader reset")
|
|
T.eq(u8(bytes, O.objectFollow + 1), 0xFF, spec.version .. ": follow follower reset")
|
|
T.eq(u8(bytes, O.objectEventCount), 2, spec.version .. ": object count")
|
|
T.eq(u8(bytes, O.objectEventsPointer), 0x17, spec.version .. ": events pointer lo")
|
|
T.eq(u8(bytes, O.objectEventsPointer + 1), 0x5A, spec.version .. ": events pointer hi")
|
|
|
|
-- And the checksum still seals the block the game verifies.
|
|
T.check(Gen2Save.checksumValid(bytes, L), spec.version .. ": moved export checksums")
|
|
|
|
-- Refusals: an unknown map, and a cache from before the extractor kept
|
|
-- the object-table address.
|
|
local refused, why = Gen2Save.encode(save(9, 9, 0, 0), spec.version, tpl,
|
|
fixtureData(21, 15))
|
|
T.check(refused == nil and why:find("unknown map 9/9", 1, true),
|
|
spec.version .. ": unknown map refuses: " .. tostring(why))
|
|
|
|
local stale = fixtureData(21, 15)
|
|
stale.maps.FIX_HOUSE.objectEventsAddr = nil
|
|
refused, why = Gen2Save.encode(save(21, 15, 0, 0), spec.version, tpl, stale)
|
|
T.check(refused == nil and why:find("re%-import the ROM"),
|
|
spec.version .. ": stale cache refuses with the re-import hint: " .. tostring(why))
|
|
|
|
local noBlocks = fixtureData(21, 15)
|
|
noBlocks.maps.FIX_HOUSE.blocks = nil
|
|
refused, why = Gen2Save.encode(save(21, 15, 0, 0), spec.version, tpl, noBlocks)
|
|
T.check(refused == nil and why:find("no block data", 1, true),
|
|
spec.version .. ": a cache with no blocks refuses rather than exporting a "
|
|
.. "save that continues into an empty room: " .. tostring(why))
|
|
end
|
|
|
|
-- home/map.asm:1169, :1829
|
|
local function connectedData()
|
|
local function neighbour(group, number, base)
|
|
local blocks = {}
|
|
for i = 1, 16 do blocks[i] = base + i end
|
|
return {
|
|
group = group, map = number, objectEventsAddr = 0x4000,
|
|
width = 4, height = 4, blocks = blocks, objects = {},
|
|
}
|
|
end
|
|
return {
|
|
pokemon = {}, moves = {}, items = {},
|
|
maps = {
|
|
FIX_TOWN = {
|
|
group = 30, map = 1, objectEventsAddr = 0x4A00,
|
|
width = 4, height = 4, objects = {},
|
|
blocks = {
|
|
0x11, 0x12, 0x13, 0x14,
|
|
0x21, 0x22, 0x23, 0x24,
|
|
0x31, 0x32, 0x33, 0x34,
|
|
0x41, 0x42, 0x43, 0x44,
|
|
},
|
|
connections = {
|
|
north = { group = 30, map = 2, mapId = "FIX_NORTH",
|
|
stripLength = 4, width = 4, offset = 0 },
|
|
south = { group = 30, map = 3, mapId = "FIX_SOUTH",
|
|
stripLength = 4, width = 4, offset = 0 },
|
|
west = { group = 30, map = 4, mapId = "FIX_WEST",
|
|
stripLength = 4, width = 4, offset = 0 },
|
|
east = { group = 30, map = 5, mapId = "FIX_EAST",
|
|
stripLength = 4, width = 4, offset = 0 },
|
|
},
|
|
},
|
|
FIX_NORTH = neighbour(30, 2, 0x50),
|
|
FIX_SOUTH = neighbour(30, 3, 0x60),
|
|
FIX_WEST = neighbour(30, 4, 0x70),
|
|
FIX_EAST = neighbour(30, 5, 0x80),
|
|
},
|
|
}
|
|
end
|
|
|
|
for _, spec in ipairs({
|
|
{ version = "crystal", L = Gen2Layout.crystal, O = Gen2MapContext.OFFSETS.crystal },
|
|
{ version = "gold", L = Gen2Layout.goldSilver, O = Gen2MapContext.OFFSETS.goldSilver },
|
|
}) do
|
|
local L, O = spec.L, spec.O
|
|
local tpl = template(L, O, 21, 14)
|
|
local bytes, err = Gen2Save.encode(save(30, 1, 0, 0), spec.version, tpl,
|
|
connectedData())
|
|
T.check(bytes, spec.version .. " moved onto a connected map: " .. tostring(err))
|
|
if bytes then
|
|
local northWest = {
|
|
0x00, 0x00, 0x59, 0x5A, 0x5B, 0x5C,
|
|
0x00, 0x00, 0x5D, 0x5E, 0x5F, 0x60,
|
|
0x73, 0x74, 0x11, 0x12, 0x13, 0x14,
|
|
0x77, 0x78, 0x21, 0x22, 0x23, 0x24,
|
|
0x7B, 0x7C, 0x31, 0x32, 0x33, 0x34,
|
|
}
|
|
for i, want in ipairs(northWest) do
|
|
T.eq(u8(bytes, O.screenSave + i - 1), want,
|
|
("%s: connected screen block %d"):format(spec.version, i))
|
|
end
|
|
end
|
|
|
|
bytes, err = Gen2Save.encode(save(30, 1, 7, 7), spec.version, tpl, connectedData())
|
|
T.check(bytes, spec.version .. " moved to the far corner: " .. tostring(err))
|
|
if bytes then
|
|
local southEast = {
|
|
0x22, 0x23, 0x24, 0x85, 0x86, 0x87,
|
|
0x32, 0x33, 0x34, 0x89, 0x8A, 0x8B,
|
|
0x42, 0x43, 0x44, 0x8D, 0x8E, 0x8F,
|
|
0x62, 0x63, 0x64, 0x00, 0x00, 0x00,
|
|
0x66, 0x67, 0x68, 0x00, 0x00, 0x00,
|
|
}
|
|
for i, want in ipairs(southEast) do
|
|
T.eq(u8(bytes, O.screenSave + i - 1), want,
|
|
("%s: far-corner screen block %d"):format(spec.version, i))
|
|
end
|
|
end
|
|
|
|
-- engine/menus/intro_menu.asm:148
|
|
bytes = Gen2Save.encode(save(21, 15, 0, 0), spec.version, tpl, fixtureData(21, 15))
|
|
T.check(bytes, spec.version .. ": an export with no box names at all")
|
|
if bytes then
|
|
local box1 = { 0x81, 0x8E, 0x97, 0xF7, 0x50 }
|
|
for i, want in ipairs(box1) do
|
|
T.eq(u8(bytes, L.wBoxNames + i - 1), want,
|
|
("%s: default box 1 name byte %d"):format(spec.version, i))
|
|
end
|
|
T.eq(u8(bytes, L.wBoxNames + 13 * 9 + 4), 0xFA,
|
|
spec.version .. ": and box 14 ends in a 4")
|
|
end
|
|
end
|
|
|
|
T.finish("gen2 save export map objects")
|