mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-27 23:35:16 -04:00
CLOSES #3048, CLOSES #3049, CLOSES #3055, CLOSES #3057, CLOSES #3058, CLOSES #3059, CLOSES #3061, CLOSES #3062
This commit is contained in:
+8
-2
@@ -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}`.
|
||||
|
||||
@@ -374,6 +374,15 @@ A write here is dropped and reported on a Gen 3 boot.
|
||||
mod.content.cries:patch("PIKACHU", { pitch = 200 })
|
||||
```
|
||||
|
||||
### On Gold (Gen 2)
|
||||
|
||||
- semantics: `record`
|
||||
- target: `Data.audio.cries`
|
||||
|
||||
The record differs; the registry name, the verbs and the id space
|
||||
do not.
|
||||
- value: {header, length, pitch} | {file} | {base, length?, pitch?} | {chip, length?, pitch?}
|
||||
|
||||
## decorations
|
||||
|
||||
- semantics: `record`
|
||||
@@ -572,13 +581,14 @@ write nothing reads.
|
||||
| key | type |
|
||||
|---|---|
|
||||
| `boot` | {namePresets?, rivalName?, startFacing?, startMap?, startMoney?, startX?, startY?} |
|
||||
| `emotes` | {bolt?, cutGrass?, fish?, fishing?, fishingFemale?, grassRustle?, happy?, heart?, jumpShadow?, order?, question?, sad?, shock?, sleep?} |
|
||||
|
||||
<!-- snippet: illustrative -->
|
||||
```lua
|
||||
mod.content.field:patch("boot", { startMap = "NEW_BARK_TOWN", startX = 6, startY = 8 })
|
||||
```
|
||||
|
||||
Gold reads only `boot`: the New Game spawn (`startMap`, `startX`, `startY`, `startFacing`, replacing SPAWN_HOME), the starting money, the rival's starting name and the naming presets. Every other Gen 1 key has no Gold consumer: `ledges` (Gold's ledges are tile collision types), `hiddenItems` (bg events in `maps`), `badgeGates`, `townMap` (`landmarks`), `flyOrder` (the flypoint table), `playerPics`, and `boot.lastHeal`, `boot.screens`, `boot.starterScript`, `boot.title`, `boot.playerName`.
|
||||
Gold reads `boot` and `emotes`. `boot` is the New Game spawn (`startMap`, `startX`, `startY`, `startFacing`, replacing SPAWN_HOME), the starting money, the rival's starting name and the naming presets. `emotes` is the overworld emote art, one image path per sheet: the showemote bubbles listed in `order`, `grassRustle`, `jumpShadow`, `cutGrass`, the fishing pose `fishing` and Crystal's `fishingFemale`; a patched path is the sheet the overworld draws. Every other Gen 1 key has no Gold consumer: `ledges` (Gold's ledges are tile collision types), `hiddenItems` (bg events in `maps`), `badgeGates`, `townMap` (`landmarks`), `flyOrder` (the flypoint table), `playerPics`, and `boot.lastHeal`, `boot.screens`, `boot.starterScript`, `boot.title`, `boot.playerName`.
|
||||
|
||||
### On Gen 3
|
||||
|
||||
@@ -728,7 +738,7 @@ do not.
|
||||
| `id` | string | yes |
|
||||
| `index` | integer 1..65535 | no |
|
||||
| `machine` | {kind, move, number} | no |
|
||||
| `name` | string | yes |
|
||||
| `name` | string | no |
|
||||
| `needsTarget` | boolean | no |
|
||||
| `price` | integer >= 0 | yes |
|
||||
| `tossable` | boolean | no |
|
||||
@@ -774,8 +784,8 @@ and reported. See the Gold subsection below for where it does land.
|
||||
| `id` | string | no |
|
||||
| `index` | integer >= 0 | yes |
|
||||
| `name` | string | yes |
|
||||
| `x` | integer >= 0 | yes |
|
||||
| `y` | integer >= 0 | yes |
|
||||
| `x` | integer -8..247 | yes |
|
||||
| `y` | integer -16..239 | yes |
|
||||
|
||||
<!-- snippet: illustrative -->
|
||||
```lua
|
||||
@@ -1668,6 +1678,10 @@ mod.content.text:override("_PalletTownText1", "HELLO!")
|
||||
- semantics: `record`
|
||||
- target: `Data.gen2Text`
|
||||
|
||||
The record differs; the registry name, the verbs and the id space
|
||||
do not.
|
||||
- value: string
|
||||
|
||||
### On Gen 3
|
||||
|
||||
- semantics: `record`
|
||||
@@ -1734,6 +1748,24 @@ mod.content.tilesets:register("MY_TILES", { image = "...", blocks = { ... } })
|
||||
- semantics: `record`
|
||||
- target: `Data.gen2Tilesets`
|
||||
|
||||
The record differs; the registry name, the verbs and the id space
|
||||
do not.
|
||||
|
||||
| field | type | required |
|
||||
|---|---|---|
|
||||
| `animation` | string | no |
|
||||
| `blocks` | list of any value | yes |
|
||||
| `counterTiles` | any value | no |
|
||||
| `doorTiles` | any value | no |
|
||||
| `id` | string | no |
|
||||
| `image` | file path | yes |
|
||||
| `imageHeight` | integer >= 1 | no |
|
||||
| `imageWidth` | integer >= 1 | no |
|
||||
| `tilesPerRow` | integer >= 1 | no |
|
||||
| `trueColor` | boolean | no |
|
||||
| `walkable` | any value | no |
|
||||
| `warpTiles` | any value | no |
|
||||
|
||||
### On Gen 3
|
||||
|
||||
Not available on Gen 3: there is no native target for this registry.
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -333,7 +333,7 @@ function LT.checkValidityOfTradeMons(slot, partnerSlot)
|
||||
local peerMon = LT.peerParty[(tonumber(partnerSlot) or 1)]
|
||||
local species = tonumber(peerMon and peerMon.species) or 0
|
||||
-- pokefirered/include/constants/species.h:155
|
||||
if (species == 151 or species == 410) and peerMon and peerMon.fatefulEncounter == false then
|
||||
if (species == 151 or species == 410) and peerMon and peerMon.modernFatefulEncounter == false then
|
||||
return LT.PARTNER_MON_INVALID
|
||||
end
|
||||
if alive == 0 then return LT.PLAYER_MON_INVALID end
|
||||
|
||||
@@ -48,8 +48,8 @@ NativesEventIslands.BY_NAME = {
|
||||
local Enc = require("src.core.game3.encounters")
|
||||
local item = Rse.specialVar(ctx, 0x8006)
|
||||
Enc.setWildBattle(Rse.specialVar(ctx, 0x8004), Rse.specialVar(ctx, 0x8005), item ~= 0 and item or nil)
|
||||
-- pokeemerald/src/pokemon.c:2635
|
||||
if Enc._pendingWild then Enc._pendingWild.fatefulEncounter = true end
|
||||
-- pokeemerald/src/pokemon.c:2638
|
||||
if Enc._pendingWild then Enc._pendingWild.modernFatefulEncounter = true end
|
||||
return false
|
||||
end,
|
||||
-- pokeemerald/src/field_specials.c:3389
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -89,37 +89,20 @@ function BattleMovesExtract.packToLua(pack)
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
--- Write via cache object (preferred) or filesystem outDir.
|
||||
function BattleMovesExtract.writeCache(pack, cacheOrDir, opts)
|
||||
opts = opts or {}
|
||||
local body = BattleMovesExtract.packToLua(pack)
|
||||
local root = (opts.cacheRoot or default_cache_root()) .. "/" .. BattleMovesExtract.CACHE_SUB
|
||||
local rel = root .. "/battle_moves.lua"
|
||||
|
||||
local ok, err
|
||||
if type(cacheOrDir) == "table" and cacheOrDir.write then
|
||||
cacheOrDir:write(rel, body)
|
||||
return rel
|
||||
ok, err = cacheOrDir:write(rel, body)
|
||||
else
|
||||
ok, err = require("src.import.CacheFs").write(rel, body)
|
||||
end
|
||||
|
||||
local okC, CacheFs = pcall(require, "src.import.CacheFs")
|
||||
if okC and CacheFs and CacheFs.write then
|
||||
local ok = pcall(CacheFs.write, rel, body)
|
||||
if ok then return rel end
|
||||
end
|
||||
|
||||
if love and love.filesystem and love.filesystem.write then
|
||||
local ok = pcall(love.filesystem.write, rel, body)
|
||||
if ok then return rel end
|
||||
end
|
||||
|
||||
local outDir = (type(cacheOrDir) == "string" and cacheOrDir) or root
|
||||
pcall(os.execute, 'mkdir -p "' .. outDir .. '"')
|
||||
local f = io.open(outDir .. "/battle_moves.lua", "wb")
|
||||
if f then
|
||||
f:write(body)
|
||||
f:close()
|
||||
end
|
||||
return outDir .. "/battle_moves.lua"
|
||||
if ok == false then error("battle_moves: could not write " .. rel .. ": " .. tostring(err)) end
|
||||
return rel
|
||||
end
|
||||
|
||||
function BattleMovesExtract.run(rom, cache, opts)
|
||||
|
||||
@@ -4319,6 +4319,8 @@ return {
|
||||
[0x463150] = 0x462B70, -- tm_case.o:sMenuActions + 0
|
||||
[0x463178] = 0x462B98, -- tm_case.o:sText_ClearTo18 + 0
|
||||
[0x46317C] = 0x462B9C, -- tm_case.o:sText_SingleSpace + 0
|
||||
[0x463180] = 0x462BA0, -- tm_case.o:sPal3Override + 0
|
||||
[0x463238] = 0x462C58, -- tm_case.o:sTMSpritePaletteOffsetByType + 0
|
||||
[0x46325C] = 0x462C7C, -- menu_indicators.o:sScrollIndicatorTemplates + 0
|
||||
[0x463308] = 0x462D28, -- menu_indicators.o:sRedArrowPal + 0
|
||||
[0x463328] = 0x462D48, -- menu_indicators.o:sRedArrowOtherGfx + 0
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ local GEN2_ROOTS = {
|
||||
gen2Sprites = "sprites", gen2Palettes = "palettes",
|
||||
gen2Icons = "icons", gen2BattleAnims = "battle_anims",
|
||||
gen2Constants = "constants", gen2Landmarks = "landmarks",
|
||||
gen2Scripts = "scripts",
|
||||
gen2Scripts = "scripts", gen2MenuGfx = "menu_gfx",
|
||||
}
|
||||
|
||||
local GEN3_ROOTS = {
|
||||
@@ -298,6 +298,10 @@ function DatasetViews:_data(view)
|
||||
local data = {}
|
||||
setmetatable(data, {
|
||||
__index = function(target, root)
|
||||
if root == "field" and view.generation == 2 then
|
||||
rawset(target, root, {})
|
||||
return DatasetHydration.seedGen2Field(target)
|
||||
end
|
||||
local value = self:_module(view, root)
|
||||
if value ~= nil then rawset(target, root, value) end
|
||||
return value
|
||||
@@ -346,7 +350,7 @@ function DatasetViews:_registry(view, name)
|
||||
local function validate(registry, id, value)
|
||||
if value == nil then return true end
|
||||
local ok, detail = Schemas.check(registry.spec, registry.name, id,
|
||||
value, "override")
|
||||
value, "base")
|
||||
if ok then return true end
|
||||
local target = registry.spec.target
|
||||
or Schemas.targetFor(registry.name, registry.spec, view.generation)
|
||||
|
||||
+142
-88
@@ -820,49 +820,135 @@ local function topLevelPaths(mount)
|
||||
return paths
|
||||
end
|
||||
|
||||
-- Copy the mounted archive subtree at `src` to the install path `dst`. Reads
|
||||
-- come from love.filesystem (the .zip is mounted there); every write goes
|
||||
-- through CacheFs so it lands in the portable game folder when portable.txt is
|
||||
-- in play and in the OS save directory otherwise (#330). No explicit mkdir:
|
||||
-- CacheFs.write creates the parent chain on both paths, which also means an
|
||||
-- empty folder inside the .zip is simply not carried over (it holds nothing).
|
||||
local function copyTree(src, dst)
|
||||
-- mods/<id>/baseroms/ holds user imports and their receipts; installs never open it.
|
||||
local function isBaseromPath(rel)
|
||||
return rel == "baseroms" or rel:sub(1, 9) == "baseroms/"
|
||||
end
|
||||
|
||||
local function listPackage(path, files, dirs, rel)
|
||||
local fs = love.filesystem
|
||||
for _, name in ipairs(fs.getDirectoryItems(src)) do
|
||||
local s = src .. "/" .. name
|
||||
local d = dst .. "/" .. name
|
||||
local info = fs.getInfo(s)
|
||||
if info and info.type == "directory" then
|
||||
local ok, err = copyTree(s, d)
|
||||
if not ok then return nil, err end
|
||||
else
|
||||
local data = fs.read(s)
|
||||
if data == nil then return nil, "could not read " .. name end
|
||||
local ok, err = CacheFs.write(d, data)
|
||||
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
|
||||
files, dirs = files or {}, dirs or {}
|
||||
for _, name in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||
local r = rel and (rel .. "/" .. name) or name
|
||||
if not isBaseromPath(r) then
|
||||
local info = fs.getInfo(path .. "/" .. name)
|
||||
if info and info.type == "directory" then
|
||||
dirs[#dirs + 1] = r
|
||||
listPackage(path .. "/" .. name, files, dirs, r)
|
||||
elseif info then
|
||||
files[#files + 1] = r
|
||||
end
|
||||
end
|
||||
end
|
||||
return files, dirs
|
||||
end
|
||||
|
||||
local function removeBoth(path)
|
||||
CacheFs.remove(path)
|
||||
love.filesystem.remove(path)
|
||||
end
|
||||
|
||||
local function pruneEmptyDirs(dest, dirs)
|
||||
local fs = love.filesystem
|
||||
for i = #dirs, 1, -1 do
|
||||
local path = dest .. "/" .. dirs[i]
|
||||
if #(fs.getDirectoryItems(path) or {}) == 0 then
|
||||
CacheFs.removeDir(path)
|
||||
fs.remove(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local STREAM_CHUNK = 4 * 1024 * 1024
|
||||
|
||||
local function streamCopy(src, dst)
|
||||
local fs = love.filesystem
|
||||
local input = fs.newFile and fs.newFile(src)
|
||||
if not (input and input:open("r")) then return nil, "could not open " .. src end
|
||||
local output, openErr = CacheFs.openWrite(dst)
|
||||
if not output then
|
||||
input:close()
|
||||
return nil, "could not create " .. dst .. ": " .. tostring(openErr)
|
||||
end
|
||||
local failure
|
||||
while true do
|
||||
local chunk = input:read(STREAM_CHUNK)
|
||||
if not chunk or #chunk == 0 then break end
|
||||
local wrote, writeErr = output:write(chunk)
|
||||
if not wrote then
|
||||
failure = "could not copy " .. src .. ": " .. tostring(writeErr or "write failed")
|
||||
break
|
||||
end
|
||||
if #chunk < STREAM_CHUNK then break end
|
||||
end
|
||||
input:close()
|
||||
output:close()
|
||||
if failure then
|
||||
CacheFs.remove(dst)
|
||||
return nil, failure
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- User-supplied baseroms are install state, not package content. Snapshot
|
||||
-- them before replacing a mod tree so an update cannot make the player select
|
||||
-- the same cartridge again (or destroy their only reusable copy if the new
|
||||
-- archive later fails to copy).
|
||||
local function snapshotTree(path, into, relative)
|
||||
local function drainBaseroms(src, dst)
|
||||
local fs = love.filesystem
|
||||
into, relative = into or {}, relative or ""
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return into end
|
||||
local info = fs.getInfo(src)
|
||||
if not info then return true end
|
||||
if info.type == "directory" then
|
||||
for _, name in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||
local rel = relative == "" and name or (relative .. "/" .. name)
|
||||
snapshotTree(path .. "/" .. name, into, rel)
|
||||
for _, name in ipairs(fs.getDirectoryItems(src) or {}) do
|
||||
local ok, err = drainBaseroms(src .. "/" .. name, dst .. "/" .. name)
|
||||
if not ok then return nil, err end
|
||||
end
|
||||
elseif relative ~= "" and into[relative] == nil then
|
||||
into[relative] = fs.read(path)
|
||||
return true
|
||||
end
|
||||
return into
|
||||
if fs.getInfo(dst) then return true end
|
||||
return streamCopy(src, dst)
|
||||
end
|
||||
|
||||
local function installPackage(src, dest, manifestRaw)
|
||||
local fs = love.filesystem
|
||||
if type(manifestRaw) ~= "string" then return nil, "could not read manifest.json" end
|
||||
local hadManifest = fs.getInfo(dest .. "/manifest.json", "file") ~= nil
|
||||
-- a portable install can carry a legacy save-dir twin, which physfs reads before the game folder
|
||||
local portable = CacheFs.root() ~= nil
|
||||
local keep, written, failure = {}, {}, nil
|
||||
for _, rel in ipairs((listPackage(src))) do
|
||||
keep[rel] = true
|
||||
if rel ~= "manifest.json" then
|
||||
local data = fs.read(src .. "/" .. rel)
|
||||
if data == nil then failure = "could not read " .. rel; break end
|
||||
local ok, err = CacheFs.write(dest .. "/" .. rel, data)
|
||||
if not ok then
|
||||
failure = "could not write " .. rel .. ": " .. tostring(err)
|
||||
break
|
||||
end
|
||||
if portable then fs.remove(dest .. "/" .. rel) end
|
||||
written[#written + 1] = rel
|
||||
end
|
||||
end
|
||||
if not failure then
|
||||
local oldFiles, oldDirs = listPackage(dest)
|
||||
for _, rel in ipairs(oldFiles) do
|
||||
if not keep[rel] and rel ~= "manifest.json" then removeBoth(dest .. "/" .. rel) end
|
||||
end
|
||||
pruneEmptyDirs(dest, oldDirs)
|
||||
local ok, err = CacheFs.write(dest .. "/manifest.json", manifestRaw)
|
||||
if ok then
|
||||
if portable then fs.remove(dest .. "/manifest.json") end
|
||||
return true
|
||||
end
|
||||
failure = "could not write manifest.json: " .. tostring(err)
|
||||
end
|
||||
if not hadManifest then
|
||||
for _, rel in ipairs(written) do removeBoth(dest .. "/" .. rel) end
|
||||
local _, leftDirs = listPackage(dest)
|
||||
pruneEmptyDirs(dest, leftDirs)
|
||||
if #(fs.getDirectoryItems(dest) or {}) == 0 then
|
||||
CacheFs.removeDir(dest)
|
||||
fs.remove(dest)
|
||||
end
|
||||
end
|
||||
return nil, failure
|
||||
end
|
||||
|
||||
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
|
||||
@@ -987,8 +1073,11 @@ local function findStrays(fs, adopt, installed)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
local dest = "mods/" .. row.id
|
||||
local copied, copyErr = copyTree(row.path, dest)
|
||||
if not copied then removeTree(dest) end
|
||||
local copied, copyErr = installPackage(row.path, dest,
|
||||
fs.read(row.path .. "/manifest.json"))
|
||||
if copied then
|
||||
copied, copyErr = drainBaseroms(row.path .. "/baseroms", dest .. "/baseroms")
|
||||
end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not copied then row.err = copyErr or "could not copy the files" end
|
||||
end
|
||||
@@ -1026,11 +1115,11 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||
-- installZip(source [, opts]) -> true, id | nil, errString
|
||||
-- source is an external path or a love DroppedFile. The archive is validated
|
||||
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
||||
-- file, and a failed copy rolls its partial tree back. A dropped file outside
|
||||
-- the save dir is staged into a save-dir temp first, because
|
||||
-- love.filesystem.mount only reaches a save-directory-relative path.
|
||||
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
||||
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
||||
-- file. A dropped file outside the save dir is staged into a save-dir temp
|
||||
-- first, because love.filesystem.mount only reaches a save-directory-relative
|
||||
-- path. opts.replace = true updates an existing same-id mod in place
|
||||
-- (updates / rollbacks). opts.expectId, when set, refuses a zip whose
|
||||
-- manifest id differs.
|
||||
function LauncherMods.installZip(source, opts)
|
||||
local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts)
|
||||
if not ok then return nil, "import failed: " .. tostring(result) end
|
||||
@@ -1121,58 +1210,23 @@ function LauncherMods._installZipInner(source, opts)
|
||||
cleanup()
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
local preservedBaseroms = {}
|
||||
-- A previous failed update may have staged the user's files outside mods/ so
|
||||
-- discovery cannot mistake recovery debris for an installed mod.
|
||||
snapshotTree(baseromRecovery, preservedBaseroms)
|
||||
if #existing > 0 then
|
||||
for _, path in ipairs(existing) do
|
||||
snapshotTree(path .. "/baseroms", preservedBaseroms)
|
||||
end
|
||||
-- drop every old tree before copy -- mods/<id> and any same-id folder
|
||||
-- under another name, or the survivor keeps winning discover()'s
|
||||
-- first-id-wins race after the "successful" update (#801). A tree with
|
||||
-- no readable manifest is debris from an interrupted copy: it never
|
||||
-- refuses the install, it only gets cleared (#834). Enable-flag is
|
||||
-- preserved (uninstall would clear it, which would surprise an update).
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
for _, path in ipairs(existing) do removeTree(path) end
|
||||
CacheFs.prefix = savedPrefix
|
||||
end
|
||||
|
||||
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
|
||||
-- the mods tree is shared by Red and Blue, so pin the prefix to the root for
|
||||
-- the copy and the rollback, then hand back whatever the launcher had set
|
||||
-- (an import coroutine leaves it pointed at that version -- RomImporter.lua).
|
||||
-- No fs.createDirectory("mods") here any more: CacheFs.write creates the
|
||||
-- parent chain in both homes, and doing it through love.filesystem would
|
||||
-- only ever make the directory in the save dir (#330).
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
local copied, copyErr = copyTree(root, dest)
|
||||
if not copied then removeTree(dest) end
|
||||
local preserveErr
|
||||
for rel, bytes in pairs(preservedBaseroms) do
|
||||
if bytes ~= nil then
|
||||
local restored, restoreErr = CacheFs.write(dest .. "/baseroms/" .. rel, bytes)
|
||||
if not restored and not preserveErr then
|
||||
preserveErr = "could not preserve baseroms/" .. rel .. ": "
|
||||
.. tostring(restoreErr)
|
||||
end
|
||||
local copied, copyErr = installPackage(root, dest, raw)
|
||||
local exactDest = false
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods") or {}) do
|
||||
if name == manifest.id then exactDest = true end
|
||||
end
|
||||
for _, path in ipairs(existing) do
|
||||
local sameDir = path == dest or (not exactDest and path:lower() == dest:lower())
|
||||
if copied and not sameDir then
|
||||
copied, copyErr = drainBaseroms(path .. "/baseroms", dest .. "/baseroms")
|
||||
if copied then removeTree(path) end
|
||||
end
|
||||
end
|
||||
if preserveErr then
|
||||
-- Do not report a successful update that discarded user-owned input, and
|
||||
-- do not leave a manifest-less baseroms tree that resembles an install.
|
||||
removeTree(dest)
|
||||
removeTree(baseromRecovery)
|
||||
for rel, bytes in pairs(preservedBaseroms) do
|
||||
if bytes ~= nil then CacheFs.write(baseromRecovery .. "/" .. rel, bytes) end
|
||||
end
|
||||
copied, copyErr = nil, preserveErr
|
||||
elseif copied then
|
||||
removeTree(baseromRecovery)
|
||||
if copied then
|
||||
copied, copyErr = drainBaseroms(baseromRecovery, dest .. "/baseroms")
|
||||
if copied then removeTree(baseromRecovery) end
|
||||
end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not copied then
|
||||
|
||||
+4
-3
@@ -540,7 +540,8 @@ end
|
||||
-- deliberately written beside options.lua so every platform's native shell
|
||||
-- can use the same filesystem contract.
|
||||
function Loader:_writeOptionSchemas()
|
||||
if not self.fs.write then return end
|
||||
local fs = SaveData.persistenceFs(self.fs)
|
||||
if not (fs and fs.write) then return end
|
||||
|
||||
local mods = {}
|
||||
for id, mod in pairs(self.mods) do
|
||||
@@ -569,7 +570,7 @@ function Loader:_writeOptionSchemas()
|
||||
-- snapshot when the current boot has no schemas so disabled/failed mods do
|
||||
-- not leave stale native settings rows behind.
|
||||
if next(mods) == nil
|
||||
and not (self.fs.getInfo and self.fs.getInfo(OPTION_SCHEMAS_FILENAME)) then
|
||||
and not (fs.getInfo and fs.getInfo(OPTION_SCHEMAS_FILENAME)) then
|
||||
return
|
||||
end
|
||||
|
||||
@@ -581,7 +582,7 @@ function Loader:_writeOptionSchemas()
|
||||
Logger.warn("mod option schema export: failed to encode: %s", tostring(encoded))
|
||||
return
|
||||
end
|
||||
local written, err = self.fs.write(OPTION_SCHEMAS_FILENAME, encoded)
|
||||
local written, err = fs.write(OPTION_SCHEMAS_FILENAME, encoded)
|
||||
if not written then
|
||||
Logger.warn("mod option schema export: failed to write: %s", tostring(err))
|
||||
end
|
||||
|
||||
+70
-19
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,8 +11,8 @@ TmCaseChrome._bgMale = nil
|
||||
TmCaseChrome._bgFemale = nil
|
||||
TmCaseChrome._coverMale = nil
|
||||
TmCaseChrome._coverFemale = nil
|
||||
TmCaseChrome._discs = {} -- typeIdx (0..16) -> Image (TM)
|
||||
TmCaseChrome._discsHm = {} -- typeIdx (0..16) -> Image (HM)
|
||||
TmCaseChrome._discs = {}
|
||||
TmCaseChrome._discsHm = {}
|
||||
TmCaseChrome._hmIcon = nil
|
||||
TmCaseChrome._manifest = nil
|
||||
TmCaseChrome._logged = false
|
||||
@@ -135,13 +135,7 @@ function TmCaseChrome.loadDisc(typeIdx, isHm)
|
||||
local filename = isHm and string.format("%s/disc_hm_%d.rgba", tm_root(), typeIdx)
|
||||
or string.format("%s/disc_%d.rgba", tm_root(), typeIdx)
|
||||
local raw = read_bytes(filename)
|
||||
if not raw or #raw < 32 * 32 * 4 then
|
||||
if isHm then
|
||||
-- Fallback to standard TM disc if HM disc not baked yet
|
||||
return TmCaseChrome.loadDisc(typeIdx, false)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
if not raw or #raw < 32 * 32 * 4 then return nil end
|
||||
local img = rgba_to_image(raw, 32, 32)
|
||||
tbl[typeIdx] = img
|
||||
return img
|
||||
|
||||
+20
-14
@@ -271,8 +271,7 @@ function OakSpeech:buildSteps()
|
||||
return hooked
|
||||
end
|
||||
|
||||
-- `ld de, MUSIC_ROUTE_30 / call PlayMusic`, after InitGender's fade.
|
||||
-- ../pokecrystal/engine/menus/intro_menu.asm:632-633, init_gender.asm:59-65
|
||||
-- ../pokegold/engine/menus/intro_menu.asm:495-500
|
||||
function OakSpeech:startMusic()
|
||||
if self.musicStarted then return end
|
||||
self.musicStarted = true
|
||||
@@ -282,13 +281,23 @@ function OakSpeech:startMusic()
|
||||
end
|
||||
end
|
||||
|
||||
-- ../pokegold/engine/rtc/timeset.asm:13-18, ../pokecrystal/engine/menus/init_gender.asm:60-65
|
||||
OakSpeech.SILENCE_FADE = 0x10
|
||||
|
||||
function OakSpeech:silenceMusic()
|
||||
self.musicStarted = false
|
||||
local data = self.game and self.game.data
|
||||
if data and data.audio and data.audio.runtime then
|
||||
Music.fadeOut(OakSpeech.SILENCE_FADE)
|
||||
end
|
||||
end
|
||||
|
||||
local SILENT_KINDS = { gender = true, initclock = true }
|
||||
|
||||
function OakSpeech:enter()
|
||||
self.step = 0
|
||||
self.answers = {}
|
||||
self.steps = self:buildSteps()
|
||||
if not (self.steps[1] and self.steps[1].kind == "gender") then
|
||||
self:startMusic()
|
||||
end
|
||||
if Runtime.wants("intro.oak_speech.started") then
|
||||
Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps })
|
||||
end
|
||||
@@ -429,6 +438,7 @@ function OakSpeech:openInitClock()
|
||||
self.busy = true
|
||||
local faded = self.faded
|
||||
self.faded = false
|
||||
self:silenceMusic()
|
||||
local pushed = Screens.push(game, "Gen2InitClock", {
|
||||
mode = "clock",
|
||||
save = game.save,
|
||||
@@ -438,6 +448,7 @@ function OakSpeech:openInitClock()
|
||||
onDone = function()
|
||||
game.stack:pop()
|
||||
-- ../pokecrystal/engine/menus/intro_menu.asm:629-635
|
||||
self:startMusic()
|
||||
if self.autoConfirm then
|
||||
self.busy = false
|
||||
return self:advance()
|
||||
@@ -454,16 +465,12 @@ function OakSpeech:openInitClock()
|
||||
end
|
||||
end
|
||||
|
||||
-- InitGender, pushed where PlayerProfileSetup farcalls it: before the speech
|
||||
-- says anything and before its music starts
|
||||
-- (../pokecrystal/engine/menus/intro_menu.asm:79-83).
|
||||
-- ../pokecrystal/engine/menus/intro_menu.asm:79-83
|
||||
function OakSpeech:openGenderSelect(step)
|
||||
local game = self.game
|
||||
if not (game and game.stack) then
|
||||
self:startMusic()
|
||||
return self:advance()
|
||||
end
|
||||
if not (game and game.stack) then return self:advance() end
|
||||
self.busy = true
|
||||
self:silenceMusic()
|
||||
local pushed = Screens.push(game, "Gen2GenderSelect", {
|
||||
save = game.save,
|
||||
fades = not self.autoConfirm,
|
||||
@@ -481,13 +488,11 @@ function OakSpeech:openGenderSelect(step)
|
||||
end
|
||||
self:recordAnswer(step, gender == "female" and 2 or 1,
|
||||
gender == "female" and "Girl" or "Boy", gender)
|
||||
self:startMusic()
|
||||
self:advance()
|
||||
end,
|
||||
})
|
||||
if not pushed then
|
||||
self.busy = false
|
||||
self:startMusic()
|
||||
self:advance()
|
||||
end
|
||||
end
|
||||
@@ -715,6 +720,7 @@ function OakSpeech:advance()
|
||||
speech = self, step = step, index = self.step,
|
||||
})
|
||||
end
|
||||
if not SILENT_KINDS[step.kind] then self:startMusic() end
|
||||
self:runStep(step)
|
||||
end
|
||||
|
||||
|
||||
+76
-23
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,52 +162,11 @@ local function gatePasses(rel)
|
||||
return true
|
||||
end
|
||||
|
||||
local function persistFullRequirement(rel, reason)
|
||||
if not (rel and rel.version and Json) then return end
|
||||
local full = rel.full
|
||||
local record = {
|
||||
version = rel.version,
|
||||
reason = reason or "full_package_required",
|
||||
full = full and { name = rel.fullName, url = full.url, size = full.size } or nil,
|
||||
}
|
||||
pcall(function()
|
||||
love.filesystem.createDirectory("updates")
|
||||
love.filesystem.write("updates/full-update.json", Json.encode(record))
|
||||
end)
|
||||
end
|
||||
|
||||
local function postFullRequirement(rel, reason)
|
||||
persistFullRequirement(rel, reason)
|
||||
post({ status = "needs_full", latest = rel and rel.version, reason = reason,
|
||||
full = rel and rel.full and { name = rel.fullName, url = rel.full.url, size = rel.full.size } or nil })
|
||||
end
|
||||
|
||||
local function clearFullRequirement()
|
||||
pcall(function() love.filesystem.remove("updates/full-update.json") end)
|
||||
end
|
||||
|
||||
local function cacheNotes(ver, notes)
|
||||
if not (ver and type(notes) == "string" and notes ~= "" and Json) then return end
|
||||
if not (love and love.filesystem) then return end
|
||||
pcall(function()
|
||||
love.filesystem.createDirectory("updates")
|
||||
local cachePath = "updates/notes_cache.json"
|
||||
local existing = {}
|
||||
if love.filesystem.getInfo and love.filesystem.getInfo(cachePath) then
|
||||
local text = love.filesystem.read(cachePath)
|
||||
if text then
|
||||
local ok, doc = pcall(Json.decode, text)
|
||||
if ok and type(doc) == "table" then existing = doc end
|
||||
end
|
||||
end
|
||||
existing[ver] = notes
|
||||
local ok, encoded = pcall(Json.encode, existing)
|
||||
if ok and encoded then
|
||||
love.filesystem.write(cachePath, encoded)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- check
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -234,9 +193,6 @@ local function doCheck(target)
|
||||
return
|
||||
end
|
||||
pending = rel
|
||||
if rel.version and type(rel.notes) == "string" and rel.notes ~= "" then
|
||||
cacheNotes(rel.version, rel.notes)
|
||||
end
|
||||
|
||||
-- Unstamped dev build: the working tree always looks "newer", so never
|
||||
-- pester the developer with an update (contract item, Check design).
|
||||
@@ -255,8 +211,7 @@ local function doCheck(target)
|
||||
if compareVersions(rel.version, currentEngine) <= 0 then
|
||||
-- We are now running a native shell at least as new as GitHub's latest
|
||||
-- release, so a former minShell/payloadHost prompt no longer applies.
|
||||
clearFullRequirement()
|
||||
post({ status = "uptodate", latest = rel.version })
|
||||
post({ status = "uptodate", latest = rel.version, clearFull = true })
|
||||
return
|
||||
end
|
||||
|
||||
@@ -491,7 +446,6 @@ local function doDownloadFull()
|
||||
love.filesystem.write(doneRel, data)
|
||||
love.filesystem.remove(partRel)
|
||||
end
|
||||
persistFullRequirement(rel, "full_package_required")
|
||||
post({ status = "full_ready", latest = rel.version, reason = "full_package_required",
|
||||
full = { name = name, url = asset.url, size = asset.size, path = doneAbs } })
|
||||
end
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
local U = require("tests.drivers.util")
|
||||
local F = require("tests.drivers.em_fa_util")
|
||||
local S = F.new("game3_event_mon_fateful_3062", os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_event_mon_fateful_3062")
|
||||
|
||||
local NAVEL_ROCK_BASE = "FR_NAVEL_ROCK_BASE"
|
||||
local LUGIA_XY = { 10, 15 }
|
||||
local FATEFUL = 2147483648
|
||||
|
||||
return function(game)
|
||||
if not F.boot(game, S) then return S.finish() end
|
||||
local Runtime = require("src.core.game3.runtime")
|
||||
local Battle = require("src.core.game3.battle")
|
||||
local Ui = require("src.core.game3.battle.ui")
|
||||
local Party = require("src.core.game3.party")
|
||||
local Bag = require("src.core.game3.bag")
|
||||
local Message = require("src.ui.game3.message")
|
||||
local Encounters = require("src.core.game3.encounters")
|
||||
local Gen3Save = require("src.save_convert.Gen3Save")
|
||||
local session = Runtime.getSession()
|
||||
local C = require("src.core.game3.constants").of(session.version)
|
||||
local lugia = C:require("species", "SPECIES_LUGIA")
|
||||
local master = C:require("items", "ITEM_MASTER_BALL")
|
||||
|
||||
session.party = {}
|
||||
Party.giveMon(session, C:require("species", "SPECIES_MEWTWO"), 100)
|
||||
Bag.add(session.bag, master, 1)
|
||||
|
||||
-- pokefirered/data/maps/NavelRock_Base/scripts.inc:15
|
||||
F.setFlag("FLAG_FOUGHT_LUGIA", false)
|
||||
F.setFlag("FLAG_LUGIA_FLEW_AWAY", false)
|
||||
S.check(F.goTo(game, NAVEL_ROCK_BASE, LUGIA_XY[1], LUGIA_XY[2] + 1, "up"), "Navel Rock Base loaded")
|
||||
|
||||
Encounters.takePendingWild()
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
for _ = 1, 600 do
|
||||
if Battle.isActive() then break end
|
||||
if Message.isOpen() then U.tap(game, "a") end
|
||||
U.wait(6)
|
||||
end
|
||||
if not S.check(Battle.isActive(), "talking to Lugia ran seteventmon and started the battle") then
|
||||
return S.finish()
|
||||
end
|
||||
|
||||
local thrown, atMenu = false, false
|
||||
for frame = 1, 9000 do
|
||||
if not Battle.isActive() then break end
|
||||
if Battle._phase == "command" and Ui._mode == "menu" and not thrown then
|
||||
atMenu = true
|
||||
local st = Battle.getState()
|
||||
local foe = st and st.enemy and st.enemy.mon
|
||||
S.check(foe and tonumber(foe.species) == lugia and tonumber(foe.level) == 70, "the event mon is Lugia Lv70")
|
||||
S.check(foe and foe.modernFatefulEncounter == true, "battle Lugia carries modernFatefulEncounter")
|
||||
S.check(S.still(game, "3062_01_navel_rock_lugia_battle.png"), "Lugia battle shot")
|
||||
thrown = true
|
||||
Ui._pendingCommand = { kind = "bag", itemId = master, user = "player" }
|
||||
Ui._mode = "none"
|
||||
elseif Battle._phase == "catch_nickname_prompt" then
|
||||
U.tap(game, "b")
|
||||
U.wait(12)
|
||||
elseif Ui.dialogPending and Ui.dialogPending() then
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
elseif frame % 16 == 0 then
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
else
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
S.check(atMenu, "the Lugia battle reached the command menu")
|
||||
F.settle(game, 600)
|
||||
S.check(not Battle.isActive(), "the battle ended")
|
||||
local Fade = require("src.ui.game3.fade")
|
||||
for _ = 1, 600 do
|
||||
if not Fade.isActive() and (Fade.t or 0) <= 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(20)
|
||||
|
||||
local caught
|
||||
for i = #session.party, 1, -1 do
|
||||
if tonumber(session.party[i].species) == lugia then caught = session.party[i] break end
|
||||
end
|
||||
if not S.check(caught ~= nil, "Lugia was caught into the party") then return S.finish() end
|
||||
S.check(caught.modernFatefulEncounter == true, "caught Lugia keeps modernFatefulEncounter")
|
||||
local ribbons = tonumber(Gen3Save.fromPortMon(caught, {}, true).ribbons) or 0
|
||||
S.check(math.floor(ribbons / FATEFUL) % 2 == 1, "exported ribbons word has bit 31 set")
|
||||
S.check(S.still(game, "3062_02_lugia_caught_field.png"), "post-catch field shot")
|
||||
S.finish()
|
||||
end
|
||||
@@ -0,0 +1,93 @@
|
||||
local U = require("tests.drivers.util")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_tm_case_dark_disc_3061"
|
||||
|
||||
-- pokefirered/include/constants/items.h:436
|
||||
local ITEM_TM_CASE = 364
|
||||
-- pokefirered/include/constants/items.h:311
|
||||
local ITEM_TM12 = 300
|
||||
-- pokefirered/include/constants/items.h:337
|
||||
local ITEM_TM38 = 326
|
||||
|
||||
local failures = 0
|
||||
local function result(ok, label)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failures = failures + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
if failures == 0 then
|
||||
print("PASS tm_case_dark_disc_3061")
|
||||
love.event.quit(0)
|
||||
else
|
||||
print("FAIL tm_case_dark_disc_3061 failures=" .. failures)
|
||||
love.event.quit(1)
|
||||
end
|
||||
end
|
||||
|
||||
return function(game)
|
||||
print("PASS driver_started")
|
||||
for _ = 1, 900 do
|
||||
if game.phase == "boot" and game.boot then break end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
game:_handleBootAction({ action = "new_game", name = "RED" })
|
||||
U.wait(240)
|
||||
|
||||
local Runtime = require("src.core.game3.runtime")
|
||||
local Bag = require("src.core.game3.bag")
|
||||
local Pokemon = require("src.core.game3.pokemon")
|
||||
local TmCase = require("src.ui.game3.tm_case")
|
||||
local TmCaseChrome = require("src.ui.game3.tm_case_chrome")
|
||||
|
||||
local session = Runtime.getSession()
|
||||
if not result(session ~= nil, "new game reached the game3 field") then return finish() end
|
||||
|
||||
session.bag = session.bag or Bag.new()
|
||||
Bag.add(session.bag, ITEM_TM_CASE, 1)
|
||||
Bag.add(session.bag, ITEM_TM12, 1)
|
||||
Bag.add(session.bag, ITEM_TM38, 1)
|
||||
|
||||
local taunt = Pokemon.battleMove(Pokemon.moveFromTmItem(ITEM_TM12)) or {}
|
||||
result(tonumber(taunt.type) == 17, "TM12 TAUNT is TYPE_DARK, type=" .. tostring(taunt.type))
|
||||
local fireBlast = Pokemon.battleMove(Pokemon.moveFromTmItem(ITEM_TM38)) or {}
|
||||
result(tonumber(fireBlast.type) == 10, "TM38 FIRE BLAST is TYPE_FIRE, type=" .. tostring(fireBlast.type))
|
||||
|
||||
result(TmCaseChrome.ready(), "TM case chrome is in the cache")
|
||||
result(TmCaseChrome.discBank == nil, "the UI carries no type to palette bank table")
|
||||
for t = 0, 17 do
|
||||
result(TmCaseChrome.loadDisc(t, false) ~= nil and TmCaseChrome.loadDisc(t, true) ~= nil,
|
||||
"TM and HM discs load for type " .. t)
|
||||
end
|
||||
local Dataset = require("src.core.game3.dataset")
|
||||
local Extract = require("src.import.gba.extract_island1")
|
||||
local function hasColor(typeIdx, r, g, b)
|
||||
local rel = string.format("%s/items/tm_case/disc_%d.rgba", Extract.CACHE_ROOT or "data/generated/gba", typeIdx)
|
||||
local data = Dataset.cache():read(rel)
|
||||
if type(data) ~= "string" or #data < 32 * 32 * 4 then return false end
|
||||
for i = 0, 32 * 32 - 1 do
|
||||
local pr, pg, pb, pa = data:byte(i * 4 + 1, i * 4 + 4)
|
||||
if pa == 255 and pr == r and pg == g and pb == b then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
result(hasColor(17, 82, 156, 165), "the Dark TM disc is tinted Dark teal")
|
||||
result(hasColor(10, 239, 173, 33), "the Fire TM disc is tinted Fire orange")
|
||||
|
||||
TmCase.show(session, session.bag)
|
||||
U.wait(10)
|
||||
if not result(TmCase.isOpen(), "the TM CASE is open") then return finish() end
|
||||
local sel = TmCase.list()[TmCase.cursor]
|
||||
result(sel and sel.id == ITEM_TM12, "the cursor is on TM12, id=" .. tostring(sel and sel.id))
|
||||
U.still(game, DIR .. "/3061_01_dark_tm_disc.png")
|
||||
|
||||
U.tap(game, "down")
|
||||
U.wait(10)
|
||||
sel = TmCase.list()[TmCase.cursor]
|
||||
result(sel and sel.id == ITEM_TM38, "the cursor is on TM38, id=" .. tostring(sel and sel.id))
|
||||
U.still(game, DIR .. "/3061_02_fire_tm_disc.png")
|
||||
|
||||
TmCase.close()
|
||||
return finish()
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
local U = require("tests.drivers.util")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Sdk = require("tests.modkit.sdk")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local hostVisible = love.visible
|
||||
love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local failures = 0
|
||||
local function check(ok, label)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failures = failures + 1 end
|
||||
return ok
|
||||
end
|
||||
local files = {
|
||||
["mods/probe/manifest.json"] = '{"id":"probe","name":"probe","version":"1.0.0",'
|
||||
.. '"api":3,"games":["all"],"entry":"main.lua"}',
|
||||
["mods/probe/main.lua"] = [[local mod = ...
|
||||
local ok, reason = mod.content.pokemon:patch("CYNDAQUIL", { battleScaleBack = 1.5 })
|
||||
mod.exports.patched, mod.exports.reason = ok ~= nil, reason
|
||||
mod.exports.back = (mod.content.pokemon:get("CYNDAQUIL") or {}).battleScaleBack]],
|
||||
}
|
||||
local loader = Loader.new({ fs = Sdk.memfs(files), generation = 2,
|
||||
version = game.save.version, dev = true })
|
||||
loader.game = game
|
||||
local data = {}
|
||||
for key, value in pairs(game.data) do data[key] = value end
|
||||
for key in pairs(require("src.mods.Builtins").namespaceRoots(2)) do data[key] = nil end
|
||||
loader:load(data)
|
||||
local probe = loader.mods.probe
|
||||
local out = loader.exports.probe or {}
|
||||
if not check(probe and not probe.failed and out.patched == true,
|
||||
"3049_api3_patch_accepted " .. tostring(out.reason)) then
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
check(out.back == 1.5, "3049_get_returns_back_scale")
|
||||
game.data.pokemon = data.pokemon
|
||||
check(game.data.pokemon.CYNDAQUIL.battleScaleBack == 1.5, "3049_merged_back_scale")
|
||||
|
||||
game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) }
|
||||
if not check(game.world:startBattle({ wild = Mon.new(game.data, "PIDGEY", 5) }),
|
||||
"3049_battle_started") then
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
local battle
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then battle = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not check(battle ~= nil, "3049_battle_screen") then
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
for _ = 1, 400 do
|
||||
if battle.phase == "menu" or battle.battle.over then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
check(battle.phase == "menu", "3049_reached_menu")
|
||||
local player = battle.battle.player
|
||||
check(player and player.species == "CYNDAQUIL"
|
||||
and battle:picScale("player_back", player, true) == 1.5, "3049_back_pic_scale_1_5")
|
||||
check(battle:picScale("enemy_front", { species = "PIDGEY" }, false) == 1, "3049_front_pic_default")
|
||||
local dir = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2_mod_battle_scale_3049"
|
||||
check(U.still(game, dir .. "/3049_01_scaled_back_pic.png"), "3049_scaled_back_shot")
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
local U = require("tests.drivers.util")
|
||||
local Assets = require("src.render.Assets")
|
||||
local Map = require("src.world.gen2.Map")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local StartMenu = require("src.ui.gen2.StartMenu")
|
||||
|
||||
local MOD = "mods/fish3058"
|
||||
local PATCHED = MOD .. "/fishing_swapped.png"
|
||||
local CART = "assets/generated/emotes/fishing.png"
|
||||
local ROD = "OLD_ROD"
|
||||
local MAPS = { "ROUTE_28", "VIRIDIAN_CITY", "ROUTE_43" }
|
||||
|
||||
local function installMod()
|
||||
local data = Assets.imageData(CART)
|
||||
data:mapPixel(function(_, _, r, g, b, a)
|
||||
if r < 0.1 then return 2 / 3, 2 / 3, 2 / 3, a end
|
||||
if r > 0.6 and r < 0.7 then return 0, 0, 0, a end
|
||||
return r, g, b, a
|
||||
end)
|
||||
love.filesystem.createDirectory(MOD)
|
||||
love.filesystem.write(PATCHED, data:encode("png"):getString())
|
||||
love.filesystem.write(MOD .. "/manifest.json",
|
||||
'{"id":"fish3058","name":"fish3058","version":"1.0.0",'
|
||||
.. '"api":3,"games":["all"],"entry":"main.lua"}')
|
||||
love.filesystem.write(MOD .. "/main.lua", ([[local mod = ...
|
||||
mod.content.field:patch("emotes", { fishing = %q })]]):format(PATCHED))
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
if not love.filesystem.getInfo(MOD .. "/manifest.json") then
|
||||
installMod()
|
||||
print("INFO 3058 installed the probe mod, restarting so the boot loads it")
|
||||
love.event.quit("restart")
|
||||
return
|
||||
end
|
||||
|
||||
local failures = 0
|
||||
local function check(ok, label)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failures = failures + 1 end
|
||||
return ok
|
||||
end
|
||||
local function tap(btn, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = btn
|
||||
game.input.state[btn] = true
|
||||
U.wait(2)
|
||||
game.input.state[btn] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
local world = game.world
|
||||
local probe = game.mods and game.mods.mods and game.mods.mods.fish3058
|
||||
check(probe ~= nil and not probe.failed, "3058_mod_loaded_at_boot")
|
||||
local field = game.data.field
|
||||
check(field and field.emotes and field.emotes.fishing == PATCHED,
|
||||
"3058_field_emotes_patched")
|
||||
check(field and field.emotes and field.emotes.shock ~= nil,
|
||||
"3058_field_emotes_keep_cart_bubbles")
|
||||
check(game.data.gen2MenuGfx.emotes.fishing == PATCHED,
|
||||
"3058_menu_gfx_emotes_written_back")
|
||||
if not check(world and world.fishingSheet == PATCHED, "3058_world_fishing_sheet_patched") then
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
|
||||
local function downShore(mapId)
|
||||
local def = world.maps and world.maps[mapId]
|
||||
local tileset = def and world.tilesets and world.tilesets[def.tileset]
|
||||
if not (def and tileset) then return nil end
|
||||
local probeMap = Map.new(def, tileset)
|
||||
local taken = {}
|
||||
for _, obj in ipairs(def.objects or {}) do taken[obj.x .. "," .. obj.y] = true end
|
||||
local function land(cx, cy)
|
||||
if not probeMap:inBounds(cx, cy) or taken[cx .. "," .. cy] then return false end
|
||||
local coll = probeMap:cellCollision(cx, cy)
|
||||
return Permissions.isWalkable(coll) and not Permissions.isWater(coll)
|
||||
end
|
||||
for cy = 0, probeMap.heightCells - 2 do
|
||||
for cx = 0, probeMap.widthCells - 1 do
|
||||
if land(cx, cy) and Permissions.isWater(probeMap:cellCollision(cx, cy + 1)) then
|
||||
return { x = cx, y = cy }
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local mapId, shore
|
||||
for _, id in ipairs(MAPS) do
|
||||
shore = downShore(id)
|
||||
if shore then mapId = id break end
|
||||
end
|
||||
if not check(mapId ~= nil, "3058_found_shore") then
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
world.maps[mapId].fishGroup = "FISHGROUP_NONE"
|
||||
game.save.inventory = { [ROD] = 1 }
|
||||
game.save.bagOrder = { ROD }
|
||||
world:warpToMapId(mapId, shore.x, shore.y, "down")
|
||||
for _ = 1, 600 do
|
||||
if world.map and world.map.id == mapId and not world.mapSetup then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(20)
|
||||
check(world.player.cellX == shore.x and world.player.cellY == shore.y,
|
||||
"3058_on_shore_facing_water")
|
||||
|
||||
tap("start")
|
||||
local menu = game.stack:top()
|
||||
if not check(getmetatable(menu) == StartMenu, "3058_start_menu") then
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
for _ = 1, #menu.items do
|
||||
local row = menu.list and menu.list:current()
|
||||
if row and row.value == "pack" then break end
|
||||
tap("down", 4)
|
||||
end
|
||||
tap("a", 10)
|
||||
local pack
|
||||
for _ = 1, 120 do
|
||||
pack = game.stack:top()
|
||||
if getmetatable(pack) == PackMenu then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not check(getmetatable(pack) == PackMenu, "3058_pack_open") then
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
for _ = 1, 4 do
|
||||
if pack:pocket().id == "KEY_ITEM" then break end
|
||||
tap("right", 6)
|
||||
end
|
||||
for _ = 1, 20 do
|
||||
local row = pack.rows[pack.index]
|
||||
if row and row.id == ROD then break end
|
||||
tap("down", 4)
|
||||
end
|
||||
local row = pack.rows[pack.index]
|
||||
check(row ~= nil and row.id == ROD, "3058_cursor_on_old_rod")
|
||||
tap("a", 8)
|
||||
tap("a", 10)
|
||||
|
||||
for _ = 1, 600 do
|
||||
if world.player.fishing == true and getmetatable(game.stack:top()) ~= PackMenu then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 600 do
|
||||
if not world.fade and not world.mapSetup then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check(not world.fade, "3058_screen_not_faded " .. tostring(world.fade))
|
||||
check(world.player.fishing == true, "3058_fishing_pose_up")
|
||||
check(world.player.fishSheet == PATCHED, "3058_pose_drawn_from_patched_sheet")
|
||||
local dir = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2_mod_fishing_emote_3058"
|
||||
check(U.still(game, dir .. "/3058_01_patched_fishing_pose.png"), "3058_patched_pose_shot")
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
-- ../pokegold/engine/rtc/timeset.asm:13-18
|
||||
-- ../pokegold/engine/menus/intro_menu.asm:494-500
|
||||
--
|
||||
-- POKEPORT_BOOT_CINEMA=1 POKEPORT_TOUCH=0 tools/run_driver.sh gold <identity> \
|
||||
-- tests/drivers/gen2_oak_music_after_clock_3059.lua <shotdir>
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local GenderSelect = require("src.ui.gen2.GenderSelect")
|
||||
local InitClock = require("src.ui.gen2.InitClock")
|
||||
local MainMenu = require("src.ui.gen2.MainMenu")
|
||||
local Music = require("src.core.Music")
|
||||
local OakSpeech = require("src.ui.gen2.OakSpeech")
|
||||
|
||||
local ROUTE30 = "Music_Route30"
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/3059-oak-music"
|
||||
local failures = 0
|
||||
|
||||
local function say(line) print("[driver] " .. line) end
|
||||
|
||||
local function result(ok, label)
|
||||
say((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failures = failures + 1 end
|
||||
end
|
||||
|
||||
local function bail(reason)
|
||||
say("FAIL " .. reason)
|
||||
love.event.quit(1)
|
||||
error(reason, 0)
|
||||
end
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function isA(class)
|
||||
local state = top()
|
||||
return state ~= nil and getmetatable(state) == class
|
||||
end
|
||||
|
||||
local route30OnClock = false
|
||||
local function sample()
|
||||
if isA(InitClock) and Music.current() == ROUTE30 then
|
||||
route30OnClock = true
|
||||
end
|
||||
end
|
||||
|
||||
local function wait(n)
|
||||
for _ = 1, n do
|
||||
U.wait(1)
|
||||
sample()
|
||||
end
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
wait(2)
|
||||
game.input.state[button] = false
|
||||
wait(frames or 4)
|
||||
end
|
||||
|
||||
local function waitFor(label, predicate, frames)
|
||||
for _ = 1, frames or 900 do
|
||||
if predicate() then return end
|
||||
wait(1)
|
||||
end
|
||||
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
|
||||
end
|
||||
|
||||
for _ = 1, 900 do
|
||||
if isA(MainMenu) then break end
|
||||
tap("start", 2)
|
||||
end
|
||||
waitFor("the intro menu", function() return isA(MainMenu) end)
|
||||
local menu = top()
|
||||
for i, item in ipairs(menu.list.items) do
|
||||
if item.value == "new" then menu.list.index = i end
|
||||
end
|
||||
for _ = 1, 20 do
|
||||
if not isA(MainMenu) then break end
|
||||
tap("a", 8)
|
||||
end
|
||||
wait(4)
|
||||
if isA(GenderSelect) then bail("gender select on a Gold boot") end
|
||||
|
||||
waitFor("the clock screen", function() return isA(InitClock) end, 300)
|
||||
result(Music.current() ~= ROUTE30,
|
||||
"no Route 30 theme as the clock screen opens")
|
||||
waitFor("the clock screen to settle",
|
||||
function() return isA(InitClock) and top().fade == nil end, 300)
|
||||
wait(8 * (0x10 + 1) + 16)
|
||||
result(isA(InitClock), "still on the clock screen after the fade")
|
||||
result(Music.current() == nil,
|
||||
("InitClock faded the music to MUSIC_NONE (current %s)")
|
||||
:format(tostring(Music.current())))
|
||||
if not U.still(game, out .. "/3059_01_clock_set_silent.png") then
|
||||
bail("no clock shot")
|
||||
end
|
||||
|
||||
for _ = 1, 200 do
|
||||
if not isA(InitClock) then break end
|
||||
tap("a", 3)
|
||||
end
|
||||
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
|
||||
local speech = top()
|
||||
result(not route30OnClock, "Route 30 never played while InitClock was up")
|
||||
|
||||
waitFor("Route 30 after the clock", function()
|
||||
return Music.current() == ROUTE30
|
||||
end, 120)
|
||||
result(Music.current() == ROUTE30, "Route 30 starts once the time is set")
|
||||
waitFor("Oak's first line", function()
|
||||
return speech.pic ~= nil and speech.fade == nil
|
||||
and speech.picReveal == nil and top() ~= speech
|
||||
end, 400)
|
||||
wait(30)
|
||||
if not U.still(game, out .. "/3059_02_oak_with_route30.png") then
|
||||
bail("no Oak shot")
|
||||
end
|
||||
|
||||
if failures > 0 then
|
||||
say(("FAIL gen2 oak music after clock (%d failures)"):format(failures))
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
say("PASS gen2 oak music after clock")
|
||||
love.event.quit(0)
|
||||
end
|
||||
@@ -0,0 +1,126 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local CacheContract = require("src.import.CacheContract")
|
||||
local DatasetViews = require("src.mods.DatasetViews")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
|
||||
local VERSIONS = { "red", "blue", "yellow", "gold", "silver", "crystal" }
|
||||
|
||||
local function slurp(path)
|
||||
local file = io.open(path, "rb")
|
||||
if not file then return nil end
|
||||
local body = file:read("*a")
|
||||
file:close()
|
||||
return body
|
||||
end
|
||||
|
||||
local function rootFor(version)
|
||||
local home = os.getenv("HOME")
|
||||
if not home or home == "" then return nil end
|
||||
local identities = { "g1r-" .. version, "pokeport-test-caches" }
|
||||
local own = os.getenv("POKEPORT_IDENTITY")
|
||||
if own and own ~= "" then table.insert(identities, 1, own) end
|
||||
local prefix = GameVersion.cachePrefix(version)
|
||||
for _, base in ipairs({ home .. "/Library/Application Support/LOVE",
|
||||
home .. "/.local/share/love" }) do
|
||||
for _, identity in ipairs(identities) do
|
||||
local root = base .. "/" .. identity .. "/"
|
||||
if CacheContract.markerMatches(version,
|
||||
slurp(root .. prefix .. CacheContract.MARKER_PATH)) then
|
||||
return root
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local roots, found = {}, 0
|
||||
for _, version in ipairs(VERSIONS) do
|
||||
roots[GameVersion.cachePrefix(version)] = rootFor(version)
|
||||
if roots[GameVersion.cachePrefix(version)] then found = found + 1 end
|
||||
end
|
||||
if found == 0 then
|
||||
print("[skip] dataset_views_real_cache_walk: no imported Gen 1 or Gen 2 cache")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
local bodies = {}
|
||||
local function full(path)
|
||||
local prefix = path:match("^[^/]+/")
|
||||
local root = prefix and roots[prefix]
|
||||
return root and root .. path
|
||||
end
|
||||
local fs = {
|
||||
read = function(path)
|
||||
if bodies[path] == nil then
|
||||
local where = full(path)
|
||||
bodies[path] = where and slurp(where) or false
|
||||
end
|
||||
return bodies[path] or nil
|
||||
end,
|
||||
}
|
||||
fs.getInfo = function(path)
|
||||
local body = fs.read(path)
|
||||
if not body then return nil end
|
||||
return { type = "file", size = #body }
|
||||
end
|
||||
|
||||
local warnings = {}
|
||||
local originalWarn = Logger.warn
|
||||
Logger.warn = function(fmt, ...)
|
||||
warnings[#warnings + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
|
||||
end
|
||||
|
||||
local names = {}
|
||||
for name in pairs(Schemas.REGISTRIES) do names[#names + 1] = name end
|
||||
table.sort(names)
|
||||
|
||||
for _, version in ipairs(VERSIONS) do
|
||||
if not roots[GameVersion.cachePrefix(version)] then
|
||||
print("[info] no " .. version .. " cache; skipped")
|
||||
else
|
||||
local service = DatasetViews.new(fs)
|
||||
local view = service:open(version)
|
||||
T.check(view ~= nil, version .. " opens")
|
||||
for _, name in ipairs(names) do
|
||||
if not view then break end
|
||||
warnings = {}
|
||||
local ok, err = pcall(function()
|
||||
for _ in view.content[name]:each() do end
|
||||
end)
|
||||
local again, reason = service:open(version)
|
||||
T.check(ok, version .. " " .. name .. " each does not throw: " .. tostring(err))
|
||||
T.check(again ~= nil, version .. " " .. name .. " walk keeps the view: "
|
||||
.. tostring(reason))
|
||||
T.eq(warnings[1], nil, version .. " " .. name .. " walk logs no rejection")
|
||||
if not again then
|
||||
service = DatasetViews.new(fs)
|
||||
view = service:open(version)
|
||||
end
|
||||
end
|
||||
if view and GameVersion.generation(version) == 2 then
|
||||
local emotes = view.content.field:get("emotes")
|
||||
T.check(type(emotes) == "table", version .. " field emotes resolves")
|
||||
emotes = emotes or {}
|
||||
T.eq(emotes.fishing, "assets/generated/emotes/fishing.png",
|
||||
version .. " emotes.fishing is the fishing sheet")
|
||||
local info = emotes.fishing and view.assets:info(emotes.fishing)
|
||||
T.eq(info and info.type, "file", version .. " fishing sheet resolves as a file")
|
||||
if version == "crystal" then
|
||||
local female = emotes.fishingFemale and view.assets:info(emotes.fishingFemale)
|
||||
T.eq(female and female.type, "file", version .. " fishingFemale resolves as a file")
|
||||
else
|
||||
T.eq(emotes.fishingFemale, nil, version .. " has no fishingFemale")
|
||||
end
|
||||
local ids = {}
|
||||
for id in view.content.field:each() do ids[#ids + 1] = id end
|
||||
T.same(ids, { "emotes" }, version .. " field lists only emotes")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Logger.warn = originalWarn
|
||||
T.finish("dataset_views_real_cache_walk")
|
||||
@@ -0,0 +1,133 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Flags = require("src.core.game3.scripting.flags")
|
||||
local Space = require("src.core.game3.scripting.space")
|
||||
local Enc = require("src.core.game3.encounters")
|
||||
local Gen3Save = require("src.save_convert.Gen3Save")
|
||||
|
||||
local FATEFUL = 2147483648
|
||||
|
||||
local function fatefulBit(mon)
|
||||
local r = Gen3Save.fromPortMon(mon, {}, true).ribbons
|
||||
return math.floor((tonumber(r) or 0) / FATEFUL) % 2 == 1
|
||||
end
|
||||
|
||||
local session = { version = "firered", name = "RED", trainerId = 1, secretId = 2, party = {},
|
||||
store = Flags.newStore(), map = "FR_NAVEL_ROCK_BASE" }
|
||||
package.loaded["src.core.game3.runtime"] = {
|
||||
getSession = function() return session end,
|
||||
isActive = function() return true end,
|
||||
}
|
||||
|
||||
-- pokefirered/data/maps/NavelRock_Base/scripts.inc:56
|
||||
GameVersion.set("firered")
|
||||
Space.store = Flags.newStore()
|
||||
local Std = require("src.core.game3.scripting.stdscripts")
|
||||
local Events = require("src.core.game3.scripting.natives_events")
|
||||
local ctx = { specialVars = {} }
|
||||
Flags.setVar(Space.store, ctx, 0x8004, 249)
|
||||
Flags.setVar(Space.store, ctx, 0x8005, 70)
|
||||
Flags.setVar(Space.store, ctx, 0x8006, 0)
|
||||
Enc.takePendingWild()
|
||||
Events.HANDLERS[Std.SPECIAL.CreateEnemyEventMon](ctx, {})
|
||||
local frFoe = Enc.takePendingWild()
|
||||
eq(frFoe and frFoe.species, 249, "FireRed CreateEnemyEventMon queues Lugia")
|
||||
eq(frFoe and frFoe.modernFatefulEncounter, true, "FireRed event mon carries modernFatefulEncounter")
|
||||
eq(frFoe and frFoe.fatefulEncounter, nil, "FireRed event mon has no legacy fatefulEncounter key")
|
||||
|
||||
-- pokeemerald/data/maps/BirthIsland_Exterior/scripts.inc:82
|
||||
local Islands = require("src.core.game3.scripting.natives_event_islands")
|
||||
local emCtx = { specialVars = {} }
|
||||
Flags.setVar(Space.store, emCtx, 0x8004, 410)
|
||||
Flags.setVar(Space.store, emCtx, 0x8005, 30)
|
||||
Flags.setVar(Space.store, emCtx, 0x8006, 0)
|
||||
Islands.BY_NAME.CreateEnemyEventMon(emCtx)
|
||||
local emFoe = Enc.takePendingWild()
|
||||
eq(emFoe and emFoe.species, 410, "Emerald CreateEnemyEventMon queues Deoxys")
|
||||
eq(emFoe and emFoe.modernFatefulEncounter, true, "Emerald event mon carries modernFatefulEncounter")
|
||||
eq(emFoe and emFoe.fatefulEncounter, nil, "Emerald event mon has no legacy fatefulEncounter key")
|
||||
|
||||
-- pokefirered/src/pokemon.c:3396
|
||||
check(fatefulBit({ species = 249, level = 70, modernFatefulEncounter = true }),
|
||||
"export sets ribbons bit 31 for a modernFatefulEncounter mon")
|
||||
check(not fatefulBit({ species = 249, level = 70, modernFatefulEncounter = false }),
|
||||
"export clears ribbons bit 31 without the flag")
|
||||
|
||||
do
|
||||
local F = require("tests.engine._xgen_fixture")
|
||||
local TradeConvert = require("src.online.xgen.TradeConvert")
|
||||
local pika = { species = "PIKACHU", level = 25, exp = 15625, hp = 10, status = "", nickname = "ZAPPY", ot = "RED",
|
||||
otId = 4242, dvs = { attack = 10, defense = 10, speed = 10, special = 10 },
|
||||
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
moves = { { id = "THUNDERSHOCK", pp = 10 } }, catchRate = 190, stats = { hp = 50 } }
|
||||
local r = TradeConvert.convert({ source = { data = F.data("red") }, target = { data = F.data("emerald") }, mon = pika })
|
||||
check(r.ok, "Gen 1 Pikachu converts to Gen 3")
|
||||
eq(r.result and r.result.modernFatefulEncounter, false, "Gen 1 -> Gen 3 non-Mew has the flag clear")
|
||||
end
|
||||
|
||||
local Cache = require("tests.game3_cache")
|
||||
require("src.import.gba.versions").select("firered")
|
||||
if Cache.mount("pokemon/battle_moves.lua") then
|
||||
local Battle = require("src.core.game3.battle.init")
|
||||
local Catching = require("src.core.game3.battle.catching")
|
||||
local Engine = require("src.core.game3.battle.engine")
|
||||
local Adapter = require("src.core.game3.battle.adapter")
|
||||
local Schema = require("src.core.game3.save_schema_firered")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Rng = require("src.core.game3.rng")
|
||||
|
||||
local function catchFrom(foe, label)
|
||||
Rng.SeedRng(0x3062)
|
||||
local s = { version = "firered", name = "RED", trainerId = 1, secretId = 2, party = {
|
||||
{ species = 6, level = 80, hp = 250, maxHp = 250, otId = 1, otSecretId = 2,
|
||||
moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 } } } }
|
||||
session = s
|
||||
Battle.start({ headless = true, autoFight = false, session = s, playerParty = s.party, foe = foe,
|
||||
wild = true, legendary = true })
|
||||
local st = assert(Battle.getState())
|
||||
eq(st.enemy.mon.modernFatefulEncounter, true, label .. " battle foe keeps modernFatefulEncounter")
|
||||
local res = Catching.storeCaught(s, st.enemy, 1)
|
||||
Battle.reset()
|
||||
check(res and res.success, label .. " is stored")
|
||||
local mon = res and res.mon or {}
|
||||
eq(mon.modernFatefulEncounter, true, label .. " caught mon keeps modernFatefulEncounter")
|
||||
check(fatefulBit(mon), label .. " exported ribbons word has bit 31")
|
||||
local save = SaveData.decode(SaveData.encode(Schema.toSaveTable(s)))
|
||||
local back = Schema.fromSaveTable(save)
|
||||
eq(back.party[2] and back.party[2].modernFatefulEncounter, true, label .. " flag survives native save and load")
|
||||
end
|
||||
|
||||
catchFrom(frFoe, "FireRed Lugia")
|
||||
catchFrom(emFoe, "Emerald Deoxys")
|
||||
|
||||
-- pokefirered/src/battle_util.c:3133
|
||||
local function obeys(fateful, label)
|
||||
local mew = { species = 151, level = 70, hp = 200, maxHp = 200, attack = 80, defense = 80, spAtk = 80, spDef = 80,
|
||||
speed = 80, ability = 0, nickname = "MEW", otId = 1, otSecretId = 2, otName = "RED",
|
||||
modernFatefulEncounter = fateful, moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 } }
|
||||
local s = { version = "firered", name = "RED", trainerId = 1, secretId = 2, party = { mew } }
|
||||
session = s
|
||||
Space.store = Flags.newStore()
|
||||
Battle.start({ headless = false, autoFight = false, session = s, playerParty = s.party,
|
||||
foe = { species = 19, level = 2 }, wild = true,
|
||||
rng = function(lo, hi) if lo == 1 and hi == 100 then return 1 end return hi end })
|
||||
local st = assert(Battle.getState())
|
||||
st.badges, st.playerTrainerId, st.playerOtName = {}, 1, "RED"
|
||||
local out = {}
|
||||
Engine.resolveMove(st.player, st.enemy, 33, 1, Adapter.new(st), st, out)
|
||||
local ok = out._anim == nil or out._anim.cancelled ~= true
|
||||
Battle.reset()
|
||||
eq(ok, fateful, label)
|
||||
end
|
||||
obeys(true, "own Mew with the fateful flag obeys")
|
||||
obeys(false, "own Mew without the fateful flag disobeys")
|
||||
else
|
||||
print("[skip] battle and catch section: " .. tostring(Cache.reason))
|
||||
end
|
||||
|
||||
T.finish("game3_event_mon_fateful_3062")
|
||||
@@ -18,7 +18,7 @@ local function mon(species, otId, fateful)
|
||||
return { species = species, level = 30, hp = 200, maxHp = 200,
|
||||
attack = 80, defense = 80, spAtk = 80, spDef = 80, speed = 80, ability = 0,
|
||||
nickname = "RENTAL", otId = otId, otName = otId == 1 and "PLAYER" or "RENTAL",
|
||||
fatefulEncounter = fateful, moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 } }
|
||||
modernFatefulEncounter = fateful, moves = { 33, 0, 0, 0 }, pp = { 35, 0, 0, 0 } }
|
||||
end
|
||||
|
||||
local function run(o)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local Cache = require("tests.game3_cache")
|
||||
if not Cache.mount("meta.json") then
|
||||
print("[skip] game3_link_fateful_3062: " .. tostring(Cache.reason))
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
GameVersion.set("firered")
|
||||
local Pokemon = require("src.core.game3.pokemon")
|
||||
local Party = require("src.core.game3.party")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Wire = require("src.link.Wire")
|
||||
local Json = require("src.link.Json")
|
||||
Pokemon.install(nil)
|
||||
|
||||
local FATEFUL = 2147483648
|
||||
|
||||
local session = { version = "firered", name = "RED", trainerId = 24680, secretId = 1357, gender = 0, party = {} }
|
||||
local function give(species, level, extra)
|
||||
local holder = { name = "RED", trainerId = 24680, secretId = 1357, gender = 0, party = {} }
|
||||
local ok, _, mon = Party.giveMon(holder, species, level)
|
||||
assert(ok and mon, "giveMon " .. tostring(species))
|
||||
for k, v in pairs(extra or {}) do mon[k] = v end
|
||||
Pokemon.applyStats(mon)
|
||||
return mon
|
||||
end
|
||||
session.party = { give(1, 10), give(4, 12) }
|
||||
|
||||
package.loaded["src.core.game3.runtime"] = {
|
||||
getSession = function() return session end,
|
||||
isActive = function() return true end,
|
||||
}
|
||||
local Link = require("src.core.game3.link")
|
||||
Link.session = function() return session end
|
||||
local LT = require("src.core.game3.link.trade")
|
||||
|
||||
local function overWire(packed)
|
||||
local raw = Json.decode(Json.encode({ type = "game3_trade_mon", mon = packed }))
|
||||
return Wire.sanitize(raw).mon
|
||||
end
|
||||
|
||||
local function partnerCode(received)
|
||||
LT.peer = nil
|
||||
LT.peerParty = { received }
|
||||
return LT.checkValidityOfTradeMons(1, 1)
|
||||
end
|
||||
|
||||
for _, case in ipairs({ { 410, 30, "DEOXYS" }, { 151, 30, "MEW" } }) do
|
||||
local species, level, name = case[1], case[2], case[3]
|
||||
local legit = give(species, level, { modernFatefulEncounter = true })
|
||||
local packed = Protocol.packMon3(legit)
|
||||
eq(packed.modernFatefulEncounter, true, name .. " packs modernFatefulEncounter")
|
||||
eq(math.floor(packed.ribbons / FATEFUL) % 2, 1, name .. " packs ribbons bit 31")
|
||||
eq(packed.fatefulEncounter, true, name .. " packs the key older releases read as true")
|
||||
local wired = overWire(packed)
|
||||
eq(wired.fatefulEncounter, true, name .. " keeps the older-release key through Wire.sanitize")
|
||||
eq(wired.modernFatefulEncounter, true, name .. " keeps modernFatefulEncounter through Wire.sanitize")
|
||||
local got, why = LT.unpackMon(wired)
|
||||
check(got ~= nil, name .. " unpacks strictly: " .. tostring(why))
|
||||
eq(got and got.modernFatefulEncounter, true, name .. " received over link keeps modernFatefulEncounter")
|
||||
eq(got and got.fatefulEncounter, nil, name .. " received over link has no legacy fatefulEncounter key")
|
||||
eq(got and partnerCode(got), LT.BOTH_MONS_VALID, name .. " with the fateful flag from the partner is valid")
|
||||
eq(Protocol.packMon3(got).modernFatefulEncounter, true, name .. " keeps the flag when traded on again")
|
||||
|
||||
local bad = LT.unpackMon(overWire(Protocol.packMon3(give(species, level, { modernFatefulEncounter = false }))))
|
||||
eq(bad and bad.modernFatefulEncounter, false, name .. " without the flag unpacks false")
|
||||
eq(bad and partnerCode(bad), LT.PARTNER_MON_INVALID, name .. " without the fateful flag is PARTNER_MON_INVALID")
|
||||
end
|
||||
|
||||
do
|
||||
local legit = Protocol.packMon3(give(410, 30, { modernFatefulEncounter = true }))
|
||||
local older = {}
|
||||
for k, v in pairs(legit) do older[k] = v end
|
||||
older.fatefulEncounter = false
|
||||
local got = LT.unpackMon(overWire(older))
|
||||
eq(got and got.modernFatefulEncounter, true, "DEOXYS from an older release (legacy key false) keeps the flag")
|
||||
eq(got and partnerCode(got), LT.BOTH_MONS_VALID, "and is valid")
|
||||
older.modernFatefulEncounter = nil
|
||||
got = LT.unpackMon(overWire(older))
|
||||
eq(got and got.modernFatefulEncounter, true, "DEOXYS from a release without the modern key reads ribbons bit 31")
|
||||
end
|
||||
|
||||
do
|
||||
local Trade = require("src.online.Trade")
|
||||
local handle = { generation = 3, version = "firered", data = { pokemon = Pokemon }, party = session.party }
|
||||
local link = { send = function() return true end, seat = function() return 0 end }
|
||||
local remote, why = Trade.remote(handle, link, {})
|
||||
check(remote ~= nil, "online Remote3 opens: " .. tostring(why))
|
||||
if remote then
|
||||
local mew = LT.unpackMon(overWire(Protocol.packMon3(give(151, 30, { modernFatefulEncounter = true }))))
|
||||
remote.session.theirParty, remote.session.theirPick = { mew }, 1
|
||||
eq(remote:_partnerMonValid(), true, "online: a partner MEW with the fateful flag is valid")
|
||||
local bad = LT.unpackMon(overWire(Protocol.packMon3(give(151, 30, { modernFatefulEncounter = false }))))
|
||||
remote.session.theirParty = { bad }
|
||||
eq(remote:_partnerMonValid(), false, "online: a partner MEW without the fateful flag is refused")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local Family = require("src.core.game3.link.family")
|
||||
local ok, deoxys = pcall(Family.species, "emerald", "SPECIES_DEOXYS")
|
||||
if ok and deoxys then
|
||||
local party = { give(410, 30, { modernFatefulEncounter = false }), give(1, 10) }
|
||||
eq(Family.canTradeSelectedMon("emerald", party, 0, { nationalDex = true }), Family.CANT_TRADE_INVALID_MON,
|
||||
"Emerald: own DEOXYS without the fateful flag can't be traded")
|
||||
party[1].modernFatefulEncounter = true
|
||||
check(Family.canTradeSelectedMon("emerald", party, 0, { nationalDex = true }) ~= Family.CANT_TRADE_INVALID_MON,
|
||||
"Emerald: own DEOXYS with the fateful flag can be traded")
|
||||
else
|
||||
print("[skip] Emerald constants: " .. tostring(deoxys))
|
||||
end
|
||||
end
|
||||
|
||||
T.finish("game3_link_fateful_3062")
|
||||
@@ -0,0 +1,142 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local Versions = require("src.import.gba.versions")
|
||||
local Extract = require("src.import.gba.extract_island1")
|
||||
local TmCaseExtract = require("src.import.gba.tm_case_extract")
|
||||
local TmCaseChrome = require("src.ui.game3.tm_case_chrome")
|
||||
|
||||
local ROOT = Extract.CACHE_ROOT or "data/generated/gba"
|
||||
|
||||
-- src/tm_case.c:396 sTMSpritePaletteOffsetByType
|
||||
local BANK_BY_TYPE = {
|
||||
[0] = 0, [1] = 9, [2] = 8, [3] = 12, [4] = 6, [5] = 5, [6] = 11, [7] = 10, [8] = 14,
|
||||
[9] = 0, [10] = 1, [11] = 2, [12] = 3, [13] = 4, [14] = 13, [15] = 7, [16] = 16, [17] = 15,
|
||||
}
|
||||
|
||||
local bytes = {}
|
||||
local function put(at, list)
|
||||
for i, b in ipairs(list) do bytes[at + i - 1] = b end
|
||||
end
|
||||
local function lz(raw)
|
||||
local out = { 0x10, #raw % 256, math.floor(#raw / 256) % 256, math.floor(#raw / 65536) % 256 }
|
||||
for i = 1, #raw, 8 do
|
||||
out[#out + 1] = 0
|
||||
for j = i, math.min(i + 7, #raw) do out[#out + 1] = raw[j] end
|
||||
end
|
||||
return out
|
||||
end
|
||||
local function fill(n, v)
|
||||
local t = {}
|
||||
for i = 1, n do t[i] = v end
|
||||
return t
|
||||
end
|
||||
local function colorFor(bank, c)
|
||||
return bank * 64 + c + 1
|
||||
end
|
||||
local function palBytes(firstBank, count)
|
||||
local t = {}
|
||||
for b = firstBank, firstBank + count - 1 do
|
||||
for c = 0, 15 do
|
||||
local v = colorFor(b, c)
|
||||
t[#t + 1] = v % 256
|
||||
t[#t + 1] = math.floor(v / 256)
|
||||
end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
local KEYS = {
|
||||
"TM_CASE_BG_GFX", "TM_CASE_MENU_TILEMAP", "TM_CASE_BG_TILEMAP", "TM_CASE_MENU_MALE_PAL",
|
||||
"TM_CASE_MENU_FEMALE_PAL", "TM_CASE_DISC_GFX", "TM_CASE_DISC_TYPES1_PAL", "TM_CASE_DISC_TYPES2_PAL",
|
||||
"TM_CASE_HM_GFX", "TM_CASE_PAL3_OVERRIDE", "TM_CASE_DISC_PAL_OFFSETS",
|
||||
}
|
||||
local saved = {}
|
||||
for i, k in ipairs(KEYS) do
|
||||
saved[k] = Versions[k]
|
||||
Versions[k] = 0x100000 + i * 0x1000
|
||||
end
|
||||
|
||||
put(Versions.TM_CASE_BG_GFX, lz(fill(32, 0)))
|
||||
put(Versions.TM_CASE_MENU_TILEMAP, lz(fill(8, 0)))
|
||||
put(Versions.TM_CASE_BG_TILEMAP, lz(fill(8, 0)))
|
||||
put(Versions.TM_CASE_MENU_MALE_PAL, lz(fill(32, 0)))
|
||||
put(Versions.TM_CASE_MENU_FEMALE_PAL, lz(fill(32, 0)))
|
||||
put(Versions.TM_CASE_DISC_GFX, lz(fill(1024, 0x21)))
|
||||
put(Versions.TM_CASE_DISC_TYPES1_PAL, lz(palBytes(0, 16)))
|
||||
put(Versions.TM_CASE_DISC_TYPES2_PAL, lz(palBytes(16, 1)))
|
||||
put(Versions.TM_CASE_HM_GFX, fill(128, 0))
|
||||
put(Versions.TM_CASE_PAL3_OVERRIDE, { 0x08, 0x21, 0x1E, 0x1A })
|
||||
for t = 0, 17 do
|
||||
local off = BANK_BY_TYPE[t] * 16
|
||||
put(Versions.TM_CASE_DISC_PAL_OFFSETS + t * 2, { off % 256, math.floor(off / 256) })
|
||||
end
|
||||
|
||||
local rom = { get = function(_, i) return bytes[i] or 0 end }
|
||||
local files = {}
|
||||
local cache = {
|
||||
write = function(_, path, data) files[path] = data; return true end,
|
||||
exists = function(_, path) return files[path] ~= nil end,
|
||||
read = function(_, path) return files[path] end,
|
||||
}
|
||||
|
||||
local ok, err = pcall(TmCaseExtract.run, rom, cache, { cacheRoot = ROOT })
|
||||
for _, k in ipairs(KEYS) do Versions[k] = saved[k] end
|
||||
check(ok, "TM case extract runs on the fixture ROM: " .. tostring(err))
|
||||
|
||||
local reads = {}
|
||||
TmCaseChrome._cache = {
|
||||
read = function(_, rel)
|
||||
local data = files[rel]
|
||||
reads[#reads + 1] = { rel = rel, data = data }
|
||||
return data
|
||||
end,
|
||||
}
|
||||
|
||||
local function rgb8(c)
|
||||
local r5, g5, b5 = c % 32, math.floor(c / 32) % 32, math.floor(c / 1024) % 32
|
||||
return math.floor(r5 * 255 / 31 + 0.5), math.floor(g5 * 255 / 31 + 0.5), math.floor(b5 * 255 / 31 + 0.5)
|
||||
end
|
||||
|
||||
local function discRead(typeIdx, isHm)
|
||||
reads = {}
|
||||
TmCaseChrome._discs, TmCaseChrome._discsHm = {}, {}
|
||||
TmCaseChrome.loadDisc(typeIdx, isHm)
|
||||
return reads[1]
|
||||
end
|
||||
|
||||
local NAMES = { [10] = "Fire", [11] = "Water", [8] = "Steel", [16] = "Dragon", [17] = "Dark", [9] = "Mystery" }
|
||||
for t = 0, 17 do
|
||||
for _, isHm in ipairs({ false, true }) do
|
||||
local label = (NAMES[t] or ("type " .. t)) .. (isHm and " HM" or " TM")
|
||||
local r = discRead(t, isHm)
|
||||
local want = string.format("disc_%s%d.rgba", isHm and "hm_" or "", t)
|
||||
eq(r and r.rel:match("(disc_[%w_]*%.rgba)$"), want, label .. " reads the per-type disc file")
|
||||
local data = r and r.data
|
||||
if check(data and #data == 32 * 32 * 4, label .. " disc is in the cache") then
|
||||
local er, eg, eb = rgb8(colorFor(BANK_BY_TYPE[t], 1))
|
||||
eq(table.concat({ data:byte(1, 4) }, ","), table.concat({ er, eg, eb, 255 }, ","),
|
||||
label .. " disc uses palette bank " .. BANK_BY_TYPE[t])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
check(TmCaseChrome.discBank == nil, "the UI carries no type to palette bank table")
|
||||
|
||||
local required = {}
|
||||
for _, rel in ipairs(TmCaseExtract.REQUIRED or {}) do required[rel] = true end
|
||||
check(required["items/tm_case/disc_17.rgba"], "disc_17 is a required cache file")
|
||||
check(required["items/tm_case/disc_hm_17.rgba"], "disc_hm_17 is a required cache file")
|
||||
check(TmCaseExtract.ready(cache, ROOT), "TmCaseExtract.ready sees every required file")
|
||||
|
||||
local plan = require("src.import.gba.plans.frlg")
|
||||
local inPlan = false
|
||||
for _, m in ipairs(plan.pokemonAfter or {}) do
|
||||
if m == "src.import.gba.tm_case_extract" then inPlan = true end
|
||||
end
|
||||
check(inPlan, "the FRLG import plan runs the TM case extract")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,117 @@
|
||||
-- ../pokegold/engine/rtc/timeset.asm:13-18
|
||||
-- ../pokegold/engine/menus/intro_menu.asm:494-500
|
||||
-- ../pokecrystal/engine/menus/init_gender.asm:60-65
|
||||
-- luajit tests/engine/gen2_oak_music_after_clock_3059.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Music = require("src.core.Music")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local OakSpeech = require("src.ui.gen2.OakSpeech")
|
||||
|
||||
local played, fades, pushed = {}, {}, {}
|
||||
local realPlay, realFade, realPush = Music.play, Music.fadeOut, Screens.push
|
||||
Music.play = function(_, song) played[#played + 1] = song end
|
||||
Music.fadeOut = function(control) fades[#fades + 1] = control end
|
||||
Screens.push = function(game, id, opts)
|
||||
local inst = { id = id, opts = opts }
|
||||
pushed[#pushed + 1] = inst
|
||||
game.stack:push(inst)
|
||||
return inst
|
||||
end
|
||||
|
||||
local function newGame(crystal)
|
||||
local game = { save = { player = { name = "GOLD" } } }
|
||||
game.data = {
|
||||
audio = { runtime = true },
|
||||
tokens = require("src.render.TextBox").TOKENS,
|
||||
gen2Sprites = crystal
|
||||
and { [FieldMoves.STATE_SPRITE_FEMALE.normal] = 1 } or nil,
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
wasPressed = function() return false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
return game
|
||||
end
|
||||
|
||||
local function route30Played()
|
||||
for _, song in ipairs(played) do
|
||||
if song == "Music_Route30" then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function reset()
|
||||
played, fades, pushed = {}, {}, {}
|
||||
end
|
||||
|
||||
do
|
||||
reset()
|
||||
local game = newGame(false)
|
||||
local speech = OakSpeech.new(game, { data = { music = "Music_Route30" } })
|
||||
speech.autoConfirm = true
|
||||
speech:enter()
|
||||
eq(pushed[1] and pushed[1].id, "Gen2InitClock",
|
||||
"gold: the speech opens on InitClock")
|
||||
check(not route30Played(),
|
||||
"gold: no Route 30 theme while the clock is being set")
|
||||
eq(fades[1], 0x10, "gold: InitClock fades the menu music out at $10")
|
||||
pushed[1].opts.onDone()
|
||||
check(route30Played(), "gold: Route 30 starts once the time is confirmed")
|
||||
end
|
||||
|
||||
do
|
||||
reset()
|
||||
local game = newGame(true)
|
||||
local speech = OakSpeech.new(game, { data = { music = "Music_Route30" } })
|
||||
speech.autoConfirm = true
|
||||
speech:enter()
|
||||
eq(pushed[1] and pushed[1].id, "Gen2GenderSelect",
|
||||
"crystal: the gender select comes first")
|
||||
check(not route30Played(), "crystal: silent during the gender select")
|
||||
eq(fades[1], 0x10, "crystal: InitGenderScreen fades the menu music at $10")
|
||||
pushed[1].opts.onDone("male")
|
||||
eq(pushed[2] and pushed[2].id, "Gen2InitClock",
|
||||
"crystal: InitClock follows the gender select")
|
||||
check(not route30Played(),
|
||||
"crystal: still silent while the clock is being set")
|
||||
pushed[2].opts.onDone()
|
||||
check(route30Played(), "crystal: Route 30 starts once the time is confirmed")
|
||||
end
|
||||
|
||||
do
|
||||
reset()
|
||||
local game = newGame(false)
|
||||
local speech = OakSpeech.new(game, { data = { music = "Music_Route30" } })
|
||||
speech.autoConfirm = true
|
||||
local base = OakSpeech.buildSteps
|
||||
speech.buildSteps = function(self)
|
||||
local steps = base(self)
|
||||
table.insert(steps, 1, { id = "mod_pre", kind = "fn",
|
||||
run = function(_, done) done() end })
|
||||
return steps
|
||||
end
|
||||
speech:enter()
|
||||
eq(pushed[1] and pushed[1].id, "Gen2InitClock",
|
||||
"mod step first: InitClock still opens")
|
||||
eq(played[1], "Music_Route30", "mod step first: theme starts at the mod step")
|
||||
eq(fades[1], 0x10, "mod step first: InitClock fades the theme out at $10")
|
||||
pushed[1].opts.onDone()
|
||||
eq(#played, 2, "mod step first: Route 30 restarts once the time is confirmed")
|
||||
end
|
||||
|
||||
Music.play, Music.fadeOut, Screens.push = realPlay, realFade, realPush
|
||||
|
||||
T.finish("gen2_oak_music_after_clock_3059")
|
||||
@@ -0,0 +1,414 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("launcher mods update keeps baseroms")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
local MOD_ID = "big_rom_mod"
|
||||
local DEST = "mods/" .. MOD_ID
|
||||
local CHUNK = 4 * 1024 * 1024
|
||||
local OLD_MANIFEST =
|
||||
('{"id":"%s","name":"Big Rom","version":"1.0.0","entry":"main.lua"}'):format(MOD_ID)
|
||||
local NEW_MANIFEST =
|
||||
('{"id":"%s","name":"Big Rom","version":"2.0.0","entry":"main.lua"}'):format(MOD_ID)
|
||||
local ARCHIVE = {
|
||||
[MOD_ID .. "/manifest.json"] = NEW_MANIFEST,
|
||||
[MOD_ID .. "/main.lua"] = "return function() return 2 end\n",
|
||||
[MOD_ID .. "/data/new.lua"] = "return {}\n",
|
||||
}
|
||||
|
||||
local files, dirs, arch, mtime = {}, {}, {}, {}
|
||||
local ops = {}
|
||||
local clock = 0
|
||||
local trapBaseromReads = true
|
||||
local failWriteOnce, failOpenWrite
|
||||
local chunkReads, maxChunk = 0, 0
|
||||
local caseFold
|
||||
|
||||
local function fold(name)
|
||||
if not caseFold then return name end
|
||||
local key = caseFold:lower()
|
||||
local low = name:lower()
|
||||
if low == key or low:sub(1, #key + 1) == key .. "/" then
|
||||
return caseFold .. name:sub(#key + 1)
|
||||
end
|
||||
return name
|
||||
end
|
||||
|
||||
local function resetFs()
|
||||
for _, t in ipairs({ files, dirs, arch, mtime, ops }) do
|
||||
for k in pairs(t) do t[k] = nil end
|
||||
end
|
||||
trapBaseromReads = true
|
||||
failWriteOnce, failOpenWrite = nil, nil
|
||||
chunkReads, maxChunk = 0, 0
|
||||
caseFold = nil
|
||||
end
|
||||
|
||||
local function log(op, path) ops[#ops + 1] = { op = op, path = path } end
|
||||
|
||||
local function store(name, data)
|
||||
clock = clock + 1
|
||||
files[name] = data
|
||||
mtime[name] = clock
|
||||
end
|
||||
|
||||
local function dirChild(key, name)
|
||||
if name == nil or name == "" then return key:match("^[^/]+") end
|
||||
local prefix = name .. "/"
|
||||
if key:sub(1, #prefix) ~= prefix then return nil end
|
||||
return key:sub(#prefix + 1):match("^[^/]+")
|
||||
end
|
||||
|
||||
local function mapInfo(map, name, kind)
|
||||
if map[name] ~= nil then return { type = kind or "file" } end
|
||||
for key in pairs(map) do
|
||||
if dirChild(key, name) then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local vfs = {}
|
||||
|
||||
function vfs.write(name, data)
|
||||
name = fold(name)
|
||||
log("write", name)
|
||||
if failWriteOnce == name then
|
||||
failWriteOnce = nil
|
||||
return nil, "simulated write failure"
|
||||
end
|
||||
store(name, data)
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.read(name)
|
||||
name = fold(name)
|
||||
log("read", name)
|
||||
if trapBaseromReads and name:find("/baseroms/", 1, true) then
|
||||
error("string length overflow")
|
||||
end
|
||||
if arch[name] ~= nil then return arch[name] end
|
||||
return files[name]
|
||||
end
|
||||
|
||||
function vfs.remove(name)
|
||||
name = fold(name)
|
||||
log("remove", name)
|
||||
files[name] = nil
|
||||
dirs[name] = nil
|
||||
mtime[name] = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.createDirectory(name)
|
||||
name = fold(name)
|
||||
dirs[name] = true
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.getInfo(name, kind)
|
||||
name = fold(name)
|
||||
log("info", name)
|
||||
local info = mapInfo(arch, name)
|
||||
or mapInfo(files, name)
|
||||
or mapInfo(dirs, name, "directory")
|
||||
if info and kind and info.type ~= kind then return nil end
|
||||
return info
|
||||
end
|
||||
|
||||
function vfs.getDirectoryItems(name)
|
||||
name = fold(name)
|
||||
log("list", name)
|
||||
local seen, items = {}, {}
|
||||
local function add(child)
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
for key in pairs(arch) do add(dirChild(key, name)) end
|
||||
for key in pairs(files) do add(dirChild(key, name)) end
|
||||
for key in pairs(dirs) do add(dirChild(key, name)) end
|
||||
table.sort(items)
|
||||
return items
|
||||
end
|
||||
|
||||
function vfs.newFile(name)
|
||||
name = fold(name)
|
||||
local f = { mode = nil, pos = 1, buf = {} }
|
||||
function f:open(mode)
|
||||
log("open:" .. mode, name)
|
||||
if mode == "w" and failOpenWrite == name then return false, "simulated open failure" end
|
||||
if mode == "r" and files[name] == nil then return false, "missing" end
|
||||
self.mode = mode
|
||||
return true
|
||||
end
|
||||
function f:read(n)
|
||||
chunkReads = chunkReads + 1
|
||||
if n > maxChunk then maxChunk = n end
|
||||
local src = files[name] or ""
|
||||
local out = src:sub(self.pos, self.pos + n - 1)
|
||||
self.pos = self.pos + #out
|
||||
return out, #out
|
||||
end
|
||||
function f:write(data)
|
||||
self.buf[#self.buf + 1] = data
|
||||
return true
|
||||
end
|
||||
function f:close()
|
||||
if self.mode == "w" then
|
||||
log("write", name)
|
||||
store(name, table.concat(self.buf))
|
||||
end
|
||||
self.mode = nil
|
||||
return true
|
||||
end
|
||||
return f
|
||||
end
|
||||
|
||||
function vfs.mount(_, point)
|
||||
for rel, body in pairs(ARCHIVE) do arch[point .. "/" .. rel] = body end
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.unmount()
|
||||
for k in pairs(arch) do arch[k] = nil end
|
||||
return true
|
||||
end
|
||||
|
||||
function vfs.newFileData(data, name)
|
||||
return { __filedata = true, data = data, name = name }
|
||||
end
|
||||
|
||||
function vfs.getSaveDirectory() return "/tmp/pokeport-update-baseroms-test" end
|
||||
function vfs.getSource() return nil end
|
||||
|
||||
local savedFs = love.filesystem
|
||||
local savedCacheFs = package.loaded["src.import.CacheFs"]
|
||||
local savedLauncherMods = package.loaded["src.mods.LauncherMods"]
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local savedPortable = SaveData.portableBaseDir
|
||||
|
||||
love.filesystem = vfs
|
||||
package.loaded["src.import.CacheFs"] = nil
|
||||
package.loaded["src.mods.LauncherMods"] = nil
|
||||
SaveData.portableBaseDir = function() return nil end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
|
||||
local function touches(prefix)
|
||||
local n = 0
|
||||
for _, o in ipairs(ops) do
|
||||
if o.path == prefix or o.path:sub(1, #prefix + 1) == prefix .. "/" then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
local function seedOldInstall()
|
||||
store(DEST .. "/manifest.json", OLD_MANIFEST)
|
||||
store(DEST .. "/main.lua", "return function() return 1 end\n")
|
||||
store(DEST .. "/old.lua", "return 'stale'\n")
|
||||
store(DEST .. "/gfx/old.bin", "stale")
|
||||
store(DEST .. "/baseroms/big.3ds", "user-owned-3ds")
|
||||
store(DEST .. "/baseroms/.required-import-rom.validated", "digest 14 1")
|
||||
end
|
||||
|
||||
local function update(name)
|
||||
store("imports/mods/" .. name, "PK\3\4" .. name)
|
||||
return LauncherMods.installZip("imports/mods/" .. name,
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
end
|
||||
|
||||
resetFs()
|
||||
seedOldInstall()
|
||||
local romTime = mtime[DEST .. "/baseroms/big.3ds"]
|
||||
local receiptTime = mtime[DEST .. "/baseroms/.required-import-rom.validated"]
|
||||
local ok, err = update("u1.zip")
|
||||
check(ok == true, "update with a baserom present succeeds (" .. tostring(err) .. ")")
|
||||
eq(touches(DEST .. "/baseroms"), 0, "update never lists, reads, writes or removes baseroms/")
|
||||
eq(files[DEST .. "/baseroms/big.3ds"], "user-owned-3ds", "baserom bytes untouched")
|
||||
eq(mtime[DEST .. "/baseroms/big.3ds"], romTime, "baserom modtime untouched")
|
||||
eq(mtime[DEST .. "/baseroms/.required-import-rom.validated"], receiptTime,
|
||||
"validated receipt untouched")
|
||||
eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "manifest is the new version")
|
||||
eq(files[DEST .. "/main.lua"], ARCHIVE[MOD_ID .. "/main.lua"], "package file updated")
|
||||
check(files[DEST .. "/data/new.lua"] ~= nil, "new package file written")
|
||||
check(files[DEST .. "/old.lua"] == nil, "stale file from the old version removed")
|
||||
check(files[DEST .. "/gfx/old.bin"] == nil, "stale nested file removed")
|
||||
|
||||
local lastWrite
|
||||
for _, o in ipairs(ops) do
|
||||
if o.op == "write" and o.path:sub(1, #DEST + 1) == DEST .. "/" then lastWrite = o.path end
|
||||
end
|
||||
eq(lastWrite, DEST .. "/manifest.json", "manifest.json is the last write")
|
||||
|
||||
resetFs()
|
||||
trapBaseromReads = false
|
||||
seedOldInstall()
|
||||
failWriteOnce = DEST .. "/main.lua"
|
||||
ok, err = update("u2.zip")
|
||||
check(not ok, "write failure fails the update")
|
||||
eq(files[DEST .. "/manifest.json"], OLD_MANIFEST, "old manifest survives the failure")
|
||||
eq(files[DEST .. "/baseroms/big.3ds"], "user-owned-3ds", "baserom survives the failure")
|
||||
eq(touches(DEST .. "/baseroms"), 0, "failed update never touches baseroms/")
|
||||
ok, err = update("u2-retry.zip")
|
||||
check(ok == true, "retry after a failure succeeds (" .. tostring(err) .. ")")
|
||||
eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "retry lands the new manifest")
|
||||
|
||||
resetFs()
|
||||
failWriteOnce = DEST .. "/manifest.json"
|
||||
store("imports/mods/fresh.zip", "PK\3\4fresh")
|
||||
ok, err = LauncherMods.installZip("imports/mods/fresh.zip")
|
||||
check(not ok, "fresh install manifest failure fails")
|
||||
local left = 0
|
||||
for k in pairs(files) do if k:sub(1, #DEST + 1) == DEST .. "/" then left = left + 1 end end
|
||||
eq(left, 0, "failed fresh install rolls back every written file")
|
||||
|
||||
resetFs()
|
||||
local big = string.rep("A", CHUNK) .. string.rep("B", CHUNK) .. "tail"
|
||||
store("mods/OldFolder/manifest.json", OLD_MANIFEST)
|
||||
store("mods/OldFolder/main.lua", "return function() end\n")
|
||||
store("mods/OldFolder/baseroms/x.z64", big)
|
||||
store("imports/baseroms-recovery/" .. MOD_ID .. "/y.z64", "recovered")
|
||||
ok, err = update("u3.zip")
|
||||
check(ok == true, "update over a shadow copy succeeds (" .. tostring(err) .. ")")
|
||||
check(files[DEST .. "/baseroms/x.z64"] == big, "shadow baserom drained byte-exact")
|
||||
eq(files[DEST .. "/baseroms/y.z64"], "recovered", "recovery baserom drained")
|
||||
check(files["mods/OldFolder/manifest.json"] == nil, "shadow tree removed")
|
||||
check(files["mods/OldFolder/baseroms/x.z64"] == nil, "shadow baserom removed after drain")
|
||||
check(files["imports/baseroms-recovery/" .. MOD_ID .. "/y.z64"] == nil,
|
||||
"recovery dir cleared")
|
||||
check(chunkReads >= 3, "drain streams in chunks (" .. chunkReads .. " reads)")
|
||||
check(maxChunk <= CHUNK, "drain never reads more than one chunk at a time")
|
||||
eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "drained install has the new manifest")
|
||||
|
||||
resetFs()
|
||||
seedOldInstall()
|
||||
store("mods/OldFolder/manifest.json", OLD_MANIFEST)
|
||||
store("mods/OldFolder/baseroms/big.3ds", "shadow-copy")
|
||||
ok, err = update("u4.zip")
|
||||
check(ok == true, "update with both copies succeeds (" .. tostring(err) .. ")")
|
||||
eq(files[DEST .. "/baseroms/big.3ds"], "user-owned-3ds", "existing baserom kept")
|
||||
|
||||
resetFs()
|
||||
store("mods/OldFolder/manifest.json", OLD_MANIFEST)
|
||||
store("mods/OldFolder/baseroms/z.z64", "only-copy")
|
||||
failOpenWrite = DEST .. "/baseroms/z.z64"
|
||||
ok, err = update("u5.zip")
|
||||
check(not ok, "drain failure fails the update")
|
||||
check(files[DEST .. "/manifest.json"] ~= nil, "mods/<id> keeps a manifest after a drain failure")
|
||||
eq(files["mods/OldFolder/baseroms/z.z64"], "only-copy", "drain source kept on failure")
|
||||
check(files[DEST .. "/baseroms/z.z64"] == nil, "partial drain copy removed")
|
||||
|
||||
resetFs()
|
||||
store(DEST .. "/baseroms/r.z64", "orphan-rom")
|
||||
store("imports/mods/heal.zip", "PK\3\4heal")
|
||||
ok, err = LauncherMods.installZip("imports/mods/heal.zip")
|
||||
check(ok == true, "re-import over orphan baseroms succeeds (" .. tostring(err) .. ")")
|
||||
eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "orphan baseroms gain a manifest")
|
||||
eq(files[DEST .. "/baseroms/r.z64"], "orphan-rom", "orphan baserom kept")
|
||||
eq(touches(DEST .. "/baseroms"), 0, "re-import never touches baseroms/")
|
||||
|
||||
resetFs()
|
||||
store("mods/Big_Rom_Mod/manifest.json", OLD_MANIFEST)
|
||||
store("mods/Big_Rom_Mod/main.lua", "return function() return 1 end\n")
|
||||
store("mods/Big_Rom_Mod/baseroms/k.z64", "case-shadow-rom")
|
||||
ok, err = update("u6.zip")
|
||||
check(ok == true, "update over a case-only shadow succeeds (" .. tostring(err) .. ")")
|
||||
check(files["mods/Big_Rom_Mod/manifest.json"] == nil,
|
||||
"case-only shadow removed on a case-sensitive filesystem")
|
||||
check(files["mods/Big_Rom_Mod/baseroms/k.z64"] == nil, "case-only shadow baserom drained out")
|
||||
eq(files[DEST .. "/baseroms/k.z64"], "case-shadow-rom", "case-only shadow baserom lands in mods/<id>")
|
||||
eq(files[DEST .. "/manifest.json"], NEW_MANIFEST, "mods/<id> has the new manifest")
|
||||
|
||||
resetFs()
|
||||
caseFold = "mods/Big_Rom_Mod"
|
||||
store("mods/Big_Rom_Mod/manifest.json", OLD_MANIFEST)
|
||||
store("mods/Big_Rom_Mod/main.lua", "return function() return 1 end\n")
|
||||
store("mods/Big_Rom_Mod/baseroms/k.z64", "folded-rom")
|
||||
ok, err = update("u7.zip")
|
||||
check(ok == true, "update on a case-insensitive filesystem succeeds (" .. tostring(err) .. ")")
|
||||
eq(files["mods/Big_Rom_Mod/manifest.json"], NEW_MANIFEST,
|
||||
"case-folded install dir is updated, not removed")
|
||||
eq(files["mods/Big_Rom_Mod/baseroms/k.z64"], "folded-rom", "case-folded install keeps its baserom")
|
||||
eq(touches("mods/Big_Rom_Mod/baseroms"), 0, "case-folded install never touches baseroms/")
|
||||
|
||||
do
|
||||
local FsIo = require("tests.fs_io")
|
||||
local tmpBase = (os.getenv("TMPDIR") or "/tmp"):gsub("[/\\]+$", "")
|
||||
local sandbox = ("%s/lm3057_portable_%d_%d"):format(tmpBase, os.time(), math.random(1, 999999))
|
||||
local game = sandbox .. "/game"
|
||||
os.execute('mkdir -p "' .. game .. '/mods/' .. MOD_ID .. '/baseroms"')
|
||||
local zip = sandbox .. "/u.zip"
|
||||
local function put(path, data)
|
||||
local f = assert(io.open(path, "wb"))
|
||||
f:write(data)
|
||||
f:close()
|
||||
end
|
||||
put(zip, "PK\003\004x")
|
||||
put(game .. "/" .. DEST .. "/baseroms/g.rom", "game-folder-rom")
|
||||
local realFs = FsIo.new(game)
|
||||
local save, parch, saveOps = {}, {}, {}
|
||||
local function mapInfoP(map, name)
|
||||
if map[name] ~= nil then return { type = "file" } end
|
||||
for k in pairs(map) do if dirChild(k, name) then return { type = "directory" } end end
|
||||
end
|
||||
local pvfs = {}
|
||||
function pvfs.write(n, d) saveOps[#saveOps + 1] = n save[n] = d return true end
|
||||
function pvfs.read(n) return parch[n] or save[n] or realFs.read(n) end
|
||||
function pvfs.remove(n) saveOps[#saveOps + 1] = n save[n] = nil return true end
|
||||
function pvfs.createDirectory() return true end
|
||||
function pvfs.getInfo(n, kind)
|
||||
local i = mapInfoP(parch, n) or mapInfoP(save, n) or realFs.getInfo(n)
|
||||
if i and kind and i.type ~= kind then return nil end
|
||||
return i
|
||||
end
|
||||
function pvfs.getDirectoryItems(n)
|
||||
local seen, out = {}, {}
|
||||
local function add(c) if c and not seen[c] then seen[c] = true out[#out + 1] = c end end
|
||||
for k in pairs(parch) do add(dirChild(k, n)) end
|
||||
for k in pairs(save) do add(dirChild(k, n)) end
|
||||
local i = realFs.getInfo(n)
|
||||
if i and i.type == "directory" then
|
||||
for _, c in ipairs(realFs.getDirectoryItems(n)) do add(c) end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
function pvfs.mount(_, pt) for r, b in pairs(ARCHIVE) do parch[pt .. "/" .. r] = b end return true end
|
||||
function pvfs.unmount() for k in pairs(parch) do parch[k] = nil end return true end
|
||||
function pvfs.getSource() return game end
|
||||
function pvfs.getSaveDirectory() return sandbox .. "/os-save" end
|
||||
|
||||
love.filesystem = pvfs
|
||||
SaveData.portableBaseDir = function() return game end
|
||||
package.loaded["src.import.CacheFs"] = nil
|
||||
package.loaded["src.mods.LauncherMods"] = nil
|
||||
local PortableMods = require("src.mods.LauncherMods")
|
||||
|
||||
save[DEST .. "/manifest.json"] = OLD_MANIFEST
|
||||
save[DEST .. "/main.lua"] = "return function() return 1 end\n"
|
||||
save[DEST .. "/baseroms/s.rom"] = "save-dir-rom"
|
||||
ok, err = PortableMods.installZip(zip, { replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "portable update over a save-dir twin succeeds (" .. tostring(err) .. ")")
|
||||
eq(pvfs.read(DEST .. "/manifest.json"), NEW_MANIFEST,
|
||||
"portable update is visible through the merged read path")
|
||||
eq(pvfs.read(DEST .. "/main.lua"), ARCHIVE[MOD_ID .. "/main.lua"],
|
||||
"portable package file is visible through the merged read path")
|
||||
check(save[DEST .. "/manifest.json"] == nil, "save-dir twin manifest cleared")
|
||||
eq(save[DEST .. "/baseroms/s.rom"], "save-dir-rom", "save-dir baserom kept")
|
||||
eq(realFs.read(DEST .. "/baseroms/g.rom"), "game-folder-rom", "game-folder baserom kept")
|
||||
local baseromOps = 0
|
||||
for _, n in ipairs(saveOps) do
|
||||
if n:find("/baseroms", 1, true) then baseromOps = baseromOps + 1 end
|
||||
end
|
||||
eq(baseromOps, 0, "portable update never writes or removes a save-dir baserom")
|
||||
os.execute('rm -r "' .. sandbox .. '"')
|
||||
end
|
||||
|
||||
love.filesystem = savedFs
|
||||
SaveData.portableBaseDir = savedPortable
|
||||
package.loaded["src.import.CacheFs"] = savedCacheFs
|
||||
package.loaded["src.mods.LauncherMods"] = savedLauncherMods
|
||||
|
||||
S.finish()
|
||||
@@ -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()
|
||||
@@ -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" }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -176,7 +176,7 @@ check(staged ~= nil, "a wild is pending after CreateEnemyEventMon")
|
||||
eq(staged and staged.species, 249, "staged species")
|
||||
eq(staged and staged.level, 70, "staged level")
|
||||
eq(staged and staged.item, nil, "ITEM_NONE stages no held item")
|
||||
check(staged and staged.fatefulEncounter == true, "the event mon is a fateful encounter")
|
||||
check(staged and staged.modernFatefulEncounter == true, "the event mon is a fateful encounter")
|
||||
|
||||
local ctx6b = newCtx()
|
||||
-- pokefirered/include/constants/species.h:419
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local GameCache = require("tests.game3_cache")
|
||||
local CacheBlob = require("src.import.CacheBlob")
|
||||
local TmCaseExtract = require("src.import.gba.tm_case_extract")
|
||||
|
||||
local root = GameCache.root(TmCaseExtract.CACHE_SUB .. "/manifest.lua")
|
||||
if not root then
|
||||
print("[skip] game3_tm_case_disc_cache_3061_test: " .. tostring(GameCache.reason))
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
local failed = 0
|
||||
local function check(cond, msg)
|
||||
if cond then
|
||||
print("[ok] " .. msg)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("[FAIL] " .. msg)
|
||||
end
|
||||
return cond
|
||||
end
|
||||
|
||||
local function read(rel)
|
||||
local path = root .. "/" .. rel
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local data = CacheBlob.decode(path, f:read("*a"))
|
||||
f:close()
|
||||
return data
|
||||
end
|
||||
|
||||
local function colors(data)
|
||||
local counts, keys = {}, {}
|
||||
for i = 0, 32 * 32 - 1 do
|
||||
local r, g, b, a = data:byte(i * 4 + 1, i * 4 + 4)
|
||||
if a == 255 then
|
||||
local k = r .. "," .. g .. "," .. b
|
||||
if not counts[k] then keys[#keys + 1] = k end
|
||||
counts[k] = (counts[k] or 0) + 1
|
||||
end
|
||||
end
|
||||
table.sort(keys)
|
||||
local sig = {}
|
||||
for i, k in ipairs(keys) do sig[i] = k .. "x" .. counts[k] end
|
||||
return counts, table.concat(sig, " ")
|
||||
end
|
||||
|
||||
local sigs, sets = {}, {}
|
||||
for t = 0, 17 do
|
||||
for _, hm in ipairs({ "", "hm_" }) do
|
||||
local rel = string.format("%s/disc_%s%d.rgba", TmCaseExtract.CACHE_SUB, hm, t)
|
||||
local data = read(rel)
|
||||
if check(data and #data == 32 * 32 * 4, rel .. " is a 32x32 disc") then
|
||||
local counts, sig = colors(data)
|
||||
check(sig ~= "", rel .. " has opaque pixels")
|
||||
if hm == "" then sigs[t], sets[t] = sig, counts end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- src/tm_case.c:396 sTMSpritePaletteOffsetByType
|
||||
check(sigs[9] ~= nil and sigs[9] == sigs[0], "TYPE_MYSTERY tints like TYPE_NORMAL")
|
||||
for a = 0, 17 do
|
||||
for b = a + 1, 17 do
|
||||
if a ~= 9 and b ~= 9 and sigs[a] and sigs[b] and sigs[a] == sigs[b] then
|
||||
check(false, string.format("types %d and %d share a disc palette", a, b))
|
||||
end
|
||||
end
|
||||
end
|
||||
check(sets[17] and sets[17]["82,156,165"] == 56, "the Dark disc carries the Dark teal accent")
|
||||
check(sets[10] and sets[10]["239,173,33"] == 56, "the Fire disc carries the Fire orange accent")
|
||||
check(sets[11] and sets[11]["74,165,239"] == 56, "the Water disc carries the Water blue accent")
|
||||
|
||||
if failed > 0 then
|
||||
print(string.format("%d failure(s)", failed))
|
||||
os.exit(1)
|
||||
end
|
||||
print("all checks passed")
|
||||
@@ -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({
|
||||
|
||||
@@ -120,6 +120,34 @@ function vfs.newFileData(data, name)
|
||||
return { __filedata = true, data = data, name = name }
|
||||
end
|
||||
|
||||
function vfs.newFile(name)
|
||||
local f = { pos = 1, buf = {} }
|
||||
function f:open(mode)
|
||||
if mode == "w" and failWriteOnce == name then
|
||||
failWriteOnce = nil
|
||||
return false, "simulated write failure"
|
||||
end
|
||||
if mode == "r" and files[name] == nil then return false, "missing" end
|
||||
self.mode = mode
|
||||
return true
|
||||
end
|
||||
function f:read(n)
|
||||
local out = (files[name] or ""):sub(self.pos, self.pos + n - 1)
|
||||
self.pos = self.pos + #out
|
||||
return out, #out
|
||||
end
|
||||
function f:write(data)
|
||||
self.buf[#self.buf + 1] = data
|
||||
return true
|
||||
end
|
||||
function f:close()
|
||||
if self.mode == "w" then files[name] = table.concat(self.buf) end
|
||||
self.mode = nil
|
||||
return true
|
||||
end
|
||||
return f
|
||||
end
|
||||
|
||||
function vfs.getSaveDirectory()
|
||||
return "/tmp/pokeport-install-zip-test"
|
||||
end
|
||||
@@ -221,8 +249,6 @@ check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil,
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
|
||||
"replace still lands in mods/<id>")
|
||||
|
||||
-- User-selected baseroms belong to the installation, not the downloaded mod
|
||||
-- archive, and survive the same replacement path.
|
||||
resetFs()
|
||||
files["mods/OldFolder/manifest.json"] =
|
||||
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
|
||||
@@ -234,12 +260,10 @@ ok, err = LauncherMods.installZip("imports/mods/update-with-rom.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "replace with a baserom succeeds (" .. tostring(err) .. ")")
|
||||
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
|
||||
"replace preserves user-owned baseroms under the canonical mod folder")
|
||||
"replace moves user-owned baseroms under the canonical mod folder")
|
||||
check(files["mods/OldFolder/baseroms/stadium2.z64"] == nil,
|
||||
"the shadow mod tree is still removed after preservation")
|
||||
"the shadow mod tree is removed after its baseroms drain")
|
||||
|
||||
-- A preservation write failure keeps recovery bytes outside mods/, where
|
||||
-- discovery cannot mistake a baseroms-only directory for an installed mod.
|
||||
resetFs()
|
||||
files["mods/OldFolder/manifest.json"] =
|
||||
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
|
||||
@@ -250,20 +274,32 @@ files["imports/mods/preserve-fail.zip"] = "PK\3\4update"
|
||||
failWriteOnce = "mods/" .. MOD_ID .. "/baseroms/stadium2.z64"
|
||||
ok, err = LauncherMods.installZip("imports/mods/preserve-fail.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(not ok, "preservation failure rejects the update")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] == nil,
|
||||
"preservation failure leaves no manifest-less tree under mods")
|
||||
eq(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"],
|
||||
"user-owned-rom", "preservation failure stages recovery outside mods")
|
||||
check(not ok, "drain failure rejects the update")
|
||||
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
|
||||
"drain failure leaves mods/<id> with a manifest")
|
||||
check(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"] == nil,
|
||||
"drain failure leaves no partial baserom")
|
||||
eq(files["mods/OldFolder/baseroms/stadium2.z64"], "user-owned-rom",
|
||||
"drain failure keeps the source baserom")
|
||||
|
||||
files["imports/mods/preserve-retry.zip"] = "PK\3\4update"
|
||||
ok, err = LauncherMods.installZip("imports/mods/preserve-retry.zip",
|
||||
{ replace = true, expectId = MOD_ID })
|
||||
check(ok == true, "retry restores staged baseroms (" .. tostring(err) .. ")")
|
||||
check(ok == true, "retry drains the baserom (" .. tostring(err) .. ")")
|
||||
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
|
||||
"retry restores the recovered baserom into the installed mod")
|
||||
"retry moves the baserom into the installed mod")
|
||||
check(files["mods/OldFolder/manifest.json"] == nil,
|
||||
"successful retry removes the shadow tree")
|
||||
|
||||
resetFs()
|
||||
files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"] = "user-owned-rom"
|
||||
files["imports/mods/recover.zip"] = "PK\3\4update"
|
||||
ok, err = LauncherMods.installZip("imports/mods/recover.zip")
|
||||
check(ok == true, "install drains legacy recovery baseroms (" .. tostring(err) .. ")")
|
||||
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
|
||||
"recovered baserom lands in the installed mod")
|
||||
check(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"] == nil,
|
||||
"successful retry clears baserom recovery debris")
|
||||
"successful install clears baserom recovery debris")
|
||||
|
||||
-- #834: a manifest-less mods/<id> tree (interrupted copy debris) must not
|
||||
-- block a plain re-import as "already installed"
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Fixture = require("tests.modkit.dataset_view_fixture")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local DatasetHydration = require("src.core.DatasetHydration")
|
||||
local DatasetViews = require("src.mods.DatasetViews")
|
||||
local Game2 = require("src.core.Game2")
|
||||
|
||||
local GEN2 = { "gold", "silver", "crystal" }
|
||||
local originalVersion, originalPrefix = GameVersion.get(), CacheFs.prefix
|
||||
GameVersion.set("red")
|
||||
|
||||
local files = {}
|
||||
for _, version in ipairs(GEN2) do Fixture.cache(files, version) end
|
||||
local probe = Fixture.addMod(files, "emote_probe", [[
|
||||
local mod = ...
|
||||
local out = {}
|
||||
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
||||
local view, reason = mod.datasets:open(version)
|
||||
local row = { reason = reason, walked = {} }
|
||||
if view then
|
||||
local emotes = view.content.field:get("emotes") or {}
|
||||
row.fishing = emotes.fishing
|
||||
row.fishingFemale = emotes.fishingFemale
|
||||
row.order = emotes.order
|
||||
row.fishingInfo = emotes.fishing and view.assets:info(emotes.fishing)
|
||||
row.femaleInfo = emotes.fishingFemale and view.assets:info(emotes.fishingFemale)
|
||||
row.battleHud = view.content.field:has("battleHud")
|
||||
row.pack = view.content.field:has("pack")
|
||||
row.fieldIds = {}
|
||||
for id in view.content.field:each() do row.fieldIds[#row.fieldIds + 1] = id end
|
||||
for name, registry in pairs(view.content) do
|
||||
local count = 0
|
||||
for _ in registry:each() do count = count + 1 end
|
||||
row.walked[name] = count
|
||||
end
|
||||
row.species = view.content.pokemon:get("FIXMON") ~= nil
|
||||
end
|
||||
local again, againReason = mod.datasets:open(version)
|
||||
row.reopened, row.reopenReason = again ~= nil, againReason
|
||||
out[version] = row
|
||||
end
|
||||
mod.exports.result = out
|
||||
]])
|
||||
|
||||
local warnings = {}
|
||||
local originalWarn = Logger.warn
|
||||
Logger.warn = function(fmt, ...)
|
||||
warnings[#warnings + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
|
||||
end
|
||||
local run = T.sdk.loadMods({ probe }, {
|
||||
fs = T.sdk.memfs(files), data = { pokemon = {} }, generation = 1,
|
||||
})
|
||||
Logger.warn = originalWarn
|
||||
T.eq(#run.errors, 0, "emote probe loads clean")
|
||||
local rejected = {}
|
||||
for _, line in ipairs(warnings) do
|
||||
if line:find("rejected", 1, true) then rejected[#rejected + 1] = line end
|
||||
end
|
||||
T.same(rejected, {}, "no Gen 2 dataset is rejected after the field walk")
|
||||
|
||||
local out = run.loader.exports.emote_probe.result or {}
|
||||
for _, version in ipairs(GEN2) do
|
||||
local row = out[version] or {}
|
||||
T.eq(row.reason, nil, version .. " opens")
|
||||
T.eq(row.fishing, "assets/generated/emotes/fishing.png",
|
||||
version .. " field emotes exposes the fishing sheet")
|
||||
T.same(row.fishingInfo, { type = "file" }, version .. " fishing sheet resolves as a file")
|
||||
T.same(row.order, { "shock" }, version .. " emotes keep their order list")
|
||||
if version == "crystal" then
|
||||
T.eq(row.fishingFemale, "assets/generated/emotes/fishing_female.png",
|
||||
version .. " exposes Kris' fishing sheet")
|
||||
T.same(row.femaleInfo, { type = "file" }, version .. " fishingFemale resolves as a file")
|
||||
else
|
||||
T.eq(row.fishingFemale, nil, version .. " has no fishingFemale")
|
||||
end
|
||||
T.eq(row.battleHud, false, version .. " other menu_gfx keys stay out of field")
|
||||
T.eq(row.pack, false, version .. " pack art stays out of field")
|
||||
T.same(row.fieldIds, { "emotes" }, version .. " field lists only emotes")
|
||||
T.eq(row.walked.field, 1, version .. " field walk yields emotes")
|
||||
T.eq(row.species, true, version .. " pokemon still reads after the walk")
|
||||
T.eq(row.reopened, true, version .. " stays valid after every registry is walked")
|
||||
T.eq(row.reopenReason, nil, version .. " reopens without a reason")
|
||||
end
|
||||
run.release()
|
||||
|
||||
for _, version in ipairs(GEN2) do
|
||||
local service = DatasetViews.new(T.sdk.memfs(files))
|
||||
local view = service:open(version)
|
||||
local internal = service.datasets[version]
|
||||
T.check(view ~= nil, version .. " lazy probe opens")
|
||||
if view then
|
||||
T.check(view.content.items:get("LEFTOVERS") ~= nil, version .. " items read")
|
||||
T.eq(internal.moduleCache.menu_gfx, nil,
|
||||
version .. " reading items leaves menu_gfx undecoded")
|
||||
T.check(type(view.content.field:get("emotes")) == "table", version .. " field emotes read")
|
||||
T.check(internal.moduleCache.menu_gfx ~= nil,
|
||||
version .. " reading field decodes menu_gfx")
|
||||
end
|
||||
end
|
||||
|
||||
local function manifest(api)
|
||||
return '{"id":"emotes","name":"emotes","version":"1.0.0","api":' .. api
|
||||
.. ',"games":["all"],"entry":"main.lua"}'
|
||||
end
|
||||
|
||||
for _, version in ipairs(GEN2) do
|
||||
for _, api in ipairs({ 2, 3 }) do
|
||||
local label = version .. " api " .. api
|
||||
local vanilla = {
|
||||
shock = "assets/generated/emotes/shock.png",
|
||||
fishing = "assets/generated/emotes/fishing.png",
|
||||
order = { "shock" },
|
||||
}
|
||||
local data = { gen2MenuGfx = { emotes = vanilla, battleHud = {} } }
|
||||
DatasetHydration.seedGen2Field(data)
|
||||
T.eq(data.field.emotes, vanilla, label .. " seeds field.emotes from menu_gfx")
|
||||
T.eq(data.field.battleHud, nil, label .. " seeds no other menu_gfx key")
|
||||
T.sdk.captureRuntime()
|
||||
local loader = Loader.new({ fs = T.sdk.memfs({
|
||||
["mods/emotes/manifest.json"] = manifest(api),
|
||||
["mods/emotes/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.before = (mod.content.field:get("emotes") or {}).fishing
|
||||
mod.exports.ok, mod.exports.reason = mod.content.field:patch("emotes",
|
||||
{ fishing = "mods/emotes/fishing_red.png" })
|
||||
]],
|
||||
}), generation = 2, version = version, dev = true })
|
||||
loader:load(data)
|
||||
T.same(loader.errors, {}, label .. " an emotes patch loads clean")
|
||||
local exports = loader.exports.emotes or {}
|
||||
T.eq(exports.before, "assets/generated/emotes/fishing.png",
|
||||
label .. " mod reads the cart fishing sheet")
|
||||
T.eq(data.field.emotes.fishing, "mods/emotes/fishing_red.png",
|
||||
label .. " the patch merges into field.emotes")
|
||||
Game2.applyFieldEmotes(data)
|
||||
T.eq(data.gen2MenuGfx.emotes.fishing, "mods/emotes/fishing_red.png",
|
||||
label .. " the patched sheet reaches gen2MenuGfx.emotes")
|
||||
T.eq(data.gen2MenuGfx.emotes.shock, "assets/generated/emotes/shock.png",
|
||||
label .. " unpatched emotes are kept")
|
||||
T.sdk.restoreRuntime()
|
||||
end
|
||||
end
|
||||
|
||||
local plain = { gen2MenuGfx = { emotes = { fishing = "a.png" } } }
|
||||
DatasetHydration.seedGen2Field(plain)
|
||||
local same = plain.gen2MenuGfx.emotes
|
||||
Game2.applyFieldEmotes(plain)
|
||||
T.eq(plain.gen2MenuGfx.emotes, same, "no mod keeps the cart emote table")
|
||||
|
||||
GameVersion.set(originalVersion)
|
||||
CacheFs.prefix = originalPrefix
|
||||
T.finish("dataset_views_gen2_emotes")
|
||||
@@ -0,0 +1,168 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Fixture = require("tests.modkit.dataset_view_fixture")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Records = require("src.mods.UnifiedRecords")
|
||||
|
||||
local VERSIONS = { "red", "blue", "yellow", "gold", "silver", "crystal" }
|
||||
local originalVersion, originalPrefix = GameVersion.get(), CacheFs.prefix
|
||||
GameVersion.set("red")
|
||||
|
||||
local files = {}
|
||||
for _, version in ipairs(VERSIONS) do Fixture.cache(files, version) end
|
||||
local probe = Fixture.addMod(files, "metadata_probe", [[
|
||||
local mod = ...
|
||||
local out = {}
|
||||
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal" }) do
|
||||
local view = assert(mod.datasets:open(version))
|
||||
local counts, ids = {}, {}
|
||||
for name, registry in pairs(view.content) do
|
||||
counts[name], ids[name] = 0, {}
|
||||
for id in registry:each() do
|
||||
counts[name] = counts[name] + 1
|
||||
ids[name][id] = true
|
||||
end
|
||||
end
|
||||
local again, reason = mod.datasets:open(version)
|
||||
out[version] = {
|
||||
counts = counts, ids = ids, reopened = again ~= nil, reason = reason,
|
||||
tutorHidden = not view.content.pokemon:has("tutorMoves"),
|
||||
capsuleHidden = not view.content.items:has("timeCapsule"),
|
||||
special = view.content.landmarks:get("LANDMARK_SPECIAL"),
|
||||
}
|
||||
end
|
||||
mod.exports.result = out
|
||||
]])
|
||||
|
||||
local warnings = {}
|
||||
local originalWarn = Logger.warn
|
||||
Logger.warn = function(fmt, ...)
|
||||
warnings[#warnings + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
|
||||
end
|
||||
local run = T.sdk.loadMods({ probe }, {
|
||||
fs = T.sdk.memfs(files), data = { pokemon = {} }, generation = 1,
|
||||
})
|
||||
Logger.warn = originalWarn
|
||||
T.eq(#run.errors, 0, "metadata probe loads clean")
|
||||
local rejected = {}
|
||||
for _, line in ipairs(warnings) do
|
||||
if line:find("rejected", 1, true) then rejected[#rejected + 1] = line end
|
||||
end
|
||||
T.same(rejected, {}, "no fixture dataset is rejected")
|
||||
|
||||
local out = run.loader.exports.metadata_probe.result
|
||||
for _, version in ipairs(VERSIONS) do
|
||||
local row = out[version]
|
||||
T.eq(row.reopened, true, version .. " stays valid after every registry is walked")
|
||||
T.eq(row.reason, nil, version .. " reopens without a reason")
|
||||
T.eq(row.counts.pokemon,
|
||||
GameVersion.generation(version) == 2 and 1 + #Fixture.CONTINUATIONS or 1,
|
||||
version .. " pokemon each counts species only")
|
||||
end
|
||||
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
||||
local row = out[version]
|
||||
T.eq(row.counts.items, 2, version .. " items each lists LEFTOVERS and ITEM_C3")
|
||||
T.eq(row.ids.items.ITEM_C3, true, version .. " nameless unused item row walks")
|
||||
T.eq(row.ids.items.timeCapsule, nil, version .. " timeCapsule is not an item id")
|
||||
T.eq(row.capsuleHidden, true, version .. " has skips timeCapsule")
|
||||
T.eq(row.counts.cries, 1, version .. " cries each accepts word pitch and length")
|
||||
T.eq(row.ids.encounters.timeFishGroups, true,
|
||||
version .. " encounters each walks timeFishGroups")
|
||||
T.eq(row.counts.landmarks, 1, version .. " landmarks each walks LANDMARK_SPECIAL")
|
||||
T.eq(row.special and row.special.x, -8, version .. " LANDMARK_SPECIAL keeps x -8")
|
||||
T.eq(row.counts.text, 1, version .. " text each lists script text only")
|
||||
T.eq(row.ids.text.labels, nil, version .. " text labels table is not a text id")
|
||||
T.eq(row.counts.tilesets, 1, version .. " tilesets each lists tileset records only")
|
||||
T.eq(row.ids.tilesets.animFrames, nil, version .. " animFrames is not a tileset id")
|
||||
T.eq(row.ids.pokemon.tutorMoves, nil, version .. " tutorMoves is not a species id")
|
||||
T.eq(row.tutorHidden, true, version .. " has skips tutorMoves")
|
||||
end
|
||||
run.release()
|
||||
|
||||
local function gen2(name)
|
||||
return Schemas.shapeFor(name, Schemas.REGISTRIES[name], 2)
|
||||
end
|
||||
local function check(name, id, value, mode)
|
||||
local ok, valid, detail = pcall(Schemas.check, gen2(name), name, id, value, mode or "register")
|
||||
T.eq(ok, true, name .. "." .. tostring(id) .. " check returns instead of throwing")
|
||||
return valid, detail
|
||||
end
|
||||
T.eq(check("items", "MY_ITEM", { id = "MY_ITEM", price = 10 }), nil,
|
||||
"a mod item without a name is still rejected")
|
||||
T.eq(check("items", "ITEM_C3", { id = "ITEM_C3", index = 0xC4, price = 10 }), nil,
|
||||
"a nameless item whose id does not match its index is rejected")
|
||||
for _, row in ipairs({ { "ITEM_FB", 0xFB }, { "ITEM_100", 0x100 } }) do
|
||||
for _, mode in ipairs({ "register", "override" }) do
|
||||
T.eq(check("items", row[1], { id = row[1], index = row[2], price = 0 }, mode), nil,
|
||||
"a nameless mod item named after its own index is rejected on " .. mode)
|
||||
end
|
||||
end
|
||||
T.eq(check("items", "ITEM_C3", { id = "ITEM_C3", index = 0xC3, price = 0 }, "register"), nil,
|
||||
"a mod register of a nameless ITEM_C3 is rejected")
|
||||
T.eq(check("items", "ITEM_C3", { id = "ITEM_C3", index = 0xC3, price = 0 }, "base"), true,
|
||||
"the cart's own nameless ITEM_C3 row is accepted as base data")
|
||||
T.eq(check("items", "ITEM_FB", { id = "ITEM_FB", index = 0xFB, price = 0 }, "base"), nil,
|
||||
"a nameless base row outside the cart's three is rejected")
|
||||
local loaded = T.fixtures.fresh()
|
||||
loaded.items.generation, loaded.items.source, loaded.items.pockets = 2, "fixture", {}
|
||||
local modFiles = {
|
||||
["mods/n/manifest.json"] = '{"id":"n","name":"n","version":"1.0.0","api":2,'
|
||||
.. '"games":["all"],"entry":"main.lua"}',
|
||||
["mods/n/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.ok = pcall(mod.content.items.register, mod.content.items, "ITEM_FB",
|
||||
{ id = "ITEM_FB", index = 251, price = 0 })
|
||||
]],
|
||||
}
|
||||
T.sdk.captureRuntime()
|
||||
local modLoader = Loader.new({ fs = T.sdk.memfs(modFiles), generation = 2,
|
||||
version = "gold", dev = true })
|
||||
local loadOk = pcall(modLoader.load, modLoader, loaded)
|
||||
T.sdk.restoreRuntime()
|
||||
T.eq(loadOk, true, "a mod registering a nameless ITEM_FB still loads")
|
||||
T.eq((modLoader.exports.n or {}).ok, false, "api 2 register of a nameless ITEM_FB is refused")
|
||||
T.eq(loaded.items.ITEM_FB, nil, "the nameless ITEM_FB is not merged")
|
||||
T.eq(check("items", "MY_ITEM", { id = "MY_ITEM", name = "MY ITEM", price = 10 }), true,
|
||||
"a named mod item is accepted")
|
||||
T.eq(check("cries", "FIXMON", { header = {}, pitch = 65536, length = 1 }), nil,
|
||||
"a Gen 2 cry pitch past a word is rejected")
|
||||
T.eq(check("landmarks", "LANDMARK_X", { name = "X", index = 1, x = -9, y = 0 }), nil,
|
||||
"a landmark left of the cart's -8 floor is rejected")
|
||||
T.eq(check("encounters", "timeFishGroups", { [true] = {
|
||||
day = { species = "FIXMON", level = 1 }, nite = { species = "FIXMON", level = 1 } } }), nil,
|
||||
"a timeFishGroups key of the wrong type is rejected")
|
||||
T.eq(check("encounters", "timeFishGroups", { [3] = {
|
||||
day = { species = "FIXMON", level = 1 }, nite = { species = "FIXMON", level = 1 } } }), true,
|
||||
"a numeric timeFishGroups key is accepted")
|
||||
T.eq(check("tilesets", "TILESET_FIXTURE", "assets/generated/tilesets/x.png"), nil,
|
||||
"a non-table tileset record is rejected")
|
||||
local thrown, detail = Schemas.check({ fields = { id = Schemas.f.str },
|
||||
extra = function() error("boom") end }, "probe", "X", { id = "X" }, "register")
|
||||
T.eq(thrown, nil, "a throwing schema hook reports failure")
|
||||
T.check(type(detail) == "string" and detail:find("boom", 1, true) ~= nil,
|
||||
"a throwing schema hook names its error")
|
||||
|
||||
local data = {
|
||||
pokemon = { MEW = { id = "MEW", index = 151 }, tutorMoves = { "FLAMETHROWER" } },
|
||||
items = { LEFTOVERS = { id = "LEFTOVERS", index = 146 },
|
||||
timeCapsule = { [0x19] = "LEFTOVERS" } },
|
||||
}
|
||||
for _, row in ipairs({ { "tutorMoves", "species" }, { "timeCapsule", "item" } }) do
|
||||
local id, reason = Records.nativeId(row[1], row[2], 2, data)
|
||||
T.eq(id, nil, row[1] .. " is not a Gen 2 " .. row[2] .. " id")
|
||||
T.check(type(reason) == "string" and reason:find("reserved", 1, true) ~= nil,
|
||||
row[1] .. " is reserved engine metadata")
|
||||
end
|
||||
local mon = { species = "MEW", level = 5, hp = 10, maxHp = 10, moves = {} }
|
||||
local accepted = Records.applyMon(mon, { species = "tutorMoves" }, 2, data, "crystal")
|
||||
T.eq(accepted, nil, "applyMon refuses tutorMoves as a species")
|
||||
T.eq(mon.species, "MEW", "refused metadata leaves the mon intact")
|
||||
|
||||
GameVersion.set(originalVersion)
|
||||
CacheFs.prefix = originalPrefix
|
||||
T.finish("dataset_views_gen2_metadata")
|
||||
@@ -0,0 +1,123 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Schemas = require("src.mods.Schemas")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
local function gen2Data()
|
||||
local data = T.fixtures.fresh()
|
||||
for _, mon in pairs(data.pokemon) do
|
||||
mon.baseStats.specialAttack, mon.baseStats.specialDefense = mon.baseStats.special, mon.baseStats.special
|
||||
mon.baseStats.special = nil
|
||||
mon.levelMoves, mon.learnset, mon.level1Moves = mon.learnset, nil, nil
|
||||
mon.picSize, mon.frontSize, mon.evolutions = mon.frontSize, nil, {}
|
||||
local dex = mon.dexEntry
|
||||
mon.dexEntry = dex and { kind = dex.kind, text = dex.text, text2 = dex.text2 }
|
||||
end
|
||||
data.pokemon.growthRates = { MEDIUM_SLOW = {
|
||||
numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } }
|
||||
return data
|
||||
end
|
||||
|
||||
local GEN2_BODY = [[
|
||||
local mod = ...
|
||||
local p = mod.content.pokemon
|
||||
mod.exports.theme = mod.content.music:register("FX_THEME", { file = "theme.ogg" }) ~= nil
|
||||
local patched, reason = p:patch("FIXMON_A", { battleScaleBack = 1.5, battleScaleFront = 0.5,
|
||||
battleTheme = "FX_THEME" })
|
||||
mod.exports.patched, mod.exports.reason = patched ~= nil, reason
|
||||
local mon = p:get("FIXMON_A") or {}
|
||||
mod.exports.back, mod.exports.front, mod.exports.bgm = mon.battleScaleBack, mon.battleScaleFront, mon.battleTheme
|
||||
mod.exports.native = mon.native and mon.native.battleScaleBack
|
||||
mon.battleScaleBack = 2
|
||||
mod.exports.roundtrip = p:override("FIXMON_A", mon) ~= nil
|
||||
mod.exports.after = (p:get("FIXMON_A") or {}).battleScaleBack
|
||||
mod.exports.rangeOk = pcall(p.patch, p, "FIXMON_B", { battleScaleBack = 5 })
|
||||
mod.exports.lowOk = pcall(p.patch, p, "FIXMON_B", { battleScaleFront = 0.1 })
|
||||
mod.exports.done = true
|
||||
]]
|
||||
|
||||
local GEN3_BODY = [[
|
||||
local mod = ...
|
||||
local p = mod.content.pokemon
|
||||
for _, key in ipairs({ "battleScaleBack", "battleScaleFront", "battleTheme" }) do
|
||||
local ok, result, reason = pcall(p.patch, p, "BULBASAUR",
|
||||
{ [key] = key == "battleTheme" and "MUS_NONE" or 1.5 })
|
||||
mod.exports[key] = { ok = ok, result = result, reason = reason }
|
||||
end
|
||||
mod.exports.done = true
|
||||
]]
|
||||
|
||||
local function load(files, data, version)
|
||||
T.sdk.captureRuntime()
|
||||
Loader._resetShimWarned()
|
||||
local loader = Loader.new({ fs = T.sdk.memfs(files), dev = true,
|
||||
generation = GameVersion.generation(version), version = version })
|
||||
local ok, result = pcall(loader.load, loader, data)
|
||||
T.sdk.restoreRuntime()
|
||||
T.check(ok, version .. " loader completes: " .. tostring(result))
|
||||
return loader
|
||||
end
|
||||
|
||||
local function manifest(files, body)
|
||||
files["mods/s/manifest.json"] =
|
||||
'{"id":"s","name":"s","version":"1.0.0","api":3,"games":["all"],"entry":"main.lua"}'
|
||||
files["mods/s/main.lua"] = body
|
||||
files["mods/s/theme.ogg"] = "OggS"
|
||||
end
|
||||
|
||||
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
||||
GameVersion.set(version)
|
||||
CacheFs.prefix = GameVersion.cachePrefix(version)
|
||||
local files, data = {}, gen2Data()
|
||||
manifest(files, GEN2_BODY)
|
||||
local loader = load(files, data, version)
|
||||
local e = loader.exports.s or {}
|
||||
T.eq(e.done, true, version .. " mod body ran to the end")
|
||||
T.eq(e.theme, true, version .. " music registered")
|
||||
T.eq(e.patched, true, version .. " api 3 patch of battleScale/battleTheme accepted: " .. tostring(e.reason))
|
||||
T.eq(e.back, 1.5, version .. " get returns battleScaleBack at the top level")
|
||||
T.eq(e.front, 0.5, version .. " get returns battleScaleFront at the top level")
|
||||
T.eq(e.bgm, "FX_THEME", version .. " get returns battleTheme at the top level")
|
||||
T.eq(e.native, nil, version .. " battleScaleBack is not parked under native")
|
||||
T.eq(e.roundtrip, true, version .. " get then override round-trips")
|
||||
T.eq(e.after, 2, version .. " override changes battleScaleBack")
|
||||
T.eq(e.rangeOk, false, version .. " battleScaleBack 5 is rejected")
|
||||
T.eq(e.lowOk, false, version .. " battleScaleFront 0.1 is rejected")
|
||||
local mon = data.pokemon.FIXMON_A
|
||||
T.eq(mon.battleScaleBack, 2, version .. " merged back scale")
|
||||
T.eq(mon.battleScaleFront, 0.5, version .. " merged front scale")
|
||||
T.eq(mon.battleTheme, "FX_THEME", version .. " merged battleTheme")
|
||||
T.eq(data.pokemon.FIXMON_B.battleScaleBack, nil, version .. " rejected write left FIXMON_B alone")
|
||||
local state = setmetatable({ pokemon = data.pokemon, spriteScales = {} }, { __index = BattleState })
|
||||
state.imageScale = function() return nil end
|
||||
local live = { species = "FIXMON_A" }
|
||||
T.eq(state:picScale("back.png", live, true), 2, version .. " battle back pic uses the mod scale")
|
||||
T.eq(state:picScale("front.png", live, false), 0.5, version .. " battle front pic uses the mod scale")
|
||||
T.eq(state:picScale("front.png", { species = "FIXMON_B" }, false), 1, version .. " unpatched species stays 1x")
|
||||
T.eq(World.modBattleTheme({ game = { data = data } }, { wild = { species = "FIXMON_A" } }),
|
||||
"FX_THEME", version .. " wild battle music uses the mod theme")
|
||||
end
|
||||
|
||||
GameVersion.set("firered")
|
||||
CacheFs.prefix = GameVersion.cachePrefix("firered")
|
||||
local g3 = T.sdk.gen3Data()
|
||||
Schemas.bindGen3(g3, GameVersion.get())
|
||||
local files = {}
|
||||
manifest(files, GEN3_BODY)
|
||||
local loader = load(files, g3, "firered")
|
||||
local e = loader.exports.s or {}
|
||||
T.eq(e.done, true, "firered mod body ran to the end")
|
||||
for _, key in ipairs({ "battleScaleBack", "battleScaleFront", "battleTheme" }) do
|
||||
local row = e[key] or {}
|
||||
T.eq(row.ok, true, "firered " .. key .. " does not raise")
|
||||
T.eq(row.result, nil, "firered " .. key .. " is refused")
|
||||
T.eq(row.reason, "unsupported: " .. key .. " has no Gen 3 representation",
|
||||
"firered " .. key .. " refuse reason")
|
||||
end
|
||||
|
||||
T.finish("unified gen2 battle scale")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user