From 2aec7650cb01845a4e98f1dbcf8a5c652990d984 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 26 Oct 2026 12:24:51 -0400 Subject: [PATCH] CLOSES #3048, CLOSES #3049, CLOSES #3055, CLOSES #3057, CLOSES #3058, CLOSES #3059, CLOSES #3061, CLOSES #3062 --- docs/modding.md | 10 +- docs/modding/reference/registries.md | 40 +- src/core/DatasetHydration.lua | 8 + src/core/Game2.lua | 8 + src/core/game3/battle/engine.lua | 2 +- src/core/game3/battle/init.lua | 2 + src/core/game3/link/family.lua | 2 +- src/core/game3/link/trade.lua | 2 +- .../game3/scripting/natives_event_islands.lua | 4 +- src/core/game3/scripting/natives_events.lua | 4 +- src/core/game3/scripting/natives_trade.lua | 2 +- src/core/game3/summary_data.lua | 4 +- src/import/CacheContract.lua | 1 + src/import/CacheFs.lua | 12 + src/import/RomExtractorGen3.lua | 5 +- src/import/gba/battle_moves_extract.lua | 29 +- src/import/gba/editions/leafgreen_1_0.lua | 2 + src/import/gba/extract_audio.lua | 11 +- src/import/gba/extract_intro.lua | 11 +- src/import/gba/extract_naming.lua | 11 +- src/import/gba/items_extract.lua | 18 +- src/import/gba/map_sections_extract.lua | 28 +- src/import/gba/multichoice_extract.lua | 20 +- src/import/gba/plans/frlg.lua | 1 + src/import/gba/pokedex_chrome_extract.lua | 23 +- src/import/gba/pokemon_extract.lua | 5 - src/import/gba/region_map_extract.lua | 17 +- src/import/gba/storage_chrome_extract.lua | 41 +- src/import/gba/tm_case_extract.lua | 72 ++- src/import/gba/versions_frlg.lua | 4 +- src/link/Protocol.lua | 7 +- src/mods/DatasetViews.lua | 8 +- src/mods/LauncherMods.lua | 230 ++++++---- src/mods/Loader.lua | 7 +- src/mods/Schemas.lua | 89 +++- src/mods/UnifiedContent.lua | 14 +- src/mods/UnifiedRecords.lua | 10 +- src/online/Trade.lua | 2 +- src/ui/game3/tm_case_chrome.lua | 12 +- src/ui/gen2/OakSpeech.lua | 34 +- src/update/Check.lua | 99 ++++- src/update/PatchNotes.lua | 9 +- src/update/check_worker.lua | 48 +- .../drivers/game3_event_mon_fateful_3062.lua | 91 ++++ .../drivers/game3_tm_case_dark_disc_3061.lua | 93 ++++ tests/drivers/gen2_mod_battle_scale_3049.lua | 72 +++ tests/drivers/gen2_mod_fishing_emote_3058.lua | 161 +++++++ .../gen2_oak_music_after_clock_3059.lua | 127 ++++++ .../engine/dataset_views_real_cache_walk.lua | 126 ++++++ tests/engine/game3_event_mon_fateful_3062.lua | 133 ++++++ .../engine/game3_frontier_obedience_2638.lua | 2 +- tests/engine/game3_link_fateful_3062.lua | 120 +++++ tests/engine/game3_tm_case_disc_bank_3061.lua | 142 ++++++ .../gen2_oak_music_after_clock_3059.lua | 117 +++++ .../launcher_mods_update_baseroms_bug3057.lua | 414 ++++++++++++++++++ tests/engine/portable_gen3_import_3048.lua | 164 +++++++ tests/engine/portable_writers_2517.lua | 4 + tests/engine/update_worker_probe_gate.lua | 12 +- tests/game3_deoxys_test.lua | 2 +- tests/game3_import_hardening_test.lua | 5 +- tests/game3_link_trade_test.lua | 4 +- tests/game3_special_events_test.lua | 2 +- tests/game3_tm_case_disc_cache_3061_test.lua | 79 ++++ tests/game3_trade_rules_test.lua | 4 +- tests/launcher_mods_install_zip_test.lua | 64 ++- .../cases/dataset_views_gen2_emotes.lua | 157 +++++++ .../cases/dataset_views_gen2_metadata.lua | 168 +++++++ .../cases/unified_gen2_battle_scale.lua | 123 ++++++ tests/modkit/dataset_view_fixture.lua | 55 ++- tests/online_mon3_test.lua | 2 +- tests/online_trade_gen3.lua | 2 +- tests/quick.list | 16 + 72 files changed, 2977 insertions(+), 452 deletions(-) create mode 100644 tests/drivers/game3_event_mon_fateful_3062.lua create mode 100644 tests/drivers/game3_tm_case_dark_disc_3061.lua create mode 100644 tests/drivers/gen2_mod_battle_scale_3049.lua create mode 100644 tests/drivers/gen2_mod_fishing_emote_3058.lua create mode 100644 tests/drivers/gen2_oak_music_after_clock_3059.lua create mode 100644 tests/engine/dataset_views_real_cache_walk.lua create mode 100644 tests/engine/game3_event_mon_fateful_3062.lua create mode 100644 tests/engine/game3_link_fateful_3062.lua create mode 100644 tests/engine/game3_tm_case_disc_bank_3061.lua create mode 100644 tests/engine/gen2_oak_music_after_clock_3059.lua create mode 100644 tests/engine/launcher_mods_update_baseroms_bug3057.lua create mode 100644 tests/engine/portable_gen3_import_3048.lua create mode 100644 tests/game3_tm_case_disc_cache_3061_test.lua create mode 100644 tests/modkit/cases/dataset_views_gen2_emotes.lua create mode 100644 tests/modkit/cases/dataset_views_gen2_metadata.lua create mode 100644 tests/modkit/cases/unified_gen2_battle_scale.lua diff --git a/docs/modding.md b/docs/modding.md index 1c375a47..69ebff59 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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}`. diff --git a/docs/modding/reference/registries.md b/docs/modding/reference/registries.md index 62659b82..320bbda0 100644 --- a/docs/modding/reference/registries.md +++ b/docs/modding/reference/registries.md @@ -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?} | ```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 | ```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. diff --git a/src/core/DatasetHydration.lua b/src/core/DatasetHydration.lua index 003cc3bf..9bd8c003 100644 --- a/src/core/DatasetHydration.lua +++ b/src/core/DatasetHydration.lua @@ -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 {} diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 53a2af91..8bcc1017 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -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) diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index 68837623..9c0dd3d6 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -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 diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index 4c3ee133..6a813b15 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -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 diff --git a/src/core/game3/link/family.lua b/src/core/game3/link/family.lua index f2bbaa18..ac271020 100644 --- a/src/core/game3/link/family.lua +++ b/src/core/game3/link/family.lua @@ -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 diff --git a/src/core/game3/link/trade.lua b/src/core/game3/link/trade.lua index 5bb78b68..301ac017 100644 --- a/src/core/game3/link/trade.lua +++ b/src/core/game3/link/trade.lua @@ -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 diff --git a/src/core/game3/scripting/natives_event_islands.lua b/src/core/game3/scripting/natives_event_islands.lua index a7a7cb06..e253eee1 100644 --- a/src/core/game3/scripting/natives_event_islands.lua +++ b/src/core/game3/scripting/natives_event_islands.lua @@ -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 diff --git a/src/core/game3/scripting/natives_events.lua b/src/core/game3/scripting/natives_events.lua index d1704142..124dfd15 100644 --- a/src/core/game3/scripting/natives_events.lua +++ b/src/core/game3/scripting/natives_events.lua @@ -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 diff --git a/src/core/game3/scripting/natives_trade.lua b/src/core/game3/scripting/natives_trade.lua index 3221f82a..dfb9bf32 100644 --- a/src/core/game3/scripting/natives_trade.lua +++ b/src/core/game3/scripting/natives_trade.lua @@ -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 diff --git a/src/core/game3/summary_data.lua b/src/core/game3/summary_data.lua index 5f070af1..b44655c9 100644 --- a/src/core/game3/summary_data.lua +++ b/src/core/game3/summary_data.lua @@ -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 diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index d03e9cbe..cefb2e46 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -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" }, } diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index eba1a310..e82f1553 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -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) diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index 720f0049..2467d5c8 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -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 diff --git a/src/import/gba/battle_moves_extract.lua b/src/import/gba/battle_moves_extract.lua index ee7e9f81..b95f18ec 100644 --- a/src/import/gba/battle_moves_extract.lua +++ b/src/import/gba/battle_moves_extract.lua @@ -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) diff --git a/src/import/gba/editions/leafgreen_1_0.lua b/src/import/gba/editions/leafgreen_1_0.lua index 4fba5df5..38f5e019 100644 --- a/src/import/gba/editions/leafgreen_1_0.lua +++ b/src/import/gba/editions/leafgreen_1_0.lua @@ -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 diff --git a/src/import/gba/extract_audio.lua b/src/import/gba/extract_audio.lua index 8915b844..8171c692 100644 --- a/src/import/gba/extract_audio.lua +++ b/src/import/gba/extract_audio.lua @@ -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) diff --git a/src/import/gba/extract_intro.lua b/src/import/gba/extract_intro.lua index c196cdcd..90fd1764 100644 --- a/src/import/gba/extract_intro.lua +++ b/src/import/gba/extract_intro.lua @@ -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) diff --git a/src/import/gba/extract_naming.lua b/src/import/gba/extract_naming.lua index 1a1515ed..3f578754 100644 --- a/src/import/gba/extract_naming.lua +++ b/src/import/gba/extract_naming.lua @@ -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) diff --git a/src/import/gba/items_extract.lua b/src/import/gba/items_extract.lua index 00692d03..ded8e77f 100644 --- a/src/import/gba/items_extract.lua +++ b/src/import/gba/items_extract.lua @@ -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, diff --git a/src/import/gba/map_sections_extract.lua b/src/import/gba/map_sections_extract.lua index 1617b8ed..1c853b2a 100644 --- a/src/import/gba/map_sections_extract.lua +++ b/src/import/gba/map_sections_extract.lua @@ -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 diff --git a/src/import/gba/multichoice_extract.lua b/src/import/gba/multichoice_extract.lua index c82b7b9e..abb42431 100644 --- a/src/import/gba/multichoice_extract.lua +++ b/src/import/gba/multichoice_extract.lua @@ -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 diff --git a/src/import/gba/plans/frlg.lua b/src/import/gba/plans/frlg.lua index d79b90ed..7076982a 100644 --- a/src/import/gba/plans/frlg.lua +++ b/src/import/gba/plans/frlg.lua @@ -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, diff --git a/src/import/gba/pokedex_chrome_extract.lua b/src/import/gba/pokedex_chrome_extract.lua index b58987ad..21281cca 100644 --- a/src/import/gba/pokedex_chrome_extract.lua +++ b/src/import/gba/pokedex_chrome_extract.lua @@ -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 diff --git a/src/import/gba/pokemon_extract.lua b/src/import/gba/pokemon_extract.lua index 4181faf2..cbf9e408 100644 --- a/src/import/gba/pokemon_extract.lua +++ b/src/import/gba/pokemon_extract.lua @@ -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 }) diff --git a/src/import/gba/region_map_extract.lua b/src/import/gba/region_map_extract.lua index 179dca8e..2da14acd 100644 --- a/src/import/gba/region_map_extract.lua +++ b/src/import/gba/region_map_extract.lua @@ -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 diff --git a/src/import/gba/storage_chrome_extract.lua b/src/import/gba/storage_chrome_extract.lua index b34f3687..fbbfb6cf 100644 --- a/src/import/gba/storage_chrome_extract.lua +++ b/src/import/gba/storage_chrome_extract.lua @@ -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 diff --git a/src/import/gba/tm_case_extract.lua b/src/import/gba/tm_case_extract.lua index 423f34da..0c4219ec 100644 --- a/src/import/gba/tm_case_extract.lua +++ b/src/import/gba/tm_case_extract.lua @@ -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 diff --git a/src/import/gba/versions_frlg.lua b/src/import/gba/versions_frlg.lua index 02732dbf..407a35b5 100644 --- a/src/import/gba/versions_frlg.lua +++ b/src/import/gba/versions_frlg.lua @@ -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) diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index b209eb31..32e048b7 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -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, } diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua index cbe01475..453dd335 100644 --- a/src/mods/DatasetViews.lua +++ b/src/mods/DatasetViews.lua @@ -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) diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 0c454bd4..db808125 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -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//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/ 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 diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4ec90fec..021f5b72 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -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 diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 54f83999..d8941c2a 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -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), diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua index 38b6d14a..998036cc 100644 --- a/src/mods/UnifiedContent.lua +++ b/src/mods/UnifiedContent.lua @@ -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 diff --git a/src/mods/UnifiedRecords.lua b/src/mods/UnifiedRecords.lua index 1626ec55..c535cca6 100644 --- a/src/mods/UnifiedRecords.lua +++ b/src/mods/UnifiedRecords.lua @@ -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) diff --git a/src/online/Trade.lua b/src/online/Trade.lua index 4d5b2708..55fdb3a6 100644 --- a/src/online/Trade.lua +++ b/src/online/Trade.lua @@ -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 diff --git a/src/ui/game3/tm_case_chrome.lua b/src/ui/game3/tm_case_chrome.lua index 08454543..ce23d8a7 100644 --- a/src/ui/game3/tm_case_chrome.lua +++ b/src/ui/game3/tm_case_chrome.lua @@ -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 diff --git a/src/ui/gen2/OakSpeech.lua b/src/ui/gen2/OakSpeech.lua index 45f54145..d0f8572c 100644 --- a/src/ui/gen2/OakSpeech.lua +++ b/src/ui/gen2/OakSpeech.lua @@ -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 diff --git a/src/update/Check.lua b/src/update/Check.lua index 2f2e958e..81045293 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -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 diff --git a/src/update/PatchNotes.lua b/src/update/PatchNotes.lua index 69e6742d..dd7a9da1 100644 --- a/src/update/PatchNotes.lua +++ b/src/update/PatchNotes.lua @@ -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 diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 6274645f..368e86bc 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -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 diff --git a/tests/drivers/game3_event_mon_fateful_3062.lua b/tests/drivers/game3_event_mon_fateful_3062.lua new file mode 100644 index 00000000..5a88946b --- /dev/null +++ b/tests/drivers/game3_event_mon_fateful_3062.lua @@ -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 diff --git a/tests/drivers/game3_tm_case_dark_disc_3061.lua b/tests/drivers/game3_tm_case_dark_disc_3061.lua new file mode 100644 index 00000000..ffba6bc5 --- /dev/null +++ b/tests/drivers/game3_tm_case_dark_disc_3061.lua @@ -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 diff --git a/tests/drivers/gen2_mod_battle_scale_3049.lua b/tests/drivers/gen2_mod_battle_scale_3049.lua new file mode 100644 index 00000000..67dfd20a --- /dev/null +++ b/tests/drivers/gen2_mod_battle_scale_3049.lua @@ -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 diff --git a/tests/drivers/gen2_mod_fishing_emote_3058.lua b/tests/drivers/gen2_mod_fishing_emote_3058.lua new file mode 100644 index 00000000..036427b8 --- /dev/null +++ b/tests/drivers/gen2_mod_fishing_emote_3058.lua @@ -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 diff --git a/tests/drivers/gen2_oak_music_after_clock_3059.lua b/tests/drivers/gen2_oak_music_after_clock_3059.lua new file mode 100644 index 00000000..f269ce3f --- /dev/null +++ b/tests/drivers/gen2_oak_music_after_clock_3059.lua @@ -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 \ +-- tests/drivers/gen2_oak_music_after_clock_3059.lua +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 diff --git a/tests/engine/dataset_views_real_cache_walk.lua b/tests/engine/dataset_views_real_cache_walk.lua new file mode 100644 index 00000000..6d808d5e --- /dev/null +++ b/tests/engine/dataset_views_real_cache_walk.lua @@ -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") diff --git a/tests/engine/game3_event_mon_fateful_3062.lua b/tests/engine/game3_event_mon_fateful_3062.lua new file mode 100644 index 00000000..67209151 --- /dev/null +++ b/tests/engine/game3_event_mon_fateful_3062.lua @@ -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") diff --git a/tests/engine/game3_frontier_obedience_2638.lua b/tests/engine/game3_frontier_obedience_2638.lua index b3cb19dd..f20c5fe1 100644 --- a/tests/engine/game3_frontier_obedience_2638.lua +++ b/tests/engine/game3_frontier_obedience_2638.lua @@ -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) diff --git a/tests/engine/game3_link_fateful_3062.lua b/tests/engine/game3_link_fateful_3062.lua new file mode 100644 index 00000000..fe8cbae6 --- /dev/null +++ b/tests/engine/game3_link_fateful_3062.lua @@ -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") diff --git a/tests/engine/game3_tm_case_disc_bank_3061.lua b/tests/engine/game3_tm_case_disc_bank_3061.lua new file mode 100644 index 00000000..1df3a38b --- /dev/null +++ b/tests/engine/game3_tm_case_disc_bank_3061.lua @@ -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() diff --git a/tests/engine/gen2_oak_music_after_clock_3059.lua b/tests/engine/gen2_oak_music_after_clock_3059.lua new file mode 100644 index 00000000..4d5eeeb3 --- /dev/null +++ b/tests/engine/gen2_oak_music_after_clock_3059.lua @@ -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") diff --git a/tests/engine/launcher_mods_update_baseroms_bug3057.lua b/tests/engine/launcher_mods_update_baseroms_bug3057.lua new file mode 100644 index 00000000..57528c20 --- /dev/null +++ b/tests/engine/launcher_mods_update_baseroms_bug3057.lua @@ -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/ 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/") +eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "mods/ 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() diff --git a/tests/engine/portable_gen3_import_3048.lua b/tests/engine/portable_gen3_import_3048.lua new file mode 100644 index 00000000..c0a7940b --- /dev/null +++ b/tests/engine/portable_gen3_import_3048.lua @@ -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() diff --git a/tests/engine/portable_writers_2517.lua b/tests/engine/portable_writers_2517.lua index 368f8ea6..361fd941 100644 --- a/tests/engine/portable_writers_2517.lua +++ b/tests/engine/portable_writers_2517.lua @@ -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" } diff --git a/tests/engine/update_worker_probe_gate.lua b/tests/engine/update_worker_probe_gate.lua index 5f9d4f02..34d44e61 100644 --- a/tests/engine/update_worker_probe_gate.lua +++ b/tests/engine/update_worker_probe_gate.lua @@ -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") diff --git a/tests/game3_deoxys_test.lua b/tests/game3_deoxys_test.lua index 814a7406..4d3f4323 100644 --- a/tests/game3_deoxys_test.lua +++ b/tests/game3_deoxys_test.lua @@ -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 = { diff --git a/tests/game3_import_hardening_test.lua b/tests/game3_import_hardening_test.lua index 16cdd8d7..85ef600d 100644 --- a/tests/game3_import_hardening_test.lua +++ b/tests/game3_import_hardening_test.lua @@ -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 diff --git a/tests/game3_link_trade_test.lua b/tests/game3_link_trade_test.lua index c11a9c70..1508c940 100644 --- a/tests/game3_link_trade_test.lua +++ b/tests/game3_link_trade_test.lua @@ -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") diff --git a/tests/game3_special_events_test.lua b/tests/game3_special_events_test.lua index 2c3b5674..41d2c4bb 100644 --- a/tests/game3_special_events_test.lua +++ b/tests/game3_special_events_test.lua @@ -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 diff --git a/tests/game3_tm_case_disc_cache_3061_test.lua b/tests/game3_tm_case_disc_cache_3061_test.lua new file mode 100644 index 00000000..db6047ee --- /dev/null +++ b/tests/game3_tm_case_disc_cache_3061_test.lua @@ -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") diff --git a/tests/game3_trade_rules_test.lua b/tests/game3_trade_rules_test.lua index 4efb5dfc..6901d25b 100644 --- a/tests/game3_trade_rules_test.lua +++ b/tests/game3_trade_rules_test.lua @@ -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({ diff --git a/tests/launcher_mods_install_zip_test.lua b/tests/launcher_mods_install_zip_test.lua index 2ef9dd51..a78d2ef7 100644 --- a/tests/launcher_mods_install_zip_test.lua +++ b/tests/launcher_mods_install_zip_test.lua @@ -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/") --- 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/ 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/ tree (interrupted copy debris) must not -- block a plain re-import as "already installed" diff --git a/tests/modkit/cases/dataset_views_gen2_emotes.lua b/tests/modkit/cases/dataset_views_gen2_emotes.lua new file mode 100644 index 00000000..bba1d52a --- /dev/null +++ b/tests/modkit/cases/dataset_views_gen2_emotes.lua @@ -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") diff --git a/tests/modkit/cases/dataset_views_gen2_metadata.lua b/tests/modkit/cases/dataset_views_gen2_metadata.lua new file mode 100644 index 00000000..9398bf8c --- /dev/null +++ b/tests/modkit/cases/dataset_views_gen2_metadata.lua @@ -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") diff --git a/tests/modkit/cases/unified_gen2_battle_scale.lua b/tests/modkit/cases/unified_gen2_battle_scale.lua new file mode 100644 index 00000000..e736b347 --- /dev/null +++ b/tests/modkit/cases/unified_gen2_battle_scale.lua @@ -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") diff --git a/tests/modkit/dataset_view_fixture.lua b/tests/modkit/dataset_view_fixture.lua index f6bfccc8..3daecb3d 100644 --- a/tests/modkit/dataset_view_fixture.lua +++ b/tests/modkit/dataset_view_fixture.lua @@ -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 diff --git a/tests/online_mon3_test.lua b/tests/online_mon3_test.lua index 79a3450c..5d6b5936 100644 --- a/tests/online_mon3_test.lua +++ b/tests/online_mon3_test.lua @@ -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 diff --git a/tests/online_trade_gen3.lua b/tests/online_trade_gen3.lua index d3c7ffab..14f30fe7 100644 --- a/tests/online_trade_gen3.lua +++ b/tests/online_trade_gen3.lua @@ -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), diff --git a/tests/quick.list b/tests/quick.list index 19dc38ef..3a57ea82 100644 --- a/tests/quick.list +++ b/tests/quick.list @@ -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