mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
58dfc00538
The Gen 3 extractor emitted eggCycles/eggGroups but never egg moves, so the
gen3_dexnav hidden-mon roll was inert on FireRed: a hidden mon kept its
level-up moves and could never gain an egg move.
Decode the ROM's own gEggMoves table (pokefirered/src/data/pokemon/
egg_moves.h) into pokemon/egg_moves.lua and expose it the same way as
learnsets/tmhm:
- Versions: EGG_MOVES = 0x25EF0C plus the 20000 species offset, the 0xFFFF
run terminator and a defensive per-species cap. The table is one flat u16
stream of `{ species + 20000, move..., 0xFFFF }` runs with no final
terminator, so the first word that is neither a header nor a plausible move
id ends the scan.
- PokemonExtract: extract_egg_moves + write_egg_moves_lua, written to the
cache by run(), required by ready() so a stale cache re-extracts, and
returned as pack.eggMoves. FORMAT_VERSION 3 -> 4.
- Pokemon.eggMoves(species): runtime accessor with the same species coercion
as Pokemon.learnset; nil for a species with no egg move.
- Schemas: eggMoves on monTables/monRecord/writeMon and the Gen 3 species
field list, so a mod reads record.eggMoves as move ids and writes names back.
- Versions.CACHE_VERSION 98 -> 99 to force re-extraction.
The table is sparse: species without egg moves are absent rather than an
empty list, all the way from the ROM to the mod record.
Validated against a supported FireRed USA 1.0 dump: 165 species, 973 moves,
<=8 per species, Bulbasaur {113,130,219,204,80,345,320,174}, Mankey
{157,193,96,68,179,251,279,265} (no Toxic), Mew nil.
Tests: new tests/engine/game3_egg_moves.lua (31 checks ROM-free, 39 with a
ROM), gate_gen3_mod_api 734/734, engine tier 587/587, modkit tier 37/37,
lint --gate clean.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
263 lines
9.4 KiB
Lua
263 lines
9.4 KiB
Lua
-- Headless mod load/merge for the SDK harness (21-testing-and-ci "modkit
|
|
-- test harness"). A mod author with a checkout of the engine and no ROM
|
|
-- can load their mod, merge it into the fixture dataset, and assert on the
|
|
-- result -- the seam that makes `modkit test` possible.
|
|
--
|
|
-- Loader:_discover hard-codes the root "mods", so loading exactly one mod
|
|
-- (or a mod that lives outside mods/) goes through an aliasing filesystem:
|
|
-- "mods/<name>" is rewritten to the real directory and the listing of
|
|
-- "mods" is narrowed to the selected set. Everything else is the
|
|
-- production path -- same Loader, same validate, same topo-sort, same
|
|
-- merge -- so a green SDK test means the mod really loads in the game.
|
|
|
|
local FsIo = require("tests.fs_io")
|
|
local Loader = require("src.mods.Loader")
|
|
local Runtime = require("src.mods.Runtime")
|
|
|
|
local Sdk = {}
|
|
|
|
local function basename(path)
|
|
return (tostring(path):gsub("/+$", ""):match("[^/]+$"))
|
|
end
|
|
|
|
-- rewrite "mods/<alias>" and anything under it to the mod's real location,
|
|
-- and answer getDirectoryItems("mods") with just the selected aliases
|
|
local function aliasFs(inner, alias)
|
|
local fs = { root = inner.root }
|
|
|
|
local function map(path)
|
|
if path == nil then return path end
|
|
for name, real in pairs(alias) do
|
|
local prefix = "mods/" .. name
|
|
if path == prefix then return real end
|
|
if path:sub(1, #prefix + 1) == prefix .. "/" then
|
|
return real .. path:sub(#prefix + 1)
|
|
end
|
|
end
|
|
return path
|
|
end
|
|
|
|
function fs.read(path) return inner.read(map(path)) end
|
|
function fs.write(path, body) return inner.write(map(path), body) end
|
|
function fs.load(path) return inner.load(map(path)) end
|
|
|
|
function fs.getInfo(path)
|
|
if path == "mods" then return { type = "directory" } end
|
|
return inner.getInfo(map(path))
|
|
end
|
|
|
|
function fs.getDirectoryItems(path)
|
|
if path == "mods" then
|
|
local names = {}
|
|
for name in pairs(alias) do names[#names + 1] = name end
|
|
table.sort(names)
|
|
return names
|
|
end
|
|
return inner.getDirectoryItems(map(path))
|
|
end
|
|
|
|
return fs
|
|
end
|
|
|
|
-- flat path -> content filesystem, for cases that synthesize a mod rather
|
|
-- than committing one to disk
|
|
function Sdk.memfs(files)
|
|
local loadstr = loadstring or load
|
|
return {
|
|
read = function(path) return files[path] end,
|
|
write = function(path, body) files[path] = body 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 loadstr(files[path], path)
|
|
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,
|
|
}
|
|
end
|
|
|
|
-- Runtime is process-wide and Loader:load installs into it; a case that
|
|
-- forgets to put it back would leak its buses into the next suite
|
|
local saved
|
|
|
|
function Sdk.captureRuntime()
|
|
saved = { events = Runtime.events, hooks = Runtime.hooks, errors = Runtime.errors }
|
|
end
|
|
|
|
function Sdk.restoreRuntime()
|
|
if not saved then return end
|
|
Runtime.events, Runtime.hooks, Runtime.errors = saved.events, saved.hooks, saved.errors
|
|
Runtime.currentMod = nil
|
|
saved = nil
|
|
end
|
|
|
|
-- opts.data the merge target (defaults to a fresh fixture dataset)
|
|
-- opts.fs override the filesystem entirely (e.g. Sdk.memfs)
|
|
-- opts.root repo root the real paths are relative to
|
|
-- opts.dev force the dev tripwire on
|
|
-- opts.generation 1 (default), 2 or 3; loads as if Gold or FireRed were the
|
|
-- running game, which is the seam the generation gate and the
|
|
-- registry target routing are tested through without a boot;
|
|
-- a Gen 3 run wants opts.data = Sdk.gen3Data()
|
|
function Sdk.loadMods(paths, opts)
|
|
opts = opts or {}
|
|
local data = opts.data or require("tests.modkit.fixtures").fresh()
|
|
|
|
local fs = opts.fs
|
|
if not fs then
|
|
local alias = {}
|
|
for _, path in ipairs(paths) do alias[basename(path)] = path end
|
|
fs = aliasFs(FsIo.new(opts.root or "."), alias)
|
|
end
|
|
|
|
Sdk.captureRuntime()
|
|
local loader = Loader.new({ fs = fs, dev = opts.dev,
|
|
generation = opts.generation })
|
|
local ok, err = pcall(loader.load, loader, data)
|
|
if not ok then
|
|
Sdk.restoreRuntime()
|
|
error(err, 0)
|
|
end
|
|
|
|
local mods = {}
|
|
for _, path in ipairs(paths) do
|
|
for id, mod in pairs(loader.mods) do
|
|
if mod.path == path or basename(mod.path) == basename(path) then mods[id] = mod end
|
|
end
|
|
end
|
|
|
|
return {
|
|
loader = loader,
|
|
data = data,
|
|
mods = mods,
|
|
errors = loader.errors,
|
|
-- release the buses; a case that wants them live calls keep()
|
|
release = function() Sdk.restoreRuntime() end,
|
|
}
|
|
end
|
|
|
|
local function mon(hp, atk, def, spe, spa, spd)
|
|
return { hp = hp, atk = atk, def = def, spe = spe, spa = spa, spd = spd }
|
|
end
|
|
|
|
local function meta(catchRate, expYield, growthRate)
|
|
return { catchRate = catchRate, expYield = expYield, genderRatio = 31,
|
|
eggCycles = 20, friendship = 70, growthRate = growthRate,
|
|
eggGroup1 = 1, eggGroup2 = 1, itemCommon = 0, itemRare = 0 }
|
|
end
|
|
|
|
local function move(power, typeId, accuracy, pp)
|
|
return { effect = 0, power = power, type = typeId, accuracy = accuracy,
|
|
pp = pp, secondaryChance = 0, target = 0, priority = 0, flags = 0 }
|
|
end
|
|
|
|
function Sdk.gen3Data()
|
|
return {
|
|
maps = {
|
|
FR_OAKS_LAB = { id = "FR_OAKS_LAB", name = "OAKS LAB",
|
|
width = 13, height = 12 },
|
|
},
|
|
tilesets = {},
|
|
gen3Pokemon = {
|
|
_names = { [4] = "CHARMANDER", [5] = "CHARMELEON", [16] = "PIDGEY",
|
|
[29] = "NIDORAN\226\153\128", [151] = "MEW",
|
|
[252] = "?" },
|
|
_types = { [4] = { 10, 10 }, [5] = { 10, 10 }, [16] = { 0, 2 },
|
|
[29] = { 3, 3 }, [151] = { 14, 14 } },
|
|
_stats = { [4] = mon(39, 52, 43, 65, 60, 50),
|
|
[5] = mon(58, 64, 58, 80, 80, 65),
|
|
[16] = mon(40, 45, 40, 56, 35, 35),
|
|
[29] = mon(55, 47, 52, 41, 40, 40),
|
|
[151] = mon(100, 100, 100, 100, 100, 100) },
|
|
_speciesMeta = { [4] = meta(45, 65, 3), [5] = meta(45, 142, 3),
|
|
[16] = meta(255, 55, 3), [29] = meta(235, 59, 3),
|
|
[151] = meta(45, 64, 3) },
|
|
_abilities = { [4] = { 66, 0 }, [5] = { 66, 0 }, [16] = { 51, 0 },
|
|
[29] = { 38, 0 }, [151] = { 28, 0 } },
|
|
_abilityNames = { [28] = "SYNCHRONIZE", [38] = "POISON POINT",
|
|
[51] = "KEEN EYE", [66] = "BLAZE" },
|
|
_learnsets = { [4] = { { 1, 10 }, { 7, 52 } }, [5] = { { 1, 10 } },
|
|
[16] = { { 1, 33 } }, [29] = { { 1, 33 } },
|
|
[151] = { { 1, 1 } } },
|
|
-- sparse, exactly like the extractor: a species with no egg move has
|
|
-- no key rather than an empty list
|
|
_eggMoves = { [4] = { 57 }, [16] = { 10, 33 } },
|
|
_evolutions = { [4] = { { method = 4, param = 16, target = 5 } } },
|
|
_dex = { [4] = { category = "LIZARD", height = 6, weight = 85 },
|
|
[151] = { category = "NEW SPECIES", height = 4, weight = 40 } },
|
|
_moveNames = { [0] = "-", [1] = "POUND", [10] = "SCRATCH",
|
|
[33] = "TACKLE", [52] = "EMBER", [57] = "SURF" },
|
|
},
|
|
gen3Moves = {
|
|
_rom = { [1] = move(40, 0, 100, 35), [10] = move(40, 0, 100, 35),
|
|
[33] = move(35, 0, 95, 35), [52] = move(40, 10, 100, 25),
|
|
[57] = move(95, 11, 100, 15) },
|
|
},
|
|
gen3Items = {
|
|
_byId = {
|
|
[0] = { name = "????????", pocket = "ITEMS", price = 0 },
|
|
[4] = { name = "POK\195\169 BALL", pocket = "POKE_BALLS", price = 200 },
|
|
[13] = { name = "POTION", pocket = "ITEMS", price = 300 },
|
|
[96] = { name = "THUNDERSTONE", pocket = "ITEMS", price = 2100 },
|
|
},
|
|
},
|
|
gen3Encounters = {
|
|
FR_ROUTE_1 = { mapGroup = 3, mapNum = 19, land = { rate = 21, slots = {
|
|
{ species = 16, minLevel = 2, maxLevel = 3 },
|
|
{ species = 29, minLevel = 2, maxLevel = 4 } } } },
|
|
["3:19"] = { mapGroup = 3, mapNum = 19, land = { rate = 21, slots = {
|
|
{ species = 16, minLevel = 2, maxLevel = 3 },
|
|
{ species = 29, minLevel = 2, maxLevel = 4 } } } },
|
|
},
|
|
gen3Trainers = {
|
|
classNames = { [81] = "RIVAL" },
|
|
trainers = {
|
|
[326] = { class = 81, className = "RIVAL", name = "TERRY",
|
|
partySize = 1, party = { { species = 4, level = 5 } },
|
|
dialogs = {} },
|
|
},
|
|
},
|
|
gen3Text = {
|
|
Text_BootedUpPC = { { t = "player" }, { t = "text", s = " booted up the PC." },
|
|
{ t = "eos" } },
|
|
},
|
|
gen3Scripts = {
|
|
EventScript_Fixture = { { op = "msgbox", text = "Text_BootedUpPC" },
|
|
{ op = "end" } },
|
|
},
|
|
}
|
|
end
|
|
|
|
function Sdk.loadMod(path, opts)
|
|
local result = Sdk.loadMods({ path }, opts)
|
|
result.mod = select(2, next(result.mods))
|
|
return result
|
|
end
|
|
|
|
-- load nothing: the no-mod baseline every parity gate compares against
|
|
function Sdk.loadNone(opts)
|
|
return Sdk.loadMods({}, opts)
|
|
end
|
|
|
|
return Sdk
|