CLOSES #3048, CLOSES #3049, CLOSES #3055, CLOSES #3057, CLOSES #3058, CLOSES #3059, CLOSES #3061, CLOSES #3062

This commit is contained in:
bryanthaboi
2026-10-26 12:24:51 -04:00
parent 535a5ec088
commit 2aec7650cb
72 changed files with 2977 additions and 452 deletions
+8 -2
View File
@@ -269,7 +269,10 @@ Records containing functions, userdata, threads, metatables, or cycles are not
exposed. Each open call receives an independent facade, so one mod cannot
replace another mod view method. Canonical boot shaping is included, such as
Gen 1 defaults and Yellow corrections, and Gold's Foresight matchup rows and
derived `held_items`.
derived `held_items`. Gold, Silver and Crystal also expose their overworld
emote sheets as `content.field:get("emotes")`, path strings for
`view.assets:path` / `info` that include the fishing pose `fishing` and
Crystal's `fishingFemale`; the same key is patchable in game.
`open` checks the completion marker, exact version-specific file inventory,
source/cache boundary, and file-size bounds without reading or decoding the
@@ -797,7 +800,9 @@ mod.content.pokemon:patch("MEW", { battleScaleBack = 1.5 })
```
`battleScaleFront` scales the enemy pic, `battleScaleBack` the player pic;
both take a number in `0.25 .. 4.0`.
both take a number in `0.25 .. 4.0`. Both work on Gen 1 and Gen 2 under
`api` 2 and `api` 3. Gen 3 has no species pic scale: an `api` 3 write there
returns `nil, reason`.
Per image, on the `battle_sprite_scales` registry, keyed by the asset path
exactly as the data references it:
@@ -1699,6 +1704,7 @@ The portable registries are `pokemon`, `moves`, `items`, `trainers` and `encount
- Species: `baseStats.hp`, `attack`, `defense`, `speed`, `special`; `level1Moves`, `learnset = { { level = 9, move = "TACKLE" } }`, `evolutions = { { method = "LEVEL", level = 16, species = "CHARMELEON" } }`; string species, move, item, type and growth-rate IDs.
- Gen 2 `levelMoves` and `into`, and Gen 3 numeric tables, are translated to that shape. Writing `special` sets both later Special stats; optional `specialAttack` and `specialDefense` carry the split, and Gen 1 requires them equal.
- Species `battleScaleFront`, `battleScaleBack` and `battleTheme` are top-level fields on Gen 1 and Gen 2. Gen 3 refuses them with `nil, reason`. The other Gen 1 presentation fields (`icon`, `cry`, `palette`) and the Gen 1 move extras (`anim`, `highCrit`, `fixedDamage`, `multiHit`, `chargeText`, `semiInvulnerable`, `counterable`) are Gen 1 only.
- Moves use Gen 1 effect IDs such as `NO_ADDITIONAL_EFFECT`.
- Trainers: `parties = { { { species = "MEW", level = 5 } } }` plus `partyNames`. Encounter slots: `{ species = "MEW", level = 5 }`; a shared encounter write applies to every Gen 2 time of day.
- Text keeps the Gen 1 control characters with symbolic placeholders such as `{PLAYER}`.
+36 -4
View File
@@ -374,6 +374,15 @@ A write here is dropped and reported on a Gen 3 boot.
mod.content.cries:patch("PIKACHU", { pitch = 200 })
```
### On Gold (Gen 2)
- semantics: `record`
- target: `Data.audio.cries`
The record differs; the registry name, the verbs and the id space
do not.
- value: {header, length, pitch} | {file} | {base, length?, pitch?} | {chip, length?, pitch?}
## decorations
- semantics: `record`
@@ -572,13 +581,14 @@ write nothing reads.
| key | type |
|---|---|
| `boot` | {namePresets?, rivalName?, startFacing?, startMap?, startMoney?, startX?, startY?} |
| `emotes` | {bolt?, cutGrass?, fish?, fishing?, fishingFemale?, grassRustle?, happy?, heart?, jumpShadow?, order?, question?, sad?, shock?, sleep?} |
<!-- snippet: illustrative -->
```lua
mod.content.field:patch("boot", { startMap = "NEW_BARK_TOWN", startX = 6, startY = 8 })
```
Gold reads only `boot`: the New Game spawn (`startMap`, `startX`, `startY`, `startFacing`, replacing SPAWN_HOME), the starting money, the rival's starting name and the naming presets. Every other Gen 1 key has no Gold consumer: `ledges` (Gold's ledges are tile collision types), `hiddenItems` (bg events in `maps`), `badgeGates`, `townMap` (`landmarks`), `flyOrder` (the flypoint table), `playerPics`, and `boot.lastHeal`, `boot.screens`, `boot.starterScript`, `boot.title`, `boot.playerName`.
Gold reads `boot` and `emotes`. `boot` is the New Game spawn (`startMap`, `startX`, `startY`, `startFacing`, replacing SPAWN_HOME), the starting money, the rival's starting name and the naming presets. `emotes` is the overworld emote art, one image path per sheet: the showemote bubbles listed in `order`, `grassRustle`, `jumpShadow`, `cutGrass`, the fishing pose `fishing` and Crystal's `fishingFemale`; a patched path is the sheet the overworld draws. Every other Gen 1 key has no Gold consumer: `ledges` (Gold's ledges are tile collision types), `hiddenItems` (bg events in `maps`), `badgeGates`, `townMap` (`landmarks`), `flyOrder` (the flypoint table), `playerPics`, and `boot.lastHeal`, `boot.screens`, `boot.starterScript`, `boot.title`, `boot.playerName`.
### On Gen 3
@@ -728,7 +738,7 @@ do not.
| `id` | string | yes |
| `index` | integer 1..65535 | no |
| `machine` | {kind, move, number} | no |
| `name` | string | yes |
| `name` | string | no |
| `needsTarget` | boolean | no |
| `price` | integer >= 0 | yes |
| `tossable` | boolean | no |
@@ -774,8 +784,8 @@ and reported. See the Gold subsection below for where it does land.
| `id` | string | no |
| `index` | integer >= 0 | yes |
| `name` | string | yes |
| `x` | integer >= 0 | yes |
| `y` | integer >= 0 | yes |
| `x` | integer -8..247 | yes |
| `y` | integer -16..239 | yes |
<!-- snippet: illustrative -->
```lua
@@ -1668,6 +1678,10 @@ mod.content.text:override("_PalletTownText1", "HELLO!")
- semantics: `record`
- target: `Data.gen2Text`
The record differs; the registry name, the verbs and the id space
do not.
- value: string
### On Gen 3
- semantics: `record`
@@ -1734,6 +1748,24 @@ mod.content.tilesets:register("MY_TILES", { image = "...", blocks = { ... } })
- semantics: `record`
- target: `Data.gen2Tilesets`
The record differs; the registry name, the verbs and the id space
do not.
| field | type | required |
|---|---|---|
| `animation` | string | no |
| `blocks` | list of any value | yes |
| `counterTiles` | any value | no |
| `doorTiles` | any value | no |
| `id` | string | no |
| `image` | file path | yes |
| `imageHeight` | integer >= 1 | no |
| `imageWidth` | integer >= 1 | no |
| `tilesPerRow` | integer >= 1 | no |
| `trueColor` | boolean | no |
| `walkable` | any value | no |
| `warpTiles` | any value | no |
### On Gen 3
Not available on Gen 3: there is no native target for this registry.
+8
View File
@@ -6,6 +6,14 @@ local GameVersion = require("src.core.GameVersion")
local DatasetHydration = {}
function DatasetHydration.seedGen2Field(data)
local field = data.field or {}
local menuGfx = data.gen2MenuGfx
field.emotes = menuGfx and menuGfx.emotes
data.field = field
return field
end
function DatasetHydration.applyGen2(data, moduleLoader)
local chart = data.type_chart or {}
chart.matchups = chart.matchups or {}
+8
View File
@@ -1080,6 +1080,12 @@ function Game2:syncEngine()
return eng
end
function Game2.applyFieldEmotes(data)
if data.gen2MenuGfx and data.field then
data.gen2MenuGfx.emotes = data.field.emotes
end
end
function Game2:load(opts)
opts = opts or {}
local arena = opts.arena
@@ -1177,6 +1183,7 @@ function Game2:load(opts)
-- through both; World:load used to set this from a second disk read, which
-- is what dropped a merge on the floor.
self.data.trainers = self.data.gen2Trainers
require("src.core.DatasetHydration").seedGen2Field(self.data)
-- Mods. Discovery and the manager are generation-agnostic -- they read
-- manifests and the enable/disable state, not game data -- so Gold gets the
@@ -1223,6 +1230,7 @@ function Game2:load(opts)
-- consumers (src/core/gen2/Nests.lua, src/ui/gen2/MapRadio.lua) read
-- game.data at the point of use.
ItemEffects.applyHeldItems(self.data, heldBefore)
Game2.applyFieldEmotes(self.data)
require("src.core.gen2.Phone").useRegistry(self.data)
require("src.core.gen2.Decorations").useRegistry(self.data)
require("src.core.gen2.Apricorns").useRegistry(self.data)
+1 -1
View File
@@ -1335,7 +1335,7 @@ local function disobedient(M)
local mon = State.partyMon(user) or user.mon or {}
local species = tonumber(user.species)
local ob = 0
if not ((species == 151 or species == 410) and mon.fatefulEncounter == false) then
if not ((species == 151 or species == 410) and mon.modernFatefulEncounter == false) then
-- pokeemerald/src/battle_util.c:3924
if BattleProfile.isRse(st) and Kinds.has(st, "frontier") then return nil end
if not Engine.isTradedMon(st, mon) or Engine.hasBadge(st, 8) then return nil end
+2
View File
@@ -317,6 +317,8 @@ local function foe_mon_from(foe, link, trainerPolicy)
ability = foe.ability or foe.abilityId,
dvs = foe.dvs,
friendship = foe.friendship,
-- pokefirered/src/pokemon.c:2029
modernFatefulEncounter = foe.modernFatefulEncounter,
}
if nativeTrainer or foe.otId ~= nil or foe.otSecretId ~= nil then
mon.otId, mon.otSecretId = foe.otId, foe.otSecretId
+1 -1
View File
@@ -491,7 +491,7 @@ local function canTradeRse(party, monIdx, opts, version)
end
end
if species[idx] == DEOXYS or species[idx] == MEW then
if party[idx] and party[idx].fatefulEncounter == false then
if party[idx] and party[idx].modernFatefulEncounter == false then
return Family.CANT_TRADE_INVALID_MON
end
end
+1 -1
View File
@@ -333,7 +333,7 @@ function LT.checkValidityOfTradeMons(slot, partnerSlot)
local peerMon = LT.peerParty[(tonumber(partnerSlot) or 1)]
local species = tonumber(peerMon and peerMon.species) or 0
-- pokefirered/include/constants/species.h:155
if (species == 151 or species == 410) and peerMon and peerMon.fatefulEncounter == false then
if (species == 151 or species == 410) and peerMon and peerMon.modernFatefulEncounter == false then
return LT.PARTNER_MON_INVALID
end
if alive == 0 then return LT.PLAYER_MON_INVALID end
@@ -48,8 +48,8 @@ NativesEventIslands.BY_NAME = {
local Enc = require("src.core.game3.encounters")
local item = Rse.specialVar(ctx, 0x8006)
Enc.setWildBattle(Rse.specialVar(ctx, 0x8004), Rse.specialVar(ctx, 0x8005), item ~= 0 and item or nil)
-- pokeemerald/src/pokemon.c:2635
if Enc._pendingWild then Enc._pendingWild.fatefulEncounter = true end
-- pokeemerald/src/pokemon.c:2638
if Enc._pendingWild then Enc._pendingWild.modernFatefulEncounter = true end
return false
end,
-- pokeemerald/src/field_specials.c:3389
+2 -2
View File
@@ -85,9 +85,9 @@ Events.BY_NAME = {
local item = varGet(ctx, VAR_0x8006)
Enc.setWildBattle(varGet(ctx, VAR_0x8004), varGet(ctx, VAR_0x8005),
item ~= 0 and item or nil)
-- pokefirered/src/pokemon.c:2026
-- pokefirered/src/pokemon.c:2029
local pending = Enc._pendingWild
if pending then pending.fatefulEncounter = true end
if pending then pending.modernFatefulEncounter = true end
return false
end,
-- pokefirered/src/safari_zone.c:27
+1 -1
View File
@@ -192,7 +192,7 @@ function Trade.canTradeSelectedMon(party, monIdx, opts)
-- pokefirered/src/trade.c:2795
if species[idx] == SPECIES_DEOXYS or species[idx] == SPECIES_MEW then
if party[idx] and party[idx].fatefulEncounter == false then
if party[idx] and party[idx].modernFatefulEncounter == false then
return Trade.CANT_TRADE_INVALID_MON
end
end
+2 -2
View File
@@ -317,7 +317,7 @@ end
local function egg_origin_index(mon, heldByOt)
if mon.isBadEgg then return 0 end
local metLocation = tonumber(mon.metLocation) or 0
if metLocation == METLOC_FATEFUL_ENCOUNTER or mon.fatefulEncounter == true then return 4 end
if metLocation == METLOC_FATEFUL_ENCOUNTER or mon.modernFatefulEncounter == true then return 4 end
local idx = 0
local game = tonumber(mon.metGame)
if game ~= nil and game ~= 4 and game ~= 5 then
@@ -368,7 +368,7 @@ function SummaryData.formatTrainerMemo(mon, playerState, opts)
if metLevel == 0 then metLevel = 5 end
local metLocation = tonumber(mon.metLocation) or 0
local fatefulMet = metLocation == METLOC_FATEFUL_ENCOUNTER
local fatefulFlag = mon.fatefulEncounter == true
local fatefulFlag = mon.modernFatefulEncounter == true
local key
local mapName
+1
View File
@@ -768,6 +768,7 @@ local SEMANTIC_MODULES = {
"pokemon", "moves", "items", "type_chart", "audio", "font", "maps",
"tilesets", "text", "rom_text", "trainers", "encounters", "sprites",
"palettes", "icons", "battle_anims", "constants", "landmarks",
"menu_gfx",
},
[3] = { "maps", "intro", "audio" },
}
+12
View File
@@ -320,6 +320,18 @@ local function ensureDirectory(parent)
return false, "could not create " .. parent .. ": " .. reason
end
function CacheFs.createDirectory(rel)
rel = withPrefix(rel)
if unsafe_rel(rel) then return false, "unsafe cache path" end
local root, rootErr = resolveWriteRoot()
if not root and rootErr then return false, rootErr end
if root then
ensureParents(root, rel .. "/_")
return true
end
return ensureDirectory(rel)
end
-- write cache-relative `rel` (forward-slash path) with the given bytes;
-- returns ok, err like love.filesystem.write
function CacheFs.write(rel, data)
+1 -4
View File
@@ -211,10 +211,7 @@ function RomExtractorGen3:writeRequiredMarkers(sha1)
end
for _, d in ipairs(self.plan.dirs or {}) do
if love and love.filesystem and love.filesystem.createDirectory then
local p = (CacheFs.prefix or "") .. GBA_ROOT .. d
pcall(love.filesystem.createDirectory, p)
end
CacheFs.createDirectory(GBA_ROOT .. d)
end
end
+6 -23
View File
@@ -89,37 +89,20 @@ function BattleMovesExtract.packToLua(pack)
return table.concat(lines, "\n")
end
--- Write via cache object (preferred) or filesystem outDir.
function BattleMovesExtract.writeCache(pack, cacheOrDir, opts)
opts = opts or {}
local body = BattleMovesExtract.packToLua(pack)
local root = (opts.cacheRoot or default_cache_root()) .. "/" .. BattleMovesExtract.CACHE_SUB
local rel = root .. "/battle_moves.lua"
local ok, err
if type(cacheOrDir) == "table" and cacheOrDir.write then
cacheOrDir:write(rel, body)
return rel
ok, err = cacheOrDir:write(rel, body)
else
ok, err = require("src.import.CacheFs").write(rel, body)
end
local okC, CacheFs = pcall(require, "src.import.CacheFs")
if okC and CacheFs and CacheFs.write then
local ok = pcall(CacheFs.write, rel, body)
if ok then return rel end
end
if love and love.filesystem and love.filesystem.write then
local ok = pcall(love.filesystem.write, rel, body)
if ok then return rel end
end
local outDir = (type(cacheOrDir) == "string" and cacheOrDir) or root
pcall(os.execute, 'mkdir -p "' .. outDir .. '"')
local f = io.open(outDir .. "/battle_moves.lua", "wb")
if f then
f:write(body)
f:close()
end
return outDir .. "/battle_moves.lua"
if ok == false then error("battle_moves: could not write " .. rel .. ": " .. tostring(err)) end
return rel
end
function BattleMovesExtract.run(rom, cache, opts)
@@ -4319,6 +4319,8 @@ return {
[0x463150] = 0x462B70, -- tm_case.o:sMenuActions + 0
[0x463178] = 0x462B98, -- tm_case.o:sText_ClearTo18 + 0
[0x46317C] = 0x462B9C, -- tm_case.o:sText_SingleSpace + 0
[0x463180] = 0x462BA0, -- tm_case.o:sPal3Override + 0
[0x463238] = 0x462C58, -- tm_case.o:sTMSpritePaletteOffsetByType + 0
[0x46325C] = 0x462C7C, -- menu_indicators.o:sScrollIndicatorTemplates + 0
[0x463308] = 0x462D28, -- menu_indicators.o:sRedArrowPal + 0
[0x463328] = 0x462D48, -- menu_indicators.o:sRedArrowOtherGfx + 0
+6 -5
View File
@@ -84,13 +84,14 @@ function ExtractAudio.resolveRoles(C, extra)
end
local function write(cache, path, data)
local ok, err
if cache and cache.write then
return cache:write(path, data)
ok, err = cache:write(path, data)
else
ok, err = require("src.import.CacheFs").write(path, data)
end
if love and love.filesystem and love.filesystem.write then
return love.filesystem.write(path, data)
end
return false
if ok == false then error("audio: could not write " .. path .. ": " .. tostring(err)) end
return true
end
local function rom_bytes(rom)
+6 -5
View File
@@ -8,13 +8,14 @@ local ExtractIntro = {}
ExtractIntro.ASSETS = Versions.INTRO
local function write(cache, path, data)
local ok, err
if cache and cache.write then
return cache:write(path, data)
ok, err = cache:write(path, data)
else
ok, err = require("src.import.CacheFs").write(path, data)
end
if love and love.filesystem and love.filesystem.write then
return love.filesystem.write(path, data)
end
return false
if ok == false then error("intro: could not write " .. path .. ": " .. tostring(err)) end
return true
end
local function romBytes(rom)
+6 -5
View File
@@ -11,13 +11,14 @@ ExtractNaming.CACHE_SUB = "gba/naming"
ExtractNaming.ASSETS = Versions.NAMING
local function write(cache, path, data)
local ok, err
if cache and cache.write then
return cache:write(path, data)
ok, err = cache:write(path, data)
else
ok, err = require("src.import.CacheFs").write(path, data)
end
if love and love.filesystem and love.filesystem.write then
return love.filesystem.write(path, data)
end
return false
if ok == false then error("naming: could not write " .. path .. ": " .. tostring(err)) end
return true
end
local function romBytes(rom)
+5 -13
View File
@@ -256,21 +256,13 @@ function ItemsExtract.run(rom, cache, opts)
local outputText = table.concat(lines, "\n")
local wrote = false
local okW, errW
if cache and cache.write then
cache:write(outRel, outputText)
wrote = true
end
if not wrote then
local okC, CacheFs = pcall(require, "src.import.CacheFs")
if okC and CacheFs and CacheFs.write then
local ok = pcall(CacheFs.write, outRel, outputText)
if ok then wrote = true end
end
end
if not wrote and love and love.filesystem and love.filesystem.write then
pcall(love.filesystem.write, outRel, outputText)
okW, errW = cache:write(outRel, outputText)
else
okW, errW = require("src.import.CacheFs").write(outRel, outputText)
end
if okW == false then error("items: could not write " .. outRel .. ": " .. tostring(errW)) end
return {
ok = true,
+7 -21
View File
@@ -392,28 +392,14 @@ function MapSectionsExtract.run(rom, cache, opts)
end
local root = opts.cacheRoot or "data/generated/gba"
local content = MapSectionsExtract.formatLua()
if cache and cache.write then
cache:write(root .. "/region_map/map_sections.lua", content)
cache:write(root .. "/map_sections.lua", content)
else
local okFs, CacheFs = pcall(require, "src.import.CacheFs")
local wrote = false
if okFs and CacheFs and CacheFs.write then
local ok1 = pcall(CacheFs.write, root .. "/region_map/map_sections.lua", content)
local ok2 = pcall(CacheFs.write, root .. "/map_sections.lua", content)
if ok1 or ok2 then wrote = true end
end
if not wrote then
local function write_file(path, str)
local dir = path:match("^(.*)/[^/]+$")
if dir then pcall(os.execute, "mkdir -p '" .. dir .. "'") end
local f = io.open(path, "w")
if f then f:write(str); f:close(); return true end
return false
end
write_file(root .. "/region_map/map_sections.lua", content)
write_file(root .. "/map_sections.lua", content)
for _, rel in ipairs({ root .. "/region_map/map_sections.lua", root .. "/map_sections.lua" }) do
local ok, err
if cache and cache.write then
ok, err = cache:write(rel, content)
else
ok, err = require("src.import.CacheFs").write(rel, content)
end
if ok == false then error("map_sections: could not write " .. rel .. ": " .. tostring(err)) end
end
return true
end
+5 -15
View File
@@ -125,23 +125,13 @@ function MultichoiceExtract.run(rom, cache, opts)
local content = MultichoiceExtract.formatLua(lists)
local rel = multichoice_path(opts.cacheRoot)
local wrote, err = false, nil
local ok, err
if cache and cache.write then
local ok, werr = cache:write(rel, content)
if ok == false then err = werr else wrote = true end
ok, err = cache:write(rel, content)
else
ok, err = require("src.import.CacheFs").write(rel, content)
end
if not wrote then
local okC, CacheFs = pcall(require, "src.import.CacheFs")
if okC and CacheFs and CacheFs.write then
local ok, werr = pcall(CacheFs.write, rel, content)
if ok then wrote = true else err = err or werr end
end
end
if not wrote and love and love.filesystem and love.filesystem.write then
local ok, werr = pcall(love.filesystem.write, rel, content)
if ok then wrote = true else err = err or werr end
end
if not wrote then
if ok == false then
error("multichoice: could not write " .. rel .. ": " .. tostring(err))
end
+1
View File
@@ -20,6 +20,7 @@ return {
"src.import.gba.weather_extract",
"src.import.gba.ingame_trades_extract",
"src.import.gba.union_room_classes_extract",
"src.import.gba.tm_case_extract",
},
auxSteps = 21,
+5 -18
View File
@@ -114,26 +114,13 @@ local function escape_lua(s)
end
local function write_file(cache, relPath, content)
local wrote = false
local ok, err
if cache and cache.write then
cache:write(relPath, content)
wrote = true
end
if not wrote then
local okC, CacheFs = pcall(require, "src.import.CacheFs")
if okC and CacheFs and CacheFs.write then
local ok = pcall(CacheFs.write, relPath, content)
if ok then wrote = true end
end
end
if not wrote and love and love.filesystem and love.filesystem.write then
pcall(love.filesystem.write, relPath, content)
end
local f = io.open(relPath, "wb") or io.open("data/generated/gba/" .. relPath:gsub("^data/generated/gba/", ""), "wb")
if f then
f:write(content)
f:close()
ok, err = cache:write(relPath, content)
else
ok, err = require("src.import.CacheFs").write(relPath, content)
end
if ok == false then error("pokedex: could not write " .. relPath .. ": " .. tostring(err)) end
end
--- Extract entries.lua from ROM gPokedexEntries table
-5
View File
@@ -1274,11 +1274,6 @@ function PokemonExtract.run(rom, cache, opts)
TrainerCardExtract.run(rom, cache, { cacheRoot = cacheRoot })
end)
local TmCaseExtract = require("src.import.gba.tm_case_extract")
pcall(function()
TmCaseExtract.run(rom, cache, { cacheRoot = cacheRoot })
end)
local BerryPouchExtract = require("src.import.gba.berry_pouch_extract")
pcall(function()
BerryPouchExtract.run(rom, cache, { cacheRoot = cacheRoot })
+1 -16
View File
@@ -252,22 +252,7 @@ local function write_file(cache, path, bytes)
if cache and cache.write then
return cache:write(path, bytes)
end
local okC, CacheFs = pcall(require, "src.import.CacheFs")
if okC and CacheFs and CacheFs.write then
local ok = pcall(CacheFs.write, path, bytes)
if ok then return true end
end
if love and love.filesystem and love.filesystem.write then
local ok = pcall(love.filesystem.write, path, bytes)
if ok then return true end
end
local f = io.open(path, "wb")
if f then
f:write(bytes)
f:close()
return true
end
return false
return require("src.import.CacheFs").write(path, bytes)
end
-- src/region_map.c:939
+6 -35
View File
@@ -73,40 +73,15 @@ local function ptr_to_offset(addr)
return addr - 0x08000000
end
local function ensure_dir(dir)
pcall(function()
local lfs = require("lfs")
local current = ""
for part in dir:gmatch("[^/]+") do
current = current == "" and part or (current .. "/" .. part)
lfs.mkdir(current)
end
end)
end
local function write_file(cache, path, data)
local ok, err
if cache and cache.write then
cache:write(path, data)
return true
ok, err = cache:write(path, data)
else
ok, err = require("src.import.CacheFs").write(path, data)
end
local okC, CacheFs = pcall(require, "src.import.CacheFs")
if okC and CacheFs and CacheFs.write then
local ok = pcall(CacheFs.write, path, data)
if ok then return true end
end
if love and love.filesystem and love.filesystem.write then
local ok = pcall(love.filesystem.write, path, data)
if ok then return true end
end
local dir = path:match("^(.*)/[^/]+$")
if dir then ensure_dir(dir) end
local f = io.open(path, "wb")
if f then
f:write(data)
f:close()
return true
end
return false
if ok == false then error("storage: could not write " .. path .. ": " .. tostring(err)) end
return true
end
local function read_palette(rom, off, count)
@@ -303,9 +278,6 @@ function StorageChromeExtract.extract(rom, opts)
return { ok = false, root = outDir, count = 0, err = "storage chrome needs a ROM" }
end
ensure_dir(outDir)
ensure_dir(outDir .. "/wallpapers")
local palOff = Versions.STORAGE_PALETTES or {}
local sheetOff = Versions.STORAGE_SHEETS or {}
local mapOff = Versions.STORAGE_TILEMAPS or {}
@@ -498,7 +470,6 @@ end
function StorageChromeExtract.extractFriends(rom, cache, outDir)
local F = Versions.STORAGE_FRIENDS
local dir = outDir .. "/" .. StorageChromeExtract.FRIENDS_SUB
ensure_dir(dir)
local written = 0
local lines = { "return {", " patterns = {" }
for i = 0, F.patternCount - 1 do
+49 -23
View File
@@ -7,7 +7,23 @@ local Lz77 = require("src.import.gba.lz77")
local TmCaseExtract = {}
TmCaseExtract.CACHE_SUB = "items/tm_case"
TmCaseExtract.FORMAT_VERSION = 1
TmCaseExtract.FORMAT_VERSION = 2
-- include/constants/pokemon.h:115 NUMBER_OF_MON_TYPES
TmCaseExtract.DISC_TYPES = 18
TmCaseExtract.REQUIRED = {
TmCaseExtract.CACHE_SUB .. "/manifest.lua",
TmCaseExtract.CACHE_SUB .. "/bg_male.rgba",
TmCaseExtract.CACHE_SUB .. "/bg_female.rgba",
TmCaseExtract.CACHE_SUB .. "/cover_male.rgba",
TmCaseExtract.CACHE_SUB .. "/cover_female.rgba",
TmCaseExtract.CACHE_SUB .. "/hm_icon.rgba",
}
for t = 0, TmCaseExtract.DISC_TYPES - 1 do
local R = TmCaseExtract.REQUIRED
R[#R + 1] = string.format("%s/disc_%d.rgba", TmCaseExtract.CACHE_SUB, t)
R[#R + 1] = string.format("%s/disc_hm_%d.rgba", TmCaseExtract.CACHE_SUB, t)
end
local function default_cache_root()
local ok, Extract = pcall(require, "src.import.gba.extract_island1")
@@ -263,33 +279,40 @@ function TmCaseExtract.run(rom, cache, opts)
cache:write(root .. "/cover_male.rgba", coverMale)
cache:write(root .. "/cover_female.rgba", coverFemale)
-- 3. Discs (TM + HM for each type 0..16)
local discGfx = Lz77.decompress(get, Versions.TM_CASE_DISC_GFX)
local discPal1 = Lz77.decompress(get, Versions.TM_CASE_DISC_TYPES1_PAL)
local discPal2 = Lz77.decompress(get, Versions.TM_CASE_DISC_TYPES2_PAL)
local banks1 = load_pal_banks(discPal1, 16)
local banks2 = load_pal_banks(discPal2, 1)
-- src/tm_case.c:1727 LoadDiscTypePalettes
local colors = {}
local function load_colors(bytes, base)
for c = 0, math.floor(byte_len(bytes) / 2) - 1 do
colors[base + c] = (bytes[c * 2 + 1] or 0) + (bytes[c * 2 + 2] or 0) * 256
end
end
load_colors(discPal1, 0)
load_colors(discPal2, 0x100)
local discBanks = {}
for b = 0, 15 do discBanks[b] = banks1[b] end
discBanks[16] = banks2[0] -- Dragon type
for typeIdx = 0, 16 do
local bank = discBanks[typeIdx] or {}
local discRgba = bake_disc_rgba(discGfx, bank, false)
cache:write(string.format("%s/disc_%d.rgba", root, typeIdx), discRgba)
local hmDiscRgba = bake_disc_rgba(discGfx, bank, true)
cache:write(string.format("%s/disc_hm_%d.rgba", root, typeIdx), hmDiscRgba)
-- src/tm_case.c:1649 TintDiscpriteByType
for typeIdx = 0, TmCaseExtract.DISC_TYPES - 1 do
local at = Versions.TM_CASE_DISC_PAL_OFFSETS + typeIdx * 2
local offset = get(at) + get(at + 1) * 256
local bank = {}
for c = 0, 15 do
bank[c] = assert(colors[offset + c], string.format("tm case disc palette offset 0x%X out of range", offset))
end
cache:write(string.format("%s/disc_%d.rgba", root, typeIdx), bake_disc_rgba(discGfx, bank, false))
cache:write(string.format("%s/disc_hm_%d.rgba", root, typeIdx), bake_disc_rgba(discGfx, bank, true))
end
-- 4. HM Icon (16x16 tiled bitmap in ROM, 16x12 visible; FRLG tm_case.c:1491,1589)
local hmGfx = {}
for i = 1, 128 do hmGfx[i] = rom:get(Versions.TM_CASE_HM_GFX + i - 1) end
-- FRLG sPal3Override on WIN_LIST palette 15: color 6 = RGB(8,8,8), color 7 = RGB(30,16,6)
-- src/tm_case.c:1491
local ov = Versions.TM_CASE_PAL3_OVERRIDE
local hmPal = {
[0] = 0,
[6] = 8 + 8 * 32 + 8 * 1024, -- RGB(8, 8, 8) = 0x2108 (dark grey)
[7] = 30 + 16 * 32 + 6 * 1024, -- RGB(30, 16, 6) = 0x1A1E (orange)
[6] = get(ov) + get(ov + 1) * 256,
[7] = get(ov + 2) + get(ov + 3) * 256,
}
local hmRgba = bake_hm_icon_rgba(hmGfx, hmPal)
cache:write(root .. "/hm_icon.rgba", hmRgba)
@@ -301,26 +324,29 @@ return {
height = %d,
discW = 32,
discH = 32,
discCount = 17,
discCount = %d,
hmW = 16,
hmH = 12,
}
]], TmCaseExtract.FORMAT_VERSION, W, H)
]], TmCaseExtract.FORMAT_VERSION, W, H, TmCaseExtract.DISC_TYPES)
cache:write(root .. "/manifest.lua", manifest)
print(string.format("[tm_case_extract] TM Case chrome baked (2 BGs, 2 Covers, 17 TM discs, 17 HM discs, HM icon) -> %s", root))
print(string.format("[tm_case_extract] TM Case chrome baked (2 BGs, 2 Covers, %d TM discs, %d HM discs, HM icon) -> %s",
TmCaseExtract.DISC_TYPES, TmCaseExtract.DISC_TYPES, root))
return {
root = root,
width = W,
height = H,
discCount = 17,
discCount = TmCaseExtract.DISC_TYPES,
}
end
function TmCaseExtract.ready(cache, cacheRoot)
local root = (cacheRoot or default_cache_root()) .. "/" .. TmCaseExtract.CACHE_SUB
if cache and cache.exists then
return cache:exists(root .. "/bg_male.rgba") and cache:exists(root .. "/cover_male.rgba") and cache:exists(root .. "/disc_0.rgba")
for _, rel in ipairs(TmCaseExtract.REQUIRED) do
if not cache:exists((cacheRoot or default_cache_root()) .. "/" .. rel) then return false end
end
return true
end
return false
end
+3 -1
View File
@@ -43,7 +43,7 @@ Versions.ROM_SIZE = 16777216
-- v115: LeafGreen profiles, edition-specific title assets and Deoxys stats.
-- v121: chrome/fonts/japanese_{normal,small}_* and japanese_widths.lua, the
-- cart's Japanese fonts, for text a Japanese translation mod prints.
Versions.CACHE_VERSION = 140
Versions.CACHE_VERSION = 141
Versions.NATIVE_VERSION = 6
Versions.OW_VERSION = 3
Versions.ANIM_VERSION = 1
@@ -1642,6 +1642,8 @@ Versions.TM_CASE_DISC_GFX = 0xE84D90 -- gTMCaseDisc_Gfx (LZ 4bpp, 10
Versions.TM_CASE_DISC_TYPES1_PAL = 0xE84F20 -- gTMCaseDiscTypes1_Pal (LZ 16 banks, 512 bytes)
Versions.TM_CASE_DISC_TYPES2_PAL = 0xE85068 -- gTMCaseDiscTypes2_Pal (LZ 1 bank, 32 bytes)
Versions.TM_CASE_HM_GFX = 0xE99118 -- gTMCaseHM_Gfx (raw 4bpp, 128 bytes)
Versions.TM_CASE_DISC_PAL_OFFSETS = 0x463238 -- src/tm_case.c:396 sTMSpritePaletteOffsetByType
Versions.TM_CASE_PAL3_OVERRIDE = 0x463180 -- src/tm_case.c:235 sPal3Override
-- Berry Pouch (LZ-compressed; FireRed USA 1.0).
Versions.BERRY_POUCH_SPRITE_GFX = 0xE8560C -- gBerryPouchSpriteTiles (LZ 4bpp 64x64, 2048 bytes)
+4 -3
View File
@@ -602,6 +602,7 @@ function Protocol.packMon3(mon)
if mon.modernFatefulEncounter and not has then ribbonWord = ribbonWord + 2147483648 end
if not mon.modernFatefulEncounter and has then ribbonWord = ribbonWord - 2147483648 end
end
local fateful = math.floor(ribbonWord / 2147483648) % 2 == 1
local abilityNum = mon.abilityNum
if abilityNum == nil then abilityNum = pair[2] ~= 0 and ability == pair[2] and 1 or 0 end
local otFull = clampInt(mon.otId, 0, U32 - 1, 0)
@@ -641,8 +642,9 @@ function Protocol.packMon3(mon)
ribbons = ribbonWord,
markings = clampInt(mon.markings, 0, 15, 0),
isEgg = egg,
fatefulEncounter = mon.fatefulEncounter == true,
modernFatefulEncounter = math.floor(ribbonWord / 2147483648) % 2 == 1,
-- fatefulEncounter is the key older releases read for Mew/Deoxys trade legality
fatefulEncounter = fateful,
modernFatefulEncounter = fateful,
eggCycles = egg and clampInt(mon.eggCycles, 0, 255, 0) or 0,
}
end
@@ -882,7 +884,6 @@ function Protocol.unpackMon3(data, packed, opts)
markings = field(packed.markings, 0, 15, 0),
isEgg = isEgg,
eggCycles = isEgg and field(packed.eggCycles, 0, 255, 0) or 0,
fatefulEncounter = packed.fatefulEncounter == true,
modernFatefulEncounter = packed.modernFatefulEncounter == true
or math.floor((tonumber(packed.ribbons) or 0) / 2147483648) % 2 == 1,
}
+6 -2
View File
@@ -46,7 +46,7 @@ local GEN2_ROOTS = {
gen2Sprites = "sprites", gen2Palettes = "palettes",
gen2Icons = "icons", gen2BattleAnims = "battle_anims",
gen2Constants = "constants", gen2Landmarks = "landmarks",
gen2Scripts = "scripts",
gen2Scripts = "scripts", gen2MenuGfx = "menu_gfx",
}
local GEN3_ROOTS = {
@@ -298,6 +298,10 @@ function DatasetViews:_data(view)
local data = {}
setmetatable(data, {
__index = function(target, root)
if root == "field" and view.generation == 2 then
rawset(target, root, {})
return DatasetHydration.seedGen2Field(target)
end
local value = self:_module(view, root)
if value ~= nil then rawset(target, root, value) end
return value
@@ -346,7 +350,7 @@ function DatasetViews:_registry(view, name)
local function validate(registry, id, value)
if value == nil then return true end
local ok, detail = Schemas.check(registry.spec, registry.name, id,
value, "override")
value, "base")
if ok then return true end
local target = registry.spec.target
or Schemas.targetFor(registry.name, registry.spec, view.generation)
+142 -88
View File
@@ -820,49 +820,135 @@ local function topLevelPaths(mount)
return paths
end
-- Copy the mounted archive subtree at `src` to the install path `dst`. Reads
-- come from love.filesystem (the .zip is mounted there); every write goes
-- through CacheFs so it lands in the portable game folder when portable.txt is
-- in play and in the OS save directory otherwise (#330). No explicit mkdir:
-- CacheFs.write creates the parent chain on both paths, which also means an
-- empty folder inside the .zip is simply not carried over (it holds nothing).
local function copyTree(src, dst)
-- mods/<id>/baseroms/ holds user imports and their receipts; installs never open it.
local function isBaseromPath(rel)
return rel == "baseroms" or rel:sub(1, 9) == "baseroms/"
end
local function listPackage(path, files, dirs, rel)
local fs = love.filesystem
for _, name in ipairs(fs.getDirectoryItems(src)) do
local s = src .. "/" .. name
local d = dst .. "/" .. name
local info = fs.getInfo(s)
if info and info.type == "directory" then
local ok, err = copyTree(s, d)
if not ok then return nil, err end
else
local data = fs.read(s)
if data == nil then return nil, "could not read " .. name end
local ok, err = CacheFs.write(d, data)
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
files, dirs = files or {}, dirs or {}
for _, name in ipairs(fs.getDirectoryItems(path) or {}) do
local r = rel and (rel .. "/" .. name) or name
if not isBaseromPath(r) then
local info = fs.getInfo(path .. "/" .. name)
if info and info.type == "directory" then
dirs[#dirs + 1] = r
listPackage(path .. "/" .. name, files, dirs, r)
elseif info then
files[#files + 1] = r
end
end
end
return files, dirs
end
local function removeBoth(path)
CacheFs.remove(path)
love.filesystem.remove(path)
end
local function pruneEmptyDirs(dest, dirs)
local fs = love.filesystem
for i = #dirs, 1, -1 do
local path = dest .. "/" .. dirs[i]
if #(fs.getDirectoryItems(path) or {}) == 0 then
CacheFs.removeDir(path)
fs.remove(path)
end
end
end
local STREAM_CHUNK = 4 * 1024 * 1024
local function streamCopy(src, dst)
local fs = love.filesystem
local input = fs.newFile and fs.newFile(src)
if not (input and input:open("r")) then return nil, "could not open " .. src end
local output, openErr = CacheFs.openWrite(dst)
if not output then
input:close()
return nil, "could not create " .. dst .. ": " .. tostring(openErr)
end
local failure
while true do
local chunk = input:read(STREAM_CHUNK)
if not chunk or #chunk == 0 then break end
local wrote, writeErr = output:write(chunk)
if not wrote then
failure = "could not copy " .. src .. ": " .. tostring(writeErr or "write failed")
break
end
if #chunk < STREAM_CHUNK then break end
end
input:close()
output:close()
if failure then
CacheFs.remove(dst)
return nil, failure
end
return true
end
-- User-supplied baseroms are install state, not package content. Snapshot
-- them before replacing a mod tree so an update cannot make the player select
-- the same cartridge again (or destroy their only reusable copy if the new
-- archive later fails to copy).
local function snapshotTree(path, into, relative)
local function drainBaseroms(src, dst)
local fs = love.filesystem
into, relative = into or {}, relative or ""
local info = fs.getInfo(path)
if not info then return into end
local info = fs.getInfo(src)
if not info then return true end
if info.type == "directory" then
for _, name in ipairs(fs.getDirectoryItems(path) or {}) do
local rel = relative == "" and name or (relative .. "/" .. name)
snapshotTree(path .. "/" .. name, into, rel)
for _, name in ipairs(fs.getDirectoryItems(src) or {}) do
local ok, err = drainBaseroms(src .. "/" .. name, dst .. "/" .. name)
if not ok then return nil, err end
end
elseif relative ~= "" and into[relative] == nil then
into[relative] = fs.read(path)
return true
end
return into
if fs.getInfo(dst) then return true end
return streamCopy(src, dst)
end
local function installPackage(src, dest, manifestRaw)
local fs = love.filesystem
if type(manifestRaw) ~= "string" then return nil, "could not read manifest.json" end
local hadManifest = fs.getInfo(dest .. "/manifest.json", "file") ~= nil
-- a portable install can carry a legacy save-dir twin, which physfs reads before the game folder
local portable = CacheFs.root() ~= nil
local keep, written, failure = {}, {}, nil
for _, rel in ipairs((listPackage(src))) do
keep[rel] = true
if rel ~= "manifest.json" then
local data = fs.read(src .. "/" .. rel)
if data == nil then failure = "could not read " .. rel; break end
local ok, err = CacheFs.write(dest .. "/" .. rel, data)
if not ok then
failure = "could not write " .. rel .. ": " .. tostring(err)
break
end
if portable then fs.remove(dest .. "/" .. rel) end
written[#written + 1] = rel
end
end
if not failure then
local oldFiles, oldDirs = listPackage(dest)
for _, rel in ipairs(oldFiles) do
if not keep[rel] and rel ~= "manifest.json" then removeBoth(dest .. "/" .. rel) end
end
pruneEmptyDirs(dest, oldDirs)
local ok, err = CacheFs.write(dest .. "/manifest.json", manifestRaw)
if ok then
if portable then fs.remove(dest .. "/manifest.json") end
return true
end
failure = "could not write manifest.json: " .. tostring(err)
end
if not hadManifest then
for _, rel in ipairs(written) do removeBoth(dest .. "/" .. rel) end
local _, leftDirs = listPackage(dest)
pruneEmptyDirs(dest, leftDirs)
if #(fs.getDirectoryItems(dest) or {}) == 0 then
CacheFs.removeDir(dest)
fs.remove(dest)
end
end
return nil, failure
end
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
@@ -987,8 +1073,11 @@ local function findStrays(fs, adopt, installed)
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
local dest = "mods/" .. row.id
local copied, copyErr = copyTree(row.path, dest)
if not copied then removeTree(dest) end
local copied, copyErr = installPackage(row.path, dest,
fs.read(row.path .. "/manifest.json"))
if copied then
copied, copyErr = drainBaseroms(row.path .. "/baseroms", dest .. "/baseroms")
end
CacheFs.prefix = savedPrefix
if not copied then row.err = copyErr or "could not copy the files" end
end
@@ -1026,11 +1115,11 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
-- installZip(source [, opts]) -> true, id | nil, errString
-- source is an external path or a love DroppedFile. The archive is validated
-- BEFORE anything is copied; every path unmounts and clears the staged temp
-- file, and a failed copy rolls its partial tree back. A dropped file outside
-- the save dir is staged into a save-dir temp first, because
-- love.filesystem.mount only reaches a save-directory-relative path.
-- opts.replace = true uninstalls an existing same-id mod first (updates /
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
-- file. A dropped file outside the save dir is staged into a save-dir temp
-- first, because love.filesystem.mount only reaches a save-directory-relative
-- path. opts.replace = true updates an existing same-id mod in place
-- (updates / rollbacks). opts.expectId, when set, refuses a zip whose
-- manifest id differs.
function LauncherMods.installZip(source, opts)
local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts)
if not ok then return nil, "import failed: " .. tostring(result) end
@@ -1121,58 +1210,23 @@ function LauncherMods._installZipInner(source, opts)
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
local preservedBaseroms = {}
-- A previous failed update may have staged the user's files outside mods/ so
-- discovery cannot mistake recovery debris for an installed mod.
snapshotTree(baseromRecovery, preservedBaseroms)
if #existing > 0 then
for _, path in ipairs(existing) do
snapshotTree(path .. "/baseroms", preservedBaseroms)
end
-- drop every old tree before copy -- mods/<id> and any same-id folder
-- under another name, or the survivor keeps winning discover()'s
-- first-id-wins race after the "successful" update (#801). A tree with
-- no readable manifest is debris from an interrupted copy: it never
-- refuses the install, it only gets cleared (#834). Enable-flag is
-- preserved (uninstall would clear it, which would surprise an update).
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
for _, path in ipairs(existing) do removeTree(path) end
CacheFs.prefix = savedPrefix
end
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
-- the mods tree is shared by Red and Blue, so pin the prefix to the root for
-- the copy and the rollback, then hand back whatever the launcher had set
-- (an import coroutine leaves it pointed at that version -- RomImporter.lua).
-- No fs.createDirectory("mods") here any more: CacheFs.write creates the
-- parent chain in both homes, and doing it through love.filesystem would
-- only ever make the directory in the save dir (#330).
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
local copied, copyErr = copyTree(root, dest)
if not copied then removeTree(dest) end
local preserveErr
for rel, bytes in pairs(preservedBaseroms) do
if bytes ~= nil then
local restored, restoreErr = CacheFs.write(dest .. "/baseroms/" .. rel, bytes)
if not restored and not preserveErr then
preserveErr = "could not preserve baseroms/" .. rel .. ": "
.. tostring(restoreErr)
end
local copied, copyErr = installPackage(root, dest, raw)
local exactDest = false
for _, name in ipairs(fs.getDirectoryItems("mods") or {}) do
if name == manifest.id then exactDest = true end
end
for _, path in ipairs(existing) do
local sameDir = path == dest or (not exactDest and path:lower() == dest:lower())
if copied and not sameDir then
copied, copyErr = drainBaseroms(path .. "/baseroms", dest .. "/baseroms")
if copied then removeTree(path) end
end
end
if preserveErr then
-- Do not report a successful update that discarded user-owned input, and
-- do not leave a manifest-less baseroms tree that resembles an install.
removeTree(dest)
removeTree(baseromRecovery)
for rel, bytes in pairs(preservedBaseroms) do
if bytes ~= nil then CacheFs.write(baseromRecovery .. "/" .. rel, bytes) end
end
copied, copyErr = nil, preserveErr
elseif copied then
removeTree(baseromRecovery)
if copied then
copied, copyErr = drainBaseroms(baseromRecovery, dest .. "/baseroms")
if copied then removeTree(baseromRecovery) end
end
CacheFs.prefix = savedPrefix
if not copied then
+4 -3
View File
@@ -540,7 +540,8 @@ end
-- deliberately written beside options.lua so every platform's native shell
-- can use the same filesystem contract.
function Loader:_writeOptionSchemas()
if not self.fs.write then return end
local fs = SaveData.persistenceFs(self.fs)
if not (fs and fs.write) then return end
local mods = {}
for id, mod in pairs(self.mods) do
@@ -569,7 +570,7 @@ function Loader:_writeOptionSchemas()
-- snapshot when the current boot has no schemas so disabled/failed mods do
-- not leave stale native settings rows behind.
if next(mods) == nil
and not (self.fs.getInfo and self.fs.getInfo(OPTION_SCHEMAS_FILENAME)) then
and not (fs.getInfo and fs.getInfo(OPTION_SCHEMAS_FILENAME)) then
return
end
@@ -581,7 +582,7 @@ function Loader:_writeOptionSchemas()
Logger.warn("mod option schema export: failed to encode: %s", tostring(encoded))
return
end
local written, err = self.fs.write(OPTION_SCHEMAS_FILENAME, encoded)
local written, err = fs.write(OPTION_SCHEMAS_FILENAME, encoded)
if not written then
Logger.warn("mod option schema export: failed to write: %s", tostring(err))
end
+70 -19
View File
@@ -186,7 +186,9 @@ checkValue = function(t, value, path, patchMode, errors, top)
if kind == "map" then
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
for k, v in pairs(value) do
if not t.key.check(k) then
local keyErrors = {}
checkValue(t.key, k, path, false, keyErrors)
if #keyErrors > 0 then
fail(errors, path .. "." .. tostring(k), "key " .. t.key.desc, k)
end
checkValue(t.value, v, path .. "." .. tostring(k), patchMode, errors)
@@ -259,7 +261,7 @@ end
-- per-generation shape first (Schemas.shapeFor), and omitting it validates
-- against the Gen 1 shape, which is what every Gen 1 call site wants and what
-- a caller already holding a derived spec has anyway.
function Schemas.check(spec, registryName, id, value, mode, generation)
local function checkRecord(spec, registryName, id, value, mode, generation)
if generation ~= nil then
spec = Schemas.shapeFor(registryName, spec, generation)
end
@@ -295,7 +297,7 @@ function Schemas.check(spec, registryName, id, value, mode, generation)
elseif spec.value then
checkValue(spec.value, value, path, patchMode, errors, true)
if #errors == 0 and not patchMode and spec.extra then
local problem = spec.extra(id, value)
local problem = spec.extra(id, value, mode)
if problem then errors[#errors + 1] = ("%s: %s"):format(path, problem) end
end
elseif spec.fields then
@@ -323,7 +325,7 @@ function Schemas.check(spec, registryName, id, value, mode, generation)
end
end
if #errors == 0 and not patchMode and spec.extra then
local problem = spec.extra(id, value)
local problem = spec.extra(id, value, mode)
if problem then
errors[#errors + 1] = ("%s: %s"):format(path, problem)
end
@@ -334,6 +336,14 @@ function Schemas.check(spec, registryName, id, value, mode, generation)
return nil, table.concat(errors, "; ")
end
function Schemas.check(spec, registryName, id, value, mode, generation)
local ok, valid, detail = pcall(checkRecord, spec, registryName, id, value,
mode, generation)
if ok then return valid, detail end
return nil, ("%s.%s: %s"):format(tostring(registryName), tostring(id),
tostring(valid))
end
-- ------- cross-reference pass
local collectRefs
@@ -845,11 +855,15 @@ local function recordMapExcept(...)
end
local pokemonGen2BaseAt, pokemonGen2BaseIds, pokemonGen2ReservedIds =
recordMapExcept("growthRates", "tmhmMoves")
recordMapExcept("growthRates", "tmhmMoves", "tutorMoves")
local movesGen2BaseAt, movesGen2BaseIds, movesGen2ReservedIds =
recordMapExcept("generation", "source")
local itemsGen2BaseAt, itemsGen2BaseIds, itemsGen2ReservedIds =
recordMapExcept("generation", "source", "pockets")
recordMapExcept("generation", "source", "pockets", "timeCapsule")
local tilesetsGen2BaseAt, tilesetsGen2BaseIds, tilesetsGen2ReservedIds =
recordMapExcept("waterFrames", "flowerFrames", "flowerCgbFrames", "animFrames")
local textGen2BaseAt, textGen2BaseIds, textGen2ReservedIds =
recordMapExcept("generation", "labels")
function Schemas.gen2Index(registry, id, value, data, mode)
local floors = { pokemon = 251, moves = 251, items = 250 }
@@ -2294,6 +2308,21 @@ for _, name in ipairs({ "moves", "items" }) do
R[name].gen2Fields = fields
end
R.items.gen2Fields.name = f.opt(f.str)
local gen2NamelessItems = {
ITEM_C3 = 0xC3, -- pokegold/constants/item_constants.asm:224
ITEM_DC = 0xDC, -- pokegold/constants/item_constants.asm:249
ITEM_FA = 0xFA, -- pokegold/constants/item_constants.asm:297
}
R.items.gen2Extra = function(id, value, mode)
if value.name ~= nil then return nil end
if mode == "base" and gen2NamelessItems[id] ~= nil
and value.index == gen2NamelessItems[id] then
return nil
end
return "name: missing required field (string)"
end
for _, name in ipairs({ "pokemon", "moves", "items" }) do
R[name].gen2Notes = "New records receive an unused script index above the native range, "
.. "up to 65535. Existing indices cannot change; collisions and reserved "
@@ -2362,6 +2391,8 @@ R.maps = {
R.tilesets = {
semantics = "record", target = "tilesets",
gen2BaseAt = tilesetsGen2BaseAt, gen2BaseIds = tilesetsGen2BaseIds,
gen2ReservedIds = tilesetsGen2ReservedIds,
fields = {
id = f.opt(f.str), image = f.path,
imageWidth = f.opt(f.int(1)), imageHeight = f.opt(f.int(1)),
@@ -2708,6 +2739,8 @@ R.text = {
semantics = "record", target = "text",
value = f.str,
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
gen2BaseAt = textGen2BaseAt, gen2BaseIds = textGen2BaseIds,
gen2ReservedIds = textGen2ReservedIds,
gen3Value = f.union{ f.str, f.list(f.any) },
gen3Write = G3.textWrite,
}
@@ -3238,16 +3271,22 @@ R.sfx = {
-- base names the species whose header a derived cry borrows, so it resolves
-- against this same registry (13.9)
local function cryValue(max)
return f.union{
f.rec{ header = f.any, pitch = f.int(0, max), length = f.int(0, max) },
f.rec{ file = f.path },
f.rec{ base = f.id("cries"), pitch = f.opt(f.int(0, max)),
length = f.opt(f.int(0, max)) },
f.rec{ chip = chipProgram, pitch = f.opt(f.int(0, max)),
length = f.opt(f.int(0, max)) },
}
end
R.cries = {
semantics = "record", target = "audio.cries",
value = f.union{
f.rec{ header = f.any, pitch = f.int(0, 255), length = f.int(0, 255) },
f.rec{ file = f.path },
f.rec{ base = f.id("cries"), pitch = f.opt(f.int(0, 255)),
length = f.opt(f.int(0, 255)) },
f.rec{ chip = chipProgram, pitch = f.opt(f.int(0, 255)),
length = f.opt(f.int(0, 255)) },
},
value = cryValue(255),
-- pokegold/data/pokemon/cries.asm:3
gen2Value = cryValue(65535),
example = 'mod.content.cries:patch("PIKACHU", { pitch = 200 })',
}
@@ -3625,13 +3664,25 @@ R.field = {
namePresets = f.opt(f.rec{ player = f.opt(f.list(f.str)),
rival = f.opt(f.list(f.str)) }),
},
-- data/sprites/emotes.asm:8, engine/events/fishing_gfx.asm:23
emotes = f.rec{
shock = f.opt(f.path), question = f.opt(f.path), happy = f.opt(f.path),
sad = f.opt(f.path), heart = f.opt(f.path), bolt = f.opt(f.path),
sleep = f.opt(f.path), fish = f.opt(f.path),
grassRustle = f.opt(f.path), jumpShadow = f.opt(f.path),
cutGrass = f.opt(f.path), fishing = f.opt(f.path),
fishingFemale = f.opt(f.path), order = f.opt(f.list(f.str)) },
},
gen2KeysClosed = true,
gen2Example = 'mod.content.field:patch("boot", { startMap = "NEW_BARK_TOWN", '
.. 'startX = 6, startY = 8 })',
gen2Notes = "Gold reads only `boot`: the New Game spawn (`startMap`, "
.. "`startX`, `startY`, `startFacing`, replacing SPAWN_HOME), the starting "
.. "money, the rival's starting name and the naming presets. Every other "
gen2Notes = "Gold reads `boot` and `emotes`. `boot` is the New Game spawn "
.. "(`startMap`, `startX`, `startY`, `startFacing`, replacing SPAWN_HOME), "
.. "the starting money, the rival's starting name and the naming presets. "
.. "`emotes` is the overworld emote art, one image path per sheet: the "
.. "showemote bubbles listed in `order`, `grassRustle`, `jumpShadow`, "
.. "`cutGrass`, the fishing pose `fishing` and Crystal's `fishingFemale`; "
.. "a patched path is the sheet the overworld draws. Every other "
.. "Gen 1 key has no Gold consumer: `ledges` (Gold's ledges are tile "
.. "collision types), `hiddenItems` (bg events in `maps`), `badgeGates`, "
.. "`townMap` (`landmarks`), `flyOrder` (the flypoint table), "
@@ -3851,8 +3902,8 @@ R.landmarks = {
id = f.opt(f.str),
-- the two-line name the town map prints, "\n" and all
name = f.str,
-- the marker's tile position on the 20x18 town map
x = f.int(0), y = f.int(0),
-- pokegold/data/maps/landmarks.asm:3
x = f.int(-8, 247), y = f.int(-16, 239),
-- LANDMARK_*: the byte a map header carries, and what
-- src/core/gen2/Nests.lua's region split reads
index = f.int(0),
+12 -2
View File
@@ -147,9 +147,15 @@ local ENCOUNTER_FIELDS = fields("id grass water rocks fishing swarmGrass swarmWa
local EXTRA_ENCOUNTERS = fields("fishGroups timeFishGroups treeSets treeMonsAsleep "
.. "bugContest roamMaps roamMons")
local MAP_KINDS = { "grass", "water", "swarmGrass", "swarmWater", "trees", "rocks" }
local MON_BATTLE_FIELDS = fields("battleScaleFront battleScaleBack battleTheme")
local function coreFields(name, allowed, generation)
if generation ~= 1 then return allowed end
if generation ~= 1 then
if name ~= "pokemon" then return allowed end
local out = copy(allowed)
for key in pairs(MON_BATTLE_FIELDS) do out[key] = true end
return out
end
local out = copy(allowed)
local nativeSchema = Schemas.shapeFor(name, Schemas.REGISTRIES[name], generation)
for key in pairs(nativeSchema.fields or {}) do
@@ -387,7 +393,11 @@ local function monWrite(value, old, generation, patch)
for _, key in ipairs({ "eggMoves", "genderRatio", "eggCycles", "itemCommon", "itemRare" }) do
if value[key] ~= nil then refuse(key .. " has no Gen 1 representation") end
end
elseif value.frontSize ~= nil then refuse("frontSize has no Gen 3 representation") end
else
for _, key in ipairs({ "frontSize", "battleScaleFront", "battleScaleBack", "battleTheme" }) do
if value[key] ~= nil then refuse(key .. " has no Gen 3 representation") end
end
end
if generation == 3 and value.trueColor ~= nil and value.trueColor ~= (old or {}).trueColor then
refuse("trueColor has no Gen 3 representation")
end
+5 -5
View File
@@ -1,8 +1,11 @@
local Ids = require("src.mods.UnifiedIds")
local Schemas = require("src.mods.Schemas")
local Stats = require("src.pokemon.Stats")
local Records = {}
local RESERVED_REGISTRY = { species = "pokemon", move = "moves", item = "items" }
local STAT_KEYS = { "hp", "attack", "defense", "speed", "special", "specialAttack", "specialDefense" }
local STAT_ALIASES = {
hp = { "hp", "maxHp", "maxhp" }, attack = { "attack", "atk" },
@@ -137,11 +140,8 @@ end
local function writeId(id, kind, generation, data, original, native)
if type(id) ~= "string" or id == "" then error(kind .. " must be a string ID", 0) end
local reserved = generation == 2 and ({
species = { growthRates = true, tmhmMoves = true },
move = { generation = true, source = true },
item = { generation = true, source = true, pockets = true },
})[kind]
local registry = generation == 2 and Schemas.REGISTRIES[RESERVED_REGISTRY[kind]]
local reserved = registry and registry.gen2ReservedIds
if reserved and reserved[id] then error("reserved engine metadata is not a " .. kind .. " ID", 0) end
local rows, base = tables(kind, generation, data)
id = Ids.native(id, kind, generation, function(key) return rows[key] ~= nil end)
+1 -1
View File
@@ -1625,7 +1625,7 @@ function Remote3:_partnerMonValid()
}) == require("src.core.game3.scripting.natives_trade").CAN_TRADE_MON
end
local species = tonumber(mon.species) or 0
if (species == 151 or species == 410) and mon.fatefulEncounter == false then
if (species == 151 or species == 410) and mon.modernFatefulEncounter == false then
return false
end
return true
+3 -9
View File
@@ -11,8 +11,8 @@ TmCaseChrome._bgMale = nil
TmCaseChrome._bgFemale = nil
TmCaseChrome._coverMale = nil
TmCaseChrome._coverFemale = nil
TmCaseChrome._discs = {} -- typeIdx (0..16) -> Image (TM)
TmCaseChrome._discsHm = {} -- typeIdx (0..16) -> Image (HM)
TmCaseChrome._discs = {}
TmCaseChrome._discsHm = {}
TmCaseChrome._hmIcon = nil
TmCaseChrome._manifest = nil
TmCaseChrome._logged = false
@@ -135,13 +135,7 @@ function TmCaseChrome.loadDisc(typeIdx, isHm)
local filename = isHm and string.format("%s/disc_hm_%d.rgba", tm_root(), typeIdx)
or string.format("%s/disc_%d.rgba", tm_root(), typeIdx)
local raw = read_bytes(filename)
if not raw or #raw < 32 * 32 * 4 then
if isHm then
-- Fallback to standard TM disc if HM disc not baked yet
return TmCaseChrome.loadDisc(typeIdx, false)
end
return nil
end
if not raw or #raw < 32 * 32 * 4 then return nil end
local img = rgba_to_image(raw, 32, 32)
tbl[typeIdx] = img
return img
+20 -14
View File
@@ -271,8 +271,7 @@ function OakSpeech:buildSteps()
return hooked
end
-- `ld de, MUSIC_ROUTE_30 / call PlayMusic`, after InitGender's fade.
-- ../pokecrystal/engine/menus/intro_menu.asm:632-633, init_gender.asm:59-65
-- ../pokegold/engine/menus/intro_menu.asm:495-500
function OakSpeech:startMusic()
if self.musicStarted then return end
self.musicStarted = true
@@ -282,13 +281,23 @@ function OakSpeech:startMusic()
end
end
-- ../pokegold/engine/rtc/timeset.asm:13-18, ../pokecrystal/engine/menus/init_gender.asm:60-65
OakSpeech.SILENCE_FADE = 0x10
function OakSpeech:silenceMusic()
self.musicStarted = false
local data = self.game and self.game.data
if data and data.audio and data.audio.runtime then
Music.fadeOut(OakSpeech.SILENCE_FADE)
end
end
local SILENT_KINDS = { gender = true, initclock = true }
function OakSpeech:enter()
self.step = 0
self.answers = {}
self.steps = self:buildSteps()
if not (self.steps[1] and self.steps[1].kind == "gender") then
self:startMusic()
end
if Runtime.wants("intro.oak_speech.started") then
Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps })
end
@@ -429,6 +438,7 @@ function OakSpeech:openInitClock()
self.busy = true
local faded = self.faded
self.faded = false
self:silenceMusic()
local pushed = Screens.push(game, "Gen2InitClock", {
mode = "clock",
save = game.save,
@@ -438,6 +448,7 @@ function OakSpeech:openInitClock()
onDone = function()
game.stack:pop()
-- ../pokecrystal/engine/menus/intro_menu.asm:629-635
self:startMusic()
if self.autoConfirm then
self.busy = false
return self:advance()
@@ -454,16 +465,12 @@ function OakSpeech:openInitClock()
end
end
-- InitGender, pushed where PlayerProfileSetup farcalls it: before the speech
-- says anything and before its music starts
-- (../pokecrystal/engine/menus/intro_menu.asm:79-83).
-- ../pokecrystal/engine/menus/intro_menu.asm:79-83
function OakSpeech:openGenderSelect(step)
local game = self.game
if not (game and game.stack) then
self:startMusic()
return self:advance()
end
if not (game and game.stack) then return self:advance() end
self.busy = true
self:silenceMusic()
local pushed = Screens.push(game, "Gen2GenderSelect", {
save = game.save,
fades = not self.autoConfirm,
@@ -481,13 +488,11 @@ function OakSpeech:openGenderSelect(step)
end
self:recordAnswer(step, gender == "female" and 2 or 1,
gender == "female" and "Girl" or "Boy", gender)
self:startMusic()
self:advance()
end,
})
if not pushed then
self.busy = false
self:startMusic()
self:advance()
end
end
@@ -715,6 +720,7 @@ function OakSpeech:advance()
speech = self, step = step, index = self.step,
})
end
if not SILENT_KINDS[step.kind] then self:startMusic() end
self:runStep(step)
end
+76 -23
View File
@@ -180,30 +180,86 @@ local function target()
return { os = osName, arch = arch, port = port }
end
local function readPersistedFullRequirement()
if not (love and love.filesystem and love.filesystem.getInfo) then return nil end
local path = "updates/full-update.json"
if not love.filesystem.getInfo(path) then return nil end
local text = love.filesystem.read(path)
local FULL_UPDATE_PATH = "updates/full-update.json"
local NOTES_CACHE_PATH = "updates/notes_cache.json"
local function persistenceFs()
return require("src.core.SaveData").persistenceFs()
end
local function readJsonFile(fs, path)
if not (fs and fs.getInfo and fs.read) then return nil end
if not fs.getInfo(path) then return nil end
local text = fs.read(path)
if type(text) ~= "string" then return nil end
local ok, Json = pcall(require, "src.link.Json")
if not ok or not Json then return nil end
local decodedOk, requirement = pcall(Json.decode, text)
if not decodedOk or type(requirement) ~= "table" then return nil end
if type(requirement.version) ~= "string" then return nil end
local decodedOk, doc = pcall(require("src.link.Json").decode, text)
if not decodedOk or type(doc) ~= "table" then return nil end
return doc
end
local function writeUpdatesFile(fs, path, doc)
local ok, encoded = pcall(require("src.link.Json").encode, doc)
if not ok or type(encoded) ~= "string" then return end
if fs.createDirectory then fs.createDirectory("updates") end
fs.write(path, encoded)
end
local function readPersistedFullRequirement()
local requirement = readJsonFile(persistenceFs(), FULL_UPDATE_PATH)
if not requirement or type(requirement.version) ~= "string" then return nil end
return requirement
end
local persistedRequirement = readPersistedFullRequirement()
if persistedRequirement then
cache = {
status = "needs_full",
latest = persistedRequirement.version,
reason = persistedRequirement.reason,
full = persistedRequirement.full,
}
local persistedLoaded = false
local function loadPersistedRequirement()
if persistedLoaded then return end
persistedLoaded = true
local persistedRequirement = readPersistedFullRequirement()
if persistedRequirement and cache.status == "idle" then
cache = {
status = "needs_full",
latest = persistedRequirement.version,
reason = persistedRequirement.reason,
full = persistedRequirement.full,
}
end
end
local notesPersisted = {}
local function persistMessage(msg)
if type(msg.latest) ~= "string" then return end
local fs = persistenceFs()
if not (fs and fs.write) then return end
if type(msg.notes) == "string" and msg.notes ~= ""
and notesPersisted[msg.latest] ~= msg.notes then
notesPersisted[msg.latest] = msg.notes
local existing = readJsonFile(fs, NOTES_CACHE_PATH) or {}
existing[msg.latest] = msg.notes
writeUpdatesFile(fs, NOTES_CACHE_PATH, existing)
end
if msg.status == "needs_full" or msg.status == "full_ready" then
local full = type(msg.full) == "table" and msg.full or nil
writeUpdatesFile(fs, FULL_UPDATE_PATH, {
version = msg.latest,
reason = msg.reason or "full_package_required",
full = full and { name = full.name, url = full.url, size = full.size } or nil,
})
elseif msg.clearFull and fs.remove then
fs.remove(FULL_UPDATE_PATH)
end
end
local function ingest(msg)
if type(msg) ~= "table" then return end
if msg.status == "checking" then notesPersisted = {} end
pcall(persistMessage, msg)
if type(msg.notes) ~= "string" and type(cache.notes) == "string" then
msg.notes = cache.notes
end
cache = msg
end
Check._ingestForTests = ingest
local function ensureWorker()
if os.getenv("POKEPORT_PORTMASTER_MANAGED") == "1" then return false end
if workerReady ~= nil then return workerReady end
@@ -234,14 +290,11 @@ end
-- Pull every pending snapshot off the state channel (keeping the newest) and
-- surface a worker crash as a soft error the UI can hide on.
local function drain()
loadPersistedRequirement()
if stateCh then
local msg = stateCh:pop()
while msg do
if type(msg) == "table" and type(msg.notes) ~= "string"
and type(cache.notes) == "string" then
msg.notes = cache.notes
end
cache = msg
ingest(msg)
msg = stateCh:pop()
end
end
+8 -1
View File
@@ -53,9 +53,16 @@ local function readPath(path)
return nonempty(text) and text or nil
end
local function readCachePath(path)
local fs = require("src.core.SaveData").persistenceFs()
if not (fs and fs.read) then return nil end
local ok, text = pcall(fs.read, path)
return ok and nonempty(text) and text or nil
end
function PatchNotes.fromCache(engine)
for _, path in ipairs(PatchNotes.CACHE_FILES) do
local text = readPath(path)
local text = readCachePath(path)
if text then
local ok, doc = pcall(Json.decode, text)
if ok and type(doc) == "table" then
+1 -47
View File
@@ -162,52 +162,11 @@ local function gatePasses(rel)
return true
end
local function persistFullRequirement(rel, reason)
if not (rel and rel.version and Json) then return end
local full = rel.full
local record = {
version = rel.version,
reason = reason or "full_package_required",
full = full and { name = rel.fullName, url = full.url, size = full.size } or nil,
}
pcall(function()
love.filesystem.createDirectory("updates")
love.filesystem.write("updates/full-update.json", Json.encode(record))
end)
end
local function postFullRequirement(rel, reason)
persistFullRequirement(rel, reason)
post({ status = "needs_full", latest = rel and rel.version, reason = reason,
full = rel and rel.full and { name = rel.fullName, url = rel.full.url, size = rel.full.size } or nil })
end
local function clearFullRequirement()
pcall(function() love.filesystem.remove("updates/full-update.json") end)
end
local function cacheNotes(ver, notes)
if not (ver and type(notes) == "string" and notes ~= "" and Json) then return end
if not (love and love.filesystem) then return end
pcall(function()
love.filesystem.createDirectory("updates")
local cachePath = "updates/notes_cache.json"
local existing = {}
if love.filesystem.getInfo and love.filesystem.getInfo(cachePath) then
local text = love.filesystem.read(cachePath)
if text then
local ok, doc = pcall(Json.decode, text)
if ok and type(doc) == "table" then existing = doc end
end
end
existing[ver] = notes
local ok, encoded = pcall(Json.encode, existing)
if ok and encoded then
love.filesystem.write(cachePath, encoded)
end
end)
end
-- ---------------------------------------------------------------------------
-- check
-- ---------------------------------------------------------------------------
@@ -234,9 +193,6 @@ local function doCheck(target)
return
end
pending = rel
if rel.version and type(rel.notes) == "string" and rel.notes ~= "" then
cacheNotes(rel.version, rel.notes)
end
-- Unstamped dev build: the working tree always looks "newer", so never
-- pester the developer with an update (contract item, Check design).
@@ -255,8 +211,7 @@ local function doCheck(target)
if compareVersions(rel.version, currentEngine) <= 0 then
-- We are now running a native shell at least as new as GitHub's latest
-- release, so a former minShell/payloadHost prompt no longer applies.
clearFullRequirement()
post({ status = "uptodate", latest = rel.version })
post({ status = "uptodate", latest = rel.version, clearFull = true })
return
end
@@ -491,7 +446,6 @@ local function doDownloadFull()
love.filesystem.write(doneRel, data)
love.filesystem.remove(partRel)
end
persistFullRequirement(rel, "full_package_required")
post({ status = "full_ready", latest = rel.version, reason = "full_package_required",
full = { name = name, url = asset.url, size = asset.size, path = doneAbs } })
end
@@ -0,0 +1,91 @@
local U = require("tests.drivers.util")
local F = require("tests.drivers.em_fa_util")
local S = F.new("game3_event_mon_fateful_3062", os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_event_mon_fateful_3062")
local NAVEL_ROCK_BASE = "FR_NAVEL_ROCK_BASE"
local LUGIA_XY = { 10, 15 }
local FATEFUL = 2147483648
return function(game)
if not F.boot(game, S) then return S.finish() end
local Runtime = require("src.core.game3.runtime")
local Battle = require("src.core.game3.battle")
local Ui = require("src.core.game3.battle.ui")
local Party = require("src.core.game3.party")
local Bag = require("src.core.game3.bag")
local Message = require("src.ui.game3.message")
local Encounters = require("src.core.game3.encounters")
local Gen3Save = require("src.save_convert.Gen3Save")
local session = Runtime.getSession()
local C = require("src.core.game3.constants").of(session.version)
local lugia = C:require("species", "SPECIES_LUGIA")
local master = C:require("items", "ITEM_MASTER_BALL")
session.party = {}
Party.giveMon(session, C:require("species", "SPECIES_MEWTWO"), 100)
Bag.add(session.bag, master, 1)
-- pokefirered/data/maps/NavelRock_Base/scripts.inc:15
F.setFlag("FLAG_FOUGHT_LUGIA", false)
F.setFlag("FLAG_LUGIA_FLEW_AWAY", false)
S.check(F.goTo(game, NAVEL_ROCK_BASE, LUGIA_XY[1], LUGIA_XY[2] + 1, "up"), "Navel Rock Base loaded")
Encounters.takePendingWild()
U.tap(game, "a")
U.wait(30)
for _ = 1, 600 do
if Battle.isActive() then break end
if Message.isOpen() then U.tap(game, "a") end
U.wait(6)
end
if not S.check(Battle.isActive(), "talking to Lugia ran seteventmon and started the battle") then
return S.finish()
end
local thrown, atMenu = false, false
for frame = 1, 9000 do
if not Battle.isActive() then break end
if Battle._phase == "command" and Ui._mode == "menu" and not thrown then
atMenu = true
local st = Battle.getState()
local foe = st and st.enemy and st.enemy.mon
S.check(foe and tonumber(foe.species) == lugia and tonumber(foe.level) == 70, "the event mon is Lugia Lv70")
S.check(foe and foe.modernFatefulEncounter == true, "battle Lugia carries modernFatefulEncounter")
S.check(S.still(game, "3062_01_navel_rock_lugia_battle.png"), "Lugia battle shot")
thrown = true
Ui._pendingCommand = { kind = "bag", itemId = master, user = "player" }
Ui._mode = "none"
elseif Battle._phase == "catch_nickname_prompt" then
U.tap(game, "b")
U.wait(12)
elseif Ui.dialogPending and Ui.dialogPending() then
U.tap(game, "a")
U.wait(8)
elseif frame % 16 == 0 then
U.tap(game, "a")
U.wait(4)
else
U.wait(1)
end
end
S.check(atMenu, "the Lugia battle reached the command menu")
F.settle(game, 600)
S.check(not Battle.isActive(), "the battle ended")
local Fade = require("src.ui.game3.fade")
for _ = 1, 600 do
if not Fade.isActive() and (Fade.t or 0) <= 0 then break end
U.wait(1)
end
U.wait(20)
local caught
for i = #session.party, 1, -1 do
if tonumber(session.party[i].species) == lugia then caught = session.party[i] break end
end
if not S.check(caught ~= nil, "Lugia was caught into the party") then return S.finish() end
S.check(caught.modernFatefulEncounter == true, "caught Lugia keeps modernFatefulEncounter")
local ribbons = tonumber(Gen3Save.fromPortMon(caught, {}, true).ribbons) or 0
S.check(math.floor(ribbons / FATEFUL) % 2 == 1, "exported ribbons word has bit 31 set")
S.check(S.still(game, "3062_02_lugia_caught_field.png"), "post-catch field shot")
S.finish()
end
@@ -0,0 +1,93 @@
local U = require("tests.drivers.util")
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_tm_case_dark_disc_3061"
-- pokefirered/include/constants/items.h:436
local ITEM_TM_CASE = 364
-- pokefirered/include/constants/items.h:311
local ITEM_TM12 = 300
-- pokefirered/include/constants/items.h:337
local ITEM_TM38 = 326
local failures = 0
local function result(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function finish()
if failures == 0 then
print("PASS tm_case_dark_disc_3061")
love.event.quit(0)
else
print("FAIL tm_case_dark_disc_3061 failures=" .. failures)
love.event.quit(1)
end
end
return function(game)
print("PASS driver_started")
for _ = 1, 900 do
if game.phase == "boot" and game.boot then break end
U.wait(1)
end
game:_handleBootAction({ action = "new_game", name = "RED" })
U.wait(240)
local Runtime = require("src.core.game3.runtime")
local Bag = require("src.core.game3.bag")
local Pokemon = require("src.core.game3.pokemon")
local TmCase = require("src.ui.game3.tm_case")
local TmCaseChrome = require("src.ui.game3.tm_case_chrome")
local session = Runtime.getSession()
if not result(session ~= nil, "new game reached the game3 field") then return finish() end
session.bag = session.bag or Bag.new()
Bag.add(session.bag, ITEM_TM_CASE, 1)
Bag.add(session.bag, ITEM_TM12, 1)
Bag.add(session.bag, ITEM_TM38, 1)
local taunt = Pokemon.battleMove(Pokemon.moveFromTmItem(ITEM_TM12)) or {}
result(tonumber(taunt.type) == 17, "TM12 TAUNT is TYPE_DARK, type=" .. tostring(taunt.type))
local fireBlast = Pokemon.battleMove(Pokemon.moveFromTmItem(ITEM_TM38)) or {}
result(tonumber(fireBlast.type) == 10, "TM38 FIRE BLAST is TYPE_FIRE, type=" .. tostring(fireBlast.type))
result(TmCaseChrome.ready(), "TM case chrome is in the cache")
result(TmCaseChrome.discBank == nil, "the UI carries no type to palette bank table")
for t = 0, 17 do
result(TmCaseChrome.loadDisc(t, false) ~= nil and TmCaseChrome.loadDisc(t, true) ~= nil,
"TM and HM discs load for type " .. t)
end
local Dataset = require("src.core.game3.dataset")
local Extract = require("src.import.gba.extract_island1")
local function hasColor(typeIdx, r, g, b)
local rel = string.format("%s/items/tm_case/disc_%d.rgba", Extract.CACHE_ROOT or "data/generated/gba", typeIdx)
local data = Dataset.cache():read(rel)
if type(data) ~= "string" or #data < 32 * 32 * 4 then return false end
for i = 0, 32 * 32 - 1 do
local pr, pg, pb, pa = data:byte(i * 4 + 1, i * 4 + 4)
if pa == 255 and pr == r and pg == g and pb == b then return true end
end
return false
end
result(hasColor(17, 82, 156, 165), "the Dark TM disc is tinted Dark teal")
result(hasColor(10, 239, 173, 33), "the Fire TM disc is tinted Fire orange")
TmCase.show(session, session.bag)
U.wait(10)
if not result(TmCase.isOpen(), "the TM CASE is open") then return finish() end
local sel = TmCase.list()[TmCase.cursor]
result(sel and sel.id == ITEM_TM12, "the cursor is on TM12, id=" .. tostring(sel and sel.id))
U.still(game, DIR .. "/3061_01_dark_tm_disc.png")
U.tap(game, "down")
U.wait(10)
sel = TmCase.list()[TmCase.cursor]
result(sel and sel.id == ITEM_TM38, "the cursor is on TM38, id=" .. tostring(sel and sel.id))
U.still(game, DIR .. "/3061_02_fire_tm_disc.png")
TmCase.close()
return finish()
end
@@ -0,0 +1,72 @@
local U = require("tests.drivers.util")
local Loader = require("src.mods.Loader")
local Sdk = require("tests.modkit.sdk")
local Mon = require("src.battle.gen2.Mon")
local hostVisible = love.visible
love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end
return function(game)
U.wait(45)
local failures = 0
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local files = {
["mods/probe/manifest.json"] = '{"id":"probe","name":"probe","version":"1.0.0",'
.. '"api":3,"games":["all"],"entry":"main.lua"}',
["mods/probe/main.lua"] = [[local mod = ...
local ok, reason = mod.content.pokemon:patch("CYNDAQUIL", { battleScaleBack = 1.5 })
mod.exports.patched, mod.exports.reason = ok ~= nil, reason
mod.exports.back = (mod.content.pokemon:get("CYNDAQUIL") or {}).battleScaleBack]],
}
local loader = Loader.new({ fs = Sdk.memfs(files), generation = 2,
version = game.save.version, dev = true })
loader.game = game
local data = {}
for key, value in pairs(game.data) do data[key] = value end
for key in pairs(require("src.mods.Builtins").namespaceRoots(2)) do data[key] = nil end
loader:load(data)
local probe = loader.mods.probe
local out = loader.exports.probe or {}
if not check(probe and not probe.failed and out.patched == true,
"3049_api3_patch_accepted " .. tostring(out.reason)) then
love.event.quit(1)
return
end
check(out.back == 1.5, "3049_get_returns_back_scale")
game.data.pokemon = data.pokemon
check(game.data.pokemon.CYNDAQUIL.battleScaleBack == 1.5, "3049_merged_back_scale")
game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) }
if not check(game.world:startBattle({ wild = Mon.new(game.data, "PIDGEY", 5) }),
"3049_battle_started") then
love.event.quit(1)
return
end
local battle
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then battle = top break end
U.wait(1)
end
if not check(battle ~= nil, "3049_battle_screen") then
love.event.quit(1)
return
end
for _ = 1, 400 do
if battle.phase == "menu" or battle.battle.over then break end
U.tap(game, "a")
U.wait(2)
end
check(battle.phase == "menu", "3049_reached_menu")
local player = battle.battle.player
check(player and player.species == "CYNDAQUIL"
and battle:picScale("player_back", player, true) == 1.5, "3049_back_pic_scale_1_5")
check(battle:picScale("enemy_front", { species = "PIDGEY" }, false) == 1, "3049_front_pic_default")
local dir = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2_mod_battle_scale_3049"
check(U.still(game, dir .. "/3049_01_scaled_back_pic.png"), "3049_scaled_back_shot")
love.event.quit(failures == 0 and 0 or 1)
end
@@ -0,0 +1,161 @@
local U = require("tests.drivers.util")
local Assets = require("src.render.Assets")
local Map = require("src.world.gen2.Map")
local PackMenu = require("src.ui.gen2.PackMenu")
local Permissions = require("src.world.gen2.Permissions")
local StartMenu = require("src.ui.gen2.StartMenu")
local MOD = "mods/fish3058"
local PATCHED = MOD .. "/fishing_swapped.png"
local CART = "assets/generated/emotes/fishing.png"
local ROD = "OLD_ROD"
local MAPS = { "ROUTE_28", "VIRIDIAN_CITY", "ROUTE_43" }
local function installMod()
local data = Assets.imageData(CART)
data:mapPixel(function(_, _, r, g, b, a)
if r < 0.1 then return 2 / 3, 2 / 3, 2 / 3, a end
if r > 0.6 and r < 0.7 then return 0, 0, 0, a end
return r, g, b, a
end)
love.filesystem.createDirectory(MOD)
love.filesystem.write(PATCHED, data:encode("png"):getString())
love.filesystem.write(MOD .. "/manifest.json",
'{"id":"fish3058","name":"fish3058","version":"1.0.0",'
.. '"api":3,"games":["all"],"entry":"main.lua"}')
love.filesystem.write(MOD .. "/main.lua", ([[local mod = ...
mod.content.field:patch("emotes", { fishing = %q })]]):format(PATCHED))
end
return function(game)
U.wait(45)
if not love.filesystem.getInfo(MOD .. "/manifest.json") then
installMod()
print("INFO 3058 installed the probe mod, restarting so the boot loads it")
love.event.quit("restart")
return
end
local failures = 0
local function check(ok, label)
print((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
return ok
end
local function tap(btn, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = btn
game.input.state[btn] = true
U.wait(2)
game.input.state[btn] = false
U.wait(frames or 6)
end
local world = game.world
local probe = game.mods and game.mods.mods and game.mods.mods.fish3058
check(probe ~= nil and not probe.failed, "3058_mod_loaded_at_boot")
local field = game.data.field
check(field and field.emotes and field.emotes.fishing == PATCHED,
"3058_field_emotes_patched")
check(field and field.emotes and field.emotes.shock ~= nil,
"3058_field_emotes_keep_cart_bubbles")
check(game.data.gen2MenuGfx.emotes.fishing == PATCHED,
"3058_menu_gfx_emotes_written_back")
if not check(world and world.fishingSheet == PATCHED, "3058_world_fishing_sheet_patched") then
love.event.quit(1)
return
end
local function downShore(mapId)
local def = world.maps and world.maps[mapId]
local tileset = def and world.tilesets and world.tilesets[def.tileset]
if not (def and tileset) then return nil end
local probeMap = Map.new(def, tileset)
local taken = {}
for _, obj in ipairs(def.objects or {}) do taken[obj.x .. "," .. obj.y] = true end
local function land(cx, cy)
if not probeMap:inBounds(cx, cy) or taken[cx .. "," .. cy] then return false end
local coll = probeMap:cellCollision(cx, cy)
return Permissions.isWalkable(coll) and not Permissions.isWater(coll)
end
for cy = 0, probeMap.heightCells - 2 do
for cx = 0, probeMap.widthCells - 1 do
if land(cx, cy) and Permissions.isWater(probeMap:cellCollision(cx, cy + 1)) then
return { x = cx, y = cy }
end
end
end
return nil
end
local mapId, shore
for _, id in ipairs(MAPS) do
shore = downShore(id)
if shore then mapId = id break end
end
if not check(mapId ~= nil, "3058_found_shore") then
love.event.quit(1)
return
end
world.maps[mapId].fishGroup = "FISHGROUP_NONE"
game.save.inventory = { [ROD] = 1 }
game.save.bagOrder = { ROD }
world:warpToMapId(mapId, shore.x, shore.y, "down")
for _ = 1, 600 do
if world.map and world.map.id == mapId and not world.mapSetup then break end
U.wait(1)
end
U.wait(20)
check(world.player.cellX == shore.x and world.player.cellY == shore.y,
"3058_on_shore_facing_water")
tap("start")
local menu = game.stack:top()
if not check(getmetatable(menu) == StartMenu, "3058_start_menu") then
love.event.quit(1)
return
end
for _ = 1, #menu.items do
local row = menu.list and menu.list:current()
if row and row.value == "pack" then break end
tap("down", 4)
end
tap("a", 10)
local pack
for _ = 1, 120 do
pack = game.stack:top()
if getmetatable(pack) == PackMenu then break end
U.wait(1)
end
if not check(getmetatable(pack) == PackMenu, "3058_pack_open") then
love.event.quit(1)
return
end
for _ = 1, 4 do
if pack:pocket().id == "KEY_ITEM" then break end
tap("right", 6)
end
for _ = 1, 20 do
local row = pack.rows[pack.index]
if row and row.id == ROD then break end
tap("down", 4)
end
local row = pack.rows[pack.index]
check(row ~= nil and row.id == ROD, "3058_cursor_on_old_rod")
tap("a", 8)
tap("a", 10)
for _ = 1, 600 do
if world.player.fishing == true and getmetatable(game.stack:top()) ~= PackMenu then break end
U.wait(1)
end
for _ = 1, 600 do
if not world.fade and not world.mapSetup then break end
U.wait(1)
end
check(not world.fade, "3058_screen_not_faded " .. tostring(world.fade))
check(world.player.fishing == true, "3058_fishing_pose_up")
check(world.player.fishSheet == PATCHED, "3058_pose_drawn_from_patched_sheet")
local dir = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2_mod_fishing_emote_3058"
check(U.still(game, dir .. "/3058_01_patched_fishing_pose.png"), "3058_patched_pose_shot")
love.event.quit(failures == 0 and 0 or 1)
end
@@ -0,0 +1,127 @@
-- ../pokegold/engine/rtc/timeset.asm:13-18
-- ../pokegold/engine/menus/intro_menu.asm:494-500
--
-- POKEPORT_BOOT_CINEMA=1 POKEPORT_TOUCH=0 tools/run_driver.sh gold <identity> \
-- tests/drivers/gen2_oak_music_after_clock_3059.lua <shotdir>
local U = require("tests.drivers.util")
local GenderSelect = require("src.ui.gen2.GenderSelect")
local InitClock = require("src.ui.gen2.InitClock")
local MainMenu = require("src.ui.gen2.MainMenu")
local Music = require("src.core.Music")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local ROUTE30 = "Music_Route30"
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/3059-oak-music"
local failures = 0
local function say(line) print("[driver] " .. line) end
local function result(ok, label)
say((ok and "PASS " or "FAIL ") .. label)
if not ok then failures = failures + 1 end
end
local function bail(reason)
say("FAIL " .. reason)
love.event.quit(1)
error(reason, 0)
end
local function top() return game.stack:top() end
local function isA(class)
local state = top()
return state ~= nil and getmetatable(state) == class
end
local route30OnClock = false
local function sample()
if isA(InitClock) and Music.current() == ROUTE30 then
route30OnClock = true
end
end
local function wait(n)
for _ = 1, n do
U.wait(1)
sample()
end
end
local function tap(button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
wait(2)
game.input.state[button] = false
wait(frames or 4)
end
local function waitFor(label, predicate, frames)
for _ = 1, frames or 900 do
if predicate() then return end
wait(1)
end
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
end
for _ = 1, 900 do
if isA(MainMenu) then break end
tap("start", 2)
end
waitFor("the intro menu", function() return isA(MainMenu) end)
local menu = top()
for i, item in ipairs(menu.list.items) do
if item.value == "new" then menu.list.index = i end
end
for _ = 1, 20 do
if not isA(MainMenu) then break end
tap("a", 8)
end
wait(4)
if isA(GenderSelect) then bail("gender select on a Gold boot") end
waitFor("the clock screen", function() return isA(InitClock) end, 300)
result(Music.current() ~= ROUTE30,
"no Route 30 theme as the clock screen opens")
waitFor("the clock screen to settle",
function() return isA(InitClock) and top().fade == nil end, 300)
wait(8 * (0x10 + 1) + 16)
result(isA(InitClock), "still on the clock screen after the fade")
result(Music.current() == nil,
("InitClock faded the music to MUSIC_NONE (current %s)")
:format(tostring(Music.current())))
if not U.still(game, out .. "/3059_01_clock_set_silent.png") then
bail("no clock shot")
end
for _ = 1, 200 do
if not isA(InitClock) then break end
tap("a", 3)
end
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
local speech = top()
result(not route30OnClock, "Route 30 never played while InitClock was up")
waitFor("Route 30 after the clock", function()
return Music.current() == ROUTE30
end, 120)
result(Music.current() == ROUTE30, "Route 30 starts once the time is set")
waitFor("Oak's first line", function()
return speech.pic ~= nil and speech.fade == nil
and speech.picReveal == nil and top() ~= speech
end, 400)
wait(30)
if not U.still(game, out .. "/3059_02_oak_with_route30.png") then
bail("no Oak shot")
end
if failures > 0 then
say(("FAIL gen2 oak music after clock (%d failures)"):format(failures))
love.event.quit(1)
return
end
say("PASS gen2 oak music after clock")
love.event.quit(0)
end
@@ -0,0 +1,126 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local CacheContract = require("src.import.CacheContract")
local DatasetViews = require("src.mods.DatasetViews")
local GameVersion = require("src.core.GameVersion")
local Logger = require("src.core.Logger")
local Schemas = require("src.mods.Schemas")
local VERSIONS = { "red", "blue", "yellow", "gold", "silver", "crystal" }
local function slurp(path)
local file = io.open(path, "rb")
if not file then return nil end
local body = file:read("*a")
file:close()
return body
end
local function rootFor(version)
local home = os.getenv("HOME")
if not home or home == "" then return nil end
local identities = { "g1r-" .. version, "pokeport-test-caches" }
local own = os.getenv("POKEPORT_IDENTITY")
if own and own ~= "" then table.insert(identities, 1, own) end
local prefix = GameVersion.cachePrefix(version)
for _, base in ipairs({ home .. "/Library/Application Support/LOVE",
home .. "/.local/share/love" }) do
for _, identity in ipairs(identities) do
local root = base .. "/" .. identity .. "/"
if CacheContract.markerMatches(version,
slurp(root .. prefix .. CacheContract.MARKER_PATH)) then
return root
end
end
end
return nil
end
local roots, found = {}, 0
for _, version in ipairs(VERSIONS) do
roots[GameVersion.cachePrefix(version)] = rootFor(version)
if roots[GameVersion.cachePrefix(version)] then found = found + 1 end
end
if found == 0 then
print("[skip] dataset_views_real_cache_walk: no imported Gen 1 or Gen 2 cache")
os.exit(0)
end
local bodies = {}
local function full(path)
local prefix = path:match("^[^/]+/")
local root = prefix and roots[prefix]
return root and root .. path
end
local fs = {
read = function(path)
if bodies[path] == nil then
local where = full(path)
bodies[path] = where and slurp(where) or false
end
return bodies[path] or nil
end,
}
fs.getInfo = function(path)
local body = fs.read(path)
if not body then return nil end
return { type = "file", size = #body }
end
local warnings = {}
local originalWarn = Logger.warn
Logger.warn = function(fmt, ...)
warnings[#warnings + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
end
local names = {}
for name in pairs(Schemas.REGISTRIES) do names[#names + 1] = name end
table.sort(names)
for _, version in ipairs(VERSIONS) do
if not roots[GameVersion.cachePrefix(version)] then
print("[info] no " .. version .. " cache; skipped")
else
local service = DatasetViews.new(fs)
local view = service:open(version)
T.check(view ~= nil, version .. " opens")
for _, name in ipairs(names) do
if not view then break end
warnings = {}
local ok, err = pcall(function()
for _ in view.content[name]:each() do end
end)
local again, reason = service:open(version)
T.check(ok, version .. " " .. name .. " each does not throw: " .. tostring(err))
T.check(again ~= nil, version .. " " .. name .. " walk keeps the view: "
.. tostring(reason))
T.eq(warnings[1], nil, version .. " " .. name .. " walk logs no rejection")
if not again then
service = DatasetViews.new(fs)
view = service:open(version)
end
end
if view and GameVersion.generation(version) == 2 then
local emotes = view.content.field:get("emotes")
T.check(type(emotes) == "table", version .. " field emotes resolves")
emotes = emotes or {}
T.eq(emotes.fishing, "assets/generated/emotes/fishing.png",
version .. " emotes.fishing is the fishing sheet")
local info = emotes.fishing and view.assets:info(emotes.fishing)
T.eq(info and info.type, "file", version .. " fishing sheet resolves as a file")
if version == "crystal" then
local female = emotes.fishingFemale and view.assets:info(emotes.fishingFemale)
T.eq(female and female.type, "file", version .. " fishingFemale resolves as a file")
else
T.eq(emotes.fishingFemale, nil, version .. " has no fishingFemale")
end
local ids = {}
for id in view.content.field:each() do ids[#ids + 1] = id end
T.same(ids, { "emotes" }, version .. " field lists only emotes")
end
end
end
Logger.warn = originalWarn
T.finish("dataset_views_real_cache_walk")
@@ -0,0 +1,133 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local check, eq = T.check, T.eq
local GameVersion = require("src.core.GameVersion")
local Flags = require("src.core.game3.scripting.flags")
local Space = require("src.core.game3.scripting.space")
local Enc = require("src.core.game3.encounters")
local Gen3Save = require("src.save_convert.Gen3Save")
local FATEFUL = 2147483648
local function fatefulBit(mon)
local r = Gen3Save.fromPortMon(mon, {}, true).ribbons
return math.floor((tonumber(r) or 0) / FATEFUL) % 2 == 1
end
local session = { version = "firered", name = "RED", trainerId = 1, secretId = 2, party = {},
store = Flags.newStore(), map = "FR_NAVEL_ROCK_BASE" }
package.loaded["src.core.game3.runtime"] = {
getSession = function() return session end,
isActive = function() return true end,
}
-- pokefirered/data/maps/NavelRock_Base/scripts.inc:56
GameVersion.set("firered")
Space.store = Flags.newStore()
local Std = require("src.core.game3.scripting.stdscripts")
local Events = require("src.core.game3.scripting.natives_events")
local ctx = { specialVars = {} }
Flags.setVar(Space.store, ctx, 0x8004, 249)
Flags.setVar(Space.store, ctx, 0x8005, 70)
Flags.setVar(Space.store, ctx, 0x8006, 0)
Enc.takePendingWild()
Events.HANDLERS[Std.SPECIAL.CreateEnemyEventMon](ctx, {})
local frFoe = Enc.takePendingWild()
eq(frFoe and frFoe.species, 249, "FireRed CreateEnemyEventMon queues Lugia")
eq(frFoe and frFoe.modernFatefulEncounter, true, "FireRed event mon carries modernFatefulEncounter")
eq(frFoe and frFoe.fatefulEncounter, nil, "FireRed event mon has no legacy fatefulEncounter key")
-- pokeemerald/data/maps/BirthIsland_Exterior/scripts.inc:82
local Islands = require("src.core.game3.scripting.natives_event_islands")
local emCtx = { specialVars = {} }
Flags.setVar(Space.store, emCtx, 0x8004, 410)
Flags.setVar(Space.store, emCtx, 0x8005, 30)
Flags.setVar(Space.store, emCtx, 0x8006, 0)
Islands.BY_NAME.CreateEnemyEventMon(emCtx)
local emFoe = Enc.takePendingWild()
eq(emFoe and emFoe.species, 410, "Emerald CreateEnemyEventMon queues Deoxys")
eq(emFoe and emFoe.modernFatefulEncounter, true, "Emerald event mon carries modernFatefulEncounter")
eq(emFoe and emFoe.fatefulEncounter, nil, "Emerald event mon has no legacy fatefulEncounter key")
-- pokefirered/src/pokemon.c:3396
check(fatefulBit({ species = 249, level = 70, modernFatefulEncounter = true }),
"export sets ribbons bit 31 for a modernFatefulEncounter mon")
check(not fatefulBit({ species = 249, level = 70, modernFatefulEncounter = false }),
"export clears ribbons bit 31 without the flag")
do
local F = require("tests.engine._xgen_fixture")
local TradeConvert = require("src.online.xgen.TradeConvert")
local pika = { species = "PIKACHU", level = 25, exp = 15625, hp = 10, status = "", nickname = "ZAPPY", ot = "RED",
otId = 4242, dvs = { attack = 10, defense = 10, speed = 10, special = 10 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
moves = { { id = "THUNDERSHOCK", pp = 10 } }, catchRate = 190, stats = { hp = 50 } }
local r = TradeConvert.convert({ source = { data = F.data("red") }, target = { data = F.data("emerald") }, mon = pika })
check(r.ok, "Gen 1 Pikachu converts to Gen 3")
eq(r.result and r.result.modernFatefulEncounter, false, "Gen 1 -> Gen 3 non-Mew has the flag clear")
end
local Cache = require("tests.game3_cache")
require("src.import.gba.versions").select("firered")
if Cache.mount("pokemon/battle_moves.lua") then
local Battle = require("src.core.game3.battle.init")
local Catching = require("src.core.game3.battle.catching")
local Engine = require("src.core.game3.battle.engine")
local Adapter = require("src.core.game3.battle.adapter")
local Schema = require("src.core.game3.save_schema_firered")
local SaveData = require("src.core.SaveData")
local Rng = require("src.core.game3.rng")
local function catchFrom(foe, label)
Rng.SeedRng(0x3062)
local s = { version = "firered", name = "RED", trainerId = 1, secretId = 2, party = {
{ species = 6, level = 80, hp = 250, maxHp = 250, otId = 1, otSecretId = 2,
moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 } } } }
session = s
Battle.start({ headless = true, autoFight = false, session = s, playerParty = s.party, foe = foe,
wild = true, legendary = true })
local st = assert(Battle.getState())
eq(st.enemy.mon.modernFatefulEncounter, true, label .. " battle foe keeps modernFatefulEncounter")
local res = Catching.storeCaught(s, st.enemy, 1)
Battle.reset()
check(res and res.success, label .. " is stored")
local mon = res and res.mon or {}
eq(mon.modernFatefulEncounter, true, label .. " caught mon keeps modernFatefulEncounter")
check(fatefulBit(mon), label .. " exported ribbons word has bit 31")
local save = SaveData.decode(SaveData.encode(Schema.toSaveTable(s)))
local back = Schema.fromSaveTable(save)
eq(back.party[2] and back.party[2].modernFatefulEncounter, true, label .. " flag survives native save and load")
end
catchFrom(frFoe, "FireRed Lugia")
catchFrom(emFoe, "Emerald Deoxys")
-- pokefirered/src/battle_util.c:3133
local function obeys(fateful, label)
local mew = { species = 151, level = 70, hp = 200, maxHp = 200, attack = 80, defense = 80, spAtk = 80, spDef = 80,
speed = 80, ability = 0, nickname = "MEW", otId = 1, otSecretId = 2, otName = "RED",
modernFatefulEncounter = fateful, moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 } }
local s = { version = "firered", name = "RED", trainerId = 1, secretId = 2, party = { mew } }
session = s
Space.store = Flags.newStore()
Battle.start({ headless = false, autoFight = false, session = s, playerParty = s.party,
foe = { species = 19, level = 2 }, wild = true,
rng = function(lo, hi) if lo == 1 and hi == 100 then return 1 end return hi end })
local st = assert(Battle.getState())
st.badges, st.playerTrainerId, st.playerOtName = {}, 1, "RED"
local out = {}
Engine.resolveMove(st.player, st.enemy, 33, 1, Adapter.new(st), st, out)
local ok = out._anim == nil or out._anim.cancelled ~= true
Battle.reset()
eq(ok, fateful, label)
end
obeys(true, "own Mew with the fateful flag obeys")
obeys(false, "own Mew without the fateful flag disobeys")
else
print("[skip] battle and catch section: " .. tostring(Cache.reason))
end
T.finish("game3_event_mon_fateful_3062")
@@ -18,7 +18,7 @@ local function mon(species, otId, fateful)
return { species = species, level = 30, hp = 200, maxHp = 200,
attack = 80, defense = 80, spAtk = 80, spDef = 80, speed = 80, ability = 0,
nickname = "RENTAL", otId = otId, otName = otId == 1 and "PLAYER" or "RENTAL",
fatefulEncounter = fateful, moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 } }
modernFatefulEncounter = fateful, moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 } }
end
local function run(o)
+120
View File
@@ -0,0 +1,120 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Cache = require("tests.game3_cache")
if not Cache.mount("meta.json") then
print("[skip] game3_link_fateful_3062: " .. tostring(Cache.reason))
os.exit(0)
end
local GameVersion = require("src.core.GameVersion")
GameVersion.set("firered")
local Pokemon = require("src.core.game3.pokemon")
local Party = require("src.core.game3.party")
local Protocol = require("src.link.Protocol")
local Wire = require("src.link.Wire")
local Json = require("src.link.Json")
Pokemon.install(nil)
local FATEFUL = 2147483648
local session = { version = "firered", name = "RED", trainerId = 24680, secretId = 1357, gender = 0, party = {} }
local function give(species, level, extra)
local holder = { name = "RED", trainerId = 24680, secretId = 1357, gender = 0, party = {} }
local ok, _, mon = Party.giveMon(holder, species, level)
assert(ok and mon, "giveMon " .. tostring(species))
for k, v in pairs(extra or {}) do mon[k] = v end
Pokemon.applyStats(mon)
return mon
end
session.party = { give(1, 10), give(4, 12) }
package.loaded["src.core.game3.runtime"] = {
getSession = function() return session end,
isActive = function() return true end,
}
local Link = require("src.core.game3.link")
Link.session = function() return session end
local LT = require("src.core.game3.link.trade")
local function overWire(packed)
local raw = Json.decode(Json.encode({ type = "game3_trade_mon", mon = packed }))
return Wire.sanitize(raw).mon
end
local function partnerCode(received)
LT.peer = nil
LT.peerParty = { received }
return LT.checkValidityOfTradeMons(1, 1)
end
for _, case in ipairs({ { 410, 30, "DEOXYS" }, { 151, 30, "MEW" } }) do
local species, level, name = case[1], case[2], case[3]
local legit = give(species, level, { modernFatefulEncounter = true })
local packed = Protocol.packMon3(legit)
eq(packed.modernFatefulEncounter, true, name .. " packs modernFatefulEncounter")
eq(math.floor(packed.ribbons / FATEFUL) % 2, 1, name .. " packs ribbons bit 31")
eq(packed.fatefulEncounter, true, name .. " packs the key older releases read as true")
local wired = overWire(packed)
eq(wired.fatefulEncounter, true, name .. " keeps the older-release key through Wire.sanitize")
eq(wired.modernFatefulEncounter, true, name .. " keeps modernFatefulEncounter through Wire.sanitize")
local got, why = LT.unpackMon(wired)
check(got ~= nil, name .. " unpacks strictly: " .. tostring(why))
eq(got and got.modernFatefulEncounter, true, name .. " received over link keeps modernFatefulEncounter")
eq(got and got.fatefulEncounter, nil, name .. " received over link has no legacy fatefulEncounter key")
eq(got and partnerCode(got), LT.BOTH_MONS_VALID, name .. " with the fateful flag from the partner is valid")
eq(Protocol.packMon3(got).modernFatefulEncounter, true, name .. " keeps the flag when traded on again")
local bad = LT.unpackMon(overWire(Protocol.packMon3(give(species, level, { modernFatefulEncounter = false }))))
eq(bad and bad.modernFatefulEncounter, false, name .. " without the flag unpacks false")
eq(bad and partnerCode(bad), LT.PARTNER_MON_INVALID, name .. " without the fateful flag is PARTNER_MON_INVALID")
end
do
local legit = Protocol.packMon3(give(410, 30, { modernFatefulEncounter = true }))
local older = {}
for k, v in pairs(legit) do older[k] = v end
older.fatefulEncounter = false
local got = LT.unpackMon(overWire(older))
eq(got and got.modernFatefulEncounter, true, "DEOXYS from an older release (legacy key false) keeps the flag")
eq(got and partnerCode(got), LT.BOTH_MONS_VALID, "and is valid")
older.modernFatefulEncounter = nil
got = LT.unpackMon(overWire(older))
eq(got and got.modernFatefulEncounter, true, "DEOXYS from a release without the modern key reads ribbons bit 31")
end
do
local Trade = require("src.online.Trade")
local handle = { generation = 3, version = "firered", data = { pokemon = Pokemon }, party = session.party }
local link = { send = function() return true end, seat = function() return 0 end }
local remote, why = Trade.remote(handle, link, {})
check(remote ~= nil, "online Remote3 opens: " .. tostring(why))
if remote then
local mew = LT.unpackMon(overWire(Protocol.packMon3(give(151, 30, { modernFatefulEncounter = true }))))
remote.session.theirParty, remote.session.theirPick = { mew }, 1
eq(remote:_partnerMonValid(), true, "online: a partner MEW with the fateful flag is valid")
local bad = LT.unpackMon(overWire(Protocol.packMon3(give(151, 30, { modernFatefulEncounter = false }))))
remote.session.theirParty = { bad }
eq(remote:_partnerMonValid(), false, "online: a partner MEW without the fateful flag is refused")
end
end
do
local Family = require("src.core.game3.link.family")
local ok, deoxys = pcall(Family.species, "emerald", "SPECIES_DEOXYS")
if ok and deoxys then
local party = { give(410, 30, { modernFatefulEncounter = false }), give(1, 10) }
eq(Family.canTradeSelectedMon("emerald", party, 0, { nationalDex = true }), Family.CANT_TRADE_INVALID_MON,
"Emerald: own DEOXYS without the fateful flag can't be traded")
party[1].modernFatefulEncounter = true
check(Family.canTradeSelectedMon("emerald", party, 0, { nationalDex = true }) ~= Family.CANT_TRADE_INVALID_MON,
"Emerald: own DEOXYS with the fateful flag can be traded")
else
print("[skip] Emerald constants: " .. tostring(deoxys))
end
end
T.finish("game3_link_fateful_3062")
@@ -0,0 +1,142 @@
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 Versions = require("src.import.gba.versions")
local Extract = require("src.import.gba.extract_island1")
local TmCaseExtract = require("src.import.gba.tm_case_extract")
local TmCaseChrome = require("src.ui.game3.tm_case_chrome")
local ROOT = Extract.CACHE_ROOT or "data/generated/gba"
-- src/tm_case.c:396 sTMSpritePaletteOffsetByType
local BANK_BY_TYPE = {
[0] = 0, [1] = 9, [2] = 8, [3] = 12, [4] = 6, [5] = 5, [6] = 11, [7] = 10, [8] = 14,
[9] = 0, [10] = 1, [11] = 2, [12] = 3, [13] = 4, [14] = 13, [15] = 7, [16] = 16, [17] = 15,
}
local bytes = {}
local function put(at, list)
for i, b in ipairs(list) do bytes[at + i - 1] = b end
end
local function lz(raw)
local out = { 0x10, #raw % 256, math.floor(#raw / 256) % 256, math.floor(#raw / 65536) % 256 }
for i = 1, #raw, 8 do
out[#out + 1] = 0
for j = i, math.min(i + 7, #raw) do out[#out + 1] = raw[j] end
end
return out
end
local function fill(n, v)
local t = {}
for i = 1, n do t[i] = v end
return t
end
local function colorFor(bank, c)
return bank * 64 + c + 1
end
local function palBytes(firstBank, count)
local t = {}
for b = firstBank, firstBank + count - 1 do
for c = 0, 15 do
local v = colorFor(b, c)
t[#t + 1] = v % 256
t[#t + 1] = math.floor(v / 256)
end
end
return t
end
local KEYS = {
"TM_CASE_BG_GFX", "TM_CASE_MENU_TILEMAP", "TM_CASE_BG_TILEMAP", "TM_CASE_MENU_MALE_PAL",
"TM_CASE_MENU_FEMALE_PAL", "TM_CASE_DISC_GFX", "TM_CASE_DISC_TYPES1_PAL", "TM_CASE_DISC_TYPES2_PAL",
"TM_CASE_HM_GFX", "TM_CASE_PAL3_OVERRIDE", "TM_CASE_DISC_PAL_OFFSETS",
}
local saved = {}
for i, k in ipairs(KEYS) do
saved[k] = Versions[k]
Versions[k] = 0x100000 + i * 0x1000
end
put(Versions.TM_CASE_BG_GFX, lz(fill(32, 0)))
put(Versions.TM_CASE_MENU_TILEMAP, lz(fill(8, 0)))
put(Versions.TM_CASE_BG_TILEMAP, lz(fill(8, 0)))
put(Versions.TM_CASE_MENU_MALE_PAL, lz(fill(32, 0)))
put(Versions.TM_CASE_MENU_FEMALE_PAL, lz(fill(32, 0)))
put(Versions.TM_CASE_DISC_GFX, lz(fill(1024, 0x21)))
put(Versions.TM_CASE_DISC_TYPES1_PAL, lz(palBytes(0, 16)))
put(Versions.TM_CASE_DISC_TYPES2_PAL, lz(palBytes(16, 1)))
put(Versions.TM_CASE_HM_GFX, fill(128, 0))
put(Versions.TM_CASE_PAL3_OVERRIDE, { 0x08, 0x21, 0x1E, 0x1A })
for t = 0, 17 do
local off = BANK_BY_TYPE[t] * 16
put(Versions.TM_CASE_DISC_PAL_OFFSETS + t * 2, { off % 256, math.floor(off / 256) })
end
local rom = { get = function(_, i) return bytes[i] or 0 end }
local files = {}
local cache = {
write = function(_, path, data) files[path] = data; return true end,
exists = function(_, path) return files[path] ~= nil end,
read = function(_, path) return files[path] end,
}
local ok, err = pcall(TmCaseExtract.run, rom, cache, { cacheRoot = ROOT })
for _, k in ipairs(KEYS) do Versions[k] = saved[k] end
check(ok, "TM case extract runs on the fixture ROM: " .. tostring(err))
local reads = {}
TmCaseChrome._cache = {
read = function(_, rel)
local data = files[rel]
reads[#reads + 1] = { rel = rel, data = data }
return data
end,
}
local function rgb8(c)
local r5, g5, b5 = c % 32, math.floor(c / 32) % 32, math.floor(c / 1024) % 32
return math.floor(r5 * 255 / 31 + 0.5), math.floor(g5 * 255 / 31 + 0.5), math.floor(b5 * 255 / 31 + 0.5)
end
local function discRead(typeIdx, isHm)
reads = {}
TmCaseChrome._discs, TmCaseChrome._discsHm = {}, {}
TmCaseChrome.loadDisc(typeIdx, isHm)
return reads[1]
end
local NAMES = { [10] = "Fire", [11] = "Water", [8] = "Steel", [16] = "Dragon", [17] = "Dark", [9] = "Mystery" }
for t = 0, 17 do
for _, isHm in ipairs({ false, true }) do
local label = (NAMES[t] or ("type " .. t)) .. (isHm and " HM" or " TM")
local r = discRead(t, isHm)
local want = string.format("disc_%s%d.rgba", isHm and "hm_" or "", t)
eq(r and r.rel:match("(disc_[%w_]*%.rgba)$"), want, label .. " reads the per-type disc file")
local data = r and r.data
if check(data and #data == 32 * 32 * 4, label .. " disc is in the cache") then
local er, eg, eb = rgb8(colorFor(BANK_BY_TYPE[t], 1))
eq(table.concat({ data:byte(1, 4) }, ","), table.concat({ er, eg, eb, 255 }, ","),
label .. " disc uses palette bank " .. BANK_BY_TYPE[t])
end
end
end
check(TmCaseChrome.discBank == nil, "the UI carries no type to palette bank table")
local required = {}
for _, rel in ipairs(TmCaseExtract.REQUIRED or {}) do required[rel] = true end
check(required["items/tm_case/disc_17.rgba"], "disc_17 is a required cache file")
check(required["items/tm_case/disc_hm_17.rgba"], "disc_hm_17 is a required cache file")
check(TmCaseExtract.ready(cache, ROOT), "TmCaseExtract.ready sees every required file")
local plan = require("src.import.gba.plans.frlg")
local inPlan = false
for _, m in ipairs(plan.pokemonAfter or {}) do
if m == "src.import.gba.tm_case_extract" then inPlan = true end
end
check(inPlan, "the FRLG import plan runs the TM case extract")
T.finish()
@@ -0,0 +1,117 @@
-- ../pokegold/engine/rtc/timeset.asm:13-18
-- ../pokegold/engine/menus/intro_menu.asm:494-500
-- ../pokecrystal/engine/menus/init_gender.asm:60-65
-- luajit tests/engine/gen2_oak_music_after_clock_3059.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")
local Music = require("src.core.Music")
local Screens = require("src.ui.Screens")
local FieldMoves = require("src.world.gen2.FieldMoves")
local OakSpeech = require("src.ui.gen2.OakSpeech")
local played, fades, pushed = {}, {}, {}
local realPlay, realFade, realPush = Music.play, Music.fadeOut, Screens.push
Music.play = function(_, song) played[#played + 1] = song end
Music.fadeOut = function(control) fades[#fades + 1] = control end
Screens.push = function(game, id, opts)
local inst = { id = id, opts = opts }
pushed[#pushed + 1] = inst
game.stack:push(inst)
return inst
end
local function newGame(crystal)
local game = { save = { player = { name = "GOLD" } } }
game.data = {
audio = { runtime = true },
tokens = require("src.render.TextBox").TOKENS,
gen2Sprites = crystal
and { [FieldMoves.STATE_SPRITE_FEMALE.normal] = 1 } or nil,
}
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 = {
wasPressed = function() return false end,
isDown = function() return false end,
}
return game
end
local function route30Played()
for _, song in ipairs(played) do
if song == "Music_Route30" then return true end
end
return false
end
local function reset()
played, fades, pushed = {}, {}, {}
end
do
reset()
local game = newGame(false)
local speech = OakSpeech.new(game, { data = { music = "Music_Route30" } })
speech.autoConfirm = true
speech:enter()
eq(pushed[1] and pushed[1].id, "Gen2InitClock",
"gold: the speech opens on InitClock")
check(not route30Played(),
"gold: no Route 30 theme while the clock is being set")
eq(fades[1], 0x10, "gold: InitClock fades the menu music out at $10")
pushed[1].opts.onDone()
check(route30Played(), "gold: Route 30 starts once the time is confirmed")
end
do
reset()
local game = newGame(true)
local speech = OakSpeech.new(game, { data = { music = "Music_Route30" } })
speech.autoConfirm = true
speech:enter()
eq(pushed[1] and pushed[1].id, "Gen2GenderSelect",
"crystal: the gender select comes first")
check(not route30Played(), "crystal: silent during the gender select")
eq(fades[1], 0x10, "crystal: InitGenderScreen fades the menu music at $10")
pushed[1].opts.onDone("male")
eq(pushed[2] and pushed[2].id, "Gen2InitClock",
"crystal: InitClock follows the gender select")
check(not route30Played(),
"crystal: still silent while the clock is being set")
pushed[2].opts.onDone()
check(route30Played(), "crystal: Route 30 starts once the time is confirmed")
end
do
reset()
local game = newGame(false)
local speech = OakSpeech.new(game, { data = { music = "Music_Route30" } })
speech.autoConfirm = true
local base = OakSpeech.buildSteps
speech.buildSteps = function(self)
local steps = base(self)
table.insert(steps, 1, { id = "mod_pre", kind = "fn",
run = function(_, done) done() end })
return steps
end
speech:enter()
eq(pushed[1] and pushed[1].id, "Gen2InitClock",
"mod step first: InitClock still opens")
eq(played[1], "Music_Route30", "mod step first: theme starts at the mod step")
eq(fades[1], 0x10, "mod step first: InitClock fades the theme out at $10")
pushed[1].opts.onDone()
eq(#played, 2, "mod step first: Route 30 restarts once the time is confirmed")
end
Music.play, Music.fadeOut, Screens.push = realPlay, realFade, realPush
T.finish("gen2_oak_music_after_clock_3059")
@@ -0,0 +1,414 @@
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("launcher mods update keeps baseroms")
local eq = S.eq
local check = S.check
local MOD_ID = "big_rom_mod"
local DEST = "mods/" .. MOD_ID
local CHUNK = 4 * 1024 * 1024
local OLD_MANIFEST =
('{"id":"%s","name":"Big Rom","version":"1.0.0","entry":"main.lua"}'):format(MOD_ID)
local NEW_MANIFEST =
('{"id":"%s","name":"Big Rom","version":"2.0.0","entry":"main.lua"}'):format(MOD_ID)
local ARCHIVE = {
[MOD_ID .. "/manifest.json"] = NEW_MANIFEST,
[MOD_ID .. "/main.lua"] = "return function() return 2 end\n",
[MOD_ID .. "/data/new.lua"] = "return {}\n",
}
local files, dirs, arch, mtime = {}, {}, {}, {}
local ops = {}
local clock = 0
local trapBaseromReads = true
local failWriteOnce, failOpenWrite
local chunkReads, maxChunk = 0, 0
local caseFold
local function fold(name)
if not caseFold then return name end
local key = caseFold:lower()
local low = name:lower()
if low == key or low:sub(1, #key + 1) == key .. "/" then
return caseFold .. name:sub(#key + 1)
end
return name
end
local function resetFs()
for _, t in ipairs({ files, dirs, arch, mtime, ops }) do
for k in pairs(t) do t[k] = nil end
end
trapBaseromReads = true
failWriteOnce, failOpenWrite = nil, nil
chunkReads, maxChunk = 0, 0
caseFold = nil
end
local function log(op, path) ops[#ops + 1] = { op = op, path = path } end
local function store(name, data)
clock = clock + 1
files[name] = data
mtime[name] = clock
end
local function dirChild(key, name)
if name == nil or name == "" then return key:match("^[^/]+") end
local prefix = name .. "/"
if key:sub(1, #prefix) ~= prefix then return nil end
return key:sub(#prefix + 1):match("^[^/]+")
end
local function mapInfo(map, name, kind)
if map[name] ~= nil then return { type = kind or "file" } end
for key in pairs(map) do
if dirChild(key, name) then return { type = "directory" } end
end
return nil
end
local vfs = {}
function vfs.write(name, data)
name = fold(name)
log("write", name)
if failWriteOnce == name then
failWriteOnce = nil
return nil, "simulated write failure"
end
store(name, data)
return true
end
function vfs.read(name)
name = fold(name)
log("read", name)
if trapBaseromReads and name:find("/baseroms/", 1, true) then
error("string length overflow")
end
if arch[name] ~= nil then return arch[name] end
return files[name]
end
function vfs.remove(name)
name = fold(name)
log("remove", name)
files[name] = nil
dirs[name] = nil
mtime[name] = nil
return true
end
function vfs.createDirectory(name)
name = fold(name)
dirs[name] = true
return true
end
function vfs.getInfo(name, kind)
name = fold(name)
log("info", name)
local info = mapInfo(arch, name)
or mapInfo(files, name)
or mapInfo(dirs, name, "directory")
if info and kind and info.type ~= kind then return nil end
return info
end
function vfs.getDirectoryItems(name)
name = fold(name)
log("list", name)
local seen, items = {}, {}
local function add(child)
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
for key in pairs(arch) do add(dirChild(key, name)) end
for key in pairs(files) do add(dirChild(key, name)) end
for key in pairs(dirs) do add(dirChild(key, name)) end
table.sort(items)
return items
end
function vfs.newFile(name)
name = fold(name)
local f = { mode = nil, pos = 1, buf = {} }
function f:open(mode)
log("open:" .. mode, name)
if mode == "w" and failOpenWrite == name then return false, "simulated open failure" end
if mode == "r" and files[name] == nil then return false, "missing" end
self.mode = mode
return true
end
function f:read(n)
chunkReads = chunkReads + 1
if n > maxChunk then maxChunk = n end
local src = files[name] or ""
local out = src:sub(self.pos, self.pos + n - 1)
self.pos = self.pos + #out
return out, #out
end
function f:write(data)
self.buf[#self.buf + 1] = data
return true
end
function f:close()
if self.mode == "w" then
log("write", name)
store(name, table.concat(self.buf))
end
self.mode = nil
return true
end
return f
end
function vfs.mount(_, point)
for rel, body in pairs(ARCHIVE) do arch[point .. "/" .. rel] = body end
return true
end
function vfs.unmount()
for k in pairs(arch) do arch[k] = nil end
return true
end
function vfs.newFileData(data, name)
return { __filedata = true, data = data, name = name }
end
function vfs.getSaveDirectory() return "/tmp/pokeport-update-baseroms-test" end
function vfs.getSource() return nil end
local savedFs = love.filesystem
local savedCacheFs = package.loaded["src.import.CacheFs"]
local savedLauncherMods = package.loaded["src.mods.LauncherMods"]
local SaveData = require("src.core.SaveData")
local savedPortable = SaveData.portableBaseDir
love.filesystem = vfs
package.loaded["src.import.CacheFs"] = nil
package.loaded["src.mods.LauncherMods"] = nil
SaveData.portableBaseDir = function() return nil end
local LauncherMods = require("src.mods.LauncherMods")
local function touches(prefix)
local n = 0
for _, o in ipairs(ops) do
if o.path == prefix or o.path:sub(1, #prefix + 1) == prefix .. "/" then n = n + 1 end
end
return n
end
local function seedOldInstall()
store(DEST .. "/manifest.json", OLD_MANIFEST)
store(DEST .. "/main.lua", "return function() return 1 end\n")
store(DEST .. "/old.lua", "return 'stale'\n")
store(DEST .. "/gfx/old.bin", "stale")
store(DEST .. "/baseroms/big.3ds", "user-owned-3ds")
store(DEST .. "/baseroms/.required-import-rom.validated", "digest 14 1")
end
local function update(name)
store("imports/mods/" .. name, "PK\3\4" .. name)
return LauncherMods.installZip("imports/mods/" .. name,
{ replace = true, expectId = MOD_ID })
end
resetFs()
seedOldInstall()
local romTime = mtime[DEST .. "/baseroms/big.3ds"]
local receiptTime = mtime[DEST .. "/baseroms/.required-import-rom.validated"]
local ok, err = update("u1.zip")
check(ok == true, "update with a baserom present succeeds (" .. tostring(err) .. ")")
eq(touches(DEST .. "/baseroms"), 0, "update never lists, reads, writes or removes baseroms/")
eq(files[DEST .. "/baseroms/big.3ds"], "user-owned-3ds", "baserom bytes untouched")
eq(mtime[DEST .. "/baseroms/big.3ds"], romTime, "baserom modtime untouched")
eq(mtime[DEST .. "/baseroms/.required-import-rom.validated"], receiptTime,
"validated receipt untouched")
eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "manifest is the new version")
eq(files[DEST .. "/main.lua"], ARCHIVE[MOD_ID .. "/main.lua"], "package file updated")
check(files[DEST .. "/data/new.lua"] ~= nil, "new package file written")
check(files[DEST .. "/old.lua"] == nil, "stale file from the old version removed")
check(files[DEST .. "/gfx/old.bin"] == nil, "stale nested file removed")
local lastWrite
for _, o in ipairs(ops) do
if o.op == "write" and o.path:sub(1, #DEST + 1) == DEST .. "/" then lastWrite = o.path end
end
eq(lastWrite, DEST .. "/manifest.json", "manifest.json is the last write")
resetFs()
trapBaseromReads = false
seedOldInstall()
failWriteOnce = DEST .. "/main.lua"
ok, err = update("u2.zip")
check(not ok, "write failure fails the update")
eq(files[DEST .. "/manifest.json"], OLD_MANIFEST, "old manifest survives the failure")
eq(files[DEST .. "/baseroms/big.3ds"], "user-owned-3ds", "baserom survives the failure")
eq(touches(DEST .. "/baseroms"), 0, "failed update never touches baseroms/")
ok, err = update("u2-retry.zip")
check(ok == true, "retry after a failure succeeds (" .. tostring(err) .. ")")
eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "retry lands the new manifest")
resetFs()
failWriteOnce = DEST .. "/manifest.json"
store("imports/mods/fresh.zip", "PK\3\4fresh")
ok, err = LauncherMods.installZip("imports/mods/fresh.zip")
check(not ok, "fresh install manifest failure fails")
local left = 0
for k in pairs(files) do if k:sub(1, #DEST + 1) == DEST .. "/" then left = left + 1 end end
eq(left, 0, "failed fresh install rolls back every written file")
resetFs()
local big = string.rep("A", CHUNK) .. string.rep("B", CHUNK) .. "tail"
store("mods/OldFolder/manifest.json", OLD_MANIFEST)
store("mods/OldFolder/main.lua", "return function() end\n")
store("mods/OldFolder/baseroms/x.z64", big)
store("imports/baseroms-recovery/" .. MOD_ID .. "/y.z64", "recovered")
ok, err = update("u3.zip")
check(ok == true, "update over a shadow copy succeeds (" .. tostring(err) .. ")")
check(files[DEST .. "/baseroms/x.z64"] == big, "shadow baserom drained byte-exact")
eq(files[DEST .. "/baseroms/y.z64"], "recovered", "recovery baserom drained")
check(files["mods/OldFolder/manifest.json"] == nil, "shadow tree removed")
check(files["mods/OldFolder/baseroms/x.z64"] == nil, "shadow baserom removed after drain")
check(files["imports/baseroms-recovery/" .. MOD_ID .. "/y.z64"] == nil,
"recovery dir cleared")
check(chunkReads >= 3, "drain streams in chunks (" .. chunkReads .. " reads)")
check(maxChunk <= CHUNK, "drain never reads more than one chunk at a time")
eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "drained install has the new manifest")
resetFs()
seedOldInstall()
store("mods/OldFolder/manifest.json", OLD_MANIFEST)
store("mods/OldFolder/baseroms/big.3ds", "shadow-copy")
ok, err = update("u4.zip")
check(ok == true, "update with both copies succeeds (" .. tostring(err) .. ")")
eq(files[DEST .. "/baseroms/big.3ds"], "user-owned-3ds", "existing baserom kept")
resetFs()
store("mods/OldFolder/manifest.json", OLD_MANIFEST)
store("mods/OldFolder/baseroms/z.z64", "only-copy")
failOpenWrite = DEST .. "/baseroms/z.z64"
ok, err = update("u5.zip")
check(not ok, "drain failure fails the update")
check(files[DEST .. "/manifest.json"] ~= nil, "mods/<id> keeps a manifest after a drain failure")
eq(files["mods/OldFolder/baseroms/z.z64"], "only-copy", "drain source kept on failure")
check(files[DEST .. "/baseroms/z.z64"] == nil, "partial drain copy removed")
resetFs()
store(DEST .. "/baseroms/r.z64", "orphan-rom")
store("imports/mods/heal.zip", "PK\3\4heal")
ok, err = LauncherMods.installZip("imports/mods/heal.zip")
check(ok == true, "re-import over orphan baseroms succeeds (" .. tostring(err) .. ")")
eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "orphan baseroms gain a manifest")
eq(files[DEST .. "/baseroms/r.z64"], "orphan-rom", "orphan baserom kept")
eq(touches(DEST .. "/baseroms"), 0, "re-import never touches baseroms/")
resetFs()
store("mods/Big_Rom_Mod/manifest.json", OLD_MANIFEST)
store("mods/Big_Rom_Mod/main.lua", "return function() return 1 end\n")
store("mods/Big_Rom_Mod/baseroms/k.z64", "case-shadow-rom")
ok, err = update("u6.zip")
check(ok == true, "update over a case-only shadow succeeds (" .. tostring(err) .. ")")
check(files["mods/Big_Rom_Mod/manifest.json"] == nil,
"case-only shadow removed on a case-sensitive filesystem")
check(files["mods/Big_Rom_Mod/baseroms/k.z64"] == nil, "case-only shadow baserom drained out")
eq(files[DEST .. "/baseroms/k.z64"], "case-shadow-rom", "case-only shadow baserom lands in mods/<id>")
eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "mods/<id> has the new manifest")
resetFs()
caseFold = "mods/Big_Rom_Mod"
store("mods/Big_Rom_Mod/manifest.json", OLD_MANIFEST)
store("mods/Big_Rom_Mod/main.lua", "return function() return 1 end\n")
store("mods/Big_Rom_Mod/baseroms/k.z64", "folded-rom")
ok, err = update("u7.zip")
check(ok == true, "update on a case-insensitive filesystem succeeds (" .. tostring(err) .. ")")
eq(files["mods/Big_Rom_Mod/manifest.json"], NEW_MANIFEST,
"case-folded install dir is updated, not removed")
eq(files["mods/Big_Rom_Mod/baseroms/k.z64"], "folded-rom", "case-folded install keeps its baserom")
eq(touches("mods/Big_Rom_Mod/baseroms"), 0, "case-folded install never touches baseroms/")
do
local FsIo = require("tests.fs_io")
local tmpBase = (os.getenv("TMPDIR") or "/tmp"):gsub("[/\\]+$", "")
local sandbox = ("%s/lm3057_portable_%d_%d"):format(tmpBase, os.time(), math.random(1, 999999))
local game = sandbox .. "/game"
os.execute('mkdir -p "' .. game .. '/mods/' .. MOD_ID .. '/baseroms"')
local zip = sandbox .. "/u.zip"
local function put(path, data)
local f = assert(io.open(path, "wb"))
f:write(data)
f:close()
end
put(zip, "PK\003\004x")
put(game .. "/" .. DEST .. "/baseroms/g.rom", "game-folder-rom")
local realFs = FsIo.new(game)
local save, parch, saveOps = {}, {}, {}
local function mapInfoP(map, name)
if map[name] ~= nil then return { type = "file" } end
for k in pairs(map) do if dirChild(k, name) then return { type = "directory" } end end
end
local pvfs = {}
function pvfs.write(n, d) saveOps[#saveOps + 1] = n save[n] = d return true end
function pvfs.read(n) return parch[n] or save[n] or realFs.read(n) end
function pvfs.remove(n) saveOps[#saveOps + 1] = n save[n] = nil return true end
function pvfs.createDirectory() return true end
function pvfs.getInfo(n, kind)
local i = mapInfoP(parch, n) or mapInfoP(save, n) or realFs.getInfo(n)
if i and kind and i.type ~= kind then return nil end
return i
end
function pvfs.getDirectoryItems(n)
local seen, out = {}, {}
local function add(c) if c and not seen[c] then seen[c] = true out[#out + 1] = c end end
for k in pairs(parch) do add(dirChild(k, n)) end
for k in pairs(save) do add(dirChild(k, n)) end
local i = realFs.getInfo(n)
if i and i.type == "directory" then
for _, c in ipairs(realFs.getDirectoryItems(n)) do add(c) end
end
table.sort(out)
return out
end
function pvfs.mount(_, pt) for r, b in pairs(ARCHIVE) do parch[pt .. "/" .. r] = b end return true end
function pvfs.unmount() for k in pairs(parch) do parch[k] = nil end return true end
function pvfs.getSource() return game end
function pvfs.getSaveDirectory() return sandbox .. "/os-save" end
love.filesystem = pvfs
SaveData.portableBaseDir = function() return game end
package.loaded["src.import.CacheFs"] = nil
package.loaded["src.mods.LauncherMods"] = nil
local PortableMods = require("src.mods.LauncherMods")
save[DEST .. "/manifest.json"] = OLD_MANIFEST
save[DEST .. "/main.lua"] = "return function() return 1 end\n"
save[DEST .. "/baseroms/s.rom"] = "save-dir-rom"
ok, err = PortableMods.installZip(zip, { replace = true, expectId = MOD_ID })
check(ok == true, "portable update over a save-dir twin succeeds (" .. tostring(err) .. ")")
eq(pvfs.read(DEST .. "/manifest.json"), NEW_MANIFEST,
"portable update is visible through the merged read path")
eq(pvfs.read(DEST .. "/main.lua"), ARCHIVE[MOD_ID .. "/main.lua"],
"portable package file is visible through the merged read path")
check(save[DEST .. "/manifest.json"] == nil, "save-dir twin manifest cleared")
eq(save[DEST .. "/baseroms/s.rom"], "save-dir-rom", "save-dir baserom kept")
eq(realFs.read(DEST .. "/baseroms/g.rom"), "game-folder-rom", "game-folder baserom kept")
local baseromOps = 0
for _, n in ipairs(saveOps) do
if n:find("/baseroms", 1, true) then baseromOps = baseromOps + 1 end
end
eq(baseromOps, 0, "portable update never writes or removes a save-dir baserom")
os.execute('rm -r "' .. sandbox .. '"')
end
love.filesystem = savedFs
SaveData.portableBaseDir = savedPortable
package.loaded["src.import.CacheFs"] = savedCacheFs
package.loaded["src.mods.LauncherMods"] = savedLauncherMods
S.finish()
+164
View File
@@ -0,0 +1,164 @@
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("portable gen3 import 3048")
local check, eq = S.check, S.eq
local FsIo = require("tests.fs_io")
local SaveData = require("src.core.SaveData")
local WIN = FsIo.isWindows
local function mkdirp(path)
if WIN then
os.execute('mkdir "' .. path:gsub("/", "\\") .. '" 2>nul')
else
os.execute('mkdir -p "' .. path .. '" 2>/dev/null')
end
end
local function rmrf(path)
if WIN then
os.execute('rmdir /s /q "' .. path:gsub("/", "\\") .. '" 2>nul')
else
os.execute('rm -rf "' .. path .. '" 2>/dev/null')
end
end
local function slurp(path)
local f = io.open(path, "rb")
if not f then return nil end
local body = f:read("*a")
f:close()
return body
end
local SANDBOX = ((os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp")
:gsub("[/\\]+$", "")) .. ("/pokeport_3048_%d_%d"):format(os.time(), math.random(1, 999999))
local GAME = SANDBOX .. "/game"
local SAVEDIR = SANDBOX .. "/appdata"
mkdirp(GAME)
mkdirp(SAVEDIR)
do
local f = assert(io.open(GAME .. "/portable.txt", "wb"))
f:close()
end
local savedFs = love.filesystem
local savedSystem = love.system
local savedCacheFs = package.loaded["src.import.CacheFs"]
local savedExtractor = package.loaded["src.import.RomExtractorGen3"]
local savedLuaWriter = package.loaded["src.import.LuaWriter"]
local SPIED = { "write", "newFile", "createDirectory", "append", "remove" }
local spyCalls = {}
local function stubLove()
local fs = {}
for k, v in pairs(savedFs) do fs[k] = v end
fs.getSource = function() return GAME end
fs.getSourceBaseDirectory = function() return GAME end
fs.getSaveDirectory = function() return SAVEDIR end
for _, name in ipairs(SPIED) do
local real = fs[name]
fs[name] = function(path, ...)
spyCalls[#spyCalls + 1] = name .. "(" .. tostring(path) .. ")"
if real then return real(path, ...) end
return false
end
end
love.filesystem = fs
love.system = { getOS = function() return WIN and "Windows" or "Linux" end }
SaveData._resetPortableCacheForTests()
end
local function saveDirEntries()
return #(FsIo.listDir(SAVEDIR) or {})
end
local function spySummary()
return #spyCalls .. (#spyCalls > 0 and (" (" .. table.concat(spyCalls, ", ") .. ")") or "")
end
local function run()
stubLove()
check(SaveData.isPortable(), "sandbox game folder is portable")
package.loaded["src.import.CacheFs"] = nil
package.loaded["src.import.LuaWriter"] = nil
package.loaded["src.import.RomExtractorGen3"] = nil
local CacheFs = require("src.import.CacheFs")
CacheFs._resetPortableForTests()
eq(CacheFs.root(), GAME, "cache root is the portable game folder")
local RomExtractorGen3 = require("src.import.RomExtractorGen3")
local GameVersion = require("src.core.GameVersion")
for _, id in ipairs({ "firered", "leafgreen", "ruby", "sapphire", "emerald" }) do
local version = GameVersion.VERSIONS[id]
spyCalls = {}
CacheFs.prefix = version.cachePrefix
local ok, err = pcall(RomExtractorGen3.writeRequiredMarkers,
{ plan = { dirs = { "", "/intro", "/rse/pokedex", "/pokemon/storage" } } }, version.sha1)
CacheFs.prefix = ""
check(ok, id .. " required markers write" .. (ok and "" or (": " .. tostring(err))))
eq(#spyCalls, 0, id .. " marker pass makes no save-dir filesystem calls: " .. spySummary())
local root = GAME .. "/" .. version.cachePrefix .. "data/generated/gba"
check(FsIo.isDir(root .. "/rse/pokedex"), id .. " plan dirs are created in the game folder")
check(FsIo.isDir(root .. "/pokemon/storage"), id .. " nested plan dirs are created in the game folder")
check(not FsIo.isDir(GAME .. "/" .. version.cachePrefix .. version.cachePrefix),
id .. " plan dirs are not double-prefixed")
eq(saveDirEntries(), 0, id .. " marker pass leaves the OS save directory empty")
end
local Loader = require("src.mods.Loader")
spyCalls = {}
local loader = setmetatable({
fs = love.filesystem,
mods = { m = { enabled = true, manifest = {} } },
optionSchemas = { m = { { key = "x" } } },
}, { __index = Loader })
local okSchema, schemaErr = pcall(Loader._writeOptionSchemas, loader)
check(okSchema, "option schema export runs" .. (okSchema and "" or (": " .. tostring(schemaErr))))
check(slurp(GAME .. "/mod_option_schemas.json") ~= nil, "option schemas land in the game folder")
eq(#spyCalls, 0, "option schema export makes no save-dir filesystem calls: " .. spySummary())
eq(saveDirEntries(), 0, "option schema export leaves the OS save directory empty")
local Check = require("src.update.Check")
local PatchNotes = require("src.update.PatchNotes")
spyCalls = {}
check(type(Check._ingestForTests) == "function", "update state ingest is reachable")
if type(Check._ingestForTests) == "function" then
local full = { name = "gen1recomp-9.9.9-windows.zip", url = "fixture:full", size = 3 }
Check._ingestForTests({ status = "checking" })
Check._ingestForTests({ status = "needs_full", latest = "9.9.9", reason = "payload_missing",
notes = "portable notes", full = full })
local notesDoc = slurp(GAME .. "/updates/notes_cache.json")
check(notesDoc ~= nil and notesDoc:find("portable notes", 1, true) ~= nil,
"release notes cache lands in the game folder")
local record = slurp(GAME .. "/updates/full-update.json")
check(record ~= nil and record:find("payload_missing", 1, true) ~= nil,
"full-update record lands in the game folder")
local body, ver = PatchNotes.fromCache("9.9.9")
eq(body, "portable notes", "patch notes read the portable notes cache")
eq(ver, "9.9.9", "patch notes name the cached release")
Check._ingestForTests({ status = "uptodate", latest = "9.9.9", clearFull = true,
notes = "portable notes" })
eq(slurp(GAME .. "/updates/full-update.json"), nil, "an up-to-date check clears the portable record")
end
eq(#spyCalls, 0, "update persistence makes no save-dir filesystem calls: " .. spySummary())
eq(saveDirEntries(), 0, "update persistence leaves the OS save directory empty")
end
local ok, err = pcall(run)
love.filesystem = savedFs
love.system = savedSystem
package.loaded["src.import.CacheFs"] = savedCacheFs
package.loaded["src.import.RomExtractorGen3"] = savedExtractor
package.loaded["src.import.LuaWriter"] = savedLuaWriter
SaveData._resetPortableCacheForTests()
rmrf(SANDBOX)
if not ok then check(false, "suite raised: " .. tostring(err)) end
S.finish()
+4
View File
@@ -51,6 +51,10 @@ local OWNED = {
"src/core/TouchSkin.lua",
"src/import/LauncherSettings.lua",
"src/import/LauncherView.lua",
"src/import/RomExtractorGen3.lua",
"src/mods/Loader.lua",
"src/update/Check.lua",
"src/update/PatchNotes.lua",
}
local WRITERS = { "write", "newFile", "createDirectory", "append" }
+10 -2
View File
@@ -81,10 +81,16 @@ local function runWorker(boot, cached)
}
os.rename = function() return false end
local ok, err = pcall(dofile, "src/update/check_worker.lua")
local workerFiles = {}
for path, data in pairs(files) do workerFiles[path] = data end
local SaveData = require("src.core.SaveData")
SaveData._resetPortableCacheForTests()
for _, state in ipairs(states) do Check._ingestForTests(state) end
SaveData._resetPortableCacheForTests()
_G.love, os.rename = savedLove, savedRename
for _, name in ipairs(moduleNames) do package.loaded[name] = savedModules[name] end
assert(ok, err)
return states, files
return states, files, workerFiles
end
local cases = {
@@ -105,7 +111,9 @@ for _, cached in ipairs({ true, false }) do
local route = cached and "cached" or "downloaded"
for _, case in ipairs(cases) do
local label = route .. " " .. case.name
local states, files = runWorker(case.boot, cached)
local states, files, workerFiles = runWorker(case.boot, cached)
eq(workerFiles["updates/full-update.json"], nil, label .. " worker leaves the record to the main thread")
eq(workerFiles["updates/notes_cache.json"], nil, label .. " worker leaves the notes cache to the main thread")
local final = states[#states]
eq(final.status, "needs_full", label .. " requires a native package")
eq(final.reason, case.reason, label .. " reports its reason")
+1 -1
View File
@@ -477,7 +477,7 @@ do
check(pending ~= nil, "the event mon is queued for the battle")
eq(pending and pending.species, Deoxys.SPECIES_DEOXYS, "the queued mon is Deoxys")
eq(pending and pending.level, 30, "at level 30")
check(pending and pending.fatefulEncounter == true, "as a fateful encounter")
check(pending and pending.modernFatefulEncounter == true, "as a fateful encounter")
local started = nil
local adapters = {
+3 -2
View File
@@ -98,6 +98,7 @@ local function loadExtractorWithStubs(pokeRun, sectionsRun)
read = function(rel) return files[rel] end,
exists = function(rel) return files[rel] ~= nil end,
remove = function(rel) files[rel] = nil; return true end,
createDirectory = function() return true end,
}
package.loaded["src.import.LuaWriter"] = {
write = function(rel) files[rel] = "return {}"; return true end,
@@ -122,7 +123,7 @@ local function loadExtractorWithStubs(pokeRun, sectionsRun)
"items_extract", "pokedex_chrome_extract", "storage_chrome_extract",
"text_chrome_extract", "trainer_card_extract", "seagallop_extract",
"cave_transition_extract", "weather_extract", "ingame_trades_extract",
"union_room_classes_extract", "map_preview_extract",
"union_room_classes_extract", "map_preview_extract", "tm_case_extract",
}) do
package.loaded["src.import.gba." .. sibling] = { run = function() return true end }
end
@@ -230,7 +231,7 @@ for _, sibling in ipairs({
"items_extract", "pokedex_chrome_extract", "storage_chrome_extract",
"text_chrome_extract", "trainer_card_extract", "seagallop_extract",
"cave_transition_extract", "ingame_trades_extract", "union_room_classes_extract",
"map_preview_extract",
"map_preview_extract", "tm_case_extract",
}) do
package.loaded["src.import.gba." .. sibling] = nil
end
+2 -2
View File
@@ -223,7 +223,7 @@ eq(LT.checkValidityOfTradeMons(1, 2), LT.PLAYER_MON_INVALID,
session.party = keepParty
local keepPeerParty, keepPeerPacked = LT.peerParty, LT._peerPacked
-- pokefirered/src/trade.c:1955 the partner cannot trade an illegitimate DEOXYS or MEW
peerParty({ mon(410, 30, nil, { fatefulEncounter = false }) }, { version = 0, progressFlags = 1 })
peerParty({ mon(410, 30, nil, { modernFatefulEncounter = false }) }, { version = 0, progressFlags = 1 })
LT.pump()
eq(LT.checkValidityOfTradeMons(1, 1), LT.PARTNER_MON_INVALID,
"an illegitimate DEOXYS on the other machine is PARTNER_MON_INVALID")
@@ -741,7 +741,7 @@ peer:take(LT.MSG.MON)
pumpPeer()
-- pokefirered/src/trade.c:1955
peerSend({ type = LT.MSG.MON, name = "BLUE", trainerId = 0x2222,
mon = Protocol.packMon3(mon(151, 30, nil, { fatefulEncounter = false })) })
mon = Protocol.packMon3(mon(151, 30, nil, { modernFatefulEncounter = false })) })
LT.update(0)
eq(LT.state, "canceled", "an illegitimate MEW in place of the SQUIRTLE is refused")
eq(LT.lastResult, "bad_mon", "as bad_mon")
+1 -1
View File
@@ -176,7 +176,7 @@ check(staged ~= nil, "a wild is pending after CreateEnemyEventMon")
eq(staged and staged.species, 249, "staged species")
eq(staged and staged.level, 70, "staged level")
eq(staged and staged.item, nil, "ITEM_NONE stages no held item")
check(staged and staged.fatefulEncounter == true, "the event mon is a fateful encounter")
check(staged and staged.modernFatefulEncounter == true, "the event mon is a fateful encounter")
local ctx6b = newCtx()
-- pokefirered/include/constants/species.h:419
@@ -0,0 +1,79 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local GameCache = require("tests.game3_cache")
local CacheBlob = require("src.import.CacheBlob")
local TmCaseExtract = require("src.import.gba.tm_case_extract")
local root = GameCache.root(TmCaseExtract.CACHE_SUB .. "/manifest.lua")
if not root then
print("[skip] game3_tm_case_disc_cache_3061_test: " .. tostring(GameCache.reason))
os.exit(0)
end
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
return cond
end
local function read(rel)
local path = root .. "/" .. rel
local f = io.open(path, "rb")
if not f then return nil end
local data = CacheBlob.decode(path, f:read("*a"))
f:close()
return data
end
local function colors(data)
local counts, keys = {}, {}
for i = 0, 32 * 32 - 1 do
local r, g, b, a = data:byte(i * 4 + 1, i * 4 + 4)
if a == 255 then
local k = r .. "," .. g .. "," .. b
if not counts[k] then keys[#keys + 1] = k end
counts[k] = (counts[k] or 0) + 1
end
end
table.sort(keys)
local sig = {}
for i, k in ipairs(keys) do sig[i] = k .. "x" .. counts[k] end
return counts, table.concat(sig, " ")
end
local sigs, sets = {}, {}
for t = 0, 17 do
for _, hm in ipairs({ "", "hm_" }) do
local rel = string.format("%s/disc_%s%d.rgba", TmCaseExtract.CACHE_SUB, hm, t)
local data = read(rel)
if check(data and #data == 32 * 32 * 4, rel .. " is a 32x32 disc") then
local counts, sig = colors(data)
check(sig ~= "", rel .. " has opaque pixels")
if hm == "" then sigs[t], sets[t] = sig, counts end
end
end
end
-- src/tm_case.c:396 sTMSpritePaletteOffsetByType
check(sigs[9] ~= nil and sigs[9] == sigs[0], "TYPE_MYSTERY tints like TYPE_NORMAL")
for a = 0, 17 do
for b = a + 1, 17 do
if a ~= 9 and b ~= 9 and sigs[a] and sigs[b] and sigs[a] == sigs[b] then
check(false, string.format("types %d and %d share a disc palette", a, b))
end
end
end
check(sets[17] and sets[17]["82,156,165"] == 56, "the Dark disc carries the Dark teal accent")
check(sets[10] and sets[10]["239,173,33"] == 56, "the Fire disc carries the Fire orange accent")
check(sets[11] and sets[11]["74,165,239"] == 56, "the Water disc carries the Water blue accent")
if failed > 0 then
print(string.format("%d failure(s)", failed))
os.exit(1)
end
print("all checks passed")
+2 -2
View File
@@ -328,11 +328,11 @@ eq(Trade.canTradeSelectedMon({
"a Ruby partner is not asked for its National Dex")
-- pokefirered/src/trade.c:2795
eq(Trade.canTradeSelectedMon({
{ species = 410, nickname = "DEOXYS", level = 30, fatefulEncounter = false }, party[2],
{ species = 410, nickname = "DEOXYS", level = 30, modernFatefulEncounter = false }, party[2],
}, 0, { nationalDex = true }), Trade.CANT_TRADE_INVALID_MON,
"an illegal DEOXYS cannot be traded")
eq(Trade.canTradeSelectedMon({
{ species = 151, nickname = "MEW", level = 30, fatefulEncounter = false }, party[2],
{ species = 151, nickname = "MEW", level = 30, modernFatefulEncounter = false }, party[2],
}, 0, { nationalDex = true }), Trade.CANT_TRADE_INVALID_MON,
"an illegal MEW cannot be traded")
eq(Trade.canTradeSelectedMon({
+50 -14
View File
@@ -120,6 +120,34 @@ function vfs.newFileData(data, name)
return { __filedata = true, data = data, name = name }
end
function vfs.newFile(name)
local f = { pos = 1, buf = {} }
function f:open(mode)
if mode == "w" and failWriteOnce == name then
failWriteOnce = nil
return false, "simulated write failure"
end
if mode == "r" and files[name] == nil then return false, "missing" end
self.mode = mode
return true
end
function f:read(n)
local out = (files[name] or ""):sub(self.pos, self.pos + n - 1)
self.pos = self.pos + #out
return out, #out
end
function f:write(data)
self.buf[#self.buf + 1] = data
return true
end
function f:close()
if self.mode == "w" then files[name] = table.concat(self.buf) end
self.mode = nil
return true
end
return f
end
function vfs.getSaveDirectory()
return "/tmp/pokeport-install-zip-test"
end
@@ -221,8 +249,6 @@ check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil,
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
"replace still lands in mods/<id>")
-- User-selected baseroms belong to the installation, not the downloaded mod
-- archive, and survive the same replacement path.
resetFs()
files["mods/OldFolder/manifest.json"] =
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
@@ -234,12 +260,10 @@ ok, err = LauncherMods.installZip("imports/mods/update-with-rom.zip",
{ replace = true, expectId = MOD_ID })
check(ok == true, "replace with a baserom succeeds (" .. tostring(err) .. ")")
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
"replace preserves user-owned baseroms under the canonical mod folder")
"replace moves user-owned baseroms under the canonical mod folder")
check(files["mods/OldFolder/baseroms/stadium2.z64"] == nil,
"the shadow mod tree is still removed after preservation")
"the shadow mod tree is removed after its baseroms drain")
-- A preservation write failure keeps recovery bytes outside mods/, where
-- discovery cannot mistake a baseroms-only directory for an installed mod.
resetFs()
files["mods/OldFolder/manifest.json"] =
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
@@ -250,20 +274,32 @@ files["imports/mods/preserve-fail.zip"] = "PK\3\4update"
failWriteOnce = "mods/" .. MOD_ID .. "/baseroms/stadium2.z64"
ok, err = LauncherMods.installZip("imports/mods/preserve-fail.zip",
{ replace = true, expectId = MOD_ID })
check(not ok, "preservation failure rejects the update")
check(files["mods/" .. MOD_ID .. "/manifest.json"] == nil,
"preservation failure leaves no manifest-less tree under mods")
eq(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"],
"user-owned-rom", "preservation failure stages recovery outside mods")
check(not ok, "drain failure rejects the update")
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
"drain failure leaves mods/<id> with a manifest")
check(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"] == nil,
"drain failure leaves no partial baserom")
eq(files["mods/OldFolder/baseroms/stadium2.z64"], "user-owned-rom",
"drain failure keeps the source baserom")
files["imports/mods/preserve-retry.zip"] = "PK\3\4update"
ok, err = LauncherMods.installZip("imports/mods/preserve-retry.zip",
{ replace = true, expectId = MOD_ID })
check(ok == true, "retry restores staged baseroms (" .. tostring(err) .. ")")
check(ok == true, "retry drains the baserom (" .. tostring(err) .. ")")
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
"retry restores the recovered baserom into the installed mod")
"retry moves the baserom into the installed mod")
check(files["mods/OldFolder/manifest.json"] == nil,
"successful retry removes the shadow tree")
resetFs()
files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"] = "user-owned-rom"
files["imports/mods/recover.zip"] = "PK\3\4update"
ok, err = LauncherMods.installZip("imports/mods/recover.zip")
check(ok == true, "install drains legacy recovery baseroms (" .. tostring(err) .. ")")
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
"recovered baserom lands in the installed mod")
check(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"] == nil,
"successful retry clears baserom recovery debris")
"successful install clears baserom recovery debris")
-- #834: a manifest-less mods/<id> tree (interrupted copy debris) must not
-- block a plain re-import as "already installed"
@@ -0,0 +1,157 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Fixture = require("tests.modkit.dataset_view_fixture")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local Logger = require("src.core.Logger")
local Loader = require("src.mods.Loader")
local DatasetHydration = require("src.core.DatasetHydration")
local DatasetViews = require("src.mods.DatasetViews")
local Game2 = require("src.core.Game2")
local GEN2 = { "gold", "silver", "crystal" }
local originalVersion, originalPrefix = GameVersion.get(), CacheFs.prefix
GameVersion.set("red")
local files = {}
for _, version in ipairs(GEN2) do Fixture.cache(files, version) end
local probe = Fixture.addMod(files, "emote_probe", [[
local mod = ...
local out = {}
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local view, reason = mod.datasets:open(version)
local row = { reason = reason, walked = {} }
if view then
local emotes = view.content.field:get("emotes") or {}
row.fishing = emotes.fishing
row.fishingFemale = emotes.fishingFemale
row.order = emotes.order
row.fishingInfo = emotes.fishing and view.assets:info(emotes.fishing)
row.femaleInfo = emotes.fishingFemale and view.assets:info(emotes.fishingFemale)
row.battleHud = view.content.field:has("battleHud")
row.pack = view.content.field:has("pack")
row.fieldIds = {}
for id in view.content.field:each() do row.fieldIds[#row.fieldIds + 1] = id end
for name, registry in pairs(view.content) do
local count = 0
for _ in registry:each() do count = count + 1 end
row.walked[name] = count
end
row.species = view.content.pokemon:get("FIXMON") ~= nil
end
local again, againReason = mod.datasets:open(version)
row.reopened, row.reopenReason = again ~= nil, againReason
out[version] = row
end
mod.exports.result = out
]])
local warnings = {}
local originalWarn = Logger.warn
Logger.warn = function(fmt, ...)
warnings[#warnings + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
end
local run = T.sdk.loadMods({ probe }, {
fs = T.sdk.memfs(files), data = { pokemon = {} }, generation = 1,
})
Logger.warn = originalWarn
T.eq(#run.errors, 0, "emote probe loads clean")
local rejected = {}
for _, line in ipairs(warnings) do
if line:find("rejected", 1, true) then rejected[#rejected + 1] = line end
end
T.same(rejected, {}, "no Gen 2 dataset is rejected after the field walk")
local out = run.loader.exports.emote_probe.result or {}
for _, version in ipairs(GEN2) do
local row = out[version] or {}
T.eq(row.reason, nil, version .. " opens")
T.eq(row.fishing, "assets/generated/emotes/fishing.png",
version .. " field emotes exposes the fishing sheet")
T.same(row.fishingInfo, { type = "file" }, version .. " fishing sheet resolves as a file")
T.same(row.order, { "shock" }, version .. " emotes keep their order list")
if version == "crystal" then
T.eq(row.fishingFemale, "assets/generated/emotes/fishing_female.png",
version .. " exposes Kris' fishing sheet")
T.same(row.femaleInfo, { type = "file" }, version .. " fishingFemale resolves as a file")
else
T.eq(row.fishingFemale, nil, version .. " has no fishingFemale")
end
T.eq(row.battleHud, false, version .. " other menu_gfx keys stay out of field")
T.eq(row.pack, false, version .. " pack art stays out of field")
T.same(row.fieldIds, { "emotes" }, version .. " field lists only emotes")
T.eq(row.walked.field, 1, version .. " field walk yields emotes")
T.eq(row.species, true, version .. " pokemon still reads after the walk")
T.eq(row.reopened, true, version .. " stays valid after every registry is walked")
T.eq(row.reopenReason, nil, version .. " reopens without a reason")
end
run.release()
for _, version in ipairs(GEN2) do
local service = DatasetViews.new(T.sdk.memfs(files))
local view = service:open(version)
local internal = service.datasets[version]
T.check(view ~= nil, version .. " lazy probe opens")
if view then
T.check(view.content.items:get("LEFTOVERS") ~= nil, version .. " items read")
T.eq(internal.moduleCache.menu_gfx, nil,
version .. " reading items leaves menu_gfx undecoded")
T.check(type(view.content.field:get("emotes")) == "table", version .. " field emotes read")
T.check(internal.moduleCache.menu_gfx ~= nil,
version .. " reading field decodes menu_gfx")
end
end
local function manifest(api)
return '{"id":"emotes","name":"emotes","version":"1.0.0","api":' .. api
.. ',"games":["all"],"entry":"main.lua"}'
end
for _, version in ipairs(GEN2) do
for _, api in ipairs({ 2, 3 }) do
local label = version .. " api " .. api
local vanilla = {
shock = "assets/generated/emotes/shock.png",
fishing = "assets/generated/emotes/fishing.png",
order = { "shock" },
}
local data = { gen2MenuGfx = { emotes = vanilla, battleHud = {} } }
DatasetHydration.seedGen2Field(data)
T.eq(data.field.emotes, vanilla, label .. " seeds field.emotes from menu_gfx")
T.eq(data.field.battleHud, nil, label .. " seeds no other menu_gfx key")
T.sdk.captureRuntime()
local loader = Loader.new({ fs = T.sdk.memfs({
["mods/emotes/manifest.json"] = manifest(api),
["mods/emotes/main.lua"] = [[
local mod = ...
mod.exports.before = (mod.content.field:get("emotes") or {}).fishing
mod.exports.ok, mod.exports.reason = mod.content.field:patch("emotes",
{ fishing = "mods/emotes/fishing_red.png" })
]],
}), generation = 2, version = version, dev = true })
loader:load(data)
T.same(loader.errors, {}, label .. " an emotes patch loads clean")
local exports = loader.exports.emotes or {}
T.eq(exports.before, "assets/generated/emotes/fishing.png",
label .. " mod reads the cart fishing sheet")
T.eq(data.field.emotes.fishing, "mods/emotes/fishing_red.png",
label .. " the patch merges into field.emotes")
Game2.applyFieldEmotes(data)
T.eq(data.gen2MenuGfx.emotes.fishing, "mods/emotes/fishing_red.png",
label .. " the patched sheet reaches gen2MenuGfx.emotes")
T.eq(data.gen2MenuGfx.emotes.shock, "assets/generated/emotes/shock.png",
label .. " unpatched emotes are kept")
T.sdk.restoreRuntime()
end
end
local plain = { gen2MenuGfx = { emotes = { fishing = "a.png" } } }
DatasetHydration.seedGen2Field(plain)
local same = plain.gen2MenuGfx.emotes
Game2.applyFieldEmotes(plain)
T.eq(plain.gen2MenuGfx.emotes, same, "no mod keeps the cart emote table")
GameVersion.set(originalVersion)
CacheFs.prefix = originalPrefix
T.finish("dataset_views_gen2_emotes")
@@ -0,0 +1,168 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Fixture = require("tests.modkit.dataset_view_fixture")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local Logger = require("src.core.Logger")
local Schemas = require("src.mods.Schemas")
local Loader = require("src.mods.Loader")
local Records = require("src.mods.UnifiedRecords")
local VERSIONS = { "red", "blue", "yellow", "gold", "silver", "crystal" }
local originalVersion, originalPrefix = GameVersion.get(), CacheFs.prefix
GameVersion.set("red")
local files = {}
for _, version in ipairs(VERSIONS) do Fixture.cache(files, version) end
local probe = Fixture.addMod(files, "metadata_probe", [[
local mod = ...
local out = {}
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) do
local view = assert(mod.datasets:open(version))
local counts, ids = {}, {}
for name, registry in pairs(view.content) do
counts[name], ids[name] = 0, {}
for id in registry:each() do
counts[name] = counts[name] + 1
ids[name][id] = true
end
end
local again, reason = mod.datasets:open(version)
out[version] = {
counts = counts, ids = ids, reopened = again ~= nil, reason = reason,
tutorHidden = not view.content.pokemon:has("tutorMoves"),
capsuleHidden = not view.content.items:has("timeCapsule"),
special = view.content.landmarks:get("LANDMARK_SPECIAL"),
}
end
mod.exports.result = out
]])
local warnings = {}
local originalWarn = Logger.warn
Logger.warn = function(fmt, ...)
warnings[#warnings + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
end
local run = T.sdk.loadMods({ probe }, {
fs = T.sdk.memfs(files), data = { pokemon = {} }, generation = 1,
})
Logger.warn = originalWarn
T.eq(#run.errors, 0, "metadata probe loads clean")
local rejected = {}
for _, line in ipairs(warnings) do
if line:find("rejected", 1, true) then rejected[#rejected + 1] = line end
end
T.same(rejected, {}, "no fixture dataset is rejected")
local out = run.loader.exports.metadata_probe.result
for _, version in ipairs(VERSIONS) do
local row = out[version]
T.eq(row.reopened, true, version .. " stays valid after every registry is walked")
T.eq(row.reason, nil, version .. " reopens without a reason")
T.eq(row.counts.pokemon,
GameVersion.generation(version) == 2 and 1 + #Fixture.CONTINUATIONS or 1,
version .. " pokemon each counts species only")
end
for _, version in ipairs({ "gold", "silver", "crystal" }) do
local row = out[version]
T.eq(row.counts.items, 2, version .. " items each lists LEFTOVERS and ITEM_C3")
T.eq(row.ids.items.ITEM_C3, true, version .. " nameless unused item row walks")
T.eq(row.ids.items.timeCapsule, nil, version .. " timeCapsule is not an item id")
T.eq(row.capsuleHidden, true, version .. " has skips timeCapsule")
T.eq(row.counts.cries, 1, version .. " cries each accepts word pitch and length")
T.eq(row.ids.encounters.timeFishGroups, true,
version .. " encounters each walks timeFishGroups")
T.eq(row.counts.landmarks, 1, version .. " landmarks each walks LANDMARK_SPECIAL")
T.eq(row.special and row.special.x, -8, version .. " LANDMARK_SPECIAL keeps x -8")
T.eq(row.counts.text, 1, version .. " text each lists script text only")
T.eq(row.ids.text.labels, nil, version .. " text labels table is not a text id")
T.eq(row.counts.tilesets, 1, version .. " tilesets each lists tileset records only")
T.eq(row.ids.tilesets.animFrames, nil, version .. " animFrames is not a tileset id")
T.eq(row.ids.pokemon.tutorMoves, nil, version .. " tutorMoves is not a species id")
T.eq(row.tutorHidden, true, version .. " has skips tutorMoves")
end
run.release()
local function gen2(name)
return Schemas.shapeFor(name, Schemas.REGISTRIES[name], 2)
end
local function check(name, id, value, mode)
local ok, valid, detail = pcall(Schemas.check, gen2(name), name, id, value, mode or "register")
T.eq(ok, true, name .. "." .. tostring(id) .. " check returns instead of throwing")
return valid, detail
end
T.eq(check("items", "MY_ITEM", { id = "MY_ITEM", price = 10 }), nil,
"a mod item without a name is still rejected")
T.eq(check("items", "ITEM_C3", { id = "ITEM_C3", index = 0xC4, price = 10 }), nil,
"a nameless item whose id does not match its index is rejected")
for _, row in ipairs({ { "ITEM_FB", 0xFB }, { "ITEM_100", 0x100 } }) do
for _, mode in ipairs({ "register", "override" }) do
T.eq(check("items", row[1], { id = row[1], index = row[2], price = 0 }, mode), nil,
"a nameless mod item named after its own index is rejected on " .. mode)
end
end
T.eq(check("items", "ITEM_C3", { id = "ITEM_C3", index = 0xC3, price = 0 }, "register"), nil,
"a mod register of a nameless ITEM_C3 is rejected")
T.eq(check("items", "ITEM_C3", { id = "ITEM_C3", index = 0xC3, price = 0 }, "base"), true,
"the cart's own nameless ITEM_C3 row is accepted as base data")
T.eq(check("items", "ITEM_FB", { id = "ITEM_FB", index = 0xFB, price = 0 }, "base"), nil,
"a nameless base row outside the cart's three is rejected")
local loaded = T.fixtures.fresh()
loaded.items.generation, loaded.items.source, loaded.items.pockets = 2, "fixture", {}
local modFiles = {
["mods/n/manifest.json"] = '{"id":"n","name":"n","version":"1.0.0","api":2,'
.. '"games":["all"],"entry":"main.lua"}',
["mods/n/main.lua"] = [[
local mod = ...
mod.exports.ok = pcall(mod.content.items.register, mod.content.items, "ITEM_FB",
{ id = "ITEM_FB", index = 251, price = 0 })
]],
}
T.sdk.captureRuntime()
local modLoader = Loader.new({ fs = T.sdk.memfs(modFiles), generation = 2,
version = "gold", dev = true })
local loadOk = pcall(modLoader.load, modLoader, loaded)
T.sdk.restoreRuntime()
T.eq(loadOk, true, "a mod registering a nameless ITEM_FB still loads")
T.eq((modLoader.exports.n or {}).ok, false, "api 2 register of a nameless ITEM_FB is refused")
T.eq(loaded.items.ITEM_FB, nil, "the nameless ITEM_FB is not merged")
T.eq(check("items", "MY_ITEM", { id = "MY_ITEM", name = "MY ITEM", price = 10 }), true,
"a named mod item is accepted")
T.eq(check("cries", "FIXMON", { header = {}, pitch = 65536, length = 1 }), nil,
"a Gen 2 cry pitch past a word is rejected")
T.eq(check("landmarks", "LANDMARK_X", { name = "X", index = 1, x = -9, y = 0 }), nil,
"a landmark left of the cart's -8 floor is rejected")
T.eq(check("encounters", "timeFishGroups", { [true] = {
day = { species = "FIXMON", level = 1 }, nite = { species = "FIXMON", level = 1 } } }), nil,
"a timeFishGroups key of the wrong type is rejected")
T.eq(check("encounters", "timeFishGroups", { [3] = {
day = { species = "FIXMON", level = 1 }, nite = { species = "FIXMON", level = 1 } } }), true,
"a numeric timeFishGroups key is accepted")
T.eq(check("tilesets", "TILESET_FIXTURE", "assets/generated/tilesets/x.png"), nil,
"a non-table tileset record is rejected")
local thrown, detail = Schemas.check({ fields = { id = Schemas.f.str },
extra = function() error("boom") end }, "probe", "X", { id = "X" }, "register")
T.eq(thrown, nil, "a throwing schema hook reports failure")
T.check(type(detail) == "string" and detail:find("boom", 1, true) ~= nil,
"a throwing schema hook names its error")
local data = {
pokemon = { MEW = { id = "MEW", index = 151 }, tutorMoves = { "FLAMETHROWER" } },
items = { LEFTOVERS = { id = "LEFTOVERS", index = 146 },
timeCapsule = { [0x19] = "LEFTOVERS" } },
}
for _, row in ipairs({ { "tutorMoves", "species" }, { "timeCapsule", "item" } }) do
local id, reason = Records.nativeId(row[1], row[2], 2, data)
T.eq(id, nil, row[1] .. " is not a Gen 2 " .. row[2] .. " id")
T.check(type(reason) == "string" and reason:find("reserved", 1, true) ~= nil,
row[1] .. " is reserved engine metadata")
end
local mon = { species = "MEW", level = 5, hp = 10, maxHp = 10, moves = {} }
local accepted = Records.applyMon(mon, { species = "tutorMoves" }, 2, data, "crystal")
T.eq(accepted, nil, "applyMon refuses tutorMoves as a species")
T.eq(mon.species, "MEW", "refused metadata leaves the mon intact")
GameVersion.set(originalVersion)
CacheFs.prefix = originalPrefix
T.finish("dataset_views_gen2_metadata")
@@ -0,0 +1,123 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Loader = require("src.mods.Loader")
local Schemas = require("src.mods.Schemas")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local BattleState = require("src.ui.gen2.BattleState")
local World = require("src.world.gen2.World")
local function gen2Data()
local data = T.fixtures.fresh()
for _, mon in pairs(data.pokemon) do
mon.baseStats.specialAttack, mon.baseStats.specialDefense = mon.baseStats.special, mon.baseStats.special
mon.baseStats.special = nil
mon.levelMoves, mon.learnset, mon.level1Moves = mon.learnset, nil, nil
mon.picSize, mon.frontSize, mon.evolutions = mon.frontSize, nil, {}
local dex = mon.dexEntry
mon.dexEntry = dex and { kind = dex.kind, text = dex.text, text2 = dex.text2 }
end
data.pokemon.growthRates = { MEDIUM_SLOW = {
numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } }
return data
end
local GEN2_BODY = [[
local mod = ...
local p = mod.content.pokemon
mod.exports.theme = mod.content.music:register("FX_THEME", { file = "theme.ogg" }) ~= nil
local patched, reason = p:patch("FIXMON_A", { battleScaleBack = 1.5, battleScaleFront = 0.5,
battleTheme = "FX_THEME" })
mod.exports.patched, mod.exports.reason = patched ~= nil, reason
local mon = p:get("FIXMON_A") or {}
mod.exports.back, mod.exports.front, mod.exports.bgm = mon.battleScaleBack, mon.battleScaleFront, mon.battleTheme
mod.exports.native = mon.native and mon.native.battleScaleBack
mon.battleScaleBack = 2
mod.exports.roundtrip = p:override("FIXMON_A", mon) ~= nil
mod.exports.after = (p:get("FIXMON_A") or {}).battleScaleBack
mod.exports.rangeOk = pcall(p.patch, p, "FIXMON_B", { battleScaleBack = 5 })
mod.exports.lowOk = pcall(p.patch, p, "FIXMON_B", { battleScaleFront = 0.1 })
mod.exports.done = true
]]
local GEN3_BODY = [[
local mod = ...
local p = mod.content.pokemon
for _, key in ipairs({ "battleScaleBack", "battleScaleFront", "battleTheme" }) do
local ok, result, reason = pcall(p.patch, p, "BULBASAUR",
{ [key] = key == "battleTheme" and "MUS_NONE" or 1.5 })
mod.exports[key] = { ok = ok, result = result, reason = reason }
end
mod.exports.done = true
]]
local function load(files, data, version)
T.sdk.captureRuntime()
Loader._resetShimWarned()
local loader = Loader.new({ fs = T.sdk.memfs(files), dev = true,
generation = GameVersion.generation(version), version = version })
local ok, result = pcall(loader.load, loader, data)
T.sdk.restoreRuntime()
T.check(ok, version .. " loader completes: " .. tostring(result))
return loader
end
local function manifest(files, body)
files["mods/s/manifest.json"] =
'{"id":"s","name":"s","version":"1.0.0","api":3,"games":["all"],"entry":"main.lua"}'
files["mods/s/main.lua"] = body
files["mods/s/theme.ogg"] = "OggS"
end
for _, version in ipairs({ "gold", "silver", "crystal" }) do
GameVersion.set(version)
CacheFs.prefix = GameVersion.cachePrefix(version)
local files, data = {}, gen2Data()
manifest(files, GEN2_BODY)
local loader = load(files, data, version)
local e = loader.exports.s or {}
T.eq(e.done, true, version .. " mod body ran to the end")
T.eq(e.theme, true, version .. " music registered")
T.eq(e.patched, true, version .. " api 3 patch of battleScale/battleTheme accepted: " .. tostring(e.reason))
T.eq(e.back, 1.5, version .. " get returns battleScaleBack at the top level")
T.eq(e.front, 0.5, version .. " get returns battleScaleFront at the top level")
T.eq(e.bgm, "FX_THEME", version .. " get returns battleTheme at the top level")
T.eq(e.native, nil, version .. " battleScaleBack is not parked under native")
T.eq(e.roundtrip, true, version .. " get then override round-trips")
T.eq(e.after, 2, version .. " override changes battleScaleBack")
T.eq(e.rangeOk, false, version .. " battleScaleBack 5 is rejected")
T.eq(e.lowOk, false, version .. " battleScaleFront 0.1 is rejected")
local mon = data.pokemon.FIXMON_A
T.eq(mon.battleScaleBack, 2, version .. " merged back scale")
T.eq(mon.battleScaleFront, 0.5, version .. " merged front scale")
T.eq(mon.battleTheme, "FX_THEME", version .. " merged battleTheme")
T.eq(data.pokemon.FIXMON_B.battleScaleBack, nil, version .. " rejected write left FIXMON_B alone")
local state = setmetatable({ pokemon = data.pokemon, spriteScales = {} }, { __index = BattleState })
state.imageScale = function() return nil end
local live = { species = "FIXMON_A" }
T.eq(state:picScale("back.png", live, true), 2, version .. " battle back pic uses the mod scale")
T.eq(state:picScale("front.png", live, false), 0.5, version .. " battle front pic uses the mod scale")
T.eq(state:picScale("front.png", { species = "FIXMON_B" }, false), 1, version .. " unpatched species stays 1x")
T.eq(World.modBattleTheme({ game = { data = data } }, { wild = { species = "FIXMON_A" } }),
"FX_THEME", version .. " wild battle music uses the mod theme")
end
GameVersion.set("firered")
CacheFs.prefix = GameVersion.cachePrefix("firered")
local g3 = T.sdk.gen3Data()
Schemas.bindGen3(g3, GameVersion.get())
local files = {}
manifest(files, GEN3_BODY)
local loader = load(files, g3, "firered")
local e = loader.exports.s or {}
T.eq(e.done, true, "firered mod body ran to the end")
for _, key in ipairs({ "battleScaleBack", "battleScaleFront", "battleTheme" }) do
local row = e[key] or {}
T.eq(row.ok, true, "firered " .. key .. " does not raise")
T.eq(row.result, nil, "firered " .. key .. " is refused")
T.eq(row.reason, "unsupported: " .. key .. " has no Gen 3 representation",
"firered " .. key .. " refuse reason")
end
T.finish("unified gen2 battle scale")
+52 -3
View File
@@ -15,6 +15,7 @@ local GEN2_MODULES = {
"pokemon", "moves", "items", "type_chart", "audio", "font", "maps",
"tilesets", "text", "rom_text", "trainers", "encounters", "sprites",
"palettes", "icons", "battle_anims", "constants", "landmarks",
"menu_gfx",
}
local CONTINUATIONS = {
@@ -59,11 +60,50 @@ local function defaults(version)
matchups = { { attacker = "NORMAL", defender = "ROCK", multiplier = 5 } },
types = {},
}
local tilesets, text, encounters, audio, landmarks = {}, {}, {}, {}, {}
local menuGfx = {}
if generation == 2 then
pokemon.growthRates = {}
pokemon.tmhmMoves = {}
if version == "crystal" then
pokemon.tutorMoves = { "FLAMETHROWER", "THUNDERBOLT", "ICE_BEAM" }
end
moves.generation, moves.source = 2, "fixture moves"
items.generation, items.source, items.pockets = 2, "fixture items", {}
items.timeCapsule = { [0x19] = "LEFTOVERS" }
items.ITEM_C3 = { id = "ITEM_C3", index = 0xC3, price = 39321,
heldEffect = "HELD_NONE", heldParameter = 0 }
audio.generation, audio.source = 2, "fixture audio"
audio.cries = { FIXMON = { header = { bank = 60, address = 29929,
generation = 2 }, pitch = 65529, length = 320 } }
encounters.generation, encounters.source = 2, "fixture encounters"
encounters.timeFishGroups = {
[0] = { day = { species = "FIXMON", level = 20 },
nite = { species = "FIXMON", level = 20 } },
}
landmarks.generation, landmarks.source = 2, "fixture landmarks"
landmarks.landmarks = { LANDMARK_SPECIAL = { id = "LANDMARK_SPECIAL",
name = "SPECIAL", index = 0, x = -8, y = -16 } }
text.generation = 2
text.labels = { _FixtureText = "6f:5d96" }
text["6f:5d96"] = "FIXTURE"
tilesets.waterFrames = "assets/generated/tilesets/water_frames.png"
tilesets.flowerFrames = "assets/generated/tilesets/flower_frames.png"
tilesets.flowerCgbFrames = { 2, 4 }
tilesets.animFrames = { tower_5d = {
image = "assets/generated/tilesets/anim/tower_5d.png", frames = 5 } }
menuGfx.battleHud = { balls = "assets/generated/battle/hud/balls.png" }
menuGfx.emotes = {
shock = "assets/generated/emotes/shock.png",
grassRustle = "assets/generated/emotes/grass_rustle.png",
fishing = "assets/generated/emotes/fishing.png",
order = { "shock" },
}
if version == "crystal" then
menuGfx.emotes.fishingFemale = "assets/generated/emotes/fishing_female.png"
end
tilesets.TILESET_FIXTURE = { id = "TILESET_FIXTURE",
image = "assets/generated/tilesets/fixture.png", blocks = {} }
for index, id in ipairs(CONTINUATIONS) do
pokemon[id] = species(id, 168 + index, generation)
end
@@ -79,12 +119,13 @@ local function defaults(version)
heldEffect = "HELD_LEFTOVERS", heldParameter = 0 }
end
return {
constants = {}, maps = {}, tilesets = {}, text = {}, rom_text = {},
constants = {}, maps = {}, tilesets = tilesets, text = text, rom_text = {},
text_pointers = {},
trainer_headers = {}, font = {}, sprites = {}, pokemon = pokemon,
moves = moves, items = items, type_chart = typeChart, trainers = {},
encounters = {}, field = { oakSpeech = {} }, battle_anims = {},
audio = {}, palettes = {}, icons = {}, landmarks = {},
encounters = encounters, field = { oakSpeech = {} }, battle_anims = {},
audio = audio, palettes = {}, icons = {}, landmarks = landmarks,
menu_gfx = menuGfx,
}
end
@@ -119,6 +160,14 @@ function Fixture.cache(files, version, overrides)
end
end
end
local emotes = type(values.menu_gfx) == "table" and values.menu_gfx.emotes
if type(emotes) == "table" then
for key, path in pairs(emotes) do
if key ~= "order" and type(path) == "string" then
files[prefix .. path] = "emote"
end
end
end
return files
end
+1 -1
View File
@@ -63,7 +63,7 @@ do
"otId", "otSecretId", "otName", "otGender", "nature", "ability", "gender", "ivs",
"evs", "moves", "item", "friendship", "pokerus", "metLocation", "metLevel",
"metGame", "pokeball", "ribbons", "markings", "isEgg", "fatefulEncounter",
"eggCycles" }
"modernFatefulEncounter", "eggCycles" }
for _, key in ipairs(keys) do
T.check(packed[key] ~= nil, "packMon3 always emits " .. key)
end
+1 -1
View File
@@ -164,7 +164,7 @@ do
T.eq(why, "that's your only POKéMON for battle", "the last mon can't be traded")
local mew = makeSave("firered", "RED", 1, {
{ species = 151, level = 5, extra = { fatefulEncounter = false } },
{ species = 151, level = 5, extra = { modernFatefulEncounter = false } },
{ species = 25, level = 5 },
}, true)
_, why = Trade.plan({ from = handle("firered", "s", mew),
+16
View File
@@ -81,6 +81,7 @@ tests/engine/crystal_phone_rematch_landmark_bug2832.lua
tests/engine/dark_cave_battle_balls_bug2562.lua
tests/engine/dataset_views_lazy_validation.lua
tests/engine/dataset_views_no_mod_parity.lua
tests/engine/dataset_views_real_cache_walk.lua
tests/engine/dataset_views_warning_policy.lua
tests/engine/date_time.lua
tests/engine/desktop_companion.lua
@@ -130,8 +131,10 @@ tests/engine/game3_door_exit_wipe_2895.lua
tests/engine/game3_em_bike_doors_2637.lua
tests/engine/game3_em_catch_prompt_2637.lua
tests/engine/game3_emerald_audio_extract_test.lua
tests/engine/game3_event_mon_fateful_3062.lua
tests/engine/game3_frlg_trainer_approach_facing.lua
tests/engine/game3_frontier_obedience_2638.lua
tests/engine/game3_link_fateful_3062.lua
tests/engine/game3_match_call_step_consumed_2826.lua
tests/engine/game3_npc_pose_2879.lua
tests/engine/game3_old_man_bag_2894.lua
@@ -155,6 +158,7 @@ tests/engine/game3_teachy_fade_2891.lua
tests/engine/game3_teachy_walk_text_2890.lua
tests/engine/game3_tent_save_reload_2638.lua
tests/engine/game3_tilt_roof_2765.lua
tests/engine/game3_tm_case_disc_bank_3061.lua
tests/engine/game3_trainer_sight_test.lua
tests/engine/game3_wailmer_pail_2697_test.lua
tests/engine/game_display_chord_test.lua
@@ -335,6 +339,7 @@ tests/engine/gen2_new_seams.lua
tests/engine/gen2_night_shade_text_pal_bug1961.lua
tests/engine/gen2_night_shade_top_row_bug1921.lua
tests/engine/gen2_no_checkhit_chain_audit.lua
tests/engine/gen2_oak_music_after_clock_3059.lua
tests/engine/gen2_offmap_object_bug1923.lua
tests/engine/gen2_options_menu_translation_test.lua
tests/engine/gen2_orientation_bug2586_test.lua
@@ -481,6 +486,7 @@ tests/engine/launcher_gold_touch_rows.lua
tests/engine/launcher_mod_downloads.lua
tests/engine/launcher_mod_scope_dropdown.lua
tests/engine/launcher_modal_focus_input_test.lua
tests/engine/launcher_mods_update_baseroms_bug3057.lua
tests/engine/launcher_navigation_perf.lua
tests/engine/launcher_nx_pad_cursor_test.lua
tests/engine/launcher_nx_version_chip_test.lua
@@ -623,7 +629,9 @@ tests/engine/pokedex_counts_bug639.lua
tests/engine/pokedex_entry_layout_bug1341.lua
tests/engine/pokedex_quit_bug571.lua
tests/engine/pokedex_seen_placeholders_bug2163.lua
tests/engine/portable_gen3_import_3048.lua
tests/engine/portable_writable_probe_test.lua
tests/engine/portable_writers_2517.lua
tests/engine/pp_restore_jingle_menu_2155.lua
tests/engine/present_probe.lua
tests/engine/present_sync_logic.lua
@@ -828,6 +836,7 @@ tests/game3_cry_modes_test.lua
tests/game3_cut_grass_mow_test.lua
tests/game3_cycling_road_bike_test.lua
tests/game3_daycare_menu_emerald_test.lua
tests/game3_deoxys_test.lua
tests/game3_display_fit_test.lua
tests/game3_doors_viewport_test.lua
tests/game3_elevation_oam_priority_test.lua
@@ -850,6 +859,7 @@ tests/game3_link_client_relay_test.lua
tests/game3_link_continue_warp_test.lua
tests/game3_link_relay_transport_test.lua
tests/game3_link_rse_groups_test.lua
tests/game3_link_trade_test.lua
tests/game3_link_union_chat_test.lua
tests/game3_menu_sfx_test.lua
tests/game3_message_speed_test.lua
@@ -885,6 +895,7 @@ tests/game3_se_source_cache_test.lua
tests/game3_seagallop_anim_test.lua
tests/game3_seam_warm_budget_test.lua
tests/game3_set_invisible_2712_test.lua
tests/game3_special_events_test.lua
tests/game3_special_ids_test.lua
tests/game3_ss_anne_departure_test.lua
tests/game3_stitchfield_flash_test.lua
@@ -894,6 +905,7 @@ tests/game3_strings_catalog_test.lua
tests/game3_tileset_anim_seams_test.lua
tests/game3_time_events_store_test.lua
tests/game3_tm_able_desc_2520_test.lua
tests/game3_tm_case_disc_cache_3061_test.lua
tests/game3_trainer_card_stats_test.lua
tests/game3_trainer_dialogs_test.lua
tests/game3_trainer_rewards_dialogs_test.lua
@@ -904,16 +916,20 @@ tests/gen2_double_faint_loss_test.lua
tests/gen2_fly_map_test.lua
tests/gen2_gamecorner_test.lua
tests/gen2_menus_test.lua
tests/launcher_mods_install_zip_test.lua
tests/mod_graphics_tests.lua
tests/mod_link_tests.lua
tests/mod_world_tests.lua
tests/modkit/cases/bag_capacity.lua
tests/modkit/cases/dataset_views_gen2_emotes.lua
tests/modkit/cases/dataset_views_gen2_metadata.lua
tests/modkit/cases/gen2_marts_registry.lua
tests/modkit/cases/gen2_native_index_range.lua
tests/modkit/cases/gen2_phone_contacts.lua
tests/modkit/cases/link_desync.lua
tests/modkit/cases/mod_map_outdoor_encounters.lua
tests/modkit/cases/unified_effects.lua
tests/modkit/cases/unified_gen2_battle_scale.lua
tests/online_convert.lua
tests/parity_D.lua
tests/parity_F.lua