diff --git a/.gitignore b/.gitignore index 69581d85..16d74675 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,9 @@ mobile/ios/bundle_id.local # Local options / preferences /options.lua* + +# User-owned ROMs imported for individual mods. Manifests declare the +# destinations, but source checkouts and packaged mods never ship the files. +/mods/*/baseroms/ +/imports/baseroms/ +/imports/baseroms-recovery/ diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 47c1a5b3..48429c6f 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -486,9 +486,11 @@ has its own entry points for (`start_battle "wild" species level`, `warp`, **by name, before the first row runs**, so a mod never gets a half-run queue. `marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no byte for it) and returns `nil, reason` rather than approximating one. -`availableFieldActions` and `useFieldAction` expose the same contextual -bicycle and fishing records in both games. Each engine keeps ownership of its -inventory, terrain, surfing, bike, and fishing rules. +`availableFieldActions` and `useFieldAction` expose the same contextual field +item and move records in both games. Gold extends the shared ids with its own +`headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and `squirtbottle` +actions. Each engine keeps ownership of its inventory, badges, terrain, +surfing, bike, fishing, and field-move rules. **Hooks and events that fire on Gold.** Every name below is the Gen 1 name carrying the Gen 1 payload keys, because Gold's call sites reuse them rather diff --git a/docs/modding.md b/docs/modding.md index 8a1a287c..36f997c9 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -44,6 +44,25 @@ Every mod contains a root `manifest.json` defining its metadata, supported games "optional_dependencies": [ "gen1_modern_ui" ], + "required_imports": [ + { + "id": "stadium2", + "name": "Pokemon Stadium 2 ROM", + "description": "Pokemon Stadium 2 (USA), any supported N64 byte order", + "file": "stadium2.z64", + "format": "n64", + "size": 67108864, + "md5": ["00000000000000000000000000000000"] + } + ], + "optional_imports": [ + { + "id": "bonus_source", + "name": "Optional bonus source", + "file": "bonus.bin", + "md5": "00000000000000000000000000000000" + } + ], "conflicts": [], "permissions": ["engine_internals"], "description": "A brief description of the mod.", @@ -67,6 +86,8 @@ Every mod contains a root `manifest.json` defining its metadata, supported games | `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). | | `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. | | `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. | +| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. | +| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. | | `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. | | `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). | | `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. | @@ -91,6 +112,42 @@ Dependencies in `dependencies` and `optional_dependencies` can be declared in se #### Version-Scoped Dependencies When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it. +### Required user-supplied files + +`required_imports` and `optional_imports` keep copyrighted or otherwise user-owned source material +out of mod archives while giving every platform the same installation flow. +Each object requires a stable `id`, a display `name`, a destination `file` +(a filename, never a path), and one MD5 digest or an array of accepted MD5 +digests. `format` is either `"raw"` (the default) or `"n64"`. An optional +`description` gives players dump or region guidance in the import panel. +`size` declares the exact canonical byte length; `max_size` declares a smaller +per-import ceiling when an exact size is not appropriate. Every import also +has an engine-enforced 128 MiB ceiling and is rejected before hashing when its +filesystem reports an invalid size. + +For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders, +strips a recognized 512-byte copier header, converts the bytes to canonical +big-endian `.z64` order, and then checks MD5. The canonical bytes are written +to `mods//baseroms/`. Each selection is a private grant to that +mod: the launcher never scans or copies another mod's imported files merely +because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for +example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem +permission is exposed. Missing `required_imports` block the mod before its +entry chunk runs; missing `optional_imports` remain visible in the same +launcher panel but do not block loading. + +MD5 here identifies a known dump because ROM databases commonly publish it; +it is not a security or authenticity guarantee. Do not paste the SHA-1 used by +Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives +must not include anything beneath `baseroms/`. The engine records a validation +receipt keyed by file size and modification time so launcher refreshes and +later boots do not repeatedly hash an unchanged imported ROM. + +New mobile code should call `love.system.pickFile("required_import")`. The +older iOS-only `"stadium"` picker kind remains temporarily for compatibility. +Android now returns `false` for unknown picker kinds instead of treating them +as game-ROM picks. + ## Mods and Gold (Gen 2) The mod API is one API across both generations, but Gold runs its own battle @@ -147,19 +204,23 @@ Companion UIs and alternate party screens can call operation is accepted only during idle overworld play; menus, movement, scripts, battles, and transitions leave the party untouched. -## Contextual field items +## Contextual field actions -`mod.world:availableFieldActions()` returns the field items that can start at -the player's current position. Red and Gold currently expose `bicycle` and -`fish`; fishing rows include the owned rods that are valid choices. The list -is empty while the world is busy, while riding states or terrain forbid an -action, or when the required item is not owned. +`mod.world:availableFieldActions()` returns the field items and moves that can +start at the player's current position. Both games expose `bicycle`, `fish`, +`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally +exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the +contextual `squirtbottle` key item. Fishing rows include the owned rods that +are valid choices. The list is empty while the world is busy, and omits an +action whenever its item, move, badge, terrain, or engine state forbids it. Call `mod.world:useFieldAction(id, opts)` to perform a listed action through the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }` and chooses automatically when only one rod is available. Invalid, stale, and busy requests return `nil` plus a reason without changing game state. Mods do -not need generation-specific bike, collision, or fishing logic. +not need generation-specific badge, terrain, bike, fishing, or field-move +logic. Action lists are extensible; callers should render the records they +understand and ignore unknown ids rather than assuming a fixed list length. ## Rendering pipelines diff --git a/main.lua b/main.lua index 64a9489e..29f1e803 100644 --- a/main.lua +++ b/main.lua @@ -76,15 +76,6 @@ end local editorHost, editorVersion, editorWindow local closeEditor -- forward declaration: openEditor hands it to the editor --- tools/save-editor/ models the Gen 1 save and nothing else: a Gen 2 party row --- carries fields its MonOps and panels have no idea about (dvs, statExp, --- happiness, pokerus, caughtLevel), and SaveIO.save writes the WHOLE table --- back, so a Gold slot opened here comes out in a shape src/core/gen2/Save.lua --- then has to quarantine on the next boot. Refuse by name, the same way the --- .sav paths do (src/save_convert/SaveConvert.lua GEN2_SAV_UNSUPPORTED), so --- Red/Blue/Yellow slots are untouched. -local GEN2_NO_EDITOR = { gold = "Pokemon Gold" } - -- The editor's modules use flat names (require("Kit"), require("Party")), so -- their directories have to be on the require path. It must be -- love.filesystem's path, not package.path: in a packaged build these files @@ -137,11 +128,6 @@ local function openEditor(version, slotId) Importer.saveNotice = Importer.saveNotice or {} Importer.saveNotice[version] = { ok = false, text = text } end - local gen2Name = GEN2_NO_EDITOR[version] - if gen2Name then - refuse(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.") - return - end local SaveData = require("src.core.SaveData") local path = SaveData.slotDiskPath(version, slotId) if not path then @@ -320,14 +306,6 @@ function love.load(args) -- cache has to be mounted before the editor's Data:load. if editorMode then local version = os.getenv("POKEPORT_VERSION") or "red" - local gen2Name = GEN2_NO_EDITOR[version] - if gen2Name then - -- No launcher behind this run to carry a notice, so say it and stop - -- rather than open a Gen 2 slot on Gen 1 panels. - print(gen2Name .. " uses a Gen 2 save; the save editor does not read one yet.") - love.event.quit(1) - return - end require("src.core.GameVersion").set(version) require("src.import.CacheFs").mountVersion(version) addEditorRequirePath() diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index b2b4ac5b..fce90d63 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const dest = "picked_mod.zip"; else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0) dest = "picked_save.sav"; + else if (strcmp(kind, "required_import") == 0) + dest = "picked_required_import.bin"; else if (strcmp(kind, "rom") == 0) dest = "picked_rom.gb"; + // Unknown kinds used to fall through to the ROM destination. Refuse them + // so a newer Lua caller cannot silently route an unrelated file as a ROM. + else + return false; } return love::android::showFilePicker(dest); #else @@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const #endif } +const char *System::pickFileKinds() const +{ +#ifdef LOVE_ANDROID + return "rom,mod,sav,required_import"; +#else + return ""; +#endif +} + bool System::createFile(const char *suggestedName) const { #ifdef LOVE_ANDROID diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index ccf3538d..7ab0484a 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -112,10 +112,12 @@ public: * love::android::showFilePicker and src/import/RomImporter.lua. * * @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb, - * "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav. + * "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav, + * "required_import" -> picked_required_import.bin. * @return Whether the picker was shown. **/ virtual bool pickFile(const char *kind = nullptr) const; + virtual const char *pickFileKinds() const; /** * Shows the platform's native "create / save a file" UI (Android SAF diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index fc8c2240..9dfa18ea 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -102,6 +102,12 @@ int w_pickFile(lua_State *L) return 1; } +int w_pickFileKinds(lua_State *L) +{ + luax_pushstring(L, instance()->pickFileKinds()); + return 1; +} + int w_createFile(lua_State *L) { const char *suggested = luaL_optstring(L, 1, nullptr); @@ -222,6 +228,7 @@ static const luaL_Reg functions[] = { "openURL", w_openURL }, { "vibrate", w_vibrate }, { "pickFile", w_pickFile }, + { "pickFileKinds", w_pickFileKinds }, { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift index c571461b..b51925f6 100644 --- a/mobile/ios/native/GRPickerBridge.swift +++ b/mobile/ios/native/GRPickerBridge.swift @@ -5,7 +5,7 @@ // without updating that patch (see mobile/ios/patch_love_src.py). // // Contract (mirrors love-android's GameActivity.showFilePicker): -// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into +// love.system.pickFile("rom"|"mod"|"sav"|"required_import") -> copies the user's pick into // the LÖVE save directory as picked_rom.gb / picked_mod.zip / // picked_save.sav; RomImporter's pending-file scan consumes it. // love.system.createFile(name) -> exports save dir's pending_export.sav @@ -83,6 +83,8 @@ public final class GRPickerBridge: NSObject { types = [.zip] case "sav": destName = "picked_save.sav" + case "required_import": + destName = "picked_required_import.bin" // A Nintendo 64 cartridge, for mods that build assets out of one -- // the voxel mod's Pokemon Stadium battle models are the caller this // was added for. Its own filename on purpose: an N64 ROM landing on @@ -135,7 +137,7 @@ public final class GRPickerBridge: NSObject { // Kept beside the switch it describes, because the two drifting apart is // the only way this can lie. @objc public static func supportedPickerKinds() -> NSString { - return "rom,mod,sav,stadium" as NSString + return "rom,mod,sav,stadium,required_import" as NSString } @objc(presentExportWithName:saveDir:) diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index e9a90a43..0ddbdc5b 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -89,7 +89,8 @@ int w_pickFile(lua_State *L) return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind); } -// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS. +// love.system.pickFileKinds() -> the comma-separated kinds supported by the +// Swift bridge (including required_import), or nil off iOS. // // So a caller can ask what this build's picker understands BEFORE opening it. // An unknown kind is refused (GRPickerBridge), and a refusal looks exactly diff --git a/scripts/pack_love.sh b/scripts/pack_love.sh index f22b9b74..beff791e 100755 --- a/scripts/pack_love.sh +++ b/scripts/pack_love.sh @@ -51,6 +51,12 @@ rm -f "$OUTPUT" tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \ -x '*.DS_Store' 'data/generated/*' 'assets/generated/*') +if [ -f "$ROOT/PATCH_NOTES.md" ]; then + (cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md) +fi +if [ -f "$ROOT/mobile/ios/app-repo.json" ]; then + (cd "$ROOT" && zip -q "$OUTPUT" mobile/ios/app-repo.json) +fi if [ -n "$BUILD_INFO" ]; then [ -f "$BUILD_INFO" ] || fail "missing build-info: $BUILD_INFO" diff --git a/scripts/test.sh b/scripts/test.sh index 3b4913b2..cec1143c 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -135,6 +135,7 @@ if [ -f data/generated/maps.lua ]; then run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua + run_tier "T3 save editor: gold / gen2" "$LUA" tests/save_editor_gen2_tests.lua run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua run_tier "T3 save editor: pad / NX input" "$LUA" tests/save_editor_pad_input_test.lua run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua diff --git a/src/battle/gen2/Mon.lua b/src/battle/gen2/Mon.lua index 89a37e0c..3caa03f5 100644 --- a/src/battle/gen2/Mon.lua +++ b/src/battle/gen2/Mon.lua @@ -98,10 +98,69 @@ function Mon.stats(baseStats, dvs, level, statExp) } end +-- The species string is the source of truth. `mon.name` is a copy of that +-- species' display name (GetPokemonName), kept so menus can print without a +-- Data lookup. It is NOT the nickname: an un-nicknamed mon has nickname nil +-- and prints this copy. Changing species without rewriting it leaves the +-- previous species' name on the party list and the SUMMARY's top line. +function Mon.syncIdentity(mon, data) + if type(mon) ~= "table" then return mon end + local def = data and data.pokemon and data.pokemon[mon.species] + if not def then return mon end + mon.name = def.name or mon.species + if def.types then mon.types = def.types end + if mon.dvs then + mon.gender = Mon.gender(def, mon.dvs, + { species = mon.species, level = mon.level }) + mon.shiny = Mon.isShiny(mon.dvs, + { species = mon.species, def = def, level = mon.level }) + if mon.species == Unown.SPECIES then + mon.unownLetter = Unown.letterFromDVs(mon.dvs) + else + mon.unownLetter = nil + end + end + return mon +end + +-- Every screen that prints a mon without a Data lookup should go through here: +-- nickname if the player set one, otherwise the species display copy `name`. +-- Skipping `name` and jumping to `species` is how a swapped mon can still +-- read as ABRA on one menu and RAYQUAZA on another. +function Mon.displayName(mon) + if type(mon) ~= "table" then return "?" end + return mon.nickname or mon.name or mon.species or "?" +end + +-- Party, boxes, both Day-Care sides, and a pending egg. Editor CONTINUE and +-- hydrate have to walk the same set: leaving dayCare.man.mon on the old +-- `name` is the ABRA bug in a second closet. +function Mon.eachSaveMon(save, fn) + if type(save) ~= "table" or type(fn) ~= "function" then return end + for _, mon in ipairs(save.party or {}) do fn(mon) end + for _, box in pairs(save.boxes or {}) do + if type(box) == "table" then + for _, mon in ipairs(box) do fn(mon) end + end + end + local dc = save.dayCare + if type(dc) == "table" then + if dc.man and dc.man.mon then fn(dc.man.mon) end + if dc.lady and dc.lady.mon then fn(dc.lady.mon) end + if dc.egg then fn(dc.egg) end + end + if save.daycare and save.daycare.mon then fn(save.daycare.mon) end +end + +function Mon.syncSaveIdentity(save, data) + Mon.eachSaveMon(save, function(mon) Mon.syncIdentity(mon, data) end) +end + function Mon.refreshStats(mon, data) if type(mon) ~= "table" then return mon end local def = data and data.pokemon and data.pokemon[mon.species] if not (def and def.baseStats) then return mon end + Mon.syncIdentity(mon, data) -- engine/pokemon/move_mon.asm:1402 local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp) mon.stats = stats diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 7532437d..8533b903 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -283,6 +283,9 @@ function Game2:continueGame(save) local modsDiff = SaveData.modsDiff(save, activeMods) self.save = save self:adoptSave(save) + -- Editor species swaps used to leave mon.name on the previous species. + -- CONTINUE rewrites party, boxes, and Day-Care copies from the live record. + require("src.battle.gen2.Mon").syncSaveIdentity(save, self.data) -- options.lua wins over anything a save file carries: options are a display -- preference that survives New Game and is edited from the launcher, so a -- save written before they moved out must not drag old values back in. @@ -588,13 +591,13 @@ function Game2:useFieldItem(itemId) end if not allowed then self:say(("%s can't learn %s!"):format( - mon.nickname or mon.species or "?", moveName)) + require("src.battle.gen2.Mon").displayName(mon), moveName)) return end for _, move in ipairs(mon.moves or {}) do if move.id == moveId then self:say(("%s already knows %s!"):format( - mon.nickname or mon.species or "?", moveName)) + require("src.battle.gen2.Mon").displayName(mon), moveName)) return end end diff --git a/src/core/gen2/FlagNames.lua b/src/core/gen2/FlagNames.lua new file mode 100644 index 00000000..0d4e44b1 --- /dev/null +++ b/src/core/gen2/FlagNames.lua @@ -0,0 +1,1340 @@ +-- Generated by tools/goldwalk/gen_flags.lua from pret/pokegold. Do not edit. +-- +-- EVENT_* / ENGINE_* name -> the numeric id the cart's scripts carry, which is +-- what src/world/gen2/Events.lua and World:engineFlag key on. Verified against +-- the ROM by tests/gold_flag_names_test.lua. +return { + source = "pret/pokegold constants/{event,engine}_flags.asm", + events = { + EVENT_ALAN_ASKED_FOR_PHONE_NUMBER = 645, + EVENT_ALAN_READY_FOR_REMATCH = 646, + EVENT_ANTHONY_ASKED_FOR_PHONE_NUMBER = 635, + EVENT_ANTHONY_READY_FOR_REMATCH = 636, + EVENT_ARNIE_ASKED_FOR_PHONE_NUMBER = 643, + EVENT_ARNIE_READY_FOR_REMATCH = 644, + EVENT_AZALEA_TOWN_HIDDEN_FULL_HEAL = 177, + EVENT_AZALEA_TOWN_SLOWPOKES = 1785, + EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET = 1786, + EVENT_BEAT_BEAUTY_ANGELICA = 1207, + EVENT_BEAT_BEAUTY_BRENDA = 1201, + EVENT_BEAT_BEAUTY_CARLENE = 1204, + EVENT_BEAT_BEAUTY_CAROLINE = 1203, + EVENT_BEAT_BEAUTY_CASSIE = 1202, + EVENT_BEAT_BEAUTY_JACLYN = 1200, + EVENT_BEAT_BEAUTY_JESSICA = 1205, + EVENT_BEAT_BEAUTY_JULIA = 1210, + EVENT_BEAT_BEAUTY_JULIE = 1199, + EVENT_BEAT_BEAUTY_KENDRA = 1208, + EVENT_BEAT_BEAUTY_RACHAEL = 1206, + EVENT_BEAT_BEAUTY_SAMANTHA = 1198, + EVENT_BEAT_BEAUTY_THERESA = 1211, + EVENT_BEAT_BEAUTY_VALERIE = 1212, + EVENT_BEAT_BEAUTY_VERONICA = 1209, + EVENT_BEAT_BEAUTY_VICTORIA = 1197, + EVENT_BEAT_BIKER_BENNY = 1073, + EVENT_BEAT_BIKER_CHARLES = 1078, + EVENT_BEAT_BIKER_DWAYNE = 1075, + EVENT_BEAT_BIKER_GLENN = 1081, + EVENT_BEAT_BIKER_HARRIS = 1076, + EVENT_BEAT_BIKER_JOEL = 1080, + EVENT_BEAT_BIKER_KAZU = 1074, + EVENT_BEAT_BIKER_RILEY = 1079, + EVENT_BEAT_BIKER_ZEKE = 1077, + EVENT_BEAT_BIRD_KEEPER_ABE = 1020, + EVENT_BEAT_BIRD_KEEPER_BOB = 1029, + EVENT_BEAT_BIRD_KEEPER_BORIS = 1028, + EVENT_BEAT_BIRD_KEEPER_BRET = 1034, + EVENT_BEAT_BIRD_KEEPER_BRYAN = 1021, + EVENT_BEAT_BIRD_KEEPER_DENIS = 1024, + EVENT_BEAT_BIRD_KEEPER_HANK = 1026, + EVENT_BEAT_BIRD_KEEPER_JOSE = 1030, + EVENT_BEAT_BIRD_KEEPER_JOSE2 = 1032, + EVENT_BEAT_BIRD_KEEPER_JOSE3 = 1035, + EVENT_BEAT_BIRD_KEEPER_PERRY = 1033, + EVENT_BEAT_BIRD_KEEPER_PETER = 1031, + EVENT_BEAT_BIRD_KEEPER_ROD = 1019, + EVENT_BEAT_BIRD_KEEPER_ROY = 1027, + EVENT_BEAT_BIRD_KEEPER_THEO = 1022, + EVENT_BEAT_BIRD_KEEPER_TOBY = 1023, + EVENT_BEAT_BIRD_KEEPER_VANCE = 1025, + EVENT_BEAT_BIRD_KEEPER_VANCE2 = 1036, + EVENT_BEAT_BIRD_KEEPER_VANCE3 = 1037, + EVENT_BEAT_BLACKBELT_KENJI = 1195, + EVENT_BEAT_BLACKBELT_KENJI2 = 1188, + EVENT_BEAT_BLACKBELT_KENJI3 = 1190, + EVENT_BEAT_BLACKBELT_KIYO = 1193, + EVENT_BEAT_BLACKBELT_LAO = 1191, + EVENT_BEAT_BLACKBELT_LUNG = 1194, + EVENT_BEAT_BLACKBELT_NOB = 1192, + EVENT_BEAT_BLACKBELT_WAI = 1196, + EVENT_BEAT_BLACKBELT_YOSHI = 1189, + EVENT_BEAT_BLAINE = 1227, + EVENT_BEAT_BLUE = 1228, + EVENT_BEAT_BOARDER_BRAD = 1039, + EVENT_BEAT_BOARDER_DOUGLAS = 1040, + EVENT_BEAT_BOARDER_RONALD = 1038, + EVENT_BEAT_BROCK = 1221, + EVENT_BEAT_BUGSY = 1214, + EVENT_BEAT_BUG_CATCHER_AL = 1341, + EVENT_BEAT_BUG_CATCHER_ARNIE = 1343, + EVENT_BEAT_BUG_CATCHER_ARNIE2 = 1348, + EVENT_BEAT_BUG_CATCHER_ARNIE3 = 1349, + EVENT_BEAT_BUG_CATCHER_BENNY = 1340, + EVENT_BEAT_BUG_CATCHER_DON = 1336, + EVENT_BEAT_BUG_CATCHER_DOUG = 1347, + EVENT_BEAT_BUG_CATCHER_ED = 1338, + EVENT_BEAT_BUG_CATCHER_JOSH = 1342, + EVENT_BEAT_BUG_CATCHER_KEN = 1344, + EVENT_BEAT_BUG_CATCHER_ROB = 1337, + EVENT_BEAT_BUG_CATCHER_WADE = 1339, + EVENT_BEAT_BUG_CATCHER_WADE2 = 1345, + EVENT_BEAT_BUG_CATCHER_WADE3 = 1346, + EVENT_BEAT_BURGLAR_COREY = 1071, + EVENT_BEAT_BURGLAR_DUNCAN = 1069, + EVENT_BEAT_BURGLAR_EDDIE = 1070, + EVENT_BEAT_CAMPER_BARRY = 1054, + EVENT_BEAT_CAMPER_DALE = 1059, + EVENT_BEAT_CAMPER_DAVID = 1065, + EVENT_BEAT_CAMPER_DEAN = 1056, + EVENT_BEAT_CAMPER_ELLIOT = 1053, + EVENT_BEAT_CAMPER_HERVEY = 1058, + EVENT_BEAT_CAMPER_IVAN = 1052, + EVENT_BEAT_CAMPER_JERRY = 1067, + EVENT_BEAT_CAMPER_JOHN = 1066, + EVENT_BEAT_CAMPER_LEROY = 1064, + EVENT_BEAT_CAMPER_LLOYD = 1055, + EVENT_BEAT_CAMPER_ROLAND = 1050, + EVENT_BEAT_CAMPER_SID = 1057, + EVENT_BEAT_CAMPER_SPENCER = 1068, + EVENT_BEAT_CAMPER_TED = 1060, + EVENT_BEAT_CAMPER_THOMAS = 1063, + EVENT_BEAT_CAMPER_TODD = 1051, + EVENT_BEAT_CAMPER_TODD2 = 1061, + EVENT_BEAT_CAMPER_TODD3 = 1062, + EVENT_BEAT_CHAMPION_LANCE = 1468, + EVENT_BEAT_CHUCK = 1218, + EVENT_BEAT_CLAIR = 1220, + EVENT_BEAT_COOLTRAINERF_BETH = 1379, + EVENT_BEAT_COOLTRAINERF_BETH2 = 1382, + EVENT_BEAT_COOLTRAINERF_BETH3 = 1388, + EVENT_BEAT_COOLTRAINERF_CAROL = 1383, + EVENT_BEAT_COOLTRAINERF_CYBIL = 1386, + EVENT_BEAT_COOLTRAINERF_EMMA = 1385, + EVENT_BEAT_COOLTRAINERF_FRAN = 1373, + EVENT_BEAT_COOLTRAINERF_GWEN = 1371, + EVENT_BEAT_COOLTRAINERF_IRENE = 1376, + EVENT_BEAT_COOLTRAINERF_JENN = 1387, + EVENT_BEAT_COOLTRAINERF_JOYCE = 1378, + EVENT_BEAT_COOLTRAINERF_KATE = 1375, + EVENT_BEAT_COOLTRAINERF_KELLY = 1377, + EVENT_BEAT_COOLTRAINERF_LOIS = 1372, + EVENT_BEAT_COOLTRAINERF_LOLA = 1374, + EVENT_BEAT_COOLTRAINERF_MEGAN = 1381, + EVENT_BEAT_COOLTRAINERF_QUINN = 1384, + EVENT_BEAT_COOLTRAINERF_REENA = 1380, + EVENT_BEAT_COOLTRAINERF_REENA2 = 1389, + EVENT_BEAT_COOLTRAINERF_REENA3 = 1390, + EVENT_BEAT_COOLTRAINERM_AARON = 1353, + EVENT_BEAT_COOLTRAINERM_ALLEN = 1370, + EVENT_BEAT_COOLTRAINERM_ANDY = 1365, + EVENT_BEAT_COOLTRAINERM_BLAKE = 1362, + EVENT_BEAT_COOLTRAINERM_BRIAN = 1363, + EVENT_BEAT_COOLTRAINERM_CODY = 1355, + EVENT_BEAT_COOLTRAINERM_ERICK = 1364, + EVENT_BEAT_COOLTRAINERM_GAVEN = 1361, + EVENT_BEAT_COOLTRAINERM_GAVEN2 = 1357, + EVENT_BEAT_COOLTRAINERM_GAVEN3 = 1358, + EVENT_BEAT_COOLTRAINERM_JAKE = 1360, + EVENT_BEAT_COOLTRAINERM_KEVIN = 1368, + EVENT_BEAT_COOLTRAINERM_MIKE = 1356, + EVENT_BEAT_COOLTRAINERM_NICK = 1352, + EVENT_BEAT_COOLTRAINERM_PAUL = 1354, + EVENT_BEAT_COOLTRAINERM_RYAN = 1359, + EVENT_BEAT_COOLTRAINERM_SEAN = 1367, + EVENT_BEAT_COOLTRAINERM_STEVE = 1369, + EVENT_BEAT_COOLTRAINERM_TYLER = 1366, + EVENT_BEAT_ELITE_4_BRUNO = 1466, + EVENT_BEAT_ELITE_4_KAREN = 1467, + EVENT_BEAT_ELITE_4_KOGA = 1465, + EVENT_BEAT_ELITE_4_WILL = 1464, + EVENT_BEAT_ELITE_FOUR = 68, + EVENT_BEAT_ERIKA = 1224, + EVENT_BEAT_FALKNER = 1213, + EVENT_BEAT_FIREBREATHER_BILL = 1098, + EVENT_BEAT_FIREBREATHER_BURT = 1097, + EVENT_BEAT_FIREBREATHER_DICK = 1095, + EVENT_BEAT_FIREBREATHER_LYLE = 1101, + EVENT_BEAT_FIREBREATHER_NED = 1096, + EVENT_BEAT_FIREBREATHER_OTIS = 1094, + EVENT_BEAT_FIREBREATHER_RAY = 1100, + EVENT_BEAT_FIREBREATHER_WALT = 1099, + EVENT_BEAT_FISHER_ANDRE = 1109, + EVENT_BEAT_FISHER_ARNOLD = 1104, + EVENT_BEAT_FISHER_BARNEY = 1116, + EVENT_BEAT_FISHER_CHRIS = 1108, + EVENT_BEAT_FISHER_CHRIS2 = 1119, + EVENT_BEAT_FISHER_CHRIS3 = 1120, + EVENT_BEAT_FISHER_EDGAR = 1112, + EVENT_BEAT_FISHER_HENRY = 1106, + EVENT_BEAT_FISHER_JONAH = 1113, + EVENT_BEAT_FISHER_JUSTIN = 1102, + EVENT_BEAT_FISHER_KYLE = 1105, + EVENT_BEAT_FISHER_MARTIN = 1114, + EVENT_BEAT_FISHER_MARVIN = 1107, + EVENT_BEAT_FISHER_RALPH = 1103, + EVENT_BEAT_FISHER_RALPH2 = 1117, + EVENT_BEAT_FISHER_RALPH3 = 1118, + EVENT_BEAT_FISHER_RAYMOND = 1110, + EVENT_BEAT_FISHER_SCOTT = 1122, + EVENT_BEAT_FISHER_STEPHEN = 1115, + EVENT_BEAT_FISHER_WILTON = 1111, + EVENT_BEAT_FISHER_WILTON2 = 1121, + EVENT_BEAT_FISHER_WILTON3 = 1123, + EVENT_BEAT_GENTLEMAN_ALFRED = 1182, + EVENT_BEAT_GENTLEMAN_EDWARD = 1179, + EVENT_BEAT_GENTLEMAN_GREGORY = 1180, + EVENT_BEAT_GENTLEMAN_PRESTON = 1178, + EVENT_BEAT_GENTLEMAN_VIRGIL = 1181, + EVENT_BEAT_GUITARIST_CLYDE = 1171, + EVENT_BEAT_GUITARIST_VINCENT = 1172, + EVENT_BEAT_HIKER_ANTHONY = 1320, + EVENT_BEAT_HIKER_ANTHONY2 = 1316, + EVENT_BEAT_HIKER_ANTHONY3 = 1327, + EVENT_BEAT_HIKER_BAILEY = 1326, + EVENT_BEAT_HIKER_BENJAMIN = 1321, + EVENT_BEAT_HIKER_DANIEL = 1333, + EVENT_BEAT_HIKER_ERIK = 1322, + EVENT_BEAT_HIKER_JIM = 1332, + EVENT_BEAT_HIKER_KENNY = 1331, + EVENT_BEAT_HIKER_LEONARD = 1319, + EVENT_BEAT_HIKER_MICHAEL = 1323, + EVENT_BEAT_HIKER_NOLAND = 1329, + EVENT_BEAT_HIKER_PARRY = 1324, + EVENT_BEAT_HIKER_PARRY2 = 1334, + EVENT_BEAT_HIKER_PARRY3 = 1335, + EVENT_BEAT_HIKER_PHILLIP = 1318, + EVENT_BEAT_HIKER_RUSSELL = 1317, + EVENT_BEAT_HIKER_SIDNEY = 1330, + EVENT_BEAT_HIKER_TIM = 1328, + EVENT_BEAT_HIKER_TIMOTHY = 1325, + EVENT_BEAT_JANINE = 1225, + EVENT_BEAT_JASMINE = 1217, + EVENT_BEAT_JUGGLER_FRITZ = 1174, + EVENT_BEAT_JUGGLER_HORTON = 1175, + EVENT_BEAT_JUGGLER_IRWIN = 1173, + EVENT_BEAT_JUGGLER_IRWIN2 = 1176, + EVENT_BEAT_JUGGLER_IRWIN3 = 1177, + EVENT_BEAT_KIMONO_GIRL_KUNI = 1250, + EVENT_BEAT_KIMONO_GIRL_MIKI = 1251, + EVENT_BEAT_KIMONO_GIRL_NAOKO = 1247, + EVENT_BEAT_KIMONO_GIRL_SAYO = 1248, + EVENT_BEAT_KIMONO_GIRL_ZUKI = 1249, + EVENT_BEAT_LASS_ALICE = 1303, + EVENT_BEAT_LASS_BRIDGET = 1302, + EVENT_BEAT_LASS_CARRIE = 1301, + EVENT_BEAT_LASS_CONNIE = 1305, + EVENT_BEAT_LASS_CONNIE2 = 1312, + EVENT_BEAT_LASS_CONNIE3 = 1313, + EVENT_BEAT_LASS_DANA = 1310, + EVENT_BEAT_LASS_DANA2 = 1314, + EVENT_BEAT_LASS_DANA3 = 1315, + EVENT_BEAT_LASS_ELLEN = 1311, + EVENT_BEAT_LASS_KRISE = 1304, + EVENT_BEAT_LASS_LAURA = 1307, + EVENT_BEAT_LASS_LINDA = 1306, + EVENT_BEAT_LASS_MICHELLE = 1309, + EVENT_BEAT_LASS_SHANNON = 1308, + EVENT_BEAT_LTSURGE = 1223, + EVENT_BEAT_MEDIUM_BETHANY = 1421, + EVENT_BEAT_MEDIUM_DORIS = 1425, + EVENT_BEAT_MEDIUM_ETHEL = 1423, + EVENT_BEAT_MEDIUM_GRACE = 1420, + EVENT_BEAT_MEDIUM_MARGRET = 1422, + EVENT_BEAT_MEDIUM_MARTHA = 1419, + EVENT_BEAT_MEDIUM_REBECCA = 1424, + EVENT_BEAT_MISTY = 1222, + EVENT_BEAT_MORTY = 1216, + EVENT_BEAT_OFFICER_DIRK = 1351, + EVENT_BEAT_OFFICER_KEITH = 1350, + EVENT_BEAT_PICNICKER_BROOKE = 1152, + EVENT_BEAT_PICNICKER_CINDY = 1154, + EVENT_BEAT_PICNICKER_DEBRA = 1157, + EVENT_BEAT_PICNICKER_EDNA = 1163, + EVENT_BEAT_PICNICKER_ERIN = 1159, + EVENT_BEAT_PICNICKER_ERIN2 = 1167, + EVENT_BEAT_PICNICKER_ERIN3 = 1170, + EVENT_BEAT_PICNICKER_GINA = 1151, + EVENT_BEAT_PICNICKER_GINA2 = 1158, + EVENT_BEAT_PICNICKER_GINA3 = 1164, + EVENT_BEAT_PICNICKER_HEIDI = 1162, + EVENT_BEAT_PICNICKER_HOPE = 1155, + EVENT_BEAT_PICNICKER_KIM = 1153, + EVENT_BEAT_PICNICKER_LIZ = 1150, + EVENT_BEAT_PICNICKER_LIZ2 = 1160, + EVENT_BEAT_PICNICKER_LIZ3 = 1161, + EVENT_BEAT_PICNICKER_SHARON = 1156, + EVENT_BEAT_PICNICKER_TANYA = 1168, + EVENT_BEAT_PICNICKER_TIFFANY = 1169, + EVENT_BEAT_PICNICKER_TIFFANY2 = 1165, + EVENT_BEAT_PICNICKER_TIFFANY3 = 1166, + EVENT_BEAT_POKEFANF_BEVERLY = 1241, + EVENT_BEAT_POKEFANF_BEVERLY2 = 1243, + EVENT_BEAT_POKEFANF_BEVERLY3 = 1244, + EVENT_BEAT_POKEFANF_GEORGIA = 1245, + EVENT_BEAT_POKEFANF_RUTH = 1242, + EVENT_BEAT_POKEFANM_ALEX = 1240, + EVENT_BEAT_POKEFANM_BRANDON = 1235, + EVENT_BEAT_POKEFANM_CARTER = 1233, + EVENT_BEAT_POKEFANM_COLIN = 1237, + EVENT_BEAT_POKEFANM_DEREK = 1230, + EVENT_BEAT_POKEFANM_DEREK2 = 1238, + EVENT_BEAT_POKEFANM_DEREK3 = 1239, + EVENT_BEAT_POKEFANM_JEREMY = 1236, + EVENT_BEAT_POKEFANM_JOSHUA = 1232, + EVENT_BEAT_POKEFANM_ROBERT = 1231, + EVENT_BEAT_POKEFANM_TREVOR = 1234, + EVENT_BEAT_POKEFANM_WILLIAM = 1229, + EVENT_BEAT_POKEMANIAC_ANDREW = 1253, + EVENT_BEAT_POKEMANIAC_BEN = 1256, + EVENT_BEAT_POKEMANIAC_BRENT = 1257, + EVENT_BEAT_POKEMANIAC_BRENT2 = 1260, + EVENT_BEAT_POKEMANIAC_BRENT3 = 1261, + EVENT_BEAT_POKEMANIAC_CALVIN = 1254, + EVENT_BEAT_POKEMANIAC_DONALD = 1263, + EVENT_BEAT_POKEMANIAC_ETHAN = 1259, + EVENT_BEAT_POKEMANIAC_ISSAC = 1262, + EVENT_BEAT_POKEMANIAC_LARRY = 1252, + EVENT_BEAT_POKEMANIAC_RON = 1258, + EVENT_BEAT_POKEMANIAC_SHANE = 1255, + EVENT_BEAT_POKEMANIAC_ZACH = 1264, + EVENT_BEAT_PRYCE = 1219, + EVENT_BEAT_PSYCHIC_FIDEL = 1085, + EVENT_BEAT_PSYCHIC_FRANKLIN = 1083, + EVENT_BEAT_PSYCHIC_GILBERT = 1091, + EVENT_BEAT_PSYCHIC_GREG = 1086, + EVENT_BEAT_PSYCHIC_HERMAN = 1084, + EVENT_BEAT_PSYCHIC_JARED = 1092, + EVENT_BEAT_PSYCHIC_MARK = 1088, + EVENT_BEAT_PSYCHIC_NATHAN = 1082, + EVENT_BEAT_PSYCHIC_NORMAN = 1087, + EVENT_BEAT_PSYCHIC_PHIL = 1089, + EVENT_BEAT_PSYCHIC_RICHARD = 1090, + EVENT_BEAT_PSYCHIC_RODNEY = 1093, + EVENT_BEAT_RIVAL_IN_MT_MOON = 793, + EVENT_BEAT_ROCKET_EXECUTIVEF_1 = 1391, + EVENT_BEAT_ROCKET_EXECUTIVEF_2 = 1392, + EVENT_BEAT_ROCKET_EXECUTIVEM_1 = 1393, + EVENT_BEAT_ROCKET_EXECUTIVEM_2 = 1394, + EVENT_BEAT_ROCKET_EXECUTIVEM_3 = 1395, + EVENT_BEAT_ROCKET_EXECUTIVEM_4 = 1396, + EVENT_BEAT_ROCKET_GRUNTF_1 = 1296, + EVENT_BEAT_ROCKET_GRUNTF_2 = 1297, + EVENT_BEAT_ROCKET_GRUNTF_3 = 1298, + EVENT_BEAT_ROCKET_GRUNTF_4 = 1299, + EVENT_BEAT_ROCKET_GRUNTF_5 = 1300, + EVENT_BEAT_ROCKET_GRUNTM_1 = 1265, + EVENT_BEAT_ROCKET_GRUNTM_10 = 1274, + EVENT_BEAT_ROCKET_GRUNTM_11 = 1275, + EVENT_BEAT_ROCKET_GRUNTM_12 = 1276, + EVENT_BEAT_ROCKET_GRUNTM_13 = 1277, + EVENT_BEAT_ROCKET_GRUNTM_14 = 1278, + EVENT_BEAT_ROCKET_GRUNTM_15 = 1279, + EVENT_BEAT_ROCKET_GRUNTM_16 = 1280, + EVENT_BEAT_ROCKET_GRUNTM_17 = 1281, + EVENT_BEAT_ROCKET_GRUNTM_18 = 1282, + EVENT_BEAT_ROCKET_GRUNTM_19 = 1283, + EVENT_BEAT_ROCKET_GRUNTM_2 = 1266, + EVENT_BEAT_ROCKET_GRUNTM_20 = 1284, + EVENT_BEAT_ROCKET_GRUNTM_21 = 1285, + EVENT_BEAT_ROCKET_GRUNTM_22 = 1286, + EVENT_BEAT_ROCKET_GRUNTM_23 = 1287, + EVENT_BEAT_ROCKET_GRUNTM_24 = 1288, + EVENT_BEAT_ROCKET_GRUNTM_25 = 1289, + EVENT_BEAT_ROCKET_GRUNTM_26 = 1290, + EVENT_BEAT_ROCKET_GRUNTM_27 = 1291, + EVENT_BEAT_ROCKET_GRUNTM_28 = 1292, + EVENT_BEAT_ROCKET_GRUNTM_29 = 1293, + EVENT_BEAT_ROCKET_GRUNTM_3 = 1267, + EVENT_BEAT_ROCKET_GRUNTM_30 = 1294, + EVENT_BEAT_ROCKET_GRUNTM_31 = 1295, + EVENT_BEAT_ROCKET_GRUNTM_4 = 1268, + EVENT_BEAT_ROCKET_GRUNTM_5 = 1269, + EVENT_BEAT_ROCKET_GRUNTM_6 = 1270, + EVENT_BEAT_ROCKET_GRUNTM_7 = 1271, + EVENT_BEAT_ROCKET_GRUNTM_8 = 1272, + EVENT_BEAT_ROCKET_GRUNTM_9 = 1273, + EVENT_BEAT_SABRINA = 1226, + EVENT_BEAT_SAGE_CHOW = 1041, + EVENT_BEAT_SAGE_EDMOND = 1047, + EVENT_BEAT_SAGE_JEFFREY = 1045, + EVENT_BEAT_SAGE_JIN = 1043, + EVENT_BEAT_SAGE_LI = 1049, + EVENT_BEAT_SAGE_NEAL = 1048, + EVENT_BEAT_SAGE_NICO = 1042, + EVENT_BEAT_SAGE_PING = 1046, + EVENT_BEAT_SAGE_TROY = 1044, + EVENT_BEAT_SAILOR_ERNEST = 1401, + EVENT_BEAT_SAILOR_EUGENE = 1397, + EVENT_BEAT_SAILOR_GARRETT = 1403, + EVENT_BEAT_SAILOR_HARRY = 1406, + EVENT_BEAT_SAILOR_HUEY = 1398, + EVENT_BEAT_SAILOR_HUEY2 = 1407, + EVENT_BEAT_SAILOR_HUEY3 = 1408, + EVENT_BEAT_SAILOR_JEFF = 1402, + EVENT_BEAT_SAILOR_KENNETH = 1404, + EVENT_BEAT_SAILOR_KENT = 1400, + EVENT_BEAT_SAILOR_STANLY = 1405, + EVENT_BEAT_SAILOR_TERRELL = 1399, + EVENT_BEAT_SCHOOLBOY_ALAN = 1134, + EVENT_BEAT_SCHOOLBOY_ALAN2 = 1146, + EVENT_BEAT_SCHOOLBOY_ALAN3 = 1147, + EVENT_BEAT_SCHOOLBOY_BILLY = 1140, + EVENT_BEAT_SCHOOLBOY_CHAD = 1141, + EVENT_BEAT_SCHOOLBOY_CHAD2 = 1148, + EVENT_BEAT_SCHOOLBOY_CHAD3 = 1149, + EVENT_BEAT_SCHOOLBOY_DANNY = 1136, + EVENT_BEAT_SCHOOLBOY_DUDLEY = 1138, + EVENT_BEAT_SCHOOLBOY_JACK = 1132, + EVENT_BEAT_SCHOOLBOY_JACK2 = 1144, + EVENT_BEAT_SCHOOLBOY_JACK3 = 1145, + EVENT_BEAT_SCHOOLBOY_JOE = 1139, + EVENT_BEAT_SCHOOLBOY_JOHNNY = 1135, + EVENT_BEAT_SCHOOLBOY_KIP = 1133, + EVENT_BEAT_SCHOOLBOY_NATE = 1142, + EVENT_BEAT_SCHOOLBOY_RICKY = 1143, + EVENT_BEAT_SCHOOLBOY_TOMMY = 1137, + EVENT_BEAT_SCIENTIST_JED = 1185, + EVENT_BEAT_SCIENTIST_MARC = 1186, + EVENT_BEAT_SCIENTIST_MITCH = 1184, + EVENT_BEAT_SCIENTIST_RICH = 1187, + EVENT_BEAT_SCIENTIST_ROSS = 1183, + EVENT_BEAT_SKIER_CLARISSA = 1427, + EVENT_BEAT_SKIER_ROXANNE = 1426, + EVENT_BEAT_SUPER_NERD_ERIC = 1410, + EVENT_BEAT_SUPER_NERD_ERIC_UNUSED = 1409, + EVENT_BEAT_SUPER_NERD_GREGG = 1411, + EVENT_BEAT_SUPER_NERD_JAY = 1412, + EVENT_BEAT_SUPER_NERD_PAT = 1416, + EVENT_BEAT_SUPER_NERD_SAM = 1414, + EVENT_BEAT_SUPER_NERD_SHAWN = 1417, + EVENT_BEAT_SUPER_NERD_TERU = 1418, + EVENT_BEAT_SUPER_NERD_TOM = 1415, + EVENT_BEAT_SWIMMERF_BRIANA = 1018, + EVENT_BEAT_SWIMMERF_DAWN = 1011, + EVENT_BEAT_SWIMMERF_DENISE = 1004, + EVENT_BEAT_SWIMMERF_DIANA = 1017, + EVENT_BEAT_SWIMMERF_ELAINE = 1000, + EVENT_BEAT_SWIMMERF_JILL = 1008, + EVENT_BEAT_SWIMMERF_JODY = 1015, + EVENT_BEAT_SWIMMERF_KARA = 1005, + EVENT_BEAT_SWIMMERF_KATIE = 1010, + EVENT_BEAT_SWIMMERF_KAYLEE = 1002, + EVENT_BEAT_SWIMMERF_LISA = 1007, + EVENT_BEAT_SWIMMERF_LORI = 1014, + EVENT_BEAT_SWIMMERF_MARY = 1009, + EVENT_BEAT_SWIMMERF_NICOLE = 1013, + EVENT_BEAT_SWIMMERF_NIKKI = 1016, + EVENT_BEAT_SWIMMERF_PAULA = 1001, + EVENT_BEAT_SWIMMERF_SUSIE = 1003, + EVENT_BEAT_SWIMMERF_TARA = 1012, + EVENT_BEAT_SWIMMERF_WENDY = 1006, + EVENT_BEAT_SWIMMERM_BERKE = 1433, + EVENT_BEAT_SWIMMERM_CAMERON = 1444, + EVENT_BEAT_SWIMMERM_CHARLIE = 1431, + EVENT_BEAT_SWIMMERM_DARYL = 1438, + EVENT_BEAT_SWIMMERM_GEORGE = 1432, + EVENT_BEAT_SWIMMERM_HAL = 1436, + EVENT_BEAT_SWIMMERM_HAROLD = 1428, + EVENT_BEAT_SWIMMERM_JAMES = 1446, + EVENT_BEAT_SWIMMERM_JEROME = 1441, + EVENT_BEAT_SWIMMERM_KIRK = 1434, + EVENT_BEAT_SWIMMERM_LEWIS = 1447, + EVENT_BEAT_SWIMMERM_MATHEW = 1435, + EVENT_BEAT_SWIMMERM_PARKER = 1448, + EVENT_BEAT_SWIMMERM_PATON = 1437, + EVENT_BEAT_SWIMMERM_RANDALL = 1430, + EVENT_BEAT_SWIMMERM_RICK = 1443, + EVENT_BEAT_SWIMMERM_SETH = 1445, + EVENT_BEAT_SWIMMERM_SIMON = 1429, + EVENT_BEAT_SWIMMERM_TONY = 1440, + EVENT_BEAT_SWIMMERM_TUCKER = 1442, + EVENT_BEAT_SWIMMERM_WALTER = 1439, + EVENT_BEAT_TEACHER_COLETTE = 1461, + EVENT_BEAT_TEACHER_HILLARY = 1462, + EVENT_BEAT_TEACHER_SHIRLEY = 1463, + EVENT_BEAT_TWINS_AMY_AND_MAY = 1124, + EVENT_BEAT_TWINS_AMY_AND_MAY2 = 1127, + EVENT_BEAT_TWINS_ANN_AND_ANNE = 1125, + EVENT_BEAT_TWINS_ANN_AND_ANNE2 = 1126, + EVENT_BEAT_TWINS_JO_AND_ZOE = 1128, + EVENT_BEAT_TWINS_JO_AND_ZOE2 = 1129, + EVENT_BEAT_TWINS_MEG_AND_PEG = 1130, + EVENT_BEAT_TWINS_MEG_AND_PEG2 = 1131, + EVENT_BEAT_WHITNEY = 1215, + EVENT_BEAT_YOUNGSTER_ALBERT = 1451, + EVENT_BEAT_YOUNGSTER_GORDON = 1452, + EVENT_BEAT_YOUNGSTER_IAN = 1454, + EVENT_BEAT_YOUNGSTER_JASON = 1460, + EVENT_BEAT_YOUNGSTER_JIMMY = 1458, + EVENT_BEAT_YOUNGSTER_JOEY = 1449, + EVENT_BEAT_YOUNGSTER_JOEY2 = 1455, + EVENT_BEAT_YOUNGSTER_JOEY3 = 1456, + EVENT_BEAT_YOUNGSTER_MIKEY = 1450, + EVENT_BEAT_YOUNGSTER_OWEN = 1459, + EVENT_BEAT_YOUNGSTER_SAMUEL = 1453, + EVENT_BEAT_YOUNGSTER_WARREN = 1457, + EVENT_BEAY_SUPER_NERD_DAVE = 1413, + EVENT_BETH_ASKED_FOR_PHONE_NUMBER = 621, + EVENT_BETH_READY_FOR_REMATCH = 622, + EVENT_BEVERLY_ASKED_FOR_PHONE_NUMBER = 609, + EVENT_BEVERLY_READY_FOR_REMATCH = 610, + EVENT_BLACKTHORN_CITY_GRAMPS_BLOCKS_DRAGONS_DEN = 1868, + EVENT_BLACKTHORN_CITY_GRAMPS_NOT_BLOCKING_DRAGONS_DEN = 1869, + EVENT_BLACKTHORN_CITY_SANTOS_OF_SATURDAY = 1885, + EVENT_BLACKTHORN_CITY_SUPER_NERD_BLOCKS_GYM = 1763, + EVENT_BLACKTHORN_CITY_SUPER_NERD_DOES_NOT_BLOCK_GYM = 1764, + EVENT_BLUE_IN_CINNABAR = 1909, + EVENT_BOULDER_IN_BLACKTHORN_GYM_1 = 1798, + EVENT_BOULDER_IN_BLACKTHORN_GYM_2 = 1799, + EVENT_BOULDER_IN_BLACKTHORN_GYM_3 = 1800, + EVENT_BOULDER_IN_ICE_PATH_1 = 1801, + EVENT_BOULDER_IN_ICE_PATH_1A = 1805, + EVENT_BOULDER_IN_ICE_PATH_2 = 1802, + EVENT_BOULDER_IN_ICE_PATH_2A = 1806, + EVENT_BOULDER_IN_ICE_PATH_3 = 1803, + EVENT_BOULDER_IN_ICE_PATH_3A = 1807, + EVENT_BOULDER_IN_ICE_PATH_4 = 1804, + EVENT_BOULDER_IN_ICE_PATH_4A = 1808, + EVENT_BRENT_ASKED_FOR_PHONE_NUMBER = 657, + EVENT_BRENT_READY_FOR_REMATCH = 658, + EVENT_BRUNOS_ROOM_ENTRANCE_CLOSED = 781, + EVENT_BRUNOS_ROOM_EXIT_OPEN = 782, + EVENT_BUG_CATCHING_CONTESTANT_10A = 1823, + EVENT_BUG_CATCHING_CONTESTANT_10B = 1833, + EVENT_BUG_CATCHING_CONTESTANT_1A = 1814, + EVENT_BUG_CATCHING_CONTESTANT_1B = 1824, + EVENT_BUG_CATCHING_CONTESTANT_2A = 1815, + EVENT_BUG_CATCHING_CONTESTANT_2B = 1825, + EVENT_BUG_CATCHING_CONTESTANT_3A = 1816, + EVENT_BUG_CATCHING_CONTESTANT_3B = 1826, + EVENT_BUG_CATCHING_CONTESTANT_4A = 1817, + EVENT_BUG_CATCHING_CONTESTANT_4B = 1827, + EVENT_BUG_CATCHING_CONTESTANT_5A = 1818, + EVENT_BUG_CATCHING_CONTESTANT_5B = 1828, + EVENT_BUG_CATCHING_CONTESTANT_6A = 1819, + EVENT_BUG_CATCHING_CONTESTANT_6B = 1829, + EVENT_BUG_CATCHING_CONTESTANT_7A = 1820, + EVENT_BUG_CATCHING_CONTESTANT_7B = 1830, + EVENT_BUG_CATCHING_CONTESTANT_8A = 1821, + EVENT_BUG_CATCHING_CONTESTANT_8B = 1831, + EVENT_BUG_CATCHING_CONTESTANT_9A = 1822, + EVENT_BUG_CATCHING_CONTESTANT_9B = 1832, + EVENT_BURNED_TOWER_1F_BURN_HEAL = 1621, + EVENT_BURNED_TOWER_1F_HIDDEN_ETHER = 128, + EVENT_BURNED_TOWER_1F_X_SPEED = 1620, + EVENT_BURNED_TOWER_B1F_BEASTS_1 = 1866, + EVENT_BURNED_TOWER_B1F_BEASTS_2 = 1867, + EVENT_BURNED_TOWER_B1F_HIDDEN_BURN_HEAL = 130, + EVENT_BURNED_TOWER_B1F_HIDDEN_NUGGET = 131, + EVENT_BURNED_TOWER_B1F_HIDDEN_ULTRA_BALL = 129, + EVENT_BURNED_TOWER_B1F_HP_UP = 1622, + EVENT_BURNED_TOWER_B1F_TM_ENDURE = 1623, + EVENT_BURNED_TOWER_FIREBREATHER_DICK_ASHES = 1893, + EVENT_BURNED_TOWER_FIREBREATHER_DICK_NORMAL = 1892, + EVENT_CELADON_CITY_HIDDEN_PP_UP = 253, + EVENT_CERULEAN_GYM_ROCKET = 1901, + EVENT_CHAD_ASKED_FOR_PHONE_NUMBER = 651, + EVENT_CHAD_READY_FOR_REMATCH = 652, + EVENT_CHARCOAL_KILN_APPRENTICE = 1782, + EVENT_CHARCOAL_KILN_BOSS = 1783, + EVENT_CHARCOAL_KILN_FARFETCH_D = 1781, + EVENT_CHIKORITA_POKEBALL_IN_ELMS_LAB = 1602, + EVENT_CHRIS_ASKED_FOR_PHONE_NUMBER = 655, + EVENT_CHRIS_READY_FOR_REMATCH = 656, + EVENT_CIANWOOD_CITY_HIDDEN_MAX_ETHER = 179, + EVENT_CIANWOOD_CITY_HIDDEN_REVIVE = 178, + EVENT_CINNABAR_ISLAND_HIDDEN_RARE_CANDY = 254, + EVENT_CINNABAR_ROCKS_CLEARED = 215, + EVENT_CLEARED_NUGGET_BRIDGE = 216, + EVENT_CLEARED_RADIO_TOWER = 33, + EVENT_CLEARED_ROCKET_HIDEOUT = 34, + EVENT_CLEARED_SLOWPOKE_WELL = 43, + EVENT_CONTEST_OFFICER_HAS_BERRY = 790, + EVENT_CONTEST_OFFICER_HAS_EVERSTONE = 788, + EVENT_CONTEST_OFFICER_HAS_GOLD_BERRY = 789, + EVENT_CONTEST_OFFICER_HAS_SUN_STONE = 787, + EVENT_COPYCATS_HOUSE_2F_DOLL = 1907, + EVENT_COP_IN_ELMS_LAB = 1794, + EVENT_CYNDAQUIL_POKEBALL_IN_ELMS_LAB = 1600, + EVENT_DANA_ASKED_FOR_PHONE_NUMBER = 649, + EVENT_DANA_READY_FOR_REMATCH = 650, + EVENT_DARK_CAVE_BLACKTHORN_ENTRANCE_REVIVE = 1697, + EVENT_DARK_CAVE_BLACKTHORN_ENTRANCE_TM_SNORE = 1698, + EVENT_DARK_CAVE_VIOLET_ENTRANCE_FULL_HEAL = 1695, + EVENT_DARK_CAVE_VIOLET_ENTRANCE_HIDDEN_ELIXER = 157, + EVENT_DARK_CAVE_VIOLET_ENTRANCE_HYPER_POTION = 1696, + EVENT_DARK_CAVE_VIOLET_ENTRANCE_POTION = 1694, + EVENT_DAY_CARE_MAN_IN_DAY_CARE = 1765, + EVENT_DAY_CARE_MAN_ON_ROUTE_34 = 1766, + EVENT_DAY_CARE_MON_1 = 1767, + EVENT_DAY_CARE_MON_2 = 1768, + EVENT_DECIDED_TO_HELP_LANCE = 96, + EVENT_DECO_BED_1 = 676, + EVENT_DECO_BED_2 = 677, + EVENT_DECO_BED_3 = 678, + EVENT_DECO_BED_4 = 679, + EVENT_DECO_BIG_LAPRAS_DOLL = 721, + EVENT_DECO_BIG_ONIX_DOLL = 720, + EVENT_DECO_BIG_SNORLAX_DOLL = 719, + EVENT_DECO_BULBASAUR_DOLL = 699, + EVENT_DECO_CARPET_1 = 680, + EVENT_DECO_CARPET_2 = 681, + EVENT_DECO_CARPET_3 = 682, + EVENT_DECO_CARPET_4 = 683, + EVENT_DECO_CHARMANDER_DOLL = 700, + EVENT_DECO_CLEFAIRY_DOLL = 697, + EVENT_DECO_DIGLETT_DOLL = 703, + EVENT_DECO_FAMICOM = 691, + EVENT_DECO_GENGAR_DOLL = 707, + EVENT_DECO_GEODUDE_DOLL = 713, + EVENT_DECO_GOLD_TROPHY = 717, + EVENT_DECO_GRIMER_DOLL = 709, + EVENT_DECO_JIGGLYPUFF_DOLL = 698, + EVENT_DECO_MACHOP_DOLL = 714, + EVENT_DECO_MAGIKARP_DOLL = 705, + EVENT_DECO_N64 = 693, + EVENT_DECO_ODDISH_DOLL = 706, + EVENT_DECO_PIKACHU_DOLL = 695, + EVENT_DECO_PLANT_1 = 684, + EVENT_DECO_PLANT_2 = 685, + EVENT_DECO_PLANT_3 = 686, + EVENT_DECO_POLIWAG_DOLL = 702, + EVENT_DECO_POSTER_1 = 687, + EVENT_DECO_POSTER_2 = 688, + EVENT_DECO_POSTER_3 = 689, + EVENT_DECO_POSTER_4 = 690, + EVENT_DECO_SHELLDER_DOLL = 708, + EVENT_DECO_SILVER_TROPHY = 718, + EVENT_DECO_SNES = 692, + EVENT_DECO_SQUIRTLE_DOLL = 701, + EVENT_DECO_STARYU_DOLL = 704, + EVENT_DECO_SURFING_PIKACHU_DOLL = 696, + EVENT_DECO_TENTACOOL_DOLL = 715, + EVENT_DECO_UNOWN_DOLL = 712, + EVENT_DECO_VIRTUAL_BOY = 694, + EVENT_DECO_VOLTORB_DOLL = 710, + EVENT_DECO_WEEDLE_DOLL = 711, + EVENT_DEREK_ASKED_FOR_PHONE_NUMBER = 653, + EVENT_DEREK_READY_FOR_REMATCH = 654, + EVENT_DIGLETTS_CAVE_HIDDEN_MAX_REVIVE = 228, + EVENT_DOOR_10_OPEN = 736, + EVENT_DOOR_11_OPEN = 737, + EVENT_DOOR_1_OPEN = 727, + EVENT_DOOR_2_OPEN = 728, + EVENT_DOOR_3_OPEN = 729, + EVENT_DOOR_4_OPEN = 730, + EVENT_DOOR_5_OPEN = 731, + EVENT_DOOR_6_OPEN = 732, + EVENT_DOOR_7_OPEN = 733, + EVENT_DOOR_8_OPEN = 734, + EVENT_DOOR_9_OPEN = 735, + EVENT_DRAGONS_DEN_B1F_DRAGON_FANG = 1704, + EVENT_DRAGONS_DEN_B1F_HIDDEN_MAX_ELIXER = 162, + EVENT_DRAGONS_DEN_B1F_HIDDEN_MAX_POTION = 161, + EVENT_DRAGONS_DEN_B1F_HIDDEN_REVIVE = 160, + EVENT_DRAGONS_DEN_CLAIR = 1752, + EVENT_DUDE_TALKED_TO_YOU = 65, + EVENT_EARLS_ACADEMY_EARL = 1739, + EVENT_ECRUTEAK_CITY_HIDDEN_HYPER_POTION = 180, + EVENT_ECRUTEAK_POKE_CENTER_BILL = 1811, + EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_LEFT = 1894, + EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_RIGHT = 1895, + EVENT_ELMS_AIDE_IN_LAB = 1793, + EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER = 1792, + EVENT_ELM_CALLED_ABOUT_STOLEN_POKEMON = 67, + EVENT_EMERGENCY_SWITCH = 726, + EVENT_ENABLE_DIPLOMA_PRINTING = 214, + EVENT_ERIN_ASKED_FOR_PHONE_NUMBER = 669, + EVENT_ERIN_READY_FOR_REMATCH = 670, + EVENT_EXPLODING_TRAP_1 = 745, + EVENT_EXPLODING_TRAP_10 = 754, + EVENT_EXPLODING_TRAP_11 = 755, + EVENT_EXPLODING_TRAP_12 = 756, + EVENT_EXPLODING_TRAP_13 = 757, + EVENT_EXPLODING_TRAP_14 = 758, + EVENT_EXPLODING_TRAP_15 = 759, + EVENT_EXPLODING_TRAP_16 = 760, + EVENT_EXPLODING_TRAP_17 = 761, + EVENT_EXPLODING_TRAP_18 = 762, + EVENT_EXPLODING_TRAP_19 = 763, + EVENT_EXPLODING_TRAP_2 = 746, + EVENT_EXPLODING_TRAP_20 = 764, + EVENT_EXPLODING_TRAP_21 = 765, + EVENT_EXPLODING_TRAP_22 = 766, + EVENT_EXPLODING_TRAP_3 = 747, + EVENT_EXPLODING_TRAP_4 = 748, + EVENT_EXPLODING_TRAP_5 = 749, + EVENT_EXPLODING_TRAP_6 = 750, + EVENT_EXPLODING_TRAP_7 = 751, + EVENT_EXPLODING_TRAP_8 = 752, + EVENT_EXPLODING_TRAP_9 = 753, + EVENT_FAST_SHIP_1F_GENTLEMAN = 1836, + EVENT_FAST_SHIP_B1F_SAILOR_LEFT = 1838, + EVENT_FAST_SHIP_B1F_SAILOR_RIGHT = 1839, + EVENT_FAST_SHIP_CABINS_NNW_NNE_NE_SAILOR = 1837, + EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_1 = 1841, + EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_2 = 1842, + EVENT_FAST_SHIP_CABINS_SE_SSE_GENTLEMAN = 1840, + EVENT_FAST_SHIP_DESTINATION_OLIVINE = 47, + EVENT_FAST_SHIP_FIRST_TIME = 48, + EVENT_FAST_SHIP_FOUND_GIRL = 50, + EVENT_FAST_SHIP_HAS_ARRIVED = 49, + EVENT_FAST_SHIP_INFORMED_ABOUT_LAZY_SAILOR = 52, + EVENT_FAST_SHIP_LAZY_SAILOR = 51, + EVENT_FAST_SHIP_PASSENGERS_EASTBOUND = 1850, + EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP = 1849, + EVENT_FAST_SHIP_PASSENGERS_WESTBOUND = 1851, + EVENT_FIRST_TIME_BANKING_WITH_MOM = 118, + EVENT_FOUGHT_HO_OH = 791, + EVENT_FOUGHT_LUGIA = 792, + EVENT_FOUGHT_SNORLAX = 1872, + EVENT_FOUGHT_SUDOWOODO = 42, + EVENT_FOUND_BERSERK_GENE_IN_CERULEAN_CITY = 250, + EVENT_FOUND_LEFTOVERS_IN_CELADON_CAFE = 249, + EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM = 251, + EVENT_GAVEN_ASKED_FOR_PHONE_NUMBER = 619, + EVENT_GAVEN_READY_FOR_REMATCH = 620, + EVENT_GAVE_KENYA = 81, + EVENT_GAVE_KURT_BLK_APRICORN = 605, + EVENT_GAVE_KURT_BLU_APRICORN = 601, + EVENT_GAVE_KURT_GRN_APRICORN = 603, + EVENT_GAVE_KURT_PNK_APRICORN = 606, + EVENT_GAVE_KURT_RED_APRICORN = 600, + EVENT_GAVE_KURT_WHT_APRICORN = 604, + EVENT_GAVE_KURT_YLW_APRICORN = 602, + EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31, + EVENT_GINA_ASKED_FOR_PHONE_NUMBER = 639, + EVENT_GINA_READY_FOR_REMATCH = 640, + EVENT_GOLDENROD_CITY_CIVILIANS = 1743, + EVENT_GOLDENROD_CITY_ROCKET_SCOUT = 1740, + EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER = 1741, + EVENT_GOLDENROD_DEPT_STORE_5F_HAPPINESS_EVENT_LADY = 1891, + EVENT_GOLDENROD_DEPT_STORE_B1F_AMULET_COIN = 1654, + EVENT_GOLDENROD_DEPT_STORE_B1F_BURN_HEAL = 1655, + EVENT_GOLDENROD_DEPT_STORE_B1F_ETHER = 1653, + EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_1 = 772, + EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_2 = 773, + EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_3 = 774, + EVENT_GOLDENROD_DEPT_STORE_B1F_ULTRA_BALL = 1656, + EVENT_GOLDENROD_TRAIN_STATION_GENTLEMAN = 1865, + EVENT_GOLDENROD_UNDERGROUND_COIN_CASE = 1650, + EVENT_GOLDENROD_UNDERGROUND_GRAMPS = 1875, + EVENT_GOLDENROD_UNDERGROUND_GRANNY = 1874, + EVENT_GOLDENROD_UNDERGROUND_HIDDEN_ANTIDOTE = 141, + EVENT_GOLDENROD_UNDERGROUND_HIDDEN_PARLYZ_HEAL = 139, + EVENT_GOLDENROD_UNDERGROUND_HIDDEN_SUPER_POTION = 140, + EVENT_GOLDENROD_UNDERGROUND_OLDER_HAIRCUT_BROTHER = 1876, + EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_FULL_HEAL = 1652, + EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_HIDDEN_MAX_POTION = 142, + EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_HIDDEN_REVIVE = 143, + EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_SMOKE_BALL = 1651, + EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_BLOCKED_OFF = 775, + EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_MAX_ETHER = 1657, + EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_TM_SLEEP_TALK = 1658, + EVENT_GOLDENROD_UNDERGROUND_YOUNGER_HAIRCUT_BROTHER = 1877, + EVENT_GOT_A_POKEMON_FROM_ELM = 26, + EVENT_GOT_BERRY_FROM_ROUTE_30_HOUSE = 39, + EVENT_GOT_BICYCLE = 91, + EVENT_GOT_BLACKBELT_FROM_WESLEY = 107, + EVENT_GOT_BLACKGLASSES_IN_DARK_CAVE = 114, + EVENT_GOT_CALCIUM_FROM_ERIN = 617, + EVENT_GOT_CARBOS_FROM_VANCE = 615, + EVENT_GOT_CHARCOAL_IN_CHARCOAL_KILN = 94, + EVENT_GOT_CHIKORITA_FROM_ELM = 29, + EVENT_GOT_CLEANSE_TAG = 219, + EVENT_GOT_COINS_FROM_GAMBLER_AT_CELADON = 206, + EVENT_GOT_CYNDAQUIL_FROM_ELM = 27, + EVENT_GOT_EEVEE = 79, + EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA = 800, + EVENT_GOT_EVERSTONE_FROM_ELM = 86, + EVENT_GOT_FIRE_STONE_FROM_BILLS_GRANDPA = 803, + EVENT_GOT_GOOD_ROD = 24, + EVENT_GOT_HARD_STONE_FROM_ARTHUR = 103, + EVENT_GOT_HM01_CUT = 16, + EVENT_GOT_HM02_FLY = 17, + EVENT_GOT_HM03_SURF = 18, + EVENT_GOT_HM04_STRENGTH = 19, + EVENT_GOT_HM05_FLASH = 20, + EVENT_GOT_HM06_WHIRLPOOL = 21, + EVENT_GOT_HM07_WATERFALL = 1672, + EVENT_GOT_HP_UP_FROM_JOEY = 614, + EVENT_GOT_HP_UP_FROM_RANDY = 82, + EVENT_GOT_HP_UP_FROM_VERMILION_GUY = 226, + EVENT_GOT_IRON_FROM_PARRY = 616, + EVENT_GOT_ITEMFINDER = 90, + EVENT_GOT_KENYA = 80, + EVENT_GOT_KINGS_ROCK_IN_SLOWPOKE_WELL = 115, + EVENT_GOT_LEAF_STONE_FROM_BILLS_GRANDPA = 801, + EVENT_GOT_LOST_ITEM_FROM_FAN_CLUB = 210, + EVENT_GOT_MAGNET_FROM_SUNNY = 105, + EVENT_GOT_MASTER_BALL_FROM_ELM = 124, + EVENT_GOT_METAL_COAT_FROM_GRANDPA_ON_SS_AQUA = 113, + EVENT_GOT_MIRACLE_SEED_IN_ROUTE_32 = 93, + EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON = 30, + EVENT_GOT_MYSTIC_WATER_IN_CHERRYGROVE = 77, + EVENT_GOT_NUGGET_FROM_GUY = 200, + EVENT_GOT_OLD_ROD = 23, + EVENT_GOT_PASS_FROM_COPYCAT = 209, + EVENT_GOT_PINK_BOW_FROM_MARY = 72, + EVENT_GOT_PINK_BOW_FROM_TUSCANY = 101, + EVENT_GOT_POISON_BARB_FROM_FRIEDA = 99, + EVENT_GOT_PROTEIN_FROM_HUEY = 613, + EVENT_GOT_QUICK_CLAW = 87, + EVENT_GOT_RAINBOW_WING = 120, + EVENT_GOT_SECRETPOTION_FROM_PHARMACY = 35, + EVENT_GOT_SHARP_BEAK_FROM_MONICA = 111, + EVENT_GOT_SHUCKIE = 69, + EVENT_GOT_SILVER_WING = 121, + EVENT_GOT_SOFT_SAND_FROM_KATE = 112, + EVENT_GOT_SPELL_TAG_FROM_SANTOS = 109, + EVENT_GOT_SQUIRTBOTTLE = 92, + EVENT_GOT_SS_TICKET_FROM_ELM = 36, + EVENT_GOT_SUNNY_DAY_FROM_RADIO_TOWER = 71, + EVENT_GOT_SUPER_ROD = 25, + EVENT_GOT_THUNDERSTONE_FROM_BILLS_GRANDPA = 804, + EVENT_GOT_TM01_DYNAMICPUNCH = 10, + EVENT_GOT_TM02_HEADBUTT = 95, + EVENT_GOT_TM03_CURSE = 218, + EVENT_GOT_TM05_ROAR = 78, + EVENT_GOT_TM06_TOXIC = 221, + EVENT_GOT_TM07_ZAP_CANNON = 223, + EVENT_GOT_TM08_ROCK_SMASH = 75, + EVENT_GOT_TM10_HIDDEN_POWER = 88, + EVENT_GOT_TM12_SWEET_SCENT = 122, + EVENT_GOT_TM13_SNORE_FROM_MOOMOO_FARM = 62, + EVENT_GOT_TM16_ICY_WIND = 14, + EVENT_GOT_TM19_GIGA_DRAIN = 220, + EVENT_GOT_TM23_IRON_TAIL = 13, + EVENT_GOT_TM24_DRAGONBREATH = 15, + EVENT_GOT_TM29_PSYCHIC = 227, + EVENT_GOT_TM30_SHADOW_BALL = 12, + EVENT_GOT_TM31_MUD_SLAP = 8, + EVENT_GOT_TM36_SLUDGE_BOMB = 89, + EVENT_GOT_TM37_SANDSTORM = 117, + EVENT_GOT_TM42_DREAM_EATER = 224, + EVENT_GOT_TM45_ATTRACT = 11, + EVENT_GOT_TM47_STEEL_WING = 116, + EVENT_GOT_TM49_FURY_CUTTER = 9, + EVENT_GOT_TM50_NIGHTMARE = 83, + EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 45, + EVENT_GOT_TOTODILE_FROM_ELM = 28, + EVENT_GOT_TYROGUE_FROM_KIYO = 97, + EVENT_GOT_UP_GRADE = 222, + EVENT_GOT_WATER_STONE_FROM_BILLS_GRANDPA = 802, + EVENT_GUIDE_GENT_IN_HIS_HOUSE = 1790, + EVENT_GUIDE_GENT_VISIBLE_IN_CHERRYGROVE = 1791, + EVENT_HEALED_MOOMOO = 61, + EVENT_HERDED_FARFETCHD = 41, + EVENT_HUEY_ASKED_FOR_PHONE_NUMBER = 611, + EVENT_HUEY_READY_FOR_REMATCH = 612, + EVENT_ICE_PATH_1F_PP_UP = 1673, + EVENT_ICE_PATH_B1F_HIDDEN_MAX_POTION = 148, + EVENT_ICE_PATH_B1F_IRON = 1674, + EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_HIDDEN_ICE_HEAL = 150, + EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_TM_REST = 1677, + EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_FULL_HEAL = 1675, + EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_HIDDEN_CARBOS = 149, + EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_MAX_POTION = 1676, + EVENT_ICE_PATH_B3F_NEVERMELTICE = 1678, + EVENT_ILEX_FOREST_APPRENTICE = 1779, + EVENT_ILEX_FOREST_CHARCOAL_MASTER = 1780, + EVENT_ILEX_FOREST_FARFETCHD_1 = 1769, + EVENT_ILEX_FOREST_FARFETCHD_10 = 1778, + EVENT_ILEX_FOREST_FARFETCHD_2 = 1770, + EVENT_ILEX_FOREST_FARFETCHD_3 = 1771, + EVENT_ILEX_FOREST_FARFETCHD_4 = 1772, + EVENT_ILEX_FOREST_FARFETCHD_5 = 1773, + EVENT_ILEX_FOREST_FARFETCHD_6 = 1774, + EVENT_ILEX_FOREST_FARFETCHD_7 = 1775, + EVENT_ILEX_FOREST_FARFETCHD_8 = 1776, + EVENT_ILEX_FOREST_FARFETCHD_9 = 1777, + EVENT_ILEX_FOREST_HIDDEN_ETHER = 136, + EVENT_ILEX_FOREST_HIDDEN_FULL_HEAL = 138, + EVENT_ILEX_FOREST_HIDDEN_SUPER_POTION = 137, + EVENT_ILEX_FOREST_REVIVE = 1649, + EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL = 1915, + EVENT_INITIALIZED_EVENTS = 54, + EVENT_IRWIN_ASKED_FOR_PHONE_NUMBER = 641, + EVENT_IRWIN_READY_FOR_REMATCH = 642, + EVENT_JACK_ASKED_FOR_PHONE_NUMBER = 607, + EVENT_JACK_READY_FOR_REMATCH = 608, + EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS = 55, + EVENT_JASMINE_RETURNED_TO_GYM = 32, + EVENT_JOEY_ASKED_FOR_PHONE_NUMBER = 627, + EVENT_JOEY_READY_FOR_REMATCH = 628, + EVENT_JOSE_ASKED_FOR_PHONE_NUMBER = 623, + EVENT_JOSE_READY_FOR_REMATCH = 624, + EVENT_KARENS_ROOM_ENTRANCE_CLOSED = 783, + EVENT_KARENS_ROOM_EXIT_OPEN = 784, + EVENT_KENJI_ASKED_FOR_PHONE_NUMBER = 665, + EVENT_KENJI_ON_BREAK = 618, + EVENT_KENJI_READY_FOR_REMATCH = 666, + EVENT_KOGAS_ROOM_ENTRANCE_CLOSED = 779, + EVENT_KOGAS_ROOM_EXIT_OPEN = 780, + EVENT_KURTS_HOUSE_KURT_1 = 1854, + EVENT_KURTS_HOUSE_KURT_2 = 1855, + EVENT_KURTS_HOUSE_SLOWPOKE = 1789, + EVENT_KURT_GAVE_YOU_LURE_BALL = 53, + EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP = 57, + EVENT_LAKE_OF_RAGE_CIVILIANS = 1845, + EVENT_LAKE_OF_RAGE_ETHER_ON_STANDBY = 58, + EVENT_LAKE_OF_RAGE_EXPLAINED_WEIRD_MAGIKARP = 56, + EVENT_LAKE_OF_RAGE_HIDDEN_FULL_RESTORE = 181, + EVENT_LAKE_OF_RAGE_HIDDEN_MAX_POTION = 183, + EVENT_LAKE_OF_RAGE_HIDDEN_RARE_CANDY = 182, + EVENT_LAKE_OF_RAGE_LANCE = 1748, + EVENT_LAKE_OF_RAGE_MAX_ETHER = 1605, + EVENT_LAKE_OF_RAGE_RED_GYARADOS = 1873, + EVENT_LAKE_OF_RAGE_TM_DETECT = 1606, + EVENT_LAKE_OF_RAGE_WESLEY_OF_WEDNESDAY = 1884, + EVENT_LANCES_ROOM_ENTRANCE_CLOSED = 785, + EVENT_LANCES_ROOM_EXIT_OPEN = 786, + EVENT_LANCES_ROOM_OAK_AND_MARY = 1887, + EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE = 76, + EVENT_LEARNED_HAIL_GIOVANNI = 767, + EVENT_LEARNED_RATICATE_TAIL = 770, + EVENT_LEARNED_SLOWPOKETAIL = 769, + EVENT_LEARNED_TO_CATCH_POKEMON = 66, + EVENT_LEFT_MONS_WITH_CONTEST_OFFICER = 776, + EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT = 212, + EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT_BUT_BAG_WAS_FULL = 211, + EVENT_LISTENED_TO_INITIAL_RADIO = 805, + EVENT_LIZ_ASKED_FOR_PHONE_NUMBER = 633, + EVENT_LIZ_READY_FOR_REMATCH = 634, + EVENT_MADE_UNOWN_APPEAR_IN_RUINS = 46, + EVENT_MADE_WHITNEY_CRY = 40, + EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE = 1749, + EVENT_MAHOGANY_MART_OWNERS = 1846, + EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_EAST = 1878, + EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM = 1879, + EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM = 70, + EVENT_MET_ARTHUR_OF_THURSDAY = 102, + EVENT_MET_BILL = 1810, + EVENT_MET_BILLS_GRANDPA = 794, + EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM = 207, + EVENT_MET_FRIEDA_OF_FRIDAY = 98, + EVENT_MET_MANAGER_AT_POWER_PLANT = 202, + EVENT_MET_MONICA_OF_MONDAY = 110, + EVENT_MET_REDS_MOM = 204, + EVENT_MET_ROCKET_GRUNT_AT_CERULEAN_GYM = 203, + EVENT_MET_SANTOS_OF_SATURDAY = 108, + EVENT_MET_SUNNY_OF_SUNDAY = 104, + EVENT_MET_TUSCANY_OF_TUESDAY = 100, + EVENT_MET_WESLEY_OF_WEDNESDAY = 106, + EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE = 236, + EVENT_MOUNT_MORTAR_1F_INSIDE_ESCAPE_ROPE = 1661, + EVENT_MOUNT_MORTAR_1F_INSIDE_HIDDEN_MAX_REPEL = 145, + EVENT_MOUNT_MORTAR_1F_INSIDE_HYPER_POTION = 1663, + EVENT_MOUNT_MORTAR_1F_INSIDE_MAX_REVIVE = 1662, + EVENT_MOUNT_MORTAR_1F_OUTSIDE_ETHER = 1659, + EVENT_MOUNT_MORTAR_1F_OUTSIDE_HIDDEN_HYPER_POTION = 144, + EVENT_MOUNT_MORTAR_1F_OUTSIDE_REVIVE = 1660, + EVENT_MOUNT_MORTAR_2F_INSIDE_DRAGON_SCALE = 1667, + EVENT_MOUNT_MORTAR_2F_INSIDE_ELIXER = 1668, + EVENT_MOUNT_MORTAR_2F_INSIDE_ESCAPE_ROPE = 1669, + EVENT_MOUNT_MORTAR_2F_INSIDE_HIDDEN_FULL_RESTORE = 146, + EVENT_MOUNT_MORTAR_2F_INSIDE_MAX_POTION = 1664, + EVENT_MOUNT_MORTAR_2F_INSIDE_RARE_CANDY = 1665, + EVENT_MOUNT_MORTAR_2F_INSIDE_TM_DEFENSE_CURL = 1666, + EVENT_MOUNT_MORTAR_B1F_FULL_HEAL = 1671, + EVENT_MOUNT_MORTAR_B1F_HIDDEN_MAX_REVIVE = 147, + EVENT_MOUNT_MORTAR_B1F_HYPER_POTION = 1670, + EVENT_MR_POKEMONS_HOUSE_OAK = 1737, + EVENT_MT_MOON_RIVAL = 1914, + EVENT_MT_MOON_SQUARE_CLEFAIRY = 1913, + EVENT_MT_MOON_SQUARE_ROCK = 1912, + EVENT_MYSTERY_GIFT_DELIVERY_GUY = 1809, + EVENT_NATIONAL_PARK_HIDDEN_FULL_HEAL = 132, + EVENT_NATIONAL_PARK_PARLYZ_HEAL = 1624, + EVENT_NATIONAL_PARK_TM_DIG = 1625, + EVENT_OLIVINE_GYM_JASMINE = 1747, + EVENT_OLIVINE_LIGHTHOUSE_3F_ETHER = 1636, + EVENT_OLIVINE_LIGHTHOUSE_5F_GREAT_BALL = 1638, + EVENT_OLIVINE_LIGHTHOUSE_5F_HIDDEN_HYPER_POTION = 133, + EVENT_OLIVINE_LIGHTHOUSE_5F_RARE_CANDY = 1637, + EVENT_OLIVINE_LIGHTHOUSE_5F_TM_SWAGGER = 1639, + EVENT_OLIVINE_LIGHTHOUSE_6F_SUPER_POTION = 1640, + EVENT_OLIVINE_LIGHTHOUSE_JASMINE = 1746, + EVENT_OLIVINE_PORT_HIDDEN_PROTEIN = 234, + EVENT_OLIVINE_PORT_PASSAGE_POKEFAN_M = 1843, + EVENT_OLIVINE_PORT_SAILOR_AT_GANGWAY = 1834, + EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME = 1848, + EVENT_OLIVINE_PORT_SPRITES_BEFORE_HALL_OF_FAME = 1847, + EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE = 771, + EVENT_OPENED_DOOR_TO_ROCKET_HIDEOUT_TRANSMITTER = 768, + EVENT_OPENED_MT_SILVER = 1871, + EVENT_PARRY_ASKED_FOR_PHONE_NUMBER = 667, + EVENT_PARRY_READY_FOR_REMATCH = 668, + EVENT_PICKED_UP_FOCUS_BAND = 1917, + EVENT_PLAYERS_HOUSE_2F_BIG_DOLL = 1860, + EVENT_PLAYERS_HOUSE_2F_CONSOLE = 1857, + EVENT_PLAYERS_HOUSE_2F_DOLL_1 = 1858, + EVENT_PLAYERS_HOUSE_2F_DOLL_2 = 1859, + EVENT_PLAYERS_HOUSE_MOM_1 = 1735, + EVENT_PLAYERS_HOUSE_MOM_2 = 1736, + EVENT_PLAYERS_ROOM_POSTER = 716, + EVENT_RADIO_TOWER_BLACKBELT_BLOCKS_STAIRS = 1745, + EVENT_RADIO_TOWER_CIVILIANS_AFTER = 1744, + EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 1742, + EVENT_RALPH_ASKED_FOR_PHONE_NUMBER = 631, + EVENT_RALPH_READY_FOR_REMATCH = 632, + EVENT_RECEIVED_CARD_KEY = 74, + EVENT_RED_IN_MT_SILVER = 1890, + EVENT_REENA_ASKED_FOR_PHONE_NUMBER = 625, + EVENT_REENA_READY_FOR_REMATCH = 626, + EVENT_REFUSED_TO_HELP_LANCE_AT_LAKE_OF_RAGE = 38, + EVENT_REFUSED_TO_TAKE_EGG_FROM_ELMS_AIDE = 44, + EVENT_RELEASED_THE_BEASTS = 123, + EVENT_RESTORED_POWER_TO_KANTO = 205, + EVENT_RETURNED_LOST_ITEM_TO_COPYCAT = 208, + EVENT_RETURNED_MACHINE_PART = 201, + EVENT_RIVAL_AZALEA_TOWN = 1727, + EVENT_RIVAL_BURNED_TOWER = 1733, + EVENT_RIVAL_CHERRYGROVE_CITY = 1726, + EVENT_RIVAL_DRAGONS_DEN = 1734, + EVENT_RIVAL_GOLDENROD_UNDERGROUND = 1729, + EVENT_RIVAL_NEW_BARK_TOWN = 1725, + EVENT_RIVAL_OLIVINE_CITY = 1731, + EVENT_RIVAL_SPROUT_TOWER = 1732, + EVENT_RIVAL_TEAM_ROCKET_BASE = 1728, + EVENT_RIVAL_VICTORY_ROAD = 1730, + EVENT_ROCK_TUNNEL_1F_ELIXER = 1918, + EVENT_ROCK_TUNNEL_1F_HIDDEN_X_ACCURACY = 231, + EVENT_ROCK_TUNNEL_1F_HIDDEN_X_DEFEND = 232, + EVENT_ROCK_TUNNEL_1F_TM_STEEL_WING = 1919, + EVENT_ROCK_TUNNEL_B1F_HIDDEN_MAX_POTION = 233, + EVENT_ROCK_TUNNEL_B1F_IRON = 1920, + EVENT_ROCK_TUNNEL_B1F_PP_UP = 1921, + EVENT_ROCK_TUNNEL_B1F_REVIVE = 1922, + EVENT_ROUTE_11_HIDDEN_REVIVE = 245, + EVENT_ROUTE_12_CALCIUM = 1928, + EVENT_ROUTE_12_HIDDEN_ELIXER = 243, + EVENT_ROUTE_12_NUGGET = 1929, + EVENT_ROUTE_13_HIDDEN_CALCIUM = 244, + EVENT_ROUTE_15_PP_UP = 1930, + EVENT_ROUTE_17_HIDDEN_MAX_ELIXER = 247, + EVENT_ROUTE_17_HIDDEN_MAX_ETHER = 246, + EVENT_ROUTE_24_ROCKET = 1900, + EVENT_ROUTE_25_HIDDEN_POTION = 248, + EVENT_ROUTE_25_MISTY_BOYFRIEND = 1902, + EVENT_ROUTE_25_PROTEIN = 1931, + EVENT_ROUTE_26_MAX_ELIXER = 1706, + EVENT_ROUTE_27_RARE_CANDY = 1708, + EVENT_ROUTE_27_TM_SOLARBEAM = 1707, + EVENT_ROUTE_28_HIDDEN_RARE_CANDY = 163, + EVENT_ROUTE_29_POTION = 1709, + EVENT_ROUTE_29_TUSCANY_OF_TUESDAY = 1881, + EVENT_ROUTE_2_CARBOS = 1925, + EVENT_ROUTE_2_DIRE_HIT = 1923, + EVENT_ROUTE_2_ELIXER = 1926, + EVENT_ROUTE_2_HIDDEN_FULL_HEAL = 238, + EVENT_ROUTE_2_HIDDEN_FULL_RESTORE = 239, + EVENT_ROUTE_2_HIDDEN_MAX_ETHER = 237, + EVENT_ROUTE_2_HIDDEN_REVIVE = 240, + EVENT_ROUTE_2_MAX_POTION = 1924, + EVENT_ROUTE_30_BATTLE = 1812, + EVENT_ROUTE_30_HIDDEN_POTION = 164, + EVENT_ROUTE_30_YOUNGSTER_JOEY = 1813, + EVENT_ROUTE_31_ANTIDOTE = 1710, + EVENT_ROUTE_31_POKE_BALL = 1711, + EVENT_ROUTE_32_FRIEDA_OF_FRIDAY = 1880, + EVENT_ROUTE_32_GREAT_BALL = 1712, + EVENT_ROUTE_32_HIDDEN_GREAT_BALL = 165, + EVENT_ROUTE_32_HIDDEN_SUPER_POTION = 166, + EVENT_ROUTE_32_POTION = 1713, + EVENT_ROUTE_34_HIDDEN_RARE_CANDY = 167, + EVENT_ROUTE_34_HIDDEN_SUPER_POTION = 168, + EVENT_ROUTE_35_NATIONAL_PARK_GATE_OFFICER_CONTEST_DAY = 1861, + EVENT_ROUTE_35_NATIONAL_PARK_GATE_OFFICER_NOT_CONTEST_DAY = 1862, + EVENT_ROUTE_35_NATIONAL_PARK_GATE_YOUNGSTER = 1844, + EVENT_ROUTE_35_TM_ROLLOUT = 1714, + EVENT_ROUTE_36_ARTHUR_OF_THURSDAY = 1882, + EVENT_ROUTE_36_NATIONAL_PARK_GATE_OFFICER_CONTEST_DAY = 1863, + EVENT_ROUTE_36_NATIONAL_PARK_GATE_OFFICER_NOT_CONTEST_DAY = 1864, + EVENT_ROUTE_36_SUDOWOODO = 1784, + EVENT_ROUTE_37_HIDDEN_ETHER = 169, + EVENT_ROUTE_37_SUNNY_OF_SUNDAY = 1883, + EVENT_ROUTE_39_HIDDEN_NUGGET = 170, + EVENT_ROUTE_40_HIDDEN_HYPER_POTION = 171, + EVENT_ROUTE_40_MONICA_OF_MONDAY = 1886, + EVENT_ROUTE_41_HIDDEN_MAX_ETHER = 172, + EVENT_ROUTE_42_HIDDEN_MAX_POTION = 173, + EVENT_ROUTE_42_SUPER_POTION = 1716, + EVENT_ROUTE_42_ULTRA_BALL = 1715, + EVENT_ROUTE_43_GATE_ROCKETS = 1756, + EVENT_ROUTE_43_MAX_ETHER = 1717, + EVENT_ROUTE_44_HIDDEN_ELIXER = 174, + EVENT_ROUTE_44_MAX_REVIVE = 1718, + EVENT_ROUTE_44_ULTRA_BALL = 1719, + EVENT_ROUTE_45_ELIXER = 1722, + EVENT_ROUTE_45_HIDDEN_PP_UP = 175, + EVENT_ROUTE_45_MAX_POTION = 1723, + EVENT_ROUTE_45_REVIVE = 1721, + EVENT_ROUTE_45_X_SPECIAL = 1720, + EVENT_ROUTE_46_DIRE_HIT = 1724, + EVENT_ROUTE_4_HIDDEN_ULTRA_BALL = 241, + EVENT_ROUTE_4_HP_UP = 1927, + EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH = 1905, + EVENT_ROUTE_9_HIDDEN_ETHER = 242, + EVENT_RUINS_OF_ALPH_INNER_CHAMBER_TOURISTS = 1797, + EVENT_RUINS_OF_ALPH_KABUTO_CHAMBER_RECEPTIONIST = 1870, + EVENT_RUINS_OF_ALPH_OUTSIDE_SCIENTIST = 1795, + EVENT_RUINS_OF_ALPH_RESEARCH_CENTER_SCIENTIST = 1796, + EVENT_SAFFRON_TRAIN_STATION_POPULATION = 1906, + EVENT_SEAFOAM_GYM_GYM_GUIDE = 1911, + EVENT_SECURITY_CAMERA_1 = 740, + EVENT_SECURITY_CAMERA_2 = 741, + EVENT_SECURITY_CAMERA_3 = 742, + EVENT_SECURITY_CAMERA_4 = 743, + EVENT_SECURITY_CAMERA_5 = 744, + EVENT_SHOWED_GROWLITHE_VULPIX_TO_BILLS_GRANDPA = 798, + EVENT_SHOWED_LICKITUNG_TO_BILLS_GRANDPA = 795, + EVENT_SHOWED_ODDISH_TO_BILLS_GRANDPA = 796, + EVENT_SHOWED_PICHU_TO_BILLS_GRANDPA = 799, + EVENT_SHOWED_STARYU_TO_BILLS_GRANDPA = 797, + EVENT_SHOWED_TOGEPI_TO_ELM = 85, + EVENT_SILVER_CAVE_ITEM_ROOMS_FULL_RESTORE = 1693, + EVENT_SILVER_CAVE_ITEM_ROOMS_MAX_REVIVE = 1692, + EVENT_SILVER_CAVE_OUTSIDE_HIDDEN_FULL_RESTORE = 184, + EVENT_SILVER_CAVE_ROOM_1_ESCAPE_ROPE = 1691, + EVENT_SILVER_CAVE_ROOM_1_HIDDEN_DIRE_HIT = 154, + EVENT_SILVER_CAVE_ROOM_1_HIDDEN_ULTRA_BALL = 155, + EVENT_SILVER_CAVE_ROOM_1_MAX_ELIXER = 1689, + EVENT_SILVER_CAVE_ROOM_1_X_ACCURACY = 1690, + EVENT_SILVER_CAVE_ROOM_2_HIDDEN_MAX_POTION = 156, + EVENT_SLOWPOKE_WELL_B1F_SUPER_POTION = 1634, + EVENT_SLOWPOKE_WELL_B2F_TM_RAIN_DANCE = 1635, + EVENT_SLOWPOKE_WELL_KURT = 1856, + EVENT_SLOWPOKE_WELL_ROCKETS = 1788, + EVENT_SLOWPOKE_WELL_SLOWPOKES = 1787, + EVENT_SOLVED_AERODACTYL_PUZZLE = 675, + EVENT_SOLVED_HO_OH_PUZZLE = 672, + EVENT_SOLVED_KABUTO_PUZZLE = 673, + EVENT_SOLVED_OMANYTE_PUZZLE = 674, + EVENT_SPROUT_TOWER_1F_PARLYZ_HEAL = 1607, + EVENT_SPROUT_TOWER_2F_X_DEFEND = 1608, + EVENT_SPROUT_TOWER_3F_ESCAPE_ROPE = 1610, + EVENT_SPROUT_TOWER_3F_POTION = 1609, + EVENT_SWITCH_1 = 723, + EVENT_SWITCH_2 = 724, + EVENT_SWITCH_3 = 725, + EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO = 63, + EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64, + EVENT_TALKED_TO_OAK_IN_KANTO = 225, + EVENT_TALKED_TO_SEAFOAM_GYM_GUIDE_ONCE = 213, + EVENT_TALKED_TO_WARDENS_GRANDDAUGHTER = 217, + EVENT_TEAM_ROCKET_BASE_B1F_HIDDEN_REVIVE = 134, + EVENT_TEAM_ROCKET_BASE_B1F_HYPER_POTION = 1641, + EVENT_TEAM_ROCKET_BASE_B1F_NUGGET = 1642, + EVENT_TEAM_ROCKET_BASE_B1F_X_ACCURACY = 1643, + EVENT_TEAM_ROCKET_BASE_B2F_DRAGONITE = 1759, + EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_1 = 1760, + EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_2 = 1761, + EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_3 = 1762, + EVENT_TEAM_ROCKET_BASE_B2F_EXECUTIVE = 1757, + EVENT_TEAM_ROCKET_BASE_B2F_GRUNT_WITH_EXECUTIVE = 1758, + EVENT_TEAM_ROCKET_BASE_B2F_HIDDEN_FULL_HEAL = 135, + EVENT_TEAM_ROCKET_BASE_B2F_LANCE = 1750, + EVENT_TEAM_ROCKET_BASE_B2F_TM_THIEF = 1644, + EVENT_TEAM_ROCKET_BASE_B3F_DIRE_HIT = 1646, + EVENT_TEAM_ROCKET_BASE_B3F_EXECUTIVE = 1755, + EVENT_TEAM_ROCKET_BASE_B3F_FULL_HEAL = 1645, + EVENT_TEAM_ROCKET_BASE_B3F_ICE_HEAL = 1648, + EVENT_TEAM_ROCKET_BASE_B3F_LANCE_PASSWORDS = 1751, + EVENT_TEAM_ROCKET_BASE_B3F_PROTEIN = 1647, + EVENT_TEAM_ROCKET_BASE_POPULATION = 1754, + EVENT_TEAM_ROCKET_BASE_SECURITY_GRUNTS = 1753, + EVENT_TEAM_ROCKET_DISBANDED = 1889, + EVENT_TELEPORT_GUY = 1916, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1 = 0, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_2 = 1, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_3 = 2, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_4 = 3, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_5 = 4, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_6 = 5, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_7 = 6, + EVENT_TEMPORARY_UNTIL_MAP_RELOAD_8 = 7, + EVENT_TIFFANY_ASKED_FOR_PHONE_NUMBER = 659, + EVENT_TIFFANY_READY_FOR_REMATCH = 660, + EVENT_TIN_TOWER_3F_FULL_HEAL = 1611, + EVENT_TIN_TOWER_4F_ESCAPE_ROPE = 1614, + EVENT_TIN_TOWER_4F_HIDDEN_MAX_POTION = 125, + EVENT_TIN_TOWER_4F_SUPER_POTION = 1613, + EVENT_TIN_TOWER_4F_ULTRA_BALL = 1612, + EVENT_TIN_TOWER_5F_HIDDEN_CARBOS = 127, + EVENT_TIN_TOWER_5F_HIDDEN_FULL_RESTORE = 126, + EVENT_TIN_TOWER_5F_RARE_CANDY = 1615, + EVENT_TIN_TOWER_7F_MAX_REVIVE = 1616, + EVENT_TIN_TOWER_8F_FULL_RESTORE = 1619, + EVENT_TIN_TOWER_8F_MAX_ELIXER = 1618, + EVENT_TIN_TOWER_8F_NUGGET = 1617, + EVENT_TIN_TOWER_ROOF_HO_OH = 1852, + EVENT_TODD_ASKED_FOR_PHONE_NUMBER = 637, + EVENT_TODD_READY_FOR_REMATCH = 638, + EVENT_TOGEPI_HATCHED = 84, + EVENT_TOHJO_FALLS_MOON_STONE = 1705, + EVENT_TOLD_ELM_ABOUT_TOGEPI_OVER_THE_PHONE = 119, + EVENT_TOTODILE_POKEBALL_IN_ELMS_LAB = 1601, + EVENT_TRAINERS_IN_CERULEAN_GYM = 1903, + EVENT_TURNED_OFF_SECURITY_CAMERAS = 739, + EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART = 738, + EVENT_UNDERGROUND_PATH_HIDDEN_FULL_RESTORE = 229, + EVENT_UNDERGROUND_PATH_HIDDEN_X_SPECIAL = 230, + EVENT_UNION_CAVE_1F_AWAKENING = 1629, + EVENT_UNION_CAVE_1F_GREAT_BALL = 1626, + EVENT_UNION_CAVE_1F_POTION = 1627, + EVENT_UNION_CAVE_1F_X_ATTACK = 1628, + EVENT_UNION_CAVE_B1F_TM_SWIFT = 1630, + EVENT_UNION_CAVE_B1F_X_DEFEND = 1631, + EVENT_UNION_CAVE_B2F_ELIXER = 1632, + EVENT_UNION_CAVE_B2F_HYPER_POTION = 1633, + EVENT_UNION_CAVE_B2F_LAPRAS = 1888, + EVENT_USED_BASEMENT_KEY = 73, + EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER = 37, + EVENT_VANCE_ASKED_FOR_PHONE_NUMBER = 661, + EVENT_VANCE_READY_FOR_REMATCH = 662, + EVENT_VERMILION_CITY_HIDDEN_FULL_HEAL = 252, + EVENT_VERMILION_CITY_SNORLAX = 1904, + EVENT_VERMILION_FAN_CLUB_DOLL = 1908, + EVENT_VERMILION_PORT_HIDDEN_IRON = 235, + EVENT_VERMILION_PORT_SAILOR_AT_GANGWAY = 1835, + EVENT_VICTORY_ROAD_FULL_HEAL = 1702, + EVENT_VICTORY_ROAD_FULL_RESTORE = 1701, + EVENT_VICTORY_ROAD_HIDDEN_FULL_HEAL = 159, + EVENT_VICTORY_ROAD_HIDDEN_MAX_POTION = 158, + EVENT_VICTORY_ROAD_MAX_REVIVE = 1700, + EVENT_VICTORY_ROAD_TM_EARTHQUAKE = 1699, + EVENT_VICTORY_ROAD_X_SPECIAL = 1703, + EVENT_VIOLET_CITY_EARL = 1738, + EVENT_VIOLET_CITY_HIDDEN_HYPER_POTION = 176, + EVENT_VIOLET_CITY_PP_UP = 1603, + EVENT_VIOLET_CITY_RARE_CANDY = 1604, + EVENT_VIRIDIAN_GYM_BLUE = 1910, + EVENT_WADE_ASKED_FOR_PHONE_NUMBER = 629, + EVENT_WADE_READY_FOR_REMATCH = 630, + EVENT_WARPED_FROM_ROUTE_35_NATIONAL_PARK_GATE = 722, + EVENT_WHIRL_ISLAND_B1F_CALCIUM = 1683, + EVENT_WHIRL_ISLAND_B1F_CARBOS = 1682, + EVENT_WHIRL_ISLAND_B1F_ESCAPE_ROPE = 1685, + EVENT_WHIRL_ISLAND_B1F_FULL_RESTORE = 1681, + EVENT_WHIRL_ISLAND_B1F_HIDDEN_FULL_RESTORE = 153, + EVENT_WHIRL_ISLAND_B1F_HIDDEN_RARE_CANDY = 151, + EVENT_WHIRL_ISLAND_B1F_HIDDEN_ULTRA_BALL = 152, + EVENT_WHIRL_ISLAND_B1F_NUGGET = 1684, + EVENT_WHIRL_ISLAND_B2F_FULL_RESTORE = 1686, + EVENT_WHIRL_ISLAND_B2F_MAX_ELIXER = 1688, + EVENT_WHIRL_ISLAND_B2F_MAX_REVIVE = 1687, + EVENT_WHIRL_ISLAND_LUGIA_CHAMBER_LUGIA = 1853, + EVENT_WHIRL_ISLAND_NE_ULTRA_BALL = 1679, + EVENT_WHIRL_ISLAND_SW_GUARD_SPEC = 1680, + EVENT_WILLS_ROOM_ENTRANCE_CLOSED = 777, + EVENT_WILLS_ROOM_EXIT_OPEN = 778, + EVENT_WILTON_ASKED_FOR_PHONE_NUMBER = 663, + EVENT_WILTON_READY_FOR_REMATCH = 664, + }, + engine = { + ENGINE_ALL_FRUIT_TREES = 83, + ENGINE_ALWAYS_ON_BIKE = 24, + ENGINE_BIKE_SHOP_CALL_ENABLED = 19, + ENGINE_BOULDERBADGE = 34, + ENGINE_BUG_CONTEST_TIMER = 16, + ENGINE_CASCADEBADGE = 35, + ENGINE_CAUGHT_POKERUS = 13, + ENGINE_CREDITS_SKIP = 15, + ENGINE_DAILY_BUG_CONTEST = 80, + ENGINE_DAISYS_GROOMING = 91, + ENGINE_DAY_CARE_LADY_HAS_MON = 7, + ENGINE_DAY_CARE_MAN_HAS_EGG = 5, + ENGINE_DAY_CARE_MAN_HAS_MON = 6, + ENGINE_DOWNHILL = 25, + ENGINE_EARTHBADGE = 41, + ENGINE_EXPN_CARD = 3, + ENGINE_FLYPOINT_AZALEA = 67, + ENGINE_FLYPOINT_BLACKTHORN = 74, + ENGINE_FLYPOINT_CELADON = 60, + ENGINE_FLYPOINT_CERULEAN = 55, + ENGINE_FLYPOINT_CHERRYGROVE = 65, + ENGINE_FLYPOINT_CIANWOOD = 68, + ENGINE_FLYPOINT_CINNABAR = 62, + ENGINE_FLYPOINT_DEBUG = 51, + ENGINE_FLYPOINT_ECRUTEAK = 71, + ENGINE_FLYPOINT_FUCHSIA = 61, + ENGINE_FLYPOINT_GOLDENROD = 69, + ENGINE_FLYPOINT_INDIGO_PLATEAU = 63, + ENGINE_FLYPOINT_LAKE_OF_RAGE = 73, + ENGINE_FLYPOINT_LAVENDER = 58, + ENGINE_FLYPOINT_MAHOGANY = 72, + ENGINE_FLYPOINT_NEW_BARK = 64, + ENGINE_FLYPOINT_OLIVINE = 70, + ENGINE_FLYPOINT_PALLET = 52, + ENGINE_FLYPOINT_PEWTER = 54, + ENGINE_FLYPOINT_PLAYERS_HOUSE = 50, + ENGINE_FLYPOINT_ROCK_TUNNEL = 56, + ENGINE_FLYPOINT_SAFFRON = 59, + ENGINE_FLYPOINT_SILVER_CAVE = 75, + ENGINE_FLYPOINT_UNUSED = 76, + ENGINE_FLYPOINT_VERMILION = 57, + ENGINE_FLYPOINT_VIOLET = 66, + ENGINE_FLYPOINT_VIRIDIAN = 53, + ENGINE_FOGBADGE = 29, + ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY = 86, + ENGINE_GLACIERBADGE = 32, + ENGINE_GOLDENROD_DEPT_STORE_TM27_RETURN = 90, + ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT = 89, + ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED = 85, + ENGINE_GOT_SHUCKIE_TODAY = 84, + ENGINE_HIVEBADGE = 27, + ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT = 92, + ENGINE_KURT_MAKING_BALLS = 79, + ENGINE_LUCKY_NUMBER_SHOW = 77, + ENGINE_MAP_CARD = 1, + ENGINE_MARSHBADGE = 39, + ENGINE_MINERALBADGE = 30, + ENGINE_MOM_ACTIVE = 9, + ENGINE_MOM_SAVING_MONEY = 8, + ENGINE_MT_MOON_SQUARE_CLEFAIRY = 87, + ENGINE_PHONE_CARD = 2, + ENGINE_PLAINBADGE = 28, + ENGINE_POKEDEX = 11, + ENGINE_POKEGEAR = 4, + ENGINE_RADIO_CARD = 0, + ENGINE_RAINBOWBADGE = 37, + ENGINE_REACHED_GOLDENROD = 21, + ENGINE_RISINGBADGE = 33, + ENGINE_ROCKETS_IN_MAHOGANY = 22, + ENGINE_ROCKETS_IN_RADIO_TOWER = 18, + ENGINE_ROCKET_SIGNAL_ON_CH20 = 14, + ENGINE_SAFARI_ZONE = 17, + ENGINE_SOULBADGE = 38, + ENGINE_STORMBADGE = 31, + ENGINE_STRENGTH_ACTIVE = 23, + ENGINE_SWARM = 81, + ENGINE_THUNDERBADGE = 36, + ENGINE_TIME_CAPSULE = 82, + ENGINE_UNION_CAVE_LAPRAS = 88, + ENGINE_UNLOCKED_UNOWNS_A_TO_K = 42, + ENGINE_UNLOCKED_UNOWNS_L_TO_R = 43, + ENGINE_UNLOCKED_UNOWNS_S_TO_W = 44, + ENGINE_UNLOCKED_UNOWNS_UNUSED_4 = 46, + ENGINE_UNLOCKED_UNOWNS_UNUSED_5 = 47, + ENGINE_UNLOCKED_UNOWNS_UNUSED_6 = 48, + ENGINE_UNLOCKED_UNOWNS_UNUSED_7 = 49, + ENGINE_UNLOCKED_UNOWNS_X_TO_Z = 45, + ENGINE_UNOWN_DEX = 12, + ENGINE_UNUSED_STATUSFLAGS2_3 = 78, + ENGINE_UNUSED_STATUSFLAGS2_5 = 20, + ENGINE_UNUSED_TWO_DAY_TIMER_ON = 10, + ENGINE_VOLCANOBADGE = 40, + ENGINE_ZEPHYRBADGE = 26, + }, +} diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 725c013c..a8477d7e 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -299,6 +299,11 @@ end local CART_DRAG_SLOP = 8 local TAU = math.pi * 2 +-- The 3D mesh is inset inside the hit box so yaw/pitch and the 1.05 hover +-- scale cannot climb into the title row (or the gear) on desktop, high-DPI, +-- or a portrait phone. Fraction of the shorter side, with a pixel floor. +local CART_MESH_PAD = 0.07 +local CART_MESH_PAD_MIN = 8 local function cartridgeState(imp, version) imp._cartridge = imp._cartridge or {} @@ -543,8 +548,11 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action) Theme.A.focus, 2, Theme.cardRadius() + 2) end - local halfW, halfH = w / 2, h / 2 - local depth = math.max(8, w * 0.14) + local meshPad = math.max(CART_MESH_PAD_MIN, + math.floor(math.min(w, h) * CART_MESH_PAD)) + local halfW = math.max(1, w / 2 - meshPad) + local halfH = math.max(1, h / 2 - meshPad) + local depth = math.max(8, (halfW * 2) * 0.14) local project = function(px, py, pz) return cartProject(cx + pressX, cy + pressY, yaw, pitch, px * pressedScale, py * pressedScale, pz * pressedScale) @@ -630,6 +638,7 @@ end local function modStatusColor(status) if status == "ok" then return Strings("Ready"), PAL.green end + if status == "needs_import" then return Strings("Import required"), PAL.yellow end if status == "conflict" then return Strings("Conflict"), PAL.red end -- not a fault: the mod is intact, this is simply not a game it is for -- (src/mods/ModTargets.lua) @@ -638,14 +647,8 @@ local function modStatusColor(status) end -- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear). -local function buildModScopeRow(imp, x, y, w, m) +local function modScopeOptions(imp) local GameVersion = require("src.core.GameVersion") - local LauncherMods = require("src.mods.LauncherMods") - local h = math.max(Kit.tapMin(), math.floor(26 * m.s)) - local gap = math.floor(6 * m.s) - local label = Strings("Show for:") - Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted) - local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s) local options = { { id = nil, label = Strings("All games") } } for _, version in ipairs(GameVersion.ORDER) do if imp.ready and imp.ready[version] then @@ -653,6 +656,33 @@ local function buildModScopeRow(imp, x, y, w, m) { id = version, label = GameVersion.info(version).label } end end + return options +end + +local function modScopeCurrentLabel(imp, options) + for _, opt in ipairs(options) do + if imp.modScope == opt.id then return opt.label end + end + return options[1] and options[1].label or Strings("All games") +end + +local function modScopeChipsWidth(options, gap, m) + local need = 0 + for i, opt in ipairs(options) do + need = need + Kit.textWidth("micro", opt.label) + math.floor(18 * m.s) + if i < #options then need = need + gap end + end + return need +end + +local function buildModScopeRow(imp, x, y, w, m) + local LauncherMods = require("src.mods.LauncherMods") + local h = math.max(Kit.tapMin(), math.floor(26 * m.s)) + local gap = math.floor(6 * m.s) + local label = Strings("Show for:") + Kit.text("small", label, x, y + (h - Kit.textHeight("small")) / 2, PAL.muted) + local cx = x + Kit.textWidth("small", label) + math.floor(10 * m.s) + local options = modScopeOptions(imp) -- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar local _, activeProf = LauncherMods.getProfiles() @@ -690,9 +720,12 @@ local function buildModScopeRow(imp, x, y, w, m) }) if #options >= 2 then - for _, opt in ipairs(options) do - local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s) - if cx + cw <= profX - gap then + local avail = profX - gap - cx + -- Chips stay when they all fit; otherwise they used to be skipped and + -- vanish off the portrait edge. Collapse to one menu in that case only. + if modScopeChipsWidth(options, gap, m) <= avail then + for _, opt in ipairs(options) do + local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s) if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong, "mod-scope-" .. tostring(opt.id or "all")) then local want = opt.id @@ -701,6 +734,15 @@ local function buildModScopeRow(imp, x, y, w, m) end cx = cx + cw + gap end + elseif avail > 0 then + local shown = Kit.ellipsize("micro", modScopeCurrentLabel(imp, options), + math.max(0, avail - math.floor(18 * m.s))) + local cw = math.min(avail, + Kit.textWidth("micro", shown) + math.floor(18 * m.s)) + if Kit.chip(cx, y, cw, h, shown, true, PAL.lineStrong, "mod-scope-menu") then + queueAction(imp, "mod-scope-menu", + function() imp._modScopePopup = true end) + end end end return h + math.floor(8 * m.s) @@ -740,8 +782,8 @@ local function setPage(imp, key, v) imp._pages[key] = v end --- A hand-drawn X, for the same reason drawCheck exists below: the UI font has --- no guaranteed glyph, and the launcher ships no icon asset for it. +-- A hand-drawn X / check: the UI font has no guaranteed glyph for either, +-- and the launcher ships no icon asset for them. local function drawCross(x, y, size, color) love.graphics.push("all") love.graphics.setColor(color) @@ -752,6 +794,18 @@ local function drawCross(x, y, size, color) love.graphics.pop() end +local function drawCheck(x, y, size, color) + love.graphics.push("all") + love.graphics.setColor(color) + love.graphics.setLineWidth(math.max(2.2, size * 0.17)) + love.graphics.setLineJoin("bevel") + love.graphics.line( + x + size * 0.02, y + size * 0.52, + x + size * 0.38, y + size * 0.80, + x + size * 1.015, y + size * 0.18) + love.graphics.pop() +end + -- ------------------------------------------------------------- header -- Rail, logo row (settings and quit on the right), tab bar. -- Returns the y at which content may start. Its vertical arithmetic is @@ -1316,20 +1370,32 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) or tostring(version) local ready = (not locked) and imp.ready[version] or false - -- title + status tag + -- title + status tag. Ready is a check chip (the font has no tick glyph); + -- missing ROM stays a yellow "ROM REQUIRED" tag so it still reads as an action. local titleH = Kit.textHeight("title") Kit.text("title", Kit.ellipsize("title", gameName, w * 0.6), x, y, PAL.heading) - local tagText, tagCol - if ready then tagText, tagCol = Strings("GOOD TO GO"), PAL.green - elseif imp.baseRoms and imp.baseRoms[version] then - tagText, tagCol = Strings("ROM FOUND"), PAL.green - elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel - else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end - local tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s) local tagH = Kit.textHeight("micro") + math.floor(10 * m.s) local tagX = x + Kit.textWidth("title", Kit.ellipsize("title", gameName, w * 0.6)) + math.floor(12 * m.s) - Kit.tag(tagX, y + (titleH - tagH) / 2, tagW, tagH, tagText, tagCol) + local tagY = y + (titleH - tagH) / 2 + local tagW, tagCol + if ready then + tagCol = PAL.green + tagW = tagH + if love.graphics then + Theme.strokeRounded(tagX, tagY, tagW, tagH, tagCol, 0.7, 1) + local ck = math.floor(tagH * 0.55) + drawCheck(tagX + (tagW - ck) / 2, tagY + (tagH - ck) / 2, ck, tagCol) + end + else + local tagText + if imp.baseRoms and imp.baseRoms[version] then + tagText, tagCol = Strings("ROM FOUND"), PAL.green + elseif locked then tagText, tagCol = Strings("COMING SOON"), PAL.steel + else tagText, tagCol = Strings("ROM REQUIRED"), PAL.yellow end + tagW = Kit.textWidth("micro", tagText) + math.floor(18 * m.s) + Kit.tag(tagX, tagY, tagW, tagH, tagText, tagCol) + end if ready then local hint = Strings("(PRESS THE CART TO PLAY)") local hintX = tagX + tagW + math.floor(10 * m.s) @@ -1337,8 +1403,11 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) Kit.text("micro", Kit.ellipsize("micro", hint, hintW), hintX, y + (titleH - Kit.textHeight("micro")) / 2, PAL.heading) end - local cy = y + titleH + math.floor(12 * m.s) - local remaining = availH - (titleH + math.floor(12 * m.s)) + -- Extra gap under the title when the cart is showing: 12px left the 3D + -- shell sitting on the hairline. Scaled, and still small on a phone. + local afterTitle = math.floor((ready and 22 or 12) * m.s) + local cy = y + titleH + afterTitle + local remaining = availH - (titleH + afterTitle) local gap = m.gap local lx, lw, rx2, rw @@ -1446,20 +1515,6 @@ local function currentSort(imp) return sortKey end --- A hand-drawn check mark: the UI font has no guaranteed glyph for one, and --- a tofu box on the "you already have this" signal would be worse than none. -local function drawCheck(x, y, size, color) - love.graphics.push("all") - love.graphics.setColor(color) - love.graphics.setLineWidth(math.max(2.2, size * 0.17)) - love.graphics.setLineJoin("bevel") - love.graphics.line( - x + size * 0.02, y + size * 0.52, - x + size * 0.38, y + size * 0.80, - x + size * 1.015, y + size * 0.18) - love.graphics.pop() -end - -- One compact coloured checkbox for each game. The cartridge colour carries -- the game identity even when the row is narrow. local function modGameCheckbox(x, y, size, checked, game, id) @@ -1978,21 +2033,47 @@ local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. "it might have been tampered with. go to the discord to verify " .. COMMUNITY_URL .. " (or click the logo above)" +-- Mark + optional updater + Patch notes. Chips match the mark's 22px +-- height so they do not read as bigger than the logo; the row can still +-- be tapMin tall for spacing. On a phone the notes chip drops onto a +-- second row rather than overflowing the mark. +local function footerLayout(imp, m, markW) + local markH = math.floor(22 * m.s) + local rowH = math.max(markH, Kit.tapMin()) + local notesLabel = Strings("Patch notes") + -- Tight chip, still enough for Kit.button's labelInset so the words survive. + local chipPad = math.floor(24 * m.s) + local nw = Kit.textWidth("micro", notesLabel) + chipPad + local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp) + local uw = upStatus + and (Kit.textWidth("micro", upLabel) + chipPad) or 0 + local gap = math.floor(10 * m.s) + local inner = m.w - 2 * m.pad + local topW = (markW or 0) + (upStatus and (gap + uw) or 0) + gap + nw + return { + rowH = rowH, chipH = markH, gap = gap, + notesLabel = notesLabel, nw = nw, + upStatus = upStatus, upLabel = upLabel, upAction = upAction, upGlow = upGlow, + uw = uw, wrap = topW > inner, + } +end + -- Pinned to the bottom of the window; returns the y it starts at, so the -- panels above know how much room they have. -- Deliberately compact: at a large UI scale the footer is pure overhead -- competing with the panel for a short window's height, so the mark and the -- link share one line and the trust warning is capped at a single line. local function footerHeight(imp, m) - -- Top pad + mark/update row + gap + the FULL wrapped trust message + - -- bottom pad. The message wraps to as many lines as it needs: truncating - -- a trust warning defeats its purpose, and the bottom pad is not optional - -- either (without it the last line sits flush on the window edge and its - -- lower half clips off). The row is tapMin tall because the small update - -- button rides beside the mark. - local rowH = math.max(math.floor(22 * m.s), Kit.tapMin()) - return math.floor(8 * m.s) + rowH + math.floor(6 * m.s) - + Kit.wrapHeight("micro", TRUST_WARNING, m.contentW) + -- Top pad + mark/update row + optional notes wrap row + gap + the FULL + -- wrapped trust message + bottom pad. The message wraps to as many lines + -- as it needs: truncating a trust warning defeats its purpose, and the + -- bottom pad is not optional either (without it the last line sits flush + -- on the window edge and its lower half clips off). The row is tapMin + -- tall because the small update button rides beside the mark. + local f = footerLayout(imp, m, math.floor(130 * m.s)) + local h = math.floor(8 * m.s) + f.rowH + math.floor(6 * m.s) + if f.wrap then h = h + f.chipH + math.floor(6 * m.s) end + return h + Kit.wrapHeight("micro", TRUST_WARNING, m.contentW) + math.floor(8 * m.s) end @@ -2009,19 +2090,17 @@ local function buildFooter(imp, m, y) local bw, bh = imp.bcg:getDimensions() local scale = math.min((130 * m.s) / bw, (22 * m.s) / bh) local dw, dh = bw * scale, bh * scale - local rowH = math.max(math.floor(22 * m.s), Kit.tapMin()) - -- The mark and the small self-update control share the row, centred as a - -- group. The updater moved down here from the header, where it overlapped - -- the wordmark on a phone; small on purpose, its glow still carries the - -- "act on me" signal. - local upStatus, upLabel, upAction, upGlow = LauncherView._updateControl(imp) - -- Kit.button insets its label 16*scale per side, so the width must budget - -- more than that or the label ellipsizes ("Check for updat..."). - local uw = upStatus - and (Kit.textWidth("micro", upLabel) + math.floor(36 * m.s)) or 0 - local groupW = dw + (upStatus and (math.floor(10 * m.s) + uw) or 0) - local bx = m.x + math.floor((m.w - groupW) / 2) + local f = footerLayout(imp, m, dw) + local rowH, gap, chipH = f.rowH, f.gap, f.chipH + -- The mark, the small self-update control, and Patch notes share the row, + -- centred as a group. The updater moved down here from the header, where + -- it overlapped the wordmark on a phone; small on purpose, its glow still + -- carries the "act on me" signal. Notes wrap under the mark on a phone. + local topW = dw + (f.upStatus and (gap + f.uw) or 0) + if not f.wrap then topW = topW + gap + f.nw end + local bx = m.x + math.floor((m.w - topW) / 2) local my = cy + math.floor((rowH - dh) / 2) + local chipY = cy + math.floor((rowH - chipH) / 2) local hot = Kit.hover(bx, my, dw, dh) love.graphics.setShader(imp.invertShader) love.graphics.setColor(1, 1, 1, hot and 1 or 0.85) @@ -2031,14 +2110,30 @@ local function buildFooter(imp, m, y) if Kit.press(bx, my, dw, dh) then queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end) end - if upStatus then - btn(imp, bx + dw + math.floor(10 * m.s), cy, uw, rowH, "updater", - upLabel, { - kind = upGlow and "warn" or "ghost", font = "micro", - glow = upGlow, action = upAction, + local cx = bx + dw + if f.upStatus then + cx = cx + gap + btn(imp, cx, chipY, f.uw, chipH, "updater", + f.upLabel, { + kind = f.upGlow and "warn" or "ghost", font = "micro", + glow = f.upGlow, action = f.upAction, }) + cx = cx + f.uw + end + local function notesBtn(x, y) + btn(imp, x, y, f.nw, chipH, "patch-notes", f.notesLabel, { + kind = "ghost", font = "micro", + action = function() imp._appPatchNotes = true end, + }) + end + if f.wrap then + cy = cy + rowH + gap + notesBtn(m.x + math.floor((m.w - f.nw) / 2), cy) + cy = cy + chipH + math.floor(6 * m.s) + else + notesBtn(cx + gap, chipY) + cy = cy + rowH + math.floor(6 * m.s) end - cy = cy + rowH + math.floor(6 * m.s) -- The trust message wraps in full, each line centred under the mark, and -- the URL inside it IS the link -- no separate link floating elsewhere. -- font:getWrap never splits an unspaced word, so the URL stays whole on @@ -2543,6 +2638,34 @@ local function buildSortModal(imp, m) action = function() imp._sortPopup = nil end }) end +-- Game-scope chooser used when the Show-for chips cannot all fit on the +-- mods toolbar (portrait phones). Same options as the chip row. +local function buildModScopeModal(imp, m) + local options = modScopeOptions(imp) + local pad = math.floor(18 * m.s) + local w = math.floor(360 * m.s) + local gap = math.floor(8 * m.s) + local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + + #options * (m.btnH + gap) + m.btnH + pad + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + Kit.text("button", Strings("Show for"), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(12 * m.s) + for _, opt in ipairs(options) do + local key = tostring(opt.id or "all") + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-" .. key, opt.label, { + kind = (imp.modScope == opt.id) and "primary" or "ghost", font = "small", + action = function() + imp:_setModScope(opt.id) + imp._modScopePopup = nil + end }) + cy = cy + m.btnH + gap + end + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "scopepop-close", + Strings("Close"), { font = "small", + action = function() imp._modScopePopup = nil end }) +end + -- Category filter for FIND MODS. Two columns, because an index can list -- enough categories to overflow a single stacked column on a short window. local function buildFilterModal(imp, m) @@ -2645,11 +2768,14 @@ local function buildModActionsModal(imp, m) local hasGit = mod.github and mod.github ~= "" local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs) local hasDeps = depSpecs and #depSpecs > 0 + local imports = mod.imports or mod.requiredImports + local hasImports = imports and #imports > 0 local info = hasGit and imp:_modUpdateInfo(mod.id) local pad = math.floor(18 * m.s) local w = math.floor(440 * m.s) local gap = math.floor(8 * m.s) - local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + 2 + local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + + (hasImports and 1 or 0) + 2 local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) + Kit.textHeight("small") + math.floor(12 * m.s) + nBtns * (m.btnH + gap) - gap + pad @@ -2699,6 +2825,19 @@ local function buildModActionsModal(imp, m) end }) cy = cy + m.btnH + gap end + if hasImports then + local missing = tonumber(mod.missingRequiredImports) or 0 + local label = missing > 0 + and Strings("Imported files (%d required)", missing) + or Strings("Imported files") + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-imports", + label, { kind = missing > 0 and "warn" or "accent", font = "small", + action = function() + imp._modImports = id + imp._modActions = nil + end }) + cy = cy + m.btnH + gap + end local armed = deleteArmed(imp, "mod", id, nil) btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del", DELETE_LABEL(armed), { @@ -2715,6 +2854,109 @@ local function buildModActionsModal(imp, m) action = function() imp._modActions = nil end }) end +-- Imported files declared by one installed mod. The engine picks, validates, +-- canonicalizes and copies; this surface never exposes a host path to mod code. +local function buildRequiredImportsModal(imp, m) + local mod + for _, candidate in ipairs(imp.mods or {}) do + if candidate.id == imp._modImports then mod = candidate break end + end + if not mod then imp._modImports = nil return end + local imports = mod.imports or mod.requiredImports or {} + local pad, gap = math.floor(18 * m.s), math.floor(8 * m.s) + local w = math.floor(540 * m.s) + local notice = imp.requiredImportNotice + if not notice or notice.modId ~= mod.id then notice = nil end + local noticeText + if notice then + local importName = notice.importId + for _, row in ipairs(imports) do + if row.id == notice.importId then importName = row.name break end + end + noticeText = Strings("%s rejected: %s", importName, notice.text) + end + local noticeW = w - 2 * pad + local noticeH = noticeText and Kit.wrapHeight("small", noticeText, noticeW, 2) or 0 + local rowH = math.max(math.floor(70 * m.s), m.btnH) + local perPage = math.min(4, math.max(1, #imports)) + local pagerH = #imports > perPage and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0 + local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) + + Kit.textHeight("small") + math.floor(12 * m.s) + + noticeH + (noticeH > 0 and gap or 0) + + perPage * rowH + math.max(0, perPage - 1) * gap + + (pagerH > 0 and (gap + pagerH) or 0) + gap + m.btnH + pad + local px, py, pw = modalPanel(m, w, h) + local cy = py + pad + Kit.text("button", Kit.ellipsize("button", mod.name, pw - 2 * pad), + px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + math.floor(4 * m.s) + Kit.text("small", Strings("User-supplied files are validated by MD5 and copied into this mod only."), + px + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + math.floor(12 * m.s) + if noticeText then + cy = cy + Kit.textWrapped("small", noticeText, px + pad, cy, + pw - 2 * pad, PAL.red, 2) + gap + end + + local pageKey = "required-imports-" .. mod.id + local cur = page(imp, pageKey) + local first, last, bounded = Kit.pageBounds(cur, #imports, perPage) + setPage(imp, pageKey, bounded) + for i = first, last do + local row = imports[i] + local importId = row.id + Kit.card(px + pad, cy, pw - 2 * pad, rowH, row.present and "muted" or false) + local innerX = px + pad + math.floor(12 * m.s) + local actionW = math.floor(108 * m.s) + local removeW = row.present and math.floor(86 * m.s) or 0 + local actionX = px + pw - pad - math.floor(10 * m.s) - actionW + if removeW > 0 then actionX = actionX - removeW - math.floor(6 * m.s) end + local textW = actionX - innerX - math.floor(8 * m.s) + Kit.text("small", Kit.ellipsize("small", row.name, textW), innerX, + cy + math.floor(8 * m.s), PAL.heading) + local stateY = cy + math.floor(8 * m.s) + Kit.textHeight("small") + + math.floor(3 * m.s) + if row.description and row.description ~= "" then + Kit.text("micro", Kit.ellipsize("micro", row.description, textW), + innerX, stateY, PAL.muted) + stateY = stateY + Kit.textHeight("micro") + math.floor(2 * m.s) + end + local state = row.present and Strings("Ready - %s", row.file) + or (row.error and Strings("Invalid file - choose again") + or (row.required and Strings("Required - %s", row.file) + or Strings("Optional - %s", row.file))) + Kit.text("micro", Kit.ellipsize("micro", state, textW), innerX, stateY, + row.present and PAL.green or (row.required and PAL.yellow or PAL.muted)) + btn(imp, actionX, cy + (rowH - m.btnH) / 2, actionW, m.btnH, + "req-pick-" .. mod.id .. "-" .. importId, + row.present and Strings("Replace") or Strings("Choose file"), { + kind = row.present and "ghost" or "accent", font = "small", + action = function() imp:chooseRequiredImport(mod.id, importId) end }) + if row.present then + local deleteId = mod.id .. ":" .. importId + local armed = deleteArmed(imp, "required-import", deleteId, nil) + btn(imp, actionX + actionW + math.floor(6 * m.s), + cy + (rowH - m.btnH) / 2, removeW, m.btnH, + "req-remove-" .. mod.id .. "-" .. row.id, DELETE_LABEL(armed), { + kind = "danger", font = "small", keepArm = true, + action = function() + imp:pressDelete("required-import", deleteId, nil, function() + imp:_removeRequiredImport(mod.id, importId) + end) + end }) + end + cy = cy + rowH + gap + end + if pagerH > 0 then + local newPage = Kit.pager(px + pad, cy, pw - 2 * pad, bounded, + #imports, perPage, pageKey) + setPage(imp, pageKey, newPage) + cy = cy + pagerH + gap + end + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "req-close", Strings("Close"), { + font = "small", action = function() imp._modImports = nil end }) +end + -- Per-mod popup for FIND MODS: the row is a plain click, and Install / -- Details / Source live here instead of crowding every row. local function buildFindEntryModal(imp, m) @@ -3253,8 +3495,10 @@ end local function modalUp(imp) return (imp._settingsText or imp._settings or imp._rename or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes + or imp._appPatchNotes or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup - or imp._filterPopup or imp._indexManage or imp._modActions + or imp._filterPopup or imp._modScopePopup or imp._indexManage + or imp._modActions or imp._modImports or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil end @@ -3351,6 +3595,20 @@ local function buildModals(imp, m) return true end if imp._modConfirm then buildConfirmModal(imp, m) return true end + if imp._appPatchNotes then + local PatchNotes = require("src.update.PatchNotes") + local ModUpdate = require("src.mods.ModUpdate") + local raw, ver = PatchNotes.body(imp.Check) + local body = ModUpdate.cleanBody(raw or "", 0) + if body == "" then body = Strings("(No patch notes.)") end + local title = Strings("Patch notes") + if ver and ver ~= "" then + title = title .. " v" .. tostring(ver) + end + buildTextModal(imp, m, "patch-notes-modal", title, body, + function() imp._appPatchNotes = nil end) + return true + end if imp._modReleaseNotes then local ModUpdate = require("src.mods.ModUpdate") local n = imp._modReleaseNotes @@ -3372,6 +3630,7 @@ local function buildModals(imp, m) end if imp._modVersions then buildVersionsModal(imp, m) return true end if imp._modDepResolver then buildDepResolverModal(imp, m) return true end + if imp._modImports then buildRequiredImportsModal(imp, m) return true end -- The lighter popups come after the deep ones on purpose: opening -- Versions or Details from inside an actions popup draws the deeper modal -- while the popup's own state stays set, so closing the deep one drops @@ -3380,6 +3639,7 @@ local function buildModals(imp, m) if imp._profilesPopup then buildProfilesModal(imp, m) return true end if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end if imp._sortPopup then buildSortModal(imp, m) return true end + if imp._modScopePopup then buildModScopeModal(imp, m) return true end if imp._filterPopup then buildFilterModal(imp, m) return true end if imp._indexManage then buildIndexesModal(imp, m) return true end if imp._modActions then buildModActionsModal(imp, m) return true end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 5311ee1e..1b178af4 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -344,6 +344,14 @@ local function readExternalPath(path) return data end +local function externalFileSize(path) + local file = io.open(path, "rb") + if not file then return nil end + local size = file:seek("end") + file:close() + return size +end + local function readDroppedFile(file) local ok, openError = file:open("r") if not ok then return nil, openError end @@ -981,6 +989,25 @@ local function findPendingSav(preferAny, skip) return nil end +local function pickerHasKind(kind) + local fn = love.system.pickFileKinds + if type(fn) ~= "function" then return false end + local ok, kinds = pcall(fn) + if not ok or type(kinds) ~= "string" then return false end + for token in kinds:gmatch("[^,%s]+") do + if token == kind then return true end + end + return false +end + +local function findPendingRequiredImport() + local names = { "picked_required_import.bin", "picked_stadium.z64" } + for _, name in ipairs(names) do + if love.filesystem.getInfo(name, "file") then return name end + end + return nil +end + -- Retire an Android pick once it has been through the installer / importer, -- whether or not it worked: a pick left on disk wins the scans above forever, -- so the next tap re-runs the same failing file and the picker never reopens @@ -1112,6 +1139,39 @@ local function chooseSav() return nil end +-- Generic user-supplied dependency picker. Keep the native prompt entirely +-- engine-owned: manifest labels are untrusted and must never enter shell +-- command templates. The LÖVE modal already shows the specific import name. +local function chooseRequiredFile() + local prompt = shellSafe(Strings("Choose required mod file")) + local platform = love.system.getOS() + if platform == "OS X" then + return commandOutput( + ([[osascript -e 'POSIX path of (choose file with prompt "%s")' 2>/dev/null]]) + :format(prompt)) + elseif platform == "Windows" then + local script = table.concat({ + "Add-Type -AssemblyName System.Windows.Forms;", + "$d=New-Object System.Windows.Forms.OpenFileDialog;", + "$d.Title='" .. prompt .. "';", + "$d.Filter='All files (*.*)|*.*';", + "if($d.ShowDialog() -eq 'OK'){", + "$t=Join-Path $env:TEMP 'pokeport_required_import.bin';", + "Copy-Item -LiteralPath $d.FileName -Destination $t -Force;", + "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", + "[Console]::Write($t)}", + }) + return commandOutput( + 'powershell -NoProfile -STA -Command "' .. script .. '"') + elseif platform == "Linux" then + local path = commandOutput( + ([[zenity --file-selection --title="%s" 2>/dev/null]]):format(prompt)) + if path then return path end + return commandOutput([[kdialog --getopenfilename "$HOME" 2>/dev/null]]) + end + return nil +end + -- The self-updater only surfaces on the real distributed build: a fused, -- interactive launcher with no scripted-run override. A dev / source checkout -- (unfused, where Boot.run already no-ops) or an autopilot / driver / @@ -1230,7 +1290,9 @@ function RomImporter.new(onComplete, opts) -- (refreshed lazily on first draw and after any toggle/install/delete); -- modScroll is the current paged list's inner scroll offset (px, clamped -- in draw); modNotice is the last install/delete result { ok, text }. - mods = nil, modScroll = 0, modNotice = nil, + -- requiredImportNotice stays inside the imported-files modal so validation + -- failures are visible beside the file picker that caused them. + mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil, -- Which game the MODS panel is answering for (a GameVersion id, nil = -- every game). Rows resolve their enable-state and their "runs here" -- verdict against it (src/mods/ModTargets.lua). @@ -1420,7 +1482,13 @@ function RomImporter:focus(f) local text = "Could not read the picked file. Reopen the picker and choose " .. "it with the Files (Documents) app, or copy it into: " .. love.filesystem.getSaveDirectory() - if pickError:find("picked_mod", 1, true) then + if pickError:find("picked_required_import", 1, true) + or pickError:find("picked_stadium", 1, true) then + self.modNotice = { ok = false, text = text } + self.pickerPendingKind = nil + self.pickerPendingModId = nil + self.pickerPendingImportId = nil + elseif pickError:find("picked_mod", 1, true) then self.modNotice = { ok = false, text = text } elseif pickError:find("picked_save", 1, true) then local version = self.androidPendingVersion or self:_savedropTarget() @@ -1431,6 +1499,20 @@ function RomImporter:focus(f) end return end + local requiredName = findPendingRequiredImport() + if requiredName then + local modId, importId = self.pickerPendingModId, self.pickerPendingImportId + self.pickerPendingKind = nil + self.pickerPendingModId, self.pickerPendingImportId = nil, nil + local imported = modId and importId + and self:_importRequiredSource(modId, importId, requiredName) + consumePick(self, requiredName, requiredName, imported) + if not modId or not importId then + self.modNotice = { ok = false, + text = "A picked dependency file had no pending mod request and was discarded." } + end + return + end local modName = findPendingMod(false, self.pickSkip) if modName then self:_installMod(modName) @@ -1738,6 +1820,152 @@ function RomImporter:chooseMod() if path then self:_installMod(path) end end +local function requiredManifest(self, modId) + for _, row in ipairs(self.mods or {}) do + if row.id == modId then return row.manifest, row end + end + return nil +end + +local function requiredSpec(manifest, importId) + for _, candidate in ipairs(require("src.mods.RequiredImports").specs(manifest)) do + if candidate.id == importId then return candidate end + end + return nil +end + +local function requiredImportNotice(self, modId, importId, text) + self.requiredImportNotice = { + modId = modId, + importId = importId, + text = tostring(text), + } +end + +function RomImporter:_importRequiredData(modId, importId, data) + local manifest = requiredManifest(self, modId) + if not manifest then + self.modNotice = { ok = false, text = "Required import failed: mod not found." } + return nil + end + local ok, result = require("src.mods.RequiredImports") + .importData(manifest, importId, data) + if ok then + self.requiredImportNotice = nil + self.modNotice = { ok = true, text = "Imported " .. tostring(importId) + .. " for " .. tostring(manifest.name or manifest.id) .. "." } + self:_refreshMods() + return true + end + -- Keep validation feedback on the imported-files page. A general Mods-page + -- notice is hidden by this modal and made MD5 failures especially easy to miss. + requiredImportNotice(self, modId, importId, result) + self.modNotice = nil + return nil +end + +function RomImporter:_importRequiredSource(modId, importId, source) + local manifest = requiredManifest(self, modId) + local spec = manifest and requiredSpec(manifest, importId) + if not spec then + requiredImportNotice(self, modId, importId, "Import declaration was not found.") + self.modNotice = nil + return nil + end + local info = love.filesystem.getInfo(source, "file") + local size = info and info.size or externalFileSize(source) + local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false) + if sizeErr then + requiredImportNotice(self, modId, importId, sizeErr) + self.modNotice = nil + return nil + end + local data = love.filesystem.read(source) + if not data then data = readExternalPath(source) end + if not data then + requiredImportNotice(self, modId, importId, "Could not read the selected file.") + self.modNotice = nil + return nil + end + return self:_importRequiredData(modId, importId, data) +end + +function RomImporter:_removeRequiredImport(modId, importId) + local manifest = requiredManifest(self, modId) + if not manifest then return end + local ok, err = require("src.mods.RequiredImports").remove(manifest, importId) + if ok then + self.requiredImportNotice = nil + self.modNotice = { ok = true, text = "Deleted " .. tostring(importId) .. "." } + self:_refreshMods() + else + requiredImportNotice(self, modId, importId, err) + self.modNotice = nil + end +end + +-- Select and validate one manifest-declared file. NX has no host picker, so +-- its equivalent is an engine-owned imports/baseroms inbox that can be filled +-- over MTP; every other native/mobile picker lands on the same validation path. +function RomImporter:chooseRequiredImport(modId, importId) + if self.workState == "working" then return end + local manifest = requiredManifest(self, modId) + if not manifest then return end + local spec = requiredSpec(manifest, importId) + if not spec then return end + + if self.isNX then + local inbox = "imports/baseroms" + love.filesystem.createDirectory(inbox) + local lastError + for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do + if name:sub(1, 1) ~= "." then + local path = inbox .. "/" .. name + local info = love.filesystem.getInfo(path, "file") + local sizeErr = info and require("src.mods.RequiredImports") + .sizeError(spec, info.size, false) + local data = not sizeErr and love.filesystem.read(path) or nil + if data and self:_importRequiredData(modId, importId, data) then return end + if sizeErr then lastError = sizeErr + elseif self.requiredImportNotice + and self.requiredImportNotice.modId == modId + and self.requiredImportNotice.importId == importId then + lastError = self.requiredImportNotice.text + end + end + end + requiredImportNotice(self, modId, importId, lastError + or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.") + self.modNotice = nil + return + end + if self.nativePicker then + if self.mobileFileBridge and not pickerHasKind("required_import") then + requiredImportNotice(self, modId, importId, + "This app build cannot pick required mod files yet. Update the app and try again.") + self.modNotice = nil + return + end + self.pickerPendingKind = "required_import" + self.pickerPendingModId = modId + self.pickerPendingImportId = importId + if not pickFile("required_import") then + self.pickerPendingKind = nil + self.pickerPendingModId = nil + self.pickerPendingImportId = nil + requiredImportNotice(self, modId, importId, "Could not open the file picker.") + self.modNotice = nil + elseif self.android then + self.pickPending = true + self.pickTimer = 0 + end + return + end + + local path = chooseRequiredFile() + if path then self:_importRequiredSource(modId, importId, path) end +end + -- Which game a dropped .sav imports into: a .sav has no version signature of -- its own, so it lands on the active game tab. When a non-game tab (mods) is -- showing, default to red -- the always-present first game -- rather than @@ -2045,7 +2273,8 @@ function RomImporter:_pollPickedFiles(dt) if not found then for _, name in ipairs(love.filesystem.getDirectoryItems("")) do local n = name:lower() - if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then + if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" + or n == "picked_required_import.bin" or n == "picked_stadium.z64" then found = true break end @@ -2174,7 +2403,12 @@ function RomImporter:update(dt) local version = self.pickerPendingVersion self.pickerPendingKind = nil self.pickerPendingVersion = nil - if kind == "mod" then + if kind == "required_import" then + local modId, importId = self.pickerPendingModId, self.pickerPendingImportId + self.pickerPendingModId, self.pickerPendingImportId = nil, nil + if modId and importId then self:_importRequiredSource(modId, importId, path) end + if Platform.isUWP() then os.remove(path) end + elseif kind == "mod" then self:_installMod(path) if Platform.isUWP() and self.modNotice and self.modNotice.ok then os.remove(path) @@ -2196,7 +2430,10 @@ function RomImporter:update(dt) local version = self.pickerPendingVersion or self:_savedropTarget() self.pickerPendingKind = nil self.pickerPendingVersion = nil - if kind == "mod" then + if kind == "required_import" then + self.modNotice = { ok = false, text = errorText } + self.pickerPendingModId, self.pickerPendingImportId = nil, nil + elseif kind == "mod" then self.modNotice = { ok = false, text = errorText } elseif kind == "sav" then self.saveNotice[version] = { ok = false, text = errorText } @@ -2820,7 +3057,7 @@ function RomImporter:keypressed(key) return end if self._modConfirm or self._modVersions or self._modReleaseNotes - or self._findDetails then + or self._findDetails or self._appPatchNotes then -- Focus navigation belongs to the visible modal as well as the launcher -- beneath it. Route arrows and an already-armed confirm before this guard -- returns; unarmed Enter still falls through to the modal guard. Keep this @@ -2833,6 +3070,8 @@ function RomImporter:keypressed(key) self._findDetails = nil elseif self._modReleaseNotes then self._modReleaseNotes = nil + elseif self._appPatchNotes then + self._appPatchNotes = nil else self._modConfirm = nil self._modVersions = nil diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 4f7f2ba8..87e51acb 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -38,6 +38,7 @@ local Version = require("src.core.Version") local SaveData = require("src.core.SaveData") local GameVersion = require("src.core.GameVersion") local CacheFs = require("src.import.CacheFs") +local RequiredImports = require("src.mods.RequiredImports") local LauncherMods = {} @@ -459,13 +460,38 @@ function LauncherMods.list(version) local ok, result = pcall(function() local options = SaveData.loadOptions() local manifests = discover() + -- Imports are private player grants. Never scan or copy another mod's + -- baseroms: a matching public digest is not permission to share the file. + local importState = {} + for _, manifest in ipairs(manifests) do + local rows, missing, missingOptional = RequiredImports.inspect(manifest) + importState[manifest.id] = { rows = rows, missing = missing, + missingOptional = missingOptional } + end -- The first build containing game-specific switches turns the old shared -- state into one explicit answer per installed mod and game. Saving here -- means users who only visit the launcher still receive the migration. if SaveData.migrateModEnablement(options, manifests) then SaveData.saveOptions(options) end - return LauncherMods.deriveList(manifests, options, version) + local rows = LauncherMods.deriveList(manifests, options, version) + for _, row in ipairs(rows) do + local state = importState[row.id] + or { rows = {}, missing = 0, missingOptional = 0 } + local imports, missing = state.rows, state.missing + row.requiredImports, row.missingRequiredImports = imports, missing + row.imports = imports + row.missingOptionalImports = state.missingOptional or 0 + if missing > 0 and row.status == "ok" then + row.status = "needs_import" + local first + for _, import in ipairs(imports) do + if not import.present then first = import break end + end + row.statusDetail = "Needs import: " .. (first and first.name or "required file") + end + end + return rows end) if not ok then -- a single bad options/mod file must not blank the launcher @@ -677,6 +703,26 @@ local function copyTree(src, dst) 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 fs = love.filesystem + into, relative = into or {}, relative or "" + local info = fs.getInfo(path) + if not info then return into 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) + end + elseif relative ~= "" and into[relative] == nil then + into[relative] = fs.read(path) + end + return into +end + -- Delete an installed mod subtree. Enumeration stays on love.filesystem (the -- portable game folder is on its read path), but the deletes go through -- CacheFs so a portable install's real files actually go away instead of @@ -919,14 +965,28 @@ function LauncherMods._installZipInner(source, opts) return nil, ("zip is for '%s', expected '%s'") :format(manifest.id, opts.expectId) end + local packagedBaseroms = root .. "/baseroms" + if fs.getInfo(packagedBaseroms, "directory") + and #(fs.getDirectoryItems(packagedBaseroms) or {}) > 0 then + cleanup() + return nil, "mod archives must not include user-supplied baseroms/ files" + end local dest = "mods/" .. manifest.id + local baseromRecovery = "imports/baseroms-recovery/" .. manifest.id local existing, installedSomewhere = sameIdTrees(fs, manifest.id) if installedSomewhere and not opts.replace then cleanup() return nil, "a mod named '" .. manifest.id .. "' is already installed" end + local preservedBaseroms = {} + -- A previous failed update may have staged the user's files outside mods/ so + -- discovery cannot mistake recovery debris for an installed mod. + snapshotTree(baseromRecovery, preservedBaseroms) if #existing > 0 then + for _, path in ipairs(existing) do + snapshotTree(path .. "/baseroms", preservedBaseroms) + end -- drop every old tree before copy -- mods/ and any same-id folder -- under another name, or the survivor keeps winning discover()'s -- first-id-wins race after the "successful" update (#801). A tree with @@ -950,6 +1010,28 @@ function LauncherMods._installZipInner(source, opts) 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 + 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) + end CacheFs.prefix = savedPrefix if not copied then cleanup() diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 8854b9bb..1a46dbf9 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -4,6 +4,7 @@ local SaveData = require("src.core.SaveData") local Data = require("src.core.Data") local GameVersion = require("src.core.GameVersion") local Version = require("src.core.Version") +local RequiredImports = require("src.mods.RequiredImports") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") local DateTime = require("src.core.DateTime") @@ -565,7 +566,24 @@ function Loader:_validate() elseif manifest.assets_transforms and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then reason = "assets_transforms file missing: " .. manifest.assets_transforms - elseif manifest.game_version and not devEngine() then + end + if not reason and #(manifest.required_imports or {}) > 0 then + for _, import in ipairs(manifest.required_imports) do + local path = mod.path .. "/baseroms/" .. import.file + if not self:_exists(path) then + reason = "required import missing: " .. import.name + break + end + local valid, importErr = RequiredImports.validateStored( + manifest, import, self.fs) + if not valid then + reason = "required import invalid: " .. import.name + .. " (" .. tostring(importErr) .. ")" + break + end + end + end + if not reason and manifest.game_version and not devEngine() then local ok, err = Semver.satisfies(Version.engine, manifest.game_version) if not ok then reason = ("needs game version %s, engine is %s") diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index 95383cde..1fef0295 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -132,13 +132,97 @@ local function mergeConflictLists(conflicts, incompatible) return out end +local scrubUtf8 -- shared by required-import labels and top-level strings + +-- User-supplied files a mod needs beside its own source. The launcher owns +-- the picker and the copy; the mod only reads the resulting +-- /baseroms/ through its existing scoped mod:read surface. MD5 is +-- deliberately the manifest vocabulary here: ROM preservation databases and +-- the mods consuming these files commonly identify dumps by MD5, and the +-- digest is an identity check rather than a security boundary. +local function parseImports(value, field, required) + field = field or "required_imports" + local out, ids, files = {}, {}, {} + for _, entry in ipairs(array(value)) do + assert(type(entry) == "table", field .. " entries must be objects") + local id = entry.id + assert(type(id) == "string" and id:match("^[%w_%-]+$"), + field .. " id must contain only letters, numbers, _ or -") + assert(not ids[id], "duplicate " .. field .. " id: " .. id) + ids[id] = true + + local file = entry.file + assert(type(file) == "string" and file ~= "", + field .. " file is required") + file = SafePath.require(file, field .. " file") + assert(not file:find("/", 1, true), + field .. " file must be a filename inside baseroms") + assert(file:sub(1, 1) ~= ".", + field .. " file must not use a hidden metadata filename") + assert(not files[file], "duplicate " .. field .. " file: " .. file) + files[file] = true + + local hashes = entry.md5 + if type(hashes) == "string" then hashes = { hashes } end + assert(type(hashes) == "table" and #hashes > 0, + field .. " md5 must be a hash or non-empty array") + local accepted, seen = {}, {} + for _, digest in ipairs(hashes) do + assert(type(digest) == "string" and digest:match("^[%x]+$") + and #digest == 32, field .. " md5 values must be 32 hex characters") + digest = digest:lower() + if not seen[digest] then + seen[digest] = true + accepted[#accepted + 1] = digest + end + end + + local format = entry.format or "raw" + assert(format == "raw" or format == "n64", + field .. " format must be raw or n64") + local name = entry.name or id + assert(type(name) == "string" and name ~= "", + field .. " name must be a non-empty string") + local description = entry.description or entry.hint + if description ~= nil then + assert(type(description) == "string" and description ~= "", + field .. " description must be a non-empty string") + end + local size = entry.size + local maxSize = entry.max_size + local function validateSize(value, label) + if value == nil then return end + assert(type(value) == "number" and value > 0 and value % 1 == 0, + field .. " " .. label .. " must be a positive integer") + assert(value <= 128 * 1024 * 1024, + field .. " " .. label .. " exceeds the 128 MiB hard limit") + end + validateSize(size, "size") + validateSize(maxSize, "max_size") + assert(not (size and maxSize) or size <= maxSize, + field .. " size must not exceed max_size") + out[#out + 1] = { + id = id, + name = scrubUtf8(name), + description = scrubUtf8(description), + file = file, + md5 = accepted, + format = format, + size = size, + max_size = maxSize, + required = required ~= false, + } + end + return out +end + -- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs, -- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises -- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a -- panel may draw must be scrubbed here -- the one place every mod manifest -- passes through -- or a single mangled description crashes the whole MODS -- panel instead of misrendering one card. -local function scrubUtf8(s) +scrubUtf8 = function(s) if type(s) ~= "string" then return s end s = s:gsub("^\239\187\191", "") local out, i, n = {}, 1, #s @@ -290,6 +374,19 @@ function Manifest.validate(raw, path) local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) + local requiredImports = parseImports(raw.required_imports, + "required_imports", true) + local optionalImports = parseImports(raw.optional_imports, + "optional_imports", false) + local importIds, importFiles = {}, {} + for _, list in ipairs({ requiredImports, optionalImports }) do + for _, import in ipairs(list) do + assert(not importIds[import.id], "duplicate import id: " .. import.id) + assert(not importFiles[import.file], "duplicate import file: " .. import.file) + importIds[import.id], importFiles[import.file] = true, true + end + end + return { id = raw.id, name = raw.name, @@ -318,6 +415,8 @@ function Manifest.validate(raw, path) permissionSet = permissionSet, options_schema = optionalFile(raw.options_schema, "options_schema"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), + required_imports = requiredImports, + optional_imports = optionalImports, -- an env var name, not a path, so it keeps the plain string check force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"), path = path, diff --git a/src/mods/RequiredImports.lua b/src/mods/RequiredImports.lua new file mode 100644 index 00000000..167c263f --- /dev/null +++ b/src/mods/RequiredImports.lua @@ -0,0 +1,295 @@ +-- Engine-owned import handling for files declared by a mod's required or +-- optional import arrays. Mods never receive host paths or broader +-- filesystem access: accepted bytes are copied into their own +-- mods//baseroms/ tree, where the existing mod:read sandbox can see them. + +local CacheFs = require("src.import.CacheFs") + +local RequiredImports = {} + +local function allSpecs(manifest) + local out = {} + for _, spec in ipairs((manifest and manifest.required_imports) or {}) do + out[#out + 1] = spec + end + for _, spec in ipairs((manifest and manifest.optional_imports) or {}) do + out[#out + 1] = spec + end + return out +end + +local function isRequired(spec) + return spec.required ~= false +end + +RequiredImports.specs = allSpecs +RequiredImports.MAX_BYTES = 128 * 1024 * 1024 + +local function sizeLabel(bytes) + return ("%.1f MiB"):format(bytes / (1024 * 1024)) +end + +-- Check size before a caller reads an external or stored file into one large +-- Lua string. N64 sources may carry a 512-byte copier header, while stored +-- files are always canonical and therefore must match the declared size. +function RequiredImports.sizeError(spec, size, stored) + if type(size) ~= "number" then return nil end + if size > RequiredImports.MAX_BYTES then + return ("file is too large (%s; hard limit is %s)") + :format(sizeLabel(size), sizeLabel(RequiredImports.MAX_BYTES)) + end + local headerAllowance = not stored and spec and spec.format == "n64" and 512 or 0 + if spec and spec.size then + if size ~= spec.size and size ~= spec.size + headerAllowance then + return ("wrong file size (expected %d bytes%s, got %d)") + :format(spec.size, headerAllowance > 0 and " or a 512-byte header" or "", size) + end + end + if spec and spec.max_size and size > spec.max_size + headerAllowance then + return ("file is too large for this import (maximum %d bytes, got %d)") + :format(spec.max_size, size) + end + return nil +end + +local N64_MAGIC = { + ["\128\55\18\64"] = "z64", -- big endian / canonical + ["\55\128\64\18"] = "v64", -- byte-swapped + ["\64\18\55\128"] = "n64", -- little endian words +} + +local function n64KindAt(data, offset) + return N64_MAGIC[data:sub(offset, offset + 3)] +end + +-- Return canonical big-endian N64 bytes. A 512-byte copier header is +-- recognized only when valid N64 magic follows it, so arbitrary data is never +-- shortened just because its size happens to line up. +function RequiredImports.normalizeN64(data) + if type(data) ~= "string" then return nil, "selected file could not be read" end + local offset, kind = 1, n64KindAt(data, 1) + if not kind then + kind = n64KindAt(data, 513) + if kind then offset = 513 end + end + if not kind then + return nil, "expected an N64 ROM (.z64/.v64/.n64); file signature was not recognized" + end + data = data:sub(offset) + if kind == "z64" then return data end + + if kind == "v64" then + if #data % 2 ~= 0 then return nil, "byte-swapped N64 ROM has an odd size" end + return (data:gsub("(.)(.)", "%2%1")) + else + if #data % 4 ~= 0 then return nil, "little-endian N64 ROM size is not word aligned" end + return (data:gsub("(.)(.)(.)(.)", "%4%3%2%1")) + end +end + +function RequiredImports.normalize(spec, data) + if spec and spec.format == "n64" then + return RequiredImports.normalizeN64(data) + end + if type(data) ~= "string" then return nil, "selected file could not be read" end + return data +end + +local function hexDigest(data, hashFn) + if hashFn then return hashFn(data):lower() end + if not (love and love.data and love.data.hash and love.data.encode) then + return nil, "MD5 support is unavailable in this build" + end + local digest = love.data.hash("md5", data) + if type(digest) == "userdata" and digest.getString then + digest = digest:getString() + end + return love.data.encode("string", "hex", digest):lower() +end + +local function accepts(spec, digest) + for _, wanted in ipairs((spec and spec.md5) or {}) do + if wanted == digest then return true end + end + return false +end + +function RequiredImports.path(manifest, spec) + return manifest.path .. "/baseroms/" .. spec.file +end + +local function removedMarker(manifest, spec) + return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".removed" +end + +local function receiptPath(manifest, spec) + return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".validated" +end + +RequiredImports.receiptPath = receiptPath + +local function parseReceipt(raw) + if type(raw) ~= "string" then return nil end + local digest, size, modtime = raw:match("^v1\n([%x]+)\n(%d+)\n([^\n]+)\n?$") + if not digest then return nil end + return digest:lower(), tonumber(size), tonumber(modtime) +end + +local function cachedDigest(manifest, spec, fs, info) + -- A size alone cannot detect a same-length replacement. Require modtime as + -- well; filesystems that do not expose it simply take the safe hash path. + if not (fs and fs.read and info and info.size and info.modtime) then return nil end + local digest, size, modtime = parseReceipt(fs.read(receiptPath(manifest, spec))) + if digest and size == info.size and modtime == info.modtime + and accepts(spec, digest) then + return digest + end + return nil +end + +local function writeReceipt(manifest, spec, digest, info, fs) + if not (digest and info and info.size and info.modtime) then return end + local path = receiptPath(manifest, spec) + local body = ("v1\n%s\n%d\n%s\n") + :format(digest, info.size, tostring(info.modtime)) + if love and fs == love.filesystem then + local savedPrefix = CacheFs.prefix + CacheFs.prefix = "" + CacheFs.write(path, body) + CacheFs.prefix = savedPrefix + elseif fs and fs.write then + fs.write(path, body) + end +end + +local function removeReceipt(manifest, spec, fs) + local path = receiptPath(manifest, spec) + if love and fs == love.filesystem then + local savedPrefix = CacheFs.prefix + CacheFs.prefix = "" + CacheFs.remove(path) + CacheFs.prefix = savedPrefix + elseif fs and fs.remove then + fs.remove(path) + end +end + +-- Validate bytes against a declaration. The returned data is canonicalized +-- (notably for N64 byte order/header variants) and is what must be stored. +function RequiredImports.validateData(spec, data, hashFn) + local sourceSizeErr = type(data) == "string" + and RequiredImports.sizeError(spec, #data, false) + if sourceSizeErr then return nil, sourceSizeErr end + local normalized, normalizeErr = RequiredImports.normalize(spec, data) + if not normalized then return nil, normalizeErr end + local storedSizeErr = RequiredImports.sizeError(spec, #normalized, true) + if storedSizeErr then return nil, storedSizeErr end + local digest, hashErr = hexDigest(normalized, hashFn) + if not digest then return nil, hashErr end + if not accepts(spec, digest) then + return nil, ("MD5 mismatch (got %s)"):format(digest) + end + return normalized, digest +end + +-- Validate one installed import without reading it when the engine-authored +-- receipt still matches the file's size and modification time. +function RequiredImports.validateStored(manifest, spec, fs, hashFn) + fs = fs or (love and love.filesystem) + if not (fs and fs.getInfo) then return nil, "filesystem is unavailable" end + local path = RequiredImports.path(manifest, spec) + local info = fs.getInfo(path, "file") + if not info then + removeReceipt(manifest, spec, fs) + return nil, "file is missing" + end + local sizeErr = RequiredImports.sizeError(spec, info.size, true) + if sizeErr then + removeReceipt(manifest, spec, fs) + return nil, sizeErr + end + local cached = cachedDigest(manifest, spec, fs, info) + if cached then return true, cached, true end + removeReceipt(manifest, spec, fs) + if not fs.read then return nil, "file could not be read" end + local data = fs.read(path) + local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn) + if not normalized then return nil, detail end + info = fs.getInfo(path, "file") or info + info.size = info.size or #data + writeReceipt(manifest, spec, detail, info, fs) + return true, detail, false +end + +function RequiredImports.validateStoredData(spec, data, hashFn) + local normalized, detail = RequiredImports.validateData(spec, data, hashFn) + if not normalized then return nil, detail end + if normalized ~= data then + return nil, "stored N64 ROM is not canonical; choose the source file again" + end + return normalized, detail +end + +function RequiredImports.inspect(manifest, fs, hashFn) + fs = fs or (love and love.filesystem) + local rows, missing, missingOptional = {}, 0, 0 + for _, spec in ipairs(allSpecs(manifest)) do + local path = RequiredImports.path(manifest, spec) + local suppressed = fs and fs.getInfo + and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil + local exists = fs and fs.getInfo and fs.getInfo(path, "file") ~= nil + local valid, detail = RequiredImports.validateStored(manifest, spec, fs, hashFn) + local row = { id = spec.id, name = spec.name, file = spec.file, + description = spec.description, format = spec.format, path = path, + present = valid == true, digest = valid and detail or nil, + error = exists and not valid and detail or nil, + suppressed = suppressed, required = isRequired(spec), spec = spec } + if not row.present then + if row.required then missing = missing + 1 + else missingOptional = missingOptional + 1 end + end + rows[#rows + 1] = row + end + return rows, missing, missingOptional +end + +function RequiredImports.importData(manifest, importId, data, opts) + opts = opts or {} + local spec + for _, candidate in ipairs(allSpecs(manifest)) do + if candidate.id == importId then spec = candidate break end + end + if not spec then return nil, "unknown required import: " .. tostring(importId) end + local normalized, digest = RequiredImports.validateData(spec, data, opts.hash) + if not normalized then return nil, digest end + local savedPrefix = CacheFs.prefix + CacheFs.prefix = "" + CacheFs.remove(receiptPath(manifest, spec)) + local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized) + if ok then CacheFs.remove(removedMarker(manifest, spec)) end + if ok and love and love.filesystem and love.filesystem.getInfo then + local info = love.filesystem.getInfo(RequiredImports.path(manifest, spec), "file") + writeReceipt(manifest, spec, digest, info, love.filesystem) + end + CacheFs.prefix = savedPrefix + if not ok then return nil, "could not copy import: " .. tostring(err) end + return true, digest +end + +function RequiredImports.remove(manifest, importId) + for _, spec in ipairs(allSpecs(manifest)) do + if spec.id == importId then + local savedPrefix = CacheFs.prefix + CacheFs.prefix = "" + CacheFs.remove(RequiredImports.path(manifest, spec)) + CacheFs.remove(receiptPath(manifest, spec)) + local marked, markErr = CacheFs.write(removedMarker(manifest, spec), "removed\n") + CacheFs.prefix = savedPrefix + if not marked then return nil, "could not remember removal: " .. tostring(markErr) end + return true + end + end + return nil, "unknown required import: " .. tostring(importId) +end + +return RequiredImports diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index f056418c..bc125e5c 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -454,30 +454,7 @@ function PartyMenu:update(dt) refuseBadge(self) return end - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - -- .flash prints on any map, but the light it records is this map's: - -- home/overworld.asm re-arms wMapPalOffset on the next dark map, so - -- a FLASH used in daylight must not carry into Rock Tunnel - local wasDark = ow and ow.dark - if wasDark then self.game.save.flashLit = true end - self.game.stack:push(TextBox.new(self.game, - self.game.data.text._FlashLightsAreaText - or Strings("A blinding FLASH\nlights the area!"), function() - self:close() - -- setDark, not a bare field write: ADVANCED carries the darkness - -- in a baked atlas, so lighting the cave drops every resident map - -- and rebakes this one (#383). It runs HERE, before the blink, - -- because start_sub_menus.asm .flash clears wMapPalOffset before - -- PrintText and blinks last of all: the cave is already lit by the - -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the - -- blink's completion instead left that rebuild's whole cost -- - -- seconds of per-pixel atlas baking on a phone -- on screen as a - -- solid white frame with nothing under it, which reads as a - -- lockup (#610). - if wasDark then ow:setDark(false) end - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) + ow:useFlashFieldMove(function() self:close() end) return elseif action == "surf" then -- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove), @@ -505,12 +482,7 @@ function PartyMenu:update(dt) -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press -- steps the player forward onto land (or across a connection -- strip when the shore is the next map's edge) - self.game.stack:pop() - ow.player.surfing = false - require("src.core.Music").setSurfing(self.game.data, false) - self.game.stack:push(Transition.whiteFlash(self.game, nil, function() - ow:stepForwardOrCrossEdge(ow.player.facing) - end)) + ow:stopSurfing(function() self.game.stack:pop() end) return end local TextBox = require("src.render.TextBox") @@ -571,26 +543,7 @@ function PartyMenu:update(dt) refuseBadge(self) return end - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - local def = self.game.data.pokemon[mon.species] - local name = mon.nickname or def.name - ow.strengthActive = true - local t1 = (self.game.data.text._UsedStrengthText - or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) - local t2 = (self.game.data.text._CanMoveBouldersText - or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) - -- like surf (#320, #385): both texts print with the party menu - -- still on screen, and the blink IS the menu closing afterwards, - -- not a flashbang on the empty map - self.game.stack:push(TextBox.new(self.game, t1, function() - self.game.stack:push(TextBox.new(self.game, t2, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - end, { auto = { sound = function() - return require("src.core.Sound").playCry(self.game.data, mon.species) - end } })) + ow:useStrengthFieldMove(mon, function() self:close() end) return elseif action == "softboiled" then -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer diff --git a/src/ui/gen2/HallOfFame.lua b/src/ui/gen2/HallOfFame.lua index 6f8e3947..8d593003 100644 --- a/src/ui/gen2/HallOfFame.lua +++ b/src/ui/gen2/HallOfFame.lua @@ -154,7 +154,7 @@ function HallOfFame.monPlacements(mon, def) put(out, genderGlyph(mon.gender), 18, 13) -- (8,14) is a bare '/', so the nickname starts at (9,14). put(out, "/", 8, 14) - put(out, mon.nickname or mon.species, 9, 14) + put(out, mon.nickname or mon.name or mon.species, 9, 14) put(out, levelText(mon.level), 1, 16) end -- '' '№' '/' at (7,16), (8,16), (9,16), then five digits at (10,16). diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index e4c8d3a4..c5f50485 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -630,7 +630,7 @@ function PackMenu:openTeachParty(row) if not allowed then if game.say then game:say(("%s can't learn %s!"):format( - mon.nickname or mon.species or "?", moveName)) + require("src.battle.gen2.Mon").displayName(mon), moveName)) end return end @@ -638,7 +638,7 @@ function PackMenu:openTeachParty(row) if move.id == moveId then if game.say then game:say(("%s already knows %s!"):format( - mon.nickname or mon.species or "?", moveName)) + require("src.battle.gen2.Mon").displayName(mon), moveName)) end return end diff --git a/src/update/Check.lua b/src/update/Check.lua index c485336c..728789aa 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -71,6 +71,9 @@ function Check.parseRelease(jsonText, Json) payloadName = payloadName, payload = Check.pickAsset(doc.assets, payloadName), sums = Check.pickAsset(doc.assets, "sha256sums.txt"), + -- GitHub release body: already fetched with the update check, shown by + -- the launcher's Patch notes footer button. + notes = type(doc.body) == "string" and doc.body or "", } end @@ -135,6 +138,10 @@ local function drain() 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 msg = stateCh:pop() end @@ -158,11 +165,11 @@ function Check.start() return end requested = true - cache = { status = "checking" } + cache = { status = "checking", notes = cache.notes, latest = cache.latest } cmdCh:push({ cmd = "check" }) end --- Current snapshot: { status, latest, progress, error }. status is one of +-- Current snapshot: { status, latest, progress, error, notes }. status is one of -- idle | checking | uptodate | available | downloading | ready | needs_full | error. function Check.state() drain() @@ -171,6 +178,7 @@ function Check.state() latest = cache.latest, progress = cache.progress, error = cache.error, + notes = cache.notes, } end @@ -180,7 +188,8 @@ function Check.download() drain() if not cmdCh then return end if cache.status ~= "available" then return end - cache = { status = "downloading", latest = cache.latest, progress = 0 } + cache = { status = "downloading", latest = cache.latest, progress = 0, + notes = cache.notes } cmdCh:push({ cmd = "download" }) end diff --git a/src/update/PatchNotes.lua b/src/update/PatchNotes.lua new file mode 100644 index 00000000..3288c4de --- /dev/null +++ b/src/update/PatchNotes.lua @@ -0,0 +1,112 @@ +-- Resolve the game's patch notes for the launcher footer modal. +-- +-- Sources, first hit wins: +-- 1. The GitHub body the self-updater already fetched (Check.parseRelease) +-- 2. PATCH_NOTES.md, if a build packed one +-- 3. mobile/ios/app-repo.json -- CI copies each release's notes into +-- versions[].localizedDescription, so a checkout already has them +-- on disk even when the updater has not run + +local Json = require("src.link.Json") + +local PatchNotes = {} + +PatchNotes.FILES = { + "PATCH_NOTES.md", + "assets/PATCH_NOTES.md", +} + +PatchNotes.REPO_FILES = { + "mobile/ios/app-repo.json", +} + +local function nonempty(s) + return type(s) == "string" and s:find("%S") +end + +function PatchNotes.fromCheck(Check) + if not (Check and Check.state) then return nil, nil end + local ok, st = pcall(Check.state) + st = (ok and type(st) == "table") and st or nil + if not st then return nil, nil end + if nonempty(st.notes) then + return st.notes, st.latest + end + return nil, st.latest +end + +local function readPath(path) + local fs = love and love.filesystem + if fs and fs.read then + local ok, text = pcall(fs.read, path) + if ok and nonempty(text) then return text end + end + local f = io.open(path, "rb") + if not f then return nil end + local text = f:read("*a") + f:close() + return nonempty(text) and text or nil +end + +function PatchNotes.fromFile() + for _, path in ipairs(PatchNotes.FILES) do + local text = readPath(path) + if text then return text end + end + return nil +end + +-- Newest-first list of { version, notes } from the iOS sidecar. +function PatchNotes.parseRepo(jsonText) + local doc = Json.decode(jsonText) + if type(doc) ~= "table" or type(doc.apps) ~= "table" then return {} end + local out = {} + for _, app in ipairs(doc.apps) do + if type(app) == "table" and type(app.versions) == "table" then + for _, row in ipairs(app.versions) do + if type(row) == "table" and nonempty(row.localizedDescription) then + out[#out + 1] = { + version = row.version, + notes = row.localizedDescription, + } + end + end + end + end + return out +end + +function PatchNotes.fromRepo(engine) + for _, path in ipairs(PatchNotes.REPO_FILES) do + local text = readPath(path) + if text then + local list = PatchNotes.parseRepo(text) + if #list == 0 then return nil, nil end + if engine and engine ~= "0.0.0-dev" then + for _, row in ipairs(list) do + if row.version == engine then + return row.notes, row.version + end + end + end + return list[1].notes, list[1].version + end + end + return nil, nil +end + +function PatchNotes.body(Check) + local notes, ver = PatchNotes.fromCheck(Check) + if notes then return notes, ver end + notes = PatchNotes.fromFile() + if notes then return notes, ver end + local Version = require("src.core.Version") + local engine = (Version and Version.engine) or "?" + notes, ver = PatchNotes.fromRepo(engine) + if notes then return notes, ver end + return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n" + .. "They appear here after the launcher checks GitHub for the latest " + .. "release.", engine +end + +return PatchNotes diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 0efc7ada..d60a349b 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -45,7 +45,12 @@ local Boot = loadModule("src/update/Boot.lua") local cmdCh = love.thread.getChannel("update_check_cmd") local stateCh = love.thread.getChannel("update_check_state") -local function post(t) stateCh:push(t) end +local function post(t) + if pending and type(t) == "table" and t.notes == nil then + t.notes = pending.notes + end + stateCh:push(t) +end local osName = (love.system and love.system.getOS and love.system.getOS()) or "" local isWindows = osName == "Windows" diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 0b845d84..7008e360 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -784,6 +784,49 @@ function OverworldState:useBicycle() return true end +-- Field-move entry points keep presentation and state transitions in the +-- overworld. The party menu and supported mod facade both call these, so a +-- shortcut cannot drift from the game's own move flow. +function OverworldState:useFlashFieldMove(onClose) + -- A FLASH used in daylight must not carry into the next dark map. Lighting + -- also happens before the blink: setDark may rebake an ADVANCED atlas, and + -- putting that work in WhiteFlash:onDone caused the frozen white frame in + -- #610. + local wasDark = self.dark + if wasDark then Game.save.flashLit = true end + Game.stack:push(TextBox.new(Game, + Game.data.text._FlashLightsAreaText + or Strings("A blinding FLASH\nlights the area!"), function() + if onClose then onClose() end + if wasDark then self:setDark(false) end + Game.stack:push(Transition.whiteFlash(Game)) + end)) + return true +end + +function OverworldState:useStrengthFieldMove(mon, onClose) + mon = mon or self:partyKnows("STRENGTH") + if not mon then return false end + local def = Game.data.pokemon[mon.species] + local name = mon.nickname or def.name + self.strengthActive = true + local first = (Game.data.text._UsedStrengthText + or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")) + :gsub("{RAM:wNameBuffer}", name) + local second = (Game.data.text._CanMoveBouldersText + or Strings("{RAM:wNameBuffer} can\nmove boulders.")) + :gsub("{RAM:wNameBuffer}", name) + Game.stack:push(TextBox.new(Game, first, function() + Game.stack:push(TextBox.new(Game, second, function() + if onClose then onClose() end + Game.stack:push(Transition.whiteFlash(Game)) + end)) + end, { auto = { sound = function() + return require("src.core.Sound").playCry(Game.data, mon.species) + end } })) + return true +end + -- The battle transition's dungeon wipe uses the explicit map lists in -- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus -- inclusive map-id ranges -- faithful to the original's omissions @@ -2500,6 +2543,15 @@ function OverworldState:trySurf(fx, fy, onClose) end)) end +function OverworldState:stopSurfing(onClose) + if onClose then onClose() end + self.player.surfing = false + require("src.core.Music").setSurfing(Game.data, false) + Game.stack:push(Transition.whiteFlash(Game, nil, function() + self:stepForwardOrCrossEdge(self.player.facing) + end)) +end + function OverworldState:tryCut(fx, fy) -- UsedCut (engine/overworld/cut.asm) gates on the TILESET before -- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index a907e8f1..4f4200e9 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,6 +6,8 @@ -- stays unsupported; anything a mod legitimately needs belongs here. local Logger = require("src.core.Logger") +local FieldDefaults = require("src.world.FieldDefaults") +local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") local MapOverview = require("src.world.MapOverview") local Party = require("src.pokemon.Party") @@ -15,6 +17,8 @@ local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" +local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true, + FACILITY = true, INTERIOR = true } local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" } local function acceptsMenuInput(game, ow) @@ -115,6 +119,30 @@ function WorldAPI:availableFieldActions() out[#out + 1] = { id = "fish", label = "FISH", rods = rods } end end + + if ow:useCutFieldMove() == "ok" then + out[#out + 1] = { id = "cut", label = "CUT" } + end + local surf = ow:useSurfFieldMove() + if surf == "ok" or surf == "dismount" then + out[#out + 1] = { id = "surf", + label = surf == "dismount" and "LEAVE WATER" or "SURF" } + end + + if not ow.strengthActive and ow:partyKnows("STRENGTH") then + out[#out + 1] = { id = "strength", label = "STRENGTH" } + end + if ow.dark and ow:partyKnows("FLASH") then + out[#out + 1] = { id = "flash", label = "FLASH" } + end + if DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM" + and ow:partyKnows("DIG") then + out[#out + 1] = { id = "dig", label = "DIG" } + end + if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def, + FieldDefaults.field(game.data, "outsideTilesets")) then + out[#out + 1] = { id = "teleport", label = "TELEPORT" } + end return out end @@ -130,6 +158,19 @@ function WorldAPI:useFieldAction(id, opts) if id == "bicycle" then if ow:useBicycle() then return true end + elseif id == "cut" then + local x, y = ow.player:facingCell() + if ow:tryCut(x, y) then return true end + elseif id == "surf" then + local mode = ow:useSurfFieldMove() + if mode == "dismount" then + ow:stopSurfing() + return true + elseif mode == "ok" then + local x, y = ow.player:facingCell() + ow:trySurf(x, y) + return true + end elseif id == "fish" then local rod = opts and opts.rod if not rod and #found.rods == 1 then rod = found.rods[1].id end @@ -137,6 +178,13 @@ function WorldAPI:useFieldAction(id, opts) if choice.id == rod and ow:useFishingRod(rod) then return true end end return nil, "fishing rod unavailable" + elseif id == "strength" then + if ow:useStrengthFieldMove() then return true end + elseif id == "flash" then + if ow:useFlashFieldMove() then return true end + elseif id == "dig" or id == "teleport" then + ow:beginTeleportOut() + return true end return nil, "field action unavailable" end diff --git a/src/world/gen2/MapPreview.lua b/src/world/gen2/MapPreview.lua new file mode 100644 index 00000000..27847d45 --- /dev/null +++ b/src/world/gen2/MapPreview.lua @@ -0,0 +1,186 @@ +-- Bake a Gen 2 map canvas without a World instance. The save editor's map +-- tab uses this so Gold rooms draw the same tiles the overworld would, +-- instead of the checkerboard fallback Map2 has no renderer for. + +local Assets = require("src.render.Assets") +local BorderFill = require("src.world.gen2.BorderFill") +local GbcPalette = require("src.render.GbcPalette") +local Palettes = require("src.world.gen2.Palettes") + +local MapPreview = {} + +local ROOF_TILESETS = { + TILESET_JOHTO = true, + TILESET_JOHTO_MODERN = true, + TILESET_KANTO = true, +} + +local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow) + local atlasData = love.image.newImageData(Assets.resolve(atlasPath)) + local roofData = love.image.newImageData(Assets.resolve(roofPath)) + for t = 0, 8 do + local destId = 0x0a + t + local dx = (destId % tilesPerRow) * 8 + local dy = math.floor(destId / tilesPerRow) * 8 + local sx = t * 8 + for y = 0, 7 do + for x = 0, 7 do + local r, g, b, a = roofData:getPixel(sx + x, y) + atlasData:setPixel(dx + x, dy + y, r, g, b, a) + end + end + end + local image = love.graphics.newImage(atlasData) + image:setFilter("nearest", "nearest") + return image +end + +local function optionalTable(data, gen2Key, plainKey, generated) + if data[gen2Key] then return data[gen2Key] end + if data[plainKey] then return data[plainKey] end + local ok, mod = pcall(require, "data.generated." .. generated) + if ok and type(mod) == "table" then return mod end + return nil +end + +function MapPreview.baker(data) + data = data or {} + return { + tilesets = data.gen2Tilesets or data.tilesets or {}, + -- Data:load does not pull roofs.lua; the Gold cache still has it. + roofs = optionalTable(data, "gen2Roofs", "roofs", "roofs"), + palettes = optionalTable(data, "gen2Palettes", "palettes", "palettes"), + atlasCache = {}, + mapImages = {}, + } +end + +function MapPreview.atlasFor(baker, mapDef) + if not (baker and mapDef) then return nil, nil end + local tileset = baker.tilesets and baker.tilesets[mapDef.tileset] + if not tileset then return nil, nil end + local cacheKey = mapDef.tileset + local roofName = nil + local roofs = baker.roofs + if ROOF_TILESETS[mapDef.tileset] then + roofName = roofs and roofs.mapGroupRoofs and roofs.mapGroupRoofs[mapDef.group] + end + if roofName then cacheKey = cacheKey .. "|" .. roofName end + local cached = baker.atlasCache[cacheKey] + if cached then return cached, tileset end + + local tilesPerRow = tileset.tilesPerRow or 16 + local atlas + local roofSpec = roofName and roofs and roofs.roofs and roofs.roofs[roofName] + if roofSpec and roofSpec.image and love.image and love.image.newImageData then + local ok, img = pcall(applyRoofOverlay, tileset.image, roofSpec.image, tilesPerRow) + if ok then atlas = img end + end + if not atlas then + if not tileset.image then return nil, tileset end + local ok, img = pcall(Assets.image, tileset.image) + if not ok then return nil, tileset end + atlas = img + if atlas.setFilter then atlas:setFilter("nearest", "nearest") end + end + baker.atlasCache[cacheKey] = atlas + return atlas, tileset +end + +-- Same bake as World:bakeMapImage (src/world/gen2/World.lua), minus the +-- World fields the overworld keeps for anim overlays and cave flicker. +function MapPreview.bake(baker, map, daytime) + local atlas, tileset = MapPreview.atlasFor(baker, map.def) + if not atlas or not tileset then return nil end + if not (love.graphics and love.graphics.newCanvas) then return nil end + local blocks = tileset.blocks + local tilesPerRow = tileset.tilesPerRow or 16 + local pw, ph = map.width * 32, map.height * 32 + local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph) + if not okCanvas or not canvas then return nil end + if canvas.setFilter then canvas:setFilter("nearest", "nearest") end + local quads = {} + local function quadFor(tile) + local q = quads[tile] + if q then return q end + local sx = (tile % tilesPerRow) * 8 + local sy = math.floor(tile / tilesPerRow) * 8 + q = love.graphics.newQuad(sx, sy, 8, 8, atlas:getDimensions()) + quads[tile] = q + return q + end + + local tilePalettes = tileset.tilePalettes + local bgSet = baker.palettes and daytime + and Palettes.bgSet(baker.palettes, map.def, daytime) or nil + local colored = bgSet and tilePalettes and GbcPalette.available() + local clearColor = { 0.15, 0.55, 0.25 } + if bgSet and bgSet[1] and bgSet[1][1] then + local c = GbcPalette.color(bgSet[1], 1) + clearColor = { c[1] / 255, c[2] / 255, c[3] / 255 } + end + + local function drawTiles(slot) + for by = 0, map.height - 1 do + for bx = 0, map.width - 1 do + local blockId = BorderFill.blockFor( + map.blocks[by * map.width + bx + 1], map.borderBlock) + local block = blocks and blocks[(blockId or 0) + 1] + if block then + for i = 0, 15 do + local tile = block[i + 1] or 0 + local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1 + if not slot or tileSlot == slot then + local tx = bx * 32 + (i % 4) * 8 + local ty = by * 32 + math.floor(i / 4) * 8 + love.graphics.draw(atlas, quadFor(tile), tx, ty) + end + end + end + end + end + end + + local function paint() + love.graphics.clear(clearColor[1], clearColor[2], clearColor[3], 1) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.push() + love.graphics.origin() + if colored then + for slot = 1, 8 do + GbcPalette.with(bgSet[slot], function() drawTiles(slot) end) + end + else + drawTiles(nil) + end + love.graphics.pop() + end + if canvas.renderTo then + canvas:renderTo(paint) + else + paint() + end + return canvas +end + +function MapPreview.imageFor(baker, map) + if not (baker and map and map.id) then return nil end + local cached = baker.mapImages[map.id] + if cached then return cached end + local img = MapPreview.bake(baker, map, "DAY") + baker.mapImages[map.id] = img or false + return img +end + +function MapPreview.renderer(baker, map) + local img = MapPreview.imageFor(baker, map) + if not img then return nil end + return { + draw = function(_, camX, camY) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, -math.floor(camX or 0), -math.floor(camY or 0)) + end, + } +end + +return MapPreview diff --git a/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua index c391678a..9c204cf4 100644 --- a/src/world/gen2/WorldAPI.lua +++ b/src/world/gen2/WorldAPI.lua @@ -36,6 +36,18 @@ WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" } +local FIELD_ACTIONS = { + { id = "cut", move = "CUT" }, + { id = "surf", move = "SURF" }, + { id = "strength", move = "STRENGTH" }, + { id = "flash", move = "FLASH" }, + { id = "headbutt", move = "HEADBUTT" }, + { id = "whirlpool", move = "WHIRLPOOL" }, + { id = "waterfall", move = "WATERFALL" }, + { id = "sweet_scent", move = "SWEET_SCENT" }, + { id = "dig", move = "DIG" }, + { id = "teleport", move = "TELEPORT" }, +} function WorldAPI.new(game, modId) return setmetatable({ game = game, modId = modId }, WorldAPI) @@ -96,6 +108,26 @@ function WorldAPI:availableFieldActions() out[#out + 1] = { id = "fish", label = "FISH", rods = rods } end end + + for _, row in ipairs(FIELD_ACTIONS) do + if not (row.move == "STRENGTH" and world.strengthActive) then + local mon = FieldMoves.partyMoveUser(context.party, row.move, context) + if mon then + context.mon = mon + local result = FieldMoves.fromMenu(row.move, context) + if result.ok then + out[#out + 1] = { id = row.id, + label = row.move:gsub("_", " ") } + end + end + end + end + + if (inventory.SQUIRTBOTTLE or 0) > 0 + and world:squirtbottleTreeScript() then + out[#out + 1] = { id = "squirtbottle", + label = itemLabel(game, "SQUIRTBOTTLE") } + end return out end @@ -123,6 +155,18 @@ function WorldAPI:useFieldAction(id, opts) end end return nil, "fishing rod unavailable" + elseif id == "squirtbottle" then + local outcome = world:useFieldItem("SQUIRTBOTTLE") + if outcome and outcome ~= "nowhere" then return true end + end + for _, row in ipairs(FIELD_ACTIONS) do + if row.id == id then + local context = world:fieldContext() + local mon = FieldMoves.partyMoveUser(context.party, row.move, context) + local result = mon and world:useFieldMove(row.move, mon) + if result and result.ok then return true end + return nil, "field action unavailable" + end end return nil, "field action unavailable" end diff --git a/tests/drivers/gold/flag_names.lua b/tests/drivers/gold/flag_names.lua index 0d4e44b1..685ab5ac 100644 --- a/tests/drivers/gold/flag_names.lua +++ b/tests/drivers/gold/flag_names.lua @@ -1,1340 +1,2 @@ --- Generated by tools/goldwalk/gen_flags.lua from pret/pokegold. Do not edit. --- --- EVENT_* / ENGINE_* name -> the numeric id the cart's scripts carry, which is --- what src/world/gen2/Events.lua and World:engineFlag key on. Verified against --- the ROM by tests/gold_flag_names_test.lua. -return { - source = "pret/pokegold constants/{event,engine}_flags.asm", - events = { - EVENT_ALAN_ASKED_FOR_PHONE_NUMBER = 645, - EVENT_ALAN_READY_FOR_REMATCH = 646, - EVENT_ANTHONY_ASKED_FOR_PHONE_NUMBER = 635, - EVENT_ANTHONY_READY_FOR_REMATCH = 636, - EVENT_ARNIE_ASKED_FOR_PHONE_NUMBER = 643, - EVENT_ARNIE_READY_FOR_REMATCH = 644, - EVENT_AZALEA_TOWN_HIDDEN_FULL_HEAL = 177, - EVENT_AZALEA_TOWN_SLOWPOKES = 1785, - EVENT_AZALEA_TOWN_SLOWPOKETAIL_ROCKET = 1786, - EVENT_BEAT_BEAUTY_ANGELICA = 1207, - EVENT_BEAT_BEAUTY_BRENDA = 1201, - EVENT_BEAT_BEAUTY_CARLENE = 1204, - EVENT_BEAT_BEAUTY_CAROLINE = 1203, - EVENT_BEAT_BEAUTY_CASSIE = 1202, - EVENT_BEAT_BEAUTY_JACLYN = 1200, - EVENT_BEAT_BEAUTY_JESSICA = 1205, - EVENT_BEAT_BEAUTY_JULIA = 1210, - EVENT_BEAT_BEAUTY_JULIE = 1199, - EVENT_BEAT_BEAUTY_KENDRA = 1208, - EVENT_BEAT_BEAUTY_RACHAEL = 1206, - EVENT_BEAT_BEAUTY_SAMANTHA = 1198, - EVENT_BEAT_BEAUTY_THERESA = 1211, - EVENT_BEAT_BEAUTY_VALERIE = 1212, - EVENT_BEAT_BEAUTY_VERONICA = 1209, - EVENT_BEAT_BEAUTY_VICTORIA = 1197, - EVENT_BEAT_BIKER_BENNY = 1073, - EVENT_BEAT_BIKER_CHARLES = 1078, - EVENT_BEAT_BIKER_DWAYNE = 1075, - EVENT_BEAT_BIKER_GLENN = 1081, - EVENT_BEAT_BIKER_HARRIS = 1076, - EVENT_BEAT_BIKER_JOEL = 1080, - EVENT_BEAT_BIKER_KAZU = 1074, - EVENT_BEAT_BIKER_RILEY = 1079, - EVENT_BEAT_BIKER_ZEKE = 1077, - EVENT_BEAT_BIRD_KEEPER_ABE = 1020, - EVENT_BEAT_BIRD_KEEPER_BOB = 1029, - EVENT_BEAT_BIRD_KEEPER_BORIS = 1028, - EVENT_BEAT_BIRD_KEEPER_BRET = 1034, - EVENT_BEAT_BIRD_KEEPER_BRYAN = 1021, - EVENT_BEAT_BIRD_KEEPER_DENIS = 1024, - EVENT_BEAT_BIRD_KEEPER_HANK = 1026, - EVENT_BEAT_BIRD_KEEPER_JOSE = 1030, - EVENT_BEAT_BIRD_KEEPER_JOSE2 = 1032, - EVENT_BEAT_BIRD_KEEPER_JOSE3 = 1035, - EVENT_BEAT_BIRD_KEEPER_PERRY = 1033, - EVENT_BEAT_BIRD_KEEPER_PETER = 1031, - EVENT_BEAT_BIRD_KEEPER_ROD = 1019, - EVENT_BEAT_BIRD_KEEPER_ROY = 1027, - EVENT_BEAT_BIRD_KEEPER_THEO = 1022, - EVENT_BEAT_BIRD_KEEPER_TOBY = 1023, - EVENT_BEAT_BIRD_KEEPER_VANCE = 1025, - EVENT_BEAT_BIRD_KEEPER_VANCE2 = 1036, - EVENT_BEAT_BIRD_KEEPER_VANCE3 = 1037, - EVENT_BEAT_BLACKBELT_KENJI = 1195, - EVENT_BEAT_BLACKBELT_KENJI2 = 1188, - EVENT_BEAT_BLACKBELT_KENJI3 = 1190, - EVENT_BEAT_BLACKBELT_KIYO = 1193, - EVENT_BEAT_BLACKBELT_LAO = 1191, - EVENT_BEAT_BLACKBELT_LUNG = 1194, - EVENT_BEAT_BLACKBELT_NOB = 1192, - EVENT_BEAT_BLACKBELT_WAI = 1196, - EVENT_BEAT_BLACKBELT_YOSHI = 1189, - EVENT_BEAT_BLAINE = 1227, - EVENT_BEAT_BLUE = 1228, - EVENT_BEAT_BOARDER_BRAD = 1039, - EVENT_BEAT_BOARDER_DOUGLAS = 1040, - EVENT_BEAT_BOARDER_RONALD = 1038, - EVENT_BEAT_BROCK = 1221, - EVENT_BEAT_BUGSY = 1214, - EVENT_BEAT_BUG_CATCHER_AL = 1341, - EVENT_BEAT_BUG_CATCHER_ARNIE = 1343, - EVENT_BEAT_BUG_CATCHER_ARNIE2 = 1348, - EVENT_BEAT_BUG_CATCHER_ARNIE3 = 1349, - EVENT_BEAT_BUG_CATCHER_BENNY = 1340, - EVENT_BEAT_BUG_CATCHER_DON = 1336, - EVENT_BEAT_BUG_CATCHER_DOUG = 1347, - EVENT_BEAT_BUG_CATCHER_ED = 1338, - EVENT_BEAT_BUG_CATCHER_JOSH = 1342, - EVENT_BEAT_BUG_CATCHER_KEN = 1344, - EVENT_BEAT_BUG_CATCHER_ROB = 1337, - EVENT_BEAT_BUG_CATCHER_WADE = 1339, - EVENT_BEAT_BUG_CATCHER_WADE2 = 1345, - EVENT_BEAT_BUG_CATCHER_WADE3 = 1346, - EVENT_BEAT_BURGLAR_COREY = 1071, - EVENT_BEAT_BURGLAR_DUNCAN = 1069, - EVENT_BEAT_BURGLAR_EDDIE = 1070, - EVENT_BEAT_CAMPER_BARRY = 1054, - EVENT_BEAT_CAMPER_DALE = 1059, - EVENT_BEAT_CAMPER_DAVID = 1065, - EVENT_BEAT_CAMPER_DEAN = 1056, - EVENT_BEAT_CAMPER_ELLIOT = 1053, - EVENT_BEAT_CAMPER_HERVEY = 1058, - EVENT_BEAT_CAMPER_IVAN = 1052, - EVENT_BEAT_CAMPER_JERRY = 1067, - EVENT_BEAT_CAMPER_JOHN = 1066, - EVENT_BEAT_CAMPER_LEROY = 1064, - EVENT_BEAT_CAMPER_LLOYD = 1055, - EVENT_BEAT_CAMPER_ROLAND = 1050, - EVENT_BEAT_CAMPER_SID = 1057, - EVENT_BEAT_CAMPER_SPENCER = 1068, - EVENT_BEAT_CAMPER_TED = 1060, - EVENT_BEAT_CAMPER_THOMAS = 1063, - EVENT_BEAT_CAMPER_TODD = 1051, - EVENT_BEAT_CAMPER_TODD2 = 1061, - EVENT_BEAT_CAMPER_TODD3 = 1062, - EVENT_BEAT_CHAMPION_LANCE = 1468, - EVENT_BEAT_CHUCK = 1218, - EVENT_BEAT_CLAIR = 1220, - EVENT_BEAT_COOLTRAINERF_BETH = 1379, - EVENT_BEAT_COOLTRAINERF_BETH2 = 1382, - EVENT_BEAT_COOLTRAINERF_BETH3 = 1388, - EVENT_BEAT_COOLTRAINERF_CAROL = 1383, - EVENT_BEAT_COOLTRAINERF_CYBIL = 1386, - EVENT_BEAT_COOLTRAINERF_EMMA = 1385, - EVENT_BEAT_COOLTRAINERF_FRAN = 1373, - EVENT_BEAT_COOLTRAINERF_GWEN = 1371, - EVENT_BEAT_COOLTRAINERF_IRENE = 1376, - EVENT_BEAT_COOLTRAINERF_JENN = 1387, - EVENT_BEAT_COOLTRAINERF_JOYCE = 1378, - EVENT_BEAT_COOLTRAINERF_KATE = 1375, - EVENT_BEAT_COOLTRAINERF_KELLY = 1377, - EVENT_BEAT_COOLTRAINERF_LOIS = 1372, - EVENT_BEAT_COOLTRAINERF_LOLA = 1374, - EVENT_BEAT_COOLTRAINERF_MEGAN = 1381, - EVENT_BEAT_COOLTRAINERF_QUINN = 1384, - EVENT_BEAT_COOLTRAINERF_REENA = 1380, - EVENT_BEAT_COOLTRAINERF_REENA2 = 1389, - EVENT_BEAT_COOLTRAINERF_REENA3 = 1390, - EVENT_BEAT_COOLTRAINERM_AARON = 1353, - EVENT_BEAT_COOLTRAINERM_ALLEN = 1370, - EVENT_BEAT_COOLTRAINERM_ANDY = 1365, - EVENT_BEAT_COOLTRAINERM_BLAKE = 1362, - EVENT_BEAT_COOLTRAINERM_BRIAN = 1363, - EVENT_BEAT_COOLTRAINERM_CODY = 1355, - EVENT_BEAT_COOLTRAINERM_ERICK = 1364, - EVENT_BEAT_COOLTRAINERM_GAVEN = 1361, - EVENT_BEAT_COOLTRAINERM_GAVEN2 = 1357, - EVENT_BEAT_COOLTRAINERM_GAVEN3 = 1358, - EVENT_BEAT_COOLTRAINERM_JAKE = 1360, - EVENT_BEAT_COOLTRAINERM_KEVIN = 1368, - EVENT_BEAT_COOLTRAINERM_MIKE = 1356, - EVENT_BEAT_COOLTRAINERM_NICK = 1352, - EVENT_BEAT_COOLTRAINERM_PAUL = 1354, - EVENT_BEAT_COOLTRAINERM_RYAN = 1359, - EVENT_BEAT_COOLTRAINERM_SEAN = 1367, - EVENT_BEAT_COOLTRAINERM_STEVE = 1369, - EVENT_BEAT_COOLTRAINERM_TYLER = 1366, - EVENT_BEAT_ELITE_4_BRUNO = 1466, - EVENT_BEAT_ELITE_4_KAREN = 1467, - EVENT_BEAT_ELITE_4_KOGA = 1465, - EVENT_BEAT_ELITE_4_WILL = 1464, - EVENT_BEAT_ELITE_FOUR = 68, - EVENT_BEAT_ERIKA = 1224, - EVENT_BEAT_FALKNER = 1213, - EVENT_BEAT_FIREBREATHER_BILL = 1098, - EVENT_BEAT_FIREBREATHER_BURT = 1097, - EVENT_BEAT_FIREBREATHER_DICK = 1095, - EVENT_BEAT_FIREBREATHER_LYLE = 1101, - EVENT_BEAT_FIREBREATHER_NED = 1096, - EVENT_BEAT_FIREBREATHER_OTIS = 1094, - EVENT_BEAT_FIREBREATHER_RAY = 1100, - EVENT_BEAT_FIREBREATHER_WALT = 1099, - EVENT_BEAT_FISHER_ANDRE = 1109, - EVENT_BEAT_FISHER_ARNOLD = 1104, - EVENT_BEAT_FISHER_BARNEY = 1116, - EVENT_BEAT_FISHER_CHRIS = 1108, - EVENT_BEAT_FISHER_CHRIS2 = 1119, - EVENT_BEAT_FISHER_CHRIS3 = 1120, - EVENT_BEAT_FISHER_EDGAR = 1112, - EVENT_BEAT_FISHER_HENRY = 1106, - EVENT_BEAT_FISHER_JONAH = 1113, - EVENT_BEAT_FISHER_JUSTIN = 1102, - EVENT_BEAT_FISHER_KYLE = 1105, - EVENT_BEAT_FISHER_MARTIN = 1114, - EVENT_BEAT_FISHER_MARVIN = 1107, - EVENT_BEAT_FISHER_RALPH = 1103, - EVENT_BEAT_FISHER_RALPH2 = 1117, - EVENT_BEAT_FISHER_RALPH3 = 1118, - EVENT_BEAT_FISHER_RAYMOND = 1110, - EVENT_BEAT_FISHER_SCOTT = 1122, - EVENT_BEAT_FISHER_STEPHEN = 1115, - EVENT_BEAT_FISHER_WILTON = 1111, - EVENT_BEAT_FISHER_WILTON2 = 1121, - EVENT_BEAT_FISHER_WILTON3 = 1123, - EVENT_BEAT_GENTLEMAN_ALFRED = 1182, - EVENT_BEAT_GENTLEMAN_EDWARD = 1179, - EVENT_BEAT_GENTLEMAN_GREGORY = 1180, - EVENT_BEAT_GENTLEMAN_PRESTON = 1178, - EVENT_BEAT_GENTLEMAN_VIRGIL = 1181, - EVENT_BEAT_GUITARIST_CLYDE = 1171, - EVENT_BEAT_GUITARIST_VINCENT = 1172, - EVENT_BEAT_HIKER_ANTHONY = 1320, - EVENT_BEAT_HIKER_ANTHONY2 = 1316, - EVENT_BEAT_HIKER_ANTHONY3 = 1327, - EVENT_BEAT_HIKER_BAILEY = 1326, - EVENT_BEAT_HIKER_BENJAMIN = 1321, - EVENT_BEAT_HIKER_DANIEL = 1333, - EVENT_BEAT_HIKER_ERIK = 1322, - EVENT_BEAT_HIKER_JIM = 1332, - EVENT_BEAT_HIKER_KENNY = 1331, - EVENT_BEAT_HIKER_LEONARD = 1319, - EVENT_BEAT_HIKER_MICHAEL = 1323, - EVENT_BEAT_HIKER_NOLAND = 1329, - EVENT_BEAT_HIKER_PARRY = 1324, - EVENT_BEAT_HIKER_PARRY2 = 1334, - EVENT_BEAT_HIKER_PARRY3 = 1335, - EVENT_BEAT_HIKER_PHILLIP = 1318, - EVENT_BEAT_HIKER_RUSSELL = 1317, - EVENT_BEAT_HIKER_SIDNEY = 1330, - EVENT_BEAT_HIKER_TIM = 1328, - EVENT_BEAT_HIKER_TIMOTHY = 1325, - EVENT_BEAT_JANINE = 1225, - EVENT_BEAT_JASMINE = 1217, - EVENT_BEAT_JUGGLER_FRITZ = 1174, - EVENT_BEAT_JUGGLER_HORTON = 1175, - EVENT_BEAT_JUGGLER_IRWIN = 1173, - EVENT_BEAT_JUGGLER_IRWIN2 = 1176, - EVENT_BEAT_JUGGLER_IRWIN3 = 1177, - EVENT_BEAT_KIMONO_GIRL_KUNI = 1250, - EVENT_BEAT_KIMONO_GIRL_MIKI = 1251, - EVENT_BEAT_KIMONO_GIRL_NAOKO = 1247, - EVENT_BEAT_KIMONO_GIRL_SAYO = 1248, - EVENT_BEAT_KIMONO_GIRL_ZUKI = 1249, - EVENT_BEAT_LASS_ALICE = 1303, - EVENT_BEAT_LASS_BRIDGET = 1302, - EVENT_BEAT_LASS_CARRIE = 1301, - EVENT_BEAT_LASS_CONNIE = 1305, - EVENT_BEAT_LASS_CONNIE2 = 1312, - EVENT_BEAT_LASS_CONNIE3 = 1313, - EVENT_BEAT_LASS_DANA = 1310, - EVENT_BEAT_LASS_DANA2 = 1314, - EVENT_BEAT_LASS_DANA3 = 1315, - EVENT_BEAT_LASS_ELLEN = 1311, - EVENT_BEAT_LASS_KRISE = 1304, - EVENT_BEAT_LASS_LAURA = 1307, - EVENT_BEAT_LASS_LINDA = 1306, - EVENT_BEAT_LASS_MICHELLE = 1309, - EVENT_BEAT_LASS_SHANNON = 1308, - EVENT_BEAT_LTSURGE = 1223, - EVENT_BEAT_MEDIUM_BETHANY = 1421, - EVENT_BEAT_MEDIUM_DORIS = 1425, - EVENT_BEAT_MEDIUM_ETHEL = 1423, - EVENT_BEAT_MEDIUM_GRACE = 1420, - EVENT_BEAT_MEDIUM_MARGRET = 1422, - EVENT_BEAT_MEDIUM_MARTHA = 1419, - EVENT_BEAT_MEDIUM_REBECCA = 1424, - EVENT_BEAT_MISTY = 1222, - EVENT_BEAT_MORTY = 1216, - EVENT_BEAT_OFFICER_DIRK = 1351, - EVENT_BEAT_OFFICER_KEITH = 1350, - EVENT_BEAT_PICNICKER_BROOKE = 1152, - EVENT_BEAT_PICNICKER_CINDY = 1154, - EVENT_BEAT_PICNICKER_DEBRA = 1157, - EVENT_BEAT_PICNICKER_EDNA = 1163, - EVENT_BEAT_PICNICKER_ERIN = 1159, - EVENT_BEAT_PICNICKER_ERIN2 = 1167, - EVENT_BEAT_PICNICKER_ERIN3 = 1170, - EVENT_BEAT_PICNICKER_GINA = 1151, - EVENT_BEAT_PICNICKER_GINA2 = 1158, - EVENT_BEAT_PICNICKER_GINA3 = 1164, - EVENT_BEAT_PICNICKER_HEIDI = 1162, - EVENT_BEAT_PICNICKER_HOPE = 1155, - EVENT_BEAT_PICNICKER_KIM = 1153, - EVENT_BEAT_PICNICKER_LIZ = 1150, - EVENT_BEAT_PICNICKER_LIZ2 = 1160, - EVENT_BEAT_PICNICKER_LIZ3 = 1161, - EVENT_BEAT_PICNICKER_SHARON = 1156, - EVENT_BEAT_PICNICKER_TANYA = 1168, - EVENT_BEAT_PICNICKER_TIFFANY = 1169, - EVENT_BEAT_PICNICKER_TIFFANY2 = 1165, - EVENT_BEAT_PICNICKER_TIFFANY3 = 1166, - EVENT_BEAT_POKEFANF_BEVERLY = 1241, - EVENT_BEAT_POKEFANF_BEVERLY2 = 1243, - EVENT_BEAT_POKEFANF_BEVERLY3 = 1244, - EVENT_BEAT_POKEFANF_GEORGIA = 1245, - EVENT_BEAT_POKEFANF_RUTH = 1242, - EVENT_BEAT_POKEFANM_ALEX = 1240, - EVENT_BEAT_POKEFANM_BRANDON = 1235, - EVENT_BEAT_POKEFANM_CARTER = 1233, - EVENT_BEAT_POKEFANM_COLIN = 1237, - EVENT_BEAT_POKEFANM_DEREK = 1230, - EVENT_BEAT_POKEFANM_DEREK2 = 1238, - EVENT_BEAT_POKEFANM_DEREK3 = 1239, - EVENT_BEAT_POKEFANM_JEREMY = 1236, - EVENT_BEAT_POKEFANM_JOSHUA = 1232, - EVENT_BEAT_POKEFANM_ROBERT = 1231, - EVENT_BEAT_POKEFANM_TREVOR = 1234, - EVENT_BEAT_POKEFANM_WILLIAM = 1229, - EVENT_BEAT_POKEMANIAC_ANDREW = 1253, - EVENT_BEAT_POKEMANIAC_BEN = 1256, - EVENT_BEAT_POKEMANIAC_BRENT = 1257, - EVENT_BEAT_POKEMANIAC_BRENT2 = 1260, - EVENT_BEAT_POKEMANIAC_BRENT3 = 1261, - EVENT_BEAT_POKEMANIAC_CALVIN = 1254, - EVENT_BEAT_POKEMANIAC_DONALD = 1263, - EVENT_BEAT_POKEMANIAC_ETHAN = 1259, - EVENT_BEAT_POKEMANIAC_ISSAC = 1262, - EVENT_BEAT_POKEMANIAC_LARRY = 1252, - EVENT_BEAT_POKEMANIAC_RON = 1258, - EVENT_BEAT_POKEMANIAC_SHANE = 1255, - EVENT_BEAT_POKEMANIAC_ZACH = 1264, - EVENT_BEAT_PRYCE = 1219, - EVENT_BEAT_PSYCHIC_FIDEL = 1085, - EVENT_BEAT_PSYCHIC_FRANKLIN = 1083, - EVENT_BEAT_PSYCHIC_GILBERT = 1091, - EVENT_BEAT_PSYCHIC_GREG = 1086, - EVENT_BEAT_PSYCHIC_HERMAN = 1084, - EVENT_BEAT_PSYCHIC_JARED = 1092, - EVENT_BEAT_PSYCHIC_MARK = 1088, - EVENT_BEAT_PSYCHIC_NATHAN = 1082, - EVENT_BEAT_PSYCHIC_NORMAN = 1087, - EVENT_BEAT_PSYCHIC_PHIL = 1089, - EVENT_BEAT_PSYCHIC_RICHARD = 1090, - EVENT_BEAT_PSYCHIC_RODNEY = 1093, - EVENT_BEAT_RIVAL_IN_MT_MOON = 793, - EVENT_BEAT_ROCKET_EXECUTIVEF_1 = 1391, - EVENT_BEAT_ROCKET_EXECUTIVEF_2 = 1392, - EVENT_BEAT_ROCKET_EXECUTIVEM_1 = 1393, - EVENT_BEAT_ROCKET_EXECUTIVEM_2 = 1394, - EVENT_BEAT_ROCKET_EXECUTIVEM_3 = 1395, - EVENT_BEAT_ROCKET_EXECUTIVEM_4 = 1396, - EVENT_BEAT_ROCKET_GRUNTF_1 = 1296, - EVENT_BEAT_ROCKET_GRUNTF_2 = 1297, - EVENT_BEAT_ROCKET_GRUNTF_3 = 1298, - EVENT_BEAT_ROCKET_GRUNTF_4 = 1299, - EVENT_BEAT_ROCKET_GRUNTF_5 = 1300, - EVENT_BEAT_ROCKET_GRUNTM_1 = 1265, - EVENT_BEAT_ROCKET_GRUNTM_10 = 1274, - EVENT_BEAT_ROCKET_GRUNTM_11 = 1275, - EVENT_BEAT_ROCKET_GRUNTM_12 = 1276, - EVENT_BEAT_ROCKET_GRUNTM_13 = 1277, - EVENT_BEAT_ROCKET_GRUNTM_14 = 1278, - EVENT_BEAT_ROCKET_GRUNTM_15 = 1279, - EVENT_BEAT_ROCKET_GRUNTM_16 = 1280, - EVENT_BEAT_ROCKET_GRUNTM_17 = 1281, - EVENT_BEAT_ROCKET_GRUNTM_18 = 1282, - EVENT_BEAT_ROCKET_GRUNTM_19 = 1283, - EVENT_BEAT_ROCKET_GRUNTM_2 = 1266, - EVENT_BEAT_ROCKET_GRUNTM_20 = 1284, - EVENT_BEAT_ROCKET_GRUNTM_21 = 1285, - EVENT_BEAT_ROCKET_GRUNTM_22 = 1286, - EVENT_BEAT_ROCKET_GRUNTM_23 = 1287, - EVENT_BEAT_ROCKET_GRUNTM_24 = 1288, - EVENT_BEAT_ROCKET_GRUNTM_25 = 1289, - EVENT_BEAT_ROCKET_GRUNTM_26 = 1290, - EVENT_BEAT_ROCKET_GRUNTM_27 = 1291, - EVENT_BEAT_ROCKET_GRUNTM_28 = 1292, - EVENT_BEAT_ROCKET_GRUNTM_29 = 1293, - EVENT_BEAT_ROCKET_GRUNTM_3 = 1267, - EVENT_BEAT_ROCKET_GRUNTM_30 = 1294, - EVENT_BEAT_ROCKET_GRUNTM_31 = 1295, - EVENT_BEAT_ROCKET_GRUNTM_4 = 1268, - EVENT_BEAT_ROCKET_GRUNTM_5 = 1269, - EVENT_BEAT_ROCKET_GRUNTM_6 = 1270, - EVENT_BEAT_ROCKET_GRUNTM_7 = 1271, - EVENT_BEAT_ROCKET_GRUNTM_8 = 1272, - EVENT_BEAT_ROCKET_GRUNTM_9 = 1273, - EVENT_BEAT_SABRINA = 1226, - EVENT_BEAT_SAGE_CHOW = 1041, - EVENT_BEAT_SAGE_EDMOND = 1047, - EVENT_BEAT_SAGE_JEFFREY = 1045, - EVENT_BEAT_SAGE_JIN = 1043, - EVENT_BEAT_SAGE_LI = 1049, - EVENT_BEAT_SAGE_NEAL = 1048, - EVENT_BEAT_SAGE_NICO = 1042, - EVENT_BEAT_SAGE_PING = 1046, - EVENT_BEAT_SAGE_TROY = 1044, - EVENT_BEAT_SAILOR_ERNEST = 1401, - EVENT_BEAT_SAILOR_EUGENE = 1397, - EVENT_BEAT_SAILOR_GARRETT = 1403, - EVENT_BEAT_SAILOR_HARRY = 1406, - EVENT_BEAT_SAILOR_HUEY = 1398, - EVENT_BEAT_SAILOR_HUEY2 = 1407, - EVENT_BEAT_SAILOR_HUEY3 = 1408, - EVENT_BEAT_SAILOR_JEFF = 1402, - EVENT_BEAT_SAILOR_KENNETH = 1404, - EVENT_BEAT_SAILOR_KENT = 1400, - EVENT_BEAT_SAILOR_STANLY = 1405, - EVENT_BEAT_SAILOR_TERRELL = 1399, - EVENT_BEAT_SCHOOLBOY_ALAN = 1134, - EVENT_BEAT_SCHOOLBOY_ALAN2 = 1146, - EVENT_BEAT_SCHOOLBOY_ALAN3 = 1147, - EVENT_BEAT_SCHOOLBOY_BILLY = 1140, - EVENT_BEAT_SCHOOLBOY_CHAD = 1141, - EVENT_BEAT_SCHOOLBOY_CHAD2 = 1148, - EVENT_BEAT_SCHOOLBOY_CHAD3 = 1149, - EVENT_BEAT_SCHOOLBOY_DANNY = 1136, - EVENT_BEAT_SCHOOLBOY_DUDLEY = 1138, - EVENT_BEAT_SCHOOLBOY_JACK = 1132, - EVENT_BEAT_SCHOOLBOY_JACK2 = 1144, - EVENT_BEAT_SCHOOLBOY_JACK3 = 1145, - EVENT_BEAT_SCHOOLBOY_JOE = 1139, - EVENT_BEAT_SCHOOLBOY_JOHNNY = 1135, - EVENT_BEAT_SCHOOLBOY_KIP = 1133, - EVENT_BEAT_SCHOOLBOY_NATE = 1142, - EVENT_BEAT_SCHOOLBOY_RICKY = 1143, - EVENT_BEAT_SCHOOLBOY_TOMMY = 1137, - EVENT_BEAT_SCIENTIST_JED = 1185, - EVENT_BEAT_SCIENTIST_MARC = 1186, - EVENT_BEAT_SCIENTIST_MITCH = 1184, - EVENT_BEAT_SCIENTIST_RICH = 1187, - EVENT_BEAT_SCIENTIST_ROSS = 1183, - EVENT_BEAT_SKIER_CLARISSA = 1427, - EVENT_BEAT_SKIER_ROXANNE = 1426, - EVENT_BEAT_SUPER_NERD_ERIC = 1410, - EVENT_BEAT_SUPER_NERD_ERIC_UNUSED = 1409, - EVENT_BEAT_SUPER_NERD_GREGG = 1411, - EVENT_BEAT_SUPER_NERD_JAY = 1412, - EVENT_BEAT_SUPER_NERD_PAT = 1416, - EVENT_BEAT_SUPER_NERD_SAM = 1414, - EVENT_BEAT_SUPER_NERD_SHAWN = 1417, - EVENT_BEAT_SUPER_NERD_TERU = 1418, - EVENT_BEAT_SUPER_NERD_TOM = 1415, - EVENT_BEAT_SWIMMERF_BRIANA = 1018, - EVENT_BEAT_SWIMMERF_DAWN = 1011, - EVENT_BEAT_SWIMMERF_DENISE = 1004, - EVENT_BEAT_SWIMMERF_DIANA = 1017, - EVENT_BEAT_SWIMMERF_ELAINE = 1000, - EVENT_BEAT_SWIMMERF_JILL = 1008, - EVENT_BEAT_SWIMMERF_JODY = 1015, - EVENT_BEAT_SWIMMERF_KARA = 1005, - EVENT_BEAT_SWIMMERF_KATIE = 1010, - EVENT_BEAT_SWIMMERF_KAYLEE = 1002, - EVENT_BEAT_SWIMMERF_LISA = 1007, - EVENT_BEAT_SWIMMERF_LORI = 1014, - EVENT_BEAT_SWIMMERF_MARY = 1009, - EVENT_BEAT_SWIMMERF_NICOLE = 1013, - EVENT_BEAT_SWIMMERF_NIKKI = 1016, - EVENT_BEAT_SWIMMERF_PAULA = 1001, - EVENT_BEAT_SWIMMERF_SUSIE = 1003, - EVENT_BEAT_SWIMMERF_TARA = 1012, - EVENT_BEAT_SWIMMERF_WENDY = 1006, - EVENT_BEAT_SWIMMERM_BERKE = 1433, - EVENT_BEAT_SWIMMERM_CAMERON = 1444, - EVENT_BEAT_SWIMMERM_CHARLIE = 1431, - EVENT_BEAT_SWIMMERM_DARYL = 1438, - EVENT_BEAT_SWIMMERM_GEORGE = 1432, - EVENT_BEAT_SWIMMERM_HAL = 1436, - EVENT_BEAT_SWIMMERM_HAROLD = 1428, - EVENT_BEAT_SWIMMERM_JAMES = 1446, - EVENT_BEAT_SWIMMERM_JEROME = 1441, - EVENT_BEAT_SWIMMERM_KIRK = 1434, - EVENT_BEAT_SWIMMERM_LEWIS = 1447, - EVENT_BEAT_SWIMMERM_MATHEW = 1435, - EVENT_BEAT_SWIMMERM_PARKER = 1448, - EVENT_BEAT_SWIMMERM_PATON = 1437, - EVENT_BEAT_SWIMMERM_RANDALL = 1430, - EVENT_BEAT_SWIMMERM_RICK = 1443, - EVENT_BEAT_SWIMMERM_SETH = 1445, - EVENT_BEAT_SWIMMERM_SIMON = 1429, - EVENT_BEAT_SWIMMERM_TONY = 1440, - EVENT_BEAT_SWIMMERM_TUCKER = 1442, - EVENT_BEAT_SWIMMERM_WALTER = 1439, - EVENT_BEAT_TEACHER_COLETTE = 1461, - EVENT_BEAT_TEACHER_HILLARY = 1462, - EVENT_BEAT_TEACHER_SHIRLEY = 1463, - EVENT_BEAT_TWINS_AMY_AND_MAY = 1124, - EVENT_BEAT_TWINS_AMY_AND_MAY2 = 1127, - EVENT_BEAT_TWINS_ANN_AND_ANNE = 1125, - EVENT_BEAT_TWINS_ANN_AND_ANNE2 = 1126, - EVENT_BEAT_TWINS_JO_AND_ZOE = 1128, - EVENT_BEAT_TWINS_JO_AND_ZOE2 = 1129, - EVENT_BEAT_TWINS_MEG_AND_PEG = 1130, - EVENT_BEAT_TWINS_MEG_AND_PEG2 = 1131, - EVENT_BEAT_WHITNEY = 1215, - EVENT_BEAT_YOUNGSTER_ALBERT = 1451, - EVENT_BEAT_YOUNGSTER_GORDON = 1452, - EVENT_BEAT_YOUNGSTER_IAN = 1454, - EVENT_BEAT_YOUNGSTER_JASON = 1460, - EVENT_BEAT_YOUNGSTER_JIMMY = 1458, - EVENT_BEAT_YOUNGSTER_JOEY = 1449, - EVENT_BEAT_YOUNGSTER_JOEY2 = 1455, - EVENT_BEAT_YOUNGSTER_JOEY3 = 1456, - EVENT_BEAT_YOUNGSTER_MIKEY = 1450, - EVENT_BEAT_YOUNGSTER_OWEN = 1459, - EVENT_BEAT_YOUNGSTER_SAMUEL = 1453, - EVENT_BEAT_YOUNGSTER_WARREN = 1457, - EVENT_BEAY_SUPER_NERD_DAVE = 1413, - EVENT_BETH_ASKED_FOR_PHONE_NUMBER = 621, - EVENT_BETH_READY_FOR_REMATCH = 622, - EVENT_BEVERLY_ASKED_FOR_PHONE_NUMBER = 609, - EVENT_BEVERLY_READY_FOR_REMATCH = 610, - EVENT_BLACKTHORN_CITY_GRAMPS_BLOCKS_DRAGONS_DEN = 1868, - EVENT_BLACKTHORN_CITY_GRAMPS_NOT_BLOCKING_DRAGONS_DEN = 1869, - EVENT_BLACKTHORN_CITY_SANTOS_OF_SATURDAY = 1885, - EVENT_BLACKTHORN_CITY_SUPER_NERD_BLOCKS_GYM = 1763, - EVENT_BLACKTHORN_CITY_SUPER_NERD_DOES_NOT_BLOCK_GYM = 1764, - EVENT_BLUE_IN_CINNABAR = 1909, - EVENT_BOULDER_IN_BLACKTHORN_GYM_1 = 1798, - EVENT_BOULDER_IN_BLACKTHORN_GYM_2 = 1799, - EVENT_BOULDER_IN_BLACKTHORN_GYM_3 = 1800, - EVENT_BOULDER_IN_ICE_PATH_1 = 1801, - EVENT_BOULDER_IN_ICE_PATH_1A = 1805, - EVENT_BOULDER_IN_ICE_PATH_2 = 1802, - EVENT_BOULDER_IN_ICE_PATH_2A = 1806, - EVENT_BOULDER_IN_ICE_PATH_3 = 1803, - EVENT_BOULDER_IN_ICE_PATH_3A = 1807, - EVENT_BOULDER_IN_ICE_PATH_4 = 1804, - EVENT_BOULDER_IN_ICE_PATH_4A = 1808, - EVENT_BRENT_ASKED_FOR_PHONE_NUMBER = 657, - EVENT_BRENT_READY_FOR_REMATCH = 658, - EVENT_BRUNOS_ROOM_ENTRANCE_CLOSED = 781, - EVENT_BRUNOS_ROOM_EXIT_OPEN = 782, - EVENT_BUG_CATCHING_CONTESTANT_10A = 1823, - EVENT_BUG_CATCHING_CONTESTANT_10B = 1833, - EVENT_BUG_CATCHING_CONTESTANT_1A = 1814, - EVENT_BUG_CATCHING_CONTESTANT_1B = 1824, - EVENT_BUG_CATCHING_CONTESTANT_2A = 1815, - EVENT_BUG_CATCHING_CONTESTANT_2B = 1825, - EVENT_BUG_CATCHING_CONTESTANT_3A = 1816, - EVENT_BUG_CATCHING_CONTESTANT_3B = 1826, - EVENT_BUG_CATCHING_CONTESTANT_4A = 1817, - EVENT_BUG_CATCHING_CONTESTANT_4B = 1827, - EVENT_BUG_CATCHING_CONTESTANT_5A = 1818, - EVENT_BUG_CATCHING_CONTESTANT_5B = 1828, - EVENT_BUG_CATCHING_CONTESTANT_6A = 1819, - EVENT_BUG_CATCHING_CONTESTANT_6B = 1829, - EVENT_BUG_CATCHING_CONTESTANT_7A = 1820, - EVENT_BUG_CATCHING_CONTESTANT_7B = 1830, - EVENT_BUG_CATCHING_CONTESTANT_8A = 1821, - EVENT_BUG_CATCHING_CONTESTANT_8B = 1831, - EVENT_BUG_CATCHING_CONTESTANT_9A = 1822, - EVENT_BUG_CATCHING_CONTESTANT_9B = 1832, - EVENT_BURNED_TOWER_1F_BURN_HEAL = 1621, - EVENT_BURNED_TOWER_1F_HIDDEN_ETHER = 128, - EVENT_BURNED_TOWER_1F_X_SPEED = 1620, - EVENT_BURNED_TOWER_B1F_BEASTS_1 = 1866, - EVENT_BURNED_TOWER_B1F_BEASTS_2 = 1867, - EVENT_BURNED_TOWER_B1F_HIDDEN_BURN_HEAL = 130, - EVENT_BURNED_TOWER_B1F_HIDDEN_NUGGET = 131, - EVENT_BURNED_TOWER_B1F_HIDDEN_ULTRA_BALL = 129, - EVENT_BURNED_TOWER_B1F_HP_UP = 1622, - EVENT_BURNED_TOWER_B1F_TM_ENDURE = 1623, - EVENT_BURNED_TOWER_FIREBREATHER_DICK_ASHES = 1893, - EVENT_BURNED_TOWER_FIREBREATHER_DICK_NORMAL = 1892, - EVENT_CELADON_CITY_HIDDEN_PP_UP = 253, - EVENT_CERULEAN_GYM_ROCKET = 1901, - EVENT_CHAD_ASKED_FOR_PHONE_NUMBER = 651, - EVENT_CHAD_READY_FOR_REMATCH = 652, - EVENT_CHARCOAL_KILN_APPRENTICE = 1782, - EVENT_CHARCOAL_KILN_BOSS = 1783, - EVENT_CHARCOAL_KILN_FARFETCH_D = 1781, - EVENT_CHIKORITA_POKEBALL_IN_ELMS_LAB = 1602, - EVENT_CHRIS_ASKED_FOR_PHONE_NUMBER = 655, - EVENT_CHRIS_READY_FOR_REMATCH = 656, - EVENT_CIANWOOD_CITY_HIDDEN_MAX_ETHER = 179, - EVENT_CIANWOOD_CITY_HIDDEN_REVIVE = 178, - EVENT_CINNABAR_ISLAND_HIDDEN_RARE_CANDY = 254, - EVENT_CINNABAR_ROCKS_CLEARED = 215, - EVENT_CLEARED_NUGGET_BRIDGE = 216, - EVENT_CLEARED_RADIO_TOWER = 33, - EVENT_CLEARED_ROCKET_HIDEOUT = 34, - EVENT_CLEARED_SLOWPOKE_WELL = 43, - EVENT_CONTEST_OFFICER_HAS_BERRY = 790, - EVENT_CONTEST_OFFICER_HAS_EVERSTONE = 788, - EVENT_CONTEST_OFFICER_HAS_GOLD_BERRY = 789, - EVENT_CONTEST_OFFICER_HAS_SUN_STONE = 787, - EVENT_COPYCATS_HOUSE_2F_DOLL = 1907, - EVENT_COP_IN_ELMS_LAB = 1794, - EVENT_CYNDAQUIL_POKEBALL_IN_ELMS_LAB = 1600, - EVENT_DANA_ASKED_FOR_PHONE_NUMBER = 649, - EVENT_DANA_READY_FOR_REMATCH = 650, - EVENT_DARK_CAVE_BLACKTHORN_ENTRANCE_REVIVE = 1697, - EVENT_DARK_CAVE_BLACKTHORN_ENTRANCE_TM_SNORE = 1698, - EVENT_DARK_CAVE_VIOLET_ENTRANCE_FULL_HEAL = 1695, - EVENT_DARK_CAVE_VIOLET_ENTRANCE_HIDDEN_ELIXER = 157, - EVENT_DARK_CAVE_VIOLET_ENTRANCE_HYPER_POTION = 1696, - EVENT_DARK_CAVE_VIOLET_ENTRANCE_POTION = 1694, - EVENT_DAY_CARE_MAN_IN_DAY_CARE = 1765, - EVENT_DAY_CARE_MAN_ON_ROUTE_34 = 1766, - EVENT_DAY_CARE_MON_1 = 1767, - EVENT_DAY_CARE_MON_2 = 1768, - EVENT_DECIDED_TO_HELP_LANCE = 96, - EVENT_DECO_BED_1 = 676, - EVENT_DECO_BED_2 = 677, - EVENT_DECO_BED_3 = 678, - EVENT_DECO_BED_4 = 679, - EVENT_DECO_BIG_LAPRAS_DOLL = 721, - EVENT_DECO_BIG_ONIX_DOLL = 720, - EVENT_DECO_BIG_SNORLAX_DOLL = 719, - EVENT_DECO_BULBASAUR_DOLL = 699, - EVENT_DECO_CARPET_1 = 680, - EVENT_DECO_CARPET_2 = 681, - EVENT_DECO_CARPET_3 = 682, - EVENT_DECO_CARPET_4 = 683, - EVENT_DECO_CHARMANDER_DOLL = 700, - EVENT_DECO_CLEFAIRY_DOLL = 697, - EVENT_DECO_DIGLETT_DOLL = 703, - EVENT_DECO_FAMICOM = 691, - EVENT_DECO_GENGAR_DOLL = 707, - EVENT_DECO_GEODUDE_DOLL = 713, - EVENT_DECO_GOLD_TROPHY = 717, - EVENT_DECO_GRIMER_DOLL = 709, - EVENT_DECO_JIGGLYPUFF_DOLL = 698, - EVENT_DECO_MACHOP_DOLL = 714, - EVENT_DECO_MAGIKARP_DOLL = 705, - EVENT_DECO_N64 = 693, - EVENT_DECO_ODDISH_DOLL = 706, - EVENT_DECO_PIKACHU_DOLL = 695, - EVENT_DECO_PLANT_1 = 684, - EVENT_DECO_PLANT_2 = 685, - EVENT_DECO_PLANT_3 = 686, - EVENT_DECO_POLIWAG_DOLL = 702, - EVENT_DECO_POSTER_1 = 687, - EVENT_DECO_POSTER_2 = 688, - EVENT_DECO_POSTER_3 = 689, - EVENT_DECO_POSTER_4 = 690, - EVENT_DECO_SHELLDER_DOLL = 708, - EVENT_DECO_SILVER_TROPHY = 718, - EVENT_DECO_SNES = 692, - EVENT_DECO_SQUIRTLE_DOLL = 701, - EVENT_DECO_STARYU_DOLL = 704, - EVENT_DECO_SURFING_PIKACHU_DOLL = 696, - EVENT_DECO_TENTACOOL_DOLL = 715, - EVENT_DECO_UNOWN_DOLL = 712, - EVENT_DECO_VIRTUAL_BOY = 694, - EVENT_DECO_VOLTORB_DOLL = 710, - EVENT_DECO_WEEDLE_DOLL = 711, - EVENT_DEREK_ASKED_FOR_PHONE_NUMBER = 653, - EVENT_DEREK_READY_FOR_REMATCH = 654, - EVENT_DIGLETTS_CAVE_HIDDEN_MAX_REVIVE = 228, - EVENT_DOOR_10_OPEN = 736, - EVENT_DOOR_11_OPEN = 737, - EVENT_DOOR_1_OPEN = 727, - EVENT_DOOR_2_OPEN = 728, - EVENT_DOOR_3_OPEN = 729, - EVENT_DOOR_4_OPEN = 730, - EVENT_DOOR_5_OPEN = 731, - EVENT_DOOR_6_OPEN = 732, - EVENT_DOOR_7_OPEN = 733, - EVENT_DOOR_8_OPEN = 734, - EVENT_DOOR_9_OPEN = 735, - EVENT_DRAGONS_DEN_B1F_DRAGON_FANG = 1704, - EVENT_DRAGONS_DEN_B1F_HIDDEN_MAX_ELIXER = 162, - EVENT_DRAGONS_DEN_B1F_HIDDEN_MAX_POTION = 161, - EVENT_DRAGONS_DEN_B1F_HIDDEN_REVIVE = 160, - EVENT_DRAGONS_DEN_CLAIR = 1752, - EVENT_DUDE_TALKED_TO_YOU = 65, - EVENT_EARLS_ACADEMY_EARL = 1739, - EVENT_ECRUTEAK_CITY_HIDDEN_HYPER_POTION = 180, - EVENT_ECRUTEAK_POKE_CENTER_BILL = 1811, - EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_LEFT = 1894, - EVENT_ECRUTEAK_TIN_TOWER_ENTRANCE_SAGE_RIGHT = 1895, - EVENT_ELMS_AIDE_IN_LAB = 1793, - EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER = 1792, - EVENT_ELM_CALLED_ABOUT_STOLEN_POKEMON = 67, - EVENT_EMERGENCY_SWITCH = 726, - EVENT_ENABLE_DIPLOMA_PRINTING = 214, - EVENT_ERIN_ASKED_FOR_PHONE_NUMBER = 669, - EVENT_ERIN_READY_FOR_REMATCH = 670, - EVENT_EXPLODING_TRAP_1 = 745, - EVENT_EXPLODING_TRAP_10 = 754, - EVENT_EXPLODING_TRAP_11 = 755, - EVENT_EXPLODING_TRAP_12 = 756, - EVENT_EXPLODING_TRAP_13 = 757, - EVENT_EXPLODING_TRAP_14 = 758, - EVENT_EXPLODING_TRAP_15 = 759, - EVENT_EXPLODING_TRAP_16 = 760, - EVENT_EXPLODING_TRAP_17 = 761, - EVENT_EXPLODING_TRAP_18 = 762, - EVENT_EXPLODING_TRAP_19 = 763, - EVENT_EXPLODING_TRAP_2 = 746, - EVENT_EXPLODING_TRAP_20 = 764, - EVENT_EXPLODING_TRAP_21 = 765, - EVENT_EXPLODING_TRAP_22 = 766, - EVENT_EXPLODING_TRAP_3 = 747, - EVENT_EXPLODING_TRAP_4 = 748, - EVENT_EXPLODING_TRAP_5 = 749, - EVENT_EXPLODING_TRAP_6 = 750, - EVENT_EXPLODING_TRAP_7 = 751, - EVENT_EXPLODING_TRAP_8 = 752, - EVENT_EXPLODING_TRAP_9 = 753, - EVENT_FAST_SHIP_1F_GENTLEMAN = 1836, - EVENT_FAST_SHIP_B1F_SAILOR_LEFT = 1838, - EVENT_FAST_SHIP_B1F_SAILOR_RIGHT = 1839, - EVENT_FAST_SHIP_CABINS_NNW_NNE_NE_SAILOR = 1837, - EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_1 = 1841, - EVENT_FAST_SHIP_CABINS_SE_SSE_CAPTAINS_CABIN_TWIN_2 = 1842, - EVENT_FAST_SHIP_CABINS_SE_SSE_GENTLEMAN = 1840, - EVENT_FAST_SHIP_DESTINATION_OLIVINE = 47, - EVENT_FAST_SHIP_FIRST_TIME = 48, - EVENT_FAST_SHIP_FOUND_GIRL = 50, - EVENT_FAST_SHIP_HAS_ARRIVED = 49, - EVENT_FAST_SHIP_INFORMED_ABOUT_LAZY_SAILOR = 52, - EVENT_FAST_SHIP_LAZY_SAILOR = 51, - EVENT_FAST_SHIP_PASSENGERS_EASTBOUND = 1850, - EVENT_FAST_SHIP_PASSENGERS_FIRST_TRIP = 1849, - EVENT_FAST_SHIP_PASSENGERS_WESTBOUND = 1851, - EVENT_FIRST_TIME_BANKING_WITH_MOM = 118, - EVENT_FOUGHT_HO_OH = 791, - EVENT_FOUGHT_LUGIA = 792, - EVENT_FOUGHT_SNORLAX = 1872, - EVENT_FOUGHT_SUDOWOODO = 42, - EVENT_FOUND_BERSERK_GENE_IN_CERULEAN_CITY = 250, - EVENT_FOUND_LEFTOVERS_IN_CELADON_CAFE = 249, - EVENT_FOUND_MACHINE_PART_IN_CERULEAN_GYM = 251, - EVENT_GAVEN_ASKED_FOR_PHONE_NUMBER = 619, - EVENT_GAVEN_READY_FOR_REMATCH = 620, - EVENT_GAVE_KENYA = 81, - EVENT_GAVE_KURT_BLK_APRICORN = 605, - EVENT_GAVE_KURT_BLU_APRICORN = 601, - EVENT_GAVE_KURT_GRN_APRICORN = 603, - EVENT_GAVE_KURT_PNK_APRICORN = 606, - EVENT_GAVE_KURT_RED_APRICORN = 600, - EVENT_GAVE_KURT_WHT_APRICORN = 604, - EVENT_GAVE_KURT_YLW_APRICORN = 602, - EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31, - EVENT_GINA_ASKED_FOR_PHONE_NUMBER = 639, - EVENT_GINA_READY_FOR_REMATCH = 640, - EVENT_GOLDENROD_CITY_CIVILIANS = 1743, - EVENT_GOLDENROD_CITY_ROCKET_SCOUT = 1740, - EVENT_GOLDENROD_CITY_ROCKET_TAKEOVER = 1741, - EVENT_GOLDENROD_DEPT_STORE_5F_HAPPINESS_EVENT_LADY = 1891, - EVENT_GOLDENROD_DEPT_STORE_B1F_AMULET_COIN = 1654, - EVENT_GOLDENROD_DEPT_STORE_B1F_BURN_HEAL = 1655, - EVENT_GOLDENROD_DEPT_STORE_B1F_ETHER = 1653, - EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_1 = 772, - EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_2 = 773, - EVENT_GOLDENROD_DEPT_STORE_B1F_LAYOUT_3 = 774, - EVENT_GOLDENROD_DEPT_STORE_B1F_ULTRA_BALL = 1656, - EVENT_GOLDENROD_TRAIN_STATION_GENTLEMAN = 1865, - EVENT_GOLDENROD_UNDERGROUND_COIN_CASE = 1650, - EVENT_GOLDENROD_UNDERGROUND_GRAMPS = 1875, - EVENT_GOLDENROD_UNDERGROUND_GRANNY = 1874, - EVENT_GOLDENROD_UNDERGROUND_HIDDEN_ANTIDOTE = 141, - EVENT_GOLDENROD_UNDERGROUND_HIDDEN_PARLYZ_HEAL = 139, - EVENT_GOLDENROD_UNDERGROUND_HIDDEN_SUPER_POTION = 140, - EVENT_GOLDENROD_UNDERGROUND_OLDER_HAIRCUT_BROTHER = 1876, - EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_FULL_HEAL = 1652, - EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_HIDDEN_MAX_POTION = 142, - EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_HIDDEN_REVIVE = 143, - EVENT_GOLDENROD_UNDERGROUND_SWITCH_ROOM_ENTRANCES_SMOKE_BALL = 1651, - EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_BLOCKED_OFF = 775, - EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_MAX_ETHER = 1657, - EVENT_GOLDENROD_UNDERGROUND_WAREHOUSE_TM_SLEEP_TALK = 1658, - EVENT_GOLDENROD_UNDERGROUND_YOUNGER_HAIRCUT_BROTHER = 1877, - EVENT_GOT_A_POKEMON_FROM_ELM = 26, - EVENT_GOT_BERRY_FROM_ROUTE_30_HOUSE = 39, - EVENT_GOT_BICYCLE = 91, - EVENT_GOT_BLACKBELT_FROM_WESLEY = 107, - EVENT_GOT_BLACKGLASSES_IN_DARK_CAVE = 114, - EVENT_GOT_CALCIUM_FROM_ERIN = 617, - EVENT_GOT_CARBOS_FROM_VANCE = 615, - EVENT_GOT_CHARCOAL_IN_CHARCOAL_KILN = 94, - EVENT_GOT_CHIKORITA_FROM_ELM = 29, - EVENT_GOT_CLEANSE_TAG = 219, - EVENT_GOT_COINS_FROM_GAMBLER_AT_CELADON = 206, - EVENT_GOT_CYNDAQUIL_FROM_ELM = 27, - EVENT_GOT_EEVEE = 79, - EVENT_GOT_EVERSTONE_FROM_BILLS_GRANDPA = 800, - EVENT_GOT_EVERSTONE_FROM_ELM = 86, - EVENT_GOT_FIRE_STONE_FROM_BILLS_GRANDPA = 803, - EVENT_GOT_GOOD_ROD = 24, - EVENT_GOT_HARD_STONE_FROM_ARTHUR = 103, - EVENT_GOT_HM01_CUT = 16, - EVENT_GOT_HM02_FLY = 17, - EVENT_GOT_HM03_SURF = 18, - EVENT_GOT_HM04_STRENGTH = 19, - EVENT_GOT_HM05_FLASH = 20, - EVENT_GOT_HM06_WHIRLPOOL = 21, - EVENT_GOT_HM07_WATERFALL = 1672, - EVENT_GOT_HP_UP_FROM_JOEY = 614, - EVENT_GOT_HP_UP_FROM_RANDY = 82, - EVENT_GOT_HP_UP_FROM_VERMILION_GUY = 226, - EVENT_GOT_IRON_FROM_PARRY = 616, - EVENT_GOT_ITEMFINDER = 90, - EVENT_GOT_KENYA = 80, - EVENT_GOT_KINGS_ROCK_IN_SLOWPOKE_WELL = 115, - EVENT_GOT_LEAF_STONE_FROM_BILLS_GRANDPA = 801, - EVENT_GOT_LOST_ITEM_FROM_FAN_CLUB = 210, - EVENT_GOT_MAGNET_FROM_SUNNY = 105, - EVENT_GOT_MASTER_BALL_FROM_ELM = 124, - EVENT_GOT_METAL_COAT_FROM_GRANDPA_ON_SS_AQUA = 113, - EVENT_GOT_MIRACLE_SEED_IN_ROUTE_32 = 93, - EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON = 30, - EVENT_GOT_MYSTIC_WATER_IN_CHERRYGROVE = 77, - EVENT_GOT_NUGGET_FROM_GUY = 200, - EVENT_GOT_OLD_ROD = 23, - EVENT_GOT_PASS_FROM_COPYCAT = 209, - EVENT_GOT_PINK_BOW_FROM_MARY = 72, - EVENT_GOT_PINK_BOW_FROM_TUSCANY = 101, - EVENT_GOT_POISON_BARB_FROM_FRIEDA = 99, - EVENT_GOT_PROTEIN_FROM_HUEY = 613, - EVENT_GOT_QUICK_CLAW = 87, - EVENT_GOT_RAINBOW_WING = 120, - EVENT_GOT_SECRETPOTION_FROM_PHARMACY = 35, - EVENT_GOT_SHARP_BEAK_FROM_MONICA = 111, - EVENT_GOT_SHUCKIE = 69, - EVENT_GOT_SILVER_WING = 121, - EVENT_GOT_SOFT_SAND_FROM_KATE = 112, - EVENT_GOT_SPELL_TAG_FROM_SANTOS = 109, - EVENT_GOT_SQUIRTBOTTLE = 92, - EVENT_GOT_SS_TICKET_FROM_ELM = 36, - EVENT_GOT_SUNNY_DAY_FROM_RADIO_TOWER = 71, - EVENT_GOT_SUPER_ROD = 25, - EVENT_GOT_THUNDERSTONE_FROM_BILLS_GRANDPA = 804, - EVENT_GOT_TM01_DYNAMICPUNCH = 10, - EVENT_GOT_TM02_HEADBUTT = 95, - EVENT_GOT_TM03_CURSE = 218, - EVENT_GOT_TM05_ROAR = 78, - EVENT_GOT_TM06_TOXIC = 221, - EVENT_GOT_TM07_ZAP_CANNON = 223, - EVENT_GOT_TM08_ROCK_SMASH = 75, - EVENT_GOT_TM10_HIDDEN_POWER = 88, - EVENT_GOT_TM12_SWEET_SCENT = 122, - EVENT_GOT_TM13_SNORE_FROM_MOOMOO_FARM = 62, - EVENT_GOT_TM16_ICY_WIND = 14, - EVENT_GOT_TM19_GIGA_DRAIN = 220, - EVENT_GOT_TM23_IRON_TAIL = 13, - EVENT_GOT_TM24_DRAGONBREATH = 15, - EVENT_GOT_TM29_PSYCHIC = 227, - EVENT_GOT_TM30_SHADOW_BALL = 12, - EVENT_GOT_TM31_MUD_SLAP = 8, - EVENT_GOT_TM36_SLUDGE_BOMB = 89, - EVENT_GOT_TM37_SANDSTORM = 117, - EVENT_GOT_TM42_DREAM_EATER = 224, - EVENT_GOT_TM45_ATTRACT = 11, - EVENT_GOT_TM47_STEEL_WING = 116, - EVENT_GOT_TM49_FURY_CUTTER = 9, - EVENT_GOT_TM50_NIGHTMARE = 83, - EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 45, - EVENT_GOT_TOTODILE_FROM_ELM = 28, - EVENT_GOT_TYROGUE_FROM_KIYO = 97, - EVENT_GOT_UP_GRADE = 222, - EVENT_GOT_WATER_STONE_FROM_BILLS_GRANDPA = 802, - EVENT_GUIDE_GENT_IN_HIS_HOUSE = 1790, - EVENT_GUIDE_GENT_VISIBLE_IN_CHERRYGROVE = 1791, - EVENT_HEALED_MOOMOO = 61, - EVENT_HERDED_FARFETCHD = 41, - EVENT_HUEY_ASKED_FOR_PHONE_NUMBER = 611, - EVENT_HUEY_READY_FOR_REMATCH = 612, - EVENT_ICE_PATH_1F_PP_UP = 1673, - EVENT_ICE_PATH_B1F_HIDDEN_MAX_POTION = 148, - EVENT_ICE_PATH_B1F_IRON = 1674, - EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_HIDDEN_ICE_HEAL = 150, - EVENT_ICE_PATH_B2F_BLACKTHORN_SIDE_TM_REST = 1677, - EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_FULL_HEAL = 1675, - EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_HIDDEN_CARBOS = 149, - EVENT_ICE_PATH_B2F_MAHOGANY_SIDE_MAX_POTION = 1676, - EVENT_ICE_PATH_B3F_NEVERMELTICE = 1678, - EVENT_ILEX_FOREST_APPRENTICE = 1779, - EVENT_ILEX_FOREST_CHARCOAL_MASTER = 1780, - EVENT_ILEX_FOREST_FARFETCHD_1 = 1769, - EVENT_ILEX_FOREST_FARFETCHD_10 = 1778, - EVENT_ILEX_FOREST_FARFETCHD_2 = 1770, - EVENT_ILEX_FOREST_FARFETCHD_3 = 1771, - EVENT_ILEX_FOREST_FARFETCHD_4 = 1772, - EVENT_ILEX_FOREST_FARFETCHD_5 = 1773, - EVENT_ILEX_FOREST_FARFETCHD_6 = 1774, - EVENT_ILEX_FOREST_FARFETCHD_7 = 1775, - EVENT_ILEX_FOREST_FARFETCHD_8 = 1776, - EVENT_ILEX_FOREST_FARFETCHD_9 = 1777, - EVENT_ILEX_FOREST_HIDDEN_ETHER = 136, - EVENT_ILEX_FOREST_HIDDEN_FULL_HEAL = 138, - EVENT_ILEX_FOREST_HIDDEN_SUPER_POTION = 137, - EVENT_ILEX_FOREST_REVIVE = 1649, - EVENT_INDIGO_PLATEAU_POKECENTER_RIVAL = 1915, - EVENT_INITIALIZED_EVENTS = 54, - EVENT_IRWIN_ASKED_FOR_PHONE_NUMBER = 641, - EVENT_IRWIN_READY_FOR_REMATCH = 642, - EVENT_JACK_ASKED_FOR_PHONE_NUMBER = 607, - EVENT_JACK_READY_FOR_REMATCH = 608, - EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS = 55, - EVENT_JASMINE_RETURNED_TO_GYM = 32, - EVENT_JOEY_ASKED_FOR_PHONE_NUMBER = 627, - EVENT_JOEY_READY_FOR_REMATCH = 628, - EVENT_JOSE_ASKED_FOR_PHONE_NUMBER = 623, - EVENT_JOSE_READY_FOR_REMATCH = 624, - EVENT_KARENS_ROOM_ENTRANCE_CLOSED = 783, - EVENT_KARENS_ROOM_EXIT_OPEN = 784, - EVENT_KENJI_ASKED_FOR_PHONE_NUMBER = 665, - EVENT_KENJI_ON_BREAK = 618, - EVENT_KENJI_READY_FOR_REMATCH = 666, - EVENT_KOGAS_ROOM_ENTRANCE_CLOSED = 779, - EVENT_KOGAS_ROOM_EXIT_OPEN = 780, - EVENT_KURTS_HOUSE_KURT_1 = 1854, - EVENT_KURTS_HOUSE_KURT_2 = 1855, - EVENT_KURTS_HOUSE_SLOWPOKE = 1789, - EVENT_KURT_GAVE_YOU_LURE_BALL = 53, - EVENT_LAKE_OF_RAGE_ASKED_FOR_MAGIKARP = 57, - EVENT_LAKE_OF_RAGE_CIVILIANS = 1845, - EVENT_LAKE_OF_RAGE_ETHER_ON_STANDBY = 58, - EVENT_LAKE_OF_RAGE_EXPLAINED_WEIRD_MAGIKARP = 56, - EVENT_LAKE_OF_RAGE_HIDDEN_FULL_RESTORE = 181, - EVENT_LAKE_OF_RAGE_HIDDEN_MAX_POTION = 183, - EVENT_LAKE_OF_RAGE_HIDDEN_RARE_CANDY = 182, - EVENT_LAKE_OF_RAGE_LANCE = 1748, - EVENT_LAKE_OF_RAGE_MAX_ETHER = 1605, - EVENT_LAKE_OF_RAGE_RED_GYARADOS = 1873, - EVENT_LAKE_OF_RAGE_TM_DETECT = 1606, - EVENT_LAKE_OF_RAGE_WESLEY_OF_WEDNESDAY = 1884, - EVENT_LANCES_ROOM_ENTRANCE_CLOSED = 785, - EVENT_LANCES_ROOM_EXIT_OPEN = 786, - EVENT_LANCES_ROOM_OAK_AND_MARY = 1887, - EVENT_LANCE_HEALED_YOU_IN_TEAM_ROCKET_BASE = 76, - EVENT_LEARNED_HAIL_GIOVANNI = 767, - EVENT_LEARNED_RATICATE_TAIL = 770, - EVENT_LEARNED_SLOWPOKETAIL = 769, - EVENT_LEARNED_TO_CATCH_POKEMON = 66, - EVENT_LEFT_MONS_WITH_CONTEST_OFFICER = 776, - EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT = 212, - EVENT_LISTENED_TO_FAN_CLUB_PRESIDENT_BUT_BAG_WAS_FULL = 211, - EVENT_LISTENED_TO_INITIAL_RADIO = 805, - EVENT_LIZ_ASKED_FOR_PHONE_NUMBER = 633, - EVENT_LIZ_READY_FOR_REMATCH = 634, - EVENT_MADE_UNOWN_APPEAR_IN_RUINS = 46, - EVENT_MADE_WHITNEY_CRY = 40, - EVENT_MAHOGANY_MART_LANCE_AND_DRAGONITE = 1749, - EVENT_MAHOGANY_MART_OWNERS = 1846, - EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_EAST = 1878, - EVENT_MAHOGANY_TOWN_POKEFAN_M_BLOCKS_GYM = 1879, - EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM = 70, - EVENT_MET_ARTHUR_OF_THURSDAY = 102, - EVENT_MET_BILL = 1810, - EVENT_MET_BILLS_GRANDPA = 794, - EVENT_MET_COPYCAT_FOUND_OUT_ABOUT_LOST_ITEM = 207, - EVENT_MET_FRIEDA_OF_FRIDAY = 98, - EVENT_MET_MANAGER_AT_POWER_PLANT = 202, - EVENT_MET_MONICA_OF_MONDAY = 110, - EVENT_MET_REDS_MOM = 204, - EVENT_MET_ROCKET_GRUNT_AT_CERULEAN_GYM = 203, - EVENT_MET_SANTOS_OF_SATURDAY = 108, - EVENT_MET_SUNNY_OF_SUNDAY = 104, - EVENT_MET_TUSCANY_OF_TUESDAY = 100, - EVENT_MET_WESLEY_OF_WEDNESDAY = 106, - EVENT_MOUNT_MOON_SQUARE_HIDDEN_MOON_STONE = 236, - EVENT_MOUNT_MORTAR_1F_INSIDE_ESCAPE_ROPE = 1661, - EVENT_MOUNT_MORTAR_1F_INSIDE_HIDDEN_MAX_REPEL = 145, - EVENT_MOUNT_MORTAR_1F_INSIDE_HYPER_POTION = 1663, - EVENT_MOUNT_MORTAR_1F_INSIDE_MAX_REVIVE = 1662, - EVENT_MOUNT_MORTAR_1F_OUTSIDE_ETHER = 1659, - EVENT_MOUNT_MORTAR_1F_OUTSIDE_HIDDEN_HYPER_POTION = 144, - EVENT_MOUNT_MORTAR_1F_OUTSIDE_REVIVE = 1660, - EVENT_MOUNT_MORTAR_2F_INSIDE_DRAGON_SCALE = 1667, - EVENT_MOUNT_MORTAR_2F_INSIDE_ELIXER = 1668, - EVENT_MOUNT_MORTAR_2F_INSIDE_ESCAPE_ROPE = 1669, - EVENT_MOUNT_MORTAR_2F_INSIDE_HIDDEN_FULL_RESTORE = 146, - EVENT_MOUNT_MORTAR_2F_INSIDE_MAX_POTION = 1664, - EVENT_MOUNT_MORTAR_2F_INSIDE_RARE_CANDY = 1665, - EVENT_MOUNT_MORTAR_2F_INSIDE_TM_DEFENSE_CURL = 1666, - EVENT_MOUNT_MORTAR_B1F_FULL_HEAL = 1671, - EVENT_MOUNT_MORTAR_B1F_HIDDEN_MAX_REVIVE = 147, - EVENT_MOUNT_MORTAR_B1F_HYPER_POTION = 1670, - EVENT_MR_POKEMONS_HOUSE_OAK = 1737, - EVENT_MT_MOON_RIVAL = 1914, - EVENT_MT_MOON_SQUARE_CLEFAIRY = 1913, - EVENT_MT_MOON_SQUARE_ROCK = 1912, - EVENT_MYSTERY_GIFT_DELIVERY_GUY = 1809, - EVENT_NATIONAL_PARK_HIDDEN_FULL_HEAL = 132, - EVENT_NATIONAL_PARK_PARLYZ_HEAL = 1624, - EVENT_NATIONAL_PARK_TM_DIG = 1625, - EVENT_OLIVINE_GYM_JASMINE = 1747, - EVENT_OLIVINE_LIGHTHOUSE_3F_ETHER = 1636, - EVENT_OLIVINE_LIGHTHOUSE_5F_GREAT_BALL = 1638, - EVENT_OLIVINE_LIGHTHOUSE_5F_HIDDEN_HYPER_POTION = 133, - EVENT_OLIVINE_LIGHTHOUSE_5F_RARE_CANDY = 1637, - EVENT_OLIVINE_LIGHTHOUSE_5F_TM_SWAGGER = 1639, - EVENT_OLIVINE_LIGHTHOUSE_6F_SUPER_POTION = 1640, - EVENT_OLIVINE_LIGHTHOUSE_JASMINE = 1746, - EVENT_OLIVINE_PORT_HIDDEN_PROTEIN = 234, - EVENT_OLIVINE_PORT_PASSAGE_POKEFAN_M = 1843, - EVENT_OLIVINE_PORT_SAILOR_AT_GANGWAY = 1834, - EVENT_OLIVINE_PORT_SPRITES_AFTER_HALL_OF_FAME = 1848, - EVENT_OLIVINE_PORT_SPRITES_BEFORE_HALL_OF_FAME = 1847, - EVENT_OPENED_DOOR_TO_GIOVANNIS_OFFICE = 771, - EVENT_OPENED_DOOR_TO_ROCKET_HIDEOUT_TRANSMITTER = 768, - EVENT_OPENED_MT_SILVER = 1871, - EVENT_PARRY_ASKED_FOR_PHONE_NUMBER = 667, - EVENT_PARRY_READY_FOR_REMATCH = 668, - EVENT_PICKED_UP_FOCUS_BAND = 1917, - EVENT_PLAYERS_HOUSE_2F_BIG_DOLL = 1860, - EVENT_PLAYERS_HOUSE_2F_CONSOLE = 1857, - EVENT_PLAYERS_HOUSE_2F_DOLL_1 = 1858, - EVENT_PLAYERS_HOUSE_2F_DOLL_2 = 1859, - EVENT_PLAYERS_HOUSE_MOM_1 = 1735, - EVENT_PLAYERS_HOUSE_MOM_2 = 1736, - EVENT_PLAYERS_ROOM_POSTER = 716, - EVENT_RADIO_TOWER_BLACKBELT_BLOCKS_STAIRS = 1745, - EVENT_RADIO_TOWER_CIVILIANS_AFTER = 1744, - EVENT_RADIO_TOWER_ROCKET_TAKEOVER = 1742, - EVENT_RALPH_ASKED_FOR_PHONE_NUMBER = 631, - EVENT_RALPH_READY_FOR_REMATCH = 632, - EVENT_RECEIVED_CARD_KEY = 74, - EVENT_RED_IN_MT_SILVER = 1890, - EVENT_REENA_ASKED_FOR_PHONE_NUMBER = 625, - EVENT_REENA_READY_FOR_REMATCH = 626, - EVENT_REFUSED_TO_HELP_LANCE_AT_LAKE_OF_RAGE = 38, - EVENT_REFUSED_TO_TAKE_EGG_FROM_ELMS_AIDE = 44, - EVENT_RELEASED_THE_BEASTS = 123, - EVENT_RESTORED_POWER_TO_KANTO = 205, - EVENT_RETURNED_LOST_ITEM_TO_COPYCAT = 208, - EVENT_RETURNED_MACHINE_PART = 201, - EVENT_RIVAL_AZALEA_TOWN = 1727, - EVENT_RIVAL_BURNED_TOWER = 1733, - EVENT_RIVAL_CHERRYGROVE_CITY = 1726, - EVENT_RIVAL_DRAGONS_DEN = 1734, - EVENT_RIVAL_GOLDENROD_UNDERGROUND = 1729, - EVENT_RIVAL_NEW_BARK_TOWN = 1725, - EVENT_RIVAL_OLIVINE_CITY = 1731, - EVENT_RIVAL_SPROUT_TOWER = 1732, - EVENT_RIVAL_TEAM_ROCKET_BASE = 1728, - EVENT_RIVAL_VICTORY_ROAD = 1730, - EVENT_ROCK_TUNNEL_1F_ELIXER = 1918, - EVENT_ROCK_TUNNEL_1F_HIDDEN_X_ACCURACY = 231, - EVENT_ROCK_TUNNEL_1F_HIDDEN_X_DEFEND = 232, - EVENT_ROCK_TUNNEL_1F_TM_STEEL_WING = 1919, - EVENT_ROCK_TUNNEL_B1F_HIDDEN_MAX_POTION = 233, - EVENT_ROCK_TUNNEL_B1F_IRON = 1920, - EVENT_ROCK_TUNNEL_B1F_PP_UP = 1921, - EVENT_ROCK_TUNNEL_B1F_REVIVE = 1922, - EVENT_ROUTE_11_HIDDEN_REVIVE = 245, - EVENT_ROUTE_12_CALCIUM = 1928, - EVENT_ROUTE_12_HIDDEN_ELIXER = 243, - EVENT_ROUTE_12_NUGGET = 1929, - EVENT_ROUTE_13_HIDDEN_CALCIUM = 244, - EVENT_ROUTE_15_PP_UP = 1930, - EVENT_ROUTE_17_HIDDEN_MAX_ELIXER = 247, - EVENT_ROUTE_17_HIDDEN_MAX_ETHER = 246, - EVENT_ROUTE_24_ROCKET = 1900, - EVENT_ROUTE_25_HIDDEN_POTION = 248, - EVENT_ROUTE_25_MISTY_BOYFRIEND = 1902, - EVENT_ROUTE_25_PROTEIN = 1931, - EVENT_ROUTE_26_MAX_ELIXER = 1706, - EVENT_ROUTE_27_RARE_CANDY = 1708, - EVENT_ROUTE_27_TM_SOLARBEAM = 1707, - EVENT_ROUTE_28_HIDDEN_RARE_CANDY = 163, - EVENT_ROUTE_29_POTION = 1709, - EVENT_ROUTE_29_TUSCANY_OF_TUESDAY = 1881, - EVENT_ROUTE_2_CARBOS = 1925, - EVENT_ROUTE_2_DIRE_HIT = 1923, - EVENT_ROUTE_2_ELIXER = 1926, - EVENT_ROUTE_2_HIDDEN_FULL_HEAL = 238, - EVENT_ROUTE_2_HIDDEN_FULL_RESTORE = 239, - EVENT_ROUTE_2_HIDDEN_MAX_ETHER = 237, - EVENT_ROUTE_2_HIDDEN_REVIVE = 240, - EVENT_ROUTE_2_MAX_POTION = 1924, - EVENT_ROUTE_30_BATTLE = 1812, - EVENT_ROUTE_30_HIDDEN_POTION = 164, - EVENT_ROUTE_30_YOUNGSTER_JOEY = 1813, - EVENT_ROUTE_31_ANTIDOTE = 1710, - EVENT_ROUTE_31_POKE_BALL = 1711, - EVENT_ROUTE_32_FRIEDA_OF_FRIDAY = 1880, - EVENT_ROUTE_32_GREAT_BALL = 1712, - EVENT_ROUTE_32_HIDDEN_GREAT_BALL = 165, - EVENT_ROUTE_32_HIDDEN_SUPER_POTION = 166, - EVENT_ROUTE_32_POTION = 1713, - EVENT_ROUTE_34_HIDDEN_RARE_CANDY = 167, - EVENT_ROUTE_34_HIDDEN_SUPER_POTION = 168, - EVENT_ROUTE_35_NATIONAL_PARK_GATE_OFFICER_CONTEST_DAY = 1861, - EVENT_ROUTE_35_NATIONAL_PARK_GATE_OFFICER_NOT_CONTEST_DAY = 1862, - EVENT_ROUTE_35_NATIONAL_PARK_GATE_YOUNGSTER = 1844, - EVENT_ROUTE_35_TM_ROLLOUT = 1714, - EVENT_ROUTE_36_ARTHUR_OF_THURSDAY = 1882, - EVENT_ROUTE_36_NATIONAL_PARK_GATE_OFFICER_CONTEST_DAY = 1863, - EVENT_ROUTE_36_NATIONAL_PARK_GATE_OFFICER_NOT_CONTEST_DAY = 1864, - EVENT_ROUTE_36_SUDOWOODO = 1784, - EVENT_ROUTE_37_HIDDEN_ETHER = 169, - EVENT_ROUTE_37_SUNNY_OF_SUNDAY = 1883, - EVENT_ROUTE_39_HIDDEN_NUGGET = 170, - EVENT_ROUTE_40_HIDDEN_HYPER_POTION = 171, - EVENT_ROUTE_40_MONICA_OF_MONDAY = 1886, - EVENT_ROUTE_41_HIDDEN_MAX_ETHER = 172, - EVENT_ROUTE_42_HIDDEN_MAX_POTION = 173, - EVENT_ROUTE_42_SUPER_POTION = 1716, - EVENT_ROUTE_42_ULTRA_BALL = 1715, - EVENT_ROUTE_43_GATE_ROCKETS = 1756, - EVENT_ROUTE_43_MAX_ETHER = 1717, - EVENT_ROUTE_44_HIDDEN_ELIXER = 174, - EVENT_ROUTE_44_MAX_REVIVE = 1718, - EVENT_ROUTE_44_ULTRA_BALL = 1719, - EVENT_ROUTE_45_ELIXER = 1722, - EVENT_ROUTE_45_HIDDEN_PP_UP = 175, - EVENT_ROUTE_45_MAX_POTION = 1723, - EVENT_ROUTE_45_REVIVE = 1721, - EVENT_ROUTE_45_X_SPECIAL = 1720, - EVENT_ROUTE_46_DIRE_HIT = 1724, - EVENT_ROUTE_4_HIDDEN_ULTRA_BALL = 241, - EVENT_ROUTE_4_HP_UP = 1927, - EVENT_ROUTE_5_6_POKEFAN_M_BLOCKS_UNDERGROUND_PATH = 1905, - EVENT_ROUTE_9_HIDDEN_ETHER = 242, - EVENT_RUINS_OF_ALPH_INNER_CHAMBER_TOURISTS = 1797, - EVENT_RUINS_OF_ALPH_KABUTO_CHAMBER_RECEPTIONIST = 1870, - EVENT_RUINS_OF_ALPH_OUTSIDE_SCIENTIST = 1795, - EVENT_RUINS_OF_ALPH_RESEARCH_CENTER_SCIENTIST = 1796, - EVENT_SAFFRON_TRAIN_STATION_POPULATION = 1906, - EVENT_SEAFOAM_GYM_GYM_GUIDE = 1911, - EVENT_SECURITY_CAMERA_1 = 740, - EVENT_SECURITY_CAMERA_2 = 741, - EVENT_SECURITY_CAMERA_3 = 742, - EVENT_SECURITY_CAMERA_4 = 743, - EVENT_SECURITY_CAMERA_5 = 744, - EVENT_SHOWED_GROWLITHE_VULPIX_TO_BILLS_GRANDPA = 798, - EVENT_SHOWED_LICKITUNG_TO_BILLS_GRANDPA = 795, - EVENT_SHOWED_ODDISH_TO_BILLS_GRANDPA = 796, - EVENT_SHOWED_PICHU_TO_BILLS_GRANDPA = 799, - EVENT_SHOWED_STARYU_TO_BILLS_GRANDPA = 797, - EVENT_SHOWED_TOGEPI_TO_ELM = 85, - EVENT_SILVER_CAVE_ITEM_ROOMS_FULL_RESTORE = 1693, - EVENT_SILVER_CAVE_ITEM_ROOMS_MAX_REVIVE = 1692, - EVENT_SILVER_CAVE_OUTSIDE_HIDDEN_FULL_RESTORE = 184, - EVENT_SILVER_CAVE_ROOM_1_ESCAPE_ROPE = 1691, - EVENT_SILVER_CAVE_ROOM_1_HIDDEN_DIRE_HIT = 154, - EVENT_SILVER_CAVE_ROOM_1_HIDDEN_ULTRA_BALL = 155, - EVENT_SILVER_CAVE_ROOM_1_MAX_ELIXER = 1689, - EVENT_SILVER_CAVE_ROOM_1_X_ACCURACY = 1690, - EVENT_SILVER_CAVE_ROOM_2_HIDDEN_MAX_POTION = 156, - EVENT_SLOWPOKE_WELL_B1F_SUPER_POTION = 1634, - EVENT_SLOWPOKE_WELL_B2F_TM_RAIN_DANCE = 1635, - EVENT_SLOWPOKE_WELL_KURT = 1856, - EVENT_SLOWPOKE_WELL_ROCKETS = 1788, - EVENT_SLOWPOKE_WELL_SLOWPOKES = 1787, - EVENT_SOLVED_AERODACTYL_PUZZLE = 675, - EVENT_SOLVED_HO_OH_PUZZLE = 672, - EVENT_SOLVED_KABUTO_PUZZLE = 673, - EVENT_SOLVED_OMANYTE_PUZZLE = 674, - EVENT_SPROUT_TOWER_1F_PARLYZ_HEAL = 1607, - EVENT_SPROUT_TOWER_2F_X_DEFEND = 1608, - EVENT_SPROUT_TOWER_3F_ESCAPE_ROPE = 1610, - EVENT_SPROUT_TOWER_3F_POTION = 1609, - EVENT_SWITCH_1 = 723, - EVENT_SWITCH_2 = 724, - EVENT_SWITCH_3 = 725, - EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO = 63, - EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64, - EVENT_TALKED_TO_OAK_IN_KANTO = 225, - EVENT_TALKED_TO_SEAFOAM_GYM_GUIDE_ONCE = 213, - EVENT_TALKED_TO_WARDENS_GRANDDAUGHTER = 217, - EVENT_TEAM_ROCKET_BASE_B1F_HIDDEN_REVIVE = 134, - EVENT_TEAM_ROCKET_BASE_B1F_HYPER_POTION = 1641, - EVENT_TEAM_ROCKET_BASE_B1F_NUGGET = 1642, - EVENT_TEAM_ROCKET_BASE_B1F_X_ACCURACY = 1643, - EVENT_TEAM_ROCKET_BASE_B2F_DRAGONITE = 1759, - EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_1 = 1760, - EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_2 = 1761, - EVENT_TEAM_ROCKET_BASE_B2F_ELECTRODE_3 = 1762, - EVENT_TEAM_ROCKET_BASE_B2F_EXECUTIVE = 1757, - EVENT_TEAM_ROCKET_BASE_B2F_GRUNT_WITH_EXECUTIVE = 1758, - EVENT_TEAM_ROCKET_BASE_B2F_HIDDEN_FULL_HEAL = 135, - EVENT_TEAM_ROCKET_BASE_B2F_LANCE = 1750, - EVENT_TEAM_ROCKET_BASE_B2F_TM_THIEF = 1644, - EVENT_TEAM_ROCKET_BASE_B3F_DIRE_HIT = 1646, - EVENT_TEAM_ROCKET_BASE_B3F_EXECUTIVE = 1755, - EVENT_TEAM_ROCKET_BASE_B3F_FULL_HEAL = 1645, - EVENT_TEAM_ROCKET_BASE_B3F_ICE_HEAL = 1648, - EVENT_TEAM_ROCKET_BASE_B3F_LANCE_PASSWORDS = 1751, - EVENT_TEAM_ROCKET_BASE_B3F_PROTEIN = 1647, - EVENT_TEAM_ROCKET_BASE_POPULATION = 1754, - EVENT_TEAM_ROCKET_BASE_SECURITY_GRUNTS = 1753, - EVENT_TEAM_ROCKET_DISBANDED = 1889, - EVENT_TELEPORT_GUY = 1916, - EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1 = 0, - EVENT_TEMPORARY_UNTIL_MAP_RELOAD_2 = 1, - EVENT_TEMPORARY_UNTIL_MAP_RELOAD_3 = 2, - EVENT_TEMPORARY_UNTIL_MAP_RELOAD_4 = 3, - EVENT_TEMPORARY_UNTIL_MAP_RELOAD_5 = 4, - EVENT_TEMPORARY_UNTIL_MAP_RELOAD_6 = 5, - EVENT_TEMPORARY_UNTIL_MAP_RELOAD_7 = 6, - EVENT_TEMPORARY_UNTIL_MAP_RELOAD_8 = 7, - EVENT_TIFFANY_ASKED_FOR_PHONE_NUMBER = 659, - EVENT_TIFFANY_READY_FOR_REMATCH = 660, - EVENT_TIN_TOWER_3F_FULL_HEAL = 1611, - EVENT_TIN_TOWER_4F_ESCAPE_ROPE = 1614, - EVENT_TIN_TOWER_4F_HIDDEN_MAX_POTION = 125, - EVENT_TIN_TOWER_4F_SUPER_POTION = 1613, - EVENT_TIN_TOWER_4F_ULTRA_BALL = 1612, - EVENT_TIN_TOWER_5F_HIDDEN_CARBOS = 127, - EVENT_TIN_TOWER_5F_HIDDEN_FULL_RESTORE = 126, - EVENT_TIN_TOWER_5F_RARE_CANDY = 1615, - EVENT_TIN_TOWER_7F_MAX_REVIVE = 1616, - EVENT_TIN_TOWER_8F_FULL_RESTORE = 1619, - EVENT_TIN_TOWER_8F_MAX_ELIXER = 1618, - EVENT_TIN_TOWER_8F_NUGGET = 1617, - EVENT_TIN_TOWER_ROOF_HO_OH = 1852, - EVENT_TODD_ASKED_FOR_PHONE_NUMBER = 637, - EVENT_TODD_READY_FOR_REMATCH = 638, - EVENT_TOGEPI_HATCHED = 84, - EVENT_TOHJO_FALLS_MOON_STONE = 1705, - EVENT_TOLD_ELM_ABOUT_TOGEPI_OVER_THE_PHONE = 119, - EVENT_TOTODILE_POKEBALL_IN_ELMS_LAB = 1601, - EVENT_TRAINERS_IN_CERULEAN_GYM = 1903, - EVENT_TURNED_OFF_SECURITY_CAMERAS = 739, - EVENT_UNCOVERED_STAIRCASE_IN_MAHOGANY_MART = 738, - EVENT_UNDERGROUND_PATH_HIDDEN_FULL_RESTORE = 229, - EVENT_UNDERGROUND_PATH_HIDDEN_X_SPECIAL = 230, - EVENT_UNION_CAVE_1F_AWAKENING = 1629, - EVENT_UNION_CAVE_1F_GREAT_BALL = 1626, - EVENT_UNION_CAVE_1F_POTION = 1627, - EVENT_UNION_CAVE_1F_X_ATTACK = 1628, - EVENT_UNION_CAVE_B1F_TM_SWIFT = 1630, - EVENT_UNION_CAVE_B1F_X_DEFEND = 1631, - EVENT_UNION_CAVE_B2F_ELIXER = 1632, - EVENT_UNION_CAVE_B2F_HYPER_POTION = 1633, - EVENT_UNION_CAVE_B2F_LAPRAS = 1888, - EVENT_USED_BASEMENT_KEY = 73, - EVENT_USED_THE_CARD_KEY_IN_THE_RADIO_TOWER = 37, - EVENT_VANCE_ASKED_FOR_PHONE_NUMBER = 661, - EVENT_VANCE_READY_FOR_REMATCH = 662, - EVENT_VERMILION_CITY_HIDDEN_FULL_HEAL = 252, - EVENT_VERMILION_CITY_SNORLAX = 1904, - EVENT_VERMILION_FAN_CLUB_DOLL = 1908, - EVENT_VERMILION_PORT_HIDDEN_IRON = 235, - EVENT_VERMILION_PORT_SAILOR_AT_GANGWAY = 1835, - EVENT_VICTORY_ROAD_FULL_HEAL = 1702, - EVENT_VICTORY_ROAD_FULL_RESTORE = 1701, - EVENT_VICTORY_ROAD_HIDDEN_FULL_HEAL = 159, - EVENT_VICTORY_ROAD_HIDDEN_MAX_POTION = 158, - EVENT_VICTORY_ROAD_MAX_REVIVE = 1700, - EVENT_VICTORY_ROAD_TM_EARTHQUAKE = 1699, - EVENT_VICTORY_ROAD_X_SPECIAL = 1703, - EVENT_VIOLET_CITY_EARL = 1738, - EVENT_VIOLET_CITY_HIDDEN_HYPER_POTION = 176, - EVENT_VIOLET_CITY_PP_UP = 1603, - EVENT_VIOLET_CITY_RARE_CANDY = 1604, - EVENT_VIRIDIAN_GYM_BLUE = 1910, - EVENT_WADE_ASKED_FOR_PHONE_NUMBER = 629, - EVENT_WADE_READY_FOR_REMATCH = 630, - EVENT_WARPED_FROM_ROUTE_35_NATIONAL_PARK_GATE = 722, - EVENT_WHIRL_ISLAND_B1F_CALCIUM = 1683, - EVENT_WHIRL_ISLAND_B1F_CARBOS = 1682, - EVENT_WHIRL_ISLAND_B1F_ESCAPE_ROPE = 1685, - EVENT_WHIRL_ISLAND_B1F_FULL_RESTORE = 1681, - EVENT_WHIRL_ISLAND_B1F_HIDDEN_FULL_RESTORE = 153, - EVENT_WHIRL_ISLAND_B1F_HIDDEN_RARE_CANDY = 151, - EVENT_WHIRL_ISLAND_B1F_HIDDEN_ULTRA_BALL = 152, - EVENT_WHIRL_ISLAND_B1F_NUGGET = 1684, - EVENT_WHIRL_ISLAND_B2F_FULL_RESTORE = 1686, - EVENT_WHIRL_ISLAND_B2F_MAX_ELIXER = 1688, - EVENT_WHIRL_ISLAND_B2F_MAX_REVIVE = 1687, - EVENT_WHIRL_ISLAND_LUGIA_CHAMBER_LUGIA = 1853, - EVENT_WHIRL_ISLAND_NE_ULTRA_BALL = 1679, - EVENT_WHIRL_ISLAND_SW_GUARD_SPEC = 1680, - EVENT_WILLS_ROOM_ENTRANCE_CLOSED = 777, - EVENT_WILLS_ROOM_EXIT_OPEN = 778, - EVENT_WILTON_ASKED_FOR_PHONE_NUMBER = 663, - EVENT_WILTON_READY_FOR_REMATCH = 664, - }, - engine = { - ENGINE_ALL_FRUIT_TREES = 83, - ENGINE_ALWAYS_ON_BIKE = 24, - ENGINE_BIKE_SHOP_CALL_ENABLED = 19, - ENGINE_BOULDERBADGE = 34, - ENGINE_BUG_CONTEST_TIMER = 16, - ENGINE_CASCADEBADGE = 35, - ENGINE_CAUGHT_POKERUS = 13, - ENGINE_CREDITS_SKIP = 15, - ENGINE_DAILY_BUG_CONTEST = 80, - ENGINE_DAISYS_GROOMING = 91, - ENGINE_DAY_CARE_LADY_HAS_MON = 7, - ENGINE_DAY_CARE_MAN_HAS_EGG = 5, - ENGINE_DAY_CARE_MAN_HAS_MON = 6, - ENGINE_DOWNHILL = 25, - ENGINE_EARTHBADGE = 41, - ENGINE_EXPN_CARD = 3, - ENGINE_FLYPOINT_AZALEA = 67, - ENGINE_FLYPOINT_BLACKTHORN = 74, - ENGINE_FLYPOINT_CELADON = 60, - ENGINE_FLYPOINT_CERULEAN = 55, - ENGINE_FLYPOINT_CHERRYGROVE = 65, - ENGINE_FLYPOINT_CIANWOOD = 68, - ENGINE_FLYPOINT_CINNABAR = 62, - ENGINE_FLYPOINT_DEBUG = 51, - ENGINE_FLYPOINT_ECRUTEAK = 71, - ENGINE_FLYPOINT_FUCHSIA = 61, - ENGINE_FLYPOINT_GOLDENROD = 69, - ENGINE_FLYPOINT_INDIGO_PLATEAU = 63, - ENGINE_FLYPOINT_LAKE_OF_RAGE = 73, - ENGINE_FLYPOINT_LAVENDER = 58, - ENGINE_FLYPOINT_MAHOGANY = 72, - ENGINE_FLYPOINT_NEW_BARK = 64, - ENGINE_FLYPOINT_OLIVINE = 70, - ENGINE_FLYPOINT_PALLET = 52, - ENGINE_FLYPOINT_PEWTER = 54, - ENGINE_FLYPOINT_PLAYERS_HOUSE = 50, - ENGINE_FLYPOINT_ROCK_TUNNEL = 56, - ENGINE_FLYPOINT_SAFFRON = 59, - ENGINE_FLYPOINT_SILVER_CAVE = 75, - ENGINE_FLYPOINT_UNUSED = 76, - ENGINE_FLYPOINT_VERMILION = 57, - ENGINE_FLYPOINT_VIOLET = 66, - ENGINE_FLYPOINT_VIRIDIAN = 53, - ENGINE_FOGBADGE = 29, - ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY = 86, - ENGINE_GLACIERBADGE = 32, - ENGINE_GOLDENROD_DEPT_STORE_TM27_RETURN = 90, - ENGINE_GOLDENROD_UNDERGROUND_GOT_HAIRCUT = 89, - ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED = 85, - ENGINE_GOT_SHUCKIE_TODAY = 84, - ENGINE_HIVEBADGE = 27, - ENGINE_INDIGO_PLATEAU_RIVAL_FIGHT = 92, - ENGINE_KURT_MAKING_BALLS = 79, - ENGINE_LUCKY_NUMBER_SHOW = 77, - ENGINE_MAP_CARD = 1, - ENGINE_MARSHBADGE = 39, - ENGINE_MINERALBADGE = 30, - ENGINE_MOM_ACTIVE = 9, - ENGINE_MOM_SAVING_MONEY = 8, - ENGINE_MT_MOON_SQUARE_CLEFAIRY = 87, - ENGINE_PHONE_CARD = 2, - ENGINE_PLAINBADGE = 28, - ENGINE_POKEDEX = 11, - ENGINE_POKEGEAR = 4, - ENGINE_RADIO_CARD = 0, - ENGINE_RAINBOWBADGE = 37, - ENGINE_REACHED_GOLDENROD = 21, - ENGINE_RISINGBADGE = 33, - ENGINE_ROCKETS_IN_MAHOGANY = 22, - ENGINE_ROCKETS_IN_RADIO_TOWER = 18, - ENGINE_ROCKET_SIGNAL_ON_CH20 = 14, - ENGINE_SAFARI_ZONE = 17, - ENGINE_SOULBADGE = 38, - ENGINE_STORMBADGE = 31, - ENGINE_STRENGTH_ACTIVE = 23, - ENGINE_SWARM = 81, - ENGINE_THUNDERBADGE = 36, - ENGINE_TIME_CAPSULE = 82, - ENGINE_UNION_CAVE_LAPRAS = 88, - ENGINE_UNLOCKED_UNOWNS_A_TO_K = 42, - ENGINE_UNLOCKED_UNOWNS_L_TO_R = 43, - ENGINE_UNLOCKED_UNOWNS_S_TO_W = 44, - ENGINE_UNLOCKED_UNOWNS_UNUSED_4 = 46, - ENGINE_UNLOCKED_UNOWNS_UNUSED_5 = 47, - ENGINE_UNLOCKED_UNOWNS_UNUSED_6 = 48, - ENGINE_UNLOCKED_UNOWNS_UNUSED_7 = 49, - ENGINE_UNLOCKED_UNOWNS_X_TO_Z = 45, - ENGINE_UNOWN_DEX = 12, - ENGINE_UNUSED_STATUSFLAGS2_3 = 78, - ENGINE_UNUSED_STATUSFLAGS2_5 = 20, - ENGINE_UNUSED_TWO_DAY_TIMER_ON = 10, - ENGINE_VOLCANOBADGE = 40, - ENGINE_ZEPHYRBADGE = 26, - }, -} +-- Re-export the shipped table so drivers keep this path. +return dofile("src/core/gen2/FlagNames.lua") diff --git a/tests/engine/launcher_mod_scope_dropdown.lua b/tests/engine/launcher_mod_scope_dropdown.lua new file mode 100644 index 00000000..d1758c96 --- /dev/null +++ b/tests/engine/launcher_mod_scope_dropdown.lua @@ -0,0 +1,62 @@ +-- Mods "Show for" chips collapse to a dropdown when they cannot all fit. +-- Landscape / desktop keep the individual game chips. No pokered cite: the +-- launcher is port-only chrome. +-- luajit tests/engine/launcher_mod_scope_dropdown.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function navIds() + local ids = {} + for i = 1, (Kit._navPrevN or 0) do + local slot = Kit._nav[i] + if slot and slot.id then ids[slot.id] = true end + end + return ids +end + +local function drawMods(W, H) + window(W, H) + local imp = RomImporter.new(function() end, { launcher = true }) + imp.tab = "mods" + imp.ready = { red = true, blue = true, yellow = true, gold = true } + local ok, err = pcall(LauncherView.draw, imp) + check(ok, ("%dx%d mods draws: %s"):format(W, H, tostring(err))) + return navIds() +end + +local portrait = drawMods(360, 780) +check(portrait["mod-scope-menu"] == true, + "portrait: Show-for collapses to a dropdown when the chips cannot all fit") +check(portrait["mod-scope-gold"] == nil + and portrait["mod-scope-red"] == nil + and portrait["mod-scope-blue"] == nil + and portrait["mod-scope-yellow"] == nil, + "portrait: individual version chips are not drawn beside the dropdown") + +local desktop = drawMods(1280, 720) +check(desktop["mod-scope-menu"] == nil, + "desktop: chips fit, so there is no dropdown") +check(desktop["mod-scope-all"] == true + and desktop["mod-scope-red"] == true + and desktop["mod-scope-blue"] == true + and desktop["mod-scope-yellow"] == true + and desktop["mod-scope-gold"] == true, + "desktop: every Show-for chip stays reachable") + +print("ok launcher mod scope dropdown") diff --git a/tests/engine/launcher_modal_focus_input_test.lua b/tests/engine/launcher_modal_focus_input_test.lua index 3d394e4d..bb198000 100644 --- a/tests/engine/launcher_modal_focus_input_test.lua +++ b/tests/engine/launcher_modal_focus_input_test.lua @@ -23,7 +23,7 @@ local function importer(field) end local modalFields = { - "_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails", + "_modConfirm", "_modVersions", "_modReleaseNotes", "_appPatchNotes", "_findDetails", } for _, field in ipairs(modalFields) do @@ -77,6 +77,12 @@ do imp:keypressed("escape") eq(imp._modReleaseNotes, nil, "Escape closes release notes") end +do + resetFocus() + local imp = importer("_appPatchNotes") + imp:keypressed("escape") + eq(imp._appPatchNotes, nil, "Escape closes patch notes") +end do resetFocus() local imp = importer("_modVersions") diff --git a/tests/engine/launcher_patch_notes.lua b/tests/engine/launcher_patch_notes.lua new file mode 100644 index 00000000..ebdffb78 --- /dev/null +++ b/tests/engine/launcher_patch_notes.lua @@ -0,0 +1,89 @@ +-- Launcher footer Patch notes control. The GitHub release body is already +-- fetched by the updater; this is the in-app viewer for it. +-- luajit tests/engine/launcher_patch_notes.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") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") +local PatchNotes = require("src.update.PatchNotes") + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +local function freshLauncher() + return RomImporter.new(function() end, { launcher = true }) +end + +local realPrint = love.graphics.print +local function drawAndCapture(imp) + local seen = {} + love.graphics.print = function(str, ...) + seen[#seen + 1] = tostring(str) + return realPrint(str, ...) + end + local ok, err = pcall(LauncherView.draw, imp) + love.graphics.print = realPrint + check(ok, "the frame draws: " .. tostring(err)) + return table.concat(seen, "\n") +end + +window(1280, 720) +local imp = freshLauncher() +local text = drawAndCapture(imp) +check(text:find("Patch notes", 1, true) ~= nil, + "desktop footer prints Patch notes") + +window(360, 780) +local phone = freshLauncher() +check(drawAndCapture(phone):find("Patch notes", 1, true) ~= nil, + "portrait footer still prints Patch notes") + +do + local body, ver = PatchNotes.body({ + state = function() + return { notes = "## Issues closed\n\n- #12 cart padding", latest = "1.4.2" } + end, + }) + eq(body, "## Issues closed\n\n- #12 cart padding", + "PatchNotes prefers the updater's GitHub body") + eq(ver, "1.4.2", "PatchNotes carries the release version") +end + +do + local body, ver = PatchNotes.body(nil) + check(type(body) == "string" and body:find("Issues closed", 1, true), + "without a check result PatchNotes uses the stashed iOS app-repo notes") + check(type(ver) == "string" and ver:find("^%d+%.%d+%.%d+$") ~= nil, + "stashed notes name a release version") +end + +do + local f = assert(io.open("mobile/ios/app-repo.json", "rb")) + local list = PatchNotes.parseRepo(f:read("*a")) + f:close() + check(#list >= 2, "app-repo.json stashes more than one release") + local notes, ver = PatchNotes.fromRepo(list[2].version) + eq(ver, list[2].version, "fromRepo can pick a specific stashed version") + eq(notes, list[2].notes, "fromRepo returns that version's notes") +end + +imp._appPatchNotes = true +local modal = drawAndCapture(imp) +check(modal:find("Patch notes", 1, true) ~= nil, "the modal titles itself") +check(modal:find("Close", 1, true) ~= nil, "the modal can be closed") + +imp:keypressed("escape") +eq(imp._appPatchNotes, nil, "Escape dismisses patch notes") + +T.finish("launcher patch notes") diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index 193ab118..1fab2838 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -32,6 +32,15 @@ eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version" eq(rel.payload.url, "http://x/love", "payload asset url picked") eq(rel.payload.size, 12345, "payload asset size picked") eq(rel.sums.url, "http://x/sums", "sums asset url picked") +eq(rel.notes, "", "missing release body becomes empty notes") + +local withNotes = Check.parseRelease(Json.encode({ + tag_name = "v1.4.2", + body = "## Issues closed\n\n- #1 cart padding", + assets = {}, +})) +eq(withNotes.notes, "## Issues closed\n\n- #1 cart padding", + "parseRelease keeps the GitHub release body") -- a newer release that ships no .love yet: parses, but the payload/sums are nil -- so the worker will route to needs_full rather than an in-place update diff --git a/tests/engine/update_tests.lua b/tests/engine/update_tests.lua index 2fb696ab..0621e130 100644 --- a/tests/engine/update_tests.lua +++ b/tests/engine/update_tests.lua @@ -287,6 +287,7 @@ eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url") eq(rel.payload.size, 12345, "parseRelease picks the payload asset size") eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url") eq(rel.sums.size, 99, "parseRelease picks the sums asset size") +eq(rel.notes, "", "parseRelease treats a missing body as empty notes") -- a release with no .love yet still parses; payload/sums are nil so the worker -- routes to a full reinstall rather than an in-place update diff --git a/tests/launcher_mods_install_zip_test.lua b/tests/launcher_mods_install_zip_test.lua index e7b117f9..2ef9dd51 100644 --- a/tests/launcher_mods_install_zip_test.lua +++ b/tests/launcher_mods_install_zip_test.lua @@ -17,6 +17,7 @@ local ARCHIVE = { local files, dirs, arch = {}, {}, {} local fileDataMounts, pathMounts, stagedTemps = 0, 0, {} local stagedEver = false +local failWriteOnce local function resetFs() for k in pairs(files) do files[k] = nil end @@ -25,6 +26,7 @@ local function resetFs() fileDataMounts, pathMounts = 0, 0 stagedTemps = {} stagedEver = false + failWriteOnce = nil end local function dirChild(key, name) @@ -45,6 +47,10 @@ end local vfs = {} function vfs.write(name, data) + if failWriteOnce == name then + failWriteOnce = nil + return nil, "simulated write failure" + end files[name] = data if name:match("^mod_import_") then stagedTemps[name] = true @@ -171,6 +177,16 @@ eq(staged, 0, "FileData path leaves no staged temp zip") check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil, "install wrote manifest into mods/") +-- A third-party archive cannot bypass modkit's no-baseroms packaging gate. +resetFs() +ARCHIVE[MOD_ID .. "/baseroms/source.z64"] = "packaged rom" +files["imports/mods/packaged-rom.zip"] = "PK\3\4packaged-rom" +ok, err = LauncherMods.installZip("imports/mods/packaged-rom.zip") +check(not ok, "an archive containing baseroms is rejected") +check(tostring(err):find("must not include", 1, true), + "baseroms archive rejection explains the policy") +ARCHIVE[MOD_ID .. "/baseroms/source.z64"] = nil + -- Fallback: no newFileData → stage temp + path mount resetFs() vfs.newFileData = nil @@ -205,6 +221,50 @@ check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil, check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil, "replace still lands in mods/") +-- User-selected baseroms belong to the installation, not the downloaded mod +-- archive, and survive the same replacement path. +resetFs() +files["mods/OldFolder/manifest.json"] = + ('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}') + :format(MOD_ID) +files["mods/OldFolder/main.lua"] = "return function() end\n" +files["mods/OldFolder/baseroms/stadium2.z64"] = "user-owned-rom" +files["imports/mods/update-with-rom.zip"] = "PK\3\4update" +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") +check(files["mods/OldFolder/baseroms/stadium2.z64"] == nil, + "the shadow mod tree is still removed after preservation") + +-- 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"}') + :format(MOD_ID) +files["mods/OldFolder/main.lua"] = "return function() end\n" +files["mods/OldFolder/baseroms/stadium2.z64"] = "user-owned-rom" +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") + +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) .. ")") +eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom", + "retry restores the recovered baserom into the installed mod") +check(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"] == nil, + "successful retry clears baserom recovery debris") + -- #834: a manifest-less mods/ tree (interrupted copy debris) must not -- block a plain re-import as "already installed" resetFs() diff --git a/tests/mod_required_imports_tests.lua b/tests/mod_required_imports_tests.lua new file mode 100644 index 00000000..271465b5 --- /dev/null +++ b/tests/mod_required_imports_tests.lua @@ -0,0 +1,256 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Manifest = require("src.mods.Manifest") +local RequiredImports = require("src.mods.RequiredImports") +local Loader = require("src.mods.Loader") +local S = require("tests.harness").suite("required mod imports") +local check, eq = S.check, S.eq + +local DIGEST = "0123456789abcdef0123456789abcdef" +local function fakeHash(data) + return data:sub(1, 4) == "\128\55\18\64" and DIGEST + or "ffffffffffffffffffffffffffffffff" +end + +local manifest = Manifest.validate({ + id = "stadium_fx", name = "Stadium FX", version = "1.0.0", entry = "main.lua", + required_imports = { + { id = "stadium2", name = "Stadium 2", file = "stadium2.z64", + description = "USA dump", format = "n64", size = 8, + md5 = { DIGEST, DIGEST:upper() } }, + }, +}, "mods/stadium_fx") + +eq(#manifest.required_imports, 1, "required import parses") +eq(#manifest.required_imports[1].md5, 1, "accepted MD5 values normalize and dedupe") +eq(manifest.required_imports[1].md5[1], DIGEST, "MD5 is lowercase") +eq(manifest.required_imports[1].description, "USA dump", + "import description is preserved for the picker UI") +eq(manifest.required_imports[1].size, 8, "exact import size parses") + +local optionalManifest = Manifest.validate({ + id = "optional_fx", name = "Optional FX", version = "1.0.0", entry = "main.lua", + optional_imports = { + { id = "bonus", name = "Bonus ROM", file = "bonus.z64", + format = "n64", md5 = DIGEST }, + }, +}, "mods/optional_fx") +eq(#optionalManifest.optional_imports, 1, "optional import parses") +eq(optionalManifest.optional_imports[1].required, false, + "optional import is marked non-blocking") +local optionalRows, optionalMissing, missingOptional = + RequiredImports.inspect(optionalManifest, love.filesystem, fakeHash) +eq(optionalMissing, 0, "missing optional import is not required") +eq(missingOptional, 1, "missing optional import is reported separately") +check(not optionalRows[1].required, "optional row is labeled optional") + +check(not pcall(Manifest.validate, { + id = "bad", name = "Bad", version = "1", entry = "main.lua", + required_imports = { { id = "rom", file = "../outside.z64", md5 = DIGEST } }, +}), "required import cannot escape baseroms") +check(not pcall(Manifest.validate, { + id = "bad", name = "Bad", version = "1", entry = "main.lua", + required_imports = { { id = "rom", file = "rom.z64", md5 = "short" } }, +}), "malformed MD5 is refused") +check(not pcall(Manifest.validate, { + id = "bad", name = "Bad", version = "1", entry = "main.lua", + required_imports = { { id = "rom", file = ".rom.removed", md5 = DIGEST } }, +}), "hidden import filenames cannot collide with engine metadata") +check(not pcall(Manifest.validate, { + id = "bad", name = "Bad", version = "1", entry = "main.lua", + required_imports = { { id = "rom", file = "rom.bin", md5 = DIGEST, + max_size = RequiredImports.MAX_BYTES + 1 } }, +}), "manifest import sizes cannot exceed the hard limit") + +local canonical = "\128\55\18\64ABCD" +local v64 = "\55\128\64\18BADC" +local n64 = "\64\18\55\128DCBA" +check(RequiredImports.importData(optionalManifest, "bonus", canonical, + { hash = fakeHash }), "optional import uses the normal validation path") +eq(RequiredImports.normalizeN64(canonical), canonical, "z64 stays canonical") +eq(RequiredImports.normalizeN64(v64), canonical, "v64 pair swap canonicalizes") +eq(RequiredImports.normalizeN64(n64), canonical, "n64 word swap canonicalizes") +eq(RequiredImports.normalizeN64(string.rep("H", 512) .. v64), canonical, + "recognized 512-byte copier header is stripped") +check(RequiredImports.normalizeN64(string.rep("H", 520)) == nil, + "an arbitrary 512-byte prefix is not treated as a copier header") + +local ok, digest = RequiredImports.importData(manifest, "stadium2", v64, + { hash = fakeHash }) +check(ok, "validated bytes import") +eq(digest, DIGEST, "import reports canonical digest") +eq(love.filesystem.read("mods/stadium_fx/baseroms/stadium2.z64"), canonical, + "import writes canonical bytes inside the mod") +local rows, missing = RequiredImports.inspect(manifest, love.filesystem, fakeHash) +eq(missing, 0, "written import satisfies its declaration") +check(rows[1].present, "inspection reports ready") + +local target = Manifest.validate({ + id = "other_fx", name = "Other FX", version = "1.0.0", entry = "main.lua", + required_imports = { + { id = "same_rom", file = "source.z64", format = "n64", md5 = DIGEST }, + }, +}, "mods/other_fx") +local targetRows, targetMissing = RequiredImports.inspect(target, + love.filesystem, fakeHash) +eq(targetMissing, 1, "matching hashes do not silently share another mod's import") +check(not targetRows[1].present, + "a mod needs its own explicit player-selected file") +check(RequiredImports.remove(target, "same_rom"), "a required import can be removed") +eq(love.filesystem.read("mods/other_fx/baseroms/source.z64"), nil, + "remove deletes this mod's private copy") +check(RequiredImports.importData(target, "same_rom", canonical, { hash = fakeHash }), + "choosing the file again clears the removal decision") + +local legacy = Manifest.validate({ + id = "legacy", name = "Legacy", version = "1.0.0", entry = "main.lua", +}, "mods/legacy") +local legacyTarget = Manifest.validate({ + id = "legacy_user", name = "Legacy User", version = "1.0.0", entry = "main.lua", + required_imports = { + { id = "rom", file = "legacy-source.z64", format = "n64", md5 = DIGEST }, + }, +}, "mods/legacy_user") +love.filesystem.write("mods/legacy/baseroms/manually-imported.v64", v64) +local legacyRows, legacyMissing = RequiredImports.inspect(legacyTarget, + love.filesystem, fakeHash) +eq(legacyMissing, 1, "undeclared files in another mod are never indexed") +check(not legacyRows[1].present, "legacy baseroms remain private to their mod") + +local capped = Manifest.validate({ + id = "capped", name = "Capped", version = "1.0.0", entry = "main.lua", + required_imports = { + { id = "small", file = "small.bin", md5 = DIGEST, max_size = 4 }, + }, +}, "mods/capped") +local tooLarge, sizeWhy = RequiredImports.validateData( + capped.required_imports[1], "12345", fakeHash) +eq(tooLarge, nil, "per-import size cap rejects before hashing") +check(tostring(sizeWhy):find("too large", 1, true) ~= nil, + "size rejection explains the limit") + +-- A successful validation writes an engine receipt. Matching size + modtime +-- lets later launcher refreshes avoid reading and hashing the ROM again. +local cacheFiles = { + ["mods/cache/baseroms/source.z64"] = canonical, +} +local dataReads = 0 +local cacheModtime = 123 +local cacheFs = { + getInfo = function(path, kind) + local data = cacheFiles[path] + if data then return { type = "file", size = #data, modtime = cacheModtime } end + return nil + end, + read = function(path) + if path == "mods/cache/baseroms/source.z64" then dataReads = dataReads + 1 end + return cacheFiles[path] + end, + write = function(path, data) cacheFiles[path] = data return true end, + remove = function(path) cacheFiles[path] = nil return true end, +} +local cacheManifest = Manifest.validate({ + id = "cache", name = "Cache", version = "1.0.0", entry = "main.lua", + required_imports = { + { id = "source", file = "source.z64", format = "n64", size = 8, + md5 = DIGEST }, + }, +}, "mods/cache") +local cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, fakeHash) +check(cacheRows[1].present, "initial cached import validation succeeds") +cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, function() + error("unchanged cached import should not be hashed again") +end) +check(cacheRows[1].present, "validation receipt satisfies the next refresh") +eq(dataReads, 1, "unchanged imported ROM is read only once") +cacheFiles["mods/cache/baseroms/source.z64"] = "BADBYTES" +cacheModtime = 124 +cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, fakeHash) +check(not cacheRows[1].present, "changed imported ROM bypasses a stale receipt") +eq(dataReads, 2, "changed imported ROM is read again") +eq(cacheFiles[RequiredImports.receiptPath(cacheManifest, cacheManifest.required_imports[1])], + nil, "stale validation receipt is removed") + +local rejected, why = RequiredImports.importData(target, "same_rom", "wrong", + { hash = fakeHash }) +eq(rejected, nil, "mismatched selection is rejected") +check(tostring(why):find("N64 ROM (.z64/.v64/.n64)", 1, true) ~= nil, + "normalization failure explains the selected format") + +love.filesystem.write("mods/launcher_needs/manifest.json", ([[{ + "id":"launcher_needs","name":"Launcher Needs","version":"1.0.0", + "entry":"main.lua","required_imports":[{"id":"source","name":"Source ROM", + "file":"source.bin","md5":"%s"}] +}]]):format(DIGEST)) +love.filesystem.write("mods/launcher_needs/main.lua", "return function(mod) end") +local launcherRows = require("src.mods.LauncherMods").list() +eq(#launcherRows, 1, "launcher keeps a mod with a missing required import visible") +eq(launcherRows[1].missingRequiredImports, 1, + "launcher row carries the missing import count") +eq(launcherRows[1].status, "needs_import", + "missing import changes Ready to Import required") +check(launcherRows[1].statusDetail:find("Source ROM", 1, true) ~= nil, + "launcher warning names the missing file") + +local function memfs(files) + return { + read = function(path) return files[path] end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + end, + getDirectoryItems = function(path) + if path == "mods" then + local out, seen = {}, {} + for key in pairs(files) do + local id = key:match("^mods/([^/]+)/manifest%.json$") + if id and not seen[id] then seen[id] = true; out[#out + 1] = id end + end + table.sort(out) + return out + end + return {} + end, + load = function(path) + local source = files[path] + if not source then return nil, "missing" end + return load(source, path) + end, + } +end + +local manifestJson = ([[{ + "id":"needs_rom","name":"Needs ROM","version":"1.0.0","entry":"main.lua", + "required_imports":[{"id":"rom","name":"Source ROM","file":"source.bin", + "md5":"%s"}] +}]]):format(DIGEST) +local loader = Loader.new({ fs = memfs({ + ["mods/needs_rom/manifest.json"] = manifestJson, + ["mods/needs_rom/main.lua"] = "return function(mod) mod.exports.ran = true end", +}) }) +check(loader:load({}) == false, "missing required import blocks the enabled mod") +local status = loader:status().available[1] +eq(status.state, "invalid", "blocked mod reports invalid") +check(status.error:find("Source ROM", 1, true) ~= nil, + "loader failure names the required import") +check(not (loader.exports.needs_rom and loader.exports.needs_rom.ran), + "blocked mod entry never executes") + +local optionalJson = ([[{ + "id":"optional_rom","name":"Optional ROM","version":"1.0.0","entry":"main.lua", + "optional_imports":[{"id":"rom","name":"Bonus ROM","file":"bonus.bin", + "md5":"%s"}] +}]]):format(DIGEST) +local optionalLoader = Loader.new({ fs = memfs({ + ["mods/optional_rom/manifest.json"] = optionalJson, + ["mods/optional_rom/main.lua"] = "return function(mod) mod.exports.ran = true end", +}) }) +check(optionalLoader:load({}), "missing optional import does not block the mod") +check(optionalLoader.exports.optional_rom.ran, + "mod entry executes without its optional import") + +S.finish() diff --git a/tests/modkit/cases/world_field_items.lua b/tests/modkit/cases/world_field_items.lua index a5a4a869..095c333a 100644 --- a/tests/modkit/cases/world_field_items.lua +++ b/tests/modkit/cases/world_field_items.lua @@ -1,11 +1,13 @@ --- Contextual bicycle and fishing actions share one public contract in both --- generations while each engine keeps ownership of its own field-item path. +-- Contextual field actions share one public contract in both generations +-- while each engine keeps ownership of its own field-item and move paths. package.path = "./?.lua;./?/init.lua;" .. package.path -local T = require("tests.harness").suite("mod world field items") +local T = require("tests.harness").suite("mod world field actions") local facingWater = false +local redCut, redSurf = false, "no_water" +local redMoves = {} local redWorld = { isOverworld = true, map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } }, @@ -14,11 +16,15 @@ local redWorld = { scriptMoves = {}, bikeAllowed = function() return true end, facingIsShoreOrWater = function() return facingWater end, + useCutFieldMove = function() return redCut and "ok" or "nothing" end, + useSurfFieldMove = function() return redSurf end, + partyKnows = function(_, move) return redMoves[move] end, useBicycle = function(self) self.bikeUsed = true return true end, useFishingRod = function(self, rod) self.rodUsed = rod return true end, } local redGame = { - data = { items = { OLD_ROD = { name = "OLD ROD" } } }, + data = { field = { outsideTilesets = { "OVERWORLD" } }, + items = { OLD_ROD = { name = "OLD ROD" } } }, save = { player = { name = "RED" }, party = {}, inventory = { BICYCLE = 1, OLD_ROD = 1 } }, stack = { states = { redWorld } }, @@ -30,8 +36,11 @@ local RedAPI = require("src.world.WorldAPI") local red = RedAPI.new(redGame, "fixture") local RedWorld = require("src.world.OverworldController") T.check(type(RedWorld.useBicycle) == "function" - and type(RedWorld.useFishingRod) == "function", - "Red keeps field-item execution in its world") + and type(RedWorld.useFishingRod) == "function" + and type(RedWorld.useFlashFieldMove) == "function" + and type(RedWorld.useStrengthFieldMove) == "function" + and type(RedWorld.stopSurfing) == "function", + "Red keeps field-action execution in its world") local actions = red:availableFieldActions() T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle") T.check(red:useFieldAction("bicycle"), "Red accepts the listed bicycle") @@ -55,26 +64,98 @@ ok, err = red:useFieldAction("bicycle") T.check(not ok and err == "world is busy", "Red refuses a stale action while busy") +redWorld.player.moving = false +facingWater, redCut, redSurf = false, true, "ok" +redWorld.dark = true +for _, move in ipairs({ "STRENGTH", "FLASH", "TELEPORT" }) do + redMoves[move] = { species = "MEW", moves = { { id = move } } } +end +redWorld.player.facingCell = function() return 4, 5 end +redWorld.tryCut = function(self) self.cutUsed = true return true end +redWorld.trySurf = function(self) self.surfUsed = true end +redWorld.useStrengthFieldMove = function(self) self.strengthUsed = true return true end +redWorld.useFlashFieldMove = function(self) self.flashUsed = true return true end +redWorld.beginTeleportOut = function(self) self.teleportUsed = true end +actions = red:availableFieldActions() +local byId = {} +for _, action in ipairs(actions) do byId[action.id] = action end +T.check(byId.cut and byId.surf and byId.strength and byId.flash + and byId.teleport, "Red lists field moves that can start now") +for _, id in ipairs({ "cut", "surf", "strength", "flash", "teleport" }) do + T.check(red:useFieldAction(id), "Red accepts listed " .. id) +end +T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed + and redWorld.flashUsed and redWorld.teleportUsed, + "Red delegates every move to its overworld path") + +redSurf = "dismount" +redWorld.player.surfing = true +redWorld.stopSurfing = function(self) self.dismounted = true end +T.check(red:useFieldAction("surf") and redWorld.dismounted, + "Red delegates the contextual SURF dismount") +redWorld.player.surfing, redSurf = false, "ok" + +redCut = false +ok, err = red:useFieldAction("cut") +T.check(not ok and err == "field action unavailable", + "Red revalidates a stale field move") + +redWorld.map.id = "ROCK_TUNNEL_1F" +redWorld.map.def.tileset = "CAVERN" +redMoves.DIG = { species = "MEW", moves = { { id = "DIG" } } } +redWorld.beginTeleportOut = function(self) self.digUsed = true end +byId = {} +for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end +T.check(byId.dig and not byId.teleport, + "Red distinguishes dungeon DIG from outdoor TELEPORT") +T.check(red:useFieldAction("dig") and redWorld.digUsed, + "Red delegates DIG to its escape path") + local goldWorld = { map = { id = "ROUTE_29", def = { environment = "ROUTE" } }, player = {}, playerState = "normal", acceptsMenuInput = function() return true end, playerCollision = function() return 0x00 end, alwaysOnBike = function() return false end, - fieldContext = function() return { facingColl = 0x20 } end, useFieldItem = function(self, item) self.itemUsed = item return "used" end, + squirtbottleTreeScript = function() return { { op = "end" } } end, + useFieldMove = function(self, move) + self.moveUsed = move + return { ok = true } + end, } local goldGame = { - data = { items = { OLD_ROD = { name = "OLD ROD" } } }, - save = { inventory = { BICYCLE = 1, OLD_ROD = 1 } }, + data = { items = { OLD_ROD = { name = "OLD ROD" }, + SQUIRTBOTTLE = { name = "SQUIRTBOTTLE" } } }, + save = { inventory = { BICYCLE = 1, OLD_ROD = 1, SQUIRTBOTTLE = 1 }, + player = { badges = { FOG = true } }, + party = { { moves = { { id = "SURF" }, { id = "SWEET_SCENT" }, + { id = "TELEPORT" } } } } }, world = goldWorld, } +goldWorld.fieldContext = function(_, mon) return { + save = goldGame.save, party = goldGame.save.party, mon = mon, + facing = "right", facingColl = 0x29, playerColl = 0, + environment = "ROUTE", playerState = "normal", alwaysOnBike = false, + dark = false, canEscapeRope = false, +} end local GoldAPI = require("src.world.gen2.WorldAPI") local gold = GoldAPI.new(goldGame, "fixture") actions = gold:availableFieldActions() +byId = {} +for _, action in ipairs(actions) do byId[action.id] = action end T.eq(actions[1].id, "bicycle", "Gold shares the bicycle action id") -T.eq(actions[2].rods[1].id, "OLD_ROD", "Gold shares the rod shape") +T.eq(actions[2].id, "fish", "Gold preserves the original action order") +T.check(byId.surf and byId.sweet_scent and byId.teleport, + "Gold lists field moves through its generic dispatcher") +T.eq(byId.fish.rods[1].id, "OLD_ROD", "Gold shares the rod shape") +T.check(byId.squirtbottle, + "Gold lists the SquirtBottle only at its matching tree") +T.check(gold:useFieldAction("sweet_scent"), + "Gold accepts a listed field move") +T.eq(goldWorld.moveUsed, "SWEET_SCENT", + "Gold delegates moves to its own field-move path") T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }), "Gold accepts the same fishing request") T.eq(goldWorld.itemUsed, "OLD_ROD", @@ -84,5 +165,9 @@ ok, err = gold:useFieldAction("fish", { rod = "SUPER_ROD" }) T.check(not ok and err == "fishing rod unavailable", "Gold rejects an unowned rod") T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing") +T.check(gold:useFieldAction("squirtbottle"), + "Gold accepts the contextual SquirtBottle") +T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE", + "Gold delegates the SquirtBottle to its field-item path") T.finish() diff --git a/tests/modkit_tests.lua b/tests/modkit_tests.lua index fc5758cc..ee53aef0 100644 --- a/tests/modkit_tests.lua +++ b/tests/modkit_tests.lua @@ -463,6 +463,8 @@ return function(mod) end ]]) write(bad .. "/hack.gb", "GBDATA") +os.execute((mkdir .. " %q"):format(bad .. "/baseroms")) +write(bad .. "/baseroms/stadium2.z64", "USER ROM") write(bad .. "/cachepath.lua", 'return { pic = "assets/generated/battle/front/mew.png" }') @@ -474,6 +476,8 @@ check(out:find("MK101", 1, true) ~= nil, "schema typo reported as MK101") check(out:find("base_stats", 1, true) ~= nil, "MK101 names the bad field") check(out:find("MK301", 1, true) ~= nil, "cache reference reported as MK301") check(out:find("MK303", 1, true) ~= nil, "ROM patch file reported as MK303") +check(out:find("MK307", 1, true) ~= nil, + "a user-supplied baseroms file is refused explicitly") out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture") :format(python, bad, root .. "/bad.modpkg")) diff --git a/tests/rom_importer_android_mod_pick_test.lua b/tests/rom_importer_android_mod_pick_test.lua index 6e06fece..b958e75b 100644 --- a/tests/rom_importer_android_mod_pick_test.lua +++ b/tests/rom_importer_android_mod_pick_test.lua @@ -14,6 +14,7 @@ love.system = love.system or {} local saved = { getOS = love.system.getOS, pickFile = love.system.pickFile, + pickFileKinds = love.system.pickFileKinds, } local pickCalls = {} @@ -22,6 +23,7 @@ love.system.pickFile = function(kind) pickCalls[#pickCalls + 1] = kind or "rom" return true end +love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end local function freshImporter(ready) return setmetatable({ @@ -94,11 +96,83 @@ eq(ri._imported.source, "picked_save.sav", "focus reads the SAF save filename") check(love.filesystem.getInfo("picked_save.sav") == nil, "successful focus import removes picked_save.sav") +-- Required mod files use their own safe picker kind and pending filename. +pickCalls = {} +ri = freshImporter({ red = true, blue = true }) +ri.nativePicker = true +ri.mobileFileBridge = true +ri.mods = { { + id = "needs_source", + manifest = { id = "needs_source", name = "Needs Source", + required_imports = { { id = "source", name = "Source", file = "source.bin", + format = "raw", md5 = { "00000000000000000000000000000000" } } } }, +} } +ri:chooseRequiredImport("needs_source", "source") +eq(pickCalls[1], "required_import", + "required file asks for the dedicated picker kind") +eq(ri.pickerPendingModId, "needs_source", "pending mod is remembered") +eq(ri.pickerPendingImportId, "source", "pending import is remembered") + +-- A rejected selection stays on the imported-files page, where the player can +-- see it before choosing another file, instead of behind the modal. +ri.nativePicker = false +ri._importRequiredData = RomImporter._importRequiredData +local savedData = love.data +love.data = { + hash = function() return "not accepted" end, + encode = function() return "ffffffffffffffffffffffffffffffff" end, +} +ri:_importRequiredData("needs_source", "source", "wrong source bytes") +love.data = savedData +check(ri.requiredImportNotice ~= nil, + "required import rejection creates an in-modal notice") +eq(ri.requiredImportNotice.modId, "needs_source", + "required import notice identifies its mod") +eq(ri.requiredImportNotice.importId, "source", + "required import notice identifies its file") +check(ri.requiredImportNotice.text:find("MD5 mismatch", 1, true) ~= nil, + "required import notice includes the MD5 failure") +check(ri.modNotice == nil, + "required import rejection is not hidden in the general Mods notice") + +-- Reported size is checked before the selected file is read into Lua. +local savedGetInfo = love.filesystem.getInfo +love.filesystem.getInfo = function(name, kind) + if name == "oversized_required.bin" then + return { type = "file", size = 10 } + end + return savedGetInfo(name, kind) +end +ri.mods[1].manifest.required_imports[1].max_size = 4 +ri._importRequiredSource = RomImporter._importRequiredSource +ri._importRequiredData = function(self) self._oversizedWasRead = true end +ri:_importRequiredSource("needs_source", "source", "oversized_required.bin") +check(not ri._oversizedWasRead, "oversized required file is rejected before import") +check(ri.requiredImportNotice.text:find("too large", 1, true) ~= nil, + "oversized required file reports its size error in the modal") +ri.mods[1].manifest.required_imports[1].max_size = nil +love.filesystem.getInfo = savedGetInfo + +ri.nativePicker = true +ri._importRequiredSource = function(self, modId, importId, source) + self._requiredImported = { modId = modId, importId = importId, source = source } + return true +end +love.filesystem.write("picked_required_import.bin", "source bytes") +ri:focus(true) +check(ri._requiredImported ~= nil, "focus consumes a required-file SAF pick") +eq(ri._requiredImported.modId, "needs_source", "focus routes to the pending mod") +eq(ri._requiredImported.importId, "source", "focus routes to the pending declaration") +check(love.filesystem.getInfo("picked_required_import.bin") == nil, + "focus removes the staged required-file pick") + love.system.getOS = saved.getOS love.system.pickFile = saved.pickFile +love.system.pickFileKinds = saved.pickFileKinds -- leftover cleanup if a failed assertion left files behind love.filesystem.remove("usb_mod.zip") love.filesystem.remove("picked_mod.zip") love.filesystem.remove("picked_save.sav") +love.filesystem.remove("picked_required_import.bin") S.finish() diff --git a/tests/run_save_editor_tests.lua b/tests/run_save_editor_tests.lua index 986e3dfc..f958d614 100644 --- a/tests/run_save_editor_tests.lua +++ b/tests/run_save_editor_tests.lua @@ -9,6 +9,7 @@ -- tests/save_editor_task7_tests.lua -- tests/save_editor_task8_tests.lua -- tests/save_editor_mod_tests.lua +-- tests/save_editor_gen2_tests.lua -- See tools/save-editor/README.md for the full list. -- -- All of them drive tools/save-editor/Ops.lua rather than clicking pixel diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 149483aa..6e2ad123 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3475,6 +3475,8 @@ do local lua = (arg and arg[-1]) or "luajit" local status = os.execute(("%q tests/save_editor_mod_tests.lua"):format(lua)) check(status == 0 or status == true, "save_editor_mod_tests suite") + status = os.execute(("%q tests/save_editor_gen2_tests.lua"):format(lua)) + check(status == 0 or status == true, "save_editor_gen2_tests suite") end -- ---------------------------------------------- input hold regressions diff --git a/tests/save_editor_gen2_tests.lua b/tests/save_editor_gen2_tests.lua new file mode 100644 index 00000000..97b67158 --- /dev/null +++ b/tests/save_editor_gen2_tests.lua @@ -0,0 +1,352 @@ +-- Headless Gold save-editor rules. Run from repo root: +-- luajit tests/save_editor_gen2_tests.lua +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua" + .. ";./tools/save-editor/panels/?.lua" + +local love_stub = require("tests.love_stub") +love = love_stub + +local passed, failed = 0, 0 + +local function check(cond, msg) + if cond then + passed = passed + 1 + else + failed = failed + 1 + print("FAIL: " .. msg) + end +end + +local function eq(a, b, msg) + check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b))) +end + +print("== save editor gen2 tests ==") + +local Gen = require("Gen") +local Catalog = require("Catalog") +local MonOps = require("MonOps") +local Ops = require("Ops") +local State = require("State") +local Save2 = require("src.core.gen2.Save") +local SaveData = require("src.core.SaveData") +local GameVersion = require("src.core.GameVersion") + +local data = { + pokemon = { + CYNDAQUIL = { + id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, + types = { "FIRE" }, + baseStats = { + hp = 39, attack = 52, defense = 43, speed = 65, + specialAttack = 60, specialDefense = 50, + }, + catchRate = 45, baseExp = 65, + growthRate = "MEDIUM_FAST", + levelMoves = { { level = 1, move = "TACKLE" } }, + genderRatio = 31, + }, + TOTODILE = { + id = "TOTODILE", name = "TOTODILE", dex = 158, + types = { "WATER" }, + baseStats = { + hp = 50, attack = 65, defense = 64, speed = 43, + specialAttack = 44, specialDefense = 48, + }, + catchRate = 45, baseExp = 66, + growthRate = "MEDIUM_FAST", + levelMoves = { { level = 1, move = "SCRATCH" } }, + genderRatio = 31, + }, + }, + moves = { + TACKLE = { pp = 35 }, + SCRATCH = { pp = 35 }, + }, + items = { + POTION = { pocket = "ITEM" }, + MASTER_BALL = { pocket = "BALL" }, + FLOWER_MAIL = { pocket = "ITEM" }, + }, + maps = {}, +} + +local function newState() + local S = State.new() + S.data = data + S.cat = Catalog.build(data) + S.save = Save2.newGame() + S.version = "gold" + Gen.ensureBoxes(S.save) + return S +end + +do + GameVersion.set("gold") + eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field") + eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold") + eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame") + eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame") +end + +do + local S = newState() + check(Ops.speciesUsable(S, "CYNDAQUIL"), "spa/spd species is usable") + local S1 = State.new() + S1.data = { + pokemon = { + PIDGEY = { baseStats = { hp = 40, attack = 45, defense = 40, speed = 56, special = 35 } }, + BROKEN = { baseStats = { hp = 1 } }, + }, + } + check(Ops.speciesUsable(S1, "PIDGEY"), "gen1 special species is usable") + check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable") +end + +do + local S = newState() + Ops.partyAdd(S) + eq(#S.save.party, 1, "partyAdd on gold") + local mon = S.save.party[1] + eq(mon.species, "CYNDAQUIL", "first catalog species") + check(mon.experience ~= nil, "gold mon has experience") + check(mon.exp == nil or mon.experience ~= nil, "does not rely on gen1 exp") + check(mon.stats.specialAttack and mon.stats.specialDefense, + "gold stats have spa/spd") + check(mon.happiness ~= nil, "gold mon has happiness") + eq(mon.ot, S.save.player.name, "stampOT copies player name") + + Ops.setLevel(S, mon, 20) + eq(mon.level, 20, "setLevel 20") + check(mon.experience > 0, "experience resynced") + + Ops.setHappiness(S, mon, 200) + eq(mon.happiness, 200, "happiness 200") + Ops.setPokerus(S, mon, 15) + eq(mon.pokerus, 15, "pokerus byte") + Ops.setHeldItem(S, mon, "POTION") + eq(mon.item, "POTION", "held item") + + eq(mon.name, "CYNDAQUIL", "new mon copies species display name") + Ops.setSpecies(S, mon, "TOTODILE") + eq(mon.species, "TOTODILE", "setSpecies id") + eq(mon.name, "TOTODILE", "setSpecies rewrites the Gold display name") + check(mon.nickname == nil, "setSpecies does not invent a nickname") + eq(mon.types[1], "WATER", "setSpecies rewrites copied types") +end + +do + local S = newState() + Ops.partyAdd(S) + Ops.partyAdd(S) + local Mail = require("src.core.gen2.Mail") + Mail.set(S.save, 1, Mail.entry("FLOWER_MAIL", "hi", "GOLD", 1, "CYNDAQUIL")) + Mail.set(S.save, 2, Mail.entry("SURF_MAIL", "bye", "GOLD", 1, "CYNDAQUIL")) + S.selectedParty = 1 + Ops.partyMove(S, 1) + eq(Mail.state(S.save).party[1].message, "bye", "partyMove carries mail with the mon") + eq(Mail.state(S.save).party[2].message, "hi", "partyMove swaps the other letter") + S.selectedParty = 1 + check(Ops.partyRemove(S) == false, "partyRemove arms") + check(Ops.partyRemove(S) == true, "partyRemove commits") + eq(Mail.state(S.save).party[1].message, "hi", "partyRemove shifts leftover mail up") + check(Mail.state(S.save).party[2] == nil, "partyRemove clears the vacated slot") +end + +do + local S = newState() + Ops.partyAdd(S) + local mon = S.save.party[1] + local Mail = require("src.core.gen2.Mail") + Ops.setHeldItem(S, mon, "FLOWER_MAIL") + eq(mon.item, "FLOWER_MAIL", "held mail item") + local letter = Mail.state(S.save).party[1] + check(letter ~= nil, "giving mail writes sPartyMail") + eq(letter.species, "CYNDAQUIL", "new letter stamps current species") + Ops.setSpecies(S, mon, "TOTODILE") + eq(Mail.state(S.save).party[1].species, "TOTODILE", + "setSpecies updates the letter's species copy") + Ops.setHeldItem(S, mon, "POTION") + check(Mail.state(S.save).party[1] == nil, "non-mail held item drops the letter") +end + +do + local Mon = require("src.battle.gen2.Mon") + local S = newState() + local stale = Mon.new(data, "CYNDAQUIL", 5) + stale.species = "TOTODILE" + stale.name = "CYNDAQUIL" + stale.types = { "FIRE" } + S.save.dayCare = { man = { mon = stale }, lady = {} } + Mon.syncSaveIdentity(S.save, data) + eq(stale.name, "TOTODILE", "syncSaveIdentity rewrites Day-Care display name") + eq(stale.types[1], "WATER", "syncSaveIdentity rewrites Day-Care types") + eq(Mon.displayName({ nickname = nil, name = "ABRA", species = "RAYQUAZA" }), + "ABRA", "displayName prefers the species copy over the id") + eq(Mon.displayName({ nickname = "BOB", name = "ABRA", species = "RAYQUAZA" }), + "BOB", "displayName prefers nickname") +end + +do + local S = newState() + eq(Ops.boxCount(S), 14, "14 gold boxes") + eq(Ops.boxCapacity(S), 20, "20 per box") + Ops.boxAdd(S) + eq(#Ops.boxes(S)[1], 1, "boxAdd into box 1") +end + +do + local S = newState() + Ops.partyAdd(S) + S.save.party[1].hp = 0 + Ops.partyAdd(S) + S.selectedParty = 1 + S.selectedBox = 1 + local ok = Ops.deposit(S) + check(ok, "deposit fainted mon while a healthy remains") +end + +do + local S = newState() + Ops.partyAdd(S) + Ops.partyAdd(S) + S.selectedParty = 1 + S.selectedBox = 1 + local ok = Ops.deposit(S) + check(ok, "deposit one of two healthy mons") +end + +do + local S = newState() + Ops.partyAdd(S) + S.selectedParty = 1 + S.selectedBox = 1 + local ok = Ops.deposit(S) + check(not ok, "refuse depositing last healthy mon") + check(S.status:lower():find("last", 1, true) or S.status:find("POKéMON") + or S.status:find("POKEMON") or S.status:find("last"), + "deposit refusal names the last-healthy rule: " .. tostring(S.status)) + eq(#S.save.party, 1, "party still has the mon") +end + +do + local S = newState() + eq(Gen.money(S.save), 3000, "gold start money on player") + Ops.addMoney(S, 1000) + eq(S.save.player.money, 4000, "money writes player.money") + check(S.save.money == nil or S.save.money ~= 4000, "does not write save.money") + Ops.maxMoney(S) + eq(S.save.player.money, 999999, "money cap") + Ops.addCoins(S, 250) + eq(S.save.player.coins, 250, "coins write player.coins") +end + +do + local S = newState() + check(not Gen.hasBadge(S.save, "ZEPHYR"), "no zephyr yet") + Ops.toggleBadge(S, "ZEPHYR") + check(Gen.hasBadge(S.save, "ZEPHYR"), "zephyr earned") + check(S.save.player.badges.ZEPHYR, "stored on player.badges") + Ops.toggleBadge(S, "BOULDER") + check(S.save.player.kantoBadges.BOULDER, "kanto badge store") +end + +do + local S = newState() + Ops.dexOwned(S, "CYNDAQUIL", true) + check(S.save.pokedex.caught.CYNDAQUIL, "dex writes caught") + check(S.save.pokedex.owned == nil or S.save.pokedex.owned.CYNDAQUIL == nil, + "does not write owned on gold") + check(S.save.pokedex.seen.CYNDAQUIL, "owned implies seen") + local _, owned = Ops.dexCounts(S) + eq(owned, 1, "dexCounts reads caught") +end + +do + local S = newState() + local name = "EVENT_BEAT_FALKNER" + Ops.setFlag(S, name, true) + check(Gen.getFlag(S.save, name), "gold EVENT_ sets bitfield") + check(S.save.flags[name] == nil, "numeric flags are not string keys") + Ops.setFlag(S, "MOD_EDITMON_GIFT", true) + check(S.save.flags.MOD_EDITMON_GIFT, "mod flags stay named on gold") +end + +do + local S = newState() + S.mapId = "NEW_BARK_TOWN" + S.mapClickCell = { cx = 4, cy = 5 } + Ops.setPlayerHere(S) + eq(S.save.position.map, "NEW_BARK_TOWN", "position.map") + eq(S.save.position.x, 4, "position.x") + eq(S.save.position.y, 5, "position.y") + check(S.save.player.map == nil or S.save.player.map ~= "NEW_BARK_TOWN", + "does not write player.map on gold") +end + +do + local encoded = SaveData.encode(Save2.newGame()) + local back = SaveData.decode(encoded) + eq(back.generation, 2, "round-trip keeps generation 2") +end + +do + local names = Catalog.goldEventList() + local hasFalkner = false + for _, n in ipairs(names) do + if n == "EVENT_BEAT_FALKNER" then hasFalkner = true break end + end + check(hasFalkner, "gold event list includes EVENT_BEAT_FALKNER") +end + +do + local maps = Gen.maps({ gen2Maps = { AZALEA_GYM = true }, maps = { PALLET_TOWN = true } }) + check(maps.AZALEA_GYM, "Gen.maps includes gen2Maps") + check(maps.PALLET_TOWN, "Gen.maps keeps Data:load maps beside gen2Maps") + check(Gen.maps({ maps = { PALLET_TOWN = true } }).PALLET_TOWN, + "Gen.maps falls back to maps") + local mansion = Gen.maps({ + maps = { + CELADON_MANSION_2F = { id = "CELADON_MANSION_2F", width = 4, height = 5 }, + }, + gen2Maps = { + CELADON_MANSION_2F = { objects = { { name = "NPC" } } }, + BERRY_FARM = { id = "BERRY_FARM", width = 19, height = 12 }, + }, + }) + eq(mansion.CELADON_MANSION_2F.width, 4, + "Gen.maps keeps extractor width under a gen2Maps objects patch") + eq(mansion.CELADON_MANSION_2F.objects[1].name, "NPC", + "Gen.maps still applies the gen2Maps patch fields") + eq(mansion.BERRY_FARM.width, 19, "Gen.maps keeps mod maps only on gen2Maps") + local bound = Gen.bindGoldData({ maps = { A = true }, tilesets = { T = true } }) + check(bound.gen2Maps == bound.maps, "bindGoldData aliases gen2Maps") + check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets") + check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM, + "Gen.tilesets prefers gen2Tilesets") +end + +do + local Map2 = require("src.world.gen2.Map") + local MapPreview = require("src.world.gen2.MapPreview") + local def = { + id = "AZALEA_GYM", tileset = "TILESET_GYM", + width = 1, height = 1, blocks = { 1 }, borderBlock = 1, + warps = {}, environment = "INDOOR", + } + local tileset = { + id = "TILESET_GYM", + image = "assets/generated/tilesets/gym.png", + tilesPerRow = 16, + blocks = { { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } }, + } + local map = Map2.new(def, tileset) + check(map.renderer == nil, "Map2 does not ship a renderer") + local baker = MapPreview.baker({ tilesets = { TILESET_GYM = tileset } }) + local renderer = MapPreview.renderer(baker, map) + check(renderer ~= nil and renderer.draw ~= nil, + "MapPreview attaches a draw for a Gold map") +end + +print(string.format("save editor gen2 tests: %d passed, %d failed", passed, failed)) +if failed > 0 then os.exit(1) end diff --git a/tests/save_editor_mod_tests.lua b/tests/save_editor_mod_tests.lua index db679d9d..9ba1d482 100644 --- a/tests/save_editor_mod_tests.lua +++ b/tests/save_editor_mod_tests.lua @@ -142,6 +142,41 @@ local ok, err = pcall(function() local report = SaveData.validate(probe, Data) check(#report.lostMons == 0 and probe.party[1].species == "EDITMON", "validate keeps the modded mon while the mod is enabled") + + -- Gold-targeted mods: spa/spd records are usable, and a Gen 1-only + -- manifest stays out of Gold (no ROM cache / App.load("gold") required). + local ModTargets = require("src.mods.ModTargets") + local Ops = require("Ops") + check(not ModTargets.supports({}, "gold"), + "legacy gen1-only fixture does not support gold") + check(not ModTargets.supports({ games = { "red" } }, "gold"), + "explicit gen1 games list does not support gold") + check(ModTargets.supports({ games = { "gold" } }, "gold"), + "gold-targeted manifest supports gold") + check(ModTargets.supports({ gen2compat = true }, "gold"), + "gen2compat legacy still supports gold") + + local goldS = { + data = { + pokemon = { + EDITMON = { + baseStats = { + hp = 50, attack = 50, defense = 50, speed = 50, + specialAttack = 50, specialDefense = 50, + }, + }, + G1ONLY = { + baseStats = { + hp = 50, attack = 50, defense = 50, speed = 50, special = 50, + }, + }, + }, + }, + } + check(Ops.speciesUsable(goldS, "EDITMON"), + "spa/spd EDITMON is usable on gold") + check(Ops.speciesUsable(goldS, "G1ONLY"), + "gen1 special record remains usable (dual-key gate)") end) os.remove(MOD_ROOT .. "/main.lua") @@ -154,7 +189,7 @@ os.remove(tmpPath) love.filesystem = savedFS -- leave shared singletons the way we found them (the fixture merged one -- record into Data.pokemon) -Data.pokemon.EDITMON = nil +if Data.pokemon then Data.pokemon.EDITMON = nil end Assets.loader = savedBridge Assets.invalidate() Runtime.install(savedEvents, savedHooks, savedErrors) diff --git a/tools/goldwalk/gen_flags.lua b/tools/goldwalk/gen_flags.lua index e700cb88..92655db3 100644 --- a/tools/goldwalk/gen_flags.lua +++ b/tools/goldwalk/gen_flags.lua @@ -12,7 +12,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local Flags = dofile("tools/goldwalk/flags.lua") local ROOT = arg[1] or "../pokegold" -local OUT = "tests/drivers/gold/flag_names.lua" +local OUT = "src/core/gen2/FlagNames.lua" local events, _ = Flags.parse(ROOT .. "/constants/event_flags.asm") local engine, _ = Flags.parse(ROOT .. "/constants/engine_flags.asm") diff --git a/tools/modkit.py b/tools/modkit.py index 42fc5acd..64b212d5 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -1045,6 +1045,14 @@ def lint_dir(repo, mod_dir, manifest): for rel in mod_files(mod_dir): path = os.path.join(mod_dir, rel) ext = os.path.splitext(rel)[1].lower() + # MK307: required_imports are user-owned installation state. Keeping + # baseroms in the walk without an explicit gate would let `pack` + # silently bundle exactly the ROM this feature exists not to ship. + if rel.startswith("baseroms/"): + findings.append(Finding( + "MK307", "error", + "user-supplied baseroms must not be distributed", rel)) + continue # MK301: nothing may live in (or point into) the generated trees if rel.startswith(("data/generated/", "assets/generated/")): findings.append(Finding( diff --git a/tools/save-editor/App.lua b/tools/save-editor/App.lua index 7b5d3c21..260aadab 100644 --- a/tools/save-editor/App.lua +++ b/tools/save-editor/App.lua @@ -25,6 +25,7 @@ local State = require("State") local Kit = require("Kit") local Theme = require("Theme") local Ops = require("Ops") +local Gen = require("Gen") local PadInput = require("PadInput") local PAL = Theme.PAL @@ -87,56 +88,43 @@ local function applyLoaded(path, statusVerb) if save then S.save = save S.status = statusVerb .. " " .. path - S.mapId = save.player.map S.loadError = false S.allowSave = true elseif existed then - -- File is present but SaveIO.load couldn't decode it: treat it as a - -- real (corrupt) save, not a missing one. Editing a stub here is fine, - -- but Save must stay disabled so we never clobber the corrupt file - -- until the user fixes it and Reload succeeds. - S.save = require("src.core.SaveData").newGame() + S.save = Gen.newGame(S.version) S.status = "Corrupt save at " .. path .. " (" .. tostring(err) .. "), Save disabled, use Reload after fixing the file" - S.mapId = S.save.player.map S.loadError = true S.allowSave = false else - S.save = require("src.core.SaveData").newGame() + S.save = Gen.newGame(S.version) S.status = "No save at " .. path .. " (" .. tostring(err) .. "), editing new game stub" - S.mapId = S.save.player.map S.loadError = false S.allowSave = true end + local mapId = Gen.playerMap(S.save) + S.mapId = mapId S.dirty = false S._quitArmed = false S._openArmed = false S.editingMon = nil Ops.disarm(S) - local boxes = require("src.pokemon.Boxes").ensure(S.save) - -- Imported .sav box mons have no stat block (box_struct stops before - -- MON_STATS). The game derives them in SaveData.validate; the editor - -- only validates a copy, so hydrate here for Boxes/Party/MonEditor. - local Stats = require("src.pokemon.Stats") - local function ensureStats(mon) - Stats.ensure(Data.pokemon and Data.pokemon[mon.species], mon) - end - for _, mon in ipairs(S.save.party or {}) do ensureStats(mon) end - for _, box in ipairs(boxes) do - for _, mon in ipairs(box) do ensureStats(mon) end - end - if S.save.daycare and S.save.daycare.mon then - ensureStats(S.save.daycare.mon) - end - -- what the running game would quarantine, computed on a copy so the - -- editor never mutates the file behind the user's back - local SaveData = require("src.core.SaveData") + Gen.ensureBoxes(S.save) + Gen.hydrateSave(Data, S.save) local probe = require("src.mods.Merge").deepCopy(S.save) - S.validation = SaveData.validate(probe, Data) - if not SaveData.emptyReport(S.validation) then - S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps", - #S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps) + S.validation = Gen.validate(probe, Data) + if not Gen.emptyReport(S.save, S.validation) then + if Gen.of(S.save, S.version) == 2 then + S.status = S.status .. string.format( + ", game would quarantine: %d script bytes, %d mail, %d events", + #(S.validation.lostScriptMem or {}), + #(S.validation.lostMail or {}), + #(S.validation.lostEvents or {})) + else + S.status = S.status .. string.format(", game would quarantine: %d mons, %d items, %d maps", + #S.validation.lostMons, #S.validation.lostItems, #S.validation.remappedMaps) + end end end @@ -152,6 +140,9 @@ function App.load(pathOverride, opts) S.slotId = opts.slotId S.embedded = opts.embedded or false S.onClose = opts.onClose + if opts.version then + require("src.core.GameVersion").set(opts.version) + end -- the same mod set the game loads, merged into Data before the catalogs -- build, so modded species/items/moves are editable and MonOps stops -- asserting on them @@ -163,6 +154,10 @@ function App.load(pathOverride, opts) -- loaded at least once, so it doubles as the "needs evicting" marker. if Data._pristineKeys then Data:unloadGenerated() end Data:load() + if Gen.of(nil, opts.version) == 2 + or require("src.core.GameVersion").generation() == 2 then + Gen.bindGoldData(Data) + end local ModLoader = require("src.mods.Loader") mods = ModLoader.new() mods:load(Data) @@ -174,9 +169,16 @@ function App.load(pathOverride, opts) for _, mod in ipairs(S.mods:status().loaded) do modRoots[#modRoots + 1] = mod.path end - S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua", - nil, modRoots) + if Gen.of(nil, opts.version) == 2 or require("src.core.GameVersion").generation() == 2 then + S.events = Catalog.goldEventList(modRoots) + else + S.events = Catalog.scrapeEvents("data/scripts", "data/generated/trainer_headers.lua", + nil, modRoots) + end applyLoaded(pathOverride or SaveIO.defaultPath(), "Loaded") + if Gen.of(S.save, S.version) == 2 then + S.events = Catalog.goldEventList(modRoots) + end end -- Switch to another save file (Open button, drag-drop, or --save arg). @@ -582,11 +584,9 @@ local function tabCount(id) return tostring(n) elseif id == "items" then local Bag = require("src.inventory.Bag") - return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data)) + return ("%d/%d"):format(Bag.slots(S.save, S.data), Bag.capacity(S.data)) elseif id == "events" then - local n = 0 - for _ in pairs(S.save.flags or {}) do n = n + 1 end - return tostring(n) + return tostring(Gen.flagCount(S.save)) elseif id == "map" then -- map ids run long (REDS_HOUSE_2F); the rail is a summary, not a label return Kit.ellipsize("tiny", S.mapId or "", 110 * Kit.scale) @@ -602,21 +602,28 @@ end -- something. Returns what to draw plus the tab that owns the first problem, -- so the rail can reserve the pill's width before laying out the tiles. local function validationPill() - local SaveData = require("src.core.SaveData") local report = S.validation - if not report or SaveData.emptyReport(report) then + if not report or Gen.emptyReport(S.save, report) then return "Save validates clean", PAL.green, nil, true end local parts = {} local target local function add(n, singular, plural, tab) + n = n or 0 if n <= 0 then return end parts[#parts + 1] = ("%d %s"):format(n, n == 1 and singular or plural) target = target or tab end - add(#report.lostMons, "mon", "mons", "party") - add(#report.lostItems, "item", "items", "items") - add(#report.remappedMaps, "map", "maps", "map") + if Gen.of(S.save, S.version) == 2 then + add(#(report.lostScriptMem or {}), "script byte", "script bytes", "events") + add(#(report.lostMail or {}), "mail", "mail", "party") + add(#(report.lostEvents or {}), "event", "events", "events") + add(#(report.lostMapScenes or {}), "map scene", "map scenes", "map") + else + add(#(report.lostMons or {}), "mon", "mons", "party") + add(#(report.lostItems or {}), "item", "items", "items") + add(#(report.remappedMaps or {}), "map", "maps", "map") + end return "Would quarantine " .. table.concat(parts, ", "), PAL.yellow, target, false end diff --git a/tools/save-editor/Catalog.lua b/tools/save-editor/Catalog.lua index b2e61149..9ef209e7 100644 --- a/tools/save-editor/Catalog.lua +++ b/tools/save-editor/Catalog.lua @@ -91,7 +91,8 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs) end end - local dirs = { scriptDir } + local dirs = {} + if scriptDir then dirs[#dirs + 1] = scriptDir end for _, dir in ipairs(extraDirs or {}) do dirs[#dirs + 1] = dir end @@ -110,4 +111,25 @@ function Catalog.scrapeEvents(scriptDir, headerPath, listFiles, extraDirs) return sortedKeys(found) end +function Catalog.goldEventList(extraDirs) + local names = {} + local ok, flags = pcall(require, "src.core.gen2.FlagNames") + if ok and flags and flags.events then + for name in pairs(flags.events) do + names[#names + 1] = name + end + end + table.sort(names) + local modFlags = Catalog.scrapeEvents(nil, nil, nil, extraDirs) + local seen = {} + for _, name in ipairs(names) do seen[name] = true end + for _, name in ipairs(modFlags) do + if not seen[name] then + names[#names + 1] = name + seen[name] = true + end + end + return names +end + return Catalog diff --git a/tools/save-editor/Gen.lua b/tools/save-editor/Gen.lua new file mode 100644 index 00000000..9050fbd0 --- /dev/null +++ b/tools/save-editor/Gen.lua @@ -0,0 +1,350 @@ +-- Generation adapter for the save editor. Panels stay generation-blind; +-- Ops and App read Gold vs RBY through this module so a Gold write never +-- lands in Gen 1 fields (save.money, pokedex.owned, 12 boxes, ...). + +local GameVersion = require("src.core.GameVersion") + +local Gen = {} + +local function versionGeneration(version) + if type(version) ~= "string" then return nil end + local info = GameVersion.info(version) + if info then return info.generation or 1 end + return nil +end + +function Gen.of(save, version) + if type(save) == "table" then + if save.generation == 2 then return 2 end + local fromVersion = versionGeneration(save.version) + if fromVersion then return fromVersion end + end + local fromArg = versionGeneration(version) + if fromArg then return fromArg end + return GameVersion.generation() +end + +function Gen.ofState(S) + if not S then return GameVersion.generation() end + return Gen.of(S.save, S.version) +end + +function Gen.is2(save, version) + return Gen.of(save, version) == 2 +end + +-- Data:load writes Gold maps/tilesets to the Gen 1 keys; Game2 and the mod +-- merge write gen2Maps / gen2Tilesets. Overlay the gen2 table on the loaded +-- cache so a mod patch that landed on an empty gen2Maps (objects only, no +-- width) does not hide the extractor's record, and a new map like BERRY_FARM +-- still appears. +local function overlayRecords(base, overlay) + if not overlay then return base or {} end + if not base or base == overlay then return overlay end + local out = {} + for id, def in pairs(base) do out[id] = def end + for id, def in pairs(overlay) do + local prior = out[id] + if type(def) == "table" and type(prior) == "table" then + local merged = {} + for k, v in pairs(prior) do merged[k] = v end + for k, v in pairs(def) do merged[k] = v end + out[id] = merged + else + out[id] = def + end + end + return out +end + +function Gen.maps(data) + if type(data) ~= "table" then return {} end + return overlayRecords(data.maps, data.gen2Maps) +end + +function Gen.tilesets(data) + if type(data) ~= "table" then return {} end + return overlayRecords(data.tilesets, data.gen2Tilesets) +end + +-- Point the Gen 2 Data keys at the tables Data:load already filled, before +-- mods:load folds into gen2Maps. Same wiring Game2 does when it boots Gold. +function Gen.bindGoldData(data) + if type(data) ~= "table" then return data end + if data.maps and data.gen2Maps == nil then data.gen2Maps = data.maps end + if data.tilesets and data.gen2Tilesets == nil then + data.gen2Tilesets = data.tilesets + end + if data.palettes and data.gen2Palettes == nil then + data.gen2Palettes = data.palettes + end + if data.gen2Roofs == nil and data.roofs == nil then + local ok, roofs = pcall(require, "data.generated.roofs") + if ok and type(roofs) == "table" then + data.roofs = roofs + data.gen2Roofs = roofs + end + elseif data.roofs and data.gen2Roofs == nil then + data.gen2Roofs = data.roofs + end + return data +end + +function Gen.newGame(version) + if (versionGeneration(version) or GameVersion.generation(version)) == 2 then + return require("src.core.gen2.Save").newGame() + end + return require("src.core.SaveData").newGame() +end + +function Gen.validate(save, data) + if Gen.of(save) == 2 then + return require("src.core.gen2.Save").validate(save) + end + return require("src.core.SaveData").validate(save, data) +end + +function Gen.emptyReport(save, report) + if Gen.of(save) == 2 then + return require("src.core.gen2.Save").emptyReport(report) + end + return require("src.core.SaveData").emptyReport(report) +end + +function Gen.hydrateMon(data, mon) + if type(mon) ~= "table" then return mon end + local def = data and data.pokemon and data.pokemon[mon.species] + local gen2 = (mon.stats and mon.stats.specialAttack) + or (def and def.baseStats and def.baseStats.specialAttack) + or mon.experience ~= nil + if gen2 then + require("src.battle.gen2.Mon").refreshStats(mon, data) + else + require("src.pokemon.Stats").ensure(def, mon) + end + return mon +end + +-- Party, boxes, Day-Care. Gold's dayCare.man/lady/egg are not save.daycare. +function Gen.hydrateSave(data, save) + if type(save) ~= "table" then return save end + if Gen.of(save) == 2 then + local Mon = require("src.battle.gen2.Mon") + Mon.eachSaveMon(save, function(mon) Mon.refreshStats(mon, data) end) + return save + end + for _, mon in ipairs(save.party or {}) do Gen.hydrateMon(data, mon) end + for _, box in ipairs(save.boxes or {}) do + if type(box) == "table" then + for _, mon in ipairs(box) do Gen.hydrateMon(data, mon) end + end + end + if save.daycare and save.daycare.mon then + Gen.hydrateMon(data, save.daycare.mon) + end + return save +end + +function Gen.ensureBoxes(save) + if Gen.of(save) == 2 then + local Boxes2 = require("src.core.gen2.Boxes") + save.boxes = save.boxes or {} + for i = 1, Boxes2.NUM_BOXES do + save.boxes[i] = save.boxes[i] or {} + end + save.currentBox = math.max(1, math.min(Boxes2.NUM_BOXES, save.currentBox or 1)) + return save.boxes + end + return require("src.pokemon.Boxes").ensure(save) +end + +function Gen.boxCount(save) + if Gen.of(save) == 2 then + return require("src.core.gen2.Boxes").NUM_BOXES + end + return require("src.pokemon.Boxes").COUNT +end + +function Gen.boxCapacity(save) + if Gen.of(save) == 2 then + return require("src.core.gen2.Boxes").MONS_PER_BOX + end + return require("src.pokemon.Boxes").CAPACITY +end + +function Gen.money(save) + if Gen.of(save) == 2 then + return (save.player and save.player.money) or 0 + end + return save.money or 0 +end + +function Gen.setMoney(save, amount) + if Gen.of(save) == 2 then + save.player = save.player or {} + save.player.money = amount + else + save.money = amount + end +end + +function Gen.coins(save) + if Gen.of(save) == 2 then + return (save.player and save.player.coins) or 0 + end + return save.coins or 0 +end + +function Gen.setCoins(save, amount) + if Gen.of(save) == 2 then + save.player = save.player or {} + save.player.coins = amount + else + save.coins = amount + end +end + +function Gen.dexOwnedKey(save) + if Gen.of(save) == 2 then return "caught" end + return "owned" +end + +function Gen.playerMap(save) + if Gen.of(save) == 2 then + local p = save.position + if p and p.map then return p.map, p.x or 0, p.y or 0, p.facing end + return save.spawn, 0, 0 + end + local p = save.player or {} + return p.map, p.x or 0, p.y or 0 +end + +function Gen.setPlayerHere(save, mapId, x, y, facing) + if Gen.of(save) == 2 then + local prev = save.position or {} + save.position = { + map = mapId, + x = x, + y = y, + facing = facing or prev.facing or "down", + } + return + end + save.player = save.player or {} + save.player.map = mapId + save.player.x = x + save.player.y = y +end + +local JOHTO = { + "ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING", +} +local KANTO = { + "BOULDER", "CASCADE", "THUNDER", "RAINBOW", + "SOUL", "MARSH", "VOLCANO", "EARTH", +} + +function Gen.badgeIds(save, cat) + if Gen.of(save) == 2 then + local ids = {} + for _, name in ipairs(JOHTO) do ids[#ids + 1] = name end + for _, name in ipairs(KANTO) do ids[#ids + 1] = name end + return ids + end + local ids = {} + for _, id in ipairs((cat and cat.items) or {}) do + if tostring(id):find("BADGE", 1, true) then ids[#ids + 1] = id end + end + return ids +end + +local KANTO_SET = {} +for _, name in ipairs(KANTO) do KANTO_SET[name] = true end + +function Gen.hasBadge(save, id) + if Gen.of(save) == 2 then + local p = save.player or {} + local store = KANTO_SET[id] and (p.kantoBadges or {}) or (p.badges or {}) + if store[id] then return true end + local list = KANTO_SET[id] and KANTO or JOHTO + for index, name in ipairs(list) do + if name == id then return store[index] == true end + end + return false + end + return save.inventory and save.inventory[id] and true or false +end + +function Gen.toggleBadge(save, id) + if Gen.of(save) == 2 then + save.player = save.player or {} + local storeName = KANTO_SET[id] and "kantoBadges" or "badges" + save.player[storeName] = save.player[storeName] or {} + local store = save.player[storeName] + local on = Gen.hasBadge(save, id) + store[id] = (not on) and true or nil + for index, name in ipairs(KANTO_SET[id] and KANTO or JOHTO) do + if name == id then store[index] = nil end + end + return not on + end + local on = save.inventory[id] and true or false + save.inventory[id] = (not on) and 1 or nil + return not on +end + +local function goldFlagId(name) + local flags = require("src.core.gen2.FlagNames") + return flags.events and flags.events[name] +end + +function Gen.getFlag(save, name) + if Gen.of(save) == 2 then + local id = goldFlagId(name) + if id then + local Events2 = require("src.world.gen2.Events") + local ev = Events2.new() + ev:restore(save.events) + return ev:get(id) + end + return save.flags and save.flags[name] == true + end + return save.flags and save.flags[name] == true +end + +function Gen.setFlag(save, name, on) + if Gen.of(save) == 2 then + local id = goldFlagId(name) + if id then + local Events2 = require("src.world.gen2.Events") + local ev = Events2.new() + ev:restore(save.events) + ev:set(id, on and true or false) + save.events = ev:serialize() + return + end + save.flags = save.flags or {} + save.flags[name] = on and true or nil + return + end + save.flags = save.flags or {} + save.flags[name] = on and true or nil +end + +function Gen.flagCount(save) + if Gen.of(save) == 2 then + local n = 0 + for _ in pairs(save.events or {}) do n = n + 1 end + for _ in pairs(save.flags or {}) do n = n + 1 end + return n + end + local n = 0 + for _ in pairs(save.flags or {}) do n = n + 1 end + return n +end + +function Gen.exp(mon) + return mon.experience or mon.exp or 0 +end + +return Gen diff --git a/tools/save-editor/MonOps.lua b/tools/save-editor/MonOps.lua index 93630cb6..418c5f36 100644 --- a/tools/save-editor/MonOps.lua +++ b/tools/save-editor/MonOps.lua @@ -4,23 +4,40 @@ local Growth = require("src.pokemon.Growth") local MonOps = {} -function MonOps.create(data, species, level) +function MonOps.create(data, species, level, gen) + if gen == 2 then + local Mon = require("src.battle.gen2.Mon") + local mon = Mon.new(data, species, level) + assert(mon, "unknown species " .. tostring(species)) + return mon + end return Pokemon.new(data, species, level) end -function MonOps.recalc(data, mon) +function MonOps.recalc(data, mon, gen) + if gen == 2 or (mon.stats and mon.stats.specialAttack) or mon.experience then + require("src.battle.gen2.Mon").refreshStats(mon, data) + return + end local def = data.pokemon[mon.species] assert(def, "unknown species") mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) mon.hp = math.max(0, math.min(mon.hp or mon.stats.hp, mon.stats.hp)) end -function MonOps.setLevel(data, mon, level) +function MonOps.setLevel(data, mon, level, gen) level = math.max(1, math.min(100, math.floor(level))) local def = data.pokemon[mon.species] mon.level = level + if gen == 2 or mon.experience ~= nil then + local Mon = require("src.battle.gen2.Mon") + local growth = Mon.growthFor(data, def.growthRate) + mon.experience = Mon.experienceForLevel(growth, level) + Mon.refreshStats(mon, data) + return + end mon.exp = Growth.expForLevel(def.growthRate, level) - MonOps.recalc(data, mon) + MonOps.recalc(data, mon, gen) end function MonOps.setMove(data, mon, slot, moveId) @@ -32,6 +49,7 @@ function MonOps.setMove(data, mon, slot, moveId) id = moveId, pp = mdef.pp + ((mon.moves[slot] and mon.moves[slot].ppUps) or 0) * math.floor(mdef.pp / 5), ppUps = mon.moves[slot] and mon.moves[slot].ppUps or nil, + maxPp = mdef.pp, } end @@ -42,19 +60,38 @@ function MonOps.syncHpDv(dvs) return dvs end -function MonOps.setDv(data, mon, key, value) +function MonOps.setDv(data, mon, key, value, gen) mon.dvs[key] = math.max(0, math.min(15, math.floor(value))) if key ~= "hp" then MonOps.syncHpDv(mon.dvs) end - MonOps.recalc(data, mon) + if gen == 2 or (mon.stats and mon.stats.specialAttack) then + local Mon = require("src.battle.gen2.Mon") + mon.dvs.hp = Mon.hpDV(mon.dvs) + local def = data.pokemon[mon.species] + if def then + mon.gender = Mon.gender(def, mon.dvs, { species = mon.species, level = mon.level }) + mon.shiny = Mon.isShiny(mon.dvs, { species = mon.species, def = def, level = mon.level }) + local Unown = require("src.core.gen2.Unown") + if mon.species == Unown.SPECIES then + mon.unownLetter = Unown.letterFromDVs(mon.dvs) + end + end + end + MonOps.recalc(data, mon, gen) end -- Keep level; resync exp to the species growth curve (species changes). -function MonOps.setSpecies(data, mon, species) +-- Gold also copies def.name onto mon.name: the party list and SUMMARY print +-- that field when nickname is nil, so leaving the previous species' name +-- made a swapped mon still read as ABRA (or whoever was added first). +function MonOps.setSpecies(data, mon, species, gen) assert(data.pokemon[species], "unknown species") mon.species = species - MonOps.setLevel(data, mon, mon.level) + MonOps.setLevel(data, mon, mon.level, gen) + if gen == 2 or mon.experience ~= nil or (mon.stats and mon.stats.specialAttack) then + require("src.battle.gen2.Mon").syncIdentity(mon, data) + end end return MonOps diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index 5da44907..36d13c64 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -17,6 +17,7 @@ local BoxesMod = require("src.pokemon.Boxes") local Bag = require("src.inventory.Bag") local MonOps = require("MonOps") local Charmap = require("src.save_convert.data.charmap") +local Gen = require("Gen") local Ops = {} @@ -35,6 +36,62 @@ local function clamp(n, lo, hi) end Ops.clamp = clamp +local function stampNewMon(S, mon) + if Gen.ofState(S) == 2 then + require("src.battle.gen2.Mon").stampOT(S.save, mon) + else + mon.ot = S.save.player.name + mon.otId = S.save.player.id + end + return mon +end + +local function createMon(S, species, level) + local mon = MonOps.create(S.data, species, level, Gen.ofState(S)) + return stampNewMon(S, mon) +end + +local function partySlot(S, mon) + for i, member in ipairs(S.save.party or {}) do + if member == mon then return i end + end +end + +-- Portrait mail (and CheckPokeMail) store species on the letter, not the mon. +local function partyMailEntry(S, slot) + local Mail = require("src.core.gen2.Mail") + return Mail.state(S.save).party[slot] +end + +local function syncPartyMailSpecies(S, mon) + if Gen.ofState(S) ~= 2 then return end + local slot = partySlot(S, mon) + if not slot then return end + local entry = partyMailEntry(S, slot) + if entry then entry.species = mon.species end +end + +local function syncPartyMailHeldItem(S, mon, prevItem, newItem) + if Gen.ofState(S) ~= 2 then return end + local slot = partySlot(S, mon) + if not slot then return end + local Mail = require("src.core.gen2.Mail") + if Mail.isMail(newItem) then + local prev = partyMailEntry(S, slot) + local player = S.save.player or {} + Mail.set(S.save, slot, Mail.entry( + newItem, + prev and prev.message or "", + tostring(mon.otName or mon.ot or player.name or ""):sub(1, Mail.AUTHOR_LENGTH), + mon.otId or player.id or 0, + mon.species)) + return + end + if Mail.isMail(prevItem) or partyMailEntry(S, slot) then + Mail.clear(S.save, slot) + end +end + local function now() if love and love.timer and love.timer.getTime then return love.timer.getTime() @@ -111,9 +168,7 @@ function Ops.partyAdd(S) return Ops.say(S, ("Party is full (%d/%d)"):format(#S.save.party, PartyMod.MAX)) end local species = S.cat.species[1] - local mon = MonOps.create(S.data, species, 5) - mon.ot = S.save.player.name - mon.otId = S.save.player.id + local mon = createMon(S, species, 5) table.insert(S.save.party, mon) S.selectedParty = #S.save.party S.editingMon = mon @@ -129,6 +184,11 @@ function Ops.partyRemove(S) return false end table.remove(S.save.party, index) + -- sPartyMail is keyed by party slot, not by mon: dropping a member without + -- shifting letters hands the next mon someone else's mail. + if Gen.ofState(S) == 2 then + require("src.core.gen2.Mail").removeSlot(S.save, index) + end if S.editingMon == mon then S.editingMon = nil end S.selectedParty = clamp(index, 1, math.max(#S.save.party, 1)) S.editingMon = S.save.party[S.selectedParty] @@ -144,6 +204,9 @@ function Ops.partyMove(S, delta) return Ops.say(S, delta < 0 and "Already the lead mon" or "Already the last mon") end party[i], party[j] = party[j], party[i] + if Gen.ofState(S) == 2 then + require("src.core.gen2.Mail").swapSlots(S.save, i, j) + end S.selectedParty = j return Ops.mark(S, ("Moved %s to slot %d"):format(party[j].species, j)) end @@ -157,7 +220,7 @@ function Ops.setLevel(S, mon, level) if want == mon.level then return Ops.say(S, want == 1 and "Level is already 1" or "Level is already 100") end - MonOps.setLevel(S.data, mon, want) + MonOps.setLevel(S.data, mon, want, Gen.ofState(S)) return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level)) end @@ -172,15 +235,23 @@ end -- instead of trusting the list: without this, picking such a species walked -- Stats.calc into `speciesDef.baseStats[key]` on a nil and took the window -- down (#541). -local BASE_STAT_KEYS = { "hp", "attack", "defense", "speed", "special" } +local BASE_STAT_KEYS_G1 = { "hp", "attack", "defense", "speed", "special" } +local BASE_STAT_KEYS_G2 = { + "hp", "attack", "defense", "speed", "specialAttack", "specialDefense", +} + +local function baseStatsComplete(bs, keys) + for _, key in ipairs(keys) do + if type(bs[key]) ~= "number" then return false end + end + return true +end function Ops.speciesUsable(S, id) local def = id and S.data.pokemon[id] if type(def) ~= "table" or type(def.baseStats) ~= "table" then return false end - for _, key in ipairs(BASE_STAT_KEYS) do - if type(def.baseStats[key]) ~= "number" then return false end - end - return true + return baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G1) + or baseStatsComplete(def.baseStats, BASE_STAT_KEYS_G2) end -- The one funnel every species change goes through (the picker, the stepper, @@ -199,14 +270,19 @@ function Ops.setSpecies(S, mon, id) end -- MonOps.recalc replaces mon.stats with a fresh table rather than editing -- it in place, so holding the old reference is a real rollback. - local wasSpecies, wasLevel, wasExp = mon.species, mon.level, mon.exp - local wasStats, wasHp = mon.stats, mon.hp - local ok, err = pcall(MonOps.setSpecies, S.data, mon, id) + local wasSpecies, wasLevel, wasExp, wasExperience = mon.species, mon.level, mon.exp, mon.experience + local wasStats, wasHp, wasName = mon.stats, mon.hp, mon.name + local wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp = + mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp + local ok, err = pcall(MonOps.setSpecies, S.data, mon, id, Gen.ofState(S)) if not ok then - mon.species, mon.level, mon.exp = wasSpecies, wasLevel, wasExp - mon.stats, mon.hp = wasStats, wasHp + mon.species, mon.level, mon.exp, mon.experience = wasSpecies, wasLevel, wasExp, wasExperience + mon.stats, mon.hp, mon.name = wasStats, wasHp, wasName + mon.types, mon.gender, mon.shiny, mon.unownLetter, mon.maxHp = + wasTypes, wasGender, wasShiny, wasUnown, wasMaxHp return Ops.say(S, ("Could not set %s: %s"):format(tostring(id), tostring(err))) end + syncPartyMailSpecies(S, mon) return Ops.mark(S, ("Species set to %s"):format(id)) end @@ -313,9 +389,9 @@ end -- the target is the box, not a mon. function Ops.openBoxAddPicker(S, Kit) local box = Ops.boxes(S)[S.selectedBox] - if #box >= BoxesMod.CAPACITY then + if #box >= Ops.boxCapacity(S) then return Ops.say(S, ("Box %d is full (%d/%d)") - :format(S.selectedBox, #box, BoxesMod.CAPACITY)) + :format(S.selectedBox, #box, Ops.boxCapacity(S))) end S.speciesPicker = { query = "", offset = 0, opened = true, mode = "box-add" } if Kit then Kit.focus = "species-picker" end -- soft keyboard rises (#529) @@ -327,17 +403,15 @@ end -- box mon and a party mon born in the editor are indistinguishable. function Ops.boxAddSpecies(S, id) local box = Ops.boxes(S)[S.selectedBox] - if #box >= BoxesMod.CAPACITY then + if #box >= Ops.boxCapacity(S) then return Ops.say(S, ("Box %d is full (%d/%d)") - :format(S.selectedBox, #box, BoxesMod.CAPACITY)) + :format(S.selectedBox, #box, Ops.boxCapacity(S))) end if not Ops.speciesUsable(S, id) then return Ops.say(S, ("%s has no usable base stats, cannot add it") :format(tostring(id))) end - local mon = MonOps.create(S.data, id, 5) - mon.ot = S.save.player.name - mon.otId = S.save.player.id + local mon = createMon(S, id, 5) table.insert(box, mon) S.selectedBoxSlot = #box S.editingMon = mon @@ -351,7 +425,7 @@ function Ops.setDv(S, mon, key, value) if want == mon.dvs[key] then return Ops.say(S, ("%s DV is already %d"):format(key, want)) end - MonOps.setDv(S.data, mon, key, want) + MonOps.setDv(S.data, mon, key, want, Gen.ofState(S)) return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp)) end @@ -382,7 +456,17 @@ end function Ops.resetMoves(S, mon) if not mon then return false end local def = S.data.pokemon[mon.species] - local learned = Pokemon.movesAtLevel(def, mon.level) + local gen = Gen.ofState(S) + local learned + if gen == 2 then + local Mon = require("src.battle.gen2.Mon") + learned = {} + for _, mv in ipairs(Mon.movesAtLevel(def, mon.level, S.data.moves)) do + learned[#learned + 1] = mv.id + end + else + learned = Pokemon.movesAtLevel(def, mon.level) + end mon.moves = {} for slot, id in ipairs(learned) do MonOps.setMove(S.data, mon, slot, id) @@ -397,6 +481,7 @@ function Ops.healMon(S, mon) return Ops.say(S, ("%s is already at full HP"):format(mon.species)) end mon.hp = mon.stats.hp + if mon.maxHp then mon.maxHp = mon.stats.hp end mon.status = nil for _, mv in ipairs(mon.moves or {}) do local def = S.data.moves[mv.id] @@ -554,27 +639,35 @@ function Ops.clearNickname(S, mon) end -- ------------------------------------------------------------------ boxes +function Ops.boxCount(S) + return Gen.boxCount(S.save) +end + +function Ops.boxCapacity(S) + return Gen.boxCapacity(S.save) +end + function Ops.boxes(S) - return BoxesMod.ensure(S.save) + return Gen.ensureBoxes(S.save) end function Ops.selectBox(S, index) - S.selectedBox = clamp(index, 1, BoxesMod.COUNT) + S.selectedBox = clamp(index, 1, Ops.boxCount(S)) S.selectedBoxSlot = 1 S.save.currentBox = S.selectedBox local box = Ops.boxes(S)[S.selectedBox] - S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, BoxesMod.CAPACITY) + S.status = ("Box %d (%d/%d)"):format(S.selectedBox, #box, Ops.boxCapacity(S)) return true end function Ops.stepBox(S, delta) - local n = BoxesMod.COUNT + local n = Ops.boxCount(S) return Ops.selectBox(S, ((S.selectedBox - 1 + delta) % n) + 1) end function Ops.selectBoxSlot(S, index) local box = Ops.boxes(S)[S.selectedBox] - S.selectedBoxSlot = clamp(index, 1, BoxesMod.CAPACITY) + S.selectedBoxSlot = clamp(index, 1, Ops.boxCapacity(S)) local mon = box[S.selectedBoxSlot] S.editingMon = mon S.status = mon @@ -589,14 +682,12 @@ end -- chooses what lands in the box instead of always getting catalog entry #1. function Ops.boxAdd(S) local box = Ops.boxes(S)[S.selectedBox] - if #box >= BoxesMod.CAPACITY then + if #box >= Ops.boxCapacity(S) then return Ops.say(S, ("Box %d is full (%d/%d)") - :format(S.selectedBox, #box, BoxesMod.CAPACITY)) + :format(S.selectedBox, #box, Ops.boxCapacity(S))) end local species = S.cat.species[1] - local mon = MonOps.create(S.data, species, 5) - mon.ot = S.save.player.name - mon.otId = S.save.player.id + local mon = createMon(S, species, 5) table.insert(box, mon) S.selectedBoxSlot = #box S.editingMon = mon @@ -612,9 +703,16 @@ function Ops.withdraw(S) return Ops.say(S, ("Party is full (%d/%d), deposit one first") :format(#S.save.party, PartyMod.MAX)) end - table.remove(box, S.selectedBoxSlot) - table.insert(S.save.party, mon) - S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#box, 1)) + if Gen.ofState(S) == 2 then + local Boxes2 = require("src.core.gen2.Boxes") + local ok, reason = Boxes2.canWithdraw(S.save, S.selectedBox, S.selectedBoxSlot) + if not ok then return Ops.say(S, reason) end + Boxes2.withdraw(S.save, S.selectedBox, S.selectedBoxSlot) + else + table.remove(box, S.selectedBoxSlot) + table.insert(S.save.party, mon) + end + S.selectedBoxSlot = clamp(S.selectedBoxSlot, 1, math.max(#Ops.boxes(S)[S.selectedBox], 1)) S.selectedParty = #S.save.party return Ops.mark(S, ("Withdrew %s to party slot %d"):format(mon.species, #S.save.party)) end @@ -639,6 +737,17 @@ function Ops.deposit(S) local i = S.selectedParty local mon = S.save.party[i] if not mon then return Ops.say(S, "No party slot selected") end + if Gen.ofState(S) == 2 then + local Boxes2 = require("src.core.gen2.Boxes") + local boxIndex = S.selectedBox or S.save.currentBox or 1 + local ok, reason = Boxes2.canDeposit(S.save, i, boxIndex) + if not ok then return Ops.say(S, reason) end + Boxes2.deposit(S.save, i, boxIndex) + S.selectedParty = clamp(i, 1, math.max(#S.save.party, 1)) + S.selectedBox = boxIndex + if S.editingMon == mon then S.editingMon = nil end + return Ops.mark(S, ("Deposited %s into box %d"):format(mon.species, boxIndex)) + end local boxNum = BoxesMod.deposit(S.save, mon) if not boxNum then return Ops.say(S, "Every box is full, release something first") @@ -652,12 +761,13 @@ end -- ------------------------------------------------------------------ items function Ops.addMoney(S, delta) - local want = clamp((S.save.money or 0) + delta, 0, Ops.MONEY_MAX) - if want == S.save.money then + local have = Gen.money(S.save) + local want = clamp(have + delta, 0, Ops.MONEY_MAX) + if want == have then return Ops.say(S, delta < 0 and "Money is already $0" or ("Money is already capped at $%d"):format(Ops.MONEY_MAX)) end - S.save.money = want + Gen.setMoney(S.save, want) return Ops.mark(S, ("Money set to $%d"):format(want)) end @@ -665,15 +775,29 @@ function Ops.maxMoney(S) return Ops.addMoney(S, Ops.MONEY_MAX) end +Ops.COIN_MAX = 9999 + +function Ops.addCoins(S, delta) + local have = Gen.coins(S.save) + local want = clamp(have + delta, 0, Ops.COIN_MAX) + if want == have then + return Ops.say(S, delta < 0 and "Coins are already 0" + or ("Coins are already capped at %d"):format(Ops.COIN_MAX)) + end + Gen.setCoins(S.save, want) + return Ops.mark(S, ("Coins set to %d"):format(want)) +end + function Ops.addToBag(S, id) if not id then return Ops.say(S, "Pick an item first") end - local capacity = Bag.capacity(S.data) + local pocket = Bag.pocketOf(id, S.data) + local capacity = Bag.capacity(S.data, pocket) if Bag.add(S.save, id, 1, S.data) then - return Ops.mark(S, ("Added %s to the bag (%d/%d slots)") - :format(id, Bag.slots(S.save), capacity)) + return Ops.mark(S, ("Added %s to the bag (%d/%d %s slots)") + :format(id, Bag.slots(S.save, S.data, pocket), capacity, pocket)) end - return Ops.say(S, ("Bag is full (%d/%d slots)") - :format(Bag.slots(S.save), capacity)) + return Ops.say(S, ("Bag is full (%d/%d %s slots)") + :format(Bag.slots(S.save, S.data, pocket), capacity, pocket)) end function Ops.bagAdjust(S, id, delta) @@ -715,6 +839,12 @@ end function Ops.addToPc(S, id) if not id then return Ops.say(S, "Pick an item first") end local pc = Ops.pcItems(S) + local n = 0 + for _ in pairs(pc) do n = n + 1 end + if not pc[id] and Gen.ofState(S) == 2 and n >= 50 then + return Ops.say(S, "PC item storage is full (50 stacks)") + end + local pc = Ops.pcItems(S) pc[id] = math.min(Ops.STACK_MAX, (pc[id] or 0) + 1) return Ops.mark(S, ("%s x%d in PC storage"):format(id, pc[id])) end @@ -749,27 +879,17 @@ function Ops.isBadgeId(id) end function Ops.badgeIds(S) - local ids = {} - for _, id in ipairs(S.cat.items) do - if Ops.isBadgeId(id) then ids[#ids + 1] = id end - end - return ids + return Gen.badgeIds(S.save, S.cat) end function Ops.toggleBadge(S, id) - -- #515: badges are truthy inventory entries written as 1 by the in-game - -- grant (checkVictoryRewards, src/world/OverworldController.lua) and by - -- GenSave's .sav import; read and write that same shape here, or a badge - -- earned in game reads as unowned and an editor-written boolean blows up - -- Bag.add's `(inv[id] or 0) + qty` (src/inventory/Bag.lua). - local on = S.save.inventory[id] and true or false - S.save.inventory[id] = (not on) and 1 or nil - return Ops.mark(S, ("%s %s"):format(id, on and "removed" or "earned")) + local nowOn = Gen.toggleBadge(S.save, id) + return Ops.mark(S, ("%s %s"):format(id, nowOn and "earned" or "removed")) end -- ----------------------------------------------------------------- events function Ops.setFlag(S, name, on) - S.save.flags[name] = on and true or nil + Gen.setFlag(S.save, name, on) return Ops.mark(S, ("%s = %s"):format(name, tostring(on and true or false))) end @@ -801,17 +921,19 @@ end -- -------------------------------------------------------------------- dex function Ops.dex(S) - S.save.pokedex = S.save.pokedex or { seen = {}, owned = {} } + local key = Gen.dexOwnedKey(S.save) + S.save.pokedex = S.save.pokedex or { seen = {}, [key] = {} } S.save.pokedex.seen = S.save.pokedex.seen or {} - S.save.pokedex.owned = S.save.pokedex.owned or {} + S.save.pokedex[key] = S.save.pokedex[key] or {} return S.save.pokedex end function Ops.dexCounts(S) local dex = Ops.dex(S) + local key = Gen.dexOwnedKey(S.save) local seen, owned = 0, 0 for _ in pairs(dex.seen) do seen = seen + 1 end - for _ in pairs(dex.owned) do owned = owned + 1 end + for _ in pairs(dex[key] or {}) do owned = owned + 1 end return seen, owned, #S.cat.species end @@ -819,25 +941,28 @@ end -- the game's own rule, enforced here so a hand-edited dex stays legal. function Ops.dexSeen(S, species, on) local dex = Ops.dex(S) + local key = Gen.dexOwnedKey(S.save) dex.seen[species] = on and true or nil - if not on then dex.owned[species] = nil end + if not on then dex[key][species] = nil end return Ops.mark(S, ("%s %s"):format(species, on and "marked seen" or "cleared")) end function Ops.dexOwned(S, species, on) local dex = Ops.dex(S) - dex.owned[species] = on and true or nil + local key = Gen.dexOwnedKey(S.save) + dex[key][species] = on and true or nil if on then dex.seen[species] = true end return Ops.mark(S, ("%s %s"):format(species, on and "marked owned" or "un-owned")) end function Ops.dexStamp(S) local dex = Ops.dex(S) + local key = Gen.dexOwnedKey(S.save) local n = 0 local function stamp(mon) - if not dex.owned[mon.species] then n = n + 1 end + if not dex[key][mon.species] then n = n + 1 end dex.seen[mon.species] = true - dex.owned[mon.species] = true + dex[key][mon.species] = true end for _, m in ipairs(S.save.party) do stamp(m) end for _, box in ipairs(S.save.boxes or {}) do @@ -855,9 +980,10 @@ end function Ops.dexOwnAll(S) local dex = Ops.dex(S) + local key = Gen.dexOwnedKey(S.save) for _, species in ipairs(S.cat.species) do dex.seen[species] = true - dex.owned[species] = true + dex[key][species] = true end return Ops.mark(S, ("Marked all %d species owned"):format(#S.cat.species)) end @@ -866,7 +992,8 @@ function Ops.dexClear(S) if not Ops.arm(S, "dex-clear", "Wipe the whole Pokedex? Click again to confirm") then return false end - S.save.pokedex = { seen = {}, owned = {} } + local key = Gen.dexOwnedKey(S.save) + S.save.pokedex = { seen = {}, [key] = {} } return Ops.mark(S, "Pokedex wiped") end @@ -927,6 +1054,11 @@ end -- OVERWORLD/PLATEAU tilesets, maps with connections, or fly spots the save -- has already visited. function Ops.isOutdoor(S, map) + if not map or not map.def then return false end + local Map2 = require("src.world.gen2.Map") + if Gen.ofState(S) == 2 and Map2.isOutdoor then + return Map2.isOutdoor(map.def) and true or false + end if map.def.tileset == "OVERWORLD" or map.def.tileset == "PLATEAU" then return true end @@ -937,15 +1069,17 @@ end function Ops.setPlayerHere(S) local cell = S.mapClickCell if not cell then return Ops.say(S, "Click a cell first") end - S.save.player.map = S.mapId - S.save.player.x = cell.cx - S.save.player.y = cell.cy + Gen.setPlayerHere(S.save, S.mapId, cell.cx, cell.cy) return Ops.mark(S, ("Player set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) end function Ops.setLastOutdoor(S, map) local cell = S.mapClickCell if not cell then return Ops.say(S, "Click a cell first") end + if Gen.ofState(S) == 2 then + S.save.spawn = S.mapId + return Ops.mark(S, ("spawn set to %s"):format(S.mapId)) + end if not Ops.isOutdoor(S, map) then return Ops.say(S, S.mapId .. " doesn't look outdoor (no connections, not visited)") end @@ -956,8 +1090,50 @@ end function Ops.setLastHeal(S) local cell = S.mapClickCell if not cell then return Ops.say(S, "Click a cell first") end + if Gen.ofState(S) == 2 then + S.save.spawn = S.mapId + return Ops.mark(S, ("spawn set to %s"):format(S.mapId)) + end S.save.lastHeal = { map = S.mapId, x = cell.cx, y = cell.cy } return Ops.mark(S, ("lastHeal set to %s (%d,%d)"):format(S.mapId, cell.cx, cell.cy)) end +function Ops.setHeldItem(S, mon, id) + if not mon then return false end + if id == "" or id == nil then + if not mon.item then return Ops.say(S, "No held item to clear") end + local was = mon.item + mon.item = nil + syncPartyMailHeldItem(S, mon, was, nil) + return Ops.mark(S, ("Cleared held item (%s)"):format(was)) + end + if not S.data.items[id] then + return Ops.say(S, ("%s is not an item"):format(tostring(id))) + end + local was = mon.item + mon.item = id + syncPartyMailHeldItem(S, mon, was, id) + return Ops.mark(S, ("%s now holds %s"):format(mon.species, id)) +end + +function Ops.setHappiness(S, mon, value) + if not mon then return false end + local want = clamp(math.floor(value), 0, 255) + if want == (mon.happiness or 0) then + return Ops.say(S, ("Happiness is already %d"):format(want)) + end + mon.happiness = want + return Ops.mark(S, ("%s happiness %d"):format(mon.species, want)) +end + +function Ops.setPokerus(S, mon, value) + if not mon then return false end + local want = clamp(math.floor(value), 0, 255) + if want == (mon.pokerus or 0) then + return Ops.say(S, ("Pokerus is already %d"):format(want)) + end + mon.pokerus = want + return Ops.mark(S, ("%s pokerus byte %d"):format(mon.species, want)) +end + return Ops diff --git a/tools/save-editor/panels/Boxes.lua b/tools/save-editor/panels/Boxes.lua index e3975f9e..dddd7510 100644 --- a/tools/save-editor/panels/Boxes.lua +++ b/tools/save-editor/panels/Boxes.lua @@ -19,7 +19,6 @@ -- in the selected box as a Lv5 mon built by the same MonOps path partyAdd -- uses, so its stats, exp and moves are consistent. -local BoxesMod = require("src.pokemon.Boxes") local PartyMod = require("src.pokemon.Party") local Theme = require("Theme") local Ops = require("Ops") @@ -33,12 +32,12 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h) local s = Kit.scale local pad = 16 * s Kit.card(x, y, stripW, h) - Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(BoxesMod.COUNT)) + Kit.caption(x + pad, y + pad, ("BOXES . %d"):format(Ops.boxCount(S))) local stripTop = y + pad + Kit.textHeight("caption") + 10 * s local stripInner = stripW - 2 * pad local bRowH = math.min(30 * s, math.max(22 * s, - (h - (stripTop - y) - pad - (BoxesMod.COUNT - 1) * 6 * s) / BoxesMod.COUNT)) - for i = 1, BoxesMod.COUNT do + (h - (stripTop - y) - pad - (Ops.boxCount(S) - 1) * 6 * s) / Ops.boxCount(S))) + for i = 1, Ops.boxCount(S) do local ry = stripTop + (i - 1) * (bRowH + 6 * s) if ry + bRowH > y + h - pad then break end if Kit.row(x + pad, ry, stripInner, bRowH, i == S.selectedBox, PAL.blue, 9 * s) then @@ -52,7 +51,7 @@ local function drawStrip(S, Kit, boxes, x, y, stripW, h) ry + (bRowH - Kit.textHeight("tiny")) / 2, PAL.caption) local mx = x + pad + stripInner - 10 * s - countW - 8 * s - 44 * s Kit.meter(mx, ry + (bRowH - 5 * s) / 2, 44 * s, 5 * s, - fill / BoxesMod.CAPACITY * 100, fill >= BoxesMod.CAPACITY and PAL.yellow or PAL.blue) + fill / Ops.boxCapacity(S) * 100, fill >= Ops.boxCapacity(S) and PAL.yellow or PAL.blue) end end @@ -66,7 +65,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h) Kit.text("tab", ("Box %d"):format(S.selectedBox), gx, y + gpad + (headH - Kit.textHeight("tab")) / 2, PAL.heading) local titleW = Kit.textWidth("tab", ("Box %d"):format(S.selectedBox)) - Kit.text("mono", ("%d/%d"):format(#box, BoxesMod.CAPACITY), + Kit.text("mono", ("%d/%d"):format(#box, Ops.boxCapacity(S)), gx + titleW + 14 * s, y + gpad + (headH - Kit.textHeight("mono")) / 2, PAL.caption) local navW = 34 * s if Kit.stepper(gx + ginner - 2 * navW - 8 * s, y + gpad, navW, headH, "<", @@ -102,7 +101,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h) end if Kit.button(gx + wdW + 10 * s, actY, addW, actH, addLabel, { font = "small", radius = 9 * s, - enabled = #box < BoxesMod.CAPACITY }) then + enabled = #box < Ops.boxCapacity(S) }) then Ops.openBoxAddPicker(S, Kit) end if Kit.button(gx + ginner - relW, actY, relW, actH, relLabel, @@ -119,7 +118,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h) -- of five slivers. local cols = math.max(2, math.min(COLS, math.floor((ginner + cellGap) / (86 * s + cellGap)))) - local rows = math.ceil(BoxesMod.CAPACITY / cols) + local rows = math.ceil(Ops.boxCapacity(S) / cols) local cellW = math.max(0, (ginner - cellGap * (cols - 1)) / cols) -- floor at Kit's 26px tap target so a short window shrinks the cells but -- never inverts them (#715); overflow clips inside the grid body rather @@ -128,7 +127,7 @@ local function drawGrid(S, Kit, box, gridX, y, gridW, h) math.min((gridH - cellGap * (rows - 1)) / rows, 110 * s)) Kit.pushClip(gx, gridTop, ginner, gridH) - for i = 1, BoxesMod.CAPACITY do + for i = 1, Ops.boxCapacity(S) do local cc = (i - 1) % cols local cr = math.floor((i - 1) / cols) local bx = gx + cc * (cellW + cellGap) @@ -220,7 +219,7 @@ function M.draw(S, Kit, x, y, w, h) local s = Kit.scale local gap = 20 * s - S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, BoxesMod.COUNT) + S.selectedBox = Ops.clamp(S.selectedBox or 1, 1, Ops.boxCount(S)) S.save.currentBox = S.selectedBox local boxes = Ops.boxes(S) local box = boxes[S.selectedBox] diff --git a/tools/save-editor/panels/Dex.lua b/tools/save-editor/panels/Dex.lua index d7929485..f22c30ee 100644 --- a/tools/save-editor/panels/Dex.lua +++ b/tools/save-editor/panels/Dex.lua @@ -145,7 +145,8 @@ function M.draw(S, Kit, x, y, w, h) local rx = cx + ci * (colW + colGap) local ry = gridTop + ri * (rowH + rowGap) local isSeen = dex.seen[id] == true - local isOwned = dex.owned[id] == true + local ownedKey = require("Gen").dexOwnedKey(S.save) + local isOwned = dex[ownedKey] and dex[ownedKey][id] == true Theme.row(rx, ry, colW, rowH, 9 * s, 0.6) local def = S.data.pokemon[id] diff --git a/tools/save-editor/panels/Events.lua b/tools/save-editor/panels/Events.lua index 70b075f7..a0f26010 100644 --- a/tools/save-editor/panels/Events.lua +++ b/tools/save-editor/panels/Events.lua @@ -9,6 +9,7 @@ local Theme = require("Theme") local Ops = require("Ops") +local Gen = require("Gen") local PAL = Theme.PAL local M = {} @@ -50,7 +51,7 @@ local function buildRows(S) if contains(name, filter) then rows[#rows + 1] = { label = name, - checked = S.save.flags[name] == true, + checked = Gen.getFlag(S.save, name), set = function(on) Ops.setFlag(S, name, on) end, } end @@ -107,7 +108,12 @@ function M.draw(S, Kit, x, y, w, h) -- past the card edge. local pillH = 32 * s local px, py = cx, y + pad - for _, t in ipairs(SUB_TABS) do + local pills = SUB_TABS + if Gen.of(S.save) == 2 then + pills = { SUB_TABS[1] } + if S.eventsTab ~= "flags" then S.eventsTab = "flags" end + end + for _, t in ipairs(pills) do local pw = Kit.textWidth("small", t.label) + 32 * s if px > cx and px + pw > cx + inner then px = cx diff --git a/tools/save-editor/panels/Items.lua b/tools/save-editor/panels/Items.lua index 9b93d3a3..e43a8282 100644 --- a/tools/save-editor/panels/Items.lua +++ b/tools/save-editor/panels/Items.lua @@ -18,6 +18,7 @@ local Bag = require("src.inventory.Bag") local Theme = require("Theme") local Ops = require("Ops") +local Gen = require("Gen") local PAL = Theme.PAL local M = {} @@ -46,7 +47,11 @@ local function quantityRow(S, Kit, x, y, w, h, id, qty, selected, onMinus, onPlu local qtyW = Kit.textWidth("monoRow", qtyText) Kit.textRight("monoRow", qtyText, bx - 10 * s, y + (h - Kit.textHeight("monoRow")) / 2, PAL.heading) - Kit.text("mono", Kit.ellipsize("mono", id, bx - qtyW - 30 * s - (x + 10 * s)), + local label = id + if Gen.of(S.save) == 2 then + label = (Bag.pocketOf(id, S.data) or "ITEM") .. " " .. id + end + Kit.text("mono", Kit.ellipsize("mono", label, bx - qtyW - 30 * s - (x + 10 * s)), x + 10 * s, y + (h - Kit.textHeight("mono")) / 2, PAL.text) return clicked end @@ -55,9 +60,13 @@ end -- Each card is a function of its own rect so the wide (three column) and the -- stacked (#715) layouts are the same drawing code with different geometry. -local function moneyHeight(Kit, s, pad) - return pad * 2 + Kit.textHeight("caption") + 8 * s +local function moneyHeight(Kit, s, pad, S) + local h = pad * 2 + Kit.textHeight("caption") + 8 * s + Kit.textHeight("headline") + 10 * s + 30 * s + if S and Gen.of(S.save) == 2 then + h = h + 28 * s + end + return h end local function drawMoney(S, Kit, x, y, w, h) @@ -68,11 +77,19 @@ local function drawMoney(S, Kit, x, y, w, h) local maxW = 74 * s if Kit.button(x + w - pad - maxW, y + pad - 4 * s, maxW, 26 * s, "Max out", { kind = "accent", font = "tiny", radius = 7 * s, - enabled = (S.save.money or 0) < Ops.MONEY_MAX }) then + enabled = Gen.money(S.save) < Ops.MONEY_MAX }) then Ops.maxMoney(S) end - Kit.text("headline", ("$%d"):format(S.save.money or 0), x + pad, + Kit.text("headline", ("$%d"):format(Gen.money(S.save)), x + pad, y + pad + Kit.textHeight("caption") + 8 * s, PAL.yellow) + if Gen.of(S.save) == 2 then + Kit.text("mono", ("COINS %d"):format(Gen.coins(S.save)), x + pad + 160 * s, + y + pad + Kit.textHeight("caption") + 8 * s, PAL.muted) + if Kit.button(x + w - pad - 74 * s, y + pad + 26 * s, 74 * s, 22 * s, "+100 coins", + { kind = "ghost", font = "tiny", radius = 6 * s }) then + Ops.addCoins(S, 100) + end + end local mbY = y + h - pad - 30 * s local mbW = (w - 2 * pad - 3 * 8 * s) / 4 for i, delta in ipairs(MONEY_STEPS) do @@ -99,10 +116,7 @@ local function drawBadges(S, Kit, x, y, w, h) Kit.card(x, y, w, h) local earned = 0 for _, id in ipairs(badgeIds) do - -- #515: truthy check, not `== true` -- the in-game grant path stores a - -- number (see OverworldController.lua checkVictoryRewards), matching - -- src/inventory/Badges.lua's own truthy read. - if S.save.inventory[id] then earned = earned + 1 end + if Gen.hasBadge(S.save, id) then earned = earned + 1 end end Kit.caption(x + pad, y + pad, "BADGES") Kit.textRight("mono", ("%d/%d"):format(earned, #badgeIds), x + w - pad, @@ -112,7 +126,7 @@ local function drawBadges(S, Kit, x, y, w, h) for i, id in ipairs(badgeIds) do local bc = (i - 1) % BADGE_COLS local br = math.floor((i - 1) / BADGE_COLS) - local on = S.save.inventory[id] + local on = Gen.hasBadge(S.save, id) local short = id:gsub("BADGE$", "") if Kit.chip(x + pad + bc * (bW + 7 * s), bTop + br * (28 * s + 7 * s), bW, 28 * s, Kit.ellipsize("micro", short, bW - 8 * s), on, @@ -253,7 +267,7 @@ function M.draw(S, Kit, x, y, w, h) -- drag over their own bodies first. local off = Theme.clamp(S.itemsScroll or 0, 0, math.max(0, (S._itemsContentH or 0) - h)) - local moneyH = moneyHeight(Kit, s, pad) + local moneyH = moneyHeight(Kit, s, pad, S) local badgeH = badgeHeight(S, Kit, s, pad) local pickH = 280 * s local listH = 300 * s @@ -278,7 +292,7 @@ function M.draw(S, Kit, x, y, w, h) -- Money and badges are fixed-height so the picker gets every pixel left -- over: cycling through ~250 item ids in a two-row list was the thing that -- made the old panel unusable. - local moneyH = moneyHeight(Kit, s, pad) + local moneyH = moneyHeight(Kit, s, pad, S) local badgeH = badgeHeight(S, Kit, s, pad) drawMoney(S, Kit, x, y, leftW, moneyH) drawPicker(S, Kit, x, y + moneyH + gap, leftW, h - moneyH - badgeH - 2 * gap) diff --git a/tools/save-editor/panels/MapBrowser.lua b/tools/save-editor/panels/MapBrowser.lua index 24c02d91..dd18d4cd 100644 --- a/tools/save-editor/panels/MapBrowser.lua +++ b/tools/save-editor/panels/MapBrowser.lua @@ -14,12 +14,18 @@ local MapLoader = require("src.world.MapLoader") local Warp = require("src.world.Warp") local Theme = require("Theme") local Ops = require("Ops") +local Gen = require("Gen") local PAL = Theme.PAL local MapBrowser = {} local CELL = 16 -- the walk grid; a cell is 16px of map art +local function playerPos(S) + local map, x, y = Gen.playerMap(S.save) + return map, x or 0, y or 0 +end + local function clampZoom(z) if z < 1 then return 1 end if z > 4 then return 4 end @@ -38,7 +44,7 @@ MapBrowser.centerOn = centerOn local function sortedMapIds(data) local ids = {} - for id in pairs(data.maps) do ids[#ids + 1] = id end + for id in pairs(Gen.maps(data)) do ids[#ids + 1] = id end table.sort(ids) return ids end @@ -52,7 +58,19 @@ local OUTSIDE_TILESETS = { OVERWORLD = true, PLATEAU = true } local function goToWarp(S, warp) local def = warp.def - local fromMap = S.data.maps[S.mapId] + if Gen.of(S.save) == 2 then + local dest = def.destMap or def.map + if dest then + S.mapId = dest + S.mapClickCell = nil + S._mapCenteredFor = dest + S.status = "Followed warp to " .. tostring(dest) + else + S.status = "Warp has no destination map" + end + return + end + local fromMap = Gen.maps(S.data)[S.mapId] if fromMap and OUTSIDE_TILESETS[fromMap.tileset] and def.destMap ~= "LAST_MAP" and def.destMap ~= S.mapId then S.save.lastOutdoor = { id = S.mapId, x = def.x, y = def.y } @@ -125,22 +143,25 @@ local function drawOverlays(S, map) return cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL end love.graphics.setColor(0.27, 0.59, 1, 0.55) - for _, wdef in ipairs(map.def.warps) do + for _, wdef in ipairs(map.def.warps or {}) do love.graphics.rectangle("line", cellRect(wdef.x, wdef.y)) end - if S.save.player.map == S.mapId then + local playerMap, px, py = playerPos(S) + if playerMap == S.mapId then love.graphics.setColor(1, 0.36, 0.4, 0.9) - love.graphics.rectangle("fill", cellRect(S.save.player.x, S.save.player.y)) + love.graphics.rectangle("fill", cellRect(px, py)) end - local heal = S.save.lastHeal - if heal and heal.map == S.mapId then - love.graphics.setColor(0.24, 0.88, 0.54, 0.9) - love.graphics.rectangle("line", cellRect(heal.x, heal.y)) - end - local out = S.save.lastOutdoor - if out and out.id == S.mapId then - love.graphics.setColor(1, 0.8, 0.02, 0.9) - love.graphics.rectangle("line", cellRect(out.x, out.y)) + if Gen.of(S.save) ~= 2 then + local heal = S.save.lastHeal + if heal and heal.map == S.mapId then + love.graphics.setColor(0.24, 0.88, 0.54, 0.9) + love.graphics.rectangle("line", cellRect(heal.x, heal.y)) + end + local out = S.save.lastOutdoor + if out and out.id == S.mapId then + love.graphics.setColor(1, 0.8, 0.02, 0.9) + love.graphics.rectangle("line", cellRect(out.x, out.y)) + end end if S.mapClickCell then love.graphics.setColor(1, 1, 0.35, 0.95) @@ -239,9 +260,13 @@ function MapBrowser.draw(S, Kit, x, y, w, h) #ids, perPage) if Kit.button(lr.x + pad, gotoY, listInner, gotoH, "Go to save location", { font = "small", radius = 9 * s }) then - MapBrowser.select(S, S.save.player.map) - Ops.say(S, ("Jumped to %s (%d,%d)"):format(S.save.player.map, - S.save.player.x, S.save.player.y)) + local pmap, px, py = playerPos(S) + if pmap then + MapBrowser.select(S, pmap) + Ops.say(S, ("Jumped to %s (%d,%d)"):format(pmap, px, py)) + else + Ops.say(S, "No player location on this save") + end end -- ---------------------------------------------------------- the viewport @@ -253,7 +278,32 @@ function MapBrowser.draw(S, Kit, x, y, w, h) Kit.text("monoBig", tostring(S.mapId), vx0, vr.y + vpad + (headH - Kit.textHeight("monoBig")) / 2, PAL.heading) - local ok, map = pcall(MapLoader.load, S.data, S.mapId) + local ok, map + if Gen.of(S.save) == 2 then + local def = Gen.maps(S.data)[S.mapId] + if def then + local Map2 = require("src.world.gen2.Map") + if type(def.width) ~= "number" or type(def.height) ~= "number" then + ok, map = false, "incomplete map record (missing width/height)" + else + local tileset = Gen.tilesets(S.data)[def.tileset] + ok, map = pcall(Map2.new, def, tileset or {}) + if ok and map and not map.renderer then + local MapPreview = require("src.world.gen2.MapPreview") + S._g2MapBaker = S._g2MapBaker or MapPreview.baker({ + tilesets = Gen.tilesets(S.data), + gen2Roofs = S.data.gen2Roofs, roofs = S.data.roofs, + gen2Palettes = S.data.gen2Palettes, palettes = S.data.palettes, + }) + map.renderer = MapPreview.renderer(S._g2MapBaker, map) + end + end + else + ok, map = false, "unknown map" + end + else + ok, map = pcall(MapLoader.load, S.data, S.mapId) + end if not ok then Kit.text("mono", "Failed to load map: " .. tostring(map), vx0, vr.y + vpad + headH + 20 * s, PAL.red) @@ -279,12 +329,13 @@ function MapBrowser.draw(S, Kit, x, y, w, h) local zBtn = 32 * s local rightEdge = vx0 + vinner local zoomW = 2 * zBtn + 56 * s + 12 * s + local pmap, px, py = playerPos(S) local showCenter = vinner >= zoomW + 10 * s + centerW + 160 * s if showCenter then if Kit.button(rightEdge - centerW, vr.y + vpad, centerW, headH, "Center on player", { kind = "accent", font = "small", radius = 7 * s }) then - if S.save.player.map == S.mapId then - centerOn(S, S.save.player.x, S.save.player.y) + if pmap == S.mapId then + centerOn(S, px, py) Ops.say(S, "Centred on the player") else Ops.say(S, "Player isn't on this map") @@ -312,10 +363,11 @@ function MapBrowser.draw(S, Kit, x, y, w, h) -- the panel has laid itself out. if S._mapCenteredFor ~= S.mapId then S._mapCenteredFor = S.mapId - if S.save.player.map == S.mapId then - centerOn(S, S.save.player.x, S.save.player.y) + if pmap == S.mapId then + centerOn(S, px, py) else - centerOn(S, map.widthCells / 2, map.heightCells / 2) + centerOn(S, (map.widthCells or map.width or 10) / 2, + (map.heightCells or map.height or 10) / 2) end end @@ -334,7 +386,24 @@ function MapBrowser.draw(S, Kit, x, y, w, h) love.graphics.push() love.graphics.translate(vx0, vy0) love.graphics.scale(S.mapZoom, S.mapZoom) - map.renderer:draw(S.mapCamX, S.mapCamY) + if map.renderer and map.renderer.draw then + map.renderer:draw(S.mapCamX, S.mapCamY) + else + local wc = map.widthCells or ((map.width or 8) * 2) + local hc = map.heightCells or ((map.height or 8) * 2) + for cy = 0, hc - 1 do + for cx = 0, wc - 1 do + if (cx + cy) % 2 == 0 then + love.graphics.setColor(0.18, 0.22, 0.32, 1) + else + love.graphics.setColor(0.14, 0.17, 0.26, 1) + end + love.graphics.rectangle("fill", + cx * CELL - S.mapCamX, cy * CELL - S.mapCamY, CELL, CELL) + end + end + love.graphics.setColor(1, 1, 1, 1) + end drawOverlays(S, map) love.graphics.pop() love.graphics.setScissor() @@ -402,20 +471,31 @@ function MapBrowser.draw(S, Kit, x, y, w, h) Kit.caption(sx0 + pad, sr.y + pad, "SPAWN POINTS") local sTop = sr.y + pad + Kit.textHeight("caption") + 12 * s local sInner = sr.w - 2 * pad - local player = S.save.player - local out = S.save.lastOutdoor - local heal = S.save.lastHeal - local spawns = { - { key = "PLAYER", color = PAL.red, - value = ("%s (%d,%d)"):format(player.map, player.x, player.y), - set = function() Ops.setPlayerHere(S) end }, - { key = "LAST HEAL", color = PAL.green, - value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset", - set = function() Ops.setLastHeal(S) end }, - { key = "LAST OUTDOOR", color = PAL.yellow, - value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset", - set = function() Ops.setLastOutdoor(S, map) end }, - } + local pmap2, px2, py2 = playerPos(S) + local playerValue = pmap2 and ("%s (%d,%d)"):format(pmap2, px2, py2) or "unset" + local spawns + if Gen.of(S.save) == 2 then + spawns = { + { key = "PLAYER", color = PAL.red, value = playerValue, + set = function() Ops.setPlayerHere(S) end }, + { key = "SPAWN", color = PAL.green, + value = tostring(S.save.spawn or "SPAWN_HOME"), + set = function() Ops.setLastHeal(S) end }, + } + else + local out = S.save.lastOutdoor + local heal = S.save.lastHeal + spawns = { + { key = "PLAYER", color = PAL.red, value = playerValue, + set = function() Ops.setPlayerHere(S) end }, + { key = "LAST HEAL", color = PAL.green, + value = heal and ("%s (%d,%d)"):format(heal.map, heal.x, heal.y) or "unset", + set = function() Ops.setLastHeal(S) end }, + { key = "LAST OUTDOOR", color = PAL.yellow, + value = out and ("%s (%d,%d)"):format(out.id, out.x, out.y) or "unset", + set = function() Ops.setLastOutdoor(S, map) end }, + } + end local spawnH = 62 * s for i, sp in ipairs(spawns) do local ry = sTop + (i - 1) * (spawnH + 8 * s) @@ -431,7 +511,7 @@ function MapBrowser.draw(S, Kit, x, y, w, h) sx0 + pad + 12 * s, ry + spawnH - 10 * s - Kit.textHeight("mono"), PAL.muted) end - local noteY = sTop + 3 * (spawnH + 8 * s) + 6 * s + local noteY = sTop + #spawns * (spawnH + 8 * s) + 6 * s Kit.textCenter("tiny", "Click a cell first. Warp cells follow the warp instead of selecting. " .. "Arrow keys / WASD pan, the wheel zooms.", diff --git a/tools/save-editor/panels/MonEditor.lua b/tools/save-editor/panels/MonEditor.lua index 8ccdc13d..b9286048 100644 --- a/tools/save-editor/panels/MonEditor.lua +++ b/tools/save-editor/panels/MonEditor.lua @@ -19,17 +19,26 @@ local Theme = require("Theme") local Ops = require("Ops") local PAL = Theme.PAL +local Gen = require("Gen") local MonEditor = {} local DV_KEYS = { "attack", "defense", "speed", "special" } -local STAT_KEYS = { +local STAT_KEYS_G1 = { { key = "HP", field = "hp" }, { key = "ATK", field = "attack" }, { key = "DEF", field = "defense" }, { key = "SPD", field = "speed" }, { key = "SPC", field = "special" }, } +local STAT_KEYS_G2 = { + { key = "HP", field = "hp" }, + { key = "ATK", field = "attack" }, + { key = "DEF", field = "defense" }, + { key = "SPA", field = "specialAttack" }, + { key = "SPD", field = "specialDefense" }, + { key = "SPE", field = "speed" }, +} -- Front sprites are read straight off the generated cache. One image per -- species, cached for the process: the old panel called newImage every frame, @@ -96,7 +105,7 @@ local function drawLevelRow(S, Kit, mon, lx0, ly) end lx = lx + bw + 8 * s end - Kit.text("mono", ("EXP %d"):format(mon.exp or 0), lx + 6 * s, + Kit.text("mono", ("EXP %d"):format(Gen.exp(mon)), lx + 6 * s, ly + (lh - Kit.textHeight("mono")) / 2, PAL.muted) return lh end @@ -106,7 +115,7 @@ end local function levelRowWidth(Kit, mon) local s = Kit.scale return 52 * s + 2 * (40 * s + 8 * s) + 58 * s + 8 * s + 2 * (40 * s + 8 * s) - + 6 * s + Kit.textWidth("mono", ("EXP %d"):format(mon.exp or 0)) + + 6 * s + Kit.textWidth("mono", ("EXP %d"):format(Gen.exp(mon))) end local function drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap) @@ -179,7 +188,7 @@ function MonEditor.draw(S, Kit, x, y, w, h) local tw = math.min(w - 40 * s, 340 * s) Kit.textCenter("button", "Pick a slot on the left to inspect it. Every change here re-runs the " .. - "Gen1 stat formulas, so HP and stats stay legal.", + "stat formulas, so HP and stats stay legal.", x + (w - tw) / 2, y + h / 2 - Kit.textHeight("button"), tw, PAL.muted) return end @@ -220,10 +229,13 @@ function MonEditor.draw(S, Kit, x, y, w, h) end -- the nickname section: a caption line (with the Clear button on it) plus -- the field + Set row + local extraH = 0 + if Gen.ofState(S) == 2 then extraH = 88 * s end local nickFieldH = 30 * s local contentH = pad + headerH + 18 * s + capH + 10 * s + nickFieldH + 18 * s + capH + 10 * s + cellH + 18 * s + + extraH + colsH + pad -- Called before the widgets so this frame already draws at the updated @@ -311,8 +323,9 @@ function MonEditor.draw(S, Kit, x, y, w, h) local statsY = nickY + capH + 10 * s + nickFieldH + 18 * s Kit.caption(cx, statsY, "STATS . recalculated from level + DVs") statsY = statsY + capH + 10 * s + local STAT_KEYS = Gen.ofState(S) == 2 and STAT_KEYS_G2 or STAT_KEYS_G1 local gap = 12 * s - local cellW = (inner - gap * 4) / 5 + local cellW = (inner - gap * (#STAT_KEYS - 1)) / #STAT_KEYS for i, st in ipairs(STAT_KEYS) do local bx = cx + (i - 1) * (cellW + gap) Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6) @@ -326,6 +339,40 @@ function MonEditor.draw(S, Kit, x, y, w, h) -- --------------------------------------------------- DVs | moves split local colY = statsY + cellH + 18 * s + if Gen.ofState(S) == 2 then + local extraY = colY + Kit.caption(cx, extraY, "GOLD") + extraY = extraY + capH + 8 * s + local row = 28 * s + Kit.text("tiny", "HELD " .. tostring(mon.item or "none"), cx, extraY, PAL.text) + if Kit.button(cx + inner - 70 * s, extraY, 70 * s, row, "Clear item", + { kind = "danger", font = "tiny", radius = 6 * s }) then + Ops.setHeldItem(S, mon, nil) + end + extraY = extraY + row + 6 * s + Kit.text("tiny", ("HAPPINESS %d"):format(mon.happiness or 0), cx, extraY, PAL.text) + if Kit.stepper(cx + 140 * s, extraY, 28 * s, row, "-", { font = "small" }) then + Ops.setHappiness(S, mon, (mon.happiness or 0) - 10) + end + if Kit.stepper(cx + 174 * s, extraY, 28 * s, row, "+", { font = "small" }) then + Ops.setHappiness(S, mon, (mon.happiness or 0) + 10) + end + Kit.text("tiny", ("PKRS %d"):format(mon.pokerus or 0), cx + 220 * s, extraY, PAL.text) + if Kit.stepper(cx + 300 * s, extraY, 28 * s, row, "-", { font = "small" }) then + Ops.setPokerus(S, mon, (mon.pokerus or 0) - 1) + end + if Kit.stepper(cx + 334 * s, extraY, 28 * s, row, "+", { font = "small" }) then + Ops.setPokerus(S, mon, (mon.pokerus or 0) + 1) + end + extraY = extraY + row + 4 * s + local bits = {} + if mon.gender then bits[#bits + 1] = mon.gender end + if mon.shiny then bits[#bits + 1] = "shiny" end + if mon.unownLetter then bits[#bits + 1] = "Unown " .. tostring(mon.unownLetter) end + Kit.text("tiny", table.concat(bits, " ") ~= "" and table.concat(bits, " ") + or "gender/shiny follow DVs", cx, extraY, PAL.caption) + colY = extraY + 22 * s + end if narrow then -- stacked: DVs first, then moves, then the two actions side by side at -- full width (#715)