diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml new file mode 100644 index 000000000..f448f8484 --- /dev/null +++ b/.github/workflows/api3-contract.yml @@ -0,0 +1,61 @@ +name: API 3 contracts +on: + pull_request: + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' + - 'tools/flag-catalog-check/**' + push: + branches: [main, dev] + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' + - 'tools/flag-catalog-check/**' +permissions: + contents: read +concurrency: + group: api3-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + compare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: gen2 + sparse-checkout: | + src + data + tests + tools/api3-contract-check + tools/flag-catalog-check + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: gen1-reference + sparse-checkout: | + src + data + tests + assets/fonts + - name: install LuaJIT + run: sudo apt-get update && sudo apt-get install -y luajit + - name: native flag catalog provenance + run: python3 gen2/tools/flag-catalog-check/generate.py --gen1 gen1-reference + - name: identical mod contracts on both engines + run: >- + python3 gen2/tools/api3-contract-check/check.py + --gen1 gen1-reference --gen2 gen2 + --report "${{ runner.temp }}/api3-report.json" + - uses: actions/upload-artifact@v7 + with: + name: api3-contract-report + path: ${{ runner.temp }}/api3-report.json + if-no-files-found: error diff --git a/docs/api3-flags.md b/docs/api3-flags.md new file mode 100644 index 000000000..3eea204a9 --- /dev/null +++ b/docs/api3-flags.md @@ -0,0 +1,75 @@ +# API-3 native and private flags + +`mod.world:getFlag(reference)` returns a boolean or `nil, reason`. +`setFlag(reference, boolean)` returns `true` or `nil, reason`. Real booleans are +required. The facade follows the current Game/save without bootstrapping one. +Flag reads/writes require a save but can run during menus/scripts; they do not +start an idle-world action. Writes from a rolled-back/reloaded/unloaded owner +are retired. Gen4/5 remain outside the shared API-3 world surface. + +## Private flags + +`mod:` and `mod..` address the same private flag in +`save.modData[modId].worldFlags`. A foreign `mod..*` reference is +refused. Keys are nonempty strings up to 256 bytes without control characters. +A read does not create storage. A clear removes the key. Malformed stores are +refused without overwriting them. The existing change-only `flag.changed` +payload remains `{name="mod..", value=boolean}`. + +## Cartridge flags + +| Version | Public references | Host storage | +| --- | --- | --- | +| Red/Blue | Known event symbol or its named bit index | Existing `EVENT_*` save.flags key | +| Yellow | Yellow event symbol or its named bit index | Existing `EVENT_*` save.flags key, with Yellow's own bit catalog | +| Gold/Silver | EVENT symbol or whole bit index 0–2047 | `EVENT_G2_%04d`, as used by the VM/save converter | +| Crystal | Whole bit index 0–2047 | Same native numbered namespace; symbolic references explicitly decline, matching the pinned Gen1 API | +| FireRed/Emerald | Known native flag symbol/alias, or a named numeric flag in that version's range | `FLAG_G3_%04X`, as used by the VM/save converter | + +Native IDs/symbols are cartridge-specific; no equivalence between the same bit +number in different games is invented. Unknown, fractional, non-finite, +negative, malformed or out-of-range references decline before writes. Special +variables are not a new flag namespace. Prism/Polished Crystal native mappings +are not guessed from Gold; private flags remain available there. + +Clearing a native host flag retains `false`, matching native object visibility's +distinction between "cleared" and "untouched". Gen3 synchronizes active object +templates carrying that exact native flag through the host's targeted native +visibility entry; unrelated actors/templates are left alone. Native failures +there can happen after the flag write and are not transactional rollback. + +Native `flag.changed` fires only on a true boolean transition. Its name is the +Gen1 symbol, Gen2 bit number, or Gen3 version-catalog name (falling back to the +numeric ID); Gen3 also provides `id`. For Gen3 cross-engine listeners, use +`event.id` for native identity: the pinned Gen1 implementation's native emitter +uses its base FireRed name catalog even on Emerald, so `event.name` is not a +portable label there. The shared fixture consumes `event.id or event.name` on +both engines. This preserves meaningful native identity rather than claiming +all label strings are identical. Reentrant writes from an API-3 flag event or +native visibility callback decline with "world is busy"; reads remain usable. + +## Catalog provenance and verification + +Generated Red/Yellow/FireRed/Emerald identifier catalogs contain names/numbers +only. No Gen1 engine implementation or ROM is vendored. Gold/Silver reuse the +host's existing Gen2Flags table. The reference is pinned to clean +`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +```sh +luajit tests/engine/api3_flags.lua +python3 tools/flag-catalog-check/generate.py --gen1 /clean/pinned/gen1 --lua luajit +# Add --write to regenerate the deterministic catalogs. +``` + +The provenance checker verifies every generated row and all 1235 pinned Gold +symbol mappings against the host. CI checks provenance alongside the shared +mod suite. Eight identical public-only mod cases bring that suite to +72 comparisons / 144 isolated loader runs; compare preflight, numeric/symbolic +aliases, booleans, no-op event suppression, public event identity/value and +private ownership. The reference uses actual flag backends with synthetic +world/store setup. + +Local checks cover every shipped catalog alias/named numeric entry, the Gen3 +VM key conversion and all 2048 Gen2 bits, plus malformed inputs/stores, +owner retirement, reentry and scoped visibility dispatch. These are ROM-free +runtime/store tests, not cartridge story progression or visual gameplay tests. diff --git a/docs/api3-invalidate-map.md b/docs/api3-invalidate-map.md new file mode 100644 index 000000000..a815db481 --- /dev/null +++ b/docs/api3-invalidate-map.md @@ -0,0 +1,57 @@ +# API-3 map invalidation + +`mod.world:invalidateMap(mapId)` requests the host's native cache invalidation +and reload for generations 1–3. Capture `mod.world` at entry and call later from +an idle world. Return `true` or `nil, reason`; query availability through +`mod:supports("world", "invalidateMap")`. Gen4/5 decline. API-1/2 behavior and +the portable script vocabulary are unchanged. + +```lua +local mod = ... +local world = mod.world +local function refresh(mapId) + return world:invalidateMap(mapId) +end +``` + +A known map ID is required: a nonempty string up to 256 bytes without control +characters. Invalid or unknown IDs return `nil, "unknown map"`, matching Gen1's +API-3 preflight. Gen3 also resolves unprefixed names using the current version's +FR_/EM_/RU_/SA_ prefix when no exact ID exists. Map IDs remain dataset-specific. + +This shares NPC/object/block mutation admission and reentry protection. A save +and topmost idle overworld are required. Scripts, queued/parallel work, player +movement, transitions, menus, link sessions and controller locks refuse before +cache writes. Missing native reload support declines. Reading the facade never +constructs Game. Rollback/reload/teardown retire retained mutation facades. + +The host's native reloadMap invalidates the target and resident neighbor cache +entries. For the active map it uses setMap with seamless reload, current player +cell/facing and keepMusic. If the retained player cell no longer fits the new +geometry, native reload sends the player to the heal point. Runtime map/actor +instance identity can change, so obtain fresh NPC handles after a reload. +Inactive invalidation does not move the player or force an unrelated active +map reload. Rendering and map lifecycle events remain native, not a new common +renderer/cache implementation. Successful host invalidation emits map.reloaded +with mapId and reason="invalidate". Pinned Gen1's Gen2 backend does not emit +that event for inactive invalidation; this native difference is retained. + +This is a cache/instance operation, not a content-write API. Permanent map +changes belong in content definitions. A native failure returns nil with its +reason but may occur after cache invalidation; already entered native effects +are not transactionally rolled back. + +## Verification + +Run `luajit tests/engine/api3_invalidate.lua` and the pinned checker documented +in [API-3 portability](api3-portability.md). Eight identical public mod cases +extend the full checker to 64 comparisons / 128 isolated loader runs. Real +loaders/native facades compare preflight, active reload, position retention, +notification and inactive-map acceptance; reload timing is a driver seam. + +Host tests additionally run actual OverworldState.reloadMap and +MapLoader.invalidate against a synthetic cache, with the setMap/render entry +instrumented. Verify target/neighbor eviction, seamless/music options, valid +player retention, heal-point fallback, reentry, failure/guard release, busy +worlds and retired owners. No ROM import or rendered gameplay equivalence is +claimed. diff --git a/docs/api3-npc.md b/docs/api3-npc.md new file mode 100644 index 000000000..3a38e387b --- /dev/null +++ b/docs/api3-npc.md @@ -0,0 +1,142 @@ +# API-3 public NPC actions + +Generations 1–3 support the same public creation, lookup and removal calls as +Gen1Recomp. Check availability and retain the world facade at mod entry: + +```lua +local mod = ... +local world = mod.world + +local function greet(mapId, spriteId) + if not mod:supports("world", "spawnNpc") then return end + local id, why = world:spawnNpc(mapId, { + name = "GREETER", sprite = spriteId, x = 3, y = 4, facing = "down", + }) + if not id then return nil, why end + local npc, reason = world:npc(mapId, id) + if not npc then return nil, reason end + return npc:scriptMove("down", 2, function() + npc:face("left") + end) +end +``` + +Invoke this later from an idle world. Reading the facade never bootstraps Game. +Gen4/5 report these three calls unavailable. API-1/2 retain their native behavior; +this does not add portable `queueScript` verbs. + +## Creation, lookup and ownership + +- `spawnNpc(mapId, definition)` returns the native runtime ID or `nil, reason`. + It can add a runtime definition to a known inactive map; an actor appears when + the native map lifecycle instantiates it. The return value promises a recorded + definition, not immediate live lookup or a common lifecycle event order. +- `npc(mapId, reference)` returns an opaque handle or `nil, reason`. References + are the live actor's string ID, its definition's numeric index or its name. + Only the active map and currently live neighboring map actors can be queried. +- `removeNpc(id)` returns `true` or `nil, reason`. Only runtime objects owned by + the calling mod may be removed. Imported objects and other owners are refused. + A mod may still look up and control an imported live actor. + +Map, sprite and object identifiers remain dataset-specific. Gen3 recognizes the +current version's `FR_`, `EM_`, `RU_` or `SA_` map prefix when an exact map ID is +absent. IDs/names are nonempty strings of at most 256 bytes without control +characters; numeric references are finite nonnegative integers. Use the ID +returned by `spawnNpc` for removal. + +Definitions are copied before native construction. Required `x` and `y` are +whole nonnegative walk-cell coordinates, at most 1,000,000. Optional `facing` is +`up`, `down`, `left` or `right`; optional `name` and `sprite` are identifiers. +A supplied sprite must exist in the active sprite registry. Native generation +fields remain supported as data; this API does not invent a canonical sprite, +movement or NPC definition schema. The host assigns runtime ownership and +identity, ignoring caller-supplied ownership/index markers. + +The copy accepts plain acyclic tables, strings, finite numbers and booleans. +Functions, userdata, metatables and cycles are refused before native writes. +Limits are depth 16, 4096 visited values/keys, 256 entries per table, 4096 bytes +per string and 64 KiB total string bytes. Refusal returns `nil, reason`. + +## Handles + +Handles expose `id` and these methods; they never expose the native actor: + +| Method | Result and behavior | +| --- | --- | +| `position()` | `x, y` in zero-based 16-pixel walk cells, in the actor's own map frame. | +| `isMoving()` | Boolean native movement state. | +| `canStep(direction)` | Boolean native collision permission, including the host's current seam cast for connected maps. It is a query, not a reservation. | +| `face(direction)` | Set facing. | +| `stepNow(direction)` | Begin one authored step; refuse an already moving actor. | +| `placeAt(x, y, facing?)` | Place at whole nonnegative coordinates up to 1,000,000, clear in-flight movement and synchronize pixel position. | +| `setPassable(boolean)` | Set the live actor's passability. | +| `setAppearance(spriteId)` | Refresh a live actor using an existing sprite registry entry. | +| `scriptMove(direction, tiles?, onDone?)` | Queue native movement, default one cell; `tiles` must be an integer from 1 to 1024. | +| `marchInPlace(onDone?)` | Queue one native stationary march. | + +Directions are the four lowercase names above. Mutations return `true` or +`nil, reason`; a stale handle returns `nil, "NPC handle is stale"`. Handles are +bound to the original actor, map instance, Game, save, data and version. Reused +native IDs or editing the public `id` field cannot redirect a retained handle. +The facade follows the current Game, while old actor handles do not. + +Spawn, removal and handle mutations use the shared API-3 idle-world guard: +a save and topmost overworld are required; menus, native/portable/parallel +scripts, player movement, controller locks, transitions and link sessions refuse +admission. Reads remain usable during overlays. Synchronous reentry into NPC or +object/block mutations is refused. Movement tracks require the active map; +other handle calls also work for live neighboring actors. + +`stepNow` and `placeAt` are authored operations, matching the native distinction +from `canStep`: they do not automatically perform collision validation or clamp +to map bounds. Choose coordinates and query collision as appropriate for the +scene. Appearance changes construct a renderer before committing the actor +change, retain its cell position and leave the native template sprite intact; +they are not persistent content edits. + +## Movement and mod lifetime + +Completion callbacks are optional, one-shot and attributed to the initiating +mod. Native synchronous completion is deferred until admission finishes and +the shared mutation guard is released. Native callback arguments, including +nil holes, are preserved. Callback errors are contained and reported once. + +Pending completions are permanently retired on observed Game/save/data/world, +map, stack, version, actor or runtime-bus replacement, and on native map lifecycle +events. The input-step boundary observes replacements before and after native +updates. Retiring removes only the movement entries owned by that operation; +it does not pop screens or cancel another mod's tracks. + +Rollback, reload and teardown retire the mod's facade and pending tracks and +remove its recorded runtime definitions/actors, including definitions spawned +on inactive maps and instantiated later as neighbors. Foreign runtime objects +and imported actors remain. Controls already applied to imported actors are +ordinary native scene changes, not a transactional patch with automatic undo. +Retained handles/facades return `nil, "world facade is retired"` after retirement. + +## Verification + +```sh +luajit tests/engine/api3_npc.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The clean Gen1 reference stays pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same public-only NPC mod runs +through both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald. Its eight cases extend the full checker to 48 comparisons +and 96 isolated runs. The shared fixture compares creation/lookup, detached +definitions, collision/passability, appearance, authored/queued movement, +callback replay/arguments, optional callbacks and ownership-aware removal. + +Local tests additionally cover bounded validation, busy worlds, reentry, stale +sessions/handles, callback errors, lifecycle cancellation and scoped cleanup. +They exercise the host's actual NPC constructor, native runtime add/remove, +scriptMove/marchInPlace/updateScriptMoves, NPC updates and seam collision casts. +Synthetic datasets and a deterministic SpriteRenderer construction seam keep +these ROM-free. The tests verify the documented shared subset, not retail-ROM +playthroughs, GPU artwork or identical native internals/event timing. Host +validation and lifecycle guards are intentionally stricter than some reference +native paths. diff --git a/docs/api3-portability.md b/docs/api3-portability.md new file mode 100644 index 000000000..689c217a8 --- /dev/null +++ b/docs/api3-portability.md @@ -0,0 +1,159 @@ +# Shared API 3 entry + +Use the same manifest and entry file on Gen1Recomp and Gen2Recomped: + +```json +{ + "id": "portable_example", + "name": "Portable Example", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} +``` + +```lua +local mod = ... +if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", {power = 45}) + if not ok then mod.log:warn("%s", reason) end +end +``` + +Create a portable starter with: + +```sh +python3 tools/modkit.py scaffold portable_example --api 3 +``` + +It generates the shared games list and a guarded canonical move patch. It +omits an engine-release range because Gen1 and Gen2 have independent engine +version numbers. The default scaffold remains API 2 with its existing range +and profile templates. The `--api` value is now honored instead of silently +emitting API 2 for every value. + +API 3 automatically selects unified content, callbacks and portable scripts. +No `content_format`, `callback_format` or `script_format` fields are needed. +An explicit `"native"` for one of those fields is an error under API 3; +`"unified"` is accepted. API 1/2 retain native defaults, and API 2 retains +its existing individual opt-ins. The engine advertises `Version.modApi = 3`. +A higher unknown API remains a load error. + +This is a common entry and a tested compatibility subset, not a claim that +all of Gen1Recomp's services have been ported. It builds on PRs #90–#99. +The existing unified-content limitations still apply: unsupported writes +return `nil, reason` before recording an operation, native diagnostics are +read-only, and ordinary schema errors raise an attributed load error. +Entry failure rolls back the mod's content and subscriptions. Content is +frozen after loading. + +## Public contracts + +Core `pokemon`, `moves`, `items`, `trainers` and `encounters` handles use the +existing unified adapters. Other registries retain their native record +shapes, rather than inventing canonical fields. Every content handle exposes +`has(id)`, `supports()` and `available`. API-3 passthrough reads and iteration +return detached values too; change data through the write methods. + +The API-3 Pokemon boundary uses short statuses (`SLP`, `PSN`, `BRN`, `FRZ`, +`PAR`, `TOX`), nickname-first display names and a common Special alias. +Writing `stats.special` in a writable callback sets both later Special stats; +contradictory split values are rejected before commit. API-2 unified status +names and callback behavior are unchanged. DVs/shiny presentation is read-only +in the callback boundary. Party and battle reads remain detached snapshots. + +Capture `mod.world` and `mod.battle` at entry time. World methods can be called +before Game exists and return `nil, reason`; reading the facade never boots +Game. The same world handle follows the loader's current Game/save instead of +retaining an earlier one. Battle snapshots and intents retain their existing +revision/replay validation. + +The common world calls currently enabled for generations 1–3 are: + +- `overworld`, `current`, `party`, `activeBlockAt`, `liveMaps`, `mapOverview`, + `effectiveEncounters`; +- `canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, + `canFly`, `flyTo`; +- [`getFlag`/`setFlag`](api3-flags.md) for private and validated native flags; +- `queueScript` for the existing bounded portable script language; +- [`warpTo`](api3-warp.md) for validated native map transitions; +- [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native + visibility and active block/metatile edits; +- [`spawnNpc`, `removeNpc` and `npc`](api3-npc.md) for runtime NPC creation, + ownership-aware removal and opaque live actor controls; +- [`startWildBattle`](api3-wild-battle.md) for validated native encounters; +- [`invalidateMap`](api3-invalidate-map.md) for guarded native cache/reload actions. + +Check `mod:supports("world", name)` before relying on a call. All 24 world +method names in the pinned Gen1 API-3 facade now have host adapters for Gen1–3. +This describes API availability, not identical native internals or full +cross-engine behavioral coverage. Native flags have version-specific validated mappings; [`mod.datasets:open`](version-datasets.md) supplies read-only, validated views for imported Gen1–3 versions. Gen4/5 do not claim this +shared world surface. + +Private flags use `save.modData[modId].worldFlags`, independently of native +flags and other mods. A read does not create a bucket. Clearing removes the +stored key. A change emits `flag.changed` with +`name = "mod.."` and the boolean value; setting the same value +emits nothing. Invalid save stores are refused without overwriting them. + +Gen1 API-3 callback names from the pinned reference are recognized even when +this engine has no call site for them. Registration is allowed; availability +still requires a native call site in the selected version. For example, +`render.output` can be subscribed to but reports unavailable here. Unknown +names remain errors. An accepted subscription is not proof that a feature +runs. Source-specific engine handles in lifecycle/UI payloads remain native. + +For scripts, use `mod:supports("scripts", verb)` to test Gen2Recomped's current +bounded vocabulary. A `queueScript` method does not imply support for every +Gen1 script verb. See [portable world scripts](portable-world-scripts.md). + +## Reproducible contract checks + +```sh +luajit tests/engine/api3_entry.lua +luajit tests/engine/api3_compat.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The external reference must be a clean checkout at +`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. +Use a separate checkout; do not reset a working installation. No Gen1 engine +implementation is vendored into this repository. Only public callback names +are recorded as a compatibility catalog. + +The exact same manifest and mod entry run on both engines for Red, Blue, +Yellow, Gold, Silver, Crystal, FireRed and Emerald, in both success and +intentional-rollback cases. Each engine/version/case has its own LuaJIT +process. The shared harness constructs native-shaped synthetic datasets; +the mod fixtures contain no engine imports or engine-specific branches. +The runner also generates an API-3 mod through the real scaffold CLI and +loads those same generated files on both engines for each version. This adds +eight comparisons to the sixteen normal/rollback cases. Eight additional warp +cases use the same public mod on both engines and native-shaped timing seams. +Eight object/block cases and eight NPC cases extend this to 184 comparisons +and 368 isolated loader runs (including world-action, flag and portable-script action cases). It compares JSON projections of public results and +records hashes of both shared and generated fixture files. It refuses a different or modified reference. + +Coverage includes canonical content reads/writes/iteration and native merge +results, detached core and passthrough reads, callable early handles, later +Game replacement, party fields, private flags, Gen1 battle snapshot/invalid-intent boundaries, callback payloads and native +writeback, custom events, unsupported-name handling, frozen content and failed +owner cleanup. Warp cases also compare validation errors, native dispatch +arguments, destination events, boundary coordinates, arrival options, callback +argument holes/replay, stale sessions/owners, synchronous completion and error +attribution. Object/block cases compare native reference routing, visibility/flag +changes, block geometry/bounds, native reads and one event per successful edit. +NPC cases compare runtime creation/lookup, movement/appearance, optional +completion callbacks and ownership-aware removal using the same public handles. +Separate Gen2 tests check API-1/2 behavior, contradictory +manifest fields, honest availability and malformed flag stores. CI pins the +same external commit and uploads the report. + +These are ROM-free loader/runtime-boundary tests, not retail-ROM gameplay +or a proof of every exposed native callback's payload. Native action details +remain covered by the prerequisite PR suites. Extend these shared fixtures +whenever another API-3 service becomes portable; do not make a failing +comparison pass by changing only one engine's mod fixture. diff --git a/docs/api3-warp.md b/docs/api3-warp.md new file mode 100644 index 000000000..6e4c1f055 --- /dev/null +++ b/docs/api3-warp.md @@ -0,0 +1,130 @@ +# Portable API-3 world warps + +API-3 mods can use the same public call on Gen1Recomp and Gen2Recomped: + +```lua +local mod = ... +local world = mod.world -- Safe to retain at entry; does not create Game. + +-- Call later, from an idle overworld interaction/update. +local function goToDestination(mapId, cellX, cellY) + if not mod:supports("world", "warpTo") then return end + local accepted, reason = world:warpTo(mapId, cellX, cellY, "down", { + keepMusic = true, + onDone = function() + mod.log:info("Arrived") + end, + }) + if not accepted then mod.log:warn("Warp declined: %s", reason) end +end +``` + +`warpTo(mapId, x, y, facing, options)` returns `true` after native admission, +or `nil, reason`. Admission does not mean arrival. It uses the existing native +`OverworldState:startWarpTo` transition, map-entry events, spawn bookkeeping +and arrival-tile guard. No direct player/save relocation bypasses that path. +This API-3 adapter does not change API-1/2 facade defaults or validation. +Generations 1–3 advertise support; API 3 still refuses this world operation on +Gen4/5. Availability is a capability, not a promise that the current world is +idle or the named map exists. Reading the facade or querying support does not +bootstrap Game. + +## Admission and coordinates + +The current Game must have a save and a live, topmost overworld. Screens, +native/portable scripts, queued/parallel scripts, movement, transitions, +field/controller locks, link sessions and another pending API-3 warp decline. +Gen2Recomped reuses its existing world-action admission guard and checks its +additional native foreground owners. Idle-state details differ between the +engines; both keep their own native readiness checks. + +Map IDs are nonempty strings of at most 256 bytes, without control characters. +The map must exist in the current merged `game.data.maps`; custom merged maps +are supported. Gen3 also accepts unprefixed names when the current version's +prefixed map exists (`FR_`, `EM_`, `RU_`, `SA_`). An exact ID wins over an alias. +There is no cross-game map-name translation: the mod selects a real map in +the active dataset. + +`x` and `y` are finite, nonnegative whole numbers in zero-based **16-pixel +walk cells**. Gen1/2 dimensions are twice the map's block dimensions; Gen3 +uses the map's metatile dimensions directly. The final valid cell is +`widthInCells - 1, heightInCells - 1`. Invalid/missing map dimensions refuse +before dispatch. Coordinates are bounded, not clamped; terrain passability is +not part of this contract. + +Facing is `up`, `down`, `left` or `right`. Omitted facing follows Gen1's common +contract: `down` on generation 1, current player facing on generations 2/3, +with `down` as fallback. Explicit facing is recommended for portable scenes. + +Options must be a plain table. Only these fields are accepted: + +| Field | Meaning | +| --- | --- | +| `arrive` | `"fly"` or `"teleport"`, using native arrival presentation | +| `keepMusic` | Boolean; forwarded to native map loading | +| `onDone` | Function, called at most once after accepted native completion | +| `fade` | Boolean, generation 3 only; false uses the same native midpoint/completion without pushing a fade screen | + +Unknown options, invalid types and unsupported `fade` use decline the whole +request before native dispatch. Options are copied; later caller edits cannot +change an accepted request. Invalid admission does not set arrival effects, +change maps/coordinates/saves, raise map-entry events or call `onDone`. +Representative reasons include `no game`, `no overworld`, `no save`, +`world is busy`, `invalid map id`, `unknown map: `, `invalid coordinates`, +`coordinates out of bounds`, `invalid facing`, `invalid options`, +`invalid arrival`, `invalid callback`, `unsupported fade` and +`unknown warp option: `. These are admission failures, not exceptions. +Native failures after dispatch can already have native effects; there is no +transactional rollback of an entered transition. + +## Callback ownership + +`onDone` runs only for an accepted request whose Game, save, data, stack, +overworld, player, version, loader buses and mod lease are still current, at +the requested destination. It preserves the native callback arguments, +including nil holes. Synchronous native completion is held until the native +return confirms admission. A rejected/throwing native dispatch cannot invoke +the mod callback, and duplicate native completions are ignored. + +A replaced session/owner observed at input-step boundaries, another map entry +or leaving the destination retires the completion permanently. Returning to an +older session cannot revive a completion already retired. Loader rollback, +reload and teardown retire the facade and its pending callback; a retained +facade returns `nil, "warp facade is retired"`. Retirement does not pop screens +or undo work belonging to the native controller. + +The callback is attributed to its mod, errors enter the loader's runtime error +list once, and the previous runtime owner is restored. An error never retries +a warp or the callback. A completion may start a new request after the native +world becomes idle. The adapter adds no synthetic map-entry event; normal +native map lifecycle remains in control. + +The portable `queueScript` language is unchanged: adding this direct method +does not enable a `warp` script row. + +## Verification + +```sh +luajit tests/engine/api3_warp.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin remains +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same manifest/entry is loaded by +both real loaders for Red, Blue, Yellow, Gold, Silver, Crystal, FireRed and +Emerald. The shared driver builds each engine's native-shaped Game/world data +and replaces only native transition timing seams. It compares validation +reasons, dispatch arguments, public destination events and callback results. +The mod has no engine imports or backend-specific branches. Together with the +existing normal, rollback, generated-starter and object/block cases, CI now +performs 48 comparisons / 96 isolated loader runs. + +The local suite additionally drives Gen2Recomped's real native +`startWarpTo` and `Transition` using synthetic map materialization, including +fade/no-fade, music options, native bookkeeping, lock release and one-shot +completion. Further cases cover host-specific locks, malformed maps, callback +reentry, native refusal/exception, lease retirement and API-2 behavior. These +are ROM-free runtime-boundary tests; no retail-ROM playthrough or rendered +arrival-effect equivalence is claimed. diff --git a/docs/api3-wild-battle.md b/docs/api3-wild-battle.md new file mode 100644 index 000000000..d6896e2f5 --- /dev/null +++ b/docs/api3-wild-battle.md @@ -0,0 +1,66 @@ +# API-3 wild encounters + +`mod.world:startWildBattle(species, level, onDone?)` starts a native wild +encounter for generations 1–3. Check `mod:supports("world", "startWildBattle")`. +Capture `mod.world` at entry and call later from an idle world. Gen4/5 decline; +API-1/2 behavior and the portable script vocabulary remain unchanged. + +```lua +local mod = ... +local world = mod.world +local function encounter() + return world:startWildBattle("MEW", 30, function(result) + mod.log:info("Encounter ended: %s", tostring(result)) + end) +end +``` + +The call returns `true` for admission or `nil, reason`. Species must be a +nonempty identifier of at most 256 bytes without control characters; resolve +it through the active dataset's canonical species index. Unknown/ambiguous +species are refused. Level must be a number, a whole integer from 1 to 100; +strings are not coerced. The optional callback must be a function. + +A current save, a healthy non-egg party member and the topmost idle overworld +are required. Menus, battles/transitions, scripts/parallel scripts, movement, +link sessions, controller locks and reentry decline before native construction. +Refused arguments do not consume constructor RNG or mark the species seen. +Reading the facade/early calls never bootstrap Game. + +Use the existing native BattleState constructor, overworld pushBattle and +post-battle afterBattle path. This preserves native enemy creation, entry +presentation/music and the usual evolution/blackout handling. Do not substitute +a hand-built battle screen. The host can still refuse or throw after native +construction; already entered native effects are not a transactional patch. + +Completion is once per battle, after native afterBattle has run. Native result +arguments, including nil holes, reach the optional callback. Synchronous native +completion is deferred until admission/guard release; rejected admission never +calls it. Callback errors are contained and attributed once. A completion may +start a later action subject to its normal idle-world checks. + +Captured Game/save/data/stack/world/player/version/bus and owner identities are +revalidated. Observed session changes permanently retire pending mod callbacks; +input.step observes them before and after the native update. Rollback, reload +and teardown retire the owner's callback/facade. An already admitted native +battle is not removed when a mod unloads: its native cleanup still runs if the +original game/session remains current. A late result cannot run cleanup against +a replaced session. Native cleanup failure is reported and suppresses the mod +callback; duplicate completion does not repeat native cleanup. + +## Verification + +Run `luajit tests/engine/api3_wild_battle.lua` and the pinned cross-engine checker +from [API-3 portability](api3-portability.md). The identical public fixture runs +on both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald, extending the checker to 56 comparisons / 112 isolated +runs. It covers early calls, invalid arguments, healthy-party checks, native +admission, busy-world refusal and synchronous/asynchronous callback results. +Native battle construction/entry/timing are seams in the shared driver. + +Local tests also exercise actual host BattleState/Pokemon construction and +Pokedex seen updates on synthetic datasets, session replacement/restore, +mod rollback/reload/unload, native failure, callback replay/errors and guard +release. Transition and afterBattle entry are instrumented seams; these are +ROM-free runtime boundary tests, not a retail-ROM battle/evolution/blackout +playthrough or rendered transition equivalence. diff --git a/docs/api3-world-edits.md b/docs/api3-world-edits.md new file mode 100644 index 000000000..1e66a6fa1 --- /dev/null +++ b/docs/api3-world-edits.md @@ -0,0 +1,124 @@ +# API-3 object visibility and block edits + +API-3 mods can call the same public methods on both engines: + +```lua +local mod = ... +local world = mod.world -- Retain at entry; invoke later from an idle world. + +local function openGate(mapId, objectRef, blockX, blockY, openBlock) + if not mod:supports("world", "toggleObject") then return end + local ok, why = world:toggleObject(mapId, objectRef, false) + if not ok then return nil, why end + if mod:supports("world", "replaceBlock") then + return world:replaceBlock(blockX, blockY, openBlock) + end +end +``` + +Each call returns `true` or `nil, reason`. Calls are independent, not an atomic +multi-command transaction. Support is advertised for generations 1–3; API-3 +Gen4/5 still refuse both methods. A captured facade follows the current Game +without bootstrapping one. Rollback, reload and teardown retire its mutation +lease; retained calls return `nil, "world facade is retired"`. API-1/2 behavior +and the portable script vocabulary are unchanged. + +Both methods require a save and an idle, topmost overworld. Native scripts, +portable scripts, queued/parallel scripts, menus, movement, transitions, +controller locks and link sessions refuse admission. Reentrant object/block +edits from an edit's own event/native callback return `nil, "world is busy"`; +the guard is released on success and native exceptions. + +## `toggleObject(mapId, objectRef, visible)` + +`mapId` and string references are nonempty strings of at most 256 bytes without +control characters. Numeric references must be finite nonnegative integers; +`visible` must be an actual boolean. The map must exist in the active merged +dataset. Gen3 also resolves unprefixed map names using the current version's +prefix when an exact ID is absent, as `warpTo` does. + +Reference routing and persistence deliberately retain the Gen1 API-3/native +contracts, rather than treating all generations' object numbers as identical: + +| Generation | Reference | Scope and effects | +| --- | --- | --- | +| 1 | Native visibility key, normally the object's `name`; numeric keys are also accepted | May name an inactive map or a key with no currently defined actor. Writes `save.objectToggles`. An active map refresh uses the native seamless reload, retaining player cell/facing and music. | +| 2 | 1-based position in `map.def.objects`, or its `name` | Active map only. Resolves the public slot to its native template and uses the same effect helper as native appear/disappear. Writes the object's event flag and visit-scoped visibility, synchronizing only that actor. | +| 3 | Native local object ID, `name` or `id` | Active map only. Uses native add/remove commands, including event flags, spawned-object state and flagless visit state; synchronizes only the selected actor. | + +This API does not make map/object IDs portable between cartridges: select a +reference from the active dataset. On Gen2/3, an unknown reference returns +`nil, "no such object: "`; an inactive map returns +`nil, "map is not active: "`. Missing object lists and malformed native +object/store metadata refuse before native writes. Gen1's key store keeps its +existing ability to prepare a later map's visibility. + +Flagged Gen2/3 objects use their native persistent flags. Flagless objects +retain native visit-scoped behavior. A targeted sync does not respawn, relocate +or re-derive unrelated live actors, even when several templates share a flag. +Other native visibility gates (time, story flags, script-spawn rules) still +apply; acceptance is not a promise to override every native gate. + +A successful call emits one `world.object_toggled` event with +`mapId`, `objName = objectRef` and the boolean `visible`. Repeating an accepted +value still follows the native call/event contract. Gen1's active refresh may +also produce its normal native map lifecycle events. Gen2/3 do not reload the +whole map for this operation. + +## `replaceBlock(blockX, blockY, blockId)` + +This changes only the active map. Coordinates are zero-based whole **block** +coordinates, with `0 <= x < map.width` and `0 <= y < map.height`: + +| Generation | One coordinate unit | Valid ID range | +| --- | --- | --- | +| 1/2 | 32-pixel block, 2×2 walk cells | 0–255, and the block must exist in the active tileset | +| 3 | 16-pixel metatile, one walk cell | 0–1023 | + +Coordinates/IDs must be finite integers. Gen3 range validation does not promise +that every ID has artwork in a particular tileset. The adapter checks the +current block, runtime geometry and redraw entry before mutation. Unknown +blocks, unavailable geometry/renderers, fractions, NaN/infinity, negatives and +out-of-bounds coordinates are declined without writing a patch or emitting an +event. Typical reasons are `invalid block id`, `unknown block id`, +`invalid block coordinates`, `block coordinates out of bounds`, +`block unavailable`, `unsupported block geometry` and +`block renderer unavailable`. + +Gen1/2 use the existing native world block replacement. Gen3 uses the host's +native metatile command with its default non-impassable setting, including +clearing old collision/shut-cell overrides, and redraws the runtime map. +Terrain/collision tables remain native; there is no invented shared collision +registry. `activeBlockAt` immediately sees the change. One successful operation +emits one `world.block_replaced` event with `mapId`, `bx`, `by` and `block`. + +On Gen2Recomped this is an instance patch, leaving generated map data intact. +It is not a persistent content edit: permanent layout changes belong in the +map registry. Native execution failures can occur after a native write and do +not promise transactional rollback; preflight refusals have no such effects. + +## Verification + +```sh +luajit tests/engine/api3_world_edits.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin stays at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same new fixture manifest/source +runs under both production loaders for Red, Blue, Yellow, Gold, Silver, +Crystal, FireRed and Emerald. Engine-specific setup stays in the shared +driver, never in the mod. Native-shaped map/render/object seams let the driver +compare refusal reasons, visibility/flag effects, native block reads, geometry +and event payloads. The full checker now runs 48 comparisons / 96 isolated +processes, including all previous entry, rollback, generated-starter and warp +cases. + +Local tests additionally exercise the actual Gen2Recomped native Map, +OverworldState block replacement and object synchronization with synthetic +pooled actors, including shared flags, retained positions, flagless resets, +packed metatiles, collision clearing, malformed stores, reentrancy and retired +owners. These are ROM-free runtime-boundary tests; they do not establish +rendered retail-ROM equivalence or full scene-playthrough coverage. diff --git a/docs/battle-actions-api.md b/docs/battle-actions-api.md new file mode 100644 index 000000000..f0e232773 --- /dev/null +++ b/docs/battle-actions-api.md @@ -0,0 +1,96 @@ +# Battle action API + +The action extension builds on `mod.battle:snapshot()` and is available to +API 1 and API 2 mods on the shared Gen 1–5 battle engine. No manifest opt-in +is required. If capability queries are installed, +`mod:supports("battle", "submit")` reports that this interface exists. It does +not promise the current battle accepts a particular command. + +```lua +local nextIntentId = 1 -- keep increasing for this captured facade +local snapshot = mod.battle:snapshot() +if snapshot and snapshot.prompt == "menu" then + local ok, reason = mod.battle:submit({ + id = nextIntentId, + revision = snapshot.revision, + kind = "menu", + choice = "fight", + }) + nextIntentId = nextIntentId + 1 + if not ok then mod.log:info("Battle command refused: %s", reason) end +end +``` + +Always fetch a fresh snapshot before the next command. Do not hold a revision +across a frame, other input, a native callback, another mod's command, a screen +change or a Game/battle replacement. The engine rechecks the live context; +returning to an apparently identical screen does not revive an old command. + +## Intent records + +Intents must be plain tables without a metatable. Every record contains +`id`, `revision`, `kind` and only the fields listed below. Integers must be +finite; `id` and `revision` are positive safe integers up to 2^53−1. Extra +fields, unknown kinds, strings in numeric fields and missing arguments are +rejected. The facade copies accepted input fields before calling native code. + +| `kind` | Extra field | Meaning | +| --- | --- | --- | +| `menu` | `choice`: fight / party / item / run | Select an action on the uncovered regular battle menu | +| `move` | `slot`: 1–4 | Select a current battle move; may enter the native target selector | +| `target` | `target`: 0–3 | Select a currently offered, occupied positional battler ID | +| `back` | none | Cancel move selection/reorder or targeting; in a double's second action menu undo the first choice when native B permits it | +| `safari` | `action`: ball / bait / rock / run | Select a native Safari command; `rock` is Platinum's mud button | +| `mimic` | `index`: 1–4 | Select a current Mimic **option index**, not the source move slot | + +Positions are 0 player-left, 1 opponent-left, 2 player-right, 3 opponent-right. +These are not indices into `snapshot.battlers` or `snapshot.targets`. Target +choices are checked again against the native selector and live field. A +pending move must still be the same current move and still pass move checks. + +`move` uses the actual chooser, including the right flank and Transform's +current move list. Native Disable, PP, Taunt, Torment and BW Choice/Imprison/ +Gravity/Heal Block restrictions are checked before accepting the request. +Mimic uses its queued user, including a right-flank user during turn resolution, +and preserves the native copy/restoration bookkeeping. + +`menu` opens the existing bag/party flow. It does not select a party member, +use an inventory item or throw a normal bag ball directly. Those screens keep +their own controller. Native forced FIGHT/Struggle, ghost refusals, trainer +RUN refusals and bug-contest item behavior are preserved. A valid menu choice +can therefore be accepted and produce a native refusal message. + +## Context, replay and errors + +`submit(intent)` returns `true` when admitted and dispatched; otherwise +`nil, reason`. Accepted means the native command path ran, not that a turn +succeeded, a move hit, a ball caught or a battle finished. + +An intent must refer to a snapshot actually returned by this facade and still +be current. Each facade owns its own monotonically increasing ID sequence; +IDs survive battle and Game changes. Once native dispatch starts, that ID is +consumed. Equal or lower IDs are refused as replayed. Skipping IDs is allowed. +A rejection before dispatch does not consume its ID; correct the problem and +read a new snapshot before retrying. + +All public facades on one Game share an in-progress guard and an invalidation +epoch outside gameplay/save state. Another mod or a callback cannot submit +recursively during validation or execution. Snapshots observed during execution +are locked and cannot authorize a command after it finishes. Accepted calls +invalidate other mods' old snapshots even if exposed data happens to stay the +same. These counters are local process bookkeeping, not link/save data. + +Native errors are caught, reported and clear the guard. **An entered native +call is not retried or rolled back.** Its ID stays consumed even if native +code throws after a partial effect or returns a refusal. Fetch the current +snapshot and use a new ID for any deliberate next action. Validation errors +also clear the guard. Errors do not automatically drive subsequent frames or +drain the turn queue. + +No battle, covered menus, mismatched phases, finished battles, fainted/NPC +choosers, automatic locks, stale targets, demos/tutorials, link/spectator, +Palace and Arena controllers are refused. This API does not advance text, +drive a covered party/bag/choice screen, perform a forced replacement, or +bypass a script/controller. Ordinary keyboard/touch input continues through +the same extracted native dispatch methods and retains its existing messages, +queueing, RNG, targeting and per-version behavior. diff --git a/docs/battle-snapshot-api.md b/docs/battle-snapshot-api.md new file mode 100644 index 000000000..e6e1cfa3c --- /dev/null +++ b/docs/battle-snapshot-api.md @@ -0,0 +1,126 @@ +# Battle snapshot API + +```lua +local battle = mod.battle -- safe in the mod entry chunk, before Game exists +local snapshot, reason = battle:snapshot() +if snapshot then + local actor = snapshot.player + if actor then print(actor.name, actor.hp, actor.maxHp) end +end +``` + +Available to API 1 and API 2 mods on all supported Gen 1–5 versions. Neither +`content_format` nor `callback_format` is required. This reads the shared +BattleState in the current Game's stack, including beneath another screen. +A read before Game is loaded also returns `nil, "no battle"` without +bootstrapping Game. Outside battle it returns `nil, "no battle"`. A UI screen merely holding a +`.battle` reference does not count as another battle. + +If the capability-query extension is installed, +`mod:supports("battle", "snapshot")` returns true without constructing Game. +When the [battle action extension](battle-actions-api.md) is installed, +`mod:supports("battle", "submit")` also returns true. Other battle capabilities +return false with a reason. + +## Records + +All returned tables, including nested records and `native` diagnostics, are +copies. They carry no engine objects, functions, userdata, threads or +metatables. Unknown native Pokemon extension fields are copied as data; +cycles are preserved. These diagnostics are not a portable write format or +necessarily JSON-serializable. Changing a snapshot never changes battle/save +state or future reads. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive, monotonically increasing within this facade; see below | +| `kind`, `phase`, `result` | Battle kind (including ghost/safari/oldman variants), native phase and optional outcome | +| `turn` | Native resolved-turn count; zero before the first turn | +| `double` | Whether this battle is configured as a double | +| `active` | Player position the menu currently selects for (0 or 2) | +| `player` | Occupied battler at `active`, or nil | +| `enemy` | Occupied opponent-left battler (1), or nil; use `battlers` for all foes | +| `battlers` | Array of occupied battler records in position order | +| `party` | Battle-specific player party when supplied (link copies), otherwise save party order, with one-based `slot` and `active` | +| `moves` | Current actor's battle moves, including Transform/Mimic overrides | +| `items` | Positive inventory quantities; acquisition order, then sorted unlisted IDs | +| `catchable` | Wild battle, one living foe, no ghost/noCatch/result restriction | +| `prompt` | Description of the battle screen's input phase; see below | +| `message` | Optional array of currently revealed text lines; unknown glyphs leave it unavailable | +| `messageSource` | Optional current message source text, which can include unrevealed text | +| `safariBalls` | Optional native remaining Safari ball count | +| `menuIndex`, `moveIndex`, `moveSwapIndex` | Optional one-based native menu/move/reorder cursors | +| `targets` | During targetSelect, ordered positional IDs from the native selector; otherwise empty | +| `targetIndex` | Optional one-based cursor into `targets` | +| `mimicMoves`, `mimicIndex` | Optional Mimic options and one-based cursor | + +Battler IDs are **0 player-left, 1 opponent-left, 2 player-right, 3 +opponent-right**. Array indices are not battler IDs. Empty positions are +omitted; an enemy at 3 never masquerades as enemy-left. `player` follows the +second player chooser, including after the left flank leaves the field. + +Pokemon records expose `species`, optional `item`, `nickname`, `name`, +`level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, and copied `native` +diagnostics. Species/move/item IDs use canonical string names from this boot's +registries. Numeric DS IDs resolve through the imported names and aliases; +unresolved IDs use their string form. Pristine IDs remain stable across +presentation-name patches; definitions registered by later mods are visible. +Status codes SLP/PSN/BRN/FRZ/PAR/TOX become +sleep/poison/burn/freeze/paralyze/toxic; unknown values are retained. + +Stats use `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, +`specialDefense`; Gen 1's special supplies both split stats. `maxHp` comes +from stored `stats.hp`. HP and status are logical state, not interpolated HUD +values. Battlers additionally expose `id`, `side` (player/enemy), `active`, +`absent` (false for occupied rows), `ally`, optional one-based `partyIndex`, +`displaySpecies`, `battleStats`, `types`, and `stages`. Original species and +stored stats stay separate from Transform's displayed species/current stats. +NPC allies are not added to the player's party. + +Moves expose one-based `slot`, portable `id`, optional `name`, `pp`, `maxPp`, +`ppUps`, `type`, `power`, `accuracy`. Max PP follows the native base PP plus +clamped 0–3 PP Ups, each adding floor(base PP / 5). Definition fields are base +values, not damage/accuracy predictions. Only the top-level actor `moves` +also expose `disabled`: true for Disable, no PP, Taunt or Torment; false if +none applies on Gen 1–4. In BW this field is omitted unless a common +restriction is known, because additional native Choice/Imprison/Gravity/ +Heal Block controllers may refuse the move. Nil is unknown, not false. +Mimic options retain their original move `slot` even if options are compacted. + +Items expose `id`, optional `name`, `count`, `ball`, `catchable`, optional +`pocket`. This is an **inventory view**, not a list of currently usable items; +it can include badges/key items. Ball classification follows imported ball +metadata. `catchable` describes the target restriction, not permission to use +an item: noItems/frontier rules, party capacity and the active bag controller +still own item eligibility. No `needsTarget`, catchChance, hitChance, +displayPower, effectiveness or guessed preview is produced. + +## Prompts and revisions + +`prompt` is `menu`, `moves`, `target`, `mimic`, `safari`, `advance`, or +`locked`. Intro, automatic demos/Palace, conservative Arena menu handling, +known automatic battler locks and every covered battle are locked. Message +advance is reported only when native manual text waits have finished their +pre-advance delay. Party, bag and choice screens above battle stay locked; +this API does not inspect or drive those screens. Link controllers remain locked. +Visible messages decode the native revealed glyph buffer through the imported +charmap and already-loaded Unicode glyph map; this never initializes a font. A prompt is descriptive, +not an action-permission token. The [battle action extension](battle-actions-api.md) supplies separately validated +submission; the snapshot's descriptive prompt is not its permission check. + +Identical successive snapshot records and action contexts retain their revision. +The action extension also tracks native controller/selector identity and private +control flags, so replacement objects can invalidate an otherwise identical +record. Accepted public actions invalidate all facades on that Game, including +snapshots observed during native callbacks. A change to +any exposed data advances it once at the next read; animation clocks and +interpolated HP alone do not. A different battle or Game also advances it, +even with identical records. Observing `no battle` invalidates the previous +record, so re-entry advances it. Unobserved transitions between reads cannot +be detected if the same Game and battle objects are reused. Each mod has its +own counter; revisions are not save data or globally synchronized IDs. + +Reading constructs no Pokemon or screen, loads no gameplay/graphics module, +executes no action, emits no event/hook and consumes no RNG. Only facade +bookkeeping and loader-local ID indexes change. The API provides an observer +boundary, not hidden-information filtering or a write-back transaction. diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..79e0fb9cc 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -18,6 +18,71 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Shared API 3 entry + +The [API-3 entry and cross-engine contracts](api3-portability.md) describe +using the same manifest and mod source on Gen1Recomp and Gen2Recomped. +API 3 selects the unified adapters automatically; API 1/2 keep their defaults. +See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, +[API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, +[API-3 NPC actions](api3-npc.md) for runtime actors and movement handles, +[API-3 wild encounters](api3-wild-battle.md) for native battle admission, +[API-3 map invalidation](api3-invalidate-map.md) for native cache/reload, +and [API-3 flags](api3-flags.md) for validated native references and private flags. + +## Feature queries (`mod:supports`) + +`mod:supports(surface, name)` returns `true` when the selected host exposes an +existing API-2 capability, or `false, reason` when it does not. Unknown names, +unknown surfaces and invalid names return `false` with an explanation. Check +for the method itself when supporting older engine builds: + +```lua +if mod.supports and mod:supports("hooks", "battle.damage") then + mod.hooks:wrap("battle.damage", function(next, ctx) + return next(ctx) + end) +end + +local canFly = mod.supports and mod:supports("world", "flyTo") -- false here +``` + +The four surfaces are: + +| Surface | Meaning of `true` | +| --- | --- | +| `content` | An API-2 registry exists, e.g. `pokemon` or `render_pipelines`. | +| `world` | A public `mod.world` method exists, e.g. `warpTo` or `terrainAt`. | +| `hooks` | The engine has a native hook call site for this version. | +| `events` | The engine has a native event call site for this version, or the name is a non-empty `mod..` namespace. | + +Queries work in the entry chunk without an active overworld, save, graphics +context or imported ROM. They do not subscribe, wrap hooks, load the Game or +materialize `mod.world`. Answers are bound to the host selected when that mod +API was built; changing the mod's copy of `gameVersion` or `host` cannot change +them. Availability does not imply the current game state permits the action: +`warpTo` can still return `nil, "no overworld"`, and elevation readers can +return `nil, "map carries no elevation"`. + +Callback availability follows Gen2Recomped's actual implementation, rather +than Gen1Recomp's newer catalog. For example, `core.logic_speed`, `item.use` +and `egg.hatched` have no native call sites here; the hatch event here is +`pokemon.egg_hatched` (Gen 2–5). `battle.status_hud_visible` is available on +Gen 4–5, `battle.overlay` on Gen 1–3, and the normal start-menu, party-submenu +and naming-grid hooks on Gen 1–3. `ui.pc.items` is available on Gen 1–2. +Oak-speech callbacks apply to Gen 1–2 except Prism; Birch-speech events apply +to Emerald. Other versions use their own intros. An event still needs its +trigger to occur; custom events also need another mod to emit them. + +For API 1/2, the query describes the existing interfaces and does not itself +normalize records, IDs or callback payloads. API 3 selects the portable +adapters described in [Shared API 3 entry](api3-portability.md), with explicit +limits on availability. Existing API-1/2 registration/subscription behavior +remains unchanged. When adding a native callback or changing screen routing, +update `src/mods/Capabilities.lua` and the capability tests in the same change. + +Additional read-only world summaries: [map overview and encounter queries](world-overview.md). + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives @@ -70,6 +135,14 @@ is a normal answer rather than a failure, and telling it apart from per frame, and a directory walk over several hundred blobs per frame is not something a menu can afford. +## Read-only world and party queries + +`mod.world:party()`, `activeBlockAt(mapId, bx, by)` and `liveMaps()` expose +common Pokémon snapshots and current runtime map data across generations +1–5. Reads return detached data and do not load maps or change gameplay. +See [record fields, coordinate units and DS limitations](world-read-api.md). +The existing `loadedMaps()` method retains its legacy shape. + ## Editing maps in Tiled Maps are data, not assets, so they can be authored in a real map editor and @@ -87,6 +160,13 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the fields that moved; a new map becomes a `:register`. See `docs/new-features.md` and the extension's own README. +## Portable dialogue scripts (opt-in) + +API-2 mods can select `"script_format": "unified"` for prevalidated, copied +`mod.world:queueScript()` dialogue/control-flow programs with exactly-once +completion and owner-only cancellation. Existing manifests keep native script +behavior. See [portable commands, ownership and limits](portable-world-scripts.md). + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it @@ -152,6 +232,17 @@ Three rules worth knowing: Returning `nil` from `drawWorld` is a normal answer meaning "not this frame"; the engine draws the vanilla world instead. +## Read-only battle snapshots + +`mod.battle:snapshot()` returns a detached common battle view on Gen 1–5, +including singles and doubles, or `nil, "no battle"`. It can be captured in +an entry chunk before Game exists. See [Battle snapshot API](battle-snapshot-api.md) +for records, revisions, prompt semantics and intentionally omitted previews. +The snapshot reads state only. The [Battle action API](battle-actions-api.md) +adds validated `mod.battle:submit(intent)` for the native menu, move, target, +back, Safari and Mimic paths. When capability queries are +installed, `mod:supports("battle", "snapshot")` reports this interface. + ## Battle sprite scaling The enemy's front pic draws at 1x and the player's back pic at 2x, the way @@ -192,6 +283,14 @@ default** (1x front, 2x back). ball-to-pic grow multiplies your scale through each stage, so a rescaled mon still grows into place from the ball, grounded the whole way. +## Unified hook and event records (opt-in) + +API-2 mods can select `"callback_format": "unified"` independently of their +content format. Known Pokémon, battler, trainer-slot and encounter records +then use common fields and symbolic IDs. Existing mods keep native payloads. +See [the callback format and mutation contract](unified-mod-callbacks.md) for +adapted arguments/results, native handles and validation rules. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload @@ -219,6 +318,14 @@ The console understands these verbs (anything else is evaluated as Lua): - `trace PAT | trace off` — trace events/hooks matching a glob pattern. - `help` — list the verbs. +## Public party, bag and dialogue actions + +`mod.ui:snapshot()` returns detached options for the current native screen; +`mod.ui:submit(intent)` selects a row, goes back, changes bag pocket or advances +ready text through its native flow. See [Public UI actions](ui-actions-api.md) +for revision/ID requirements, per-generation menu behavior and supported screens. +The existing `mod.ui` list-building helpers retain their original signatures. + ## Tool input and title-menu hooks Tool mods that need to act once per game logic tick can wrap `input.step`. @@ -255,6 +362,15 @@ without the engine knowing the layout. Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. +## Public world actions + +`mod.world` exposes guarded party swaps, verified contextual field actions +and native flight through `canReorderParty`, `reorderParty`, +`availableFieldActions`, `useFieldAction`, `canFly` and `flyTo`. +See [contracts, busy-state checks and generation coverage](world-actions-api.md). +Calls revalidate the live state and preserve native gameplay paths; the +field-action adapter explicitly refuses unsupported script-driven cases. + ## Support link (`support`) A mod can add a link where players can thank its creator: a Ko-fi, Patreon or @@ -277,3 +393,10 @@ The launcher hands the link to the operating system to open, so only `http://` and `https://` links are accepted. Any other value, such as a `file:` link or a custom scheme, is dropped with a warning. The mod itself still loads; it just gets no button. + +## Unified content records (opt-in) + +API-2 mods can select `"content_format": "unified"` to read and edit common +Pokémon, move, item, trainer and encounter definition shapes. Existing mods +keep native formats. See [the format, supported operations and limitations](unified-mod-content.md) +for the manifest guard, generation coverage, symbolic IDs and failure behavior. diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md new file mode 100644 index 000000000..ad8e876c9 --- /dev/null +++ b/docs/portable-world-scripts.md @@ -0,0 +1,514 @@ +# Portable dialogue scripts + +API-2 mods can opt into a bounded, prevalidated language for +`mod.world:queueScript(rows, options)`: + +```json +{ + "id": "quest_dialogue", + "name": "Quest dialogue", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "script_format": "unified" +} +``` + +Check `mod.scriptFormat == "unified"` in the entry file. Older engines may +ignore unknown manifest fields; checking the marker prevents a portable row +list from silently reaching their native script runner. Omitted or explicit +`"native"` formats preserve existing API-1/API-2 queueScript behavior. Other +values, or API 1 with `"unified"`, are load errors. Content and callback format +opt-ins are independent. The engine's Mod API major remains 2. + +## Contract + +```lua +return function(mod) + assert(mod.scriptFormat == "unified", "portable scripts required") + + -- Run this from an idle-world interaction/update, not at entry time. + local function offerQuest() + local ok, why = mod.world:queueScript({ + { "ask", "Help with the quest, {PLAYER}?" }, + { "jump_if_false", "declined" }, + { "show_text", "Thank you!" }, + { "jump", "end" }, + { "label", "declined" }, + { "text", "Maybe another time." }, + }, { + onDone = function(completed, reason) + if not completed then mod.log:warn("Dialogue stopped: %s", reason) end + end, + }) + if not ok then mod.log:warn("Dialogue not started: %s", why) end + end +end +``` + +Admission returns `true`, or `nil, reason` without executing any prefix of a +rejected program. The entire list, every row/argument and every jump target is +validated and copied first, including unreachable rows. Caller edits to the +original list, choice labels/options or callback options cannot change an +accepted program. Reading the facade and an early request do not construct +Game. A request with no live save/world fails quietly. + +`true` means admitted, not successfully completed. After admission, +`onDone(true)` reports completion and `onDone(false, reason)` reports a runtime +failure or cancellation. It fires exactly once, including failures during the +initial dispatch. It is not called for rejected requests. A throwing completion +callback is isolated and attributed to its mod; completion is not retried. +Already entered native work is not rolled back or automatically replayed. + +Only `onDone` is accepted in options. Programs and rows must be dense plain +arrays without metatables or extra keys. Functions/userdata/native handles are +not command arguments. Choice options are a closed plain table. + +## Initial command set + +| Row | Meaning | +| --- | --- | +| `{ "show_text", text }` / `{ "text", text }` | Native manual TextBox; wait for all pages and final dismissal | +| `{ "ask", text }` | Native text and YES/NO overlay; YES is true, NO/B is false | +| `{ "choice", labels, opts }` | Native N-way menu; choice 1 is true, every other choice is false | +| `{ "wait", frames }` | Native logic-frame wait, integer 0–36000; zero waits until the next world update | +| `{ "label", name }` | Named jump destination | +| `{ "jump", target }` | Unconditional jump | +| `{ "jump_if_true", target }` | Jump after a true answer | +| `{ "jump_if_false", target }` | Jump after false/nil, including before any answer | +| `{ "end" }` | Finish the program | + +Text is literal UTF-8, up to 4096 bytes per row, without NUL. Native TextBox +placeholder substitution (e.g. `{PLAYER}`), prose pagination, typing, scroll/ +page holds and answer holds remain in use. These rows do not resolve native +text registry IDs or lower ROM script bytecode. Future pages are not skipped. + +`labels` contains 1–16 nonempty UTF-8 strings of at most 128 bytes each. +Optional `opts.default` and `opts.cancel` are valid 1-based indices; defaults +are 1 and the last choice. B follows `cancel`. Answers remain internal to the +program for conditional jumps; the completion callback reports success/failure. + +Labels contain letters, digits, `_`, `-` or `:`, at most 64 bytes, and are unique. +`end` is reserved. Jump targets are existing labels, valid 1-based row indices, +or the string `"end"`. No arbitrary native verbs, custom command handlers, +extra callback arguments or executable rows are accepted. + +A program has at most 1024 rows and 128 KiB of text/choice labels. Execution +yields after each slice of 128 instructions and fails after 65536 instructions +in total. This bounds authored jump loops without a Lua debug hook or a frame +stall; it is not a wall-clock deadline on a player reading text. + +## Ownership and cancellation + +Admission requires the current overworld to own the top of the stack. Native +scripts, queued/parallel scripts, movement, transitions, field animations, +input/controller locks, link sessions and another portable program decline. +Only one portable foreground program can own a Game at a time. + +An accepted program temporarily installs an isolated instance of the native +ScriptRunner. Its `isRunning()` participates in the world's existing input and +script-scheduling gates. It uses native TextBox, ChoiceBox and Menu classes, +including their normal keyboard/touch handling and delayed answer completion. +The idle runner is restored at completion/cancellation, provided it has not +already been replaced by another controller. Global runner/UI classes and the +native command registry are unchanged. + +`mod.world:cancelScript()` cancels only this facade's active portable program. +It returns `true`, or `false, "no portable script"`. It cannot cancel another +mod or a native story script. Cancellation removes only the run's exact native +windows, even beneath a foreign overlay, and releases its coroutine. It does +not pop unrelated screens or release another controller's locks. A covering +foreign screen pauses normal progress. Removing an awaited owned window +externally cancels at the next input-step boundary. + +A replaced Game/save/data/stack/world/map/player/runner, changed map identity or +player position/version, transition, link session, mod rollback, reload or +teardown cancels the program. Session changes are checked at input-step and UI/ +runner boundaries, including while the world is covered. Retained callbacks +cannot resume a cancelled program, skip a later row's wait or pop a new screen. +Facades from an unloaded/reloaded/failed mod are retired. + +Native `script.started`/`script.ended` lifecycle events retain their context +shape; `source.modId` attributes the portable run and `completed` describes its +outcome. End notification/completion follow owned cleanup. Portable rows use +this validated language rather than resolving native/custom handlers or firing +`script.command` wrappers; native-format scripts retain those contracts. +Direct changes to engine objects from such event handlers or other mods remain +outside this boundary's guarantees; this is not a mod security sandbox. + +When optional capability queries are installed, unified mods can probe +`mod:supports("scripts", verb)` and `mod:supports("world", "cancelScript")`. +Unknown/unimplemented portable verbs return false. The existing native +`world/queueScript` capability is not, by itself, evidence of this opt-in. + +## Scope and verification + +This first adapter covers dialogue/control flow across the 15 supported +version selections (Gen 1–5). Items/Pokémon, world movement/warps, +battles, shops and other Gen1Recomp portable verbs need separately verified +adapters; they refuse the complete list before the first effect here. + +The contract is informed by Gen1Recomp's +[UnifiedScripts](https://github.com/bryanthaboi/gen1recomp/blob/79ea62f45fb2ea8f65d081a109431e4eb1bbdb36/src/mods/UnifiedScripts.lua). +This implementation uses this host's native shared UI and runner architecture. + +Run `luajit tests/engine/portable_world_scripts.lua` and +`LUA=luajit bash scripts/test.sh`. The ROM-free suite uses the real Loader, +ScriptRunner, state stack and native UI classes. It compares complete per-frame +native/portable dialogue traces, checks preflight/argument copying, rejection +immutability, identity/session changes, replay, cancellation/reentry, budget and +native failures. Fixture runs do not establish rendered retail-ROM coverage. + +When the unified content/callback PRs are also present, run +`PORTABLE_SCRIPT_ALL_FORMATS=1 luajit tests/engine/portable_world_scripts.lua` +to repeat the suite with all three format opt-ins enabled together. + +## Portable flag commands + +The validated language also supports: + +| Row | Meaning | +| --- | --- | +| `{ "set_flag", reference }` / `{ "setflag", reference }` | Set a private/native boolean flag. | +| `{ "clear_flag", reference }` / `{ "clearflag", reference }` | Clear it. | +| `{ "check_flag", reference }` | Set the conditional result to the flag's boolean value. | +| `{ "wait_flag", reference, timeout? }` | Yield until true or until the optional timeout expires; set conditional result true/false. | + +References use [API-3 flag addressing](api3-flags.md): `mod:` is private to +the initiating mod, while native symbols/numbers retain their version-specific +meaning. Crystal native references remain numeric-only; unknown hack mappings +are declined. These rows are available through the unified script opt-in and +API 3. Gen1's pinned reference does not offer the host's `scripts` capability +query surface; portable API-3 mods can use the common world/queueScript query, +while host-specific tools may inspect scripts/. + +Every reference, including unreachable rows, is validated and copied before +admission. All referenced save stores are checked before the first effect; +an invalid later row never permits an earlier set_flag to execute. Native and +private writes share the public flag implementation, including private ownership, +native storage, reentry protection and Gen3 visibility synchronization. Native +flag handling uses the already resolved world and never asks the loader to boot +Game. + +A timeout is a whole number from 0 to 36000 logic frames. Omission waits until +signaled or cancelled. Even a zero timeout or already-set flag yields once; +on the next condition poll a true flag wins over timeout expiry. Waiting pauses +under a foreign covering screen, preserving its lifetime and timeout. Waits do +not consume the script instruction budget on every frame. Existing cancellation, +owner/session checks, one-shot completion and instruction/slice budgets apply. +A flag store that becomes malformed after admission fails the run and restores +the original runner; already executed native writes are not rolled back. + +The identical public-only shared mod verifies branching, both aliases, signal +resume, timeout and complete-list refusal on both engines for eight versions. +It extends the pinned suite to 80 comparisons /160 isolated loader runs. +Local tests additionally verify detached rows, malformed stores/timeouts, paused +foreign overlays, native flag conditions and stale-session/owner cancellation. +The existing 5268-check dialogue suite remains unchanged and passing. + +## Wallet, inventory and party state commands + +The bounded language also accepts these synchronous rows: + +| Row | Meaning | +| --- | --- | +| `{ "give_money", amount }` / `{ "take_money", amount }` | Add/subtract money; subtraction saturates at zero. | +| `{ "check_money", amount }` | Set the conditional result to whether the current wallet covers the amount. | +| `{ "take_coins", amount }` | Subtract coins, saturating at zero. | +| `{ "check_item", itemId }` | Set the conditional result to whether the bag contains the item. | +| `{ "take_item", itemId, count? }` | Remove up to count (default one), using native Bag.remove/order cleanup. | +| `{ "check_dex_owned", amount? }` | Check the owned/caught species count, default threshold one. | +| `{ "mark_seen", speciesId }` | Mark an existing species seen using the host's native storage key. | +| `{ "heal_party" }` | Heal native party HP/status and restore PP from the current Game's move data. | + +Amounts are finite whole nonnegative numbers up to Lua's exact integer limit +(2^53-1). Item count is 1–999. Item/species IDs are canonical strings, resolved +through the existing loader index rather than assumed cartridge numbers. +Missing/ambiguous content, malformed referenced wallets/inventory/dex/party +stores or malformed arguments reject the complete list before any effect, +including unreachable rows. Accepted rows are detached; live stores are checked +again when dispatching after a wait. Native effects already executed before a +later runtime failure are not rolled back. + +Wallet writes retain the verified Gen1 API's generation behavior: Gen2/3 +addition caps at 999999; Gen1 retains its native unbounded script wallet. +Queries and item/coin removal do not create gifts or UI. Inventory and party +structures retain this host's native layouts. The optional moves argument on +Pokemon.heal lets this path use Game.data instead of an unrelated Data singleton; +existing native callers keep their default behavior. + +The same public script compares branches and resulting money, coins, item +quantity, HP/PP and seen state through both native backends on eight versions. +The full pinned checker now runs 88 comparisons /176 isolated loader runs. +Host tests add malformed/unreachable-row refusal with no prefix effects, wallet +caps, saturation, actual Bag order removal, live-store failure and argument +copying. These are ROM-free runtime/store checks, not a story playthrough. + +## Item and Pokemon gifts + +- `{ "give_item", itemId, count?, gotText?, bagFullText? }`: use the native + bag gift command. Count defaults to one (1–999). Optional receipt/full-bag + text is literal UTF-8 up to 4096 bytes; `gotText=false` suppresses the receipt. + A full bag shows its native failure message and halts before later gift flags. +- `{ "give_pokemon", speciesId, level, skipNickname?, gotText? }`: use native + creation, OT stamping, party/box placement and nickname flow. Level is 1–100; + the optional flags are actual booleans. `gotText=true` requests a receipt + before the nickname question. A full party sends the gift through native box + storage and waits for its sent-to-box text; completely full storage leaves + the native conditional result false for the script to branch on. + +The complete row list's content references, counts/options and referenced +inventory/party/player/dex/box store shapes are checked before a prefix runs. +IDs are canonical, but native bag capacities, native Pokemon fields and native +storage layouts remain the host's. Already entered native effects are not a +transaction. Scripts cannot pass executable callbacks or native options into +these rows. Gifts do not introduce new species constructors or storage slots. + +The isolated runner adopts exact native screens created by the gift operation +and screens subsequently created by those owned screens. Native receipt, +YES/NO and naming callbacks receive per-operation, per-phase one-shot guards; +an old receipt/question/naming callback cannot mutate a nickname twice, bypass +a later screen or skip the next row's wait. Cancellation removes owned screens +and retains a foreign overlay. A mod retired by its before-give hook cannot +continue into native Pokemon construction. Native callers outside the portable +context retain their existing callback/default behavior. + +The same public mod on both engines verifies item and Pokemon gifts with +receipt/nickname suppressed, real native bag/party/dex effects and whole-list +preflight refusal. This extends the pinned suite to 96 comparisons /192 isolated +loader runs. Host tests additionally drive actual native receipts, bag-full +messages, YES/NO, naming, box deposits, callback replay and unload/session +cancellation on synthetic data. Rendering/pixel equivalence and retail-ROM +playthroughs are not claimed. Direct mutations of engine objects by other mods +remain outside the portable runner's ownership guarantees. + + +## Object and block scripts + +API 3 also accepts `show_object(mapId, objectRef)`, +`hide_object(mapId, objectRef)` and `replace_block(bx, by, blockId)` on +Gen 1–3. Object references must name an existing definition; Gen 2/3 +require the active map. Block coordinates use the same geometry and limits +as `mod.world:replaceBlock`. Validation covers every row before any prefix +runs, then repeats against current stores at dispatch. + +These rows run through the validated native edit plans under the script's +existing foreground ownership. Ordinary public world actions retain their +idle-world admission guard. Gen 1's seamless object refresh may replace +both the map and its default runner: the script continues on that map and +restores the new idle runner when finished. Foreign overlays pause the +program; unloading, changed saves/maps and invalid live stores cancel it. +Edits already committed before cancellation are retained, and public world +events follow the native edit behavior. + +## Movement and facing + +On Gen1–3, API 3 accepts `move_player(dir, tiles?)`, +`move_npc(ref, dir, tiles?)`, `move_npc_to(ref, x, y)`, +`place_npc(ref, x, y, facing?)`, `face_object(ref, dir, {hold=frames}?)`, +`face_player_dir(dir)`, `face(dir)`, `face_npc()` and `face_player()`. +`face` turns the talking NPC; `face_npc` turns the player toward that NPC, +and `face_player` turns that NPC toward the player. The last three require +an existing talking-NPC context. Object references resolve existing native +actors on the active map. Counts are 0–1024 cells (default 1). + +All target references and argument shapes are checked before any command +runs. Paths are computed against the live walkable map at dispatch, using +a bounded search; an unavailable path fails the admitted program. Steps +use the native movement queue and walker updates. Each completion is tied +to its exact run, step and actor. Movement only permits its own expected +player position changes. Callbacks are one-shot, and continuation waits +for a foreground logic frame, so foreign overlays pause later effects. + +Cancellation removes only the run's exact queued entries and leaves foreign +tracks intact. Invalid sessions/actors, refused native steps and a stalled +step (3,600 foreground logic frames) terminate the script. A native step +which crosses into a different map ends this run; use explicit `warp` for +portable cross-map scripts when supported. These limits are intentionally +reported rather than silently claiming arbitrary native movement parity. + +## Explicit travel + +Gen1–3 accept `warp(mapId, x, y, facing?)` and +`remember_outdoor(mapId, x, y)`. Destination names, 0-based cell coordinates, +dimensions and facing use the public API-3 warp planner. `remember_outdoor` +sets the native outdoor/escape destination without travelling. + +Preflight follows the linear row list's map context, just as the reference +engine does. Object/block and movement rows after a warp are checked against +the destination's definitions; path/block geometry requires the destination +tileset to be available. These plans do not load a live map or run callbacks. +All dispatches still validate their actual live targets. Branches and native +entry scripts can change runtime conditions, so successful preflight is not +a guarantee that an admitted asynchronous program will complete. + +A warp uses the native transition and arrival bookkeeping. Its continuation +binds to the destination map and idle runner only after the native arrival +callback; it never resumes beneath a foreign overlay. A busy destination +runner is preserved and the portable run fails. Talking-NPC context is +cleared at arrival. One-shot callbacks, session checks and a 3,600-frame +native-transition/foreground deadline prevent late script effects. + +The transition belongs to the engine: unloading or timing out a portable +script leaves the already admitted native warp free to finish and release +its own locks. It suppresses the script continuation, rather than undoing +committed native travel or removing the engine's transition screen. + +## Wild and static battles + +Gen1–3 accept `start_battle("wild", speciesId, level)`, +`static_battle(speciesId, level, beatFlag?)` and +`check_battle_result(result, ...)` (1–4 of `win`, `lose`, `run`, `caught`). +Species IDs are canonical strings. Levels are whole numbers 1–100. +All species references and optional beat flags are checked before any +prefix runs. Healthy-party admission is checked at dispatch, so an earlier +`heal_party` can repair the party before the encounter. + +The private script admission guard reuses the public wild-battle adapter, +native constructors, entry transition and `afterBattle`. Ordinary public +calls retain idle-world admission. A static encounter sets its optional +beat flag and hides the talking NPC on win, catch or flee; loss skips both. +Result checks set the condition used by `jump_if_true`/`jump_if_false`. +The foreground continuation waits behind native post-battle and foreign UI. + +Cancelling/unloading suppresses the script continuation while preserving +native battle screens and native cleanup for the same live game/save. +Changed sessions suppress that old-session cleanup. Native constructor, +admission and cleanup errors fail the script; late callbacks are one-shot. +No rollback of already committed battle effects is implied. + +Private beat flags use this host's same namespaced flag service on every +generation. The pinned Gen1 reference delegates Gen1 static encounters to +its native command, which does not route private beat flags through that +namespace. The shared Gen1 static cases therefore exercise a real native +beat flag; host-specific private-flag behavior has separate coverage. +Trainer encounters are described below. + + +## Trainer encounters + +`start_battle("trainer", trainerRef, partyIndex?)` uses this version's native +trainer data and constructor. Gen1 normally selects a class key and numbered +party; Gen2 accepts the native class number (resolved to the host's class +record); Gen3 accepts a native trainer number through `trainerOrder`, with +one party per trainer. Existing host keys are also accepted. These selectors +are game-specific and are not universal trainer IDs. Use an explicit party +index for shared Gen2 scripts: the pinned reference requires that argument. + +Preflight validates every referenced roster before a prefix runs and repeats +that check at dispatch. Unknown species, invalid levels, empty/oversized +parties, ambiguous class aliases and unavailable Gen3 flag metadata are +refused. Native class aliases select the canonical class deterministically. +Gen3 named reference constants are not guessed from display names: use the +native trainer number for engine portability. + +Trainer battles share the same owned continuation, cancellation and native +cleanup lifetime as wild encounters. Gen3 wins use the host's native trainer +victory flag and Match Call registration helpers; loss does not mark a win. +Cleanup remains valid after the originating mod retires, provided the same +native session still exists. Native battle mechanics and game-specific story +rewards remain the responsibility of this host's battle/field code. + +## Native marts + +`open_mart(textConstant)` opens the native mart selected by the active map's +`data:textEntry(map.label, textConstant).mart` entry. Entries hold 1–99 item +IDs; canonical names and known native item IDs resolve through this boot's +item registry. Every mart row, including a row after a warp, is validated +before any prefix runs. Missing data is refused rather than guessed from +unrelated ROM pointers. Live dispatch repeats content/store validation. + +The screen factory and native buy/sell/quantity/confirmation flows remain +in charge of transactions. The script waits until the root counter closes. +UI callbacks are bound to their exact active screen and phase: closed, +cancelled, superseded or retired quantity/confirmation callbacks cannot +replay a transaction. Foreign overlays block callbacks and continuation. +Nested owned screens are removed on cancellation while foreign UI remains. +A retired constructor cannot leave its late native root on the stack. + +Wallet/inventory shapes, counts and prices must be representable (whole +non-negative amounts; prices at most 999,999, inventory at most 4,096 +entries). Checked sale totals must fit the exact integer range. Replaced +inventories or changed item records/prices cancel an open shop before later +callbacks run. Committed native transactions remain committed after +cancellation. The same guard covers classic lists, FireRed's inline sale +quantity and Emerald's native sell bag. Closed UI records are released +between frames rather than accumulated for a long shopping session. + +## Sound and music + +Gen1–3 accept `play_sound(id)`, `wait_sound()`, +`play_music(id, {keep=boolean, reason=string}?)`, `stop_music()`, +`fade_music(control?)`, `play_once(id)` and `play_default_music()`. +The active game's native audio definitions must contain the cue. All rows +are checked before a prefix runs and checked again at dispatch. Gen3 +numeric and extracted `musName`/`sfxName` aliases resolve to an unambiguous +native sequence; unknown aliases are refused. Gen3 `play_music(0)` retains +the native stop convention. `play_once("Get_Item1")` can resolve the native +Gen3 obtain-item fanfare role. + +Playback uses the existing Sound/Music controllers. `wait_sound` waits for +native SFX activity and its last returned source, with the reference's +600-foreground-frame release ceiling. A one-shot waits on native restore +state, releases if another song takes over, and fails after 3,600 foreground +frames if it stalls. Foreign overlays pause continuation. Music `keep` is +armed only while the script still owns the context. Fade control is a whole +number 0–255 (default 10); timing follows this host's native fade controller. + +Names/numbers remain game-specific unless the mod supplies a shared cue. +The reference Gen3 resolver can return an unknown string unchanged; this +host requires a real definition before admission. Missing audio data is +therefore a preflight failure; a muted/unavailable device keeps the native +headless playback behavior. Already started audio remains engine-owned on +script cancellation. Dialogue-bound cues are described below. + + +### Deferred dialogue audio + +`play_cry(canonicalSpecies, waitForButton?)`, `text_sound(id)` and +`engage_music(id)` arm the next `show_text` or `ask`, rather than playing +while its text types. Species and native cue assets are checked before any +row runs, at arm time, at box creation and again at playback. Native numeric +cry identities are resolved through canonical species names. Unknown assets +fail admission; muted devices retain native no-source behavior. + +The native TextBox waits for returned sources, with its 600-frame ceiling. +An ordinary cry auto-closes after playback; `waitForButton=true` retains the +button wait. Text sounds and engage-only music retain the button wait. +Engage music starts once, after sound completion or the native ceiling. +Questions always wait for audio before opening their native choice box. +Gen3 sequence text cues use the native fanfare music duck controller. + +Retained callbacks cannot replay cues after the box closes, beneath a foreign +overlay or after the script retires. Reentrant unload during playback aborts +the native update before it can pop an unrelated UI state. Already started +sources remain engine-owned. A combined cry and text sound plays both on this +host; the reference Gen1 opts merge drops the text sound in that combination. +Shared cases deliberately verify individual cues, not that reference defect. +They drive real native dialogue controls with synthetic font/text data and +a fake audio-device clock; they do not claim imported-ROM or PCM equivalence. + +### Save fields and options + +`set_field(key, value?)` writes a native save-root field; `mod:key` writes +only the current mod's `save.modData[modId][key]`. `set_option(key, value?)` +writes the native `save.options[key]` dictionary. As in the reference, +`set_option("mod:key", value)` retains that literal option key; it does not +redirect to a private bucket. Omitting the value deletes the selected entry. +Native root field and option names/meanings remain game-specific. + +Values must be finite numbers, strings, booleans or acyclic plain data tables +with string/positive-integer keys. Functions, metatables and foreign mod +names (`mod.other.key`, `other:key`) are refused. Replacing the root `modData` +store is refused so `set_field` cannot bypass ownership. This is intentionally +stricter than the reference's unrestricted root write. A value is limited to +16 nested levels, 1,024 nodes and 32 KiB of strings; the complete program is +limited to 8,192 nodes and 128 KiB of field strings. + +The compiler snapshots values; each execution copies again so neither the +caller nor an earlier loop iteration can mutate compiled data. All target +store shapes are checked before admission and again at dispatch. A linear +preflight accounts for an earlier root `options` replacement before a later +`set_option`. Conditional/loop behavior can still change native store validity +at runtime; a failed runtime check stops further rows. Root writes do not +translate game-specific semantics or persist the save file automatically. diff --git a/docs/ui-actions-api.md b/docs/ui-actions-api.md new file mode 100644 index 000000000..caa7c4d36 --- /dev/null +++ b/docs/ui-actions-api.md @@ -0,0 +1,128 @@ +# Public UI actions + +`mod.ui:snapshot()` and `mod.ui:submit(intent)` drive the **currently open** +native party, bag or dialogue screen. They are additive for API 1 and 2, with +no manifest opt-in. Existing `mod.ui.insertBefore`, `insertAfter`, step-list +helpers and their dot-call signatures remain available. Each mod receives its +own facade. If the optional capability-query feature is installed, +`mod:supports("ui", "snapshot")` and `mod:supports("ui", "submit")` return true. +This describes the interface, not whether the current screen accepts an action. + +The battle API can open the native party/bag menu. This API then selects its +rows and follows the native target, item, confirmation and message flow. It +does not open a menu from arbitrary world/script states. + +## Snapshot and intents + +A snapshot returns a detached table, or `nil, reason` if the top screen is +absent or unsupported. It never searches through a covering screen. Reading +early does not bootstrap Game, load a screen, poll input or render graphics. + +| Field | Meaning | +| --- | --- | +| `revision` | Positive per-facade context revision; obtain it from this facade | +| `kind` | `party`, `party_actions`, `bag`, `bag_actions`, `text` or `choice` | +| `rows` | Ordered options, including disabled placeholders; empty for text | +| `index` | Native selected row, where applicable | +| `locked` | Animation, typing, controller or script currently owns input | +| `canBack`, `canAdvance`, `canPocket` | Which non-selection intents are offered | +| `pocket` | Optional native pocket identifier; diagnostic, not portable | +| `page` | DS battle bag stage: `menu`, `pocket` or `use` | + +Every row has `index` and `enabled`. Depending on the native screen it can also +carry a string `label`/`action`, portable `item`/`species` ID, item `count`, or +`partyIndex`, `hp`, `maxHp`. Missing fields remain absent. The current dataset +resolves IDs, including numeric DS IDs and ROM-hack aliases. Labels can be +localized; native action strings are diagnostic, not a common semantic action +enumeration. There are no callbacks, native objects or future dialogue pages +in the public record. + +`select.index` is **1-based in the returned rows**. It is not always a party +slot or item number: party confirmation/cancel rows, DS list headers, GBA +blank action cells, and DS battle-bag navigation buttons retain their native +positions. Disabled rows cannot be submitted. In field bags the rows cover the +current pocket in native order; HGSS/BW scroll/page cursors are positioned for +the selected row. DS battle bags expose only their current page's six item +slots plus previous/next/back buttons. + +Intents must be plain tables with exactly the fields below: + +```lua +{ id = 1, revision = snapshot.revision, kind = "select", index = 2 } +{ id = 2, revision = snapshot.revision, kind = "back" } +{ id = 3, revision = snapshot.revision, kind = "advance" } +{ id = 4, revision = snapshot.revision, kind = "pocket", direction = 1 } +``` + +`id` is a strictly increasing positive safe integer **per UI facade**; +`revision` must come from a fresh snapshot returned by that facade. Pocket +`direction` is exactly `-1` or `1`. Unknown fields/kinds, malformed integers, +missing/disabled rows, stale contexts and unavailable actions return +`nil, reason` before native dispatch. Failed admission does not consume the ID. +Keep the ID counter across menu/Game changes. + +```lua +local nextId = 0 +local function chooseRow(index) + local snapshot, why = mod.ui:snapshot() + if not snapshot then return nil, why end + local row = snapshot.rows[index] + if snapshot.locked or not row or not row.enabled then + return nil, "row is not currently available" + end + nextId = nextId + 1 + return mod.ui:submit({ + id = nextId, revision = snapshot.revision, kind = "select", index = index, + }) +end +``` + +Take a new snapshot after each accepted action or native input/update. Revisions +track native screen/option/callback identity, live party/inventory and relevant +selection/control state. A private per-Game guard/epoch serializes UI and battle +submissions, rejects callback reentry and expires other mods' old and in-flight +snapshots. A snapshot obtained during dispatch reports `locked = true`. + +## Native behavior and limits + +- Party lists/pickers, swap/field/item submenus, forced replacement and team + selection retain their native eligibility, callback and cancellation paths. + `back` uses that screen's B behavior; it may clear a pending swap or submenu + instead of closing. The underlying battle decides how a cancelled forced + replacement is reopened. GB/GBA/DS each keep their own return semantics. +- Bags preserve native pocket order, item-use eligibility, target pickers and + consumption. The classic bag's own action menu and GBA item action grid are + supported; unrelated generic menus/lists are not inferred from their shape. + DS battle-bag presses complete on subsequent native updates, including their + page/category return paths. `true` does not mean an item has already run. +- `advance` handles a ready manual page/line break or final text, a bag's + result message, or a DS party notice. Typing and page/scroll holds are never + skipped. Multi-page question/auto/stay text can advance its intermediate + manual pages; the final script-owned page stays with its controller. +- TextBox's native choice creates the separate YES/NO screen on the next + native update. `select` chooses YES/NO; `back` selects NO. The native answer + hold and delayed callback are preserved, so the answer cannot be replayed + while pending. +- Scripts, no-input/tutorial states, healing fills, pending answer/DS battle + button animations, item sorting and quantity entry are locked. QuantityBox, + summary screens, PC/shop-specific auxiliary screens, TM cases/berry pouches, + arbitrary custom menus and custom screen subclasses have no adapter here; + they return `nil, reason`. This is not a generic UI automation API. + +`true` means a native action was admitted and entered. It may open another +screen, display a native refusal or schedule work. IDs are consumed **before** +native code. An error after partial native effects returns +`nil, "UI action failed; intent consumed: ..."`; the guard is released, but no +rollback or automatic retry occurs. Inspect a fresh snapshot before continuing. +The read/validation boundary is not a security sandbox for mods that directly +replace native engine methods or mutate Game themselves. + +## Reproducible tests + +Run `luajit tests/engine/ui_actions.lua` and `LUA=luajit bash scripts/test.sh`. +The focused suite uses real Loader/native menus, Pokémon, item effects and text +updates with the ROM-free fixture data across all 15 supported version IDs. +It checks native A/B parity, copied reads, full-graph immutability on refusal, +identity/revision/replay checks, callbacks and recovery after partial errors. +DS UI fixtures include native pocket/answer/press delays. Imported-ROM gameplay +and rendered cartridge scenes require a separate ROM-backed run. diff --git a/docs/unified-mod-callbacks.md b/docs/unified-mod-callbacks.md new file mode 100644 index 000000000..a79661ca6 --- /dev/null +++ b/docs/unified-mod-callbacks.md @@ -0,0 +1,177 @@ +# Unified hook and event records + +This is an additive, explicit Mod API 2 option inspired by Gen1Recomp's +unified callback boundary. It adapts Gen2Recomped's existing call sites across +generations 1–5. It does not introduce Gen1Recomp API 3 hooks or events. + +```json +{ + "id": "portable_hooks", + "name": "Portable hooks", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "callback_format": "unified" +} +``` + +Absent/`"native"` keeps the original API, including object identities and +custom hook names. Unknown format values and unified callbacks on API 1 fail +manifest validation. `mod.callbackFormat` exposes the selected format. The +option is independent of `content_format`; either can be adopted first. +There is no global conversion of saves, registry data or battle state. + +```lua +return function(mod) + mod.events:on("pokemon.level_up", function(event) + mod.log:info("%s reached %d", event.mon.species, event.level) + end) + + mod.hooks:wrap("battle.damage", function(next, ctx) + local damage, info = next() + if ctx.user.mon.species == "PIKACHU" then damage = damage + 1 end + return damage, info + end) + + mod.events:on("pokemon.before_give", function(gift) + if gift.species == "EEVEE" then gift.nickname = "Buddy" end + end) +end +``` + +## Records + +IDs are resolved through this boot's registries. Pokémon, move and item IDs +are symbolic strings, including on numeric-ID DS hosts. Imported numeric +records use their names (e.g. `PIKACHU`); named/custom keys retain their IDs. +No National Dex lookup or hard-coded item-number conversion is used. Unknown +or ambiguous replacement IDs fail validation. An unchanged unknown native +ID can round-trip without being guessed or rewritten. Native aliases remain +one identity even after registry merging creates separate row tables. + +| Record | Common fields | +| --- | --- | +| Live Pokémon | `species`, `item`, `nickname`, `name`, `level`, `hp`, `maxHp`, `status`, `isEgg`, `stats`, `moves`, `native` | +| Stats | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| Live move slot | `{ id, pp, maxPp, ppUps }` | +| Battler | `{ mon, id, side, native }`; `side` is `"player"`/`"enemy"` when known | +| Trainer party slot | `species`, `level`, optional `item`, `moves` as symbolic ID strings, `native`; other native metadata is read-only | +| Encounter / fishing slot | `species`, `level` or `min`/`max`; native slot metadata retained | +| Move definition | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `priority`, `category`, symbolic `effect` when available, `native` | +| Species definition | `id`, `name`, `baseStats`, `types`, `baseExp`, `catchRate`, `growthRate`, `native` | +| Item definition | `id`, `name`, `price`, `description`, `consumable`, `native` | + +Optional fields remain absent if the native data does not provide them. +`maxHp` maps to native `mon.stats.hp`. Special stats map to `spatk`/`spdef`; +Gen 1's single special stat is exposed through all three special fields. +Editing one keeps its aliases consistent; contradictory edits are rejected. +Type aliases match unified content IDs (`ELECTR` → `ELECTRIC`, `FIGHT` → +`FIGHTING`, `PSYCHIC`/`PSYCHC` → `PSYCHIC_TYPE`, `MYSTERY` → `CURSE_TYPE`). +Status values are `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or +`nil`; clearing uses `nil` or `false`. Unknown existing statuses are retained +until explicitly changed. `isEgg` is the native boolean flag. + +Definitions and `native` snapshots are detached, read-only diagnostics. Use +content registration during mod initialization to change definitions. The +callback view intentionally exposes a limited common definition shape; +additional generation-specific fields are available under `native`. +Battler metadata is read-only. Change its live Pokémon via `battler.mon`. +Shared native records share one public view within a call, so `user.mon` and +`target.mon` remain the same object when they reference the same Pokémon. + +## Adapted hooks + +| Hook | Adapted boundary | +| --- | --- | +| `battle.accuracy`, `battle.damage`, `battle.crit`, `exp.gain` | Context records: battlers, Pokémon, move/species definitions, known ID fields | +| `battle.run`, `battle.catch_exp`, `battle.low_health_alarm` | Context shell; native battle handle | +| `battle.turn_order` | Both battler/move pairs; bound battler result preserves native identity | +| `battle.enemy_action` | Second argument battler; move-action `id` in result | +| `battle.exp_award` | `alive` Pokémon list and `applyShare` helper | +| `catch.rate` | Ball ID, Pokémon, species definition, `opts.ballDef` item definition | +| `trainer.party` | Third argument and result use trainer slots, not live saved Pokémon | +| `evolution.check` | Second argument live Pokémon, evolution/trigger context known IDs | +| `fieldmove.eligibility` | Move ID and bound live Pokémon result | +| `encounter.fishing` | Rod ID, candidate list and encounter result | +| `encounter.roll` | Context and encounter result; imported encounter-table argument stays native | +| `encounter.species` | Encounter argument/result and context | +| `pokemon.sprite`, `pokemon.icon` | Context Pokémon and species ID; path result stays native | +| `ui.party.submenu` | Third argument live Pokémon; menu rows and nested callbacks stay native | + +The remaining native hooks pass their arguments/results through. In +particular, UI callbacks, renderers, input, script arguments, maps, movement, +saves, intro speech and link fingerprint data retain their engine contracts. +Registering an unknown engine callback in unified mode fails immediately. +The source-catalog test forces an explicit boundary decision for new seams; +registration is not a promise that a given game executes that seam. Where +available, use `mod:supports("hooks"/"events", name)` to query the host. + +`battle.exp_award`'s `ctx.applyShare(mon, divisor, announce)` accepts a bound +public Pokémon, commits pending validated edits, calls the native helper, +and refreshes the public views. It retains native argument/result behavior, +including errors. Retained helpers and `next` cannot be used after the hook +callback returns. Live party membership/order cannot be changed through this +view. New encounter, enemy-action and trainer-party records may be returned; +live Pokémon/battler replacements must be records bound during that call. + +## Mutation and failure behavior + +Edits to represented live Pokémon fields, trainer slots, encounters and +context fields are validated before being written. Levels must be 1–100; +stats/max HP positive integers; HP must fit the maximum. Move lists contain +at most four entries, PP must fit the selected move and PP Ups, and `maxPp` +is read-only. Trainer parties contain at most six slots. Unsupported fields, +read-only changes and invalid IDs reject the transaction before any staged +record is changed. Hidden native metadata (IVs, EVs, personality, slot fields) +is retained. Native Pokémon, stats tables, move lists and existing move slots +keep their identities, including battle aliases into those tables. + +Before `next()`, validated edits are committed together. After downstream +returns, public views refresh from native state. A successful callback's +later edits commit together too. Throwing before `next()` skips the wrapper; +throwing/validation failure after `next()` keeps downstream results and +changes. Already committed edits and native helper effects are not undone. +Calling `next()` again returns the cached result (including nil holes), or +rethrows the cached error; it never repeats downstream gameplay. Native +errors preserve their identity and are not converted into mod failures. + +Only the represented record edits participate in this transaction. Game, +battle, save, world, map, data and RNG handles stay native, as do unclassified +engine-specific fields; direct changes through such handles have ordinary +native side effects. Changing a species/level does not automatically rebuild +battle definitions, recalculate stats, grant moves or emit evolution events. +Use the engine's gameplay operations when that bookkeeping is required. + +## Events + +Ordinary engine notifications copy their outer context and adapt known +record fields/IDs: battle Pokémon/battlers/moves, level-up learnable move IDs, +move actions, caught/received/evolved Pokémon and trade records. Edits to +these snapshots do not write back. Engine handles remain native, including +`side`, `battle`, `game`, interaction targets and script contexts. A status +source such as `SYNCHRONIZE` remains an engine label rather than a guessed +move ID. + +`pokemon.before_give` is the explicit mutable exception: `species`, `level` +and optional `nickname` are validated and committed atomically. Its script +`ctx` stays native. A failure changes none of those gift fields, and later +listeners continue through the original event bus. + +Save lifecycle/repair events, screen and intro-speech events, `game.ready`, +`mods.loaded`, and mod option events pass through natively. This preserves +save repair semantics, forwarding metatables and nested callback identities. +Namespaced `mod..` events also pass through unchanged. +Mods may listen to other mods' events and emit only their own namespace. +Priorities, unsubscribe, once semantics, owner attribution and failed-entry +rollback are provided by the original event/hook buses. + +## Verification + +`tests/engine/unified_mod_callbacks.lua` runs through a ROM-free in-memory +filesystem and the real loader/event/hook buses across every supported host. +It covers string/numeric IDs and aliases, transactional writeback, native +identity, mixed mod chains, `next`/helper failures, event isolation, mutable +gifts, priorities, ownership rollback and the native default. The catalog +checks compare against actual source call sites, rather than another list. +Run it with the same LuaJIT runtime as the engine, then run `scripts/test.sh`. +ROM-dependent gameplay checks still require an imported legal ROM. diff --git a/docs/unified-mod-content.md b/docs/unified-mod-content.md new file mode 100644 index 000000000..6108608a9 --- /dev/null +++ b/docs/unified-mod-content.md @@ -0,0 +1,187 @@ +# Unified definition records (opt-in) + +A mod can use the same core content records when modifying different game +versions. Enable the format explicitly in an API-2 manifest: + +```json +{ + "id": "portable_balance", + "name": "Portable Balance", + "version": "1.0.0", + "api": 2, + "content_format": "unified", + "generations": [1, 2, 3, 4, 5], + "entry": "main.lua" +} +``` + +```lua +return function(mod) + -- Required when distributing to older engine builds: those builds do not + -- understand content_format and would otherwise supply native records. + assert(mod.contentFormat == "unified", "This mod needs unified content support") + if mod.content.moves:has("TACKLE") then + local ok, reason = mod.content.moves:patch("TACKLE", { power = 45 }) + assert(ok, reason) + end +end +``` + +Omitting the field (or using `"native"`) preserves existing mod behavior. +API 1 cannot opt in. Mod API stays at **2**: this is the content-record part of +portability, not Gen1Recomp's complete API 3. Callback payloads, saved Pokémon, +world/battle services, scripts and other registries retain their native forms. +A mod still needs testing on every game it claims. + +## Available records + +The five handles retain `register`, `override`, `patch`, `remove`, `get` and +`each`, and add `has`, `supports()` and `available`. Other content handles pass +through unchanged. `mod.assets` retains its existing content-handle aliases. +On engines that provide `mod:supports`, content queries follow the selected +format's availability; other capability surfaces retain their existing answers. + +| Handle | Unified fields | Supported versions | +| --- | --- | --- | +| `pokemon` | `id`, `name`, `dex`, `types`, `baseStats`, `catchRate`, `baseExp`, `growthRate`, `level1Moves`, `learnset`, `evolutions`, `tmhm`; optional breeding/art fields | Existing definitions, Gen 1–5 | +| `moves` | `id`, `name`, `type`, `power`, `accuracy`, `pp`, `effect`, `secondaryChance`, `category`, `priority`, `description` | Existing definitions, Gen 1–5 | +| `items` | `id`, `name`, `price`, `machine`, `effect`, `ball`, `tossable`, `needsTarget`, `description` | Existing definitions, Gen 1–5 | +| `trainers` | `id`, `name`, `pic`, `baseMoney`, `battleTheme`, `partyNames`, `parties` | Existing definitions, Gen 1–5 | +| `encounters` | `id`, `grass`, `water` | Gen 1–3 grass/water tables | + +Reads and iteration return detached records. Editing a returned table does +nothing until a write method is called. Each record also contains a detached, +**read-only** `native` snapshot for diagnostics. Unexposed native fields are +preserved by edits; changing that snapshot is refused. `override` requires a +complete unified record and preserves omitted optional/native extension fields; +use `mod.DELETE` to clear a represented optional field. For normal +edits use `patch`; this keeps earlier and later mods' independent fields in the +registry's normal operation order. Writes return `true` on success. + +`register` supports string-key definitions on Gen 1–3, except new Gen 3 species: +their EV/ability/storage metadata has no canonical constructor here. Numeric +DS allocations are refused. This layer does not invent storage slots or +metadata. Use native content for those additions. Existing records can be +patched, overridden and removed across all five generations. + +### IDs and references + +Species, move and item IDs are uppercase symbolic strings, such as `PIKACHU`, +`TACKLE` and `POTION`. Resolution uses the selected dataset's native keys and +names, not fixed cartridge numbers. `FARFETCHD`, `MR_MIME`, `NIDORAN_F` and +`NIDORAN_M` retain distinct names. `TM01`/`HM01` names normalize to +`TM_01`/`HM_01`. Types use Gen 1 spellings, including `PSYCHIC_TYPE`; the native GBA abbreviations `PSYCHC`, +`FIGHT` and `ELECTR` map to their common names. + +A numeric table or a generated key such as `SPECIES_025` is mapped through its +name in that ROM. Renaming a definition through a mod does not change its ID +within that load. Numeric/string aliases of one record are read once and +written or removed together. Multiple distinct records with one canonical +name are ambiguous: the adapter refuses to choose one. Missing or ambiguous +`get` returns `nil, reason`; `has` returns false; ambiguous iteration raises. + +Trainer and encounter IDs remain game-specific; no cross-game equivalence +between a trainer class, a numbered individual or two maps is implied. +Numeric trainer IDs are exposed as decimal strings. References must resolve +when a write is made: define a referenced record first or declare a mod +dependency that provides it. Missing references return `nil, reason` before +an operation is appended. Removing a referenced definition retains the +loader's post-merge, owner-attributed reference diagnostics, including DS +numeric targets and Gen 2 time tables. + +### Pokémon and trainers + +`baseStats` uses `hp`, `attack`, `defense`, `speed`, `special`, with optional +`specialAttack` and `specialDefense` for the later split. A patch to `special` +sets both halves on Gen 2–5; explicit split fields can change them separately. +Conflicting values are rejected. Gen 1 refuses unequal Special stats. + +`level1Moves` is a list of move IDs. `learnset` is +`{ { level = 9, move = "TACKLE" } }`. Evolutions use +`{ method = "LEVEL", level = 20, species = "RAICHU" }`, or `ITEM`, `TRADE` +and `FRIENDSHIP` with `item`, `time` (`ANYTIME`, `MORNDAY`, `NITE`) or +`comparison` (`ATK_GT_DEF`, `ATK_LT_DEF`, `ATK_EQ_DEF`) where representable. +Held trade items and friendship/stat conditions are translated for Gen 2–3. +DS level evolutions are translated to their numeric target and parameter. +Other DS evolution writes and unknown method changes are refused; unchanged +native methods survive a detached read/modify/write. Gen 1 refuses eggs, +breeding fields, held items and later evolution conditions. + +Trainer `parties` always has the form +`{ { { species = "PIKACHU", level = 18, item = "POTION", moves = { "TACKLE" } } } }`. +Items and moves are optional, and held items require Gen 2 or later. Native +per-slot IVs, flags and AI metadata are retained. A DS trainer's single `party` +and `parties[1]` aliases stay synchronized. + +Move accuracy is percent, as in the engine's existing normalized definitions. +Gen1–3 move effects use canonical IDs, resolved to the host's actual native +battle handlers before an operation is journaled. For example +`SPECIAL_UP1_EFFECT` selects `SP_ATK_UP1_EFFECT` on Gen2/3; the Gen1 Special +stage does not leak into split Special Attack/Defense. `HIT` and `SLEEP` are +accepted aliases. Registered custom native effects retain their own IDs. + +`secondaryChance` is an optional whole percentage (0–100) on Gen2/3. Explicit +odds convert to the nearest native fraction out of 256; detached unmodified +records retain their exact original byte. Explicit zero is distinguished from +a legacy native zero byte whose controller uses a fallback. Changing a +canonical effect applies its reference default chance unless the caller +supplies odds. Authored aliases survive later odds edits. + +Unknown native selectors are opaque `native:generation:selector` tokens on +Gen2/3. They roundtrip only with their original record; they cannot assign an +unknown handler to another move. Gen4/5 retain the previous diagnostic-only +numeric selector behavior and refuse effect/chance changes. Item behavior +and changing a DS move type remain unavailable without their native adapters. + +The data-only binding catalog is reproduced from pinned Gen1Recomp contract +maps plus the host importer's native names by +`tools/move-effect-contract/generate.py --gen1 CLEAN_REFERENCE --lua LUAJIT +--check`. It does not execute imported content. A binding still requires a +real registered host handler: FOLLOW_ME, HELPING_HAND, MAGIC_COAT, SNATCH, +IMPRISON, ASSIST, NATURE_POWER and SECRET_POWER are refused on the current +host. Identity-dependent native Rest/SonicBoom variants are not assigned to +an incompatible move. This adapter selects native behavior; it does not add +missing battle mechanics or promise cross-engine RNG/pixel equivalence. + +### Encounters + +An area is `{ rate, slots = { { species = "PIKACHU", level = 5 } } }`. +A range uses `minLevel` and `maxLevel` instead of `level`. Rates are fractions +out of **256**, including fractional values needed to represent GBA rates +exactly. Conversion refuses a rate that cannot be represented in native units; +it never silently rounds. Slot edits retain the native count and probability +buckets. Changing slot counts or weights requires native content. + +Gen 2 also exposes `grass.times.MORN`, `DAY` and `NITE` in the same area shape. +A shared rate/slot patch applies to every native time. An explicitly changed +time can override that patch. Unchanged inherited times in a detached record +do not undo a shared edit. + +DS encounter tables carry daily, seasonal, swarm and other replacements. +This adapter reports `available = false` and `supports()` returns `false, +reason` for them; reads/writes return `nil, reason` and iteration is empty. +The native encounter API remains available to mods using the native format. + +### Lists, failures and lifecycle + +Lists replace; `__append` and `__prepend` extend them. `{}` and `mod.DELETE` +clear represented lists, including inherited learnsets and TM lists. Required +scalar fields, base stats and species types cannot be deleted. Other optional +fields support `mod.DELETE` where their native representation permits it. + +Malformed records raise schema errors. Non-representable operations return +`nil, reason` before recording anything. Payloads, returned records and native +snapshots never share writable tables with the operation log. Frozen content +still rejects writes. An entry failure rolls back every operation owned by +that mod, including all aliases. No Game, save, graphics or imported ROM is +needed to query or construct the content facade. + +## Validation + +Run `luajit tests/engine/unified_content.lua`. The tests load synthetic datasets +through the real loader, exercise all registered host versions and compare +native and unified round trips. Behavioral checks use native Pokémon/trainer +construction, encounter selection, DS move parsing and evolution dispatch. +This proves the exercised formats and consumers, not real-ROM playthroughs or +visual correctness. The complete local suite and its base comparison are +reported in the pull request. diff --git a/docs/version-datasets.md b/docs/version-datasets.md new file mode 100644 index 000000000..28a3e418d --- /dev/null +++ b/docs/version-datasets.md @@ -0,0 +1,67 @@ +# Read-only version datasets + +API-3 mods can inspect another imported Gen1–3 version without importing engine +modules or switching the active game: + +```lua +local view, why = mod.datasets:open("gold") +if view then + local mew = view.content.pokemon:get("MEW") + for id, species in view.content.pokemon:each() do + -- Canonical content IDs and detached data records. + end +end +``` + +The view contains `version`, `generation`, `content` and `assets`. Registry +handles expose `get`, `has` and sorted `each`; write methods are absent. Core +records use the existing unified content shapes. Function-bearing engine +records are omitted. No live game, save, mod overlays or runtime registration +state is exposed or changed. A view is independent of enabled mods. + +`open` returns `nil, "unknown_version"`, `"unsupported_generation"`, +`"not_imported"` or `"invalid_cache"` when appropriate. The host's exact +import format and ROM-hash marker is authoritative; required generated +metadata must be present. A complete developer source can be used without a +marker only with verified source provenance for every required module. +Gen4/5 dataset adapters remain unavailable. + +Generated files are parsed as literals, never compiled or executed. Limits +are 8 MiB per module, 48 MiB aggregate, depth 64, 500,000 nodes, 250,000 entries +per table and 2 MiB per string. Arrays, keyed tables, comments, quoted/long +strings and finite numeric literals are accepted; code, operators, duplicate +keys and invalid/nonfinite data are refused. Native record schemas validate +reads. `open` leaves semantic data lazy; engine descriptor shaping may load +additional data roots at first access. + +Repeated reads check cached module bytes and rebuild the isolated view after +valid edits. A rejected view stays retired; reopen it after repairing the +cache. Iteration returns a detached, validated snapshot rather than mixing +records from different revisions. Observed content changes, marker changes +and root changes retire outstanding iterators. Merely editing a file does not +mutate an already captured snapshot. Iteration does not re-read every large +generated module for every returned row. + +`view.assets:path("assets/generated/...")` returns the host's native virtual +path; `assets:info` returns copied file metadata. Paths reject traversal, +backslashes and control characters. Cache layouts differ between engines: +use the returned path rather than composing a prefix. Gen2Recomped's Red +cache remains at the root. While another edition's overlay is active, a Red +asset path is refused (`nil, "unavailable asset path"`) because that string +would resolve to the active edition. Exact Red metadata and asset info still +use the correct physical cache. Other editions retain explicit prefixes. + +Native reads use the already resolved cache root, default save directory or +verified developer source. They never initialize a cache root, mount or +unmount a version, change `CacheFs.prefix`, change `GameVersion`, load a Game, +execute an imported module or rewrite a cache. If root resolution has not run, +only verified developer source is considered. Asset paths are native resource +addresses, not permanent handles to immutable file bytes. + +Shared contract fixtures use the same mod on eight versions and both engines, +with each backend's own synthetic cache format. This validates public reads, +mutation isolation, canonical iteration and asset metadata; it does not claim +ROM-content equality or real asset decoding. Local tests additionally cover +parser bounds, stale/malformed caches, repairs, iterator revisions, real +physical file reads beneath a conflicting virtual overlay and the developer +permission tripwire. diff --git a/docs/world-actions-api.md b/docs/world-actions-api.md new file mode 100644 index 000000000..230b7737e --- /dev/null +++ b/docs/world-actions-api.md @@ -0,0 +1,135 @@ +# Public world actions + +`mod.world` adds six methods without a manifest opt-in or Mod API bump: +`canReorderParty`, `reorderParty`, `availableFieldActions`, `useFieldAction`, +`canFly`, and `flyTo`. The new actions validate free-roam state before using +existing native gameplay entry points. Existing world methods, party menus +and script commands keep their contracts. + +```lua +local ok, reason = mod.world:reorderParty(1, 2) +if not ok then mod.log:info("%s", reason) end + +local actions = mod.world:availableFieldActions() +for _, action in ipairs(actions) do + -- { id = "surf", label = "SURF" }, for example. + -- Show a companion UI; revalidate when the player actually chooses it. +end + +-- Later, from an explicit player choice: +local started, why = mod.world:useFieldAction("surf") +local flying, flyWhy = mod.world:flyTo("A_NATIVE_DESTINATION_MAP_ID") +``` + +## Free-roam boundary + +The save, overworld map, player, native script runner and authoritative stack +top must be present and ready. Methods refuse while another screen owns the +stack (including battles/dialogue), a script is running/queued, scripted +movement is pending, a transition or field animation is active, the player +is moving/locked, a mod script lock is held, a decoration mode owns input, +or a link session is live. Native engine errors propagate; an action that +throws is never retried by this facade. + +`canReorderParty()` and `canFly()` return booleans. Mutations return `true` +when accepted, or `nil, reason` when refused. `availableFieldActions()` gives +an array of detached `{ id, label }` records, or an empty array and a reason +when the world is unavailable/busy. Queries use native eligibility checks, +including existing mod hooks; the facade does not itself perform an action +while probing. A mod's own hook can still have ordinary native side effects. +Recursive mutations from those eligibility hooks are refused, and a changed +source context invalidates the pending request. + +A previous successful probe/list is not a reservation. Every action checks +state again when invoked. Typical reasons are `no save`, `no overworld`, +`world is busy`, invalid arguments, unavailable destination or unavailable +field action. Availability of the interface does not promise every action +exists in every generation. + +## Party ordering + +`reorderParty(fromSlot, toSlot)` swaps two existing party references, matching +the native party menus. It does not insert/remove Pokémon, recalculate them +or copy their data. Slots are integer, one-based indices from 1 to 6; both +must exist. Fainted Pokémon and eggs can be reordered. A same-slot request is +a successful no-op. Invalid slots refuse before changing the party. + +`canReorderParty()` is true only when the world is ready and there are at +least two party members. A retained facade reads the current save when used. +No menu selection or battle party order is changed behind a screen's back. + +## Flight + +`canFly()` additionally requires a real non-egg party carrier, the native +badge/location checks and at least one currently unlocked, valid destination. +`flyTo(mapId)` accepts a native map ID from that game's destination set. It +is not a general warp: unknown, locked, unvisited or invalid landing points +are refused before departure state changes. A caller-provided coordinate, +spawn-table index or arbitrary destination object is not accepted. + +| Host | Eligibility and destinations | Native departure | +| --- | --- | --- | +| Gen 1/2 | `partyKnows("FLY")`, outside-map rules, visited `field.flyOrder` towns with `flyWarps` | `OverworldState:flyTo` | +| Gen 3 | Native carrier/badge check, `Gen3FieldRules`, visited town set | Native field-move cut-in, then `flyTo` | +| Platinum / HGSS | `Gen4FieldMoves`, `Gen4Fly.check/destinations`, partner/Safari/badge checks and HGSS costume refusal | `gen4FlyTo` | +| Black / White | Native numeric move lookup, `Gen5Fly.allowed/destinations/destination`; no invented badge gate | `gen4FlyTo` | + +The native handlers retain departure timing, player input lock, bike/surf +state changes and exact landing coordinates. The direct map-ID call does +not open a destination picker. Gen 3 keeps its native Pokémon cut-in, falling +back directly when there is no picture to display. + +## Verified contextual field actions + +This initial adapter deliberately exposes only these native paths: + +| Action ID | Gen 1 | Gen 2 | Gen 3 | Gen 4/5 | +| --- | --- | --- | --- | --- | +| `cut` | Native cut eligibility + `tryCut` | Same, reaching native Gen 2 Cut | Refused: object-script adapter required | Refused: native script adapter required | +| `surf` | Native eligibility + `trySurf` | Same | Same, with native field-move presentation | Refused: native script adapter required | +| `dig` | Eligible dungeon → native last-heal departure | Eligible cave/dungeon → recorded entrance | `Gen3FieldRules` → recorded entrance | Refused: native script adapter required | +| `teleport` | Outside map → last heal | Outside map → last heal | Native travel rules → last heal | Refused: native script adapter required | + +Native carrier lookup selects a current non-egg party member. Badge checks +and target/location gates are retained; fainted carriers remain eligible +where the native engine permits them. Surf starts mounting; there is no +invented generic dismount action. Dig and Teleport keep distinct native +escape/last-heal semantics. Cut uses live target eligibility rather than +matching a global block number. + +`useFieldAction(id, opts)` accepts no options or an empty plain options table. +Unsupported options, action IDs and generation adapters return a reason. +This version does not expose bicycle/fishing, Strength/Flash, healing moves, +custom source selection, Gen 3 object-script actions or DS field-script +activation. These require separate adapters with their native checks; callers +must use the returned action list rather than assuming a fixed menu. Fly is +separate because it needs a destination. + +## Deferred completion and cancellation + +A `true` result means the native request was accepted, not that the animation, +dialogue or warp has finished. Native gameplay owns those asynchronous flows. +A Gen 3 cut-in blocks competing public mutations until it finishes. Its +effect is guarded by the source save/data/map/player, position/facing, version, +party membership and fresh eligibility. Changing that context cancels the +effect. A repeated completion callback cannot replay it. Abandoning the +presentation does not leave a permanent facade lock; a stale callback cannot +start a later effect. Already executed native gameplay is not rolled back. + +## Capabilities and tests + +With the optional capability-query feature, each of these six method names +returns true from `mod:supports("world", name)` without constructing Game or +the world. Other capability answers are delegated unchanged. Use +`canFly`/`availableFieldActions` and the mutation's return value for current +state and action-level availability. + +`tests/engine/world_actions_api.lua` uses the real loader across all 15 host +version IDs. It exercises refusal parity for world/player/script/link locks, +party identities, slots, destination gates, eggs, partner/costume checks, +contextual dispatch and reentrant/deferred/error paths. It also runs actual +native carrier/badge lookup, Fly/DS Fly departure methods and Gen 1–3 +Dig/Teleport departure state changes without ROM assets or graphics. A cut-in +test double drives completion/cancellation deterministically; it is not a +rendered playthrough. ROM-dependent map scripts and graphics still need an +imported legal ROM. diff --git a/docs/world-overview.md b/docs/world-overview.md new file mode 100644 index 000000000..e3c494197 --- /dev/null +++ b/docs/world-overview.md @@ -0,0 +1,105 @@ +# World overview and encounter queries + +These additive API-1/API-2 readers use the Gen1Recomp `mapOverview` and +`effectiveEncounters` result shapes. They return detached tables and do not +load maps, consume RNG, run mod callbacks, refresh objects, or change the save. +They are available through the normal lazy `mod.world` facade. Hosts with +`mod:supports` report both names on the `world` surface before Game exists. +This does not opt a mod into API 3 or change existing actions/encounter rolls. + +```lua +local overview, reason = mod.world:mapOverview() +if overview then + -- Cells and marker coordinates start at zero; Lua row arrays start at one. + local terrain = overview.rows[y + 1]:sub(x + 1, x + 1) +end + +local encounters, reason = mod.world:effectiveEncounters(mapId, "grass", { + daytime = "NITE", -- optional; MORN/MORNING, DAY, NITE/NIGHT +}) +if encounters then + local total = 0 + for species, weight in pairs(encounters.dist) do total = total + weight end + -- Conditional species share = weight / total (when total > 0). +end +``` + +## `mapOverview()` + +Requires an active overworld; menus/battles above it do not prevent reads. +At the title screen, returns `nil, "no overworld"`. + +| Field | Meaning | +| --- | --- | +| `mapId`, `width`, `height` | Active map ID and size in 16px walk cells | +| `rows` | `height` strings, each exactly `width` ASCII characters | +| `markers` | Array of detached `{kind, x, y}` records | +| `tileRows`, `tileWidth`, `tileHeight` | Optional visual shade grid at twice the cell resolution | +| `tileDetailRows`, `tileDetailWidth`, `tileDetailHeight` | Optional visual shade grid at four times the cell resolution | + +Terrain characters are `+` for a native warp tile, `~` for water, `.` for +walkable ground, and a space for a wall, in that order of precedence. This +summarizes terrain, not directional movement permissions, elevation crossings, +NPC occupancy, or a pathfinding result. Runtime block/collision patches win. +Packed GBA blocks are decoded privately; a cold map remains cold afterwards. + +Markers identify in-bounds `warp` events, `item` objects actually in the active +NPC list (at their current cells, excluding hidden objects and fruit trees), +and untaken `hidden` item spots. Hidden-item signs also honor their event flags +and their distinct native taken keys. Neighbour-map objects are not included. +The snapshot reflects the loaded object list; it does not run visibility or +spawn filters again. Hidden markers deliberately reveal item locations to the +requesting mod, as in Gen1Recomp. + +GB visual grids use the resolved, mod-overridable atlas's pixel luminance: +`"0"` is lightest, `"3"` darkest. `tileRows` averages each 8x8 tile; +`tileDetailRows` preserves each 4x4 quadrant. Missing/invalid art is omitted. +The private visual cache is invalidated with the engine's asset cache. +GBA metatile compositions and DS 3D art have no GB atlas, so visual grids +are omitted there. Terrain grids support GB/GBA maps and resident DS interior +permission grids. DS maps without a resident behaviour grid return +`nil, "map overview unavailable for streamed terrain"`; reading streamed +renderer chunks would load/cache terrain during a read query. + +## `effectiveEncounters(mapId, terrain, opts?)` + +Takes an explicit, known map ID, including at the title screen. `terrain` is +`"grass"`, `"water"`, or `"indoor"` (the grass table used by cave floors). +Returns `{chance = number, dist = {[publicSpeciesId] = integerWeight}}`. +Unknown maps, invalid terrain/options/time, unloaded encounter data and invalid +bucket/rate metadata return `nil, reason`. A known map without that table +returns `{chance = 0, dist = {}}`. + +`chance` is the table's independent integer rate gate, clamped to its native +denominator (256 by default, 100 on DS adapter tables). It is **not** the final +probability of a battle on the player's next step. `dist` aggregates consecutive +cumulative bucket differences for duplicate species. Weights are not percentages: +use their sum when computing conditional shares. Missing slots/uncovered bucket +space remain empty outcomes, just as in the native chooser. Zero-rate tables +return an empty distribution. Species IDs use the same dataset-derived public +names as the other portable readers, including numeric DS species and aliases. +Loader identity metadata is captured before presentation-name patches. + +Gen2 land tables use the current clock period, or the last effective cached +period when a `world.tod` hook is installed. `opts.daytime` overrides this +without dispatching the hook or updating the world. Active Gold/Silver/Crystal +swarm tables are selected by the native resolver. DS previews use the native +map-to-table indirection, day/night substitutions, active daily slots/outbreaks +and BW season selection. DS `daytime` selects a representative hour (6/12/22). +Water uses its own table. + +This is a **table preview**: it excludes per-step ability/radio/held-item rate +changes, movement/grace gates, Repel, roamers, lead-based slot filtering, +fishing, Radar, phenomena and dark-grass overrides. It never samples or executes +`encounter.roll`, `encounter.species`, or `world.tod`. Gen2Recomped has no native +`encounter.table` hook; this PR does not introduce one or pretend to preview +arbitrary mod callbacks. A future shared table-transform seam can extend both +the native roll and preview together. + +HGSS's position-dependent Safari Zone returns +`nil, "position-dependent Safari encounters unavailable"`; its native resolver +consults the global player's position and can initialize save state. Emerald's +Battle Pike wild room and Battle Pyramid floors return +`nil, "facility encounters unavailable"`; these use separate facility tables. +These refusals keep an explicit-map query from reporting another map's table +or changing gameplay state. diff --git a/docs/world-read-api.md b/docs/world-read-api.md new file mode 100644 index 000000000..01b2a60d0 --- /dev/null +++ b/docs/world-read-api.md @@ -0,0 +1,151 @@ +# Read-only world and party queries + +Native mods can read `mod.world:party()`, `activeBlockAt(mapId, bx, by)` and +`liveMaps()` across generations 1–5. These are additive methods: no manifest +format switch or Mod API bump is required. Their records use the common live +Pokémon shape from unified callbacks, independently of the mod's selected +content/callback format. Existing world actions and `loadedMaps()` retain +their previous behavior. + +The methods read the current save/runtime map when called. They do not load +maps, decode whole packed grids, create Pokémon, emit events, write state or +advance random generators. Returned tables are detached plain data; editing +or retaining them never changes the game. Native engine handles remain +available through the existing APIs, but these three methods return none. + +```lua +return function(mod) + mod.events:on("world.stepped", function() + local here = mod.world:current() + if not here then return end + + local party, reason = mod.world:party() + if not party then mod.log:warn("%s", reason); return end + + local maps = mod.world:liveMaps() + local geometry = maps and maps[1].block + if geometry then + -- current().x/y use walk cells; activeBlockAt uses map blocks. + local cellsPerBlock = geometry.tileSize / geometry.cellSize + local block, info = mod.world:activeBlockAt(here.mapId, + math.floor(here.x / cellsPerBlock), math.floor(here.y / cellsPerBlock)) + if block ~= nil then mod.log:info("%s block %d", info.kind, block) end + end + end) +end +``` + +## Party snapshots + +`party()` returns an array in native party order. A save with an empty or +absent party gives `{}`; no save gives `nil, "no save"`. Reading a saved party +does not require an active overworld, so menus and a title-screen save are +valid contexts. A retained facade observes a replacement of `game.save` on +its next call; previously returned records remain snapshots. + +Each Pokémon has the following common fields (optional native values may +be absent): + +| Field | Meaning | +| --- | --- | +| `species`, `item` | Symbolic IDs; an absent/zero held item gives `nil` | +| `nickname`, `name` | Native nickname and optional native name | +| `level`, `hp`, `maxHp` | Current values; `maxHp` is native `stats.hp` | +| `status` | `sleep`, `poison`, `burn`, `freeze`, `paralyze`, `toxic`, or absent; unknown native status labels are retained | +| `isEgg` | Native egg flag as a boolean | +| `stats` | `hp`, `attack`, `defense`, `speed`, `special`, `specialAttack`, `specialDefense` | +| `moves` | Array of `{ id, pp, maxPp, ppUps }`; absent definitions leave `maxPp` absent | +| `native` | Detached data-only diagnostics, including IVs/EVs and custom save fields | + +IDs use this boot's actual registries, including custom records and numeric +DS IDs, rather than hard-coded National Dex numbers. Numeric IDs resolve via +imported names; existing named keys retain their names. Standard aliases use +the shared unified-ID resolver. Identity/name metadata is captured before +mod merging, so a display-name patch does not rename a canonical ID. Records +registered later during mod initialization remain visible. Unknown IDs are +preserved as strings; this is a read-only view, not an allocation or reference +validation API. The native diagnostics retain the underlying numeric IDs. + +Gen 1's single special stat fills both special-attack and special-defense +fields; later split stats read native `spatk` and `spdef`. PP maxima use native +PP-Up rounding (`base + ppUps * floor(base / 5)`). Reads never recalculate +stats, learn moves, infer shininess or consume RNG. Functions, userdata, +threads, metatables and table-valued keys are excluded from diagnostics; +cycles in data are copied without retaining native tables. + +## Active block lookup + +`activeBlockAt(mapId, bx, by)` returns `blockId, metadata`, or `nil, reason`. +The expected map ID is required: a request prepared before a warp cannot +silently inspect the replacement map. Only the active runtime map is read. +Coordinates are finite, nonnegative integers in **zero-based block units**; +off-map coordinates are refused rather than returning the map's border fill. +A block ID of zero is a valid result: check `blockId ~= nil`. + +Runtime `blockPatch` entries take precedence, including zero-valued changes. +Table grids, packed little-endian 16-bit grids and an existing decoded cache +follow the runtime Map representation. A cold packed read extracts only the +requested word and its low ten ID bits; it does not populate `_blockArray`. +Missing/truncated data, negative/invalid IDs and inconsistent geometry give +`nil, reason`. No call into map loading, rendering or collision is made. + +Metadata is a fresh record: + +| Field | Units / meaning | +| --- | --- | +| `tileSize` | Pixels per map block, from runtime `blockTiles * 8` | +| `cellSize` | 16 pixels per walk cell | +| `coordinateBase` | `0` | +| `width`, `height` | Map dimensions in blocks | +| `kind` | `block`, `metatile`, or `runtime_cell` | + +Ordinary Gen 1/2 blocks are 32 pixels; Gen 3 metatiles are 16 pixels. Geometry +comes from the actual runtime tileset so custom maps need not match their +selected generation's usual size. Gen 4/5 imported maps use a 16-pixel proxy +grid in the shared runtime; `runtime_cell` explicitly identifies that grid. +Its values are synthetic runtime/behavior values, **not DS mesh/model IDs or +a public collision/elevation encoding**. Use the existing terrain/elevation +services for those questions. A DS map must carry the runtime grid; this +method does not generate one from meshes or lazily load shared layouts. + +## Loaded map snapshots + +`liveMaps()` returns only maps already held by the active overworld, with the +active map first and neighbors in native order. It never enumerates all ROM +maps or loads missing neighbors. No overworld gives `nil, "no overworld"`. +Each row contains: + +```lua +{ + mapId = "MAP_ID", active = true, + x = 0, y = 0, -- placement relative to active map, walk cells + width = 20, height = 18, -- dimensions in walk cells + block = { -- absent if runtime geometry is unavailable + tileSize = 32, cellSize = 16, coordinateBase = 0, + width = 10, height = 9, kind = "block" -- dimensions in blocks + } +} +``` + +Neighbor offsets may be negative. Exact duplicate placements collapse; +distinct placements of one map at different offsets remain separate rows. +Each row carries its own geometry, including mixed-size neighborhoods. +The legacy `loadedMaps()` method keeps its original shape, duplicates and +order for existing consumers. + +## Availability and tests + +When the optional `mod:supports` capability-query feature is installed, +`mod:supports("world", "party")`, `activeBlockAt` and `liveMaps` report true. +Probes do not construct Game or a world. Availability describes the method; +a call can still return `no save`, `no overworld`, `map is not active`, invalid +coordinates, unavailable geometry or unavailable data. Other capability +answers retain their existing behavior. + +`tests/engine/world_read_api.lua` exercises the real loader and Map objects +without an imported ROM or a graphics context, across all supported version +IDs. It checks detached nested data, ID aliases and late registrations, +current save/map replacement, block sizes/bounds, cold/warm packed data, +runtime patches, map placements, native `loadedMaps()` parity and RNG/gameplay +tripwires. When the unified-callback feature is present, it also compares the +party shape against an actual event's Pokémon record. diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua new file mode 100644 index 000000000..fff9aa581 --- /dev/null +++ b/src/battle/BattleCommands.lua @@ -0,0 +1,362 @@ +-- Native command dispatch shared by BattleState input and mod.battle intents. +-- Keep gameplay mutations here, after the mod facade's admission checks. +local Strings = require("src.core.Strings") +local Targeting = require("src.battle.Targeting") +local Commands, Methods = {}, {} +Commands.methods = Methods + +function Methods.chooseMenu(self, choice) + -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / + -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the + -- third slot IS the ball, so the item action becomes the throw. + if choice == "item" and self.bugContest then + self:bugContestBall() + return true + end + if choice == "fight" and self.ghost then + self:say(Strings("%s is too\nscared to move!", self.player.name)) + self.phase = "messages" + self.afterQueue = "menu" + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() + self:endOfTurn() + end) + elseif choice == "fight" then + -- After the menu: own trapping/Bide or foe Wrap skips the move + -- list and forces the locked action (core.asm:320-329) + local fightLock = self:fightLockedAction(self.player) + if fightLock then + self:resolveTurn(fightLock) + return true + end + local who = self:menuBattler() + if not self:playerHasPP(who) then + -- _NoMovesLeftText, then Struggle engages + self:say(Strings("%s has no\nmoves left!", who.name)) + self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) + return true + end + self.phase = "moveSelect" + self.moveIndex = math.min(self.moveIndex, #who.curMoves) + self.moveSwapIndex = nil + elseif choice == "run" then + self:tryRun() + elseif choice == "item" then + self:openItems() + else + self:openParty() + end + return true +end + +function Methods.chooseMove(self, index) + local chooser = self:menuBattler() + local moves = chooser.curMoves + self.moveIndex = index + local mv = moves[self.moveIndex] + local mvDef = mv and self.data.moves[mv.id] + if chooser.disabledSlot == self.moveIndex then + self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) + self.phase = "messages" + self.afterQueue = "menu" + -- TAUNT and TORMENT refuse the move at SELECTION rather than after the + -- fact, the way DISABLE already does, so the player is never told to + -- pick again for something the game was never going to allow. + elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then + -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block + self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tauntBlocks(chooser, mvDef) then + self:say(Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tormentBlocks(chooser, mvDef) then + self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef) then + self:say(require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef)) + self.phase = "messages" + self.afterQueue = "menu" + elseif mv.pp <= 0 then + self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + self.phase = "messages" + self.afterQueue = "menu" + elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then + -- WHICH OF THE TWO, when the move takes one and there are two. + -- + -- Emerald's selector cycles over the legal slots and names the + -- candidate; nothing else in the menu changes. A single battle + -- never gets here, because needsChoice answers false whenever there + -- is only one thing to aim at -- and on the Game Boy cartridges the + -- moves carry no target byte at all, so it answers false for every + -- move in the game. + self.pendingMove = mv + self.targetChoices = Targeting.choices(self, self:menuBattler()) + self.targetIndex = 1 + self.phase = "targetSelect" + else + self:chooseAction(mv) + end + return true +end + +function Methods.chooseTarget(self, index) + local list = self.targetChoices or {} + self.targetIndex = index + local mv = self.pendingMove + local pick = list[self.targetIndex] + self.pendingMove, self.targetChoices = nil, nil + self.chosenTargets = self.chosenTargets or {} + local who = self:menuBattler() + if who and who.position then + self.chosenTargets[who.position] = pick + end + self:chooseAction(mv) + return true +end + +function Methods.chooseMimic(self, index) + local moves = self.mimicMoves + self.mimicIndex = index + local pick = moves[self.mimicIndex] + local ctx = self.mimicCtx + self.mimicMoves, self.mimicCtx = nil, nil + self.phase = "messages" + self.nextInsert = 0 -- the copy's anim + text go to the queue head + self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + return true +end + +local SAFARI_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 } +function Methods.chooseSafari(self, action) + self.menuIndex = SAFARI_INDEX[action] + self:safariAction(action) + return true +end + +local function backToFirst(self) + if not self:isDouble() or self:choosingSlotNow() ~= 2 then + return nil + end + local left = self:battlerAt(0) + local pending = self.pendingActions and self.pendingActions[0] + if + left + and not left.ally + and (left.mon and left.mon.hp or 0) > 0 + and pending + and not self:menuLockedAction(left) + then + return left + end +end +function Methods.cancelMove(self) + if self.phase == "moveSelect" then + self.moveSwapIndex, self.phase = nil, "menu" + elseif self.phase == "targetSelect" then + self.pendingMove, self.targetChoices = nil, nil + self.phase = "moveSelect" + elseif self.phase == "menu" and backToFirst(self) then + self.pendingActions[0] = nil + if self.chosenTargets then + self.chosenTargets[0] = nil + end + self.choosingSlot, self.menuIndex, self.moveIndex = 0, 1, 1 + pcall(function() + require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") + end) + else + return nil, "nothing to cancel" + end + return true +end + +local function moveReason(b, actor, slot, move) + if not move or not b.data.moves[move.id] then + return "invalid move slot" + end + if actor.disabledSlot == slot then + return "move is disabled" + end + if (move.pp or 0) <= 0 then + return "move has no PP" + end + local def = b.data.moves[move.id] + if b.bw and require("src.battle.Gen5MoveRules").moveBlocked(b, actor, def, true) then + return "move is blocked by native BW rules" + end + if b.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(b, actor, def) then + return "move is blocked by native Gen4 rules" + end + if b:tauntBlocks(actor, def) then + return "move is blocked by Taunt" + end + if b:tormentBlocks(actor, def) then + return "move is blocked by Torment" + end +end + +-- Admission checks are read-only. Native refusal messages still belong to +-- chooseMove when driven by ordinary input; invalid API requests do not act. +function Commands.reason(b, intent) + if b.kind == "link" or b.spectating or b.demo or b.dudeDemo or b.pokedudeDemo or b.palace or b.arena then + return "battle controller owns input" + end + if b.result or b.dead or b.waitingUI then + return "battle is not ready" + end + if intent.kind == "safari" then + if b.phase ~= "menu" or not b.safari then + return "safari menu is not active" + end + if (b.safari.balls or 0) <= 0 then + return "no safari balls remain" + end + return + end + if b.safari then + return "safari controller owns input" + end + local actor = intent.kind == "mimic" and b.mimicCtx and b.mimicCtx.user or b:menuBattler() + if not (actor and actor.mon) or actor.ally or (actor.mon.hp or 0) <= 0 then + return "player battler is not ready" + end + if + intent.kind ~= "mimic" + and ( + b:menuLockedAction(actor) + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + ) + then + return "player battler is locked" + end + if intent.kind == "menu" then + if b.phase ~= "menu" then + return "battle menu is not active" + end + local acting = b.player or b:activeOn(1) + if not acting or (acting.mon.hp or 0) <= 0 or b:menuLockedAction(acting) then + return "battle menu is not ready" + end + elseif intent.kind == "move" then + if b.phase ~= "moveSelect" then + return "move menu is not active" + end + if b.moveSwapIndex then + return "move reorder is active" + end + return moveReason(b, actor, intent.slot, (actor.curMoves or {})[intent.slot]) + elseif intent.kind == "target" then + if b.phase ~= "targetSelect" then + return "target menu is not active" + end + local move, slot = b.pendingMove, nil + for index, mv in ipairs(actor.curMoves or {}) do + if mv == move then + slot = index + break + end + end + if not slot then + return "pending move is no longer current" + end + local reason = moveReason(b, actor, slot, move) + if reason then + return reason + end + if not Targeting.needsChoice(b, actor, b.data.moves[move.id]) then + return "target choice is no longer needed" + end + local pick = b:battlerAt(intent.target) + if not (pick and pick.mon and (pick.mon.hp or 0) > 0) then + return "target is not on the field" + end + local offered = false + for _, target in ipairs(b.targetChoices or {}) do + if target == pick then + offered = true + break + end + end + if not offered then + return "target is not in the current selector" + end + for _, target in ipairs(Targeting.choices(b, actor)) do + if target == pick then + return + end + end + return "target is no longer eligible" + elseif intent.kind == "back" then + if b.phase == "moveSelect" or b.phase == "targetSelect" or (b.phase == "menu" and backToFirst(b)) then + return + end + return "nothing to cancel" + elseif intent.kind == "mimic" then + if b.phase ~= "mimicSelect" then + return "mimic menu is not active" + end + local ctx, pick = b.mimicCtx, (b.mimicMoves or {})[intent.index] + if not ctx or not pick or ctx.user ~= actor or not ctx.target or not ctx.moveInst then + return "invalid mimic context" + end + local targetPresent = false + for pos = 0, 3 do + if b:battlerAt(pos) == ctx.target then + targetPresent = true + end + end + if not targetPresent or #(actor.curMoves or {}) == 0 then + return "invalid mimic context" + end + local onField = false + for _, pos in ipairs({ 0, 2 }) do + if b:battlerAt(pos) == actor then + onField = true + end + end + if not onField then + return "mimic user is no longer on the field" + end + local source = (ctx.target.curMoves or {})[pick.slot] + if not source or source.id ~= pick.id then + return "mimic option is no longer current" + end + else + return "unknown battle intent" + end +end + +local DISPATCH = { + menu = "chooseMenu", + move = "chooseMove", + target = "chooseTarget", + back = "cancelMove", + safari = "chooseSafari", + mimic = "chooseMimic", +} +function Commands.dispatch(b, intent) + -- Ordinary input already performs this at its menu boundary. + if b.phase == "menu" and not b.safari then + b:clearTurnFlinches() + end + if intent.kind == "target" then + local pick = b:battlerAt(intent.target) + for index, target in ipairs(b.targetChoices or {}) do + if target == pick then + return b:chooseTarget(index) + end + end + return nil, "target is not in the current selector" + end + local value = intent.choice or intent.slot or intent.action or intent.index + return b[DISPATCH[intent.kind]](b, value) +end +return Commands diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..d72139edc 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -38,6 +38,7 @@ local WideBattle = require("src.battle.WideBattle") local Gen3Battle = require("src.battle.Gen3Battle") local Gen4Battle = require("src.battle.Gen4Battle") local GameVersion = require("src.core.GameVersion") +local BattleCommands = require("src.battle.BattleCommands") -- FORWARD-DECLARED, because they are defined next to the other doubles -- helpers two thousand lines down but read from `stepHPDrain` up here. A @@ -53,6 +54,10 @@ local TEXT_AREA_ROWS = { 112, 128 } local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Data readers can identify the shared Gen 1-5 battle without loading this module. +BattleState.isBattleState = true +-- Input and public intents share the exact same native command dispatch. +for name, method in pairs(BattleCommands.methods) do BattleState[name] = method end -- pokered prints the battle lines itself (engine/battle/core.asm and the -- move-effect banks), and the importer extracts every one of them, so the @@ -4440,7 +4445,7 @@ function BattleState:update(dt) if self.palPark then require("src.battle.Gen4PalParkBattle").skipRow(self, dir) end end if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4466,7 +4471,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4546,60 +4551,10 @@ function BattleState:update(dt) -- asked (fainted, or locked into a move it cannot choose). if input:wasPressed("b") and self:isDouble() and self:choosingSlotNow() == BattleState.POS.PLAYER_RIGHT then - local left = self:battlerAt(BattleState.POS.PLAYER_LEFT) - local pend = self.pendingActions and self.pendingActions[BattleState.POS.PLAYER_LEFT] - if left and not left.ally and (left.mon and left.mon.hp or 0) > 0 and pend - and not self:menuLockedAction(left) then - self.pendingActions[BattleState.POS.PLAYER_LEFT] = nil - if self.chosenTargets then self.chosenTargets[BattleState.POS.PLAYER_LEFT] = nil end - self.choosingSlot = BattleState.POS.PLAYER_LEFT - self.menuIndex, self.moveIndex = 1, 1 - pcall(function() require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") end) - return - end + if self:cancelMove() then return end end if input:wasPressed("a") then - local choice = self:menuActions()[self.menuIndex] - -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / - -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the - -- third slot IS the ball, so the item action becomes the throw. - if choice == "item" and self.bugContest then - self:bugContestBall() - return - end - if choice == "fight" and self.ghost then - self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" - self.afterQueue = "menu" - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) - self:act(function() self:endOfTurn() end) - elseif choice == "fight" then - -- After the menu: own trapping/Bide or foe Wrap skips the move - -- list and forces the locked action (core.asm:320-329) - local fightLock = self:fightLockedAction(self.player) - if fightLock then - self:resolveTurn(fightLock) - return - end - local who = self:menuBattler() - if not self:playerHasPP(who) then - -- _NoMovesLeftText, then Struggle engages - self:say(Strings("%s has no\nmoves left!", who.name)) - self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) - return - end - self.phase = "moveSelect" - self.moveIndex = math.min(self.moveIndex, #who.curMoves) - self.moveSwapIndex = nil - elseif choice == "run" then - self:tryRun() - elseif choice == "item" then - self:openItems() - else - self:openParty() - end + self:chooseMenu(self:menuActions()[self.menuIndex]) end return end @@ -4625,63 +4580,14 @@ function BattleState:update(dt) self.moveSwapIndex = self.moveIndex end elseif input:wasPressed("b") then - self.moveSwapIndex = nil - self.phase = "menu" + self:cancelMove() elseif input:wasPressed("a") then if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil return end - local mv = moves[self.moveIndex] - local mvDef = mv and self.data.moves[mv.id] - if chooser.disabledSlot == self.moveIndex then - self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" - self.afterQueue = "menu" - -- TAUNT and TORMENT refuse the move at SELECTION rather than after the - -- fact, the way DISABLE already does, so the player is never told to - -- pick again for something the game was never going to allow. - elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then - -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block - self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tauntBlocks(chooser, mvDef) then - self:say(Strings("%s can't use\n%s after the\ntaunt!", - chooser.name, mvDef and mvDef.name or mv.id)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tormentBlocks(chooser, mvDef) then - self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", - chooser.name)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef) then - -- PLATINUM / HGSS: Gravity and Heal Block (BattleSystem_CanUseMove) - self:say(require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef)) - self.phase = "messages" - self.afterQueue = "menu" - elseif mv.pp <= 0 then - self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" - self.afterQueue = "menu" - elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then - -- WHICH OF THE TWO, when the move takes one and there are two. - -- - -- Emerald's selector cycles over the legal slots and names the - -- candidate; nothing else in the menu changes. A single battle - -- never gets here, because needsChoice answers false whenever there - -- is only one thing to aim at -- and on the Game Boy cartridges the - -- moves carry no target byte at all, so it answers false for every - -- move in the game. - self.pendingMove = mv - self.targetChoices = Targeting.choices(self, self:menuBattler()) - self.targetIndex = 1 - self.phase = "targetSelect" - else - self:chooseAction(mv) - end + self:chooseMove(self.moveIndex) end return end @@ -4699,16 +4605,9 @@ function BattleState:update(dt) elseif input:wasPressed("right") or input:wasPressed("down") then self.targetIndex = self.targetIndex < #list and self.targetIndex + 1 or 1 elseif input:wasPressed("b") then - self.pendingMove, self.targetChoices = nil, nil - self.phase = "moveSelect" + self:cancelMove() elseif input:wasPressed("a") then - local mv = self.pendingMove - local pick = list[self.targetIndex] - self.pendingMove, self.targetChoices = nil, nil - self.chosenTargets = self.chosenTargets or {} - local who = self:menuBattler() - if who and who.position then self.chosenTargets[who.position] = pick end - self:chooseAction(mv) + self:chooseTarget(self.targetIndex) end return end @@ -4729,12 +4628,7 @@ function BattleState:update(dt) elseif input:wasPressed("down") then self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1 elseif input:wasPressed("a") then - local pick = moves[self.mimicIndex] - local ctx = self.mimicCtx - self.mimicMoves, self.mimicCtx = nil, nil - self.phase = "messages" - self.nextInsert = 0 -- the copy's anim + text go to the queue head - self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + self:chooseMimic(self.mimicIndex) end return end diff --git a/src/battle/Gen4MoveEffects.lua b/src/battle/Gen4MoveEffects.lua index 2bd10da81..4f1447ee2 100644 --- a/src/battle/Gen4MoveEffects.lua +++ b/src/battle/Gen4MoveEffects.lua @@ -509,9 +509,13 @@ F.REMOVE_PROTECT = { local FLAVOR_STAT = { [11] = "attack", [12] = "spatk", [13] = "speed", [14] = "spdef", [15] = "defense" } local STAT_UP = { [16] = "attack", [17] = "defense", [18] = "speed", [19] = "spatk", [20] = "spdef" } local CURE = { [1] = "PAR", [2] = "SLP", [3] = "PSN", [4] = "BRN", [5] = "FRZ" } -local CURE_TEXT = { PAR = "%s was\ncured of paralysis!", SLP = "%s woke up!", - PSN = "%s was\ncured of poisoning!", BRN = "%s's\nburn was healed!", - FRZ = "%s thawed out!" } +local CURE_TEXT = { + PAR = Strings.source("%s was\ncured of paralysis!"), + SLP = Strings.source("%s woke up!"), + PSN = Strings.source("%s was\ncured of poisoning!"), + BRN = Strings.source("%s's\nburn was healed!"), + FRZ = Strings.source("%s thawed out!"), +} local function eat(battle, eater, effect, param, def, out) local max = maxHp(eater) diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5772141ae..1db62ca08 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -155,7 +155,7 @@ end -- SHIELD DUST still refuses a doubled roll. local function sideChance(move, fallback, user, target) local own = move and tonumber(move.effectChance) - local base = (own and own > 0) and own or fallback + local base = (own and (own > 0 or move._api3EffectChance)) and own or fallback if user or target then base = require("src.battle.Abilities").secondaryOdds(user, target, base) end diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 7093a32f5..a6e84df7e 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -264,14 +264,34 @@ end -- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it -- Resolve a cartridge effect by its extracted native name, including effects -- that have no shared engine role in audio.sfx. -function Sound.playNamedEffect(data,name) - for id,song in pairs(data and data.audio and data.audio.songs or {}) do - if type(song)=="table" and song.sfxName==name and tonumber(song.index) then - local src=playPath(data,id,{song=id,m4a=tonumber(song.index)}) - if src then played("sfx",name) end - return src +-- Native numeric/symbolic sequence aliases must resolve unambiguously. +function Sound.sequenceKey(data, name) + if type(name) ~= "string" and type(name) ~= "number" then return nil end + local songs = data and data.audio and data.audio.songs or {} + if (type(name) == "string" or type(name) == "number") and (type(songs[name]) == "string" or type(songs[name]) == "table") then + return name, songs[name] + end + local key, found + for id, song in pairs(songs) do + if type(song) == "table" and (song.sfxName == name or song.musName == name + or type(name) == "number" and song.index == name) then + if found and found ~= song then return nil, "ambiguous native audio id" end + key, found = id, song end end + return key, found +end + +function Sound.playNamedEffect(data, name) + local id, song = Sound.sequenceKey(data, name) + if not id then return nil end + local def = song + if type(song) == "table" and tonumber(song.index) and not song.file then + def = { song = id, m4a = tonumber(song.index) } + end + local src = playPath(data, id, def) + if src then played("sfx", name) end + return src end function Sound.play(data, name) diff --git a/src/core/Version.lua b/src/core/Version.lua index 82cad2f61..b7ba8b6df 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -16,7 +16,7 @@ local Version = { -- binary (e.g. a LOVE version bump); an older shell -- refuses to chainload a payload whose minShell -- exceeds the shell it provides. - modApi = 2, -- mod API major (manifest `api`) + modApi = 3, -- mod API major (manifest `api`) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) saveFormat = 4, -- save.meta.format cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 814ced9e5..9c4238476 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -399,6 +399,12 @@ end -- PORTABLE FIRST. A portable copy has already said where it keeps its -- things, and it says so with a file sitting next to the executable, which -- beats a line in an options file the portable copy may not even be reading. +-- Observational counterpart for dataset views: never resolves or mounts a root. +function CacheFs.datasetRoot() + if not portableResolved or (not portableRoot and not customResolved) then return nil, false end + return portableRoot or customRoot, true +end + function CacheFs.root() return resolvePortableRoot() or resolveCustomRoot() end @@ -1191,6 +1197,19 @@ function CacheFs.activePrefixShim() return shimPrefix end +-- Exact developer-source reads for read-only version datasets. Refuse an +-- active overlay rather than reading another edition under an unprefixed path. +function CacheFs.datasetSourceFile(path, maximum, infoOnly) + local fs = love and love.filesystem + if not fs or not fs.getRealDirectory or not fs.getSource + or fs.getRealDirectory(path) ~= fs.getSource() then return nil end + local info = (shimRealGetInfo or fs.getInfo)(path, "file") + if not info or info.type and info.type ~= "file" then return nil end + if infoOnly then return info end + if info.size and info.size > maximum then return nil, "size limit" end + return (shimRealRead or fs.read)(path) +end + -- Undo mountVersion. A process normally mounts exactly one version and then -- boots it, but the launcher can open the save editor on a Blue/Yellow save, -- close it, and press Play on Red: with that version's subtree still diff --git a/src/import/DataLiteral.lua b/src/import/DataLiteral.lua new file mode 100644 index 000000000..97bacfbbf --- /dev/null +++ b/src/import/DataLiteral.lua @@ -0,0 +1,253 @@ +-- Bounded generated-data grammar. Never compiles or executes input. +local D = {} +D.LIMITS = { bytes = 8 * 1024 * 1024, depth = 64, nodes = 500000, string = 2 * 1024 * 1024, entries = 250000 } +function D.decode(source, limits) + limits = limits or D.LIMITS + if type(source) ~= "string" or #source > limits.bytes then + return nil, "generated data size limit" + end + local p, nodes, depth = 1, 0, 0 + local function fail(why) + error("data literal at byte " .. p .. ": " .. why, 0) + end + local function node() + nodes = nodes + 1 + if nodes > limits.nodes then + fail("node limit") + end + end + local function long() + local equals = source:match("^%[(=*)%[", p) + if not equals or #equals > 16 then + fail("invalid long string") + end + p = p + #equals + 2 + local finish = source:find("]" .. equals .. "]", p, true) + if not finish then + fail("unterminated long string") + end + local value = source:sub(p, finish - 1) + if #value > limits.string then + fail("string limit") + end + p = finish + #equals + 2 + if value:sub(1, 2) == "\r\n" then + value = value:sub(3) + elseif value:sub(1, 1) == "\n" or value:sub(1, 1) == "\r" then + value = value:sub(2) + end + return value + end + local function skip() + while true do + local _, last = source:find("^%s*", p) + p = last + 1 + if source:sub(p, p + 1) ~= "--" then + return + end + p = p + 2 + if source:match("^%[=*%[", p) then + long() + else + p = (source:find("\n", p, true) or #source) + 1 + end + end + end + local escapes = + { a = "\a", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t", v = "\v", ["\\"] = "\\", ['"'] = '"', ["'"] = "'" } + local function quoted() + local quote = source:sub(p, p) + p = p + 1 + local out, size = {}, 0 + while p <= #source do + local c = source:sub(p, p) + p = p + 1 + if c == quote then + return table.concat(out) + end + if c == "\\" then + c = source:sub(p, p) + p = p + 1 + if c:match("%d") then + local digits = source:match("^%d%d?%d?", p - 1) + local byte = tonumber(digits) + if byte > 255 then + fail("invalid byte escape") + end + p = p - 1 + #digits + c = string.char(byte) + elseif c == "x" then + local digits = source:sub(p, p + 1) + if not digits:match("^%x%x$") then + fail("invalid hex escape") + end + p = p + 2 + c = string.char(tonumber(digits, 16)) + elseif c == "\n" then + c = "\n" + elseif c == "\r" then + if source:sub(p, p) == "\n" then + p = p + 1 + end + c = "\n" + elseif c == "z" then + local _, last = source:find("^%s*", p) + p = last + 1 + c = "" + else + c = escapes[c] + if not c then + fail("invalid string escape") + end + end + elseif c == "\n" or c == "\r" then + fail("newline in quoted string") + end + size = size + #c + if size > limits.string then + fail("string limit") + end + out[#out + 1] = c + end + fail("unterminated string") + end + local value + local function identifier() + local word = source:match("^[%a_][%w_]*", p) + if not word then + fail("expected name") + end + p = p + #word + if #word > limits.string then + fail("string limit") + end + return word + end + local function array() + depth = depth + 1 + if depth > limits.depth then + fail("depth limit") + end + p = p + 1 + local out, seen, index, count = {}, {}, 1, 0 + skip() + while source:sub(p, p) ~= "}" do + count = count + 1 + if count > limits.entries then + fail("table entry limit") + end + local key, child + local c = source:sub(p, p) + if c == "[" and not source:match("^%[=*%[", p) then + p = p + 1 + key = value() + skip() + if source:sub(p, p) ~= "]" then + fail("expected ]") + end + p = p + 1 + skip() + if source:sub(p, p) ~= "=" then + fail("expected =") + end + p = p + 1 + child = value() + elseif c:match("[%a_]") then + local start = p + key = identifier() + skip() + if source:sub(p, p) == "=" then + node() + p = p + 1 + child = value() + else + p = start + key = index + index = index + 1 + child = value() + end + else + key = index + index = index + 1 + child = value() + end + if type(key) ~= "string" and type(key) ~= "number" and type(key) ~= "boolean" then + fail("invalid key") + end + if seen[key] then + fail("duplicate key") + end + seen[key] = true + out[key] = child + skip() + c = source:sub(p, p) + if c == "," or c == ";" then + p = p + 1 + skip() + elseif c ~= "}" then + fail("expected separator") + end + end + p = p + 1 + depth = depth - 1 + return out + end + value = function() + skip() + node() + local c = source:sub(p, p) + if c == "{" then + return array() + end + if c == '"' or c == "'" then + return quoted() + end + if source:match("^%[=*%[", p) then + return long() + end + if c:match("[%a_]") then + local word = identifier() + if word == "true" then + return true + elseif word == "false" then + return false + elseif word == "nil" then + return nil + end + fail("unexpected name") + end + local token = source:match("^[^%s,%]}=;]+", p) + if token and #token <= 128 then + local number = tonumber(token) + if number and number == number and math.abs(number) < math.huge then + p = p + #token + return number + end + end + fail("expected finite literal") + end + local ok, result = pcall(function() + skip() + if identifier() ~= "return" then + fail("expected return") + end + local result = value() + skip() + if source:sub(p, p) == ";" then + p = p + 1 + skip() + end + if p <= #source then + fail("trailing content") + end + if type(result) ~= "table" then + fail("root must be a table") + end + return result + end) + if ok then + return result + end + return nil, result +end +return D diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bbd959cfd..3e6d8ee80 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1690,6 +1690,24 @@ local function requiredFiles(version) return REQUIRED_FILES_GEN1 end +-- Read-only metadata inspection shares the importer's exact format/hash gate. +-- This descriptor does not read files, mount caches or touch active prefixes. +function RomImporter.datasetContract(version) + local info = GameVersion.info(version) + if not info or info.generation > 3 then return nil end + local modules, seen = {}, {} + for _, path in ipairs(requiredFiles(version)) do + local name = path:match("^data/generated/(.+)%.lua$") + if name and not seen[name] then modules[#modules + 1] = name; seen[name] = true end + end + for _, name in ipairs({ "constants", "pokemon", "moves", "items", "maps", + "trainers", "encounters", "type_chart" }) do + if not seen[name] then modules[#modules + 1] = name; seen[name] = true end + end + table.sort(modules) + return { marker = markerFor(version), modules = modules } +end + local function gen2TilesetRegistryLooksValid(version) if GameVersion.generation(version) ~= 2 then return true end local CacheFs = require("src.import.CacheFs") diff --git a/src/import/VersionFiles.lua b/src/import/VersionFiles.lua new file mode 100644 index 000000000..de1d2aae7 --- /dev/null +++ b/src/import/VersionFiles.lua @@ -0,0 +1,117 @@ +-- Exact version-qualified reads. Never changes GameVersion, prefixes or mounts. +local Version = require("src.core.GameVersion") +local V = {} +function V.new(fs) + local native = love and fs == love.filesystem + local function physical(path, maximum, infoOnly) + local f = io.open(path, "rb") + if not f then + return nil + end + local size = f:seek("end") + if not size then + f:close() + return nil + end + if infoOnly then + f:close() + return { type = "file", size = size } + end + if size > maximum then + f:close() + return nil, "size limit" + end + f:seek("set") + local data = f:read(maximum + 1) + f:close() + if type(data) == "string" and #data <= maximum then + return data + end + return nil, "size limit" + end + local function identity() + if not native then + return fs + end + local Cache = require("src.import.CacheFs") + return table.concat( + { + Cache.datasetRoot() or "", + fs.getSaveDirectory and fs.getSaveDirectory() or "", + fs.getSource and fs.getSource() or "", + }, + "\n" + ) + end + local function file(version, path, maximum, infoOnly) + local full = Version.cachePrefix(version) .. path + if not native then + if infoOnly then + return fs.getInfo(full, "file") + end + return fs.read(full) + end + local Cache = require("src.import.CacheFs") + local root, ready = Cache.datasetRoot() + if root then + return physical(root .. "/" .. full, maximum, infoOnly) + end + local save = ready and fs.getSaveDirectory and fs.getSaveDirectory() + if save then + local data, why = physical(save .. "/" .. full, maximum, infoOnly) + if data ~= nil or why then + return data, why + end + end + local source = fs.getSource and fs.getSource() + if source then + local data, why = physical(source .. "/" .. full, maximum, infoOnly) + if data ~= nil or why then + return data, why + end + end + return Cache.datasetSourceFile(full, maximum, infoOnly) + end + local function source(version, paths) + local prefix = Version.cachePrefix(version) + if native then + local Cache = require("src.import.CacheFs") + if Cache.datasetRoot() then + return false + end + for _, path in ipairs(paths) do + if not Cache.datasetSourceFile(prefix .. path, 0, true) then + return false + end + end + return true + end + if not fs.getSource or not fs.getRealDirectory then + return false + end + for _, path in ipairs(paths) do + if fs.getRealDirectory(prefix .. path) ~= fs.getSource() then + return false + end + end + return true + end + return { + read = function(v, p, max) + return file(v, p, max, false) + end, + info = function(v, p) + return file(v, p, 0, true) + end, + identity = identity, + source = source, + path = function(version, path) + -- An unprefixed asset path would be redirected by the active overlay. + if native and version == "red" and Version.get() ~= "red" then + return nil, "unavailable asset path" + end + return Version.cachePrefix(version) .. path + end, + } +end +return V diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua new file mode 100644 index 000000000..770c737e0 --- /dev/null +++ b/src/mods/Api3.lua @@ -0,0 +1,194 @@ +-- API-3 entry over the existing native adapters. Unsupported operations have +-- callable handles but never delegate to a native method with a different contract. +local Records = require("src.mods.Api3Records") +local Runtime = require("src.mods.Runtime") +local Warp = require("src.mods.Api3Warp") +local Edits = require("src.mods.Api3WorldEdits") +local NPC = require("src.mods.Api3NPC") +local Wild = require("src.mods.Api3WildBattle") +local Flags = require("src.mods.Api3Flags") +local copy = require("src.mods.Merge").deepCopy +local A = {} +local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview warpTo toggleObject + setFlag getFlag effectiveEncounters replaceBlock spawnNpc liveMaps removeNpc npc + queueScript startWildBattle invalidateMap]] +local SUPPORTED = {} +for name in + ([[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc startWildBattle invalidateMap]]):gmatch( + "%S+" + ) +do + SUPPORTED[name] = true +end +local function pack(...) + return { n = select("#", ...), ... } +end +local unpack = table.unpack or unpack +local function block(gen) + return { + tileSize = gen == 3 and 16 or 32, + cellSize = 16, + coordinateBase = 0, + kind = gen == 3 and "metatile" or "block", + } +end +function A.install(api, loader, readContext, overviewContext, engineRequire, engineCall) + local gen = api.generation + local supportedGeneration = gen >= 1 and gen <= 3 + -- Passthrough registries keep native shapes but expose the same handle metadata. + for name, entry in pairs(api.content) do + local handle = entry + if type(handle) == "table" and handle.supports == nil then + local available, reason = api:supports("content", name) + handle.available = available + local get, each = handle.get, handle.each + if get then + handle.get = function(self, id) + local value, why = get(self, id) + return copy(value), why + end + end + if each then + handle.each = function(self) + local iter, state, key = each(self) + return function() + local id, value = iter(state, key) + key = id + return id, copy(value) + end + end + end + handle.supports = function() + return available, reason + end + end + if type(handle) == "table" and handle.has == nil and handle.get then + handle.has = function(_, id) + return handle:get(id) ~= nil + end + end + end + local scripts = api.world -- PortableScripts owns the per-loader/session queue. + local world = {} + local nativeGame, nativeWorld + local function current() + return loader:_game(true) -- Never bootstrap Game for an observational early call. + end + local function native(game) + if game ~= nativeGame then + nativeWorld = engineRequire("src.world.WorldAPI").new(game, api.id, readContext) + nativeWorld.overviewContext = overviewContext + nativeGame = game + end + return nativeWorld + end + local warp, licensed, npc, wild + if supportedGeneration then + warp, licensed = Warp.new(loader, api.id, gen, current, native) + npc = NPC.new(loader, api.id, gen, current, native, licensed, engineRequire) + wild = Wild.new(loader, api.id, current, native, licensed, engineRequire) + end + local function dispatch(name, ...) + if not supportedGeneration or not SUPPORTED[name] then + return nil, "unsupported: API 3 world method " .. name + end + if name == "spawnNpc" or name == "removeNpc" or name == "npc" then + return npc(name, ...) + end + if name == "startWildBattle" then + return wild(...) + end + if name == "warpTo" then + return warp(...) + end + local edit = name == "toggleObject" or name == "replaceBlock" or name == "invalidateMap" + if edit and not licensed() then + return nil, "world facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + if edit then + return Edits.call(game, native(game), gen, engineRequire, name, ...) + end + if name == "setFlag" or name == "getFlag" then + local key, value = ... + return Flags.call(game, api.id, gen, key, value, name == "setFlag", licensed, function(live) + return native(live):overworld() + end) + end + if name == "queueScript" then + return scripts:queueScript(...) + end + local raw = native(game) + local result = pack(raw[name](raw, ...)) + if name == "party" and type(result[1]) == "table" then + for _, mon in ipairs(result[1]) do + Records.mon(mon, game.data, gen) + end + elseif (name == "current" or name == "mapOverview") and type(result[1]) == "table" then + result[1].block = block(gen) + end + return unpack(result, 1, result.n) + end + for name in WORLD:gmatch("%S+") do + local method = name + world[method] = function(_, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local values = pack(pcall(dispatch, method, ...)) + Runtime.currentMod = owner + if not values[1] then + return nil, "world operation failed: " .. tostring(values[2]) + end + return unpack(values, 2, values.n) + end + end + api.world = world + local battle = api.battle + api.battle = { + snapshot = function() + local snapshot, why = battle:snapshot() + if not snapshot then + return nil, why + end + local game = current() + local data = game and game.data + Records.mon(snapshot.player, data, gen) + Records.mon(snapshot.enemy, data, gen) + for _, name in ipairs({ "party", "battlers" }) do + for _, mon in ipairs(snapshot[name] or {}) do + Records.mon(mon, data, gen) + end + end + return snapshot + end, + submit = function(_, ...) + return battle:submit(...) + end, + } + api.datasets = { + open = function(_, version) + if not loader.datasetViews then + local Views = engineRequire("src.mods.DatasetViews") + loader.datasetViews = Views.new(loader.fs, engineRequire, engineCall) + end + return loader.datasetViews:open(version) + end, + } + local supports = api.supports + function api:supports(surface, name) + if surface == "world" then + if supportedGeneration and SUPPORTED[name] then + return true + end + return false, "unsupported: API 3 world method " .. tostring(name) + end + return supports(self, surface, name) + end +end +return A diff --git a/src/mods/Api3Catalog.lua b/src/mods/Api3Catalog.lua new file mode 100644 index 000000000..0549286b9 --- /dev/null +++ b/src/mods/Api3Catalog.lua @@ -0,0 +1,57 @@ +-- Public callback-name snapshot from Gen1Recomp API 3, commit +-- 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36. Recognition is not availability: +-- Capabilities still requires a native Gen2Recomped call site. +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +local C = {} +C.hooks = names([[ +battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.caught_marker_visible +battle.charge_required battle.crit battle.damage battle.enemy_action +battle.enemy_switch_or_item battle.exp_award battle.field_residual battle.low_health_alarm +battle.menu_auxiliary battle.move_grid_navigation battle.overlay battle.run +battle.status_hud_visible battle.style battle.turn_order breeding.compatibility +catch.nickname catch.party_full catch.rate core.logic_speed +core.quit_to_launcher core.update encounter.fishing encounter.roll +encounter.species encounter.table evolution.check exp.gain +fieldmove.eligibility gender.roll held_item.trigger input.gamepad +input.key input.pointer input.step input.wheel +intro.oak_speech.build item.use link.fingerprint map.palette +movement.collision movement.speed music.select music.volume +phone.contact_list player.sprite pokemon.icon pokemon.level_visible +pokemon.sprite render.compose render.hud render.letterbox +render.output render.output_enabled render.viewport render.window +render.zones save.new_game save.write screen.render_visible +script.command shiny.roll trainer.before_battle trainer.party +transition.style ui.list_menu ui.naming.grid ui.options.rows +ui.party.grid_navigation ui.party.submenu ui.pc.items ui.start_menu.items +ui.title_menu.items warp.destination world.follower.spawn world.talk +world.tod zoom.range +]]) +C.events = names([[ +apricorn.converted assets.transformed battle.ball_thrown battle.battler_switched +battle.damage_dealt battle.ended battle.exp_gained battle.fainted +battle.move_used battle.started battle.status_inflicted battle.turn_ended +battle.turn_started breeding.egg_created bug_contest.scored checkpoint.restored +clock.day_changed discord.join_requested egg.hatched flag.changed +game.ready happiness.changed intro.boot.copyright intro.boot.gamefreak +intro.boot.movie intro.boot.movie_ended intro.boot.title intro.oak_speech.answered +intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step link.battle_ended +link.connected link.desync link.ended mail.read +mail.written map.entered map.exited map.reloaded +mod.options_changed mods.loaded music.started music.stopped +phone.call_received player.warped pokemon.before_give pokemon.caught +pokemon.evolved pokemon.level_up pokemon.move_learned pokemon.received +pokerus.infected radio.channel roamer.encountered roamer.moved +save.created save.loaded save.loading save.writing +screen.popped screen.pushed script.ended script.started +sound.played trade.completed unown.unlocked world.blacked_out +world.block_replaced world.boulder_moved world.interacted world.live_maps_preparing +world.live_maps_updated world.npc_spawned world.object_toggled world.stepped +world.tod_changed world.trainer_engaged +]]) +return C diff --git a/src/mods/Api3Flags.lua b/src/mods/Api3Flags.lua new file mode 100644 index 000000000..c7b937c73 --- /dev/null +++ b/src/mods/Api3Flags.lua @@ -0,0 +1,201 @@ +-- Portable references mapped to the flag stores the host VM/save converter use. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local F = {} +local catalogs, writing = {}, setmetatable({}, { __mode = "k" }) +local function integer(value, minimum, maximum) + return type(value) == "number" + and value == value + and value % 1 == 0 + and value >= minimum + and value <= maximum +end +local function identifier(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +local function catalog(version) + local key = version == "blue" and "red" or version + if catalogs[key] then + return catalogs[key] + end + local data = require("src.mods.flag_names." .. key) + local out = { names = {}, ids = {}, limit = data.limit } + for id, row in pairs(data.records) do + if row[1] then + out.names[id] = row[1] + end + for _, name in ipairs(row) do + if name then + out.ids[name] = id + end + end + end + catalogs[key] = out + return out +end +function F.resolve(generation, version, name) + local supported = ({ + red = 1, + blue = 1, + yellow = 1, + gold = 2, + silver = 2, + crystal = 2, + firered = 3, + emerald = 3, + })[version] + if supported ~= generation then + return nil, "native flag mapping unavailable for selected version" + end + if generation == 1 then + local t = catalog(version) + local id = integer(name, 0, 65535) and name or identifier(name) and t.ids[name] + if id ~= nil and t.names[id] then + return t.names[id], t.names[id], id + end + elseif generation == 2 then + local native = require("src.script.Gen2Flags") + local id = name + if identifier(name) then + if version == "crystal" then + return nil, "symbolic event flags unavailable for Crystal; use native ids" + end + id = native.EVENT_FLAG_INDEX[name] + end + if integer(id, 0, 2047) then + return native.eventFlag(id), id, id + end + elseif generation == 3 then + local t = catalog(version) + local id + if type(name) == "number" then + if integer(name, 1, t.limit - 1) and t.names[name] then + id = name + end + elseif identifier(name) then + id = t.ids[name] + end + if id and integer(id, 1, t.limit - 1) then + return ("FLAG_G3_%04X"):format(id), t.names[id] or id, id + end + end + return nil, "invalid or unknown flag" +end +-- Resolve without a Game/save for whole-program script preflight. +function F.address(owner, gen, version, name) + local key + if type(name) == "string" then + if name:sub(1, 4) == "mod:" then + key = name:sub(5) + elseif name:sub(1, 4) == "mod." then + local prefix = "mod." .. owner .. "." + if name:sub(1, #prefix) ~= prefix then + return nil, "foreign mod flag" + end + key = name:sub(#prefix + 1) + end + end + if key then + if not identifier(key) then + return nil, "invalid mod flag" + end + return { kind = "mod", key = key } + end + local storage, public, number = F.resolve(gen, version, name) + if not storage then + return nil, public + end + return { kind = "native", key = storage, public = public, number = number } +end +function F.call(game, owner, gen, name, value, write, licensed, overworld) + if write and not licensed() then + return nil, "world facade is retired" + end + if write and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local save = game.save + if not save then + return nil, "no save" + end + local address, reason = F.address(owner, gen, Version.get(), name) + if not address then + return nil, reason + end + if address.kind == "mod" then + local key = address.key + local store = save.modData + if store ~= nil and type(store) ~= "table" then + return nil, "invalid mod save store" + end + local bucket = store and store[owner] + if bucket ~= nil and type(bucket) ~= "table" then + return nil, "invalid mod save store" + end + local flags = bucket and bucket.worldFlags + if flags ~= nil and type(flags) ~= "table" then + return nil, "invalid mod flag store" + end + if not write then + return flags ~= nil and flags[key] == true + end + if writing[save] then + return nil, "world is busy" + end + local before = flags ~= nil and flags[key] == true + save.modData = store or {} + bucket = bucket or {} + save.modData[owner] = bucket + bucket.worldFlags = flags or {} + bucket.worldFlags[key] = value or nil + if before ~= value then + writing[save] = true + local ok, err = + pcall(Runtime.emit, "flag.changed", { name = "mod." .. owner .. "." .. key, value = value }) + writing[save] = nil + if not ok then + error(err, 0) + end + end + return true + end + local storage, public, number = address.key, address.public, address.number + if type(save.flags) ~= "table" then + return nil, "invalid flag store" + end + if not write then + return save.flags[storage] == true + end + if writing[save] then + return nil, "world is busy" + end + writing[save] = true + local ok, err = pcall(function() + local before = save.flags[storage] == true + -- Native clear keeps false: visibility distinguishes cleared from untouched. + save.flags[storage] = value + if gen == 3 then + local ow = overworld(game) + if ow and ow.syncObjectVisibility then + for _, object in ipairs(ow.map and ow.map.def and ow.map.def.objects or {}) do + if object.eventFlag == storage then + ow:syncObjectVisibility(object) + end + end + end + end + if before ~= value then + local event = { name = public, value = value } + if gen == 3 then + event.id = number + end + Runtime.emit("flag.changed", event) + end + end) + writing[save] = nil + if not ok then + error(err, 0) + end + return true +end +return F diff --git a/src/mods/Api3NPC.lua b/src/mods/Api3NPC.lua new file mode 100644 index 000000000..8a4091f2a --- /dev/null +++ b/src/mods/Api3NPC.lua @@ -0,0 +1,565 @@ +-- Public API-3 NPC handles. Engine objects never cross the handle boundary. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Edits = require("src.mods.Api3WorldEdits") +local Collision = require("src.world.Collision") +local copy = require("src.mods.Merge").deepCopy +local N = {} +local services = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local moves = {} +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n, minimum, maximum) + return type(n) == "number" + and n == n + and n >= (minimum or 0) + and n < math.huge + and n % 1 == 0 + and (not maximum or n <= maximum) +end +local function message(value) + local ok, text = pcall(tostring, value) + return ok and text or "unprintable error" +end +local function identifier(s) + return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c") +end +local function reference(v) + return identifier(v) or integer(v) +end +-- Bounded data-only copy, including extra generation-native definition fields. +local function definition(value) + local visiting, nodes, bytes = {}, 0, 0 + local function clone(v, depth) + nodes = nodes + 1 + if nodes > 4096 or depth > 16 then + error("NPC definition too large", 0) + end + local t = type(v) + if t == "string" then + bytes = bytes + #v + if #v > 4096 or bytes > 65536 then + error("NPC definition too large", 0) + end + return v + elseif t == "number" then + if v ~= v or v == math.huge or v == -math.huge then + error("invalid NPC definition", 0) + end + return v + elseif t == "nil" or t == "boolean" then + return v + elseif t ~= "table" or getmetatable(v) ~= nil or visiting[v] then + error("invalid NPC definition", 0) + end + visiting[v] = true + local out, count = {}, 0 + for k, item in pairs(v) do + count = count + 1 + if count > 256 or type(k) ~= "string" and not integer(k) then + error("invalid NPC definition", 0) + end + out[clone(k, depth + 1)] = clone(item, depth + 1) + end + visiting[v] = nil + return out + end + if type(value) ~= "table" then + return nil, "invalid NPC definition" + end + local ok, result = pcall(clone, value, 0) + if not ok then + return nil, result + end + if not integer(result.x, 0, 1000000) or not integer(result.y, 0, 1000000) then + return nil, "invalid NPC coordinates" + end + for _, key in ipairs({ "name", "sprite" }) do + if result[key] ~= nil and not identifier(result[key]) then + return nil, "invalid NPC " .. key + end + end + if result.facing ~= nil and not dirs[result.facing] then + return nil, "invalid NPC facing" + end + return result +end +local function removeFrom(list, predicate) + for i = #(list or {}), 1, -1 do + if predicate(list[i]) then + table.remove(list, i) + end + end +end +local function retire(move) + move.done = true + moves[move] = nil + move.callback = nil + local removed = false + removeFrom(move.world.scriptMoves, function(entry) + if move.entries[entry] then + removed = true + return true + end + end) + if removed then + local foreign = false + for _, entry in ipairs(move.world.scriptMoves or {}) do + if entry.entity == move.actor then + foreign = true + end + end + if not foreign then + move.actor.moving, move.actor.marching = false, false + move.actor.targetX, move.actor.targetY = nil, nil + move.actor.px, move.actor.py = move.actor.cellX * 16, move.actor.cellY * 16 + end + end +end +local function removeSpawn(token) + -- Scoped by exact native record identity, even after Game/provider changes. + local rows = token.data.maps and token.data.maps[token.mapId] + local removed = false + removeFrom(rows and rows.objects, function(row) + if row == token.record then + removed = true + return true + end + end) + local function owned(actor) + return actor + and ( + actor == token.actor + or actor.def == token.record + or removed and actor.id == token.mapId .. "_obj_" .. tostring(token.record.index) + ) + end + removeFrom(token.world.npcs, owned) + removeFrom(token.world.entities, owned) + removeFrom(token.world.ghosts, function(g) + return owned(g.npc) + end) + for id, actor in pairs(token.world.npcPool or {}) do + if owned(actor) then + token.world.npcPool[id] = nil + end + end +end +function N.cancelAll(loader, owner) + for service in pairs(services) do + if (not loader or loader == service.loader) and (not owner or owner == service.owner) then + service.closed = true + for move in pairs(moves) do + if move.service == service then + retire(move) + end + end + for _, token in ipairs(service.spawned) do + removeSpawn(token) + end + service.spawned = {} + services[service] = nil + end + end +end +local function inspect(loader) + for move in pairs(moves) do + if move.service.loader == loader then + local ok, valid = pcall(move.valid) + if not ok or not valid then + retire(move) + end + end + end +end +local function observe(loader) + if observers[loader] and observers[loader][1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(moves) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_npc") + for _, event in ipairs({ "map.exited", "map.entered" }) do + loader.events:on(event, function() + for move in pairs(moves) do + if move.service.loader == loader then + retire(move) + end + end + end, math.huge, "engine:api3_npc") + end +end +function N.new(loader, owner, gen, current, native, licensed, req) + local service = { loader = loader, owner = owner, spawned = {} } + services[service] = true + observe(loader) + local function alive() + return not service.closed and licensed() + end + local function resolveGame() + if not alive() then + return nil, nil, "world facade is retired" + end + local game = current() + if not game then + return nil, nil, "no game" + end + local ow = native(game):overworld() + if not ow then + return nil, nil, "no overworld" + end + return game, ow + end + local function mapId(game, id) + if gen == 3 and not (game.data.maps or {})[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if (game.data.maps or {})[prefix .. id] then + return prefix .. id + end + end + return id + end + local function actorAt(ow, id, ref) + local function matches(actor) + local def = actor.def or {} + return actor.id == ref or def.index == ref or def.name == ref + end + if ow.map and ow.map.id == id then + for _, actor in ipairs(ow.npcs or {}) do + if matches(actor) then + return actor, ow.map, ow.cast or ow.entities + end + end + return nil, "no such object: " .. tostring(ref) + end + for _, g in ipairs(ow.ghosts or {}) do + local actor = g.npc + local map = g.map + if map and map.id == id and actor and matches(actor) then + local peers = g.nb and g.nb.cast or g.peers + if not peers then + peers = {} + for _, sibling in ipairs(ow.ghosts) do + if sibling.map == map then + peers[#peers + 1] = sibling.npc + end + end + end + return actor, map, peers, true + end + end + return nil, "map is not live" + end + local function sprite(game, id) + return game.data and game.data.sprites and game.data.sprites[id] + end + local function queue(game, ow, actor, valid, name, a, b, callback) + local hooks, events = Runtime.hooks, Runtime.events + local move = { service = service, world = ow, actor = actor, entries = {}, callback = callback } + local save, data, stack, map, version, fastWorld = + game.save, game.data, game.stack, ow.map, Version.get(), game.overworld + function move.valid() + return alive() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and game.overworld == fastWorld + and ow.map == map + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and valid() + end + local accepted, deferred + local function finish(...) + if move.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local checked, valid = pcall(move.valid) + local fn = accepted and checked and valid and move.callback + retire(move) + if not fn then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local ok, why = pcall(fn, ...) + Runtime.currentMod = previous + if not ok then + Runtime.reportError(owner, "NPC movement callback failed: " .. message(why)) + end + end + local before = {} + for _, entry in ipairs(ow.scriptMoves or {}) do + before[entry] = true + end + moves[move] = true + local called, result, detail + if name == "scriptMove" then + called, result, detail = pcall(ow.scriptMove, ow, actor, a, b or 1, finish) + else + called, result, detail = pcall(ow.marchInPlace, ow, actor, finish) + end + for _, entry in ipairs(ow.scriptMoves or {}) do + if not before[entry] and entry.entity == actor then + move.entries[entry] = true + end + end + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if accepted then + local checked, valid = pcall(move.valid) + accepted = checked and valid and not move.done + end + if not accepted then + retire(move) + return nil, + called and (detail or "NPC movement failed") or "world operation failed: " .. message(result) + end + local drain = deferred and function() + finish(unpack(deferred, 1, deferred.n)) + end + return true, nil, drain + end + local function handle(game, ow, id, ref) + local actor, map = actorAt(ow, id, ref) + if not actor then + return nil, map + end + local publicId = actor.id + if not identifier(publicId) then + return nil, "NPC identity unavailable" + end + local save, data, version, fastWorld = game.save, game.data, Version.get(), game.overworld + local function live() + if + not alive() + or current() ~= game + or game.save ~= save + or game.data ~= data + or game.overworld ~= fastWorld + or Version.get() ~= version + or native(game):overworld() ~= ow + then + return + end + local found, activeMap, peers, ghost = actorAt(ow, id, publicId) + if found ~= actor or activeMap ~= map then + return + end + return actor, activeMap, peers, ghost + end + local out = { id = publicId } + local function invoke(name, a, b, c) + if not alive() then + return nil, "world facade is retired" + end + local npc, onMap, peers, ghost = live() + if not npc then + return nil, "NPC handle is stale" + end + if name == "position" then + return npc.cellX, npc.cellY + end + if name == "isMoving" then + return not not npc.moving + end + if name == "canStep" or name == "stepNow" or name == "face" or name == "scriptMove" then + if not dirs[a] then + return nil, "invalid direction" + end + end + if name == "canStep" then + return not not Collision.canMove(onMap, peers or {}, npc, a) + end + if + name == "scriptMove" and (b ~= nil and not integer(b, 1, 1024) or c ~= nil and type(c) ~= "function") + then + return nil, "invalid movement" + end + if name == "marchInPlace" and a ~= nil and type(a) ~= "function" then + return nil, "invalid callback" + end + if + name == "placeAt" + and (not integer(a, 0, 1000000) or not integer(b, 0, 1000000) or c ~= nil and not dirs[c]) + then + return nil, "invalid coordinates" + end + if name == "setPassable" and type(a) ~= "boolean" then + return nil, "invalid passability" + end + if name == "setAppearance" and not identifier(a) then + return nil, "invalid sprite id" + end + local ok, why = Edits.ready(game, ow) + if not ok then + return nil, why + end + if ghost and (name == "scriptMove" or name == "marchInPlace") then + return nil, "movement tracks require the active map" + end + local drain + local value, detail = Edits.run(game, function() + if name == "scriptMove" or name == "marchInPlace" then + local value, why + local callback = a + if name == "scriptMove" then + callback = c + end + value, why, drain = queue(game, ow, npc, live, name, a, b, callback) + return value, why + elseif name == "face" then + npc.facing = a + elseif name == "stepNow" then + if npc.moving then + return nil, "already moving" + end + npc.facing = a + npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, a) + npc.moving, npc.progress = true, 0 + elseif name == "placeAt" then + npc.moving, npc.marching, npc.progress = false, false, 0 + npc.targetX, npc.targetY = nil, nil + npc.cellX, npc.cellY, npc.px, npc.py = a, b, a * 16, b * 16 + if c then + npc.facing = c + end + elseif name == "setPassable" then + npc.passable = a + elseif name == "setAppearance" then + if not sprite(game, a) then + return nil, "unknown sprite: " .. a + end + local proxy = { id = npc.id, def = copy(npc.def) } + proxy.def.sprite = a + proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite = + nil, nil, nil, nil + req("src.world.NPC").refreshSprite(proxy, game.data) + npc.def, npc.sprite, npc.appearance = proxy.def, proxy.sprite, a + npc.big = proxy.sprite and proxy.sprite.big or false + end + return true + end) + if drain then + drain() + end + return value, detail + end + for _, name in ipairs({ + "position", + "isMoving", + "canStep", + "scriptMove", + "marchInPlace", + "face", + "stepNow", + "placeAt", + "setPassable", + "setAppearance", + }) do + local method = name + out[method] = function(_, ...) + local previous = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(invoke, method, ...)) + Runtime.currentMod = previous + if not result[1] then + return nil, "world operation failed: " .. message(result[2]) + end + return unpack(result, 2, result.n) + end + end + return out + end + return function(name, a, b) + local game, ow, why = resolveGame() + if not game then + return nil, why + end + if name == "npc" then + if not identifier(a) or not reference(b) then + return nil, "invalid NPC reference" + end + return handle(game, ow, mapId(game, a), b) + end + local ok, reason = Edits.ready(game, ow) + if not ok then + return nil, reason + end + return Edits.run(game, function() + if name == "spawnNpc" then + if not identifier(a) or type(b) ~= "table" then + return nil, "invalid NPC definition" + end + local id = mapId(game, a) + local mapDef = game.data.maps and game.data.maps[id] + if not mapDef then + return nil, "unknown map: " .. a + end + local row, err = definition(b) + if not row then + return nil, err + end + if row.sprite and not sprite(game, row.sprite) then + return nil, "unknown sprite: " .. row.sprite + end + local before = {} + for _, item in ipairs(mapDef.objects or {}) do + before[item] = true + end + local called, value, detail = pcall(native(game).spawnNpc, native(game), id, row) + for _, item in ipairs(mapDef.objects or {}) do + if not before[item] and item.runtime and item.owner == owner then + local publicId = id .. "_obj_" .. tostring(item.index) + local actor = actorAt(ow, id, publicId) + local token = { data = game.data, world = ow, mapId = id, record = item, actor = actor } + if not called or not value then + removeSpawn(token) + else + service.spawned[#service.spawned + 1] = token + end + end + end + if not called then + return nil, "world operation failed: " .. message(value) + end + return value, detail + end + if not reference(a) or gen == 3 and not identifier(a) then + return nil, "invalid NPC id" + end + local value, err = native(game):removeNpc(a) + if value then + for i = #service.spawned, 1, -1 do + local token = service.spawned[i] + if token.mapId .. "_obj_" .. tostring(token.record.index) == a then + table.remove(service.spawned, i) + end + end + end + return value, err + end) + end +end +return N diff --git a/src/mods/Api3Records.lua b/src/mods/Api3Records.lua new file mode 100644 index 000000000..22d540b20 --- /dev/null +++ b/src/mods/Api3Records.lua @@ -0,0 +1,59 @@ +-- API-3 public Pokemon values. API-2 unified records keep their existing shape. +local R = {} +local Stats = require("src.pokemon.Stats") +local bit = require("bit") +local LONG = { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local SHORT = {} +for short, long in pairs(LONG) do + SHORT[long], SHORT[short] = short, short +end +function R.status(value) + if value == false or value == "" or value == 0 then + return nil + end + return SHORT[value] or value +end +function R.nativeStatus(value, previous) + if value == nil or value == false then + return nil + end + local short = R.status(value) + assert(LONG[short], "unknown status") + if previous and LONG[SHORT[previous]] == previous then + return LONG[short] + end + return short +end +function R.stats(out, gen) + out.special = gen ~= 1 and out.specialAttack or out.special + return out +end +function R.shiny(raw, gen) + if raw.shiny ~= nil then + return raw.shiny == true + end + if gen ~= 3 then + return Stats.isShiny(raw.dvs) + end + if raw.isShiny ~= nil then + return raw.isShiny == true + end + local p = (tonumber(raw.personality) or 0) % 4294967296 + local id = (tonumber(raw.otId or raw.trainerId) or 0) % 4294967296 + local secret = tonumber(raw.otSecretId) or math.floor(id / 65536) + return bit.bxor(id % 65536, secret, math.floor(p / 65536), p % 65536) < 8 +end +function R.mon(out, data, gen) + if type(out) ~= "table" then + return out + end + out.status = R.status(out.status) + out.stats = R.stats(out.stats or {}, gen) + local raw = out.native or {} + local def = ((data or {}).pokemon or {})[raw.species or out.species] or {} + out.name = out.nickname and out.nickname ~= "" and out.nickname or out.name or def.name or out.species + out.dvs = gen ~= 3 and type(raw.dvs) == "table" and raw.dvs or nil -- raw is already a detached snapshot + out.shiny = R.shiny(raw, gen) + return out +end +return R diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua new file mode 100644 index 000000000..c447cdec1 --- /dev/null +++ b/src/mods/Api3Warp.lua @@ -0,0 +1,265 @@ +-- API-3 warp admission and callback lifetime over the native overworld path. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Actions = require("src.world.WorldActions") +local W = {} +local contexts = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local pending = setmetatable({}, { __mode = "k" }) +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n) + return type(n) == "number" and n == n and n >= 0 and n < math.huge and n % 1 == 0 +end +local function retire(token) + token.done = true + token.callback = nil + if pending[token.game] == token then + pending[token.game] = nil + end +end +function W.cancelAll(loader, owner) + for candidate, context in pairs(contexts) do + if not loader or candidate == loader then + if owner then + context.owners[owner] = nil + else + contexts[candidate] = nil + end + end + end + for _, token in pairs(pending) do + if (not loader or token.loader == loader) and (not owner or token.owner == owner) then + retire(token) + end + end +end +local function inspect(loader) + for _, token in pairs(pending) do + if token.loader == loader and not token.valid() then + retire(token) + end + end +end +local function observe(loader) + local old = observers[loader] + if old and old[1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Installed at facade creation, never spliced into a dispatching hook chain. + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(pending) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_warp") + for _, event in ipairs({ "map.entered", "map.exited" }) do + local name = event + loader.events:on(name, function(payload) + for _, token in pairs(pending) do + if token.loader == loader then + if not token.valid() then + retire(token) + elseif name == "map.entered" then + if token.entered or payload.mapId ~= token.destination then + retire(token) + else + token.entered = true + end + elseif token.entered then + retire(token) + end + end + end + end, math.huge, "engine:api3_warp") + end +end +-- Pure destination validation shared by public warps and portable scripts. +function W.plan(game, ow, generation, mapId, x, y, facing) + if type(mapId) ~= "string" or #mapId == 0 or #mapId > 256 or mapId:find("%c") then + return nil, "invalid map id" + end + local maps = game.data and game.data.maps + local destination = mapId + if generation == 3 and maps and not maps[destination] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. destination] then + destination = prefix .. destination + end + end + local def = maps and maps[destination] + if not def then + return nil, "unknown map: " .. mapId + end + if not integer(x) or not integer(y) then + return nil, "invalid coordinates" + end + local scale = generation == 3 and 1 or 2 + if not integer(def.width) or def.width == 0 or not integer(def.height) or def.height == 0 then + return nil, "invalid map dimensions" + end + if x >= def.width * scale or y >= def.height * scale then + return nil, "coordinates out of bounds" + end + if facing ~= nil and not dirs[facing] then + return nil, "invalid facing" + end + return { + mapId = destination, + x = x, + y = y, + facing = facing or (generation ~= 1 and ow.player.facing) or "down", + def = def, + } +end +function W.new(loader, owner, generation, current, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[owner] = lease + local function licensed() + return contexts[loader] == context and context.owners[owner] == lease + end + observe(loader) + local function warp(mapId, x, y, facing, options) + if not licensed() then + return nil, "warp facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + local api = native(game) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ready, why = Actions.ready(game, ow) + if not ready then + return nil, why + end + inspect(loader) + if pending[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then + return nil, "world is busy" + end + local plan, reason = W.plan(game, ow, generation, mapId, x, y, facing) + if not plan then + return nil, reason + end + local destination = plan.mapId + if options ~= nil and (type(options) ~= "table" or getmetatable(options) ~= nil) then + return nil, "invalid options" + end + local opts = {} + for key, value in pairs(options or {}) do + if key == "arrive" then + if value ~= "fly" and value ~= "teleport" then + return nil, "invalid arrival" + end + elseif key == "onDone" then + if type(value) ~= "function" then + return nil, "invalid callback" + end + elseif key == "keepMusic" or key == "fade" then + if type(value) ~= "boolean" then + return nil, "invalid options" + end + if key == "fade" and generation ~= 3 then + return nil, "unsupported fade" + end + else + return nil, "unknown warp option: " .. tostring(key) + end + opts[key] = value + end + facing = plan.facing + local hooks, events = Runtime.hooks, Runtime.events + local token = + { game = game, loader = loader, owner = owner, destination = destination, callback = opts.onDone } + local save, data, stack, player, version = game.save, game.data, game.stack, ow.player, Version.get() + function token.valid() + return not token.done + and licensed() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and ow.player == player + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and native(game):overworld() == ow + end + local accepted, deferred + local function finish(...) + if token.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local valid = token.valid() and ow.map and ow.map.id == destination + local callback = valid and token.callback + retire(token) + if not accepted or not callback then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local result = pack(pcall(callback, ...)) + Runtime.currentMod = previous + if not result[1] then + Runtime.reportError(owner, "world warp callback failed: " .. tostring(result[2])) + end + end + pending[game] = token + local arrival = ow.arriveWarp + if opts.arrive then + ow.arriveWarp = opts.arrive + end + local called, result, detail = pcall( + ow.startWarpTo, + ow, + destination, + x, + y, + facing, + finish, + { via = "warp", keepMusic = opts.keepMusic, fade = opts.fade } + ) + -- The native controller is a void method. Explicit false/refusal and errors + -- never count as admission; a callback fired before that result is deferred. + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if not accepted then + retire(token) + ow.arriveWarp = arrival + if not called then + return nil, "world operation failed: " .. tostring(result) + end + return nil, detail or "world operation failed" + end + if deferred then + finish(unpack(deferred, 1, deferred.n)) + end + return true + end + -- Synchronous API-3 edits share this owner lease and loader lifecycle. + return warp, licensed +end +return W diff --git a/src/mods/Api3WildBattle.lua b/src/mods/Api3WildBattle.lua new file mode 100644 index 000000000..8f5ca5ece --- /dev/null +++ b/src/mods/Api3WildBattle.lua @@ -0,0 +1,205 @@ +-- API-3 encounter admission over native construction, transition and afterBattle. +-- Portable scripts may supply a private owned guard and native trainer plan. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Edits = require("src.mods.Api3WorldEdits") +local Ids = require("src.mods.UnifiedIds") +local Party = require("src.pokemon.Party") +local B = {} +local pending, observers = {}, setmetatable({}, { __mode = "k" }) +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function text(value) + local ok, result = pcall(tostring, value) + return ok and result or "unprintable error" +end +local function retire(token) + token.retired, token.callback = true, nil + pending[token] = nil +end +function B.cancelAll(loader, owner) + for token in pairs(pending) do + if (not loader or loader == token.loader) and (not owner or owner == token.owner) then + retire(token) + end + end +end +local function inspect(loader) + for token in pairs(pending) do + if token.loader == loader and not token.valid() then + retire(token) + end + end +end +local function observe(loader) + if observers[loader] == loader.hooks then + return + end + observers[loader] = loader.hooks + loader.hooks:wrap("input.step", function(nextFn, ...) + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_wild_battle") +end +function B.new(loader, owner, current, native, licensed, req, ownedReady, ownedNative) + local ids = Ids.new(loader) + observe(loader) + return function(species, level, callback) + if not licensed() then + return nil, "world facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + local ow = native(game):overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = (ownedReady or Edits.ready)(game, ow) + if not ok then + return nil, reason + end + for token in pairs(pending) do + if token.game == game then + if token.valid() then + return nil, "world is busy" + end + retire(token) + end + end + local key, why + if ownedNative then + key, why = ownedNative.resolve(game, species, level) + if not key then + return nil, why + end + if callback ~= nil and type(callback) ~= "function" then + return nil, "invalid callback" + end + else + if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then + return nil, "invalid species id" + end + if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then + return nil, "level must be a whole number 1..100" + end + if callback ~= nil and type(callback) ~= "function" then + return nil, "invalid callback" + end + local group, why = ids:resolve("pokemon", species) + if not group then + return nil, why:find("^ambiguous") and why or "unknown species: " .. species + end + key = group.key + if not (game.data.pokemon and game.data.pokemon[key]) then + return nil, "unknown species: " .. species + end + end + if not Party.firstHealthy(game.save.party or {}) then + return nil, "no healthy party" + end + if type(ow.pushBattle) ~= "function" or type(ow.afterBattle) ~= "function" then + return nil, "battle runtime unavailable" + end + local save, data, stack, player, world = game.save, game.data, game.stack, ow.player, game.overworld + local version, hooks, events = Version.get(), Runtime.hooks, Runtime.events + local token = { loader = loader, owner = owner, game = game, callback = callback } + local function live() + return current() == game + and game.save == save + and game.data == data + and game.stack == stack + and game.overworld == world + and ow.player == player + and native(game):overworld() == ow + and Version.get() == version + end + function token.valid() + return not token.retired + and licensed() + and live() + and Runtime.hooks == hooks + and Runtime.events == events + end + local admitted, deferred, finished, battle + local function finish(...) + if finished then + return + end + if admitted == nil then + if not deferred then + deferred = pack(...) + end + return + end + finished = true + local valid, fn = token.valid(), token.callback + retire(token) + if not admitted or not live() then + return + end + -- Native cleanup still runs after the originating mod has been unloaded. + local outcome = (...) + local called, err = pcall(function() + if ownedNative and ownedNative.cleanup then + ownedNative.cleanup(game, ow, key, outcome, battle) + end + ow:afterBattle(outcome, battle) + end) + if not called then + Runtime.reportError(owner, "world battle cleanup failed: " .. text(err)) + if ownedReady and valid and fn then + pcall(fn, nil, "native battle cleanup failed: " .. text(err)) + end + return + end + if valid and licensed() and live() and fn then + local previous = Runtime.currentMod + Runtime.currentMod = owner + called, err = pcall(fn, ...) + Runtime.currentMod = previous + if not called then + Runtime.reportError(owner, "world battle callback failed: " .. text(err)) + end + end + end + pending[token] = true + local called, accepted, err = pcall(Edits.run, game, function() + if ownedNative then + battle = ownedNative.construct(game, key) + else + battle = req("src.battle.BattleState").newWild(game, key, level) + end + if not battle or battle.dead then + return nil, "battle failed" + end + if not token.valid() or ownedReady and not ownedReady(game, ow) then + return nil, "battle session changed" + end + battle.onFinish = finish + local value, detail = ow:pushBattle(battle) + if value == false or value == nil and detail ~= nil then + return nil, detail or "battle failed" + end + return true + end) + admitted = called and accepted == true + if not admitted then + retire(token) + return nil, called and (err or "battle failed") or "world operation failed: " .. text(accepted) + end + if deferred then + finish(unpack(deferred, 1, deferred.n)) + end + return true + end +end +return B diff --git a/src/mods/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua new file mode 100644 index 000000000..77d8ce2eb --- /dev/null +++ b/src/mods/Api3WorldEdits.lua @@ -0,0 +1,213 @@ +-- Validated API-3 object/block edits over this host's native runtime paths. +local Actions = require("src.world.WorldActions") +local Reads = require("src.mods.WorldReads") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local E = {} +local running = setmetatable({}, { __mode = "k" }) +local function integer(value, maximum) + return type(value) == "number" + and value == value + and value >= 0 + and value < math.huge + and value % 1 == 0 + and (not maximum or value <= maximum) +end +local function identifier(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +local function store(value) + return value == nil or type(value) == "table" +end +local function toggle(game, ow, api, gen, req, mapId, ref, visible) + if not identifier(mapId) or not (identifier(ref) or integer(ref)) or type(visible) ~= "boolean" then + return nil, "invalid object toggle" + end + local maps = game.data and game.data.maps + local id = mapId + if gen == 3 and maps and not maps[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. id] then + id = prefix .. id + end + end + local def = maps and maps[id] + if not def then + return nil, "unknown map: " .. mapId + end + local save = game.save + if not store(save.objectToggles) or not store(save.objectToggles and save.objectToggles[id]) then + return nil, "invalid object toggle store" + end + if not store(save.flags) then + return nil, "invalid flag store" + end + if gen == 1 then + -- Gen 1 permits off-map keys and resolves their visibility on map load. + return function() + return api:toggleObject(id, ref, visible) + end + end + if ow.map.id ~= id then + return nil, "map is not active: " .. mapId + end + local objects = ow.map.def and ow.map.def.objects + if type(objects) ~= "table" then + return nil, "map has no objects" + end + local object + for i, candidate in ipairs(objects) do + local numeric = gen == 2 and i or tonumber(candidate.localId or candidate.index) + if numeric == ref or candidate.name == ref or gen == 3 and candidate.id == ref then + object = candidate + break + end + end + if not object then + return nil, "no such object: " .. tostring(ref) + end + if type(ow.syncObjectVisibility) ~= "function" then + return nil, "object runtime unavailable" + end + local ctx = { game = game, save = save, overworld = ow, mapId = id } + local index = object.index + if gen == 2 then + if not store(save.g2ObjectToggles) then + return nil, "invalid object toggle store" + end + else + if not integer(index, 254) or index == 0 then + return nil, "invalid object definition" + end + if + not store(save.gen3Spawned) + or not store(save.gen3Spawned and save.gen3Spawned[id]) + or not store(save.gen3SessionObjects) + then + return nil, "invalid object toggle store" + end + end + return function() + if gen == 2 then + req("src.script.Gen2Commands").setObjectVisibility(ctx, object, id, visible) + else + req("src.script.Gen3Commands") + local commands = req("src.script.Commands") + local command = visible and commands.g3_show_object or commands.g3_hide_object + command(ctx, index) + end + Runtime.emit("world.object_toggled", { mapId = id, objName = ref, visible = visible }) + return true + end +end +local function replace(game, ow, api, gen, req, bx, by, block) + if not integer(block, gen == 3 and 1023 or 255) then + return nil, "invalid block id" + end + local present, metadata = Reads.activeBlockAt(ow.map, ow.map.id, bx, by) + if present == nil then + return nil, metadata + end + local cells = gen == 3 and 1 or 2 + if ow.map.blockCells ~= cells then + return nil, "unsupported block geometry" + end + if gen ~= 3 and not (ow.map.tileset and ow.map.tileset.blocks and ow.map.tileset.blocks[block + 1]) then + return nil, "unknown block id" + end + local renderer = ow.map.renderer + if not renderer or type(renderer.rebuild) ~= "function" then + return nil, "block renderer unavailable" + end + if gen ~= 3 then + return function() + return api:replaceBlock(bx, by, block) + end + end + return function() + -- Like Gen1's Gen3 Field.setMetatile default, use the native non-impassable + -- metatile command; it replaces the old collision override as well. + req("src.script.Gen3Commands") + req("src.script.Commands").g3_set_metatile( + { game = game, save = game.save, overworld = ow }, + bx, + by, + block, + 0 + ) + renderer:rebuild() + ow.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = ow.map.id, bx = bx, by = by, block = block }) + return true + end +end +local function invalidate(game, ow, api, gen, req, mapId) + if not identifier(mapId) then + return nil, "unknown map" + end + local maps = game.data and game.data.maps + local id = mapId + if gen == 3 and maps and not maps[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if maps[prefix .. id] then + id = prefix .. id + end + end + if not (maps and maps[id]) then + return nil, "unknown map" + end + if type(ow.reloadMap) ~= "function" then + return nil, "map reload unavailable" + end + return function() + return api:invalidateMap(id) + end +end +-- Shared admission/commit guard for synchronous API-3 world mutations. +function E.ready(game, ow) + local ok, reason = Actions.ready(game, ow) + if not ok then + return nil, reason + end + if running[game] or ow.gen3Locked or #(ow.parallelRunners or {}) > 0 or #(ow.parallelQueue or {}) > 0 then + return nil, "world is busy" + end + return true +end +function E.run(game, operation, ...) + running[game] = true + local called, value, why = pcall(operation, ...) + running[game] = nil + if not called then + error(value, 0) + end + return value, why +end +-- Pure validation shared by public edits and an already owned script run. +function E.plan(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local operation = name == "toggleObject" and toggle or name == "invalidateMap" and invalidate or replace + return operation(game, ow, api, generation, req, ...) +end +function E.call(game, api, generation, req, name, ...) + local ow = api:overworld() + if not ow then + return nil, "no overworld" + end + local ok, reason = E.ready(game, ow) + if not ok then + return nil, reason + end + local function execute(...) + local operation, reason = E.plan(game, api, generation, req, name, ...) + if not operation then + return nil, reason + end + return operation() + end + return E.run(game, execute, ...) +end +return E diff --git a/src/mods/BattleIntents.lua b/src/mods/BattleIntents.lua new file mode 100644 index 000000000..5daa69311 --- /dev/null +++ b/src/mods/BattleIntents.lua @@ -0,0 +1,178 @@ +-- Data-only intent validation and private cross-facade submission state. +local Intents = {} +local states = setmetatable({}, { __mode = "k" }) +local FIELDS = { + menu = { choice = true }, + move = { slot = true }, + target = { target = true }, + back = {}, + safari = { action = true }, + mimic = { index = true }, +} +local MENU = { fight = true, party = true, item = true, run = true } +local SAFARI = { ball = true, bait = true, rock = true, run = true } +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function Intents.normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id, 1, 9007199254740991) then + return nil, "intent id must be a positive safe integer" + end + if not integer(raw.revision, 1, 9007199254740991) then + return nil, "invalid battle revision" + end + local fields = type(raw.kind) == "string" and FIELDS[raw.kind] + if not fields then + return nil, "unknown battle intent" + end + for key in pairs(raw) do + if key ~= "id" and key ~= "revision" and key ~= "kind" and not fields[key] then + return nil, "unknown battle intent field" + end + end + local out = { id = raw.id, revision = raw.revision, kind = raw.kind } + if raw.kind == "menu" then + if type(raw.choice) ~= "string" or not MENU[raw.choice] then + return nil, "unknown battle menu choice" + end + out.choice = raw.choice + elseif raw.kind == "move" then + if not integer(raw.slot, 1, 4) then + return nil, "invalid move slot" + end + out.slot = raw.slot + elseif raw.kind == "target" then + if not integer(raw.target, 0, 3) then + return nil, "target must be a battler id 0-3" + end + out.target = raw.target + elseif raw.kind == "safari" then + if type(raw.action) ~= "string" or not SAFARI[raw.action] then + return nil, "invalid safari action" + end + out.action = raw.action + elseif raw.kind == "mimic" then + if not integer(raw.index, 1, 4) then + return nil, "invalid mimic index" + end + out.index = raw.index + end + return out +end +function Intents.state(game) + local state = states[game] + if not state then + state = { epoch = 0, busy = false } + states[game] = state + end + return state +end + +local BATTLE_FLAGS = { + "waitingUI", + "dead", + "demo", + "dudeDemo", + "pokedudeDemo", + "palace", + "arena", + "spectating", + "bugContest", + "noItems", + "gen4Safari", +} +local BATTLER_FLAGS = { + "disabledSlot", + "tauntTurns", + "tormented", + "lastMove", + "mustRecharge", + "charging", + "thrashTurns", + "thrashMove", + "rageMove", + "trappingTurns", + "bideTurns", + "encoreTurns", + "encoreMove", + "bwSkyDropped", + "bwChoiceLock", + "bwImprison", + "bwHealBlock", + "bwEmbargo", +} +-- Reference identity matters as well as record values: an identical replacement +-- Pokemon, move slot, selector or covering screen must invalidate old commands. +-- refs are compared shallowly; values are copied by the snapshot owner. +function Intents.context(game, b, top, at) + local refs = { + game = game, + battle = b, + top = top, + stack = game.stack, + queue = b.queue, + current = b.current, + targets = b.targetChoices, + mimic = b.mimicCtx, + mimicMoves = b.mimicMoves, + pendingMove = b.pendingMove, + pending = b.pendingActions, + chosen = b.chosenTargets, + safari = b.safari, + } + local values = { queueLength = #(b.queue or {}), flags = {}, battlers = {} } + for _, key in ipairs(BATTLE_FLAGS) do + values.flags[key] = type(b[key]) == "table" and true or b[key] + end + values.bw = type(b.bw) == "table" + and { gravity = b.bw.field and b.bw.field.gravity, magicRoom = b.bw.field and b.bw.field.magicRoom } + or b.bw + if b.mimicCtx then + refs.mimicUser, refs.mimicTarget, refs.mimicMove = b.mimicCtx.user, b.mimicCtx.target, b.mimicCtx.moveInst + end + for i, move in ipairs(b.mimicMoves or {}) do + refs["mimicOption" .. i] = move + end + for pos = 0, 3 do + local actor = at(b, pos) + refs["b" .. pos] = actor + if actor then + refs["mon" .. pos] = actor.mon + refs["moves" .. pos] = actor.curMoves + local flags = {} + for _, key in ipairs(BATTLER_FLAGS) do + flags[key] = type(actor[key]) == "table" and true or actor[key] + end + values.battlers[pos] = flags + for slot, mv in ipairs(actor.curMoves or {}) do + refs["move" .. pos .. ":" .. slot] = mv + end + end + refs["pending" .. pos] = b.pendingActions and b.pendingActions[pos] + refs["chosen" .. pos] = b.chosenTargets and b.chosenTargets[pos] + end + for i, target in ipairs(b.targetChoices or {}) do + refs["target" .. i] = target + end + return refs, values +end +function Intents.sameRefs(a, b) + if not a or not b then + return a == b + end + for key, value in pairs(a) do + if b[key] ~= value then + return false + end + end + for key, value in pairs(b) do + if a[key] ~= value then + return false + end + end + return true +end +return Intents diff --git a/src/mods/BattleSnapshots.lua b/src/mods/BattleSnapshots.lua new file mode 100644 index 000000000..1fce0a816 --- /dev/null +++ b/src/mods/BattleSnapshots.lua @@ -0,0 +1,583 @@ +-- Detached Gen 1-5 snapshots and revision-bound native command admission. +-- Snapshot building stays data-only; submit validates before native dispatch. +local Ids = require("src.mods.UnifiedIds") +local Intents = require("src.mods.BattleIntents") +local Commands = require("src.battle.BattleCommands") +local Runtime = require("src.mods.Runtime") +-- Native work requested through a facade remains engine work. The buses keep +-- their existing callback/error attribution; lazy requires must not be blamed +-- on the requesting mod by the dev-mode permission shim. +local function enginePcall(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local ok, a, b = pcall(fn, ...) + Runtime.currentMod = owner + return ok, a, b +end +local Snapshots = {} + +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STATS = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "string" or kind == "boolean" or kind == "number" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, v in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(v, seen) + end + end + return out +end + +local function equal(a, b, seen) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b or (a ~= a and b ~= b) + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end + +local function stats(raw) + local out = {} + for public, native in pairs(STATS) do + out[public] = (raw or {})[native] + end + out.specialAttack = out.specialAttack or out.special + out.specialDefense = out.specialDefense or out.special + return out +end + +local function at(battle, pos) + -- Constructors fill these aliases before enter()/syncSides populates the + -- positional arrays. A cleared alias also truthfully represents a faint. + if pos == 0 then + return battle.player + end + if pos == 1 then + return battle.enemy + end + local side = battle.sides and battle.sides[pos % 2 + 1] + return side and side.battlers and side.battlers[math.floor(pos / 2) + 1] +end + +local function activeSlot(battle) + if battle.phase == "mimicSelect" and battle.mimicCtx then + for _, pos in ipairs({ 0, 2 }) do + if at(battle, pos) == battle.mimicCtx.user then + return pos + end + end + end + if not battle.double then + return 0 + end + local pos = battle.choosingSlot + if (pos == 0 or pos == 2) and at(battle, pos) then + return pos + end + for _, id in ipairs({ 0, 2 }) do + local b = at(battle, id) + if b and not b.ally then + return id + end + end + return 0 +end + +local function moveView(move, slot, data, ids) + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] or {} + local ups = type(move) == "table" and math.min(3, math.max(0, math.floor(tonumber(move.ppUps) or 0))) or 0 + return { + slot = slot, + id = ids:read("moves", id), + name = def.name, + pp = type(move) == "table" and move.pp or nil, + maxPp = def.pp and (def.pp + ups * math.floor(def.pp / 5)) or nil, + ppUps = ups, + type = def.type, + power = def.power, + accuracy = def.accuracy, + } +end + +local function monView(raw, data, ids) + if not raw then + return nil + end + local out = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name or raw.nickname or ((data.pokemon or {})[raw.species] or {}).name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats(raw.stats), + moves = {}, + native = copy(raw), + } + for slot, move in ipairs(raw.moves or {}) do + out.moves[slot] = moveView(move, slot, data, ids) + end + return out +end + +local function battlerView(b, pos, party, data, ids) + if not (b and b.mon) then + return nil + end + local out = monView(b.mon, data, ids) + out.name = b.name or out.name + out.id, out.side, out.active, out.absent = pos, pos % 2 == 0 and "player" or "enemy", true, false + out.displaySpecies = ids:read("pokemon", b.species or b.mon.species) + out.battleStats, out.types, out.stages = + stats(b.curStats or b.mon.stats), copy(b.curTypes), copy(b.stages or {}) + out.ally = b.ally == true + for index, mon in ipairs(party) do + if mon == b.mon then + out.partyIndex = index + break + end + end + out.moves = {} + for slot, move in ipairs(b.curMoves or b.mon.moves or {}) do + out.moves[slot] = moveView(move, slot, data, ids) + end + return out +end + +-- Prompt describes the battle screen only. Covered screens and automatic +-- controllers stay locked; no attempt is made to inspect or drive their UI. +local function prompt(b, covered, actor) + if + covered + or b.result + or b.waitingUI + or b.demo + or b.pokedudeDemo + or b.palace + or b.kind == "link" + or (b.arena and b.phase == "menu") + then + return "locked" + end + if b.phase == "menu" then + if b.safari then + return (b.safari.balls or 0) > 0 and "safari" or "locked" + end + if + not (actor and actor.mon and (actor.mon.hp or 0) > 0) + or actor.ally + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + then + return "locked" + end + -- Sky Drop's controller in BW owns the menu; unknown native locks are + -- not advertised as actionable (this API never submits input). + if b.bw and actor.bwSkyDropped then + return "locked" + end + return "menu" + end + if b.phase == "moveSelect" then + return "moves" + end + if b.phase == "targetSelect" then + return "target" + end + if b.phase == "mimicSelect" then + return "mimic" + end + if + b.phase == "messages" + and b.current + and not b.current.choice + and ((b.msgWaiting and (b.msgPreWait or 0) <= 0) or (b.msgPrompt and (b.msgPromptWait or 0) <= 0)) + then + return "advance" + end + return "locked" +end + +-- Decode only glyphs already revealed by the native message clock. Unknown +-- glyphs leave the optional visible message unavailable, never reveal ahead. +local function visibleMessage(battle, covered, data) + if covered or battle.phase ~= "messages" or not battle.shown then + return nil + end + local reverse = {} + local function add(rows) + for _, row in ipairs(rows or {}) do + if type(row.code) == "number" and type(row.seq) == "string" and reverse[row.code] == nil then + reverse[row.code] = row.seq + end + end + end + add(data.font and data.font.charmap) + local pages, names = data.font and data.font.pages or {}, {} + for name in pairs(pages) do + names[#names + 1] = name + end + table.sort(names) + for _, name in ipairs(names) do + add(type(pages[name]) == "table" and pages[name].charmap) + end + local unicode = package.loaded["src.translation.UnicodeFont"] + local chars = type(unicode) == "table" and unicode.chars or {} + local scaffold = data.font + and type(data.font.source) == "string" + and data.font.source:find("Gen2 scaffold", 1, true) + local out = {} + for i, codes in ipairs(battle.shown) do + local line = {} + for _, code in ipairs(codes) do + local seq = chars[code] or reverse[code] + if scaffold and type(code) == "number" and code >= 0 and code <= 255 then + seq = string.char(code) + end + if not seq then + return nil + end + line[#line + 1] = seq + end + out[i] = table.concat(line) + end + return out +end + +local function kind(battle) + -- The same variants as BattleState:battleKind(), without a method call. + if battle.ghost then + return "ghost" + end + if battle.safari then + return "safari" + end + if battle.demo and not battle.dudeDemo then + return "oldman" + end + return battle.kind +end + +local function build(game, battle, covered, ids) + local data, save = battle.data or game.data or {}, game.save or {} + local party, enemyParty = battle.playerParty or save.party or {}, battle.enemyParty or {} + local active = activeSlot(battle) + local actor = at(battle, active) + local out = { + kind = kind(battle), + phase = battle.phase, + double = battle.double == true, + active = active, + turn = battle.turnCount or 0, + prompt = prompt(battle, covered, actor), + message = visibleMessage(battle, covered, data), + messageSource = battle.current and battle.current.text, + safariBalls = battle.safari and battle.safari.balls, + menuIndex = battle.menuIndex, + moveIndex = battle.moveIndex, + moveSwapIndex = battle.moveSwapIndex, + result = battle.result, + party = {}, + battlers = {}, + moves = {}, + items = {}, + targets = {}, + } + local foes = 0 + local rows = {} + for pos = 0, (out.double and 3 or 1) do + local b = at(battle, pos) + local row = battlerView( + b, + pos, + pos % 2 == 0 and (b and b.ally and battle.ally and battle.ally.party or party) or enemyParty, + data, + ids + ) + rows[pos] = row + if row then + out.battlers[#out.battlers + 1] = row + if pos % 2 == 1 and (row.hp or 0) > 0 then + foes = foes + 1 + end + end + end + out.player, out.enemy = rows[active], rows[1] + out.catchable = battle.kind == "wild" + and not battle.ghost + and not battle.noCatch + and not battle.result + and foes == 1 + for slot, raw in ipairs(party) do + local row = monView(raw, data, ids) + row.slot, row.active = slot, false + for _, pos in ipairs({ 0, 2 }) do + local b = at(battle, pos) + if b and not b.ally and b.mon == raw then + row.active = true + end + end + out.party[slot] = row + end + for slot, move in ipairs(actor and (actor.curMoves or actor.mon.moves) or {}) do + local row = moveView(move, slot, data, ids) + local def = (data.moves or {})[move.id] or {} + -- The three explicit guards used by BattleState's move selector. This + -- is a local menu restriction, not a guarantee a move will execute. + row.disabled = actor.disabledSlot == slot + or (move.pp or 0) <= 0 + or (actor.tauntTurns ~= nil and (def.power or 0) == 0) + or (actor.tormented and actor.lastMove ~= nil and move.id == actor.lastMove) + or false + -- BW adds Choice/Imprison/Gravity/Heal Block checks through native + -- controllers. Do not report an unverified false for those moves. + if battle.bw and not row.disabled then + row.disabled = nil + end + out.moves[slot] = row + end + -- Inventory quantities, not a use-eligibility result. Keep acquisition + -- order, then append positive unlisted IDs deterministically. + local inventory, seen, remaining = save.inventory or {}, {}, {} + local function item(id) + if seen[id] then + return + end + seen[id] = true + local count = inventory[id] + if type(count) ~= "number" or count <= 0 then + return + end + local def = (data.items or {})[id] or {} + local balls = data.balls or {} + local ball = def.ball ~= nil and def.ball ~= false or balls[id] ~= nil or balls[tostring(id)] ~= nil + out.items[#out.items + 1] = { + id = ids:read("items", id), + name = def.name, + count = count, + ball = ball, + catchable = ball and out.catchable, + pocket = def.pocket, + } + end + for _, id in ipairs(save.bagOrder or {}) do + item(id) + end + for id in pairs(inventory) do + if not seen[id] then + remaining[#remaining + 1] = id + end + end + table.sort(remaining, function(a, b) + return type(a) == type(b) and a < b or type(a) < type(b) + end) + for _, id in ipairs(remaining) do + item(id) + end + if battle.phase == "targetSelect" then + for _, target in ipairs(battle.targetChoices or {}) do + for pos = 0, 3 do + if at(battle, pos) == target then + out.targets[#out.targets + 1] = pos + break + end + end + end + out.targetIndex = battle.targetIndex + end + if battle.phase == "mimicSelect" then + out.mimicMoves, out.mimicIndex = {}, battle.mimicIndex + for index, move in ipairs(battle.mimicMoves or {}) do + out.mimicMoves[index] = moveView(move, move.slot or index, data, ids) + end + end + return out +end + +function Snapshots.new(gameProvider, loader) + -- Ids keeps alias metadata on the loader, never on gameplay records. + local initial = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + initial:entries(kind) + end + local facade = {} + local lastGame, lastBattle, previous, revision = nil, nil, nil, 0 + local lastRefs, lastControl, lastEpoch, offeredRevision, lastIntentId = nil, nil, nil, nil, 0 + local function refresh() + local game = gameProvider() + local states = game and game.stack and game.stack.states or {} + local battle, index + for i = #states, 1, -1 do + if states[i].isBattleState == true then + battle, index = states[i], i + break + end + end + if not battle then + lastGame, lastBattle, previous, lastRefs, lastControl, offeredRevision = nil, nil, nil, nil, nil, nil + return nil, nil, nil, "no battle" + end + local state = Intents.state(game) + local out = copy(build(game, battle, index ~= #states, Ids.new(loader))) + local refs, control = Intents.context(game, battle, states[#states], at) + control = copy(control) + if + game ~= lastGame + or battle ~= lastBattle + or not equal(out, previous) + or not Intents.sameRefs(refs, lastRefs) + or not equal(control, lastControl) + or state.epoch ~= lastEpoch + then + revision = revision + 1 + end + lastGame, lastBattle, previous = game, battle, out + lastRefs, lastControl, lastEpoch = refs, control, state.epoch + return game, battle, index, state + end + function facade:snapshot() + local game, battle, _, state = refresh() + if not battle then + return nil, "no battle" + end + local result = copy(previous) + result.revision = revision + if state.busy then + result.prompt = "locked" + end + offeredRevision = revision + return result + end + function facade:submit(raw) + local intent, reason = Intents.normalize(raw) + if not intent then + return nil, reason + end + if intent.id <= lastIntentId then + return nil, "replayed intent" + end + local game, battle, index, state = refresh() + if not battle then + return nil, "no battle" + end + if state.busy then + return nil, "battle action is already in progress" + end + if intent.revision ~= revision then + return nil, "stale battle context" + end + if offeredRevision ~= revision then + return nil, "fresh battle snapshot required" + end + if battle.game ~= game then + return nil, "battle does not belong to current Game" + end + if index ~= #game.stack.states then + return nil, "battle menu is covered" + end + -- The native admission helpers are read-only. Lock during validation too, + -- so a mod callback/overridden helper cannot recursively enter submission. + local refs, control = lastRefs, lastControl + state.busy = true + local checked, refusal = enginePcall(Commands.reason, battle, intent) + if not checked or refusal then + state.busy = false + return nil, checked and refusal or "battle validation failed" + end + local fresh, currentGame, currentBattle = pcall(refresh) + if not fresh then + state.busy = false + return nil, "battle validation failed" + end + if + game ~= currentGame + or battle ~= currentBattle + or revision ~= intent.revision + or not Intents.sameRefs(refs, lastRefs) + or not equal(control, lastControl) + then + state.busy = false + return nil, "stale battle context" + end + -- Consume BEFORE native code: a callback or partial native failure cannot + -- replay this ID. Both boundaries invalidate snapshots held by other mods, + -- including snapshots observed halfway through an accepted action. + lastIntentId, offeredRevision = intent.id, nil + state.epoch = state.epoch + 1 + local ok, accepted, err = enginePcall(Commands.dispatch, battle, intent) + state.epoch = state.epoch + 1 + state.busy = false + if not ok then + local printable, message = pcall(tostring, accepted) + return nil, "battle action failed; intent consumed" .. (printable and ": " .. message or "") + end + if not accepted then + return nil, err or "battle action did not complete; intent consumed" + end + return true + end + return facade +end +-- Run after construction so an optional capability provider and its other +-- surface wrappers are already installed. Standalone main has no provider. +function Snapshots.withCapabilities(api) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "battle" then + if name == "snapshot" or name == "submit" then + return true + end + return false, "unknown battle capability: " .. tostring(name) + end + return supports(self, surface, name) + end + end + return api +end +return Snapshots diff --git a/src/mods/CallbackRecords.lua b/src/mods/CallbackRecords.lua new file mode 100644 index 000000000..4ce1679c5 --- /dev/null +++ b/src/mods/CallbackRecords.lua @@ -0,0 +1,731 @@ +local Api3Records = require("src.mods.Api3Records") +-- A call-local transaction. Only known record roles cross this boundary; +-- opaque engine handles (Game, battle, save, map, RNG, UI callbacks) stay native. +local Ids = require("src.mods.UnifiedIds") +local R = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function shallow(t) + local out = {} + for k, v in pairs(t or {}) do + out[k] = v + end + return out +end +local function copy(v, seen) + if type(v) ~= "table" then + if type(v) == "function" then + return nil + end + return v + end + seen = seen or {} + if seen[v] then + return seen[v] + end + local out = {} + seen[v] = out + for k, x in pairs(v) do + out[k] = copy(x, seen) + end + return out +end +local function equal(a, b, seen) + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + seen = seen or {} + if seen[a] == b then + return true + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function integer(v, lo, hi, label) + assert(type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi, label .. " out of range") + return v +end +local status = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local short = {} +for k, v in pairs(status) do + short[v] = k +end +local stats = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} +local typeAliases = { + PSYCHIC = "PSYCHIC_TYPE", + PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", + FIGHT = "FIGHTING", + ELECTR = "ELECTRIC", +} +local function typeRead(id) + return typeAliases[id] or id +end +local monFields = { + species = true, + item = true, + nickname = true, + name = true, + level = true, + hp = true, + maxHp = true, + status = true, + isEgg = true, + stats = true, + moves = true, + native = true, +} +local idRoles = { + species = "pokemon", + fromSpecies = "pokemon", + toSpecies = "pokemon", + evolveTo = "pokemon", + moveId = "moves", + item = "items", + itemId = "items", + ball = "items", +} +local recordRoles = { + mon = "mon", + pokemon = "mon", + user = "battler", + target = "battler", + attacker = "battler", + defender = "battler", + battler = "battler", + incoming = "battler", + previous = "battler", + move = "move", + defeatedDef = "species", + ballDef = "item", + opts = "context", + alive = "party", + sent = "mon", + received = "mon", + playerAction = "action", + enemyAction = "action", +} +local function array(t, label, max) + assert(type(t) == "table", label .. " must be a list") + local n, count = #t, 0 + assert(not max or n <= max, label .. " too long") + for k in pairs(t) do + count = count + 1 + assert(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= n, label .. " must be dense") + end + assert(count == n, label .. " must be dense") +end + +function R.new(loader, name, generation, api3) + local ids = Ids.new(loader) + local portable = api3 and Api3Records or nil + local function readStatus(value) + if portable then + return portable.status(value) + end + return status[value] or value + end + local function writeStatus(value, old) + if portable then + return portable.nativeStatus(value, old) + end + assert(value == nil or value == false or short[value], "unknown status") + return short[value] + end + local byRaw, byView, entries = {}, {}, {} + local view, restore, commit, refresh + local open = true + local function read(kind, id) + return ids:read(kind, id) + end + local function native(kind, id, old) + if id == read(kind, old) then + return old + end + if id == nil or id == false then + return nil + end + assert(type(id) == "string", kind .. " ID must be a string") + local entry, why = ids:resolve(kind, id) + assert(entry, why) + -- Keep each host's existing numeric/string representation. + if type(old) == "string" then + for _, key in ipairs(entry.keys) do + if type(key) == "string" then + return key + end + end + end + return entry.key + end + local function moveDef(id) + local group = ids:resolve("moves", read("moves", id)) + return group and loader.content.moves:get(group.key) or nil + end + local function moveViews(raw) + local out = {} + for i, m in ipairs(raw or {}) do + local id = type(m) == "table" and m.id or m + local def = moveDef(id) + local ups = type(m) == "table" and (m.ppUps or 0) or 0 + out[i] = { + id = read("moves", id), + pp = type(m) == "table" and m.pp or nil, + maxPp = def and math.floor((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + return out + end + local function statView(raw) + local out = {} + for k, n in pairs(stats) do + out[k] = (raw or {})[n] + end + out.specialAttack = out.specialAttack or out.special + out.specialDefense = out.specialDefense or out.special + if portable then + portable.stats(out, generation) + end + return out + end + local function fields(raw, kind) + if kind == "mon" then + return { + species = read("pokemon", raw.species), + item = read("items", raw.item), + nickname = raw.nickname, + name = portable and raw.nickname and raw.nickname ~= "" and raw.nickname or raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = readStatus(raw.status), + isEgg = raw.isEgg == true, + dvs = portable and generation ~= 3 and type(raw.dvs) == "table" and copy(raw.dvs) or nil, + shiny = portable and portable.shiny(raw, generation), + stats = statView(raw.stats), + moves = moveViews(raw.moves), + native = copy(raw), + } + elseif kind == "battler" then + return { + mon = view(raw.mon, "mon"), + id = raw.id, + side = raw.isPlayer == true and "player" or raw.isPlayer == false and "enemy" or nil, + native = copy({ name = raw.name, species = raw.species, stages = raw.stages }), + } + elseif kind == "move" or kind == "species" or kind == "item" then + local out = { native = copy(raw), name = raw.name } + local registryKind = kind == "species" and "pokemon" or kind == "move" and "moves" or "items" + local key = raw.id or raw.index or raw.numId + if key == nil then + for id, row in loader.content[registryKind]:each() do + if row == raw then + key = id + break + end + end + end + out.id = read(registryKind, key) + if kind == "species" then + out.baseStats = statView(raw.baseStats) + out.types = {} + for i, t in ipairs(raw.types or {}) do + out.types[i] = typeRead(t) + end + out.baseExp = raw.baseExp or raw.expYield + out.catchRate = raw.catchRate + out.growthRate = raw.growthRate + elseif kind == "move" then + for _, key in ipairs({ "type", "power", "accuracy", "pp", "priority" }) do + out[key] = raw[key] + end + out.type = typeRead(out.type) + out.category = raw.category or raw.class + out.effect = type(raw.effect) == "string" and raw.effect or nil + else + for _, key in ipairs({ "price", "description", "consumable" }) do + out[key] = raw[key] + end + end + return out + elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then + local out = {} + local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter" + for i, v in ipairs(raw) do + out[i] = view(v, role) + end + return out + elseif kind == "trainerMon" then + local out = copy(raw) + out.species = read("pokemon", raw.species) + out.item = read("items", raw.item) + out.moves = {} + for i, id in ipairs(raw.moves or {}) do + out.moves[i] = read("moves", id) + end + out.native = copy(raw) + return out + end + local out = shallow(raw) + for k, v in pairs(raw) do + local role = recordRoles[k] + if name == "world.interacted" and k == "target" then + role = nil + end + if name == "trade.completed" and k == "evolution" then + role = "context" + end + if name == "pokemon.before_give" and k == "ctx" then + role = nil + end + if k == "status" then + out[k] = readStatus(v) + elseif k == "move" and type(v) ~= "table" then + out[k] = read("moves", v) + elseif kind == "action" and k == "id" and not raw.special then + out[k] = read("moves", v) + elseif idRoles[k] and type(v) ~= "table" then + out[k] = read(idRoles[k], v) + elseif role and type(v) == "table" then + out[k] = view(v, role) + elseif k == "learnable" and name == "pokemon.level_up" then + out[k] = {} + for i, id in ipairs(v) do + out[k][i] = read("moves", id) + end + elseif k == "applyShare" and name == "battle.exp_award" and type(v) == "function" then + out[k] = function(mon, ...) + local bound = byView[mon] + assert(bound and bound.kind == "mon", "applyShare requires a bound Pokemon") + commit() + local result = pack(pcall(v, bound.raw, ...)) + if not result[1] then + error(result[2], 0) + end + refresh() + return unpack(result, 2, result.n) + end + end + end + return out + end + view = function(raw, kind) + if type(raw) ~= "table" then + return raw + end + byRaw[raw] = byRaw[raw] or {} + if byRaw[raw][kind] then + return byRaw[raw][kind].public + end + local e = { raw = raw, kind = kind, public = {} } + byRaw[raw][kind] = e + byView[e.public] = e + entries[#entries + 1] = e + for k, v in pairs(fields(raw, kind)) do + e.public[k] = v + end + e.before = ( + kind == "action" + or kind == "gift" + or kind == "context" + or kind == "encounter" + or kind == "party" + or kind == "trainerParty" + or kind == "candidates" + ) + and shallow(e.public) + or copy(e.public) + return e.public + end + local function statChanges(public, old, baseline, ops) + assert(type(old) == "table", "native stats are missing") + assert(type(public) == "table", "stats must be a record") + for k in pairs(public) do + assert(stats[k], "unknown stat " .. tostring(k)) + end + if portable then + local common = public.special ~= baseline.special and public.special or nil + if generation == 1 then + for _, k in ipairs({ "specialAttack", "specialDefense" }) do + if public[k] ~= baseline[k] then + assert(common == nil or common == public[k], "conflicting special stats") + common = public[k] + end + end + elseif common ~= nil then + for _, k in ipairs({ "specialAttack", "specialDefense" }) do + assert(public[k] == baseline[k] or public[k] == common, "conflicting special stats") + end + end + for k, n in pairs(stats) do + if k ~= "special" or generation == 1 then + local value = public[k] + if common ~= nil and (k == "special" or k == "specialAttack" or k == "specialDefense") then + value = common + end + if value ~= baseline[k] then + value = integer(value, 1, 65535, "stats." .. k) + if generation == 1 and (k == "specialAttack" or k == "specialDefense") then + n = "special" + end + local target = n + ops[#ops + 1] = function() + old[target] = value + if generation > 1 and target == "spatk" and old.special ~= nil then + old.special = value + end + end + end + end + end + return + end + local specialValue + for _, k in ipairs({ "special", "specialAttack", "specialDefense" }) do + if public[k] ~= baseline[k] and (generation == 1 or k ~= "specialDefense") then + assert(not specialValue or specialValue == public[k], "conflicting special stats") + specialValue = public[k] + end + end + for k, n in pairs(stats) do + local value = public[k] + if generation == 1 and (k == "special" or k == "specialAttack" or k == "specialDefense") then + value = specialValue or baseline[k] + n = "special" + elseif specialValue and (k == "special" or k == "specialAttack") then + value = specialValue + end + if value ~= baseline[k] then + value = integer(value, 1, 65535, "stats." .. k) + ops[#ops + 1] = function() + old[n] = value + end + end + end + end + local pending, visiting + local function stage(raw, key, value) + assert(type(raw) == "table", "native record is missing") + pending[#pending + 1] = function() + raw[key] = value + end + end + local function movesNative(public, old, baseline) + array(public, "moves", 4) + local out = {} + for i, m in ipairs(public) do + assert(type(m) == "table", "move must be a record") + for k in pairs(m) do + assert(k == "id" or k == "pp" or k == "maxPp" or k == "ppUps", "unknown move field") + end + local prev = (old or {})[i] + local prior = (baseline or {})[i] or {} + assert(m.maxPp == prior.maxPp, "maxPp is read-only") + local row = type(prev) == "table" and shallow(prev) or {} + row.id = native("moves", m.id, row.id) + assert(row.id, "move ID is required") + row.ppUps = integer(m.ppUps or 0, 0, 3, "ppUps") + local def = moveDef(row.id) + local max = def and math.floor((def.pp or 0) + row.ppUps * math.floor((def.pp or 0) / 5)) or 0 + row.pp = integer(m.pp, 0, max, "pp") + out[i] = row + end + return out + end + restore = function(value, kind) + if type(value) ~= "table" then + assert( + value == nil or (kind == "encounter" and value == false), + "expected " .. tostring(kind) .. " record" + ) + return value + end + local e = byView[value] + if not e then + assert( + kind == "action" + or kind == "encounter" + or kind == "trainerMon" + or kind == "candidates" + or kind == "trainerParty", + "replacement " .. tostring(kind) .. " must be a bound record" + ) + e = { public = value, kind = kind, raw = {}, before = {} } + end + if visiting[e] then + return e.raw + end + visiting[e] = true + local raw, before = e.raw, e.before + kind = e.kind + if kind == "move" or kind == "species" or kind == "item" or kind == "battler" then + if kind == "battler" then + assert( + value.id == before.id and value.side == before.side and equal(value.native, before.native), + "battler metadata is read-only" + ) + assert(byView[value.mon] and byView[value.mon].raw == raw.mon, "battler.mon identity is read-only") + for k in pairs(value) do + assert(k == "mon" or k == "id" or k == "side" or k == "native", "unknown battler field") + end + restore(value.mon, "mon") + else + assert(equal(value, before), kind .. " definition is read-only; use mod.content") + end + elseif kind == "mon" then + for k in pairs(value) do + assert( + monFields[k] or (portable and (k == "dvs" or k == "shiny")), + "unknown Pokemon field " .. tostring(k) + ) + end + assert(equal(value.native, before.native), "native snapshot is read-only") + assert(value.name == before.name, "name is read-only; set nickname") + if portable then + assert(equal(value.dvs, before.dvs) and value.shiny == before.shiny, "DVs and shiny are read-only") + end + for _, key in ipairs({ "species", "item", "nickname", "level", "hp", "maxHp", "status", "isEgg" }) do + local v = value[key] + if not equal(v, before[key]) then + if key == "species" or key == "item" then + v = native(key == "species" and "pokemon" or "items", v, raw[key]) + assert(key ~= "species" or v, "species is required") + elseif key == "level" then + v = integer(v, 1, 100, key) + elseif key == "hp" then + v = integer(v, 0, 65535, key) + elseif key == "maxHp" then + v = integer(v, 1, 65535, key) + elseif key == "status" then + v = writeStatus(v, raw.status) + elseif key == "nickname" then + assert(v == nil or type(v) == "string", "nickname must be a string") + elseif key == "isEgg" then + assert(type(v) == "boolean", "isEgg must be boolean") + end + if key == "maxHp" then + stage(raw.stats, "hp", v) + else + stage(raw, key, v) + end + end + end + if value.maxHp ~= before.maxHp and value.stats.hp ~= before.stats.hp then + assert(value.maxHp == value.stats.hp, "conflicting maxHp and stats.hp") + end + local limit = value.stats.hp ~= before.stats.hp and value.stats.hp or value.maxHp + if value.hp ~= before.hp or limit ~= before.maxHp then + assert( + type(value.hp) == "number" and type(value.maxHp) == "number" and value.hp <= limit, + "hp exceeds maxHp" + ) + end + if not equal(value.stats, before.stats) then + statChanges(value.stats, raw.stats, before.stats, pending) + end + if not equal(value.moves, before.moves) then + local new = movesNative(value.moves, raw.moves, before.moves) + local target = raw.moves or {} + stage(raw, "moves", target) + pending[#pending + 1] = function() + local previous = shallow(target) + for i in pairs(target) do + target[i] = nil + end + for i, row in ipairs(new) do + local slot = type(previous[i]) == "table" and previous[i] or {} + for k in pairs(slot) do + slot[k] = nil + end + for k, v in pairs(row) do + slot[k] = v + end + target[i] = slot + end + end + end + elseif kind == "party" or kind == "candidates" or kind == "trainerParty" then + array(value, kind, kind ~= "candidates" and 6 or nil) + local out = {} + local role = kind == "party" and "mon" or kind == "trainerParty" and "trainerMon" or "encounter" + for i, v in ipairs(value) do + out[i] = restore(v, role) + end + if kind == "party" then + assert(#value == #raw, "live party membership is read-only") + for i, v in ipairs(out) do + assert(v == raw[i], "live party order is read-only") + end + else + pending[#pending + 1] = function() + for k in pairs(raw) do + raw[k] = nil + end + for i, v in ipairs(out) do + raw[i] = v + end + end + end + elseif kind == "gift" then + assert(value.ctx == before.ctx, "gift context is read-only") + for k in pairs(value) do + assert(k == "ctx" or k == "species" or k == "level" or k == "nickname", "unknown gift field") + end + stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required")) + stage(raw, "level", integer(value.level, 1, 100, "level")) + assert(value.nickname == nil or type(value.nickname) == "string", "nickname must be a string") + stage(raw, "nickname", value.nickname) + elseif kind == "trainerMon" then + assert(value.native == nil or equal(value.native, before.native), "native snapshot is read-only") + local allowed = { species = true, item = true, level = true, moves = true, native = true } + for k, v in pairs(value) do + if not allowed[k] then + assert(equal(v, before[k]), "trainer metadata is read-only") + end + end + stage(raw, "species", assert(native("pokemon", value.species, raw.species), "species is required")) + stage(raw, "item", native("items", value.item, raw.item)) + stage(raw, "level", integer(value.level, 1, 100, "level")) + if not equal(value.moves, before.moves) then + array(value.moves, "trainer moves", 4) + local out = {} + for i, id in ipairs(value.moves) do + out[i] = assert(native("moves", id, (raw.moves or {})[i]), "move is required") + end + stage(raw, "moves", out) + end + else + if kind == "encounter" then + assert(value.species ~= nil, "encounter species is required") + if value.level ~= nil then + integer(value.level, 1, 100, "encounter level") + end + for _, k in ipairs({ "min", "max" }) do + if value[k] ~= nil then + integer(value[k], 1, 100, k) + end + end + assert( + value.level ~= nil or (value.min and value.max and value.min <= value.max), + "encounter level is required" + ) + end + local keys = shallow(before) + for k in pairs(value) do + keys[k] = true + end + for k in pairs(keys) do + local v = value[k] + local old = raw[k] + if type(v) == "table" and byView[v] then + local restored = restore(v) + if v ~= before[k] then + stage(raw, k, restored) + end + elseif k == "move" and type(v) ~= "table" and v ~= before[k] then + stage(raw, k, native("moves", v, old)) + elseif kind == "action" and k == "id" and not value.special and v ~= before[k] then + stage(raw, k, native("moves", v, old)) + elseif k == "status" and v ~= before[k] then + stage(raw, k, writeStatus(v, old)) + elseif idRoles[k] and v ~= before[k] then + stage(raw, k, native(idRoles[k], v, old)) + elseif k == "applyShare" and name == "battle.exp_award" then + assert(v == before[k], "applyShare is read-only") + elseif k == "learnable" and name == "pokemon.level_up" then + assert(equal(v, before[k]), "learnable is read-only") + elseif v ~= before[k] then + local role = recordRoles[k] + if kind == "encounter" and k == "level" then + integer(v, 1, 100, "encounter level") + end + if role and type(v) == "table" then + v = restore(v, role) + end + stage(raw, k, v) + end + end + end + return raw + end + commit = function(extra) + assert(open, "callback transaction is closed") + pending, visiting = {}, {} + for _, e in ipairs(entries) do + restore(e.public, e.kind) + end + local result + if extra then + result = extra() + end + for _, op in ipairs(pending) do + op() + end + return result + end + refresh = function() + -- Reuse public identities, but refresh after vanilla mutates native data. + for _, e in ipairs(entries) do + local nextValue = fields(e.raw, e.kind) + for k in pairs(e.public) do + e.public[k] = nil + end + for k, v in pairs(nextValue) do + e.public[k] = v + end + end + for _, e in ipairs(entries) do + e.before = ( + e.kind == "action" + or e.kind == "gift" + or e.kind == "context" + or e.kind == "encounter" + or e.kind == "party" + or e.kind == "trainerParty" + or e.kind == "candidates" + ) + and shallow(e.public) + or copy(e.public) + end + end + return { + close = function() + open = false + end, + view = view, + restore = function(v, kind) + return restore(v, kind) + end, + commit = commit, + refresh = refresh, + read = read, + native = native, + copy = copy, + } +end +return R diff --git a/src/mods/Capabilities.lua b/src/mods/Capabilities.lua new file mode 100644 index 000000000..32da142d7 --- /dev/null +++ b/src/mods/Capabilities.lua @@ -0,0 +1,108 @@ +-- Availability of the existing API-2 surfaces, before a Game/overworld exists. +-- Keep callback names tied to native call sites, not to the permissive buses: +-- successfully subscribing to a name does not mean the engine ever fires it. +local GameVersion = require("src.core.GameVersion") +local Schemas = require("src.mods.Schemas") + +local Capabilities = {} +local callbacks = { hooks = {}, events = {} } +local function add(surface, generations, names) + for name in names:gmatch("%S+") do + callbacks[surface][name] = generations + end +end + +-- Shared simulation, world, input, rendering and lifecycle code. A callback +-- need not fire on every action (e.g. an always-hit move skips accuracy). +add("hooks", "12345", [[ + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action + battle.exp_award battle.low_health_alarm battle.run battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain + fieldmove.eligibility input.step link.fingerprint map.palette movement.collision + movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite + render.compose render.hud render.letterbox render.zones save.new_game save.write + script.command trainer.party transition.style ui.list_menu ui.options.rows + ui.title_menu.items warp.destination world.tod zoom.range +]]) +-- Screens have distinct implementations: Screens.lua aliases the DS party, +-- naming and start menus; those implementations do not call the GB/GBA hooks. +-- OverworldController:openPC bypasses ui.pc.items on Gen 3/4 (and Gen 5 uses +-- its own script PC). Gen4Battle paints its HUD through a separate seam and +-- does not dispatch battle.overlay, unlike BattleState/Gen3Battle/WideBattle. +add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") +add("hooks", "12", "ui.pc.items") +add("hooks", "45", "battle.status_hud_visible") +add("hooks", "12", "intro.oak_speech.build") + +add("events", "12345", [[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt + battle.ended battle.exp_gained battle.fainted battle.move_used battle.started + battle.status_inflicted battle.turn_ended battle.turn_started discord.join_requested + flag.changed game.ready link.connected link.desync link.ended map.entered map.exited + map.reloaded mod.option_action mod.options_changed mods.loaded music.started + music.stopped player.warped pokemon.before_give pokemon.caught pokemon.evolved + pokemon.level_up pokemon.move_learned pokemon.received save.created save.loaded + save.loading save.writing screen.popped screen.pushed script.ended script.started + sound.played trade.completed world.blacked_out world.block_replaced + world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +add("events", "2345", "pokemon.egg_hatched") +add("events", "12", [[ + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step +]]) +add("events", "3", "intro.birch_speech.started intro.birch_speech.finished") + +-- Data:applyDefaults selects BirchSpeech on Emerald, Gen3OakSpeechFRLG on +-- FireRed, and no OakSpeech on Prism. DS imports select their own intros. +local function nativeVersion(name, version) + if name:match("^intro%.birch_speech%.") then return version == "emerald" end + if name:match("^intro%.oak_speech%.") then return version ~= "prism" end + return true +end + +-- Public WorldAPI methods only; constructors, internal helpers and methods +-- on returned NPC handles are not mod.world methods. These remain callable +-- at the title screen, returning their normal "no overworld"/"no save" error. +-- Elevation readers can return "map carries no elevation" on any dataset. +local world = {} +for name in ([[ + overworld current warpTo toggleObject setFlag getFlag replaceBlock spawnNpc + removeNpc loadedMaps elevationInfo elevationAt terrainAt elevationRanks heightAt + elevationGrid npc queueScript invalidateMap +]]):gmatch("%S+") do world[name] = true end + +function Capabilities.supports(surface, name, version) + if surface ~= "content" and surface ~= "world" + and surface ~= "hooks" and surface ~= "events" then + return false, "unsupported capability surface: " .. tostring(surface) + end + if type(name) ~= "string" or name == "" then + return false, "capability name must be a non-empty string" + end + local info = GameVersion.VERSIONS[version] + if not info then return false, "unknown game version: " .. tostring(version) end + if surface == "content" then + -- Every loader builds the same API-2 registries from Schemas. This says + -- the registration API exists, not that record shapes/IDs are portable. + if Schemas.REGISTRIES[name] then return true end + elseif surface == "world" then + if world[name] then return true end + else + -- A mod may listen to another mod's namespace without that mod installed; + -- this only promises the bus supports it, never that a producer is present. + if surface == "events" and name:match("^mod%.[%w_%-]+%..+") then return true end + local gens = callbacks[surface][name] + if gens then + if gens:find(tostring(info.generation), 1, true) and nativeVersion(name, version) then + return true + end + return false, ("%s capability %s has no native call site in %s") + :format(surface, name, version) + end + end + return false, ("unknown %s capability: %s"):format(surface, name) +end + +return Capabilities diff --git a/src/mods/DatasetViews.lua b/src/mods/DatasetViews.lua new file mode 100644 index 000000000..6dfc962df --- /dev/null +++ b/src/mods/DatasetViews.lua @@ -0,0 +1,415 @@ +-- Read-only version datasets. Generated modules are parsed as bounded data. +local Version = require("src.core.GameVersion") +local Literal = require("src.import.DataLiteral") +local Files = require("src.import.VersionFiles") +local Registry = require("src.mods.Registry") +local Schemas = require("src.mods.Schemas") +local Unified = require("src.mods.UnifiedContent") +local Merge = require("src.mods.Merge") +local D = {} +local CORE = { + pokemon = true, + moves = true, + items = true, + maps = true, + trainers = true, + encounters = true, + type_chart = true, + constants = true, +} +local MAX_TOTAL = 48 * 1024 * 1024 +local function dataOnly(value, seen) + if type(value) ~= "table" then + return type(value) ~= "function" and type(value) ~= "userdata" and type(value) ~= "thread" + end + if getmetatable(value) ~= nil then + return false + end + seen = seen or {} + if seen[value] then + return false + end + seen[value] = true + for k, v in pairs(value) do + if not dataOnly(k, seen) or not dataOnly(v, seen) then + return false + end + end + seen[value] = nil + return true +end +local function asset(path) + if + type(path) ~= "string" + or #path > 1024 + or path:sub(1, 17) ~= "assets/generated/" + or path:find("[%c\\]") + then + error("dataset asset requires a generated relative path", 0) + end + for segment in path:gmatch("[^/]+") do + if segment == "." or segment == ".." or segment:find(":", 1, true) then + error("dataset asset path traversal", 0) + end + end + return path +end +function D.new(fs, req, trust) + req = req or require + trust = trust or function(fn, ...) + return fn(...) + end + local files = Files.new(fs) + local function descriptor(version) + local importer = req("src.import.RomImporter") + return importer.datasetContract(version) + end + local function inspect(version) + if type(version) ~= "string" or not Version.VERSIONS[version] then + return nil, "unknown_version" + end + if Version.generation(version) > 3 then + return nil, "unsupported_generation" + end + local contract = descriptor(version) + local paths, names, total = {}, {}, 0 + for _, name in ipairs(contract.modules) do + names[name] = true + end + for _, spec in pairs(Schemas.REGISTRIES) do + local root = spec.target and spec.target:match("^[^%.]+") + if root then + names[root] = true + end + end + local marker = files.read(version, "rom-cache.complete", 65536) + if marker and (#marker > 65536 or marker:match("^[^\r\n]*") ~= contract.marker) then + return nil, "not_imported" + end + local absent = {} + for line in (marker or ""):gmatch("[^\r\n]+") do + local name = line:match("^absent:data/generated/(.+)%.lua$") + if name then + absent[name] = true + end + end + local mandatory = {} + for _, name in ipairs(contract.modules) do + if CORE[name] or not absent[name] then + mandatory[name] = true + end + end + for name in pairs(CORE) do + mandatory[name] = true + end + for name in pairs(names) do + local path = "data/generated/" .. name .. ".lua" + local info = files.info(version, path) + if info and (info.type == nil or info.type == "file") then + if info.size and (info.size < 0 or info.size > Literal.LIMITS.bytes) then + return nil, "invalid_cache" + end + total = total + (info.size or 0) + if total > MAX_TOTAL then + return nil, "invalid_cache" + end + paths[name] = path + elseif mandatory[name] then + return nil, "not_imported" + end + end + local required = {} + for name in pairs(mandatory) do + required[#required + 1] = "data/generated/" .. name .. ".lua" + end + if not marker and not files.source(version, required) then + return nil, "not_imported" + end + return { + paths = paths, + marker = marker, + identity = files.identity(), + source = not marker, + required = required, + } + end + local function open(version) + local plan, why = inspect(version) + if not plan then + return nil, why + end + local view = { version = version, generation = Version.generation(version) } + local cache, decodedBytes, revision = {}, 0, 0 + local registries, handles, invalid + local function reject(reason) + invalid = reason or "invalid_cache" + return nil + end + local function safe(fn, ...) + local function pack(...) + return { n = select("#", ...), ... } + end + local r = pack(trust(pcall, fn, ...)) + if not r[1] then + return reject("invalid_cache") + end + return (table.unpack or unpack)(r, 2, r.n) + end + local function ready() + if invalid then + return false + end + local nextPlan = inspect(version) + if not nextPlan or nextPlan.identity ~= plan.identity or nextPlan.marker ~= plan.marker then + return reject("not_imported") + end + for name, path in pairs(plan.paths) do + if nextPlan.paths[name] ~= path then + return reject("not_imported") + end + end + local changed = false + for name in pairs(nextPlan.paths) do + if not plan.paths[name] then + changed = true + end + end + for name, old in pairs(cache) do + local source = files.read(version, plan.paths[name], Literal.LIMITS.bytes) + if type(source) ~= "string" then + return reject("invalid_cache") + end + if source ~= old.source then + changed = true + end + end + if changed then + cache = {} + decodedBytes = 0 + registries = nil + handles = nil + revision = revision + 1 + end + plan = nextPlan + return true + end + local function module(name) + if cache[name] then + return cache[name].value + end + local path = plan.paths[name] + if not path then + return nil + end + local source = files.read(version, path, Literal.LIMITS.bytes) + if type(source) ~= "string" or #source > Literal.LIMITS.bytes then + return reject("invalid_cache") + end + decodedBytes = decodedBytes + #source + if decodedBytes > MAX_TOTAL then + return reject("invalid_cache") + end + local value = Literal.decode(source) + if not value then + return reject("invalid_cache") + end + cache[name] = { source = source, value = value } + return value + end + local function build() + if handles then + return handles + end + local data = setmetatable({}, { + __index = function(t, key) + local value = module(key) + if value ~= nil then + rawset(t, key, value) + end + return value + end, + }) + local constants = data.constants + if constants and constants.generation ~= nil and constants.generation ~= view.generation then + return reject("invalid_cache") + end + registries = {} + for name, spec in pairs(Schemas.REGISTRIES) do + local shape = Merge.deepCopy(spec) + if name == "pokemon" and view.generation > 1 then + for _, key in ipairs({ "spatk", "spdef" }) do + shape.fields.baseStats.fields[key] = Schemas.f.opt(Schemas.f.int(1, 255)) + end + end + local r = Registry.new(name, shape) + if spec.target then + local target = spec.target + r.base = function() + local node = data + for key in target:gmatch("[^%.]+") do + node = type(node) == "table" and node[key] or nil + end + return node + end + end + registries[name] = r + end + -- Seed engine data descriptors in isolated registries; function-bearing + -- runtime records are filtered from every public read below. + req("src.mods.Builtins").install(registries, data) + if invalid then + return nil + end + for _, r in pairs(registries) do + r:freeze() + end + local raw = {} + for name, r in pairs(registries) do + raw[name] = { + get = function(_, id) + return r:get(id) + end, + has = function(_, id) + return r:get(id) ~= nil + end, + each = function() + return r:each() + end, + } + end + handles = Unified.new({ content = registries }, { manifest = { id = "dataset" } }, raw, view.generation) + return handles + end + local function rawValid(name, id) + local r = registries[name] + local raw = r:get(id) + if raw == nil or not dataOnly(raw) then + return false + end + local ok = Schemas.check(r.spec, name, id, raw, "override") + if not ok then + reject("invalid_cache") + return false + end + return true + end + local function current(name) + if not ready() then + return nil + end + local all = build() + if invalid then + return nil + end + return all and all[name] + end + local function get(name, id, prepared) + if type(id) ~= "string" or id == "" then + return nil + end + local handle = prepared and handles[name] or current(name) + if not handle then + return nil + end + local value = handle:get(id) + if invalid or value == nil or not dataOnly(value) then + return nil + end + -- Unified core IDs may have native aliases; validate all rows involved + -- through the native registry, not just the normalized projection. + if require("src.mods.UnifiedSchemas")[name] then + local group = require("src.mods.UnifiedIds").new({ content = registries }):resolve(name, id) + if not group or not rawValid(name, group.key) then + return nil + end + elseif not rawValid(name, id) then + return nil + end + return Merge.deepCopy(value) + end + view.content = {} + for name in pairs(Schemas.REGISTRIES) do + local registryName = name + view.content[name] = { + get = function(_, id) + return safe(get, registryName, id) + end, + has = function(_, id) + return safe(get, registryName, id) ~= nil + end, + each = function() + return safe(function() + local handle = current(registryName) + local rows = {} + if handle then + for id in handle:each() do + local value = get(registryName, id, true) + if invalid then + rows = {} + break + end + if value ~= nil then + rows[#rows + 1] = { id, value } + end + end + end + local initial, index = revision, 0 + return function() + return safe(function() + if + invalid + or revision ~= initial + or files.identity() ~= plan.identity + or files.read(version, "rom-cache.complete", 65536) ~= plan.marker + then + return nil + end + index = index + 1 + local row = rows[index] + if row then + return row[1], Merge.deepCopy(row[2]) + end + end) + end + end) or function() end + end, + } + end + for alias, name in pairs(Schemas.ALIASES) do + view.content[alias] = view.content[name] + end + view.assets = { + path = function(_, path) + path = asset(path) + return safe(function() + if not ready() then + return nil + end + return files.path(version, path) + end) + end, + info = function(_, path) + path = asset(path) + return safe(function() + if not ready() then + return nil + end + local info = files.info(version, path) + return info and (info.type == nil or info.type == "file") and { type = "file", size = info.size } + or nil + end) + end, + } + return view + end + return { + open = function(_, version) + local ok, view, why = trust(pcall, open, version) + if not ok then + return nil, "invalid_cache" + end + return view, why + end, + } +end +return D diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..9da7d0576 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -11,18 +11,28 @@ local Data = require("src.core.Data") local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") +local PortableScripts = require("src.mods.PortableScripts") +local Api3Warp = require("src.mods.Api3Warp") +local Api3NPC = require("src.mods.Api3NPC") +local Api3WildBattle = require("src.mods.Api3WildBattle") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") local Registry = require("src.mods.Registry") local Schemas = require("src.mods.Schemas") +local Capabilities = require("src.mods.Capabilities") local Semver = require("src.mods.Semver") local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") local ModStorage = require("src.mods.Storage") +local WorldReads = require("src.mods.WorldReads") local Runtime = require("src.mods.Runtime") +local BattleSnapshots = require("src.mods.BattleSnapshots") +local UIActions = require("src.mods.UIActions") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") +local WorldActions = require("src.world.WorldActions") +local WorldOverview = require("src.mods.WorldOverview") -- Module-name aliases for mods written against the Gold port's per-generation -- layout (src.world.gen2.Player and friends). Installed here because this is -- the module that loads mods: the searcher has to be in place before the @@ -119,6 +129,17 @@ local function engineRequire(name) return module end +-- Native facade work stays outside the mod permission scanner, including +-- nested lazy reads performed after mod initialization. +local function engineCall(fn, ...) + local function pack(...) return { n = select("#", ...), ... } end + devShim.depth = devShim.depth + 1 + local result = pack(pcall(fn, ...)) + devShim.depth = devShim.depth - 1 + if not result[1] then error(result[2], 0) end + return (table.unpack or unpack)(result, 2, result.n) +end + function Loader:_installDevShim() for id, mod in pairs(self.mods) do devShim.permissions[id] = mod.manifest.permissionSet @@ -831,7 +852,7 @@ function Loader:_api(mod) -- new game and a new load. local hostGen, hostId, hostInfo = 1, nil, nil do - local okV, GameVersion = pcall(require, "src.core.GameVersion") + local okV, GameVersion = pcall(engineRequire, "src.core.GameVersion") if okV and GameVersion then local okG, g = pcall(GameVersion.generation) if okG and tonumber(g) then hostGen = tonumber(g) end @@ -841,9 +862,11 @@ function Loader:_api(mod) if okN then hostInfo = info end end end + -- Capture pristine ID aliases before mod merges; the facade resolves Game only on a read. + local battle = BattleSnapshots.new(function() return loader:_game(true) end, loader) local engineVersion do - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") engineVersion = okE and V and V.engine or nil end local api = { @@ -868,6 +891,7 @@ function Loader:_api(mod) -- returns the same module table the engine holds, so a mod that mutates -- one is changing the engine, exactly as it always was. engineRequire = engineRequire, + battle = battle, -- 1 to 5. The one field worth reaching for before anything else. generation = hostGen, -- the version id the launcher started: "red", "crystal", "emerald", @@ -884,13 +908,14 @@ function Loader:_api(mod) label = hostInfo and hostInfo.label or hostId, engine = engineVersion, modApi = (function() - local okE, V = pcall(require, "src.core.Version") + local okE, V = pcall(engineRequire, "src.core.Version") return okE and V and V.modApi or nil end)(), developer = loader.dev == true, }, -- a deep copy: what a mod does to its own view never reaches the loader manifest = Merge.deepCopy(mod.manifest), + callbackFormat = mod.manifest.callback_format or "native", content = {}, exports = {}, DELETE = Registry.DELETE, @@ -914,9 +939,9 @@ function Loader:_api(mod) hooks = { wrap = function(_, name, callback, priority) return loader.hooks:wrap(name, callback, priority, modId) end }, - -- the widget toolkit facade (12 4.5) is one shared surface, not - -- per-mod state; each widget inside it loads on first touch - ui = ModUI, + -- Per-mod action/revision state, with the existing lazy widget builders. + -- Reading the facade early must not construct Game or a native screen. + ui = UIActions.new(function() return loader:_game(true) end, loader, ModUI), -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { @@ -999,6 +1024,9 @@ function Loader:_api(mod) }, } self.exports[modId] = api.exports + if api.callbackFormat == "unified" then + engineRequire("src.mods.UnifiedCallbacks").install(loader, api, modId, hostGen) + end -- a handle, not the mod object: {id, version, exports} or nil when the -- other mod is absent, disabled, failed, or has not run yet. Tolerates -- mod:find(id) as well as the documented mod.find(id). @@ -1020,6 +1048,10 @@ function Loader:_api(mod) api.content[alias] = self:_contentApi(mod, self.content[canonical], ("the %s registry is deprecated; use %s"):format(alias, canonical)) end + api.contentFormat = mod.manifest.content_format or "native" + if api.contentFormat == "unified" then + api.content = engineRequire("src.mods.UnifiedContent").new(loader, mod, api.content, hostGen) + end -- assets keeps the v1 alias to the content accessors and adds the file -- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve -- A RELATIVE PATH STAYS RELATIVE. @@ -1119,13 +1151,52 @@ function Loader:_api(mod) end return data end) + -- Capture the host selected for this API, as with generation/gameVersion. + -- Probing must not instantiate mod.world/mod.game or register callbacks. + function api:supports(surface, name) + return Capabilities.supports(surface, name, hostId) + end -- mod.world / mod.game / mod.storage all materialize on first touch, for - -- the same reason: a headless load must not drag the world stack in, and - -- the Game the facade acts on is still being wired when the entry chunk + -- the same reason: a headless load must not drag the world stack in. + -- Battle capability queries can also be installed before Game exists. + BattleSnapshots.withCapabilities(api) + UIActions.withCapabilities(api) + -- The Game the facade acts on is still being wired when the entry chunk -- runs. mod.storage is the bulk counterpart to mod.save -- one sandboxed -- namespace per mod for payloads too large to belong in a save file. + -- When capability queries are installed, these additive methods exist on + -- every host; individual actions still validate native version and state. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldActions.supports(name) then return true end + return supports(self, surface, name) + end + end + -- Read-only dataset views use the same native service at every API level. + -- Construction stays lazy and does not instantiate Game or import a ROM. + api.datasets = { + open = function(_, version) + if not loader.datasetViews then + local Views = engineRequire("src.mods.DatasetViews") + loader.datasetViews = Views.new(loader.fs, engineRequire, engineCall) + end + return loader.datasetViews:open(version) + end, + } local world, storage - setmetatable(api, { __index = function(_, key) + local overviewContext = WorldOverview.install(api, loader) + local readContext = WorldReads.context(loader) + -- Capability queries are optional on older API-2 hosts. When present, + -- report the additive readers without constructing Game or the world. + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and WorldReads.supports(name) then return true end + return supports(self, surface, name) + end + end + setmetatable(api, PortableScripts.wrap({ __index = function(_, key) if key == "game" then return loader:_game() end if key == "storage" then -- Do not pass loader.fs when it is love.filesystem: that always writes @@ -1139,17 +1210,37 @@ function Loader:_api(mod) local game = loader:_game() local module = game and engineRequire("src.world.WorldAPI") if not module then return nil end - world = module.new(game, modId) + world = module.new(game, modId, readContext) + world.overviewContext = overviewContext return world - end }) + end }, api, loader, mod.manifest)) + -- When capability queries are installed, report the selected content + -- format's actual availability. Other surfaces retain their host query. + if api.contentFormat == "unified" and api.supports then + local supports, content = api.supports, api.content + api.supports = function(self, surface, name) + local handle = type(name) == "string" and content[name] + if surface == "content" and handle and handle.supports then return handle:supports() end + return supports(self, surface, name) + end + end + if (mod.manifest.api or 1) >= 3 then + engineRequire("src.mods.Api3").install(api, loader, readContext, overviewContext, engineRequire, engineCall) + end return api end -- the live Game. An injected reference wins so a headless caller can hand -- over a stub; otherwise the boot singleton, whose stack and overworld fill -- in after this loader returns -- holding the table keeps the facade live. -function Loader:_game() - return self.game or engineRequire("src.core.Game") +function Loader:_game(loadedOnly) + if self.game then return self.game end + -- Observational facades must not bootstrap Game from an early/headless read. + if loadedOnly then + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil + end + return engineRequire("src.core.Game") end function Loader:_loadMod(mod) @@ -1196,6 +1287,10 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Api3WildBattle.cancelAll(self, modId) + Api3NPC.cancelAll(self, modId) + Api3Warp.cancelAll(self, modId) + PortableScripts.cancelAll(self, modId, "mod rolled back") for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) end @@ -1310,6 +1405,10 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + Api3WildBattle.cancelAll() + Api3NPC.cancelAll() + Api3Warp.cancelAll() + PortableScripts.cancelAll(nil, nil, "mods reloaded") -- A SECOND LOAD IN ONE PROCESS STARTS FROM THE ENGINE, NOT FROM THE LAST -- LOAD'S MODS. EXIT GAME -> launcher -> Play runs every mod's main.lua -- again (and the launcher backdrop boots the plain game in between); the @@ -1653,6 +1752,10 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Api3WildBattle.cancelAll() + Api3NPC.cancelAll() + Api3Warp.cancelAll() + PortableScripts.cancelAll(nil, nil, "mods unloaded") local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..3267b2c54 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -223,6 +223,16 @@ function Manifest.validate(raw, path) assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") :format(api, Version.modApi)) local strict = api >= 2 + -- API 3 is one portable contract, not three independent API-2 opt-ins. + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + assert(api < 3 or raw[field] == nil or raw[field] == "unified", + "API 3 requires unified " .. field) + end + local defaultFormat = api >= 3 and "unified" or "native" + assert(raw.content_format == nil or raw.content_format == "native" + or raw.content_format == "unified", "content_format must be native or unified") + assert(raw.content_format ~= "unified" or api >= 2, + "unified content requires mod API 2") local profile = raw.profile or "content" if not Manifest.PROFILES[profile] then @@ -240,6 +250,10 @@ function Manifest.validate(raw, path) end end + local scriptFormat = raw.script_format == nil and defaultFormat or raw.script_format + assert(scriptFormat == "native" or scriptFormat == "unified", "script_format must be native or unified") + assert(scriptFormat ~= "unified" or api >= 2, "unified scripts require mod API 2") + local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version) assert(gameVersionOk, ("malformed game_version %q: %s") :format(tostring(raw.game_version), tostring(gameVersionErr))) @@ -279,6 +293,10 @@ function Manifest.validate(raw, path) return value end + assert(raw.callback_format == nil or raw.callback_format == "native" or raw.callback_format == "unified", + "callback_format must be native or unified") + assert(raw.callback_format ~= "unified" or api >= 2, "callback_format unified requires mod API 2") + local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) -- `support`: where to thank the mod's creator -- a Ko-fi, a Patreon, a @@ -305,6 +323,7 @@ function Manifest.validate(raw, path) version = raw.version, entry = raw.entry, api = api, + content_format = raw.content_format or defaultFormat, priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), @@ -316,6 +335,7 @@ function Manifest.validate(raw, path) category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", + callback_format = raw.callback_format or defaultFormat, github = github, support = support, updateCheck = updateCheck, @@ -325,6 +345,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + script_format = scriptFormat, options_schema = optionalFile(raw.options_schema, "options_schema"), -- Base files the mod needs but cannot ship -- a Stadium 2 cartridge, a -- second game's ROM. Declared here, satisfied by the launcher, read by diff --git a/src/mods/MoveEffectContract.lua b/src/mods/MoveEffectContract.lua new file mode 100644 index 000000000..1d1ec1c27 --- /dev/null +++ b/src/mods/MoveEffectContract.lua @@ -0,0 +1,1213 @@ +-- Generated by tools/move-effect-contract/generate.py; do not hand-edit. +return { + ["bindings"] = { + ["ACCURACY_DOWN1_EFFECT"] = {["1"]={["native"]="ACCURACY_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ACCURACY_DOWN",["semantic"]=23},["3"]={["chance"]=0,["native"]=23,["semantic"]=23}}, + ["ASSIST"] = {["3"]={["native"]=180,["semantic"]=180}}, + ["ATTACK_DOWN1_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN",["semantic"]=18},["3"]={["chance"]=0,["native"]=18,["semantic"]=18}}, + ["ATTACK_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_DOWN_2",["semantic"]=58},["3"]={["chance"]=0,["native"]=58,["semantic"]=58}}, + ["ATTACK_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="ATTACK_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_ATTACK_DOWN_HIT",["semantic"]=68},["3"]={["chance"]=33,["native"]=68,["semantic"]=68}}, + ["ATTACK_TWICE_EFFECT"] = {["1"]={["native"]="ATTACK_TWICE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DOUBLE_HIT",["semantic"]=44},["3"]={["chance"]=0,["native"]=44,["semantic"]=44}}, + ["ATTACK_UP1_EFFECT"] = {["1"]={["native"]="ATTACK_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP",["semantic"]=10},["3"]={["chance"]=0,["native"]=10,["semantic"]=10}}, + ["ATTACK_UP2_EFFECT"] = {["1"]={["native"]="ATTACK_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ATTACK_UP_2",["semantic"]=50},["3"]={["chance"]=0,["native"]=50,["semantic"]=50}}, + ["BIDE_EFFECT"] = {["1"]={["native"]="BIDE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_BIDE",["semantic"]=26},["3"]={["chance"]=0,["native"]=26,["semantic"]=26}}, + ["BLAZE_KICK"] = {["3"]={["native"]=200,["semantic"]=200}}, + ["BRICK_BREAK"] = {["3"]={["native"]=186,["semantic"]=186}}, + ["BULK_UP"] = {["3"]={["native"]=208,["semantic"]=208}}, + ["BURN_SIDE_EFFECT1"] = {["1"]={["native"]="BURN_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=10,["native"]=4,["semantic"]=4}}, + ["BURN_SIDE_EFFECT2"] = {["1"]={["native"]="BURN_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_BURN_HIT",["semantic"]=4},["3"]={["chance"]=30,["native"]=4,["semantic"]=4}}, + ["CALM_MIND"] = {["3"]={["native"]=211,["semantic"]=211}}, + ["CAMOUFLAGE"] = {["3"]={["native"]=213,["semantic"]=213}}, + ["CHARGE"] = {["3"]={["native"]=174,["semantic"]=174}}, + ["CHARGE_EFFECT"] = {["1"]={["native"]="CHARGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAZOR_WIND",["semantic"]=39},["3"]={["chance"]=0,["native"]=39,["semantic"]=39}}, + ["CONFUSION_EFFECT"] = {["1"]={["native"]="CONFUSION_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_CONFUSE",["semantic"]=49},["3"]={["chance"]=0,["native"]=49,["semantic"]=49}}, + ["CONFUSION_SIDE_EFFECT"] = {["1"]={["native"]="CONFUSION_SIDE_EFFECT"},["2"]={["chance"]=10,["native"]="EFFECT_CONFUSE_HIT",["semantic"]=76},["3"]={["chance"]=10,["native"]=76,["semantic"]=76}}, + ["CONVERSION_2"] = {["3"]={["native"]=93,["semantic"]=93}}, + ["CONVERSION_EFFECT"] = {["1"]={["native"]="CONVERSION_EFFECT"},["3"]={["chance"]=0,["native"]=30,["semantic"]=30}}, + ["COSMIC_POWER"] = {["3"]={["native"]=206,["semantic"]=206}}, + ["DEFENSE_DOWN1_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN",["semantic"]=19},["3"]={["chance"]=0,["native"]=19,["semantic"]=19}}, + ["DEFENSE_DOWN2_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_DOWN_2",["semantic"]=59},["3"]={["chance"]=0,["native"]=59,["semantic"]=59}}, + ["DEFENSE_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="DEFENSE_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_DEFENSE_DOWN_HIT",["semantic"]=69},["3"]={["chance"]=33,["native"]=69,["semantic"]=69}}, + ["DEFENSE_UP1_EFFECT"] = {["1"]={["native"]="DEFENSE_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP",["semantic"]=11},["3"]={["chance"]=0,["native"]=11,["semantic"]=11}}, + ["DEFENSE_UP2_EFFECT"] = {["1"]={["native"]="DEFENSE_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DEFENSE_UP_2",["semantic"]=51},["3"]={["chance"]=0,["native"]=51,["semantic"]=51}}, + ["DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}}, + ["DISABLE_EFFECT"] = {["1"]={["native"]="DISABLE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DISABLE",["semantic"]=86},["3"]={["chance"]=0,["native"]=86,["semantic"]=86}}, + ["DOUBLE_EDGE"] = {["3"]={["native"]=198,["semantic"]=198}}, + ["DRAGON_DANCE"] = {["3"]={["native"]=212,["semantic"]=212}}, + ["DRAIN_HP_EFFECT"] = {["1"]={["native"]="DRAIN_HP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_HIT",["semantic"]=3},["3"]={["chance"]=0,["native"]=3,["semantic"]=3}}, + ["DREAM_EATER_EFFECT"] = {["1"]={["native"]="DREAM_EATER_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_DREAM_EATER",["semantic"]=8},["3"]={["chance"]=0,["native"]=8,["semantic"]=8}}, + ["EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}}, + ["EFFECT_ACCURACY_DOWN_HIT"] = {["2"]={["native"]="EFFECT_ACCURACY_DOWN_HIT",["semantic"]=73},["3"]={["native"]=73,["semantic"]=73}}, + ["EFFECT_ALL_UP_HIT"] = {["2"]={["native"]="EFFECT_ALL_UP_HIT",["semantic"]=140},["3"]={["native"]=140,["semantic"]=140}}, + ["EFFECT_ATTACK_UP_HIT"] = {["2"]={["native"]="EFFECT_ATTACK_UP_HIT",["semantic"]=139},["3"]={["native"]=139,["semantic"]=139}}, + ["EFFECT_ATTRACT"] = {["2"]={["native"]="EFFECT_ATTRACT",["semantic"]=120},["3"]={["native"]=120,["semantic"]=120}}, + ["EFFECT_BATON_PASS"] = {["2"]={["native"]="EFFECT_BATON_PASS",["semantic"]=127},["3"]={["native"]=127,["semantic"]=127}}, + ["EFFECT_BEAT_UP"] = {["2"]={["native"]="EFFECT_BEAT_UP",["semantic"]=154},["3"]={["native"]=154,["semantic"]=154}}, + ["EFFECT_BELLY_DRUM"] = {["2"]={["native"]="EFFECT_BELLY_DRUM",["semantic"]=142},["3"]={["native"]=142,["semantic"]=142}}, + ["EFFECT_CONVERSION2"] = {["3"]={["native"]=93,["semantic"]=93}}, + ["EFFECT_CURSE"] = {["2"]={["native"]="EFFECT_CURSE",["semantic"]=109},["3"]={["native"]=109,["semantic"]=109}}, + ["EFFECT_DEFENSE_CURL"] = {["2"]={["native"]="EFFECT_DEFENSE_CURL",["semantic"]=156},["3"]={["native"]=156,["semantic"]=156}}, + ["EFFECT_DEFENSE_UP_HIT"] = {["2"]={["native"]="EFFECT_DEFENSE_UP_HIT",["semantic"]=138},["3"]={["native"]=138,["semantic"]=138}}, + ["EFFECT_DESTINY_BOND"] = {["3"]={["native"]=98,["semantic"]=98}}, + ["EFFECT_EARTHQUAKE"] = {["3"]={["native"]=147,["semantic"]=147}}, + ["EFFECT_ENCORE"] = {["2"]={["native"]="EFFECT_ENCORE",["semantic"]=90},["3"]={["native"]=90,["semantic"]=90}}, + ["EFFECT_ENDURE"] = {["2"]={["native"]="EFFECT_ENDURE",["semantic"]=116},["3"]={["native"]=116,["semantic"]=116}}, + ["EFFECT_FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}}, + ["EFFECT_FALSE_SWIPE"] = {["2"]={["native"]="EFFECT_FALSE_SWIPE",["semantic"]=101},["3"]={["native"]=101,["semantic"]=101}}, + ["EFFECT_FLAME_WHEEL"] = {["3"]={["native"]=125,["semantic"]=125}}, + ["EFFECT_FORESIGHT"] = {["2"]={["native"]="EFFECT_FORESIGHT",["semantic"]=113},["3"]={["native"]=113,["semantic"]=113}}, + ["EFFECT_FRUSTRATION"] = {["2"]={["native"]="EFFECT_FRUSTRATION",["semantic"]=123},["3"]={["native"]=123,["semantic"]=123}}, + ["EFFECT_FURY_CUTTER"] = {["2"]={["native"]="EFFECT_FURY_CUTTER",["semantic"]=119},["3"]={["native"]=119,["semantic"]=119}}, + ["EFFECT_FUTURE_SIGHT"] = {["2"]={["native"]="EFFECT_FUTURE_SIGHT",["semantic"]=148},["3"]={["native"]=148,["semantic"]=148}}, + ["EFFECT_GUST"] = {["3"]={["native"]=149,["semantic"]=149}}, + ["EFFECT_HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}}, + ["EFFECT_LOCK_ON"] = {["2"]={["native"]="EFFECT_LOCK_ON",["semantic"]=94},["3"]={["native"]=94,["semantic"]=94}}, + ["EFFECT_MAGNITUDE"] = {["2"]={["native"]="EFFECT_MAGNITUDE",["semantic"]=126},["3"]={["native"]=126,["semantic"]=126}}, + ["EFFECT_MEAN_LOOK"] = {["2"]={["native"]="EFFECT_MEAN_LOOK",["semantic"]=106},["3"]={["native"]=106,["semantic"]=106}}, + ["EFFECT_MIRROR_COAT"] = {["2"]={["native"]="EFFECT_MIRROR_COAT",["semantic"]=144},["3"]={["native"]=144,["semantic"]=144}}, + ["EFFECT_MOONLIGHT"] = {["2"]={["native"]="EFFECT_MOONLIGHT",["semantic"]=134},["3"]={["native"]=134,["semantic"]=134}}, + ["EFFECT_MORNING_SUN"] = {["2"]={["native"]="EFFECT_MORNING_SUN",["semantic"]=132},["3"]={["native"]=132,["semantic"]=132}}, + ["EFFECT_NIGHTMARE"] = {["2"]={["native"]="EFFECT_NIGHTMARE",["semantic"]=107},["3"]={["native"]=107,["semantic"]=107}}, + ["EFFECT_PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}}, + ["EFFECT_PERISH_SONG"] = {["2"]={["native"]="EFFECT_PERISH_SONG",["semantic"]=114},["3"]={["native"]=114,["semantic"]=114}}, + ["EFFECT_PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}}, + ["EFFECT_PRIORITY_HIT"] = {["2"]={["native"]="EFFECT_PRIORITY_HIT",["semantic"]=103}}, + ["EFFECT_PROTECT"] = {["2"]={["native"]="EFFECT_PROTECT",["semantic"]=111},["3"]={["native"]=111,["semantic"]=111}}, + ["EFFECT_PSYCH_UP"] = {["2"]={["native"]="EFFECT_PSYCH_UP",["semantic"]=143},["3"]={["native"]=143,["semantic"]=143}}, + ["EFFECT_PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}}, + ["EFFECT_RAIN_DANCE"] = {["2"]={["native"]="EFFECT_RAIN_DANCE",["semantic"]=136},["3"]={["native"]=136,["semantic"]=136}}, + ["EFFECT_RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}}, + ["EFFECT_RETURN"] = {["2"]={["native"]="EFFECT_RETURN",["semantic"]=121},["3"]={["native"]=121,["semantic"]=121}}, + ["EFFECT_REVERSAL"] = {["3"]={["native"]=99,["semantic"]=99}}, + ["EFFECT_ROLLOUT"] = {["2"]={["native"]="EFFECT_ROLLOUT",["semantic"]=117},["3"]={["native"]=117,["semantic"]=117}}, + ["EFFECT_SACRED_FIRE"] = {["2"]={["native"]="EFFECT_SACRED_FIRE",["semantic"]=125},["3"]={["native"]=125,["semantic"]=125}}, + ["EFFECT_SAFEGUARD"] = {["2"]={["native"]="EFFECT_SAFEGUARD",["semantic"]=124},["3"]={["native"]=124,["semantic"]=124}}, + ["EFFECT_SANDSTORM"] = {["2"]={["native"]="EFFECT_SANDSTORM",["semantic"]=115},["3"]={["native"]=115,["semantic"]=115}}, + ["EFFECT_SKETCH"] = {["2"]={["native"]="EFFECT_SKETCH",["semantic"]=95},["3"]={["native"]=95,["semantic"]=95}}, + ["EFFECT_SLEEP_TALK"] = {["2"]={["native"]="EFFECT_SLEEP_TALK",["semantic"]=97},["3"]={["native"]=97,["semantic"]=97}}, + ["EFFECT_SNORE"] = {["2"]={["native"]="EFFECT_SNORE",["semantic"]=92},["3"]={["native"]=92,["semantic"]=92}}, + ["EFFECT_SPIKES"] = {["2"]={["native"]="EFFECT_SPIKES",["semantic"]=112},["3"]={["native"]=112,["semantic"]=112}}, + ["EFFECT_SPITE"] = {["2"]={["native"]="EFFECT_SPITE",["semantic"]=100},["3"]={["native"]=100,["semantic"]=100}}, + ["EFFECT_SP_ATK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}}, + ["EFFECT_SP_ATK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}}, + ["EFFECT_STOMP"] = {["3"]={["native"]=150,["semantic"]=150}}, + ["EFFECT_SUNNY_DAY"] = {["2"]={["native"]="EFFECT_SUNNY_DAY",["semantic"]=137},["3"]={["native"]=137,["semantic"]=137}}, + ["EFFECT_SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}}, + ["EFFECT_SYNTHESIS"] = {["2"]={["native"]="EFFECT_SYNTHESIS",["semantic"]=133},["3"]={["native"]=133,["semantic"]=133}}, + ["EFFECT_THIEF"] = {["2"]={["native"]="EFFECT_THIEF",["semantic"]=105},["3"]={["native"]=105,["semantic"]=105}}, + ["EFFECT_THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}}, + ["EFFECT_TRIPLE_KICK"] = {["2"]={["native"]="EFFECT_TRIPLE_KICK",["semantic"]=104},["3"]={["native"]=104,["semantic"]=104}}, + ["EFFECT_TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}}, + ["EFFECT_TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}}, + ["ENDEAVOR"] = {["3"]={["native"]=189,["semantic"]=189}}, + ["ERUPTION"] = {["3"]={["native"]=190,["semantic"]=190}}, + ["EVASION_DOWN1_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_EVASION_DOWN",["semantic"]=24},["3"]={["chance"]=0,["native"]=24,["semantic"]=24}}, + ["EVASION_UP1_EFFECT"] = {["1"]={["native"]="EVASION_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_EVASION_UP",["semantic"]=16},["3"]={["chance"]=0,["native"]=16,["semantic"]=16}}, + ["EXPLODE_EFFECT"] = {["1"]={["native"]="EXPLODE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SELFDESTRUCT",["semantic"]=7},["3"]={["chance"]=0,["native"]=7,["semantic"]=7}}, + ["FACADE"] = {["3"]={["native"]=169,["semantic"]=169}}, + ["FAKE_OUT"] = {["3"]={["native"]=158,["semantic"]=158}}, + ["FLAIL"] = {["3"]={["native"]=99,["semantic"]=99}}, + ["FLATTER"] = {["3"]={["native"]=166,["semantic"]=166}}, + ["FLINCH_MINIMIZE_HIT"] = {["3"]={["native"]=150,["semantic"]=150}}, + ["FLINCH_SIDE_EFFECT1"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=10,["native"]=31,["semantic"]=31}}, + ["FLINCH_SIDE_EFFECT2"] = {["1"]={["native"]="FLINCH_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_FLINCH_HIT",["semantic"]=31},["3"]={["chance"]=30,["native"]=31,["semantic"]=31}}, + ["FLY_EFFECT"] = {["1"]={["native"]="FLY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FLY",["semantic"]=155},["3"]={["chance"]=0,["native"]=155,["semantic"]=155}}, + ["FOCUS_ENERGY_EFFECT"] = {["1"]={["native"]="FOCUS_ENERGY_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FOCUS_ENERGY",["semantic"]=47},["3"]={["chance"]=0,["native"]=47,["semantic"]=47}}, + ["FOCUS_PUNCH"] = {["3"]={["native"]=170,["semantic"]=170}}, + ["FOLLOW_ME"] = {["3"]={["native"]=172,["semantic"]=172}}, + ["FREEZE_SIDE_EFFECT1"] = {["1"]={["native"]="FREEZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=10,["native"]=5,["semantic"]=5}}, + ["FREEZE_SIDE_EFFECT2"] = {["2"]={["chance"]=30,["native"]="EFFECT_FREEZE_HIT",["semantic"]=5},["3"]={["chance"]=30,["native"]=5,["semantic"]=5}}, + ["GRUDGE"] = {["3"]={["native"]=194,["semantic"]=194}}, + ["GUST"] = {["3"]={["native"]=149,["semantic"]=149}}, + ["HAIL"] = {["3"]={["native"]=164,["semantic"]=164}}, + ["HAZE_EFFECT"] = {["1"]={["native"]="HAZE_EFFECT"},["3"]={["chance"]=0,["native"]=25,["semantic"]=25}}, + ["HEAL_BELL"] = {["3"]={["native"]=102,["semantic"]=102}}, + ["HEAL_EFFECT"] = {["1"]={["native"]="HEAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HEAL",["semantic"]=32},["3"]={["chance"]=0,["native"]=32,["semantic"]=32}}, + ["HELPING_HAND"] = {["3"]={["native"]=176,["semantic"]=176}}, + ["HIT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}}, + ["HYPER_BEAM_EFFECT"] = {["1"]={["native"]="HYPER_BEAM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_HYPER_BEAM",["semantic"]=80},["3"]={["chance"]=0,["native"]=80,["semantic"]=80}}, + ["IMPRISON"] = {["3"]={["native"]=192,["semantic"]=192}}, + ["INGRAIN"] = {["3"]={["native"]=181,["semantic"]=181}}, + ["JUMP_KICK_EFFECT"] = {["1"]={["native"]="JUMP_KICK_EFFECT"},["3"]={["chance"]=0,["native"]=45,["semantic"]=45}}, + ["KNOCK_OFF"] = {["3"]={["native"]=188,["semantic"]=188}}, + ["LEECH_SEED_EFFECT"] = {["1"]={["native"]="LEECH_SEED_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEECH_SEED",["semantic"]=84},["3"]={["chance"]=0,["native"]=84,["semantic"]=84}}, + ["LIGHT_SCREEN_EFFECT"] = {["1"]={["native"]="LIGHT_SCREEN_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LIGHT_SCREEN",["semantic"]=35},["3"]={["chance"]=0,["native"]=35,["semantic"]=35}}, + ["LOW_KICK"] = {["3"]={["native"]=196,["semantic"]=196}}, + ["MAGIC_COAT"] = {["3"]={["native"]=183,["semantic"]=183}}, + ["MEMENTO"] = {["3"]={["native"]=168,["semantic"]=168}}, + ["METRONOME_EFFECT"] = {["1"]={["native"]="METRONOME_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_METRONOME",["semantic"]=83},["3"]={["chance"]=0,["native"]=83,["semantic"]=83}}, + ["MIMIC_EFFECT"] = {["1"]={["native"]="MIMIC_EFFECT"},["3"]={["chance"]=0,["native"]=82,["semantic"]=82}}, + ["MINIMIZE"] = {["3"]={["native"]=108,["semantic"]=108}}, + ["MIRROR_MOVE_EFFECT"] = {["1"]={["native"]="MIRROR_MOVE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIRROR_MOVE",["semantic"]=9},["3"]={["chance"]=0,["native"]=9,["semantic"]=9}}, + ["MIST_EFFECT"] = {["1"]={["native"]="MIST_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MIST",["semantic"]=46},["3"]={["chance"]=0,["native"]=46,["semantic"]=46}}, + ["MUD_SPORT"] = {["3"]={["native"]=201,["semantic"]=201}}, + ["NATURE_POWER"] = {["3"]={["native"]=173,["semantic"]=173}}, + ["NO_ADDITIONAL_EFFECT"] = {["1"]={["native"]="NO_ADDITIONAL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_NORMAL_HIT",["semantic"]=0},["3"]={["chance"]=0,["native"]=0,["semantic"]=0}}, + ["OHKO_EFFECT"] = {["1"]={["native"]="OHKO_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_OHKO",["semantic"]=38},["3"]={["chance"]=0,["native"]=38,["semantic"]=38}}, + ["OVERHEAT"] = {["3"]={["native"]=204,["semantic"]=204}}, + ["PAIN_SPLIT"] = {["3"]={["native"]=91,["semantic"]=91}}, + ["PARALYZE_EFFECT"] = {["1"]={["native"]="PARALYZE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_PARALYZE",["semantic"]=67},["3"]={["chance"]=0,["native"]=67,["semantic"]=67}}, + ["PARALYZE_SIDE_EFFECT1"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT1"},["2"]={["chance"]=10,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=10,["native"]=6,["semantic"]=6}}, + ["PARALYZE_SIDE_EFFECT2"] = {["1"]={["native"]="PARALYZE_SIDE_EFFECT2"},["2"]={["chance"]=30,["native"]="EFFECT_PARALYZE_HIT",["semantic"]=6},["3"]={["chance"]=30,["native"]=6,["semantic"]=6}}, + ["PAY_DAY_EFFECT"] = {["1"]={["native"]="PAY_DAY_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_PAY_DAY",["semantic"]=34},["3"]={["chance"]=100,["native"]=34,["semantic"]=34}}, + ["POISON_EFFECT"] = {["1"]={["native"]="POISON_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_POISON",["semantic"]=66},["3"]={["chance"]=0,["native"]=66,["semantic"]=66}}, + ["POISON_FANG"] = {["3"]={["native"]=202,["semantic"]=202}}, + ["POISON_SIDE_EFFECT1"] = {["1"]={["native"]="POISON_SIDE_EFFECT1"},["2"]={["chance"]=20,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=20,["native"]=2,["semantic"]=2}}, + ["POISON_SIDE_EFFECT2"] = {["1"]={["native"]="POISON_SIDE_EFFECT2"},["2"]={["chance"]=40,["native"]="EFFECT_POISON_HIT",["semantic"]=2},["3"]={["chance"]=40,["native"]=2,["semantic"]=2}}, + ["POISON_TAIL"] = {["3"]={["native"]=209,["semantic"]=209}}, + ["PRESENT"] = {["3"]={["native"]=122,["semantic"]=122}}, + ["PURSUIT"] = {["3"]={["native"]=128,["semantic"]=128}}, + ["RAGE_EFFECT"] = {["1"]={["native"]="RAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAGE",["semantic"]=81},["3"]={["chance"]=0,["native"]=81,["semantic"]=81}}, + ["RAPID_SPIN"] = {["3"]={["native"]=129,["semantic"]=129}}, + ["RECOIL_EFFECT"] = {["1"]={["native"]="RECOIL_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RECOIL_HIT",["semantic"]=48},["3"]={["chance"]=0,["native"]=48,["semantic"]=48}}, + ["RECYCLE"] = {["3"]={["native"]=184,["semantic"]=184}}, + ["REFLECT_EFFECT"] = {["1"]={["native"]="REFLECT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_REFLECT",["semantic"]=65},["3"]={["chance"]=0,["native"]=65,["semantic"]=65}}, + ["REFRESH"] = {["3"]={["native"]=193,["semantic"]=193}}, + ["REVENGE"] = {["3"]={["native"]=185,["semantic"]=185}}, + ["ROLE_PLAY"] = {["3"]={["native"]=178,["semantic"]=178}}, + ["SECRET_POWER"] = {["3"]={["native"]=197,["semantic"]=197}}, + ["SKILL_SWAP"] = {["3"]={["native"]=191,["semantic"]=191}}, + ["SKY_UPPERCUT"] = {["3"]={["native"]=207,["semantic"]=207}}, + ["SLEEP"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}}, + ["SLEEP_EFFECT"] = {["1"]={["native"]="SLEEP_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SLEEP",["semantic"]=1},["3"]={["chance"]=0,["native"]=1,["semantic"]=1}}, + ["SMELLINGSALT"] = {["3"]={["native"]=171,["semantic"]=171}}, + ["SNATCH"] = {["3"]={["native"]=195,["semantic"]=195}}, + ["SOFTBOILED"] = {["3"]={["native"]=157,["semantic"]=157}}, + ["SPECIAL_ATTACK_DOWN_HIT"] = {["3"]={["native"]=71,["semantic"]=71}}, + ["SPECIAL_ATTACK_UP_2"] = {["3"]={["native"]=53,["semantic"]=53}}, + ["SPECIAL_DAMAGE_EFFECT"] = {["1"]={["native"]="SPECIAL_DAMAGE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_LEVEL_DAMAGE",["semantic"]=87},["3"]={["chance"]=0,["native"]=87,["semantic"]=87}}, + ["SPECIAL_DOWN2_EFFECT"] = {["3"]={["chance"]=0,["native"]=62,["semantic"]=62}}, + ["SPECIAL_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPECIAL_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SP_DEF_DOWN_HIT",["semantic"]=72},["3"]={["chance"]=33,["native"]=72,["semantic"]=72}}, + ["SPECIAL_UP1_EFFECT"] = {["1"]={["native"]="SPECIAL_UP1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_ATK_UP",["semantic"]=13},["3"]={["chance"]=0,["native"]=13,["semantic"]=13}}, + ["SPECIAL_UP2_EFFECT"] = {["1"]={["native"]="SPECIAL_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SP_DEF_UP_2",["semantic"]=54},["3"]={["chance"]=0,["native"]=54,["semantic"]=54}}, + ["SPEED_DOWN1_EFFECT"] = {["1"]={["native"]="SPEED_DOWN1_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN",["semantic"]=20},["3"]={["chance"]=0,["native"]=20,["semantic"]=20}}, + ["SPEED_DOWN2_EFFECT"] = {["2"]={["chance"]=0,["native"]="EFFECT_SPEED_DOWN_2",["semantic"]=60},["3"]={["chance"]=0,["native"]=60,["semantic"]=60}}, + ["SPEED_DOWN_SIDE_EFFECT"] = {["1"]={["native"]="SPEED_DOWN_SIDE_EFFECT"},["2"]={["chance"]=33,["native"]="EFFECT_SPEED_DOWN_HIT",["semantic"]=70},["3"]={["chance"]=33,["native"]=70,["semantic"]=70}}, + ["SPEED_UP2_EFFECT"] = {["1"]={["native"]="SPEED_UP2_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPEED_UP_2",["semantic"]=52},["3"]={["chance"]=0,["native"]=52,["semantic"]=52}}, + ["SPIT_UP"] = {["3"]={["native"]=161,["semantic"]=161}}, + ["SPLASH_EFFECT"] = {["1"]={["native"]="SPLASH_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SPLASH",["semantic"]=85},["3"]={["chance"]=0,["native"]=85,["semantic"]=85}}, + ["STOCKPILE"] = {["3"]={["native"]=160,["semantic"]=160}}, + ["SUBSTITUTE_EFFECT"] = {["1"]={["native"]="SUBSTITUTE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUBSTITUTE",["semantic"]=79},["3"]={["chance"]=0,["native"]=79,["semantic"]=79}}, + ["SUPERPOWER"] = {["3"]={["native"]=182,["semantic"]=182}}, + ["SUPER_FANG_EFFECT"] = {["1"]={["native"]="SUPER_FANG_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_SUPER_FANG",["semantic"]=40},["3"]={["chance"]=0,["native"]=40,["semantic"]=40}}, + ["SWAGGER"] = {["3"]={["native"]=118,["semantic"]=118}}, + ["SWALLOW"] = {["3"]={["native"]=162,["semantic"]=162}}, + ["SWIFT_EFFECT"] = {["1"]={["native"]="SWIFT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_ALWAYS_HIT",["semantic"]=17},["3"]={["chance"]=0,["native"]=17,["semantic"]=17}}, + ["SWITCH_AND_TELEPORT_EFFECT"] = {["1"]={["native"]="SWITCH_AND_TELEPORT_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_FORCE_SWITCH",["semantic"]=28},["3"]={["chance"]=0,["native"]=28,["semantic"]=28}}, + ["TAUNT"] = {["3"]={["native"]=175,["semantic"]=175}}, + ["TEETER_DANCE"] = {["3"]={["native"]=199,["semantic"]=199}}, + ["THRASH_PETAL_DANCE_EFFECT"] = {["1"]={["native"]="THRASH_PETAL_DANCE_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_RAMPAGE",["semantic"]=27},["3"]={["chance"]=0,["native"]=27,["semantic"]=27}}, + ["THUNDER"] = {["3"]={["native"]=152,["semantic"]=152}}, + ["TICKLE"] = {["3"]={["native"]=205,["semantic"]=205}}, + ["TORMENT"] = {["3"]={["native"]=165,["semantic"]=165}}, + ["TRANSFORM_EFFECT"] = {["1"]={["native"]="TRANSFORM_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_TRANSFORM",["semantic"]=57},["3"]={["chance"]=0,["native"]=57,["semantic"]=57}}, + ["TRAPPING_EFFECT"] = {["1"]={["native"]="TRAPPING_EFFECT"},["2"]={["chance"]=100,["native"]="EFFECT_TRAP_TARGET",["semantic"]=42},["3"]={["chance"]=100,["native"]=42,["semantic"]=42}}, + ["TRICK"] = {["3"]={["native"]=177,["semantic"]=177}}, + ["TRI_ATTACK"] = {["3"]={["native"]=36,["semantic"]=36}}, + ["TWINEEDLE_EFFECT"] = {["1"]={["native"]="TWINEEDLE_EFFECT"},["3"]={["chance"]=20,["native"]=77,["semantic"]=77}}, + ["TWISTER"] = {["3"]={["native"]=146,["semantic"]=146}}, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["1"]={["native"]="TWO_TO_FIVE_ATTACKS_EFFECT"},["2"]={["chance"]=0,["native"]="EFFECT_MULTI_HIT",["semantic"]=29},["3"]={["chance"]=0,["native"]=29,["semantic"]=29}}, + ["UPROAR"] = {["3"]={["native"]=159,["semantic"]=159}}, + ["VITAL_THROW"] = {["3"]={["native"]=78,["semantic"]=78}}, + ["WATER_SPORT"] = {["3"]={["native"]=210,["semantic"]=210}}, + ["WEATHER_BALL"] = {["3"]={["native"]=203,["semantic"]=203}}, + ["WISH"] = {["3"]={["native"]=179,["semantic"]=179}}, + ["YAWN"] = {["3"]={["native"]=187,["semantic"]=187}}, + }, + ["hostMapSha256"] = "640d77b2a86740d18458262bdf424070ad9c52164365dbdb4879f3af2ba038ee", + ["native3"] = { + ["0"] = {"NO_ADDITIONAL_EFFECT"}, + ["1"] = {"SLEEP_EFFECT"}, + ["10"] = {"ATTACK_UP1_EFFECT"}, + ["100"] = {"SPITE_EFFECT"}, + ["101"] = {"FALSE_SWIPE_EFFECT"}, + ["102"] = {"HEAL_BELL_EFFECT"}, + ["103"] = {"NO_ADDITIONAL_EFFECT"}, + ["104"] = {"TRIPLE_KICK_EFFECT"}, + ["105"] = {"THIEF_EFFECT"}, + ["106"] = {"MEAN_LOOK_EFFECT"}, + ["107"] = {"NIGHTMARE_EFFECT"}, + ["108"] = {"EVASION_UP1_EFFECT"}, + ["109"] = {"CURSE_EFFECT"}, + ["11"] = {"DEFENSE_UP1_EFFECT"}, + ["111"] = {"PROTECT_EFFECT"}, + ["112"] = {"SPIKES_EFFECT"}, + ["113"] = {"FORESIGHT_EFFECT"}, + ["114"] = {"PERISH_SONG_EFFECT"}, + ["115"] = {"SANDSTORM_EFFECT"}, + ["116"] = {"ENDURE_EFFECT"}, + ["117"] = {"ROLLOUT_EFFECT"}, + ["118"] = {"SWAGGER_EFFECT"}, + ["119"] = {"FURY_CUTTER_EFFECT"}, + ["120"] = {"ATTRACT_EFFECT"}, + ["121"] = {"RETURN_EFFECT"}, + ["122"] = {"PRESENT_EFFECT"}, + ["123"] = {"FRUSTRATION_EFFECT"}, + ["124"] = {"SAFEGUARD_EFFECT"}, + ["125"] = {"BURN_SIDE_EFFECT1","BURN_SIDE_EFFECT2"}, + ["126"] = {"MAGNITUDE_EFFECT"}, + ["127"] = {"BATON_PASS_EFFECT"}, + ["128"] = {"PURSUIT_EFFECT"}, + ["129"] = {"RAPID_SPIN_EFFECT"}, + ["13"] = {"SP_ATK_UP1_EFFECT"}, + ["130"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["132"] = {"MORNING_SUN_EFFECT"}, + ["133"] = {"SYNTHESIS_EFFECT"}, + ["134"] = {"MOONLIGHT_EFFECT"}, + ["135"] = {"HIDDEN_POWER_EFFECT"}, + ["136"] = {"RAIN_DANCE_EFFECT"}, + ["137"] = {"SUNNY_DAY_EFFECT"}, + ["138"] = {"DEFENSE_UP_HIT_EFFECT"}, + ["139"] = {"ATTACK_UP_HIT_EFFECT"}, + ["140"] = {"ALL_UP_HIT_EFFECT"}, + ["142"] = {"BELLY_DRUM_EFFECT"}, + ["143"] = {"PSYCH_UP_EFFECT"}, + ["144"] = {"MIRROR_COAT_EFFECT"}, + ["145"] = {"CHARGE_EFFECT"}, + ["146"] = {"FLINCH_SIDE_EFFECT1"}, + ["147"] = {"EARTHQUAKE_EFFECT"}, + ["148"] = {"FUTURE_SIGHT_EFFECT"}, + ["149"] = {"GUST_EFFECT"}, + ["150"] = {"FLINCH_SIDE_EFFECT2"}, + ["151"] = {"SOLARBEAM_EFFECT"}, + ["152"] = {"THUNDER_EFFECT"}, + ["153"] = {"SWITCH_AND_TELEPORT_EFFECT"}, + ["154"] = {"BEAT_UP_EFFECT"}, + ["155"] = {"FLY_EFFECT"}, + ["156"] = {"DEFENSE_UP1_EFFECT"}, + ["157"] = {"HEAL_EFFECT"}, + ["158"] = {"FAKE_OUT_EFFECT"}, + ["159"] = {"UPROAR_EFFECT"}, + ["16"] = {"EVASION_UP1_EFFECT"}, + ["160"] = {"STOCKPILE_EFFECT"}, + ["161"] = {"SPIT_UP_EFFECT"}, + ["162"] = {"SWALLOW_EFFECT"}, + ["164"] = {"HAIL_EFFECT"}, + ["165"] = {"TORMENT_EFFECT"}, + ["166"] = {"FLATTER_EFFECT"}, + ["167"] = {"WILL_O_WISP_EFFECT"}, + ["168"] = {"MEMENTO_EFFECT"}, + ["169"] = {"FACADE_EFFECT"}, + ["17"] = {"SWIFT_EFFECT"}, + ["170"] = {"FOCUS_PUNCH_EFFECT"}, + ["171"] = {"SMELLINGSALT_EFFECT"}, + ["172"] = {"FOLLOW_ME_EFFECT"}, + ["173"] = {"NATURE_POWER_EFFECT"}, + ["174"] = {"CHARGE_UP_EFFECT"}, + ["175"] = {"TAUNT_EFFECT"}, + ["176"] = {"HELPING_HAND_EFFECT"}, + ["177"] = {"TRICK_EFFECT"}, + ["178"] = {"ROLE_PLAY_EFFECT"}, + ["179"] = {"WISH_EFFECT"}, + ["18"] = {"ATTACK_DOWN1_EFFECT"}, + ["180"] = {"ASSIST_EFFECT"}, + ["181"] = {"INGRAIN_EFFECT"}, + ["182"] = {"SUPERPOWER_EFFECT"}, + ["183"] = {"MAGIC_COAT_EFFECT"}, + ["184"] = {"RECYCLE_EFFECT"}, + ["185"] = {"REVENGE_EFFECT"}, + ["186"] = {"BRICK_BREAK_EFFECT"}, + ["187"] = {"YAWN_EFFECT"}, + ["188"] = {"KNOCK_OFF_EFFECT"}, + ["189"] = {"ENDEAVOR_EFFECT"}, + ["19"] = {"DEFENSE_DOWN1_EFFECT"}, + ["190"] = {"ERUPTION_EFFECT"}, + ["191"] = {"SKILL_SWAP_EFFECT"}, + ["192"] = {"IMPRISON_EFFECT"}, + ["193"] = {"REFRESH_EFFECT"}, + ["194"] = {"GRUDGE_EFFECT"}, + ["195"] = {"SNATCH_EFFECT"}, + ["196"] = {"LOW_KICK_EFFECT"}, + ["197"] = {"SECRET_POWER_EFFECT"}, + ["198"] = {"RECOIL_EFFECT"}, + ["199"] = {"TEETER_DANCE_EFFECT"}, + ["2"] = {"POISON_SIDE_EFFECT2"}, + ["20"] = {"SPEED_DOWN1_EFFECT"}, + ["200"] = {"BLAZE_KICK_EFFECT"}, + ["201"] = {"MUD_SPORT_EFFECT"}, + ["202"] = {"POISON_FANG_EFFECT"}, + ["203"] = {"WEATHER_BALL_EFFECT"}, + ["204"] = {"OVERHEAT_EFFECT"}, + ["205"] = {"TICKLE_EFFECT"}, + ["206"] = {"COSMIC_POWER_EFFECT"}, + ["207"] = {"SKY_UPPERCUT_EFFECT"}, + ["208"] = {"BULK_UP_EFFECT"}, + ["209"] = {"POISON_TAIL_EFFECT"}, + ["210"] = {"WATER_SPORT_EFFECT"}, + ["211"] = {"CALM_MIND_EFFECT"}, + ["212"] = {"DRAGON_DANCE_EFFECT"}, + ["213"] = {"CAMOUFLAGE_EFFECT"}, + ["23"] = {"ACCURACY_DOWN1_EFFECT"}, + ["24"] = {"EVASION_DOWN1_EFFECT"}, + ["25"] = {"HAZE_EFFECT"}, + ["26"] = {"BIDE_EFFECT"}, + ["27"] = {"THRASH_PETAL_DANCE_EFFECT"}, + ["28"] = {"SWITCH_AND_TELEPORT_EFFECT"}, + ["29"] = {"TWO_TO_FIVE_ATTACKS_EFFECT"}, + ["3"] = {"DRAIN_HP_EFFECT"}, + ["30"] = {"CONVERSION_EFFECT"}, + ["31"] = {"FLINCH_SIDE_EFFECT1","FLINCH_SIDE_EFFECT2"}, + ["32"] = {"HEAL_EFFECT"}, + ["33"] = {"POISON_EFFECT"}, + ["34"] = {"PAY_DAY_EFFECT"}, + ["35"] = {"LIGHT_SCREEN_EFFECT"}, + ["36"] = {"TRI_ATTACK_EFFECT"}, + ["37"] = {"HEAL_EFFECT"}, + ["38"] = {"OHKO_EFFECT"}, + ["39"] = {"CHARGE_EFFECT"}, + ["4"] = {"BURN_SIDE_EFFECT1"}, + ["40"] = {"SUPER_FANG_EFFECT"}, + ["41"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["42"] = {"TRAPPING_EFFECT"}, + ["43"] = {"HIGH_CRITICAL_EFFECT"}, + ["44"] = {"ATTACK_TWICE_EFFECT"}, + ["45"] = {"JUMP_KICK_EFFECT"}, + ["46"] = {"MIST_EFFECT"}, + ["47"] = {"FOCUS_ENERGY_EFFECT"}, + ["48"] = {"RECOIL_EFFECT"}, + ["49"] = {"CONFUSION_EFFECT"}, + ["5"] = {"FREEZE_SIDE_EFFECT1"}, + ["50"] = {"ATTACK_UP2_EFFECT"}, + ["51"] = {"DEFENSE_UP2_EFFECT"}, + ["52"] = {"SPEED_UP2_EFFECT"}, + ["53"] = {"SP_ATK_UP2_EFFECT"}, + ["54"] = {"SP_DEF_UP2_EFFECT"}, + ["57"] = {"TRANSFORM_EFFECT"}, + ["58"] = {"ATTACK_DOWN2_EFFECT"}, + ["59"] = {"DEFENSE_DOWN2_EFFECT"}, + ["6"] = {"PARALYZE_SIDE_EFFECT1","PARALYZE_SIDE_EFFECT2"}, + ["60"] = {"SPEED_DOWN2_EFFECT"}, + ["62"] = {"SP_DEF_DOWN2_EFFECT"}, + ["65"] = {"REFLECT_EFFECT"}, + ["66"] = {"POISON_EFFECT"}, + ["67"] = {"PARALYZE_EFFECT"}, + ["68"] = {"ATTACK_DOWN_SIDE_EFFECT"}, + ["69"] = {"DEFENSE_DOWN_SIDE_EFFECT"}, + ["7"] = {"EXPLODE_EFFECT"}, + ["70"] = {"SPEED_DOWN_SIDE_EFFECT"}, + ["71"] = {"SP_ATK_DOWN_HIT_EFFECT"}, + ["72"] = {"SP_DEF_DOWN_SIDE_EFFECT"}, + ["73"] = {"ACCURACY_DOWN_SIDE_EFFECT"}, + ["75"] = {"CHARGE_EFFECT"}, + ["76"] = {"CONFUSION_SIDE_EFFECT"}, + ["77"] = {"TWINEEDLE_EFFECT"}, + ["78"] = {"SWIFT_EFFECT"}, + ["79"] = {"SUBSTITUTE_EFFECT"}, + ["8"] = {"DREAM_EATER_EFFECT"}, + ["80"] = {"HYPER_BEAM_EFFECT"}, + ["81"] = {"RAGE_EFFECT"}, + ["82"] = {"MIMIC_EFFECT"}, + ["83"] = {"METRONOME_EFFECT"}, + ["84"] = {"LEECH_SEED_EFFECT"}, + ["85"] = {"SPLASH_EFFECT"}, + ["86"] = {"DISABLE_EFFECT"}, + ["87"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["88"] = {"SPECIAL_DAMAGE_EFFECT"}, + ["89"] = {"COUNTER_EFFECT"}, + ["9"] = {"MIRROR_MOVE_EFFECT"}, + ["90"] = {"ENCORE_EFFECT"}, + ["91"] = {"PAIN_SPLIT_EFFECT"}, + ["92"] = {"SNORE_EFFECT"}, + ["93"] = {"CONVERSION2_EFFECT"}, + ["94"] = {"LOCK_ON_EFFECT"}, + ["95"] = {"SKETCH_EFFECT"}, + ["97"] = {"SLEEP_TALK_EFFECT"}, + ["98"] = {"DESTINY_BOND_EFFECT"}, + ["99"] = {"REVERSAL_EFFECT"}, + }, + ["read3"] = { + ["0"] = "NO_ADDITIONAL_EFFECT", + ["1"] = "SLEEP_EFFECT", + ["10"] = "ATTACK_UP1_EFFECT", + ["100"] = "EFFECT_SPITE", + ["101"] = "EFFECT_FALSE_SWIPE", + ["102"] = "HEAL_BELL", + ["103"] = "EFFECT_PRIORITY_HIT", + ["104"] = "EFFECT_TRIPLE_KICK", + ["105"] = "EFFECT_THIEF", + ["106"] = "EFFECT_MEAN_LOOK", + ["107"] = "EFFECT_NIGHTMARE", + ["108"] = "MINIMIZE", + ["109"] = "EFFECT_CURSE", + ["11"] = "DEFENSE_UP1_EFFECT", + ["110"] = "native:3:110", + ["111"] = "EFFECT_PROTECT", + ["112"] = "EFFECT_SPIKES", + ["113"] = "EFFECT_FORESIGHT", + ["114"] = "EFFECT_PERISH_SONG", + ["115"] = "EFFECT_SANDSTORM", + ["116"] = "EFFECT_ENDURE", + ["117"] = "EFFECT_ROLLOUT", + ["118"] = "SWAGGER", + ["119"] = "EFFECT_FURY_CUTTER", + ["12"] = "native:3:SPEED_UP", + ["120"] = "EFFECT_ATTRACT", + ["121"] = "EFFECT_RETURN", + ["122"] = "PRESENT", + ["123"] = "EFFECT_FRUSTRATION", + ["124"] = "EFFECT_SAFEGUARD", + ["125"] = "EFFECT_SACRED_FIRE", + ["126"] = "EFFECT_MAGNITUDE", + ["127"] = "EFFECT_BATON_PASS", + ["128"] = "PURSUIT", + ["129"] = "RAPID_SPIN", + ["13"] = "SPECIAL_UP1_EFFECT", + ["130"] = "native:3:SONICBOOM", + ["131"] = "native:3:131", + ["132"] = "EFFECT_MORNING_SUN", + ["133"] = "EFFECT_SYNTHESIS", + ["134"] = "EFFECT_MOONLIGHT", + ["135"] = "native:3:HIDDEN_POWER", + ["136"] = "EFFECT_RAIN_DANCE", + ["137"] = "EFFECT_SUNNY_DAY", + ["138"] = "EFFECT_DEFENSE_UP_HIT", + ["139"] = "EFFECT_ATTACK_UP_HIT", + ["14"] = "native:3:SPECIAL_DEFENSE_UP", + ["140"] = "EFFECT_ALL_UP_HIT", + ["141"] = "native:3:141", + ["142"] = "EFFECT_BELLY_DRUM", + ["143"] = "EFFECT_PSYCH_UP", + ["144"] = "EFFECT_MIRROR_COAT", + ["145"] = "native:3:SKULL_BASH", + ["146"] = "TWISTER", + ["147"] = "EARTHQUAKE", + ["148"] = "EFFECT_FUTURE_SIGHT", + ["149"] = "GUST", + ["15"] = "native:3:ACCURACY_UP", + ["150"] = "FLINCH_MINIMIZE_HIT", + ["151"] = "native:3:SOLAR_BEAM", + ["152"] = "THUNDER", + ["153"] = "native:3:TELEPORT", + ["154"] = "EFFECT_BEAT_UP", + ["155"] = "FLY_EFFECT", + ["156"] = "EFFECT_DEFENSE_CURL", + ["157"] = "SOFTBOILED", + ["158"] = "FAKE_OUT", + ["159"] = "UPROAR", + ["16"] = "EVASION_UP1_EFFECT", + ["160"] = "STOCKPILE", + ["161"] = "SPIT_UP", + ["162"] = "SWALLOW", + ["163"] = "native:3:163", + ["164"] = "HAIL", + ["165"] = "TORMENT", + ["166"] = "FLATTER", + ["167"] = "EFFECT_BURN", + ["168"] = "MEMENTO", + ["169"] = "FACADE", + ["17"] = "SWIFT_EFFECT", + ["170"] = "FOCUS_PUNCH", + ["171"] = "SMELLINGSALT", + ["172"] = "FOLLOW_ME", + ["173"] = "NATURE_POWER", + ["174"] = "CHARGE", + ["175"] = "TAUNT", + ["176"] = "HELPING_HAND", + ["177"] = "TRICK", + ["178"] = "ROLE_PLAY", + ["179"] = "WISH", + ["18"] = "ATTACK_DOWN1_EFFECT", + ["180"] = "ASSIST", + ["181"] = "INGRAIN", + ["182"] = "SUPERPOWER", + ["183"] = "MAGIC_COAT", + ["184"] = "RECYCLE", + ["185"] = "REVENGE", + ["186"] = "BRICK_BREAK", + ["187"] = "YAWN", + ["188"] = "KNOCK_OFF", + ["189"] = "ENDEAVOR", + ["19"] = "DEFENSE_DOWN1_EFFECT", + ["190"] = "ERUPTION", + ["191"] = "SKILL_SWAP", + ["192"] = "IMPRISON", + ["193"] = "REFRESH", + ["194"] = "GRUDGE", + ["195"] = "SNATCH", + ["196"] = "LOW_KICK", + ["197"] = "SECRET_POWER", + ["198"] = "DOUBLE_EDGE", + ["199"] = "TEETER_DANCE", + ["2"] = "POISON_SIDE_EFFECT1", + ["20"] = "SPEED_DOWN1_EFFECT", + ["200"] = "BLAZE_KICK", + ["201"] = "MUD_SPORT", + ["202"] = "POISON_FANG", + ["203"] = "WEATHER_BALL", + ["204"] = "OVERHEAT", + ["205"] = "TICKLE", + ["206"] = "COSMIC_POWER", + ["207"] = "SKY_UPPERCUT", + ["208"] = "BULK_UP", + ["209"] = "POISON_TAIL", + ["21"] = "native:3:SPECIAL_ATTACK_DOWN", + ["210"] = "WATER_SPORT", + ["211"] = "CALM_MIND", + ["212"] = "DRAGON_DANCE", + ["213"] = "CAMOUFLAGE", + ["214"] = "native:3:214", + ["215"] = "native:3:215", + ["216"] = "native:3:216", + ["217"] = "native:3:217", + ["218"] = "native:3:218", + ["219"] = "native:3:219", + ["22"] = "native:3:SPECIAL_DEFENSE_DOWN", + ["220"] = "native:3:220", + ["221"] = "native:3:221", + ["222"] = "native:3:222", + ["223"] = "native:3:223", + ["224"] = "native:3:224", + ["225"] = "native:3:225", + ["226"] = "native:3:226", + ["227"] = "native:3:227", + ["228"] = "native:3:228", + ["229"] = "native:3:229", + ["23"] = "ACCURACY_DOWN1_EFFECT", + ["230"] = "native:3:230", + ["231"] = "native:3:231", + ["232"] = "native:3:232", + ["233"] = "native:3:233", + ["234"] = "native:3:234", + ["235"] = "native:3:235", + ["236"] = "native:3:236", + ["237"] = "native:3:237", + ["238"] = "native:3:238", + ["239"] = "native:3:239", + ["24"] = "EVASION_DOWN1_EFFECT", + ["240"] = "native:3:240", + ["241"] = "native:3:241", + ["242"] = "native:3:242", + ["243"] = "native:3:243", + ["244"] = "native:3:244", + ["245"] = "native:3:245", + ["246"] = "native:3:246", + ["247"] = "native:3:247", + ["248"] = "native:3:248", + ["249"] = "native:3:249", + ["25"] = "HAZE_EFFECT", + ["250"] = "native:3:250", + ["251"] = "native:3:251", + ["252"] = "native:3:252", + ["253"] = "native:3:253", + ["254"] = "native:3:254", + ["255"] = "native:3:255", + ["26"] = "BIDE_EFFECT", + ["27"] = "THRASH_PETAL_DANCE_EFFECT", + ["28"] = "SWITCH_AND_TELEPORT_EFFECT", + ["29"] = "TWO_TO_FIVE_ATTACKS_EFFECT", + ["3"] = "DRAIN_HP_EFFECT", + ["30"] = "CONVERSION_EFFECT", + ["31"] = "FLINCH_SIDE_EFFECT1", + ["32"] = "HEAL_EFFECT", + ["33"] = "native:3:TOXIC", + ["34"] = "PAY_DAY_EFFECT", + ["35"] = "LIGHT_SCREEN_EFFECT", + ["36"] = "TRI_ATTACK", + ["37"] = "native:3:REST", + ["38"] = "OHKO_EFFECT", + ["39"] = "CHARGE_EFFECT", + ["4"] = "BURN_SIDE_EFFECT1", + ["40"] = "SUPER_FANG_EFFECT", + ["41"] = "native:3:DRAGON_RAGE", + ["42"] = "TRAPPING_EFFECT", + ["43"] = "native:3:HIGH_CRITICAL", + ["44"] = "ATTACK_TWICE_EFFECT", + ["45"] = "JUMP_KICK_EFFECT", + ["46"] = "MIST_EFFECT", + ["47"] = "FOCUS_ENERGY_EFFECT", + ["48"] = "RECOIL_EFFECT", + ["49"] = "CONFUSION_EFFECT", + ["5"] = "FREEZE_SIDE_EFFECT1", + ["50"] = "ATTACK_UP2_EFFECT", + ["51"] = "DEFENSE_UP2_EFFECT", + ["52"] = "SPEED_UP2_EFFECT", + ["53"] = "SPECIAL_ATTACK_UP_2", + ["54"] = "SPECIAL_UP2_EFFECT", + ["55"] = "native:3:ACCURACY_UP_2", + ["56"] = "native:3:EVASION_UP_2", + ["57"] = "TRANSFORM_EFFECT", + ["58"] = "ATTACK_DOWN2_EFFECT", + ["59"] = "DEFENSE_DOWN2_EFFECT", + ["6"] = "PARALYZE_SIDE_EFFECT1", + ["60"] = "SPEED_DOWN2_EFFECT", + ["61"] = "native:3:SPECIAL_ATTACK_DOWN_2", + ["62"] = "SPECIAL_DOWN2_EFFECT", + ["63"] = "native:3:ACCURACY_DOWN_2", + ["64"] = "native:3:EVASION_DOWN_2", + ["65"] = "REFLECT_EFFECT", + ["66"] = "POISON_EFFECT", + ["67"] = "PARALYZE_EFFECT", + ["68"] = "ATTACK_DOWN_SIDE_EFFECT", + ["69"] = "DEFENSE_DOWN_SIDE_EFFECT", + ["7"] = "EXPLODE_EFFECT", + ["70"] = "SPEED_DOWN_SIDE_EFFECT", + ["71"] = "SPECIAL_ATTACK_DOWN_HIT", + ["72"] = "SPECIAL_DOWN_SIDE_EFFECT", + ["73"] = "EFFECT_ACCURACY_DOWN_HIT", + ["74"] = "native:3:EVASION_DOWN_HIT", + ["75"] = "native:3:SKY_ATTACK", + ["76"] = "CONFUSION_SIDE_EFFECT", + ["77"] = "TWINEEDLE_EFFECT", + ["78"] = "VITAL_THROW", + ["79"] = "SUBSTITUTE_EFFECT", + ["8"] = "DREAM_EATER_EFFECT", + ["80"] = "HYPER_BEAM_EFFECT", + ["81"] = "RAGE_EFFECT", + ["82"] = "MIMIC_EFFECT", + ["83"] = "METRONOME_EFFECT", + ["84"] = "LEECH_SEED_EFFECT", + ["85"] = "SPLASH_EFFECT", + ["86"] = "DISABLE_EFFECT", + ["87"] = "SPECIAL_DAMAGE_EFFECT", + ["88"] = "native:3:PSYWAVE", + ["89"] = "native:3:COUNTER", + ["9"] = "MIRROR_MOVE_EFFECT", + ["90"] = "EFFECT_ENCORE", + ["91"] = "PAIN_SPLIT", + ["92"] = "EFFECT_SNORE", + ["93"] = "CONVERSION_2", + ["94"] = "EFFECT_LOCK_ON", + ["95"] = "EFFECT_SKETCH", + ["96"] = "native:3:96", + ["97"] = "EFFECT_SLEEP_TALK", + ["98"] = "DESTINY_BOND", + ["99"] = "FLAIL", + }, + ["reference"] = { + ["commit"] = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36", + ["sha256"] = {["src/battle/g3u/EffectMap1.lua"]="15a66e3c011128a94f5d84689581c78e1f7f737da7c17d60d46b8cdf3ad22cfd",["src/battle/g3u/EffectMap2.lua"]="009ed83f5a76638050110b4304e66bc514b61ded28bbf4e4b75e5d1f448cb708",["src/core/game3/battle/effect_ids.lua"]="00ce6a4196c2b05d56c8b575ec3eb20d8e32d16a99646716f18f08624795f496",["src/mods/UnifiedEffects.lua"]="bde10a80bd8c64a71a279b8a143757a8f26f30848c095ce49b6a085690181676"}, + }, + ["reserved"] = { + ["ABSORB"] = true, + ["ACCURACY_DOWN"] = true, + ["ACCURACY_DOWN1_EFFECT"] = true, + ["ACCURACY_DOWN2_EFFECT"] = true, + ["ACCURACY_DOWN_2"] = true, + ["ACCURACY_DOWN_HIT"] = true, + ["ACCURACY_UP"] = true, + ["ACCURACY_UP1_EFFECT"] = true, + ["ACCURACY_UP2_EFFECT"] = true, + ["ACCURACY_UP_2"] = true, + ["AFTER_HIT"] = true, + ["ALL_STATS_UP_HIT"] = true, + ["ALWAYS_HIT"] = true, + ["ASSIST"] = true, + ["ATTACK_DOWN"] = true, + ["ATTACK_DOWN1_EFFECT"] = true, + ["ATTACK_DOWN2_EFFECT"] = true, + ["ATTACK_DOWN_2"] = true, + ["ATTACK_DOWN_HIT"] = true, + ["ATTACK_DOWN_SIDE_EFFECT"] = true, + ["ATTACK_TWICE_EFFECT"] = true, + ["ATTACK_UP"] = true, + ["ATTACK_UP1_EFFECT"] = true, + ["ATTACK_UP2_EFFECT"] = true, + ["ATTACK_UP_2"] = true, + ["ATTACK_UP_HIT"] = true, + ["ATTRACT"] = true, + ["BATON_PASS"] = true, + ["BEAT_UP"] = true, + ["BELLY_DRUM"] = true, + ["BIDE"] = true, + ["BIDE_EFFECT"] = true, + ["BLAZE_KICK"] = true, + ["BRICK_BREAK"] = true, + ["BULK_UP"] = true, + ["BURN_HIT"] = true, + ["BURN_SIDE_EFFECT1"] = true, + ["BURN_SIDE_EFFECT2"] = true, + ["CALM_MIND"] = true, + ["CAMOUFLAGE"] = true, + ["CHARGE"] = true, + ["CHARGE_EFFECT"] = true, + ["CONFUSE"] = true, + ["CONFUSE_HIT"] = true, + ["CONFUSION_EFFECT"] = true, + ["CONFUSION_SIDE_EFFECT"] = true, + ["CONVERSION"] = true, + ["CONVERSION_2"] = true, + ["CONVERSION_EFFECT"] = true, + ["COSMIC_POWER"] = true, + ["COUNTER"] = true, + ["CURSE"] = true, + ["DEFENSE_CURL"] = true, + ["DEFENSE_DOWN"] = true, + ["DEFENSE_DOWN1_EFFECT"] = true, + ["DEFENSE_DOWN2_EFFECT"] = true, + ["DEFENSE_DOWN_2"] = true, + ["DEFENSE_DOWN_HIT"] = true, + ["DEFENSE_DOWN_SIDE_EFFECT"] = true, + ["DEFENSE_UP"] = true, + ["DEFENSE_UP1_EFFECT"] = true, + ["DEFENSE_UP2_EFFECT"] = true, + ["DEFENSE_UP_2"] = true, + ["DEFENSE_UP_HIT"] = true, + ["DESTINY_BOND"] = true, + ["DISABLE"] = true, + ["DISABLE_EFFECT"] = true, + ["DOUBLE_EDGE"] = true, + ["DOUBLE_HIT"] = true, + ["DRAGON_DANCE"] = true, + ["DRAGON_RAGE"] = true, + ["DRAIN_HP_EFFECT"] = true, + ["DREAM_EATER"] = true, + ["DREAM_EATER_EFFECT"] = true, + ["EARTHQUAKE"] = true, + ["EFFECT_01"] = true, + ["EFFECT_1E"] = true, + ["EFFECT_ACCURACY_DOWN"] = true, + ["EFFECT_ACCURACY_DOWN_2"] = true, + ["EFFECT_ACCURACY_DOWN_HIT"] = true, + ["EFFECT_ACCURACY_UP"] = true, + ["EFFECT_ACCURACY_UP_2"] = true, + ["EFFECT_ALL_UP_HIT"] = true, + ["EFFECT_ALWAYS_HIT"] = true, + ["EFFECT_ATTACK_DOWN"] = true, + ["EFFECT_ATTACK_DOWN_2"] = true, + ["EFFECT_ATTACK_DOWN_HIT"] = true, + ["EFFECT_ATTACK_UP"] = true, + ["EFFECT_ATTACK_UP_2"] = true, + ["EFFECT_ATTACK_UP_HIT"] = true, + ["EFFECT_ATTRACT"] = true, + ["EFFECT_BATON_PASS"] = true, + ["EFFECT_BEAT_UP"] = true, + ["EFFECT_BELLY_DRUM"] = true, + ["EFFECT_BIDE"] = true, + ["EFFECT_BURN_HIT"] = true, + ["EFFECT_CONFUSE"] = true, + ["EFFECT_CONFUSE_HIT"] = true, + ["EFFECT_CONVERSION"] = true, + ["EFFECT_CONVERSION2"] = true, + ["EFFECT_COUNTER"] = true, + ["EFFECT_CURSE"] = true, + ["EFFECT_DEFENSE_CURL"] = true, + ["EFFECT_DEFENSE_DOWN"] = true, + ["EFFECT_DEFENSE_DOWN_2"] = true, + ["EFFECT_DEFENSE_DOWN_HIT"] = true, + ["EFFECT_DEFENSE_UP"] = true, + ["EFFECT_DEFENSE_UP_2"] = true, + ["EFFECT_DEFENSE_UP_HIT"] = true, + ["EFFECT_DEFROST_OPPONENT"] = true, + ["EFFECT_DESTINY_BOND"] = true, + ["EFFECT_DISABLE"] = true, + ["EFFECT_DOUBLE_HIT"] = true, + ["EFFECT_DREAM_EATER"] = true, + ["EFFECT_EARTHQUAKE"] = true, + ["EFFECT_ENCORE"] = true, + ["EFFECT_ENDURE"] = true, + ["EFFECT_EVASION_DOWN"] = true, + ["EFFECT_EVASION_DOWN_2"] = true, + ["EFFECT_EVASION_DOWN_HIT"] = true, + ["EFFECT_EVASION_UP"] = true, + ["EFFECT_EVASION_UP_2"] = true, + ["EFFECT_FAKE_OUT"] = true, + ["EFFECT_FALSE_SWIPE"] = true, + ["EFFECT_FLAME_WHEEL"] = true, + ["EFFECT_FLINCH_HIT"] = true, + ["EFFECT_FLY"] = true, + ["EFFECT_FOCUS_ENERGY"] = true, + ["EFFECT_FORCE_SWITCH"] = true, + ["EFFECT_FORESIGHT"] = true, + ["EFFECT_FREEZE_HIT"] = true, + ["EFFECT_FRUSTRATION"] = true, + ["EFFECT_FURY_CUTTER"] = true, + ["EFFECT_FUTURE_SIGHT"] = true, + ["EFFECT_GUST"] = true, + ["EFFECT_HEAL"] = true, + ["EFFECT_HEAL_BELL"] = true, + ["EFFECT_HIDDEN_POWER"] = true, + ["EFFECT_HYPER_BEAM"] = true, + ["EFFECT_JUMP_KICK"] = true, + ["EFFECT_LEECH_HIT"] = true, + ["EFFECT_LEECH_SEED"] = true, + ["EFFECT_LEVEL_DAMAGE"] = true, + ["EFFECT_LIGHT_SCREEN"] = true, + ["EFFECT_LOCK_ON"] = true, + ["EFFECT_MAGNITUDE"] = true, + ["EFFECT_MEAN_LOOK"] = true, + ["EFFECT_METRONOME"] = true, + ["EFFECT_MIMIC"] = true, + ["EFFECT_MIRROR_COAT"] = true, + ["EFFECT_MIRROR_MOVE"] = true, + ["EFFECT_MIST"] = true, + ["EFFECT_MOONLIGHT"] = true, + ["EFFECT_MORNING_SUN"] = true, + ["EFFECT_MULTI_HIT"] = true, + ["EFFECT_NIGHTMARE"] = true, + ["EFFECT_NORMAL_HIT"] = true, + ["EFFECT_OHKO"] = true, + ["EFFECT_PAIN_SPLIT"] = true, + ["EFFECT_PARALYZE"] = true, + ["EFFECT_PARALYZE_HIT"] = true, + ["EFFECT_PAY_DAY"] = true, + ["EFFECT_PERISH_SONG"] = true, + ["EFFECT_POISON"] = true, + ["EFFECT_POISON_HIT"] = true, + ["EFFECT_POISON_MULTI_HIT"] = true, + ["EFFECT_PRESENT"] = true, + ["EFFECT_PRIORITY_HIT"] = true, + ["EFFECT_PROTECT"] = true, + ["EFFECT_PSYCH_UP"] = true, + ["EFFECT_PSYWAVE"] = true, + ["EFFECT_PURSUIT"] = true, + ["EFFECT_RAGE"] = true, + ["EFFECT_RAIN_DANCE"] = true, + ["EFFECT_RAMPAGE"] = true, + ["EFFECT_RAPID_SPIN"] = true, + ["EFFECT_RAZOR_WIND"] = true, + ["EFFECT_RECOIL_HIT"] = true, + ["EFFECT_REFLECT"] = true, + ["EFFECT_RESET_STATS"] = true, + ["EFFECT_RETURN"] = true, + ["EFFECT_REVERSAL"] = true, + ["EFFECT_ROLLOUT"] = true, + ["EFFECT_SACRED_FIRE"] = true, + ["EFFECT_SAFEGUARD"] = true, + ["EFFECT_SANDSTORM"] = true, + ["EFFECT_SELFDESTRUCT"] = true, + ["EFFECT_SKETCH"] = true, + ["EFFECT_SKULL_BASH"] = true, + ["EFFECT_SKY_ATTACK"] = true, + ["EFFECT_SLEEP"] = true, + ["EFFECT_SLEEP_TALK"] = true, + ["EFFECT_SNORE"] = true, + ["EFFECT_SOLARBEAM"] = true, + ["EFFECT_SPEED_DOWN"] = true, + ["EFFECT_SPEED_DOWN_2"] = true, + ["EFFECT_SPEED_DOWN_HIT"] = true, + ["EFFECT_SPEED_UP"] = true, + ["EFFECT_SPEED_UP_2"] = true, + ["EFFECT_SPIKES"] = true, + ["EFFECT_SPITE"] = true, + ["EFFECT_SPLASH"] = true, + ["EFFECT_SP_ATK_DOWN"] = true, + ["EFFECT_SP_ATK_DOWN_2"] = true, + ["EFFECT_SP_ATK_DOWN_HIT"] = true, + ["EFFECT_SP_ATK_UP"] = true, + ["EFFECT_SP_ATK_UP_2"] = true, + ["EFFECT_SP_DEF_DOWN"] = true, + ["EFFECT_SP_DEF_DOWN_2"] = true, + ["EFFECT_SP_DEF_DOWN_HIT"] = true, + ["EFFECT_SP_DEF_UP"] = true, + ["EFFECT_SP_DEF_UP_2"] = true, + ["EFFECT_STATIC_DAMAGE"] = true, + ["EFFECT_STOMP"] = true, + ["EFFECT_SUBSTITUTE"] = true, + ["EFFECT_SUNNY_DAY"] = true, + ["EFFECT_SUPER_FANG"] = true, + ["EFFECT_SWAGGER"] = true, + ["EFFECT_SYNTHESIS"] = true, + ["EFFECT_TELEPORT"] = true, + ["EFFECT_THIEF"] = true, + ["EFFECT_THUNDER"] = true, + ["EFFECT_TOXIC"] = true, + ["EFFECT_TRANSFORM"] = true, + ["EFFECT_TRAP_TARGET"] = true, + ["EFFECT_TRIPLE_KICK"] = true, + ["EFFECT_TRI_ATTACK"] = true, + ["EFFECT_TWISTER"] = true, + ["EFFECT_UNUSED_25"] = true, + ["EFFECT_UNUSED_2B"] = true, + ["EFFECT_UNUSED_4E"] = true, + ["EFFECT_UNUSED_6E"] = true, + ["EFFECT_UNUSED_82"] = true, + ["EFFECT_UNUSED_83"] = true, + ["ENCORE"] = true, + ["ENDEAVOR"] = true, + ["ENDURE"] = true, + ["ERUPTION"] = true, + ["EVASION_DOWN"] = true, + ["EVASION_DOWN1_EFFECT"] = true, + ["EVASION_DOWN2_EFFECT"] = true, + ["EVASION_DOWN_2"] = true, + ["EVASION_DOWN_HIT"] = true, + ["EVASION_UP"] = true, + ["EVASION_UP1_EFFECT"] = true, + ["EVASION_UP2_EFFECT"] = true, + ["EVASION_UP_2"] = true, + ["EXPLODE_EFFECT"] = true, + ["EXPLOSION"] = true, + ["FACADE"] = true, + ["FAKE_OUT"] = true, + ["FALSE_SWIPE"] = true, + ["FLAIL"] = true, + ["FLATTER"] = true, + ["FLINCH_HIT"] = true, + ["FLINCH_MINIMIZE_HIT"] = true, + ["FLINCH_SIDE_EFFECT1"] = true, + ["FLINCH_SIDE_EFFECT2"] = true, + ["FLY"] = true, + ["FLY_EFFECT"] = true, + ["FOCUS_ENERGY"] = true, + ["FOCUS_ENERGY_EFFECT"] = true, + ["FOCUS_PUNCH"] = true, + ["FOLLOW_ME"] = true, + ["FORESIGHT"] = true, + ["FREEZE_HIT"] = true, + ["FREEZE_SIDE_EFFECT1"] = true, + ["FREEZE_SIDE_EFFECT2"] = true, + ["FRUSTRATION"] = true, + ["FURY_CUTTER"] = true, + ["FUTURE_SIGHT"] = true, + ["GRUDGE"] = true, + ["GUST"] = true, + ["HAIL"] = true, + ["HAZE"] = true, + ["HAZE_EFFECT"] = true, + ["HEAL_BELL"] = true, + ["HEAL_EFFECT"] = true, + ["HELPING_HAND"] = true, + ["HIDDEN_POWER"] = true, + ["HIGH_CRITICAL"] = true, + ["HIT"] = true, + ["HYPER_BEAM_EFFECT"] = true, + ["IMPRISON"] = true, + ["INGRAIN"] = true, + ["JUMP_KICK_EFFECT"] = true, + ["KNOCK_OFF"] = true, + ["LEECH_SEED"] = true, + ["LEECH_SEED_EFFECT"] = true, + ["LEVEL_DAMAGE"] = true, + ["LIGHT_SCREEN"] = true, + ["LIGHT_SCREEN_EFFECT"] = true, + ["LOCK_ON"] = true, + ["LOW_KICK"] = true, + ["MAGIC_COAT"] = true, + ["MAGNITUDE"] = true, + ["MEAN_LOOK"] = true, + ["MEMENTO"] = true, + ["METRONOME"] = true, + ["METRONOME_EFFECT"] = true, + ["MIMIC"] = true, + ["MIMIC_EFFECT"] = true, + ["MINIMIZE"] = true, + ["MIRROR_COAT"] = true, + ["MIRROR_MOVE"] = true, + ["MIRROR_MOVE_EFFECT"] = true, + ["MIST"] = true, + ["MIST_EFFECT"] = true, + ["MOONLIGHT"] = true, + ["MORNING_SUN"] = true, + ["MUD_SPORT"] = true, + ["MULTI_HIT"] = true, + ["NATURE_POWER"] = true, + ["NIGHTMARE"] = true, + ["NO_ADDITIONAL_EFFECT"] = true, + ["OHKO"] = true, + ["OHKO_EFFECT"] = true, + ["OVERHEAT"] = true, + ["PAIN_SPLIT"] = true, + ["PARALYZE"] = true, + ["PARALYZE_EFFECT"] = true, + ["PARALYZE_HIT"] = true, + ["PARALYZE_SIDE_EFFECT1"] = true, + ["PARALYZE_SIDE_EFFECT2"] = true, + ["PAY_DAY"] = true, + ["PAY_DAY_EFFECT"] = true, + ["PERISH_SONG"] = true, + ["POISON"] = true, + ["POISON_EFFECT"] = true, + ["POISON_FANG"] = true, + ["POISON_HIT"] = true, + ["POISON_SIDE_EFFECT1"] = true, + ["POISON_SIDE_EFFECT2"] = true, + ["POISON_TAIL"] = true, + ["PRESENT"] = true, + ["PROTECT"] = true, + ["PSYCH_UP"] = true, + ["PSYWAVE"] = true, + ["PURSUIT"] = true, + ["QUICK_ATTACK"] = true, + ["RAGE"] = true, + ["RAGE_EFFECT"] = true, + ["RAIN_DANCE"] = true, + ["RAMPAGE"] = true, + ["RAPID_SPIN"] = true, + ["RAZOR_WIND"] = true, + ["RECHARGE"] = true, + ["RECOIL"] = true, + ["RECOIL_25"] = true, + ["RECOIL_EFFECT"] = true, + ["RECOIL_IF_MISS"] = true, + ["RECOVER"] = true, + ["RECYCLE"] = true, + ["REFLECT"] = true, + ["REFLECT_EFFECT"] = true, + ["REFRESH"] = true, + ["REST"] = true, + ["RESTORE_HP"] = true, + ["RETURN"] = true, + ["REVENGE"] = true, + ["ROAR"] = true, + ["ROLE_PLAY"] = true, + ["ROLLOUT"] = true, + ["SAFEGUARD"] = true, + ["SANDSTORM"] = true, + ["SECONDARY"] = true, + ["SECRET_POWER"] = true, + ["SECRET_POWER_BY_TERRAIN"] = true, + ["SEMI_INVULNERABLE"] = true, + ["SKETCH"] = true, + ["SKILL_SWAP"] = true, + ["SKULL_BASH"] = true, + ["SKY_ATTACK"] = true, + ["SKY_UPPERCUT"] = true, + ["SLEEP"] = true, + ["SLEEP_EFFECT"] = true, + ["SLEEP_TALK"] = true, + ["SMELLINGSALT"] = true, + ["SNATCH"] = true, + ["SNORE"] = true, + ["SOFTBOILED"] = true, + ["SOLAR_BEAM"] = true, + ["SONICBOOM"] = true, + ["SPECIAL_ATTACK_DOWN"] = true, + ["SPECIAL_ATTACK_DOWN_2"] = true, + ["SPECIAL_ATTACK_DOWN_HIT"] = true, + ["SPECIAL_ATTACK_UP"] = true, + ["SPECIAL_ATTACK_UP_2"] = true, + ["SPECIAL_DAMAGE_EFFECT"] = true, + ["SPECIAL_DEFENSE_DOWN"] = true, + ["SPECIAL_DEFENSE_DOWN_2"] = true, + ["SPECIAL_DEFENSE_DOWN_HIT"] = true, + ["SPECIAL_DEFENSE_UP"] = true, + ["SPECIAL_DEFENSE_UP_2"] = true, + ["SPECIAL_DOWN1_EFFECT"] = true, + ["SPECIAL_DOWN2_EFFECT"] = true, + ["SPECIAL_DOWN_SIDE_EFFECT"] = true, + ["SPECIAL_UP1_EFFECT"] = true, + ["SPECIAL_UP2_EFFECT"] = true, + ["SPEED_DOWN"] = true, + ["SPEED_DOWN1_EFFECT"] = true, + ["SPEED_DOWN2_EFFECT"] = true, + ["SPEED_DOWN_2"] = true, + ["SPEED_DOWN_HIT"] = true, + ["SPEED_DOWN_SIDE_EFFECT"] = true, + ["SPEED_UP"] = true, + ["SPEED_UP1_EFFECT"] = true, + ["SPEED_UP2_EFFECT"] = true, + ["SPEED_UP_2"] = true, + ["SPIKES"] = true, + ["SPITE"] = true, + ["SPIT_UP"] = true, + ["SPLASH"] = true, + ["SPLASH_EFFECT"] = true, + ["STATUS_SETUP"] = true, + ["STAT_CHANGES"] = true, + ["STOCKPILE"] = true, + ["SUBSTITUTE"] = true, + ["SUBSTITUTE_EFFECT"] = true, + ["SUNNY_DAY"] = true, + ["SUPERPOWER"] = true, + ["SUPER_FANG"] = true, + ["SUPER_FANG_EFFECT"] = true, + ["SWAGGER"] = true, + ["SWALLOW"] = true, + ["SWIFT_EFFECT"] = true, + ["SWITCH_AND_TELEPORT_EFFECT"] = true, + ["SYNTHESIS"] = true, + ["TAUNT"] = true, + ["TEETER_DANCE"] = true, + ["TELEPORT"] = true, + ["THAW_HIT"] = true, + ["THIEF"] = true, + ["THRASH"] = true, + ["THRASH_PETAL_DANCE_EFFECT"] = true, + ["THUNDER"] = true, + ["TICKLE"] = true, + ["TORMENT"] = true, + ["TOXIC"] = true, + ["TRANSFORM"] = true, + ["TRANSFORM_EFFECT"] = true, + ["TRAP"] = true, + ["TRAPPING_EFFECT"] = true, + ["TRICK"] = true, + ["TRIPLE_KICK"] = true, + ["TRI_ATTACK"] = true, + ["TWINEEDLE"] = true, + ["TWINEEDLE_EFFECT"] = true, + ["TWISTER"] = true, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = true, + ["UPROAR"] = true, + ["VITAL_THROW"] = true, + ["WATER_SPORT"] = true, + ["WEATHER_BALL"] = true, + ["WILL_O_WISP"] = true, + ["WISH"] = true, + ["YAWN"] = true, + }, + ["variants"] = { + ["ACCURACY_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=23}, + ["ATTACK_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=18}, + ["ATTACK_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=58}, + ["ATTACK_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=68}, + ["ATTACK_TWICE_EFFECT"] = {["chance"]=0,["semantic"]=44}, + ["ATTACK_UP1_EFFECT"] = {["chance"]=0,["semantic"]=10}, + ["ATTACK_UP2_EFFECT"] = {["chance"]=0,["semantic"]=50}, + ["BIDE_EFFECT"] = {["chance"]=0,["semantic"]=26}, + ["BURN_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=4}, + ["BURN_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=4}, + ["CHARGE_EFFECT"] = {["chance"]=0,["semantic"]=39}, + ["CONFUSION_EFFECT"] = {["chance"]=0,["semantic"]=49}, + ["CONFUSION_SIDE_EFFECT"] = {["chance"]=10,["semantic"]=76}, + ["CONVERSION_EFFECT"] = {["chance"]=0,["semantic"]=30}, + ["DEFENSE_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=19}, + ["DEFENSE_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=59}, + ["DEFENSE_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=69}, + ["DEFENSE_UP1_EFFECT"] = {["chance"]=0,["semantic"]=11}, + ["DEFENSE_UP2_EFFECT"] = {["chance"]=0,["semantic"]=51}, + ["DISABLE_EFFECT"] = {["chance"]=0,["semantic"]=86}, + ["DRAIN_HP_EFFECT"] = {["chance"]=0,["semantic"]=3}, + ["DREAM_EATER_EFFECT"] = {["chance"]=0,["semantic"]=8}, + ["EVASION_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=24}, + ["EVASION_UP1_EFFECT"] = {["chance"]=0,["semantic"]=16}, + ["EXPLODE_EFFECT"] = {["chance"]=0,["semantic"]=7}, + ["FLINCH_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=31}, + ["FLINCH_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=31}, + ["FLY_EFFECT"] = {["chance"]=0,["semantic"]=155}, + ["FOCUS_ENERGY_EFFECT"] = {["chance"]=0,["semantic"]=47}, + ["FREEZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=5}, + ["FREEZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=5}, + ["HAZE_EFFECT"] = {["chance"]=0,["semantic"]=25}, + ["HEAL_EFFECT"] = {["chance"]=0,["semantic"]=32}, + ["HYPER_BEAM_EFFECT"] = {["chance"]=0,["semantic"]=80}, + ["JUMP_KICK_EFFECT"] = {["chance"]=0,["semantic"]=45}, + ["LEECH_SEED_EFFECT"] = {["chance"]=0,["semantic"]=84}, + ["LIGHT_SCREEN_EFFECT"] = {["chance"]=0,["semantic"]=35}, + ["METRONOME_EFFECT"] = {["chance"]=0,["semantic"]=83}, + ["MIMIC_EFFECT"] = {["chance"]=0,["semantic"]=82}, + ["MIRROR_MOVE_EFFECT"] = {["chance"]=0,["semantic"]=9}, + ["MIST_EFFECT"] = {["chance"]=0,["semantic"]=46}, + ["NO_ADDITIONAL_EFFECT"] = {["chance"]=0,["semantic"]=0}, + ["OHKO_EFFECT"] = {["chance"]=0,["semantic"]=38}, + ["PARALYZE_EFFECT"] = {["chance"]=0,["semantic"]=67}, + ["PARALYZE_SIDE_EFFECT1"] = {["chance"]=10,["semantic"]=6}, + ["PARALYZE_SIDE_EFFECT2"] = {["chance"]=30,["semantic"]=6}, + ["PAY_DAY_EFFECT"] = {["chance"]=0,["semantic"]=34}, + ["POISON_EFFECT"] = {["chance"]=0,["semantic"]=66}, + ["POISON_SIDE_EFFECT1"] = {["chance"]=20,["semantic"]=2}, + ["POISON_SIDE_EFFECT2"] = {["chance"]=40,["semantic"]=2}, + ["RAGE_EFFECT"] = {["chance"]=0,["semantic"]=81}, + ["RECOIL_EFFECT"] = {["chance"]=0,["semantic"]=48}, + ["REFLECT_EFFECT"] = {["chance"]=0,["semantic"]=65}, + ["SLEEP_EFFECT"] = {["chance"]=0,["semantic"]=1}, + ["SPECIAL_DAMAGE_EFFECT"] = {["chance"]=0,["semantic"]=87}, + ["SPECIAL_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=62}, + ["SPECIAL_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=72}, + ["SPECIAL_UP1_EFFECT"] = {["chance"]=0,["semantic"]=13}, + ["SPECIAL_UP2_EFFECT"] = {["chance"]=0,["semantic"]=54}, + ["SPEED_DOWN1_EFFECT"] = {["chance"]=0,["semantic"]=20}, + ["SPEED_DOWN2_EFFECT"] = {["chance"]=0,["semantic"]=60}, + ["SPEED_DOWN_SIDE_EFFECT"] = {["chance"]=33,["semantic"]=70}, + ["SPEED_UP2_EFFECT"] = {["chance"]=0,["semantic"]=52}, + ["SPLASH_EFFECT"] = {["chance"]=0,["semantic"]=85}, + ["SUBSTITUTE_EFFECT"] = {["chance"]=0,["semantic"]=79}, + ["SUPER_FANG_EFFECT"] = {["chance"]=0,["semantic"]=40}, + ["SWIFT_EFFECT"] = {["chance"]=0,["semantic"]=17}, + ["SWITCH_AND_TELEPORT_EFFECT"] = {["chance"]=0,["semantic"]=28}, + ["THRASH_PETAL_DANCE_EFFECT"] = {["chance"]=0,["semantic"]=27}, + ["TRANSFORM_EFFECT"] = {["chance"]=0,["semantic"]=57}, + ["TRAPPING_EFFECT"] = {["chance"]=0,["semantic"]=42}, + ["TWINEEDLE_EFFECT"] = {["chance"]=20,["semantic"]=77}, + ["TWO_TO_FIVE_ATTACKS_EFFECT"] = {["chance"]=0,["semantic"]=29}, + }, +} diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua new file mode 100644 index 000000000..72d855cec --- /dev/null +++ b/src/mods/PortableScripts.lua @@ -0,0 +1,1314 @@ +-- Per-mod portable dialogue programs. An isolated native runner owns the +-- foreground until completion, then returns the idle runner it borrowed. +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local Flags = require("src.mods.Api3Flags") +local State = require("src.mods.ScriptState") +local Gifts = require("src.mods.ScriptGifts") +local Audio = require("src.mods.ScriptAudio") +local Fields = require("src.mods.ScriptFields") +local DialogueAudio = require("src.mods.ScriptDialogueAudio") +local Shops = require("src.mods.ScriptShops") +local Battles = require("src.mods.ScriptBattles") +local Travel = require("src.mods.ScriptTravel") +local Movement = require("src.mods.ScriptMovement") +local WorldEdits = require("src.mods.ScriptWorldEdits") +local GameVersion = require("src.core.GameVersion") +local Scripts = {} +local runs = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local contexts = setmetatable({}, { __mode = "k" }) +local draining = false +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(fn, ...)) + Runtime.currentMod = owner + return unpack(result, 1, result.n) +end +local function current(loader) + if loader.game then + return loader.game + end + local game = package.loaded["src.core.Game"] + return type(game) == "table" and game or nil +end +local function message(err) + local ok, text = pcall(tostring, err) + return ok and text or "unprintable error" +end +local function contains(stack, state) + if type(stack.states) ~= "table" then + return false + end + for _, value in ipairs(stack.states) do + if value == state then + return true + end + end + return false +end +local function remove(stack, state) + if type(stack.states) ~= "table" then + return + end + for i = #stack.states, 1, -1 do + if stack.states[i] == state then + table.remove(stack.states, i) + if Runtime.wants("screen.popped") then + Runtime.emit("screen.popped", { state = state }) + end + return + end + end +end +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", +} +local function ready(game) + if not (game and game.save) then + return nil, "no save" + end + local stack = game.stack + if not (stack and type(stack.states) == "table" and type(stack.top) == "function") then + return nil, "no overworld" + end + local ow = stack:top() + if not (ow and ow.isOverworld and ow.map and ow.player and ow.runner) then + return nil, "world is busy" + end + if + runs[game] + or game.overworld and game.overworld ~= ow + or game.linkSession + or game.linkNet and not game.linkNet.closed + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + if ow.player[key] then + return nil, "world is busy" + end + end + if + ow.runner:isRunning() + or #(ow.scriptMoves or {}) > 0 + or #(ow.pendingScripts or {}) > 0 + or #(ow.parallelRunners or {}) > 0 + or #(ow.parallelQueue or {}) > 0 + then + return nil, "world is busy" + end + return ow +end +local function observe(loader) + local installed = observers[loader] + if installed and installed[1] == loader.hooks then + return + end + -- Weak values too: the hook closure captures its loader, so retaining the + -- hooks here would otherwise keep every unloaded loader alive on LuaJIT. + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + -- Keep one observer per loader: retiring a hook while its chain dispatches + -- would shift the following mod's index. Per-run cleanup never edits it. + loader.hooks:wrap("input.step", function(nextFn, game, dt) + if next(runs) == nil then + return nextFn(game, dt) + end + local pending = {} + for _, token in pairs(runs) do + if token.loader == loader then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + local ok, live, why = engine(token.valid) + if not ok or not live then + token.finish(false, ok and why or "script context failed") + elseif token.waitingUI and not contains(token.stack, token.waitingUI) then + token.finish(false, "script UI was removed") + elseif token.shop then + token.shop.refresh() + elseif token.travel then + token.travel.tick() + end + end + return nextFn(game, dt) + end, math.huge, "engine:portable_scripts") +end +function Scripts.cancelAll(loader, modId, reason) + if loader and modId then + if contexts[loader] then + contexts[loader].owners[modId] = nil + end + elseif loader then + contexts[loader] = nil + else + for owner in pairs(contexts) do + contexts[owner] = nil + end + end + local wasDraining = draining + draining = true + local pending = {} + for _, token in pairs(runs) do + if (not loader or token.loader == loader) and (not modId or token.modId == modId) then + pending[#pending + 1] = token + end + end + for _, token in ipairs(pending) do + token.finish(false, reason or "script session ended") + end + draining = wasDraining +end +local function facade(loader, modId, native) + local context = contexts[loader] + if not context then + context = { owners = {} } + contexts[loader] = context + end + local lease = {} + context.owners[modId] = lease + local function licensed() + return not draining and contexts[loader] == context and context.owners[modId] == lease + end + -- Install before entry code can subscribe/queue from input.step. Sorting + -- a new observer into a live hook chain could replay its current wrapper. + observe(loader) + local world, active = {}, nil + local nativeGame, nativeWorld + local function battleWorld(game) + if nativeGame ~= game then + nativeWorld = require("src.world.WorldAPI").new(game, modId) + nativeGame = game + end + return nativeWorld + end + local function ownedBattle(game, ow) + if + active + and runs[game] == active + and not active.done + and active.world == ow + and ow.runner == active.runner + and game.stack:top() == ow + and active.valid() + then + return true + end + return nil, "portable script does not own this world" + end + local function provider() + return current(loader) + end + local startWild = require("src.mods.Api3WildBattle").new( + loader, + modId, + provider, + battleWorld, + licensed, + require, + ownedBattle + ) + local startTrainer = require("src.mods.Api3WildBattle").new( + loader, + modId, + provider, + battleWorld, + licensed, + require, + ownedBattle, + Battles.nativeTrainer + ) + local function queue(rows, options) + if not licensed() then + return nil, "script facade is retired" + end + local program, why = Program.compile( + rows, + { owner = modId, generation = GameVersion.generation(), version = GameVersion.get() } + ) + if not program then + return nil, why + end + if options ~= nil then + if type(options) ~= "table" or getmetatable(options) ~= nil then + return nil, "options must be a plain table" + end + for key, value in pairs(options) do + if key ~= "onDone" or type(value) ~= "function" then + return nil, "only onDone callback is supported" + end + end + end + local onDone = options and options.onDone + local game = current(loader) + local ow + ow, why = ready(game) + if not ow then + return nil, why + end + local function flag(name, value, write) + return Flags.call(game, modId, GameVersion.generation(), name, value, write, licensed, function() + return ow + end) + end + -- Check stores for every row, including unreachable branches, before a prefix runs. + for i, row in ipairs(program) do + if + row[1] == "set_flag" + or row[1] == "clear_flag" + or row[1] == "check_flag" + or row[1] == "wait_flag" + then + local value, reason = flag(row[2]) + if value == nil then + return nil, "row " .. i .. ": " .. reason + end + end + end + local fields, fieldsReason = Fields.prepare(game, modId, program) + if not fields then + return nil, fieldsReason + end + local dialogueAudio, dialogueReason = DialogueAudio.prepare(loader, game, program) + if not dialogueAudio then + return nil, dialogueReason + end + local audio, audioReason = Audio.prepare(game, program) + if not audio then + return nil, audioReason + end + local state, reason = State.prepare(loader, game, ow, program) + if not state then + return nil, reason + end + local gift, giftReason = Gifts.prepare(loader, game, ow, program) + if not gift then + return nil, giftReason + end + local battleOK, battleReason = Battles.prepare(loader, game, ow, program, flag) + if not battleOK then + return nil, battleReason + end + local travel, travelReason = Travel.prepare(game, ow, modId, program, loader) + if not travel then + return nil, travelReason + end + local edit, editReason = WorldEdits.prepare(game, ow, modId, program, true) + if not edit then + return nil, editReason + end + local movement, moveReason = Movement.prepare(ow, program, true) + if not movement then + return nil, moveReason + end + local Runner = require("src.script.ScriptRunner") + local runner = Runner.new(game, ow) + local token = { + loader = loader, + modId = modId, + game = game, + world = ow, + runner = runner, + original = ow.runner, + stack = game.stack, + states = game.stack.states, + mapId = ow.map.id, + x = ow.player.cellX, + y = ow.player.cellY, + save = game.save, + data = game.data, + map = ow.map, + player = ow.player, + version = GameVersion.get(), + ui = {}, + ownedUI = {}, + steps = 0, + } + function token.valid() + if token.done then + return nil, "script is finished" + end + if + not licensed() + or current(loader) ~= game + or game.save ~= token.save + or game.data ~= token.data + or game.stack ~= token.stack + or game.stack.states ~= token.states + or not contains(token.stack, ow) + or game.overworld and game.overworld ~= ow + or not token.travel and not token.mutating and ow.map ~= token.map + or not token.travel and ow.map.id ~= token.mapId + or ow.player ~= token.player + or not token.travel and (ow.player.cellX ~= token.x or ow.player.cellY ~= token.y) and not (token.move and token.move.position()) + or not token.travel and not token.mutating and ow.runner ~= runner + or token.move and not token.move.live() + or token.shop and not token.shop.valid() + or GameVersion.get() ~= token.version + or Runtime.hooks ~= loader.hooks + or Runtime.events ~= loader.events + or game.linkSession + or game.linkNet and not game.linkNet.closed + or not token.travel and ow.transitioning + or token.travel and not token.travel.valid() + then + return nil, "script session changed" + end + return true + end + function token.finish(ok, reason) + if token.done then + return + end + token.done = true + if token.travel then + token.travel.retired = true + end + if token.move then + token.move.cancel() + end + runner.co, runner.waitingFrames, runner.waitingCheck = nil, nil, nil + -- Remove only this run's exact native screens, preserving foreign + -- overlays and a replacement world's runner/locks. + for i = #token.ui, 1, -1 do + remove(token.stack, token.ui[i]) + end + if ow.runner == runner then + ow.runner = token.original + end + if runs[game] == token then + runs[game] = nil + end + if active == token then + active = nil + end + if Runtime.wants("script.ended") then + Runtime.emit("script.ended", { ctx = runner.ctx, completed = ok }) + end + if onDone then + local called, err = pcall(onDone, ok, reason) + if not called then + Runtime.reportError(modId, "portable script completion failed: " .. message(err)) + end + end + end + local function check() + local valid, reason = token.valid() + if not valid then + token.finish(false, reason) + end + return valid + end + local function mutate(operation) + if not check() then + return nil, "script session changed" + end + token.mutating = true + local called, value, reason = engine(operation) + if + not token.done + and current(loader) == game + and game.save == token.save + and game.data == token.data + and game.stack == token.stack + and ow.player == token.player + then + if + called + and value + and ow.map ~= token.map + and ow.map + and ow.map.id == token.mapId + and ow.runner ~= runner + then + token.original = ow.runner + ow.runner = runner + end + if called and value and ow.map and ow.map.id == token.mapId then + token.map = ow.map + end + end + token.mutating = nil + if not check() then + return nil, "script session changed" + end + if not called then + return nil, message(value) + end + return value, reason + end + local discover + local function snapshot() + local before = {} + for _, state in ipairs(game.stack.states) do + before[state] = true + end + return before + end + local function owned(state, step) + if token.ownedUI[state] then + return state + end + token.ownedUI[state] = true + token.ui[#token.ui + 1] = state + local update = state.update + function state:update(...) + if not check() or token.step ~= step or game.stack:top() ~= self then + return + end + local before = token.nativeStep == step and snapshot() + local previous, phase, priorBefore = token.executingUI, token.uiPhase, token.uiBefore + if token.shop and token.shop.step == step then + token.executingUI, token.uiPhase, token.uiBefore = self, self.asking, before + end + local ok, err = engine(update, self, ...) + if before then + discover(before, step) + end + if token.shop then + token.shop.refresh() + end + token.executingUI, token.uiPhase, token.uiBefore = previous, phase, priorBefore + if not ok then + token.finish(false, "native UI failed: " .. message(err)) + end + end + if token.shop then + token.shop.attach(state) + end + return state + end + discover = function(before, step) + if token.done or token.step ~= step or token.travel or token.battling then + return + end + for _, state in ipairs(game.stack.states) do + if not before[state] then + owned(state, step) + if token.nativeStep == step and not token.shop then + token.waitingUI = state + end + end + end + end + local function nativeGift(ctx, row) + local step = token.step + local bound = setmetatable({ + resume = function(_, ...) + if token.step == step and check() then + runner:resume(...) + end + end, + }, { __index = runner, __newindex = runner }) + local revision = 0 + local proxy = setmetatable({ + portableCallback = function(callback) + local expected, fired = revision, false + return function(...) + if fired or expected ~= revision or token.step ~= step or not check() then + return + end + fired = true + revision = revision + 1 + return callback(...) + end + end, + runner = bound, + portableAlive = function() + return token.step == step and check() + end, + }, { __index = ctx, __newindex = ctx }) + token.nativeStep = step + local result = gift(proxy, row) + token.nativeStep = nil + token.waitingUI = nil + return result + end + local function dialog(ctx, text, asking, step) + local TextBox = require("src.render.TextBox") + local box, answered + local function answer(value) + if answered or token.step ~= step or not check() then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck = value + runner:resume() + end + local opts = dialogueAudio.options(ctx, asking, function() + return not answered + and token.step == step + and check() + and box ~= nil + and contains(token.stack, box) + and token.stack:top() == box + end) + box = TextBox.new(game, TextBox.fromProse(text), function() + if not asking and not contains(token.stack, box) then + answer(nil) + end + end, opts) + if asking then + local update, pushed, selected = box.update, false, nil + box.choice = function(yes) + if token.step ~= step or not check() or not selected or contains(token.stack, selected) then + return + end + remove(token.stack, box) + answer(yes) + end + function box:update(dt) + if self.done and (self.holdFrames or 0) <= 0 and not pushed and not self.auto then + pushed, self.choicePushed = true, true + local Choice = require("src.ui.ChoiceBox") + local geometry = self.choiceBox or {} + selected = owned( + Choice.new( + game, + self.choice, + { anchor = "bottom", tx = geometry.tx, ty = geometry.ty, tw = geometry.tw, th = geometry.th } + ), + step + ) + token.waitingUI = selected + game.stack:push(selected) + else + update(self, dt) + end + end + end + owned(box, step) + token.waitingUI = box + game.stack:push(box) + runner:yield() + end + local function choice(ctx, labels, opts, step) + local Menu = require("src.ui.Menu") + local items, menu, answered = {}, nil, false + local function pick(index) + if answered or token.step ~= step or not check() or contains(token.stack, menu) then + return + end + answered = true + token.waitingUI = nil + ctx.lastCheck, ctx.lastChoice = index == 1, { index = index, label = labels[index] } + runner:resume() + end + for i, label in ipairs(labels) do + items[i] = { + label = label, + onSelect = function() + pick(i) + end, + } + end + menu = owned( + Menu.new(game, items, { + onCancel = function() + pick(opts.cancel) + end, + }), + step + ) + menu.index = opts.default + token.waitingUI = menu + game.stack:push(menu) + runner:yield() + end + local function shop(ctx, row) + local stock, validate = Shops.plan(loader, game, ow, row) + if not stock then + error(validate, 0) + end + local step, complete, root = token.step, false, nil + local service = { step = step } + function service.valid() + if not complete and root and not contains(token.stack, root) and token.executingUI ~= root then + return false + end + return validate() + end + local invoke + service.attach = require("src.mods.ScriptShopUI").new({ + alive = function(state) + return not complete + and token.shop == service + and token.step == step + and check() + and ( + game.stack:top() == state + or token.executingUI == state + or token.executingUI + and token.ownedUI[token.executingUI] + and (contains(token.stack, state) or token.uiBefore and token.uiBefore[state]) + ) + end, + phase = function(state) + return token.executingUI == state and token.uiPhase or nil + end, + invoke = function(state, fn, ...) + return invoke(state, fn, ...) + end, + }) + function service.refresh() + if token.done then + return + end + if not token.executingUI then + for i = #token.ui, 1, -1 do + if not contains(token.stack, token.ui[i]) then + token.ownedUI[token.ui[i]] = nil + table.remove(token.ui, i) + end + end + end + for _, state in ipairs(token.ui) do + if contains(token.stack, state) then + service.attach(state) + end + end + end + invoke = function(state, fn, ...) + local previous, phase, priorBefore = token.executingUI, token.uiPhase, token.uiBefore + local before = snapshot() + token.executingUI, token.uiPhase, token.uiBefore = state, state.asking, before + local result = pack(engine(fn, ...)) + discover(before, step) + service.refresh() + token.executingUI, token.uiPhase, token.uiBefore = previous, phase, priorBefore + if not result[1] then + token.finish(false, "native shop callback failed: " .. message(result[2])) + return + end + return unpack(result, 2, result.n) + end + token.shop, token.nativeStep = service, step + local function done() + if not root or complete or token.step ~= step or not check() or contains(token.stack, root) then + return + end + complete = true + end + local called, state = engine(require("src.ui.Screens").push, game, "ShopMenu", stock, done) + if not called then + token.shop, token.nativeStep = nil, nil + error(state, 0) + end + root = state + if token.done then + remove(token.stack, root) + return + end + owned(root, step) + if not check() then + return + end + runner.waitingCheck = function() + return complete + end + runner:yield() + token.shop, token.nativeStep = nil, nil + end + local function battle(ctx, row) + local verb = row[1] + if verb == "check_battle_result" then + ctx.lastCheck = false + for i = 2, #row do + if row[i] == ctx.lastBattleResult then + ctx.lastCheck = true + end + end + return + end + local species, level = row[2], row[3] + if verb == "start_battle" then + species, level = row[3], row[4] + end + local step, complete, result = token.step, false, nil + token.battling = true + local start = verb == "start_battle" and row[2] == "trainer" and startTrainer or startWild + local ok, why = start(species, level, function(outcome, failure) + if complete or token.step ~= step or not check() then + return + end + if not Battles.outcomes[outcome] then + token.finish(false, failure or "invalid native battle result") + return + end + complete, result = true, outcome + end) + if not ok then + token.battling = nil + error(why, 0) + end + if not check() then + return + end + if not complete then + runner.waitingCheck = function() + return complete + end + runner:yield() + end + token.battling = nil + if not check() then + return + end + ctx.lastBattleResult, ctx.lastCheck = result, result == "win" + if verb == "static_battle" and result ~= "lose" then + if row[4] ~= nil then + local value, reason = flag(row[4], true, true) + if not value then + error(reason, 0) + end + end + if not check() then + return + end + local npc = movement.npc + local def = npc and npc.def + local ref = def and (def.name or def.localId or def.index or def.id) + if ref then + local value, reason = require("src.mods.ScriptWorldEdits").prepare( + game, + ow, + modId, + { { "hide_object", ow.map.id, ref } } + ) + if not value then + error(reason, 0) + end + local accepted, detail = value({ "hide_object", ow.map.id, ref }, mutate) + if not check() then + return + end + if not accepted then + error(detail, 0) + end + end + end + end + local function warp(ctx, row) + local plan, reason = travel(row) + if not plan then + error(reason, 0) + end + if row[1] == "remember_outdoor" then + token.save.lastOutdoor = { id = plan.mapId, x = plan.x, y = plan.y } + if GameVersion.generation() == 3 then + token.save.escapeWarp = { map = plan.mapId, warpId = 255, x = plan.x, y = plan.y } + elseif ow.rememberOutdoor then + ow:rememberOutdoor(plan.mapId, plan.x, plan.y) + else + ow.lastOutdoor = token.save.lastOutdoor + end + return + end + local origin, step = ow.map, token.step + local accepted, pending, fired, complete = nil, false, false, false + local lease = { ui = {} } + local remaining = 3600 + function lease.tick() + local top = token.stack:top() + if top == ow or lease.ui[top] then + remaining = remaining - 1 + if remaining <= 0 then + token.finish(false, "native warp timed out") + end + end + end + function lease.valid() + if lease.retired then + return false + end + if + ow.map == origin + and ow.player.cellX == token.x + and ow.player.cellY == token.y + and ow.runner == runner + then + return true + end + return ow.map and ow.map.id == plan.mapId and ow.player.cellX == plan.x and ow.player.cellY == plan.y + end + local function done() + if fired or lease.retired then + return + end + if accepted == nil then + pending = true + return + end + fired = true + if not accepted or token.step ~= step or not check() then + return + end + if + not ow.map + or ow.map.id ~= plan.mapId + or ow.player.cellX ~= plan.x + or ow.player.cellY ~= plan.y + or ow.transitioning + then + token.finish(false, "native warp did not arrive") + return + end + if ow.runner ~= runner then + if not ow.runner or ow.runner:isRunning() then + token.finish(false, "destination script is busy") + return + end + token.original = ow.runner + ow.runner = runner + end + token.map, token.mapId, token.x, token.y = ow.map, plan.mapId, plan.x, plan.y + ctx.mapId, ctx.npc = plan.mapId, nil + movement.forgetNpc() + movement.npc = nil + complete = true + end + token.travel = lease + local statesBefore = snapshot() + local called, value, why = + engine(ow.startWarpTo, ow, plan.mapId, plan.x, plan.y, plan.facing, done, { via = "warp" }) + for _, state in ipairs(game.stack.states) do + if not statesBefore[state] then + lease.ui[state] = true + end + end + accepted = called and value ~= false and not (value == nil and why ~= nil) + if not accepted then + lease.retired = true + token.travel = nil + error(called and (why or "native warp refused") or message(value), 0) + end + if pending then + done() + end + if not check() then + return + end + if not complete then + runner.waitingCheck = function() + return complete + end + runner:yield() + end + lease.retired = true + if token.travel == lease then + token.travel = nil + end + end + local function move(ctx, row) + local actor = movement.target(row) + if not actor then + error("native movement target unavailable", 0) + end + local verb, dirs = row[1], {} + if verb == "move_player" or verb == "move_npc" then + local at = verb == "move_player" and 2 or 3 + for i = 1, row[at + 1] or 1 do + dirs[i] = row[at] + end + elseif verb == "move_npc_to" then + local reason + dirs, reason = Movement.path(ow, actor, row[3], row[4]) + if not dirs then + error(reason, 0) + end + elseif verb == "place_npc" then + if actor.def and actor.def.index then + require("src.script.Commands").place_npc(ctx, actor.def.index, row[3], row[4], row[5]) + return + end + actor.cellX, actor.cellY, actor.px, actor.py = row[3], row[4], row[3] * 16, row[4] * 16 + actor.moving, actor.marching, actor.progress = false, false, 0 + actor.targetX, actor.targetY = nil, nil + if row[5] then + actor.facing = row[5] + end + return + else + local dir = verb == "face_object" and row[3] or row[2] + if verb == "face_npc" or verb == "face_player" then + local other = verb == "face_npc" and movement.npc or ow.player + local dx, dy = other.cellX - actor.cellX, other.cellY - actor.cellY + dir = math.abs(dx) > math.abs(dy) and (dx > 0 and "right" or "left") or (dy > 0 and "down" or "up") + end + if actor.scriptFace then + actor:scriptFace(dir) + else + actor.facing = dir + end + if verb == "face_object" and row[4] and row[4].hold then + actor.timer = row[4].hold + end + return + end + for _, dir in ipairs(dirs) do + if not check() then + return + end + if actor ~= ow.player and movement.target(row) ~= actor then + error("movement target changed", 0) + end + local step, completed, accepted, fired, pending = token.step, false, nil, false, false + local sx, sy = actor.cellX, actor.cellY + local delta = Movement.directions[dir] + local tx, ty = sx + delta[1], sy + delta[2] + local entries, before = {}, {} + for _, entry in ipairs(ow.scriptMoves or {}) do + before[entry] = true + end + local lease = {} + function lease.live() + return movement.target(row) == actor + and (actor.cellX == sx and actor.cellY == sy or actor.cellX == tx and actor.cellY == ty) + end + function lease.position() + if actor ~= ow.player then + return false + end + local x, y = actor.cellX, actor.cellY + return (x == sx and y == sy or x == tx and y == ty) + end + function lease.cancel() + local removed = false + for i = #(ow.scriptMoves or {}), 1, -1 do + if entries[ow.scriptMoves[i]] then + table.remove(ow.scriptMoves, i) + removed = true + end + end + local foreign = false + for _, entry in ipairs(ow.scriptMoves or {}) do + if entry.entity == actor then + foreign = true + end + end + if removed and not foreign then + actor.moving, actor.marching = false, false + actor.targetX, actor.targetY = nil, nil + actor.px, actor.py = actor.cellX * 16, actor.cellY * 16 + end + if token.move == lease then + token.move = nil + end + end + local function done() + if fired then + return + end + if accepted == nil then + pending = true + return + end + fired = true + if not accepted or token.step ~= step or not check() then + return + end + if actor.cellX ~= tx or actor.cellY ~= ty then + token.finish(false, "native movement did not arrive") + return + end + completed = true + if actor == ow.player then + token.x, token.y = tx, ty + end + lease.cancel() + -- Poll completion on the next foreground logic frame. A callback + -- beneath a foreign overlay cannot resume later script effects. + end + token.move = lease + local called, value, reason = engine(ow.scriptMove, ow, actor, dir, 1, done) + for _, entry in ipairs(ow.scriptMoves or {}) do + if not before[entry] and entry.entity == actor then + entries[entry] = true + end + end + if token.done then + lease.cancel() + return + end + accepted = called and value ~= false and not (value == nil and reason ~= nil) + if not accepted then + lease.cancel() + error(called and (reason or "native movement refused") or message(value), 0) + end + if pending then + done() + end + if not check() then + return + end + if not completed then + local remaining = 3600 + runner.waitingCheck = function() + remaining = remaining - 1 + if remaining <= 0 then + token.finish(false, "native movement timed out") + end + return completed + end + runner:yield() + end + end + end + local function waitAudio(poll, ceiling, continueOnTimeout) + if poll() then + return + end + local remaining = ceiling + runner.waitingCheck = function() + if not check() then + return false + end + if poll() then + return true + end + remaining = remaining - 1 + if remaining <= 0 then + if continueOnTimeout then + return true + end + token.finish(false, "native audio timed out") + end + return false + end + runner:yield() + end + function runner:exec(compiled, ctx) + local pc, slice = 1, 0 + while pc <= #compiled do + if not check() then + return + end + if slice >= Program.SLICE then + self.waitingFrames = 1 + self:yield() + slice = 0 + if not check() then + return + end + end + token.steps, slice = token.steps + 1, slice + 1 + if token.steps > Program.MAX_STEPS then + error("portable script instruction budget exceeded", 0) + end + local row = compiled[pc] + local verb = row[1] + token.step = {} + self.lastRow = verb .. " (portable row " .. pc .. ")" + ctx.script, ctx.pc = compiled, pc + if Fields.ARITY[verb] then + fields(ctx, row) + elseif DialogueAudio.ARITY[verb] then + dialogueAudio.arm(ctx, row) + elseif Audio.ARITY[verb] then + audio(ctx, row, waitAudio, check) + elseif Shops.ARITY[verb] then + shop(ctx, row) + elseif Battles.ARITY[verb] then + battle(ctx, row) + elseif Travel.ARITY[verb] then + warp(ctx, row) + elseif Movement.ARITY[verb] then + move(ctx, row) + elseif WorldEdits.ARITY[verb] then + local value, reason = edit(row, mutate) + if not check() then + return + end + if not value then + error(reason, 0) + end + elseif Gifts.ARITY[verb] then + if nativeGift(ctx, row) == math.huge then + return + end + elseif State.ARITY[verb] then + state(ctx, row) + elseif verb == "set_flag" or verb == "clear_flag" then + local ok, reason = flag(row[2], verb == "set_flag", true) + if not ok then + error(reason, 0) + end + elseif verb == "check_flag" then + local value, reason = flag(row[2]) + if value == nil then + error(reason, 0) + end + ctx.lastCheck = value + elseif verb == "wait_flag" then + local left = row[3] + self.waitingCheck = function() + if not check() then + return false + end + local value, reason = flag(row[2]) + if value == nil then + error(reason, 0) + end + if value then + return true, true + end + if left then + left = left - 1 + if left <= 0 then + return true, false + end + end + return false + end + ctx.lastCheck = self:yield() + elseif verb == "wait" then + self.waitingFrames = row[2] + self:yield() + elseif verb == "show_text" or verb == "ask" then + dialog(ctx, row[2], verb == "ask", token.step) + elseif verb == "choice" then + choice(ctx, row[2], row[3], token.step) + elseif verb == "end" then + return + elseif + verb == "jump" + or verb == "jump_if_true" and ctx.lastCheck + or verb == "jump_if_false" and not ctx.lastCheck + then + pc = row[2] - 1 + end + pc = pc + 1 + end + end + local update = Runner.update + function runner:resume(...) + if not check() or not self.co or coroutine.status(self.co) == "running" then + return + end + local before, step = snapshot(), token.step + token.waitingUI = nil + local ok, err = coroutine.resume(self.co, ...) + discover(before, token.step or step) + if not ok then + Runtime.reportError(modId, "portable script failed: " .. message(err)) + token.finish(false, "script failed: " .. message(err)) + elseif not token.done and coroutine.status(self.co) == "dead" then + token.finish(true) + end + end + function runner:update(...) + if not check() or game.stack:top() ~= ow then + return + end + local ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "script update failed: " .. message(err)) + end + end + function runner:run() + error("portable script owns this runner", 0) + end + -- Session/argument checks above do not mutate gameplay. Admission starts + -- here; subsequent native failures complete through onDone(false, reason). + active, runs[game], ow.runner = token, token, runner + runner.ctx = runner:makeContext({ + source = { modId = modId, mapId = ow.map.id, hook = "queueScript", strict = true }, + }) + runner.co = coroutine.create(function() + runner:exec(program, runner.ctx) + end) + if Runtime.wants("script.started") then + Runtime.emit("script.started", { ctx = runner.ctx }) + end + local ok, err = engine(runner.resume, runner) + if not ok then + token.finish(false, "script start failed: " .. message(err)) + end + return true + end + function world:queueScript(rows, options) + local ok, result, reason = engine(queue, rows, options) + if not ok then + return nil, "portable script validation failed" + end + return result, reason + end + function world:cancelScript() + if not active or active.done then + return false, "no portable script" + end + active.finish(false, "script cancelled") + return true + end + return setmetatable(world, { + __index = function(_, key) + local value = native() + local member = value and value[key] + if type(member) == "function" then + return function(_, ...) + return member(value, ...) + end + end + return member + end, + }) +end +function Scripts.wrap(mt, api, loader, manifest) + local format = manifest.script_format or "native" + api.scriptFormat = format + if format ~= "unified" then + return mt + end + local index = mt.__index + local world = facade(loader, manifest.id, function() + return index(api, "world") + end) + mt.__index = function(self, key) + if key == "world" then + return world + end + return index(self, key) + end + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "scripts" then + if Program.supports(name) then + return true + end + return false, "unsupported portable script command" + end + if surface == "world" and (name == "queueScript" or name == "cancelScript") then + return true + end + return supports(self, surface, name) + end + end + return mt +end +return Scripts diff --git a/src/mods/Registry.lua b/src/mods/Registry.lua index 9719673ef..cf200f844 100644 --- a/src/mods/Registry.lua +++ b/src/mods/Registry.lua @@ -28,11 +28,16 @@ function Registry.new(name, spec) }, Registry) end +local function validId(self, id) + return type(id) == "string" and id ~= "" + or self.numericIds and type(id) == "number" and id >= 1 and id < math.huge and id % 1 == 0 +end + local function append(self, id, op, value, owner) if self.frozen then error(self.name .. ": content is frozen after load") end - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") local list = self.ops[id] if not list then list = {} @@ -94,6 +99,14 @@ local function fold(self, value, opList, id) if type(entry.value) == "table" then value = Merge.deepMerge(Merge.deepCopy(value == nil and {} or value), entry.value, self.spec.semantics) + -- Adapter-produced list patches include empty replacements. Native + -- patch semantics keep {} as a dictionary/no-op, so this internal + -- path list explicitly distinguishes clearing a list from omitting it. + for _, path in ipairs(entry.replaceLists or {}) do + local into, from = value, entry.value + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = Merge.deepCopy(from[path[#path]]) + end else value = entry.value end @@ -120,12 +133,14 @@ function Registry:override(id, value, owner) return append(self, id, "override", value, owner) end -function Registry:patch(id, partial, owner) +function Registry:patch(id, partial, owner, replaceLists) assert(partial ~= nil, self.name .. " patch value is required for " .. tostring(id)) if self.spec.semantics == "compose" then error(self.name .. ": patch is not supported on compose registries") end - return append(self, id, "patch", partial, owner) + local result = append(self, id, "patch", partial, owner) + if replaceLists then self.ops[id][#self.ops[id]].replaceLists = Merge.deepCopy(replaceLists) end + return result end -- tombstone: consumers treat the id as absent after the merge @@ -196,7 +211,7 @@ end -- Works after the loader has frozen content, because it appends nothing -- the -- ops are untouched and only the reading of them changes. function Registry:preferOwner(id, owner) - assert(type(id) == "string" and id ~= "", self.name .. " id is required") + assert(validId(self, id), self.name .. " id is required") self.prefer = self.prefer or {} self.prefer[id] = owner self.cache[id] = nil diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9014835cc..d9e994c1a 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -268,13 +268,14 @@ end -- ------- cross-reference pass +local NUMERIC_REFS = { pokemon = true, moves = true, items = true, trainers = true } local collectRefs collectRefs = function(t, value, path, out) if value == nil or value == Merge.DELETE then return end local kind = t.kind if kind == "opt" then return collectRefs(t.inner, value, path, out) end if kind == "id" then - if type(value) == "string" then + if type(value) == "string" or NUMERIC_REFS[t.registry] and type(value) == "number" and value > 0 then out[#out + 1] = { registry = t.registry, ref = value, path = path } end return @@ -353,7 +354,7 @@ function Schemas.crossValidate(loader, data) end end for name, registry in pairs(loader.content) do - local spec = registry.spec + local spec = registry.referenceSpec or registry.spec for id in pairs(registry.ops) do local value = registry:get(id) if value ~= nil and registry.owners[id] ~= Schemas.ENGINE then diff --git a/src/mods/ScriptAudio.lua b/src/mods/ScriptAudio.lua new file mode 100644 index 000000000..b02767bfd --- /dev/null +++ b/src/mods/ScriptAudio.lua @@ -0,0 +1,152 @@ +-- Portable native audio cues, with pure data lookup and bounded waits. +local Version = require("src.core.GameVersion") +local A = { + ARITY = { + play_sound = 2, + wait_sound = 1, + play_music = 3, + stop_music = 1, + fade_music = 2, + play_once = 2, + play_default_music = 1, + }, +} +local function integer(v, max) + return type(v) == "number" and v == v and v >= 0 and v <= max and v % 1 == 0 +end +local function id(v) + return type(v) == "string" and #v > 0 and #v <= 256 and not v:find("%c") or integer(v, 65535) +end +function A.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable audio unavailable for this generation" + end + local minimum = (verb == "play_music" and 2 or verb == "fade_music" and 1) or A.ARITY[verb] + if #row < minimum or #row > A.ARITY[verb] then + return nil, "invalid command arity" + end + local out = { row[1], row[2] } + if verb == "fade_music" then + if row[2] ~= nil and not integer(row[2], 255) then + return nil, "invalid music fade" + end + elseif verb == "play_sound" or verb == "play_music" or verb == "play_once" then + if not id(row[2]) then + return nil, "invalid audio id" + end + end + if verb == "play_music" and row[3] ~= nil then + if type(row[3]) ~= "table" or getmetatable(row[3]) ~= nil then + return nil, "invalid music options" + end + out[3] = {} + for k, v in pairs(row[3]) do + if + not ( + k == "keep" and type(v) == "boolean" + or k == "reason" and type(v) == "string" and #v <= 256 and not v:find("%c") + ) + then + return nil, "invalid music option" + end + out[3][k] = v + end + end + return out +end +function A.resolve(game, row) + local verb, ref = row[1], row[2] + if Version.generation() == 3 and verb == "play_music" and ref == 0 then + return { stop = true } + end + local audio = game.data.audio or {} + local sound = verb == "play_sound" + local rows = sound and audio.sfx or audio.songs + if rows and (type(rows[ref]) == "table" or type(rows[ref]) == "string") then + return { key = ref, sound = sound } + end + if Version.generation() == 3 and (sound or verb == "play_music" or verb == "play_once") then + if verb == "play_once" and ref == "Get_Item1" then + local def = audio.sfx and audio.sfx.Get_Item1 + if type(def) == "table" then + ref = def.song or def.m4a or ref + end + end + local key, def = require("src.core.Sound").sequenceKey(game.data, ref) + if key then + return { key = key, sound = sound, sequence = sound } + end + if def then + return nil, def + end + end + return nil, "native audio unavailable: " .. tostring(row[2]) +end +function A.prepare(game, rows) + for i, row in ipairs(rows) do + if row[1] == "play_sound" or row[1] == "play_music" or row[1] == "play_once" then + local plan, why = A.resolve(game, row) + if not plan then + return nil, "row " .. i .. ": " .. why + end + end + end + return function(ctx, row, wait, alive) + local verb = row[1] + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + if verb == "stop_music" then + Music.stop() + elseif verb == "fade_music" then + Music.fadeOut(row[2] or 10) + elseif verb == "play_default_music" then + local ow = ctx.overworld + Music.playMap(game.data, ow.map.id, game.save.onBike, ow.player.surfing) + elseif verb == "wait_sound" then + local source = ctx.lastSfxSrc + ctx.lastSfxSrc = nil + local function busy() + if Sound.anyPlaying("sfx") then + return true + end + if not source then + return false + end + local ok, value = pcall(function() + return source:isPlaying() + end) + return ok and value == true + end + if busy() then + wait(function() + return not busy() + end, 600, true) + end + else + local plan, why = A.resolve(game, row) + if not plan then + error(why, 0) + end + if verb == "play_sound" then + if plan.sequence then + ctx.lastSfxSrc = Sound.playNamedEffect(game.data, plan.key) + else + ctx.lastSfxSrc = Sound.play(game.data, plan.key) + end + elseif verb == "play_music" then + if plan.stop then + Music.stop() + return + end + Music.play(game.data, plan.key, nil, row[3]) + if alive() and row[3] and row[3].keep then + ctx.overworld.keepMusicOnce = true + end + elseif Music.playOnce(game.data, plan.key) then + wait(function() + return Music.current() ~= plan.key or not Music.oneShotPlaying() + end, 3600, false) + end + end + end +end +return A diff --git a/src/mods/ScriptBattles.lua b/src/mods/ScriptBattles.lua new file mode 100644 index 000000000..2dc34dcfa --- /dev/null +++ b/src/mods/ScriptBattles.lua @@ -0,0 +1,190 @@ +-- Portable wild/static battle requests; native battle cleanup owns its lifetime. +local Ids = require("src.mods.UnifiedIds") +local Version = require("src.core.GameVersion") +local B = { ARITY = { start_battle = 4, static_battle = 4, check_battle_result = 5 } } +local outcomes = { win = true, lose = true, run = true, caught = true } +local function integer(n, lo, hi) + return type(n) == "number" and n == n and n >= lo and n <= hi and n % 1 == 0 +end +function B.trainer(game, reference, member) + local gen = Version.generation() + local rows = game.data.trainers or {} + local key, found + if type(reference) == "number" then + if gen == 3 then + key = game.data.constants + and game.data.constants.trainerOrder + and game.data.constants.trainerOrder[reference] + else + for id, row in pairs(rows) do + if type(row) == "table" and row.index == reference then + local candidateKey = row.id and rows[row.id] and row.id or id + local candidate = rows[candidateKey] + if candidate.index ~= reference then + return nil, "inconsistent trainer alias" + end + if found and found ~= candidate then + return nil, "ambiguous trainer class" + end + key, found = candidateKey, candidate + end + end + end + else + key = reference + end + local row = key and rows[key] + local party = member or 1 + if gen == 3 and party ~= 1 then + return nil, "Gen3 trainer has one party" + end + local roster = row and row.parties and row.parties[party] + if type(roster) ~= "table" or #roster == 0 or #roster > 6 then + return nil, "trainer content unavailable: " .. tostring(reference) + end + for _, mon in ipairs(roster) do + if + type(mon) ~= "table" + or not integer(mon.level, 1, 100) + or not (game.data.pokemon and game.data.pokemon[mon.species]) + then + return nil, "invalid native trainer party" + end + end + local nativeIndex = row and row.index + if gen == 3 then + local order = game.data.constants and game.data.constants.trainerOrder or {} + nativeIndex = nil + for index, id in pairs(order) do + if id == key then + if nativeIndex and nativeIndex ~= index then + return nil, "ambiguous native trainer id" + end + nativeIndex = index + end + end + local base = game.data.constants and game.data.constants.gen3TrainerFlagBase + if + not integer(nativeIndex, 1, 65535) + or not integer(base, 0, 65535 - nativeIndex) + or game.save.flags ~= nil and type(game.save.flags) ~= "table" + then + return nil, "native trainer flags unavailable" + end + end + return { key = key, member = party, index = nativeIndex } +end +B.nativeTrainer = { + resolve = B.trainer, + construct = function(game, plan) + return require("src.battle.BattleState").newTrainer(game, plan.key, plan.member) + end, + cleanup = function(game, world, plan, outcome) + if Version.generation() == 3 and outcome == "win" then + local ctx = { game = game, save = game.save, overworld = world } + local commands = require("src.script.Gen3Commands") + commands.markTrainerBeaten(ctx, plan.index) + commands.registerTrainerInMatchCall(ctx, plan.index) + end + end, +} +function B.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable battles unavailable for this generation" + end + if verb == "check_battle_result" then + if #row < 2 or #row > 5 then + return nil, "invalid battle result list" + end + local out = { verb } + for i = 2, #row do + if not outcomes[row[i]] then + return nil, "invalid battle result" + end + out[i] = row[i] + end + return out + end + local species, level = row[2], row[3] + if verb == "start_battle" then + if row[2] == "trainer" then + if + #row < 3 + or #row > 4 + or not (type(row[3]) == "string" and #row[3] > 0 and #row[3] <= 256 and not row[3]:find("%c") or integer( + row[3], + 0, + 65535 + )) + or row[4] ~= nil and not integer(row[4], 1, 255) + then + return nil, "invalid script trainer" + end + return { row[1], row[2], row[3], row[4] } + end + if #row ~= 4 or row[2] ~= "wild" then + return nil, "invalid script battle" + end + species, level = row[3], row[4] + elseif #row < 3 or #row > 4 then + return nil, "invalid command arity" + end + if type(species) ~= "string" or #species == 0 or #species > 256 or species:find("%c") then + return nil, "invalid species id" + end + if type(level) ~= "number" or level ~= level or level < 1 or level > 100 or level % 1 ~= 0 then + return nil, "invalid battle level" + end + return { row[1], row[2], row[3], row[4] } +end +function B.prepare(loader, game, world, rows, flag) + local ids = Ids.new(loader) + local talking = world.talkNpc or world.runner and world.runner.ctx and world.runner.ctx.npc + for i, row in ipairs(rows) do + local verb = row[1] + if verb == "warp" then + talking = nil + end + if verb == "start_battle" or verb == "static_battle" then + if verb == "start_battle" and row[2] == "trainer" then + local plan, why = B.trainer(game, row[3], row[4]) + if not plan then + return nil, "row " .. i .. ": " .. why + end + else + local species = verb == "start_battle" and row[3] or row[2] + local group, why = ids:resolve("pokemon", species) + if not group or not (game.data.pokemon and game.data.pokemon[group.key]) then + return nil, "row " .. i .. ": " .. (why or "unknown species") + end + end + if type(world.pushBattle) ~= "function" or type(world.afterBattle) ~= "function" then + return nil, "battle runtime unavailable" + end + if verb == "static_battle" and talking then + local def = talking.def + local ref = def and (def.name or def.localId or def.index or def.id) + if ref then + local edit, reason = require("src.mods.ScriptWorldEdits").prepare( + game, + world, + "", + { { "hide_object", world.map.id, ref } } + ) + if not edit then + return nil, "row " .. i .. ": " .. reason + end + end + end + if verb == "static_battle" and row[4] ~= nil then + local value, reason = flag(row[4]) + if value == nil then + return nil, "row " .. i .. ": " .. reason + end + end + end + end + return true +end +B.outcomes = outcomes +return B diff --git a/src/mods/ScriptDialogueAudio.lua b/src/mods/ScriptDialogueAudio.lua new file mode 100644 index 000000000..ce8fde2a1 --- /dev/null +++ b/src/mods/ScriptDialogueAudio.lua @@ -0,0 +1,165 @@ +-- Deferred, owned dialogue cues. Native TextBox supplies its 600-frame gate. +local Audio = require("src.mods.ScriptAudio") +local Version = require("src.core.GameVersion") +local D = { ARITY = { play_cry = 3, text_sound = 2, engage_music = 2 } } +function D.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable dialogue audio unavailable for this generation" + end + if #row < 2 or #row > D.ARITY[verb] then + return nil, "invalid command arity" + end + if verb == "play_cry" then + if + type(row[2]) ~= "string" + or not row[2]:match("^[A-Z][A-Z0-9_]*$") + or #row[2] > 128 + or row[3] ~= nil and type(row[3]) ~= "boolean" + then + return nil, "invalid cry" + end + return { verb, row[2], row[3] } + end + local out, why = Audio.syntax( + verb == "text_sound" and "play_sound" or "play_music", + { verb == "text_sound" and "play_sound" or "play_music", row[2] }, + context + ) + if not out then + return nil, why + end + return { verb, row[2] } +end +function D.prepare(loader, game, rows) + local ids = require("src.mods.UnifiedIds").new(loader) + local function resolve(row) + if row[1] ~= "play_cry" then + return Audio.resolve(game, { row[1] == "text_sound" and "play_sound" or "play_music", row[2] }) + end + local group, why = ids:resolve("pokemon", row[2]) + local mon = group and game.data.pokemon and game.data.pokemon[group.key] + if not mon then + return nil, why or "cry species unavailable" + end + local audio = game.data.audio or {} + local def = audio.cries + and ( + audio.cries[group.key] + or type(group.key) == "number" and audio.cries[mon.translationSourceName or mon.name] + ) + local pika = Version.get() == "yellow" + and group.key == "PIKACHU" + and type(audio.pikaCries) == "table" + and audio.pikaCries[1] + if type(def) ~= "table" and type(def) ~= "string" and not pika then + return nil, "native cry unavailable: " .. row[2] + end + return { key = group.key, cry = true } + end + for i, row in ipairs(rows) do + if D.ARITY[row[1]] then + local plan, why = resolve(row) + if not plan then + return nil, "row " .. i .. ": " .. why + end + end + end + local function checked(row) + local plan, why = resolve(row) + if not plan then + error(why, 0) + end + return plan + end + local function arm(ctx, row) + checked(row) + ctx.dialogueAudio = ctx.dialogueAudio or {} + ctx.dialogueAudio[row[1]] = row + end + local function options(ctx, asking, alive) + local cues = ctx.dialogueAudio + ctx.dialogueAudio = nil + if not cues then + return nil + end + for _, row in pairs(cues) do + checked(row) + end + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + local sources, started, engaged, waited = {}, false, false, 0 + -- Throw only after a callback entered alive: abort native update before it + -- can pop a foreign overlay installed by a reentrant audio hook. + local function stillAlive() + if not alive() then + error("dialogue audio owner retired", 0) + end + end + local function busy() + if not alive() then + return false + end + local playing = false + for _, src in ipairs(sources) do + local ok, value = pcall(function() + return src:isPlaying() + end) + stillAlive() + playing = playing or ok and value == true + end + return playing + end + local function sound() + if started or not alive() then + return nil + end + started = true + for _, verb in ipairs({ "play_cry", "text_sound" }) do + if cues[verb] then + local p = checked(cues[verb]) + local src + if p.cry then + src = Sound.playCry(game.data, p.key) + elseif p.sequence then + src = Sound.playNamedEffect(game.data, p.key) + else + src = Sound.play(game.data, p.key) + end + stillAlive() + if src and p.sequence then + Music.duckForFanfare(src) + stillAlive() + end + if src then + sources[#sources + 1] = src + end + end + end + return { isPlaying = busy } + end + local function tick() + if engaged or not alive() then + return + end + waited = waited + 1 + if busy() and waited < 600 then + return + end + engaged = true + if cues.engage_music then + local p = checked(cues.engage_music) + if p.stop then + Music.stop() + else + Music.play(game.data, p.key) + end + stillAlive() + end + end + local cry = cues.play_cry + return { + auto = { sound = sound, tick = tick, delay = 0, wait = asking or not cry or cry[3] == true }, + } + end + return { arm = arm, options = options } +end +return D diff --git a/src/mods/ScriptFields.lua b/src/mods/ScriptFields.lua new file mode 100644 index 000000000..ade27470a --- /dev/null +++ b/src/mods/ScriptFields.lua @@ -0,0 +1,127 @@ +-- Data-only field writes; no executable values or namespace escape. +local F = { ARITY = { set_field = 3, set_option = 3 } } +local function copy(value, state, depth) + state.nodes = state.nodes + 1 + if state.nodes > 1024 or depth > 16 then + error("script field data limit", 0) + end + local kind = type(value) + if kind == "nil" or kind == "boolean" then + return value + end + if kind == "number" then + if value ~= value or value == math.huge or value == -math.huge then + error("invalid field number", 0) + end + return value + end + if kind == "string" then + state.bytes = state.bytes + #value + if state.bytes > 32768 then + error("script field string limit", 0) + end + return value + end + if kind ~= "table" or getmetatable(value) ~= nil or state.seen[value] then + error("script fields require acyclic plain data", 0) + end + state.seen[value] = true + local out = {} + for k, v in pairs(value) do + if type(k) ~= "string" and not (type(k) == "number" and k >= 1 and k % 1 == 0 and k < math.huge) then + error("invalid field data key", 0) + end + local key = copy(k, state, depth + 1) + out[key] = copy(v, state, depth + 1) + end + state.seen[value] = nil + return out +end +local function snapshot(value) + local state = { nodes = 0, bytes = 0, seen = {} } + return copy(value, state, 0), state +end +function F.syntax(verb, row, context) + if (context.generation or require("src.core.GameVersion").generation()) > 3 then + return nil, "portable fields unavailable for this generation" + end + local key = row[2] + if + #row < 2 + or #row > 3 + or type(key) ~= "string" + or #key == 0 + or #key > 256 + or key:find("%c") + or key:match("^mod%.") + or key == "mod:" + or key:find(":", 1, true) and not key:match("^mod:.+") + or verb == "set_field" and key == "modData" + then + return nil, "invalid or foreign script field" + end + local ok, value = pcall(snapshot, row[3]) + if not ok then + return nil, value + end + return { verb, key, value } +end +local function plain(value) + return value == nil or type(value) == "table" and getmetatable(value) == nil +end +function F.prepare(game, owner, rows) + local bytes, nodes = 0, 0 + local options = game.save.options + local function stores(row, plannedOptions) + if row[1] == "set_option" then + if not plain(plannedOptions) then + return nil, "invalid options store" + end + elseif row[2]:match("^mod:") then + local data = game.save.modData + if not plain(data) or data and not plain(data[owner]) then + return nil, "invalid mod save store" + end + end + return true + end + for i, row in ipairs(rows) do + if F.ARITY[row[1]] then + local _, size = snapshot(row[3]) + bytes, nodes = bytes + size.bytes, nodes + size.nodes + if bytes > 131072 or nodes > 8192 then + return nil, "aggregate script field data limit" + end + local ok, why = stores(row, options) + if not ok then + return nil, "row " .. i .. ": " .. why + end + if row[1] == "set_field" and row[2] == "options" then + options = row[3] + end + end + end + return function(_, row) + local ok, why = stores(row, game.save.options) + if not ok then + error(why, 0) + end + -- Copy again on each dispatch: loops and post-run save mutations never + -- expose or mutate the compiled program's snapshot. + local value = snapshot(row[3]) + if row[1] == "set_option" then + game.save.options = game.save.options or {} + game.save.options[row[2]] = value + else + local private = row[2]:match("^mod:(.+)$") + if private then + game.save.modData = game.save.modData or {} + game.save.modData[owner] = game.save.modData[owner] or {} + game.save.modData[owner][private] = value + else + game.save[row[2]] = value + end + end + end +end +return F diff --git a/src/mods/ScriptGifts.lua b/src/mods/ScriptGifts.lua new file mode 100644 index 000000000..63b4b535d --- /dev/null +++ b/src/mods/ScriptGifts.lua @@ -0,0 +1,135 @@ +-- Closed portable gift rows; execute through native gift/bag/party/box commands. +local Ids = require("src.mods.UnifiedIds") +local G = { ARITY = { give_item = 5, give_pokemon = 5 } } +local function integer(n, lo, hi) + return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi +end +local function id(s) + return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c") +end +local function text(s) + return type(s) == "string" and #s <= 4096 and not s:find("%z") +end +function G.syntax(verb, row) + if not id(row[2]) then + return nil, "invalid gift id" + end + if verb == "give_item" then + if #row < 2 or #row > 5 then + return nil, "invalid command arity" + end + if row[3] ~= nil and not integer(row[3], 1, 999) then + return nil, "invalid item count" + end + if row[4] ~= nil and row[4] ~= false and not text(row[4]) then + return nil, "invalid gift text" + end + if row[5] ~= nil and not text(row[5]) then + return nil, "invalid bag full text" + end + else + if #row < 3 or #row > 5 then + return nil, "invalid command arity" + end + if not integer(row[3], 1, 100) then + return nil, "invalid gift level" + end + if row[4] ~= nil and type(row[4]) ~= "boolean" or row[5] ~= nil and type(row[5]) ~= "boolean" then + return nil, "invalid gift option" + end + end + return { row[1], row[2], row[3], row[4], row[5] } +end +function G.prepare(loader, game, world, rows) + local ids = Ids.new(loader) + local plans = {} + local function validate(row) + if G.ARITY[row[1]] then + local kind = row[1] == "give_item" and "items" or "pokemon" + local group, reason = ids:resolve(kind, row[2]) + if not group or not (game.data[kind] and game.data[kind][group.key]) then + return nil, reason or "gift content unavailable" + end + local save = game.save + if row[1] == "give_item" then + if type(save.inventory) ~= "table" or save.bagOrder ~= nil and type(save.bagOrder) ~= "table" then + return nil, "invalid inventory store" + end + for _, count in pairs(save.inventory) do + if not integer(count, 0, 999999) then + return nil, "invalid inventory count" + end + end + elseif + type(save.party) ~= "table" + or type(save.pokedex) ~= "table" + or type(save.pokedex.seen) ~= "table" + or type(save.pokedex.owned) ~= "table" + then + return nil, "invalid gift party/dex store" + end + if row[1] == "give_pokemon" then + if type(save.player) ~= "table" then + return nil, "invalid gift player store" + end + if #save.party > 6 then + return nil, "invalid gift party" + end + for index, mon in pairs(save.party) do + if not integer(index, 1, #save.party) or type(mon) ~= "table" then + return nil, "invalid gift party" + end + end + for index = 1, #save.party do + if type(save.party[index]) ~= "table" then + return nil, "invalid gift party" + end + end + for _, name in ipairs({ "boxes", "box", "boxNames" }) do + if save[name] ~= nil and type(save[name]) ~= "table" then + return nil, "invalid box store" + end + end + if save.currentBox ~= nil and not integer(save.currentBox, 1, 65535) then + return nil, "invalid box index" + end + for _, box in pairs(save.boxes or {}) do + if type(box) ~= "table" then + return nil, "invalid box store" + end + end + end + return group.key + end + end + for i, row in ipairs(rows) do + if G.ARITY[row[1]] then + local key, reason = validate(row) + if not key then + return nil, "row " .. i .. ": " .. reason + end + plans[row] = key + end + end + return function(ctx, row) + local key = plans[row] + if not key then + error("unprepared gift", 0) + end + if not game.data[row[1] == "give_item" and "items" or "pokemon"][key] then + error("gift content unavailable", 0) + end + local ready, reason = validate(row) + if not ready then + error(reason, 0) + end + local Commands = require("src.script.Commands") + if row[1] == "give_item" then + ctx.portableBagFullText = row[5] + return Commands.give_item(ctx, key, row[3], row[4]) + end + ctx.portableGiftText = row[5] + return Commands.give_pokemon(ctx, key, row[3], row[4]) + end +end +return G diff --git a/src/mods/ScriptMovement.lua b/src/mods/ScriptMovement.lua new file mode 100644 index 000000000..a459f9ee7 --- /dev/null +++ b/src/mods/ScriptMovement.lua @@ -0,0 +1,184 @@ +-- Closed movement vocabulary over this host's native actor/track services. +local Version = require("src.core.GameVersion") +local unpack = table.unpack or unpack +local M = { + ARITY = { + move_player = 3, + move_npc = 4, + move_npc_to = 4, + place_npc = 5, + face = 2, + face_object = 4, + face_npc = 1, + face_player = 1, + face_player_dir = 2, + }, +} +local directions = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } +local function integer(v, max) + return type(v) == "number" and v == v and v >= 0 and v <= max and v % 1 == 0 +end +local function ref(v) + return type(v) == "string" and #v > 0 and #v <= 256 and not v:find("%c") or integer(v, 1000000) +end +function M.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable movement unavailable for this generation" + end + local n = #row + local minimum = ( + verb == "move_player" and 2 + or verb == "move_npc" and 3 + or verb == "place_npc" and 4 + or verb == "face_object" and 3 + ) or M.ARITY[verb] + if n < minimum or n > M.ARITY[verb] then + return nil, "invalid command arity" + end + if verb == "move_player" or verb == "move_npc" then + local at = verb == "move_player" and 2 or 3 + if + not directions[row[at]] + or row[at + 1] ~= nil and not integer(row[at + 1], 1024) + or at == 3 and not ref(row[2]) + then + return nil, "invalid script movement" + end + elseif verb == "place_npc" or verb == "move_npc_to" then + if + not ref(row[2]) + or not integer(row[3], 1000000) + or not integer(row[4], 1000000) + or row[5] ~= nil and not directions[row[5]] + then + return nil, "invalid NPC position" + end + elseif verb == "face_object" then + if not ref(row[2]) or not directions[row[3]] then + return nil, "invalid object facing" + end + elseif verb == "face" or verb == "face_player_dir" then + if not directions[row[2]] then + return nil, "invalid facing" + end + end + local out = { row[1], row[2], row[3], row[4], row[5] } + if verb == "face_object" and row[4] ~= nil then + if type(row[4]) ~= "table" or getmetatable(row[4]) ~= nil then + return nil, "invalid facing options" + end + out[4] = {} + for k, v in pairs(row[4]) do + if k ~= "hold" or not integer(v, 36000) then + return nil, "invalid facing options" + end + out[4][k] = v + end + end + return out +end +function M.actor(world, reference) + if reference == "player" then + return world.player + end + for _, actor in ipairs(world.npcs or {}) do + local def = actor.def or {} + if + actor.id == reference + or def.name == reference + or def.id == reference + or def.index == reference + or def.localId == reference + then + return actor + end + end +end +function M.prepare(world, rows, prevalidated) + local npc = world.talkNpc or world.runner and world.runner.ctx and world.runner.ctx.npc + local function target(row) + local verb = row[1] + if verb == "move_player" or verb == "face_player_dir" or verb == "face_npc" then + return world.player + end + if verb == "face" or verb == "face_player" then + return npc + end + return M.actor(world, row[2]) + end + for i, row in ipairs(rows) do + if not prevalidated and M.ARITY[row[1]] then + local actor = target(row) + if not actor then + return nil, "row " .. i .. ": native movement target unavailable" + end + if (row[1] == "face_npc" or row[1] == "face_player") and not npc then + return nil, "row " .. i .. ": script NPC unavailable" + end + if not integer(actor.cellX, 1000000) or not integer(actor.cellY, 1000000) then + return nil, "invalid actor position" + end + if row[1]:match("^move_") and type(world.scriptMove) ~= "function" then + return nil, "native movement unavailable" + end + if + row[1] == "move_npc_to" + and (type(world.map.inBounds) ~= "function" or type(world.map.isWalkableCell) ~= "function") + then + return nil, "native path unavailable" + end + end + end + return { + target = target, + npc = npc, + forgetNpc = function() + npc = nil + end, + } +end +function M.path(world, actor, tx, ty) + -- The native Gen1 command routes around occupied cells; later generations + -- use their walkable grid. Bound the search independently of map dimensions. + local map, gen = world.map, Version.generation() + local function key(x, y) + return x .. ":" .. y + end + local queue, first = { { actor.cellX, actor.cellY } }, 1 + local prev = { [key(actor.cellX, actor.cellY)] = false } + local dirs = { "up", "down", "left", "right" } + while queue[first] and first <= 16384 do + local x, y = unpack(queue[first]) + first = first + 1 + if x == tx and y == ty then + local path, at = {}, key(x, y) + while prev[at] do + table.insert(path, 1, prev[at][2]) + at = prev[at][1] + end + return path + end + for _, dir in ipairs(dirs) do + local d = directions[dir] + local nx, ny = x + d[1], y + d[2] + local k = key(nx, ny) + local goal = nx == tx and ny == ty + if + prev[k] == nil + and map:inBounds(nx, ny) + and (map:isWalkableCell(nx, ny) or gen == 1 and goal) + and ( + gen ~= 1 + or goal + or not require("src.world.Collision").occupied(world.entities or {}, nx, ny, actor) + ) + then + prev[k] = { key(x, y), dir } + queue[#queue + 1] = { nx, ny } + end + end + end + return nil, "native movement path unavailable" +end +M.directions = directions +return M diff --git a/src/mods/ScriptProgram.lua b/src/mods/ScriptProgram.lua new file mode 100644 index 000000000..0bcc7fdaf --- /dev/null +++ b/src/mods/ScriptProgram.lua @@ -0,0 +1,320 @@ +-- Pure preflight for the bounded portable dialogue/control-flow language. +local Program = {} +local State = require("src.mods.ScriptState") +local Gifts = require("src.mods.ScriptGifts") +local Audio = require("src.mods.ScriptAudio") +local Fields = require("src.mods.ScriptFields") +local DialogueAudio = require("src.mods.ScriptDialogueAudio") +local Shops = require("src.mods.ScriptShops") +local Battles = require("src.mods.ScriptBattles") +local Travel = require("src.mods.ScriptTravel") +local Movement = require("src.mods.ScriptMovement") +local WorldEdits = require("src.mods.ScriptWorldEdits") +Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 +local ARITY = { + set_flag = 2, + clear_flag = 2, + check_flag = 2, + wait_flag = 3, + show_text = 2, + ask = 2, + choice = 3, + wait = 2, + label = 2, + jump = 2, + jump_if_true = 2, + jump_if_false = 2, + ["end"] = 1, +} +for verb, arity in pairs(State.ARITY) do + ARITY[verb] = arity +end +for verb, arity in pairs(Gifts.ARITY) do + ARITY[verb] = arity +end +for verb, arity in pairs(WorldEdits.ARITY) do + ARITY[verb] = arity +end +for verb, arity in pairs(Movement.ARITY) do + ARITY[verb] = arity +end +for verb, arity in pairs(Travel.ARITY) do + ARITY[verb] = arity +end +for verb, arity in pairs(Battles.ARITY) do + ARITY[verb] = arity +end +for verb, arity in pairs(Shops.ARITY) do + ARITY[verb] = arity +end +for verb, arity in pairs(Audio.ARITY) do + ARITY[verb] = arity +end +for verb, arity in pairs(DialogueAudio.ARITY) do + ARITY[verb] = arity +end +for verb, arity in pairs(Fields.ARITY) do + ARITY[verb] = arity +end +local aliases = { text = "show_text", setflag = "set_flag", clearflag = "clear_flag" } +function Program.supports(verb) + if type(verb) ~= "string" then + return false + end + if + ( + WorldEdits.ARITY[verb] + or Movement.ARITY[verb] + or Travel.ARITY[verb] + or Battles.ARITY[verb] + or Shops.ARITY[verb] + or Audio.ARITY[verb] + or DialogueAudio.ARITY[verb] + or Fields.ARITY[verb] + ) and require("src.core.GameVersion").generation() > 3 + then + return false + end + return ARITY[aliases[verb] or verb] ~= nil +end +local function integer(n, lo, hi) + return type(n) == "number" and n == n and n % 1 == 0 and n >= lo and n <= hi +end +local function array(t, max) + if type(t) ~= "table" or getmetatable(t) ~= nil then + return nil + end + local n = 0 + for k in pairs(t) do + if not integer(k, 1, max) then + return nil + end + n = n + 1 + end + for i = 1, n do + if rawget(t, i) == nil then + return nil + end + end + return n +end +local function label(s) + return type(s) == "string" and #s > 0 and #s <= 64 and s:match("^[%w_:%-]+$") ~= nil +end +local function text(value, max) + if type(value) ~= "string" or #value > max or value:find("%z") then + return false + end + local i = 1 + while i <= #value do + local b = value:byte(i) + local n = b < 128 and 1 + or b >= 194 and b <= 223 and 2 + or b >= 224 and b <= 239 and 3 + or b >= 240 and b <= 244 and 4 + if not n or i + n - 1 > #value then + return false + end + for j = i + 1, i + n - 1 do + local c = value:byte(j) + if c < 128 or c > 191 then + return false + end + end + if n > 1 then + local c = value:byte(i + 1) + if b == 224 and c < 160 or b == 237 and c > 159 or b == 240 and c < 144 or b == 244 and c > 143 then + return false + end + end + i = i + n + end + return true +end +function Program.compile(raw, context) + context = context or {} + local n = array(raw, Program.MAX_ROWS) + if not n then + return nil, "script must be a dense plain array of at most 1024 rows" + end + local rows, labels, bytes = {}, {}, 0 + local function fail(i, message) + return nil, "row " .. i .. ": " .. message + end + for i = 1, n do + local source, count = raw[i], array(raw[i], 5) + if not count or count == 0 then + return fail(i, "expected a dense plain command row") + end + local verb = aliases[source[1]] or source[1] + if type(verb) ~= "string" or not ARITY[verb] then + return fail(i, "unsupported portable command") + end + if + not State.ARITY[verb] + and not Gifts.ARITY[verb] + and not WorldEdits.ARITY[verb] + and not Movement.ARITY[verb] + and not Travel.ARITY[verb] + and not Battles.ARITY[verb] + and not Shops.ARITY[verb] + and not Audio.ARITY[verb] + and not DialogueAudio.ARITY[verb] + and not Fields.ARITY[verb] + and (count > ARITY[verb] or count < ((verb == "choice" or verb == "wait_flag") and 2 or ARITY[verb])) + then + return fail(i, "invalid command arity") + end + local row = { verb, source[2] } + if Fields.ARITY[verb] then + local value, reason = Fields.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif DialogueAudio.ARITY[verb] then + local value, reason = DialogueAudio.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Audio.ARITY[verb] then + local value, reason = Audio.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Shops.ARITY[verb] then + local value, reason = Shops.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Battles.ARITY[verb] then + local value, reason = Battles.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Travel.ARITY[verb] then + local value, reason = Travel.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Movement.ARITY[verb] then + local value, reason = Movement.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif WorldEdits.ARITY[verb] then + local value, reason = WorldEdits.syntax(verb, source, context) + if not value then + return fail(i, reason) + end + row = value + elseif Gifts.ARITY[verb] then + local value, reason = Gifts.syntax(verb, source) + if not value then + return fail(i, reason) + end + row = value + for _, index in ipairs({ 4, 5 }) do + if type(row[index]) == "string" then + if not text(row[index], 4096) then + return fail(i, "gift text must be valid UTF-8 without NUL") + end + bytes = bytes + #row[index] + end + end + elseif State.ARITY[verb] then + local value, reason = State.syntax(verb, source) + if not value then + return fail(i, reason) + end + row = value + elseif verb == "set_flag" or verb == "clear_flag" or verb == "check_flag" or verb == "wait_flag" then + local Version = require("src.core.GameVersion") + local address, reason = require("src.mods.Api3Flags").address( + context.owner or "", + context.generation or Version.generation(), + context.version or Version.get(), + source[2] + ) + if not address then + return fail(i, reason) + end + if verb == "wait_flag" then + if source[3] ~= nil and not integer(source[3], 0, 36000) then + return fail(i, "invalid flag timeout") + end + row[3] = source[3] + end + elseif verb == "show_text" or verb == "ask" then + if not text(source[2], 4096) then + return fail(i, "text must be valid UTF-8 without NUL, at most 4096 bytes") + end + bytes = bytes + #source[2] + elseif verb == "choice" then + local size = array(source[2], 16) + if not size or size == 0 then + return fail(i, "choice needs 1-16 labels") + end + row[2] = {} + for j = 1, size do + local value = source[2][j] + if not text(value, 128) or #value == 0 then + return fail(i, "invalid choice label") + end + row[2][j], bytes = value, bytes + #value + end + local opts = source[3] + row[3] = { default = 1, cancel = size } + if opts ~= nil then + if type(opts) ~= "table" or getmetatable(opts) ~= nil then + return fail(i, "choice options must be a plain table") + end + for key, value in pairs(opts) do + if (key ~= "default" and key ~= "cancel") or not integer(value, 1, size) then + return fail(i, "invalid choice option") + end + row[3][key] = value + end + end + elseif verb == "wait" then + if not integer(source[2], 0, 36000) then + return fail(i, "wait must be 0-36000 native logic frames") + end + elseif verb == "label" then + if not label(source[2]) or source[2] == "end" or labels[source[2]] then + return fail(i, "invalid or duplicate label") + end + labels[source[2]] = i + elseif verb ~= "end" then + if not label(source[2]) and not integer(source[2], 1, n) then + return fail(i, "invalid jump target") + end + end + if bytes > 131072 then + return fail(i, "script text exceeds 128 KiB") + end + rows[i] = row + end + for i, row in ipairs(rows) do + if row[1] == "jump" or row[1] == "jump_if_true" or row[1] == "jump_if_false" then + local target = row[2] + if type(target) == "string" then + if target == "end" then + row[2] = n + 1 + elseif labels[target] then + row[2] = labels[target] + else + return fail(i, "jump to missing label") + end + end + end + end + return rows +end +return Program diff --git a/src/mods/ScriptShopUI.lua b/src/mods/ScriptShopUI.lua new file mode 100644 index 000000000..5ed099c32 --- /dev/null +++ b/src/mods/ScriptShopUI.lua @@ -0,0 +1,61 @@ +-- Callback leases for the native mart's menu, quantity and confirmation phases. +-- Only explicit UI fields are visited, never a state's Game/save/data graph. +local U = {} +local methods = { "select", "choose", "cancel", "close", "buy", "sell", "askQuantity", "askQuantityInput" } +local callbacks = + { onDone = true, onChoose = true, onCancel = true, onQuit = true, onSelect = true, choice = true } +function U.new(control) + local wrapped = setmetatable({}, { __mode = "k" }) + local function bind(state, holder, key, once, phase) + local original = holder[key] + if type(original) ~= "function" or wrapped[original] then + return + end + local fired = false + local guarded + guarded = function(...) + if fired or holder[key] ~= guarded or not control.alive(state) or phase and not phase() then + return + end + if once then + fired = true + end + return control.invoke(state, original, ...) + end + holder[key] = guarded + wrapped[guarded] = true + end + local function attach(state) + for key in pairs(callbacks) do + if rawget(state, key) ~= nil then + local once = key == "onDone" + or key == "choice" + or key == "onChoose" and getmetatable(state) == require("src.ui.ChoiceBox") + bind(state, state, key, once) + end + end + for _, key in ipairs(methods) do + bind(state, state, key, false) + end + for _, item in ipairs(state.items or {}) do + if type(item) == "table" then + bind(state, item, "onSelect", false, function() + for _, current in ipairs(state.items or {}) do + if current == item then + return true + end + end + return false + end) + end + end + local asking = state.asking + if type(asking) == "table" then + bind(state, asking, "onDone", true, function() + return state.asking == asking or control.phase(state) == asking + end) + end + end + return attach +end +return U diff --git a/src/mods/ScriptShops.lua b/src/mods/ScriptShops.lua new file mode 100644 index 000000000..f026c2cae --- /dev/null +++ b/src/mods/ScriptShops.lua @@ -0,0 +1,121 @@ +-- Native mart content and save validation; no live UI is created by preflight. +local Ids = require("src.mods.UnifiedIds") +local Version = require("src.core.GameVersion") +local S = { ARITY = { open_mart = 2 } } +local function integer(n, max) + return type(n) == "number" and n == n and n >= 0 and n <= max and n % 1 == 0 +end +function S.syntax(_, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable shops unavailable for this generation" + end + local ref = row[2] + if + #row ~= 2 + or not (type(ref) == "string" and #ref > 0 and #ref <= 256 and not ref:find("%c") or integer(ref, 65535)) + then + return nil, "invalid mart text constant" + end + return { row[1], ref } +end +local function stores(game) + local save = game.save + if + not integer(save.money, 9007199254740991) + or type(save.inventory) ~= "table" + or save.bagOrder ~= nil and type(save.bagOrder) ~= "table" + then + return nil, "invalid shop wallet/inventory" + end + local total, room = 0, 9007199254740991 - save.money + for id, count in pairs(save.inventory) do + total = total + 1 + if total > 4096 then + return nil, "shop inventory too large" + end + if not integer(count, 999999) then + return nil, "invalid inventory count" + end + local item = game.data.items and game.data.items[id] + if + item + and not item.keyItem + and not require("src.inventory.ItemEffects").alias(id, item):find("^HM_") + then + if not integer(item.price, 999999) then + return nil, "invalid inventory item price" + end + local value = math.floor(item.price / 2) * count + if value > room then + return nil, "shop money overflow" + end + room = room - value + end + end + return true +end +function S.plan(loader, game, world, row) + local valid, why = stores(game) + if not valid then + return nil, why + end + local def = world.map and world.map.def + if not (def and type(def.label) == "string" and type(game.data.textEntry) == "function") then + return nil, "native mart content unavailable" + end + local entry = game.data:textEntry(def.label, row[2]) + local list = entry and entry.mart + if type(list) ~= "table" or #list == 0 or #list > 99 then + return nil, "native mart content unavailable: " .. tostring(row[2]) + end + for k in pairs(list) do + if not integer(k, #list) or k == 0 then + return nil, "invalid mart stock" + end + end + local ids = Ids.new(loader) + local stock, records = {}, {} + for i = 1, #list do + local public = ids:read("items", list[i]) + local group, reason = ids:resolve("items", public) + local item = group and game.data.items and game.data.items[group.key] + if not item then + return nil, reason or "unknown mart item" + end + if not integer(item.price, 999999) or type(item.name) ~= "string" then + return nil, "invalid mart item price/name" + end + stock[i] = group.key + records[group.key] = { record = item, price = item.price } + end + local inventory = game.save.inventory + for id in pairs(inventory) do + local item = game.data.items[id] + if item then + records[id] = { record = item, price = item.price } + end + end + return stock, + function() + local ok, reason = stores(game) + if not ok then + return nil, reason + end + if game.save.inventory ~= inventory then + return nil, "shop inventory changed" + end + for id in pairs(inventory) do + local item = game.data.items[id] + if item and not records[id] then + records[id] = { record = item, price = item.price } + end + end + for id, expected in pairs(records) do + if game.data.items[id] ~= expected.record or expected.record.price ~= expected.price then + return nil, "shop item data changed" + end + end + return true + end +end +return S diff --git a/src/mods/ScriptState.lua b/src/mods/ScriptState.lua new file mode 100644 index 000000000..dbf73a6e5 --- /dev/null +++ b/src/mods/ScriptState.lua @@ -0,0 +1,193 @@ +-- Synchronous portable script actions over the host's native save/party/bag. +local Ids = require("src.mods.UnifiedIds") +local Version = require("src.core.GameVersion") +local S = {} +S.ARITY = { + give_money = 2, + take_money = 2, + check_money = 2, + take_coins = 2, + check_item = 2, + take_item = 3, + check_dex_owned = 2, + mark_seen = 2, + heal_party = 1, +} +local function integer(value, minimum, maximum) + return type(value) == "number" + and value == value + and value % 1 == 0 + and value >= minimum + and value < math.huge + and (not maximum or value <= maximum) +end +local function identifier(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +function S.syntax(verb, source) + local n = S.ARITY[verb] + local minimum = (verb == "take_item" or verb == "check_dex_owned") and 2 or n + if verb == "check_dex_owned" then + minimum = 1 + end + if #source < minimum or #source > n then + return nil, "invalid command arity" + end + if verb == "check_item" or verb == "take_item" or verb == "mark_seen" then + if not identifier(source[2]) then + return nil, "invalid content id" + end + if verb == "take_item" and source[3] ~= nil and not integer(source[3], 1, 999) then + return nil, "invalid item count" + end + elseif verb ~= "heal_party" then + if + not (verb == "check_dex_owned" and source[2] == nil) and not integer(source[2], 0, 9007199254740991) + then + return nil, "invalid script amount" + end + end + return { verb, source[2], source[3] } +end +local function tableOrNil(value) + return value == nil or type(value) == "table" +end +function S.prepare(loader, game, world, rows) + local ids = Ids.new(loader) + local plans = {} + local save = game.save + local function number(value) + return value == nil or integer(value, 0, 9007199254740991) + end + local function validate(row) + local verb = row[1] + if S.ARITY[verb] then + local reason, key + if verb == "give_money" or verb == "take_money" or verb == "check_money" then + if not number(save.money) then + reason = "invalid money store" + elseif + verb == "give_money" + and Version.generation() == 1 + and not integer((save.money or 0) + row[2], 0, 9007199254740991) + then + reason = "money overflow" + end + elseif verb == "take_coins" then + if not number(save.coins) then + reason = "invalid coin store" + end + elseif verb == "check_item" or verb == "take_item" or verb == "mark_seen" then + local kind = verb == "mark_seen" and "pokemon" or "items" + local group, why = ids:resolve(kind, row[2]) + if not group or not (game.data[kind] and game.data[kind][group.key]) then + reason = why or "content unavailable" + else + key = group.key + end + if not reason and kind == "items" then + if + type(save.inventory) ~= "table" + or not number(save.inventory[key]) + or not tableOrNil(save.bagOrder) + then + reason = "invalid inventory store" + end + end + end + if verb == "mark_seen" or verb == "check_dex_owned" then + local dex = save.pokedex + if + not tableOrNil(dex) + or dex and (not tableOrNil(dex.seen) or not tableOrNil(dex.owned) or not tableOrNil(dex.caught)) + then + reason = "invalid pokedex store" + end + elseif verb == "heal_party" then + if type(save.party) ~= "table" then + reason = "invalid party" + else + for _, mon in ipairs(save.party) do + if + type(mon) ~= "table" + or type(mon.stats) ~= "table" + or not integer(mon.stats.hp, 1, 65535) + or not tableOrNil(mon.moves) + then + reason = "invalid party" + break + end + for _, move in ipairs(mon.moves or {}) do + if type(move) ~= "table" or not (game.data.moves and game.data.moves[move.id]) then + reason = "invalid party move" + break + end + end + end + end + end + if reason then + return nil, reason + end + return { key = key } + end + end + for i, row in ipairs(rows) do + if S.ARITY[row[1]] then + local plan, reason = validate(row) + if not plan then + return nil, "row " .. i .. ": " .. reason + end + plans[row] = plan + end + end + return function(ctx, row) + local verb, a = row[1], row[2] + local plan = plans[row] + if not plan then + return false + end + -- Revalidate live stores too: another mod can mutate them while this script waits. + local checked, reason = validate(row) + if not checked then + error(reason, 0) + end + if verb == "give_money" or verb == "take_money" then + local value = math.max(0, (save.money or 0) + (verb == "give_money" and a or -a)) + if Version.generation() ~= 1 then + value = math.min(999999, value) + end + save.money = value + elseif verb == "check_money" then + ctx.lastCheck = (save.money or 0) >= a + elseif verb == "take_coins" then + save.coins = math.max(0, (save.coins or 0) - a) + elseif verb == "check_item" then + ctx.lastCheck = (save.inventory[plan.key] or 0) > 0 + elseif verb == "take_item" then + local n = math.min(row[3] or 1, save.inventory[plan.key] or 0) + if n > 0 then + require("src.inventory.Bag").remove(save, plan.key, n) + end + elseif verb == "mark_seen" then + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.seen[plan.key] = true + elseif verb == "check_dex_owned" then + local dex = save.pokedex + local n = 0 + for _, value in pairs(dex and (dex.owned or dex.caught) or {}) do + if value then + n = n + 1 + end + end + ctx.lastCheck = n >= (a or 1) + elseif verb == "heal_party" then + for _, mon in ipairs(save.party) do + require("src.pokemon.Pokemon").heal(mon, game.data.moves) + end + end + return true + end +end +return S diff --git a/src/mods/ScriptTravel.lua b/src/mods/ScriptTravel.lua new file mode 100644 index 000000000..5a6ec345b --- /dev/null +++ b/src/mods/ScriptTravel.lua @@ -0,0 +1,94 @@ +-- Destination preflight, including later world rows against destination data. +local Shops = require("src.mods.ScriptShops") +local Warp = require("src.mods.Api3Warp") +local Movement = require("src.mods.ScriptMovement") +local Edits = require("src.mods.ScriptWorldEdits") +local Version = require("src.core.GameVersion") +local copy = require("src.mods.Merge").deepCopy +local T = { ARITY = { warp = 5, remember_outdoor = 4 } } +function T.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable travel unavailable for this generation" + end + if #row < 4 or #row > T.ARITY[verb] then + return nil, "invalid command arity" + end + -- Destination names, coordinates, dimensions and facing are validated with + -- the same pure planner as the public API once a save/world is available. + return { row[1], row[2], row[3], row[4], row[5] } +end +local function future(game, world, plan, cache) + local tiles = game.data.tilesets and game.data.tilesets[plan.def.tileset] + local map = cache[plan.mapId] + if map then + elseif tiles then + map = require("src.world.Map").new(copy(plan.def), copy(tiles)) + else + map = { id = plan.mapId, def = plan.def } + end + map.renderer = { rebuild = function() end } + cache[plan.mapId] = map + local view = { + isOverworld = true, + map = map, + player = { cellX = plan.x, cellY = plan.y, facing = plan.facing }, + npcs = {}, + syncObjectVisibility = world.syncObjectVisibility, + scriptMove = world.scriptMove, + } + for _, def in ipairs(plan.def.objects or {}) do + view.npcs[#view.npcs + 1] = + { def = def, id = plan.mapId .. "_obj_" .. tostring(def.index), cellX = def.x, cellY = def.y } + end + local proxy = { + data = game.data, + save = game.save, + overworld = view, + stack = { + top = function() + return view + end, + states = { view }, + }, + } + return proxy, view +end +function T.prepare(game, world, owner, rows, loader) + local gen = Version.generation() + local model, modelGame = world, game + local cache = {} + for i, row in ipairs(rows) do + local verb = row[1] + if T.ARITY[verb] then + local plan, why = Warp.plan(game, model, gen, row[2], row[3], row[4], row[5]) + if not plan then + return nil, "row " .. i .. ": " .. why + end + if verb == "warp" then + if type(world.startWarpTo) ~= "function" then + return nil, "native warp unavailable" + end + modelGame, model = future(game, world, plan, cache) + end + elseif Shops.ARITY[verb] then + local stock, why = Shops.plan(loader, modelGame, model, row) + if not stock then + return nil, "row " .. i .. ": " .. why + end + elseif Edits.ARITY[verb] then + local edit, why = Edits.prepare(modelGame, model, owner, { row }) + if not edit then + return nil, "row " .. i .. ": " .. why + end + elseif Movement.ARITY[verb] then + local movement, why = Movement.prepare(model, { row }) + if not movement then + return nil, "row " .. i .. ": " .. why + end + end + end + return function(row) + return Warp.plan(game, world, gen, row[2], row[3], row[4], row[5]) + end +end +return T diff --git a/src/mods/ScriptWorldEdits.lua b/src/mods/ScriptWorldEdits.lua new file mode 100644 index 000000000..0c2c7ba9d --- /dev/null +++ b/src/mods/ScriptWorldEdits.lua @@ -0,0 +1,85 @@ +-- Pure plans plus guarded native object/block effects inside an owned script. +local E = require("src.mods.Api3WorldEdits") +local Version = require("src.core.GameVersion") +local W = { ARITY = { show_object = 3, hide_object = 3, replace_block = 4 } } +local function id(value) + return type(value) == "string" and #value > 0 and #value <= 256 and not value:find("%c") +end +local function integer(value) + return type(value) == "number" and value == value and value >= 0 and value < math.huge and value % 1 == 0 +end +function W.syntax(verb, row, context) + if (context.generation or Version.generation()) > 3 then + return nil, "portable world edits unavailable for this generation" + end + if #row ~= W.ARITY[verb] then + return nil, "invalid command arity" + end + if verb == "replace_block" then + if not integer(row[2]) or not integer(row[3]) or not integer(row[4]) then + return nil, "invalid script block" + end + elseif not id(row[2]) or not (id(row[3]) or integer(row[3])) then + return nil, "invalid script object" + end + return { row[1], row[2], row[3], row[4] } +end +function W.prepare(game, world, owner, rows, prevalidated) + local needed = false + for _, row in ipairs(rows) do + needed = needed or W.ARITY[row[1]] ~= nil + end + if not needed then + return function() end + end + local gen = Version.generation() + local api = require("src.world.WorldAPI").new(game, owner) + local function plan(row) + if row[1] == "replace_block" then + return E.plan(game, api, gen, require, "replaceBlock", row[2], row[3], row[4]) + end + local mapId = row[2] + local maps = game.data.maps or {} + if gen == 3 and not maps[mapId] then + local prefix = Version.get() == "emerald" and "EM_" or "FR_" + if maps[prefix .. mapId] then + mapId = prefix .. mapId + end + end + local def = maps[mapId] + local found = false + for _, object in ipairs(def and def.objects or {}) do + if + object.name == row[3] + or object.id == row[3] + or object.index == row[3] + or object.localId == row[3] + then + found = true + break + end + end + if not found then + return nil, "native object unavailable: " .. tostring(row[3]) + end + return E.plan(game, api, gen, require, "toggleObject", mapId, row[3], row[1] == "show_object") + end + for i, row in ipairs(rows) do + if not prevalidated and W.ARITY[row[1]] then + local operation, reason = plan(row) + if not operation then + return nil, "row " .. i .. ": " .. reason + end + end + end + return function(row, mutate) + local operation, reason = plan(row) + if not operation then + return nil, reason + end + return mutate(function() + return E.run(game, operation) + end) + end +end +return W diff --git a/src/mods/UIActions.lua b/src/mods/UIActions.lua new file mode 100644 index 000000000..533730961 --- /dev/null +++ b/src/mods/UIActions.lua @@ -0,0 +1,241 @@ +-- Per-mod, detached UI snapshots and revision-bound native actions. +local Commands = require("src.ui.UICommands") +local Intents = require("src.mods.BattleIntents") +local Ids = require("src.mods.UnifiedIds") +local Runtime = require("src.mods.Runtime") +local Actions = {} +local function copy(v, seen) + if type(v) ~= "table" then + if type(v) == "string" or type(v) == "number" or type(v) == "boolean" then + return v + end + return nil + end + seen = seen or {} + if seen[v] then + return seen[v] + end + local out = {} + seen[v] = out + for k, value in pairs(v) do + if type(k) == "string" or type(k) == "number" then + out[k] = copy(value, seen) + end + end + return out +end +local function equal(a, b, seen) + if type(a) ~= type(b) then + return false + end + if type(a) ~= "table" then + return a == b or (a ~= a and b ~= b) + end + seen = seen or {} + if seen[a] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not equal(v, b[k], seen) then + return false + end + end + for k in pairs(b) do + if a[k] == nil then + return false + end + end + return true +end +local function engine(fn, ...) + local owner = Runtime.currentMod + Runtime.currentMod = nil + local ok, a, b, c = pcall(fn, ...) + Runtime.currentMod = owner + return ok, a, b, c +end +local function integer(n) + return type(n) == "number" and n >= 1 and n <= 9007199254740991 and n % 1 == 0 +end +local function normalize(raw) + if type(raw) ~= "table" or getmetatable(raw) ~= nil then + return nil, "intent must be a plain table" + end + if not integer(raw.id) or not integer(raw.revision) then + return nil, "invalid UI id or revision" + end + local fields = ({ select = { index = true }, back = {}, advance = {}, pocket = { direction = true } })[raw.kind] + if not fields then + return nil, "unknown UI intent" + end + for k in pairs(raw) do + if k ~= "id" and k ~= "revision" and k ~= "kind" and not fields[k] then + return nil, "unknown UI intent field" + end + end + if raw.kind == "select" and not integer(raw.index) then + return nil, "invalid UI row" + end + if raw.kind == "pocket" and raw.direction ~= -1 and raw.direction ~= 1 then + return nil, "invalid pocket direction" + end + return { + id = raw.id, + revision = raw.revision, + kind = raw.kind, + index = raw.index, + direction = raw.direction, + } +end +function Actions.new(provider, loader, builders) + local ids = Ids.new(loader) + ids:entries("pokemon") + ids:entries("items") + local facade = setmetatable({}, { __index = builders }) + local revision, consumed = 0, 0 + local previous, oldRefs, oldControls, oldEpoch, offered + local function refresh() + local game = provider() + local states = game and game.stack and game.stack.states + local top = states and states[#states] + if not top then + previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil + return nil, "no UI screen" + end + local ok, view, refs, kind = engine(Commands.describe, game, top, Ids.new(loader)) + if not ok or not view then + previous, oldRefs, oldControls, oldEpoch, offered = nil, nil, nil, nil, nil + return nil, ok and refs or "UI snapshot failed" + end + refs.game = game + -- Capture live eligibility, target and inventory changes even when the + -- native screen has not rebuilt its labels yet. Never return this data. + local controls = copy({ + party = type(top.party) == "table" and top.party or game.save and game.save.party, + inventory = game.save and game.save.inventory, + bagOrder = game.save and game.save.bagOrder, + select = top.select, + menu = top.menu, + submenu = top.submenu, + swapFrom = top.swapFrom, + switchFrom = top.switchFrom, + softboiledFrom = top.softboiledFrom, + hpTransfer = top.hpTransfer, + hpTransferFrom = top.hpTransferFrom, + forceSwitch = top.forceSwitch, + pickOnly = top.pickOnly, + keepOpen = top.keepOpen, + pick = top.pick, + store = top.store, + sell = top.sell, + registeredItem = game.save and game.save.registeredItem, + cur = top.cur, + mem = top.mem, + page = top.page, + top = top.top, + done = top.done, + waiting = top.waiting, + shown = top.shown, + contAdvance = top.contAdvance, + lineIndex = top.lineIndex, + pageIndex = top.pageIndex, + pending = top.pending, + trash = top.trash, + moving = top.moving, + lockPocket = top.lockPocket, + resultMessage = top.resultMessage, + message = top.message, + selectNotice = top.selectNotice, + }) + local state = Intents.state(game) + if + not equal(view, previous) + or not Intents.sameRefs(refs, oldRefs) + or not equal(controls, oldControls) + or state.epoch ~= oldEpoch + then + revision = revision + 1 + end + previous, oldRefs, oldControls, oldEpoch = view, refs, controls, state.epoch + return { game = game, top = top, view = view, kind = kind, state = state } + end + function facade:snapshot() + local ctx, err = refresh() + if not ctx then + return nil, err + end + local out = copy(ctx.view) + out.revision = revision + if ctx.state.busy then + out.locked, out.canBack, out.canAdvance, out.canPocket = true, false, false, false + end + offered = revision + return out + end + function facade:submit(raw) + local intent, err = normalize(raw) + if not intent then + return nil, err + end + if intent.id <= consumed then + return nil, "replayed UI intent" + end + local ctx + ctx, err = refresh() + if not ctx then + return nil, err + end + local state, view = ctx.state, ctx.view + if state.busy then + return nil, "UI action is already in progress" + end + if intent.revision ~= revision then + return nil, "stale UI context" + end + if offered ~= revision then + return nil, "fresh UI snapshot required" + end + if view.locked then + return nil, "UI is locked" + end + if intent.kind == "select" then + if not view.rows[intent.index] or not view.rows[intent.index].enabled then + return nil, "unavailable UI row" + end + elseif intent.kind == "back" and not view.canBack then + return nil, "UI cannot go back" + elseif intent.kind == "advance" and not view.canAdvance then + return nil, "UI cannot advance" + elseif intent.kind == "pocket" and not view.canPocket then + return nil, "UI cannot change pocket" + end + -- Native work can raise after partial effects. Consume before dispatch; + -- the shared epoch also expires snapshots held by battle/other UI facades. + consumed, offered = intent.id, nil + state.busy, state.epoch = true, state.epoch + 1 + local ok, failure = engine(Commands.dispatch, ctx.top, ctx.kind, view, intent) + state.busy, state.epoch = false, state.epoch + 1 + if not ok then + local printable, message = pcall(tostring, failure) + return nil, "UI action failed; intent consumed" .. (printable and ": " .. message or "") + end + return true + end + return facade +end +function Actions.withCapabilities(api) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "ui" then + if name == "snapshot" or name == "submit" then + return true + end + return false, "unknown UI capability: " .. tostring(name) + end + return supports(self, surface, name) + end + end +end +return Actions diff --git a/src/mods/UnifiedCallbacks.lua b/src/mods/UnifiedCallbacks.lua new file mode 100644 index 000000000..5e6a6195d --- /dev/null +++ b/src/mods/UnifiedCallbacks.lua @@ -0,0 +1,222 @@ +local Api3Catalog = require("src.mods.Api3Catalog") +local Records = require("src.mods.CallbackRecords") +local Ids = require("src.mods.UnifiedIds") +local C = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function names(text) + local out = {} + for name in text:gmatch("%S+") do + out[name] = true + end + return out +end +-- This is an audited boundary catalog, not a claim that every game executes +-- every seam. The source-catalog test requires a decision for each new seam. +C.hooks = names([[ + battle.accuracy battle.catch_exp battle.crit battle.damage battle.enemy_action battle.exp_award + battle.low_health_alarm battle.overlay battle.run battle.status_hud_visible battle.turn_order catch.rate + encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility + input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed + music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud + render.letterbox render.zones save.new_game save.write script.command trainer.party transition.style + ui.list_menu ui.naming.grid ui.options.rows ui.party.submenu ui.pc.items ui.start_menu.items + ui.title_menu.items warp.destination world.tod zoom.range +]]) +C.events = names([[ + assets.transformed battle.ball_thrown battle.battler_switched battle.damage_dealt battle.ended + battle.exp_gained battle.fainted battle.move_used battle.started battle.status_inflicted battle.turn_ended + battle.turn_started discord.join_requested flag.changed game.ready intro.birch_speech.finished + intro.birch_speech.started intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started + intro.oak_speech.step link.connected link.desync link.ended map.entered map.exited map.reloaded + mod.option_action mod.options_changed mods.loaded music.started music.stopped player.warped + pokemon.before_give pokemon.caught pokemon.egg_hatched pokemon.evolved pokemon.level_up + pokemon.move_learned pokemon.received save.created save.loaded save.loading save.writing screen.popped + screen.pushed script.ended script.started sound.played trade.completed world.blacked_out + world.block_replaced world.blocks_replaced world.boulder_moved world.interacted world.npc_spawned + world.object_toggled world.stepped world.tick world.tod_changed world.trainer_engaged +]]) +local positions = { + ["battle.accuracy"] = { "context" }, + ["battle.damage"] = { "context" }, + ["battle.crit"] = { "context" }, + ["battle.run"] = { "context" }, + ["battle.catch_exp"] = { "context" }, + ["battle.low_health_alarm"] = { "context" }, + ["battle.exp_award"] = { "context" }, + ["exp.gain"] = { "context" }, + ["battle.turn_order"] = { "battler", "move", "battler", "move", "context" }, + ["battle.enemy_action"] = { false, "battler" }, + ["catch.rate"] = { "items", "mon", "species", "context" }, + ["trainer.party"] = { false, false, "trainerParty" }, + ["evolution.check"] = { false, "mon", "context", "context" }, + ["fieldmove.eligibility"] = { "moves", false }, + ["encounter.fishing"] = { "items", false, "candidates" }, + ["encounter.roll"] = { false, "context" }, + ["encounter.species"] = { "encounter", "context" }, + ["ui.party.submenu"] = { false, false, "mon", false }, + ["pokemon.sprite"] = { false, "context" }, + ["pokemon.icon"] = { false, "context" }, +} +local results = { + ["fieldmove.eligibility"] = "mon", + ["battle.enemy_action"] = "action", + ["trainer.party"] = "trainerParty", + ["battle.turn_order"] = "battler", + ["encounter.fishing"] = "encounter", + ["encounter.roll"] = "encounter", + ["encounter.species"] = "encounter", +} +-- These payloads are engine handles, repair flows or UI objects, not record +-- notifications. In particular, game.ready has a forwarding metatable. +local nativeEvents = names([[ + game.ready save.created save.loaded save.loading save.writing mods.loaded + screen.popped screen.pushed intro.birch_speech.finished intro.birch_speech.started + intro.oak_speech.answered intro.oak_speech.finished intro.oak_speech.started intro.oak_speech.step + mod.option_action mod.options_changed +]]) +local function custom(name) + return type(name) == "string" and name:match("^mod%.[%w_%-]+%..+$") ~= nil +end +function C.install(loader, api, owner, generation) + -- Capture pristine alias identities before Loader merges them into separate + -- records. Each call still builds a fresh index, including later mods' IDs. + local ids = Ids.new(loader) + local api3 = (api.manifest.api or 1) >= 3 + local extra = api3 and Api3Catalog + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + local function check(catalog, name) + assert( + type(name) == "string" + and ( + catalog[name] + or (extra and (catalog == C.events and extra.events[name] or catalog == C.hooks and extra.hooks[name])) + or (catalog == C.events and custom(name)) + ), + "unknown unified callback: " .. tostring(name) + ) + end + local function subscribe(once, name, callback, priority) + check(C.events, name) + assert(type(callback) == "function", "event callback must be a function") + local wrapped = callback + if not custom(name) and not nativeEvents[name] then + wrapped = function(payload) + local boundary = Records.new(loader, name, generation, api3) + local public = boundary.view(payload, name == "pokemon.before_give" and "gift" or "context") + local values = pack(callback(public)) + if name == "pokemon.before_give" then + boundary.commit() + end + return unpack(values, 1, values.n) + end + end + if once then + return loader.events:once(name, wrapped, priority, owner) + end + return loader.events:on(name, wrapped, priority, owner) + end + api.events = { + on = function(_, ...) + return subscribe(false, ...) + end, + once = function(_, ...) + return subscribe(true, ...) + end, + emit = function(_, name, payload) + local prefix = "mod." .. owner .. "." + assert(custom(name) and name:sub(1, #prefix) == prefix, "mods may only emit " .. prefix .. "* events") + return loader.events:emit(name, payload) + end, + } + api.hooks = { + wrap = function(_, name, callback, priority) + check(C.hooks, name) + assert(type(callback) == "function", "hook callback must be a function") + return loader.hooks:wrap(name, function(nextFn, ...) + local boundary = Records.new(loader, name, generation, api3) + local roles = positions[name] or {} + local function translate(args) + local out = { n = args.n } + for i = 1, args.n do + local role = roles[i] + local v = args[i] + if role == "items" or role == "moves" then + v = boundary.read(role, v) + elseif role then + v = boundary.view(v, role) + end + out[i] = v + end + return out + end + local original = pack(...) + local function rawArgs(args) + local out = { n = args.n } + for i = 1, args.n do + local role, v = roles[i], args[i] + if role == "items" or role == "moves" then + v = boundary.native(role, v, original[i]) + elseif role then + v = boundary.restore(v, role) + end + out[i] = v + end + return out + end + local public = translate(original) + local called, downstream, publicResult = false, nil, nil + local active = true + local function nextPublic(...) + assert(active, "next is only valid during its hook callback") + assert(not called or downstream, "next is already running") + if not called then + local args = select("#", ...) == 0 and public or pack(...) + local raw = boundary.commit(function() + return rawArgs(args) + end) + called = true + downstream = pack(pcall(nextFn, unpack(raw, 1, raw.n))) + if downstream[1] then + boundary.refresh() + publicResult = pack(unpack(downstream, 2, downstream.n)) + if results[name] then + publicResult[1] = boundary.view(publicResult[1], results[name]) + end + end + end + if not downstream[1] then + error(downstream[2], 0) + end + return unpack(publicResult, 1, publicResult.n) + end + local invoked = pack(pcall(callback, nextPublic, unpack(public, 1, public.n))) + active = false + if not invoked[1] then + boundary.close() + error(invoked[2], 0) + end + local values = pack(unpack(invoked, 2, invoked.n)) + if called and not downstream[1] then + boundary.close() + error(downstream[2], 0) + end + local ok, err = pcall(boundary.commit, function() + if results[name] then + values[1] = boundary.restore(values[1], results[name]) + end + end) + boundary.close() + if not ok then + error(err, 0) + end + return unpack(values, 1, values.n) + end, priority, owner) + end, + } +end +return C diff --git a/src/mods/UnifiedContent.lua b/src/mods/UnifiedContent.lua new file mode 100644 index 000000000..93a258afe --- /dev/null +++ b/src/mods/UnifiedContent.lua @@ -0,0 +1,541 @@ +-- Explicit opt-in definition-record boundary. Native registries and gameplay +-- stay authoritative; no save, callback, world or battle data is rewritten. +local Merge = require("src.mods.Merge") +local Schemas = require("src.mods.Schemas") +local Shapes = require("src.mods.UnifiedSchemas") +local Ids = require("src.mods.UnifiedIds") +local Effects = require("src.mods.UnifiedEffects") +local U = {} +local copy, DELETE = Merge.deepCopy, Merge.DELETE +local function unsupported(reason) error({ unsupported = reason }, 0) end +local function check(ok, reason) if not ok then error("invalid unified content: " .. reason, 0) end end +local function equal(a, b, seen) + if a == b then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + seen = seen or {}; if seen[a] == b then return true end; seen[a] = b + for k, v in pairs(a) do if not equal(v, b[k], seen) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end +local function finite(v, seen) + if type(v) == "number" then check(v == v and math.abs(v) < math.huge, "non-finite number") end + if type(v) ~= "table" or v == DELETE then return end + seen = seen or {}; check(not seen[v], "cyclic record"); seen[v] = true + for k, sub in pairs(v) do finite(k, seen); finite(sub, seen) end + seen[v] = nil +end +local function list(value, old, convert) + if value == DELETE then return {} end + check(type(value) == "table", "expected list") + if Merge.isWrapper(value) then + for k in pairs(value) do check(k == "__prepend" or k == "__append", "unknown list extension") end + local out = {} + for _, source in ipairs({ value.__prepend or {}, old or {}, value.__append or {} }) do + for _, row in ipairs(list(source, nil, function(v) return copy(v) end)) do out[#out + 1] = row end + end + return list(out, nil, convert) + end + local count = 0 + for k in pairs(value) do + check(type(k) == "number" and k % 1 == 0 and k >= 1 and k <= #value, "expected contiguous list") + count = count + 1 + end + check(count == #value, "expected contiguous list") + local out = {} + for i, row in ipairs(value) do out[i] = convert(row, i) end + return out +end +local function resolved(ctx, kind, id) + check(type(id) == "string" and id ~= "", "expected string " .. kind .. " ID") + local found, why = ctx.ids:resolve(kind, id) + if not found then unsupported(why) end + return found.key +end +local function readIds(ctx, kind, values) + local out = {} + for _, id in ipairs(values or {}) do + if id ~= 0 then out[#out + 1] = ctx.ids:read(kind, id) end + end + return out +end +local function writeIds(ctx, kind, values, old) + return list(values, old, function(id) return resolved(ctx, kind, id) end) +end +-- Gen 3's seven-byte names also produce these spellings (TypeChart's +-- registrant deliberately keeps them authoritative rather than renaming Data). +local TYPE_ALIASES = { PSYCHIC = "PSYCHIC_TYPE", PSYCHC = "PSYCHIC_TYPE", + MYSTERY = "CURSE_TYPE", FIGHT = "FIGHTING", ELECTR = "ELECTRIC" } +local function typeRead(id) return TYPE_ALIASES[id] or id end +local function typeWrite(ctx, id) + local registry = ctx.loader.content.type_chart + local found = registry:get(id) ~= nil and id or nil + for native, canonical in pairs(TYPE_ALIASES) do + if canonical == id and registry:get(native) ~= nil then + if found then unsupported("ambiguous type ID: " .. tostring(id)) end + found = native + end + end + if found then return found end + unsupported("unknown type ID: " .. tostring(id)) +end +local function reference(ctx, kind, id) + if ctx.loader.content[kind]:get(id) == nil then unsupported("unknown " .. kind .. " ID: " .. tostring(id)) end + return id +end + +local EVO = { + level = "LEVEL", useItem = "ITEM", trade = "TRADE", tradeWithItem = "TRADE", + friendship = "FRIENDSHIP", friendshipDay = "FRIENDSHIP", friendshipNight = "FRIENDSHIP", + HAPPINESS = "FRIENDSHIP", STAT = "LEVEL", + levelAtkGreater = "LEVEL", levelAtkLess = "LEVEL", levelAtkEqual = "LEVEL", +} +local function evolutionRead(ctx, row) + local method = EVO[row.method] or row.method + local out = { method = method, species = ctx.ids:read("pokemon", row.species or row.target) } + if method == "LEVEL" then out.level = row.level or row.param end + if method == "ITEM" then out.item = ctx.ids:read("items", row.item or row.param) end + if method == "TRADE" then out.item = ctx.ids:read("items", row.heldItem or (row.method == "tradeWithItem" and row.param)) end + if method == "FRIENDSHIP" then + out.time = row.timeOfDay or (row.method == "friendshipDay" and "MORNDAY") + or (row.method == "friendshipNight" and "NITE") + end + out.comparison = row.compare or ({ levelAtkGreater = "ATK_GT_DEF", levelAtkLess = "ATK_LT_DEF", + levelAtkEqual = "ATK_EQ_DEF" })[row.method] + return out +end +local function evolutionWrite(ctx, row, old) + if old and equal(row, evolutionRead(ctx, old)) then return copy(old) end + local method = row.method + if method ~= "LEVEL" and method ~= "ITEM" and method ~= "TRADE" and method ~= "FRIENDSHIP" then + unsupported("evolution method has no unified writer: " .. tostring(method)) + end + if ctx.gen == 1 and (method == "FRIENDSHIP" or row.item and method == "TRADE" + or row.time or row.comparison) then unsupported("evolution condition has no Gen 1 representation") end + if ctx.gen >= 4 then + -- This engine implements the native lowercase level method. Other DS + -- evolution conditions retain cartridge parameters and lack a safe writer. + if method ~= "LEVEL" or row.comparison then unsupported("DS evolution condition has no unified writer") end + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + return { method = "level", param = row.level, paramKind = "level", methodId = 4, + target = resolved(ctx, "pokemon", row.species) } + end + local out = { method = method, species = resolved(ctx, "pokemon", row.species) } + if method == "LEVEL" then + check(row.level ~= nil and row.item == nil and row.time == nil, "invalid LEVEL condition") + out.level = row.level + if row.comparison then out.method, out.compare = "STAT", row.comparison end + elseif method == "ITEM" then + check(row.item ~= nil and row.level == nil and row.time == nil and row.comparison == nil, "invalid ITEM condition") + out.item = resolved(ctx, "items", row.item) + elseif method == "TRADE" then + check(row.level == nil and row.time == nil and row.comparison == nil, "invalid TRADE condition") + if row.item then out.heldItem = resolved(ctx, "items", row.item) end + else + check(row.level == nil and row.item == nil and row.comparison == nil, "invalid FRIENDSHIP condition") + out.method, out.timeOfDay = "HAPPINESS", row.time ~= "ANYTIME" and row.time or nil + end + return out +end + +local readRecord +readRecord = function(ctx, kind, id, raw) + local out = { id = id, native = copy(raw) } + for key in pairs(Shapes[kind].fields) do + if key ~= "native" and key ~= "id" then out[key] = copy(raw[key]) end + end + if kind == "pokemon" then + local stats = raw.baseStats or {} + out.baseStats = { hp = stats.hp, attack = stats.attack, defense = stats.defense, speed = stats.speed, + special = stats.special or stats.spatk or stats.spAttack, + specialAttack = ctx.gen > 1 and (stats.spatk or stats.spAttack) or nil, + specialDefense = ctx.gen > 1 and (stats.spdef or stats.spDefense) or nil } + out.types = {}; for i, value in ipairs(raw.types or {}) do out.types[i] = typeRead(value) end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if raw[key] then out[key] = readIds(ctx, "moves", raw[key]) end + end + out.level1Moves, out.learnset, out.evolutions = out.level1Moves or {}, {}, {} + for i, row in ipairs(raw.learnset or {}) do + out.learnset[i] = { level = row.level, move = ctx.ids:read("moves", row.move) } + end + for i, row in ipairs(raw.evolutions or {}) do out.evolutions[i] = evolutionRead(ctx, row) end + elseif kind == "moves" then + out.type = typeRead(raw.type) + out.category = raw.category or raw.class + out.effect = Effects.read(raw.effect, ctx.gen, raw) + out.secondaryChance = Effects.chance(raw, ctx.gen) + elseif kind == "items" then + if type(raw.effect) ~= "string" then out.effect = nil end + if raw.machine then out.machine.move = ctx.ids:read("moves", raw.machine.move) end + elseif kind == "trainers" then + out.parties = {} + for i, party in ipairs(raw.parties or (raw.party and { raw.party }) or {}) do + out.parties[i] = {} + for j, mon in ipairs(party) do + out.parties[i][j] = { species = ctx.ids:read("pokemon", mon.species), level = mon.level, + item = ctx.ids:read("items", mon.item), moves = mon.moves and readIds(ctx, "moves", mon.moves) } + end + end + elseif kind == "encounters" then + local function areaRead(area) + if not area then return nil end + local row = { rate = (area.rate or 0) * 256 / (area.rateMax or 256), slots = {} } + for i, slot in ipairs(area.slots or {}) do + row.slots[i] = { species = ctx.ids:read("pokemon", slot.species), level = slot.level, + minLevel = slot.min, maxLevel = slot.max } + end + if area.byTime then + row.times = { DAY = { rate = row.rate, slots = copy(row.slots) } } + for key, native in pairs({ MORN = "morn", NITE = "nite" }) do + if area.byTime[native] then row.times[key] = areaRead(area.byTime[native]) end + end + end + return row + end + out.grass, out.water = areaRead(raw.grass), areaRead(raw.water) + end + return out +end + +local function writeRecord(ctx, kind, id, value, old, mode) + local previous = old and readRecord(ctx, kind, id, old) or {} + if value.native ~= nil and not equal(value.native, old or {}) then unsupported("native diagnostics are read-only") end + if value.id ~= nil then check(value.id == id, "record id differs from operation id") end + if value.native ~= nil and old then + -- Detached read/modify/write records carry inherited fields. Convert only + -- actual edits, so round trips retain native selectors and absent aliases. + value = copy(value) + for key, v in pairs(value) do + if key ~= "id" and key ~= "native" and equal(v, previous[key]) then value[key] = nil end + end + end + local out, replacements = {}, {} + local function replace(key, v) out[key] = v; replacements[#replacements + 1] = { key } end + for key, v in pairs(value) do + if key ~= "native" and key ~= "id" then out[key] = copy(v) end + end + if not old then out.id = id end + if kind == "pokemon" then + if value.baseStats and value.baseStats ~= DELETE then + local stats, native = value.baseStats, {} + for _, stat in pairs(stats) do check(stat ~= DELETE, "cannot delete a native base stat") end + for _, key in ipairs({ "hp", "attack", "defense", "speed" }) do native[key] = stats[key] end + local a, b = stats.specialAttack, stats.specialDefense + if ctx.gen == 1 then + a, b = a or stats.special or (previous.baseStats or {}).special, + b or stats.special or (previous.baseStats or {}).special + if (a or b) and a ~= b then unsupported("Gen 1 cannot split Special") end + if stats.special and a ~= stats.special then check(false, "conflicting Special values") end + native.special = stats.special or stats.specialAttack or stats.specialDefense + else + -- A patch to special intentionally writes both halves. On detached + -- read/modify/write records, unchanged split fields are inherited. + if stats.special ~= nil and (value.native == nil or stats.special ~= (previous.baseStats or {}).special) then + if value.native ~= nil then + if a == (previous.baseStats or {}).specialAttack then a = nil end + if b == (previous.baseStats or {}).specialDefense then b = nil end + end + check(a == nil or a == stats.special, "conflicting Special Attack values") + a, b = a or stats.special, b or stats.special + elseif stats.special ~= nil and not old then a, b = a or stats.special, b or stats.special end + native.spatk, native.spdef = a, b + if a ~= nil then native.special = a end + if old and old.baseStats and old.baseStats.spAttack ~= nil then native.spAttack = a end + if old and old.baseStats and old.baseStats.spDefense ~= nil then native.spDefense = b end + end + out.baseStats = native + end + check(value.types ~= DELETE, "species types cannot be deleted") + if value.types then + local types = list(value.types, previous.types, function(t) return typeWrite(ctx, t) end) + check(#types >= 1 and #types <= 2, "species needs one or two types") + replace("types", types) + out.type1, out.type2 = types[1], types[2] or types[1] + end + for _, key in ipairs({ "level1Moves", "tmhm", "eggMoves" }) do + if value[key] ~= nil then replace(key, writeIds(ctx, "moves", value[key], previous[key])) end + end + if value.learnset ~= nil then + replace("learnset", list(value.learnset, previous.learnset, function(row) + return { level = row.level, move = resolved(ctx, "moves", row.move) } + end)) + end + if value.evolutions ~= nil then + replace("evolutions", list(value.evolutions, previous.evolutions, function(row, i) + return evolutionWrite(ctx, row, old and old.evolutions and old.evolutions[i]) + end)) + end + if value.growthRate and value.growthRate ~= DELETE then reference(ctx, "growth_rates", value.growthRate) end + if ctx.gen == 1 and (value.eggMoves or value.genderRatio or value.eggCycles) then + unsupported("breeding fields have no Gen 1 representation") + end + if not old and ctx.gen >= 3 then unsupported("new species require native EV/ability/storage metadata; patch an existing species") end + elseif kind == "moves" then + if value.type and value.type ~= DELETE then + out.type = typeWrite(ctx, value.type) + if old and old.typeId and out.type ~= old.type then unsupported("changing a DS type requires native type metadata") end + end + local probability = value.secondaryChance + if value.effect and value.effect ~= DELETE then + local plan, changed = Effects.write(value.effect, ctx.gen, old, function(effect) + return ctx.loader.content.move_effects:get(effect) ~= nil + end) + if not plan then unsupported(changed) end + out.effect = plan.effect + if changed then + out._api3EffectId = plan._api3EffectId + if ctx.gen == 3 then out.gen3Effect = plan.gen3Effect or DELETE end + if ctx.gen > 1 and probability == nil then probability = plan.probability or DELETE end + end + end + out.secondaryChance = nil + if probability ~= nil then + if ctx.gen == 1 or ctx.gen > 3 then unsupported("secondaryChance requires a Gen 2/3 native adapter") end + if old and probability ~= DELETE and probability == Effects.chance(old, ctx.gen) + and (probability ~= 0 or old._api3EffectChance or value.native ~= nil) then + -- Detached records preserve the exact original native chance byte. + else + out.effectChance = probability == DELETE and DELETE or math.floor(probability * 256 / 100 + 0.5) + out._api3EffectChance = probability == DELETE and DELETE or true + if ctx.gen == 3 then out.secondaryChance = probability end + end + end + if value.category ~= nil and old and old.class ~= nil then + check(value.category ~= DELETE, "native move class cannot be deleted") + out.class = value.category + if old.classId ~= nil then out.classId = ({ physical = 0, special = 1, status = 2 })[value.category] end + end + elseif kind == "items" then + if value.machine and value.machine ~= DELETE then + out.machine.move = resolved(ctx, "moves", value.machine.move) + end + for key, registry in pairs({ effect = "item_effects", ball = "balls" }) do + if value[key] and value[key] ~= DELETE then + reference(ctx, registry, value[key]) + if ctx.gen > 1 and (not old or value[key] ~= old[key]) then unsupported("changing item behavior requires native metadata") end + end + end + elseif kind == "trainers" and value.parties ~= nil then + local prior = old and (old.parties or (old.party and { old.party })) or {} + local parties = list(value.parties, previous.parties, function(party, i) + return list(party, nil, function(mon, j) + local native = copy(prior[i] and prior[i][j] or {}) + native.species, native.level = resolved(ctx, "pokemon", mon.species), mon.level + if ctx.gen == 1 and mon.item then unsupported("held items have no Gen 1 representation") end + native.item = mon.item and resolved(ctx, "items", mon.item) or nil + native.moves = mon.moves and writeIds(ctx, "moves", mon.moves) or nil + return native + end) + end) + replace("parties", parties) + if old and old.party then + check(#parties == 1, "native trainer has exactly one party") + replace("party", parties[1]) + end + elseif kind == "encounters" then + local function areaWrite(input, native, before, path) + if input == DELETE then return DELETE end + native, before = native or {}, before or {} + check(input.times ~= DELETE, "native time tables cannot be deleted") + if input.times then + input = copy(input) + if equal(input.rate, before.rate) then input.rate = nil end + if equal(input.slots, before.slots) then input.slots = nil end + end + local result = {} + if input.rate ~= nil then + check(input.rate ~= DELETE, "encounter rate cannot be deleted") + local rate = input.rate * (native.rateMax or 256) / 256 + if math.abs(rate - math.floor(rate + 0.5)) > 1e-8 then unsupported("encounter rate is not representable in native units") end + result.rate = math.floor(rate + 0.5) + end + if input.slots ~= nil then + local rows = list(input.slots, before.slots, function(slot, i) + local row = copy((native.slots or {})[i] or {}) + row.species = resolved(ctx, "pokemon", slot.species) + if slot.level then + check(slot.minLevel == nil and slot.maxLevel == nil, "conflicting encounter levels") + row.level, row.min, row.max = slot.level, nil, nil + else + check(slot.minLevel ~= nil and slot.maxLevel ~= nil and slot.minLevel <= slot.maxLevel, "invalid encounter level range") + row.level, row.min, row.max = nil, slot.minLevel, slot.maxLevel + end + if slot.chance then unsupported("changing encounter weights requires native buckets") end + return row + end) + if #rows ~= #(native.slots or {}) then unsupported("changing slot count requires native probability buckets") end + result.slots = rows; local p = copy(path); p[#p + 1] = "slots"; replacements[#replacements + 1] = p + end + if input.times then check(ctx.gen == 2 and native.byTime ~= nil, "time-specific encounters require a native time table") end + if native.byTime then + result.byTime = {} + for time, nativeTime in pairs({ MORN = "morn", DAY = "day", NITE = "nite" }) do + local shared = { rate = input.rate, slots = input.slots } + -- A full detached record contains inherited times: only explicit + -- changes override a shared rate/slot patch. + local specific = input.times and input.times[time] + if specific and before.times and equal(specific, before.times[time]) then specific = nil end + if specific then + for k, v in pairs(specific) do + if not before.times or not before.times[time] or not equal(v, before.times[time][k]) then shared[k] = v end + end + end + if nativeTime == "day" then + local d = areaWrite(shared, { rate = native.rate, rateMax = native.rateMax, slots = native.slots }, before, path) + if d.rate ~= nil then result.rate = d.rate end + if d.slots then result.slots = d.slots end + elseif native.byTime[nativeTime] then + local p = copy(path); p[#p + 1] = "byTime"; p[#p + 1] = nativeTime + result.byTime[nativeTime] = areaWrite(shared, native.byTime[nativeTime], before.times and before.times[time], p) + end + end + end + return result + end + for _, key in ipairs({ "grass", "water" }) do + if value[key] ~= nil then out[key] = areaWrite(value[key], old and old[key], previous[key], { key }) end + end + end + return out, replacements +end + +local function supported(kind, gen) + if kind == "encounters" and gen >= 4 then + return false, "unsupported: DS encounters have seasonal/daily replacement tables; use native content" + end + return true +end +local function protect(fn) + local result = { pcall(fn) } + if not result[1] then + if type(result[2]) == "table" and result[2].unsupported then return nil, "unsupported: " .. result[2].unsupported end + error(result[2], 0) + end + return unpack(result, 2) +end +function U.new(loader, mod, native, gen) + local out = {}; for name, handle in pairs(native) do out[name] = handle end + for kind, shape in pairs(Shapes) do + local registry = loader.content[kind] + if gen >= 4 and kind ~= "encounters" then registry.numericIds = true end + local f = Schemas.f + registry.referenceSpec = copy(registry.spec) + if kind == "pokemon" then + registry.referenceSpec.fields.evolutions = f.list(f.rec { + species = f.opt(f.id("pokemon")), target = f.opt(f.id("pokemon")), + item = f.opt(f.id("items")), heldItem = f.opt(f.id("items")), + }) + elseif kind == "trainers" then + local parties = f.list(f.list(f.rec { species = f.id("pokemon"), + item = f.opt(f.id("items")), moves = f.opt(f.list(f.id("moves"))) })) + registry.referenceSpec.fields.parties = parties + registry.referenceSpec.fields.party = parties.inner + elseif kind == "encounters" then + local slots = f.list(f.rec { species = f.id("pokemon") }) + local area = f.rec { slots = slots, byTime = f.opt(f.map(f.str, f.rec { slots = slots })) } + registry.referenceSpec.fields.grass, registry.referenceSpec.fields.water = area, area + if gen >= 4 then + -- The DS writer is unavailable, but removing a numeric species must + -- still see native encounter references, including seasonal tables. + local fields = {} + for _, key in ipairs({ "grass", "darkGrass", "rustling" }) do fields[key] = slots end + for _, key in ipairs({ "surf", "oldRod", "goodRod", "superRod", "ripplingSurf", "ripplingFishing" }) do + fields[key] = f.rec { slots = slots } + end + fields.timed = f.map(f.str, f.list(f.id("pokemon"))) + fields.radar = f.list(f.id("pokemon")) + fields.dualSlot = f.map(f.str, f.list(f.id("pokemon"))) + fields.swarm = f.union { f.list(f.id("pokemon")), f.map(f.str, f.id("pokemon")) } + fields.seasons = f.map(f.str, f.rec(fields)) + registry.referenceSpec.fields = fields + end + end + local available, why = supported(kind, gen) + local function context() return { loader = loader, gen = gen, ids = Ids.new(loader) } end + local function read(id) + if not available then return nil, why end + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found then return nil, err end + local raw = registry:get(found.key) + return raw and readRecord(ctx, kind, id, raw) or nil + end + local handle = { available = available, supports = function() return available, why end, + get = function(_, id) return read(id) end, + has = function(_, id) return read(id) ~= nil end, + each = function() + if not available then return function() end, why end + local ctx, keys = context(), {} + for id, found in pairs(ctx.ids:entries(kind)) do + if found == false then error("ambiguous " .. kind .. " ID: " .. id, 0) end + keys[#keys + 1] = id + end + table.sort(keys) + local i = 0 + return function() + i = i + 1; local id = keys[i] + if id == nil then return end + local found = ctx.ids:resolve(kind, id) + return id, readRecord(ctx, kind, id, registry:get(found.key)) + end + end, + } + for _, verb in ipairs({ "register", "override", "patch", "remove" }) do + handle[verb] = function(_, id, value) + if not available then return nil, why end + return protect(function() + check(type(id) == "string" and id ~= "", "operation id must be a non-empty string") + local ctx = context() + local found, err = ctx.ids:resolve(kind, id) + if not found and err:match("^ambiguous") then unsupported(err) end + if not found then + if gen >= 4 then unsupported("new numeric records require native storage allocation") end + found = { key = id, keys = { id } } + end + local old = registry:get(found.key) + if verb == "register" then check(old == nil, kind .. " already registered: " .. id) end + if verb == "remove" then + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do registry:remove(key, mod.manifest.id) end + return true + end + finite(value) + check(type(value) == "table" and value ~= DELETE, "expected record") + for key in pairs(value) do check(shape.fields[key] ~= nil, "unknown " .. kind .. " field " .. tostring(key)) end + local function deletes(fields, record) + for key, entry in pairs(record) do + local field = fields[key] + if field and field.kind ~= "opt" then + check(entry ~= DELETE or field.kind == "list", "cannot delete required field " .. key) + if field.kind == "rec" and type(entry) == "table" then deletes(field.fields, entry) end + end + end + end + deletes(shape.fields, value) + local valid, validation = Schemas.check(shape, kind, id, value, verb) + check(valid, validation or "invalid record") + local payload, replacements = writeRecord(ctx, kind, id, value, old, verb) + if verb == "patch" and not old then unsupported("cannot patch absent " .. kind .. " ID: " .. id) end + check(not registry.frozen, kind .. ": content is frozen after load") + loader:_journal(kind) + for _, key in ipairs(found.keys) do + if verb == "patch" then registry:patch(key, payload, mod.manifest.id, replacements) + else + local record = Merge.deepMerge(copy(old or {}), payload) + for _, path in ipairs(replacements) do + local into, from = record, payload + for i = 1, #path - 1 do into, from = into[path[i]], from[path[i]] end + into[path[#path]] = copy(from[path[#path]]) + end + registry[verb](registry, key, record, mod.manifest.id) + end + end + return true + end) + end + end + out[kind] = handle + end + return out +end +return U diff --git a/src/mods/UnifiedEffects.lua b/src/mods/UnifiedEffects.lua new file mode 100644 index 000000000..2083f29a1 --- /dev/null +++ b/src/mods/UnifiedEffects.lua @@ -0,0 +1,135 @@ +-- Canonical effect IDs over this host's shared native battle handlers. +local C = require("src.mods.MoveEffectContract") +local E = {} +local numbers, nativeNames, variants = {}, {}, {} +for number in pairs(C.native3) do + numbers[#numbers + 1] = tonumber(number) +end +table.sort(numbers) +for _, number in ipairs(numbers) do + for _, name in ipairs(C.native3[tostring(number)]) do + if nativeNames[name] == nil then + nativeNames[name] = number + end + end +end +for id in pairs(C.variants) do + variants[#variants + 1] = id +end +table.sort(variants) +local function native(row, gen, chance) + if gen == 1 then + return row.native + end + local names = C.native3[tostring(row.semantic)] + if not names then + return nil + end + return names[2] and (chance or row.chance or 0) > 10 and names[2] or names[1] +end +local function opaque(value, gen) + return "native:" .. gen .. ":" .. tostring(value) +end +function E.chance(raw, gen) + if gen ~= 2 and gen ~= 3 then + return nil + end + if type(raw.effectChance) == "number" then + return math.max(0, math.min(100, math.floor(raw.effectChance * 100 / 256 + 0.5))) + end + return gen == 3 and raw.secondaryChance or nil +end +function E.read(value, gen, raw) + if value == nil then + return nil + end + if gen == 1 or gen > 3 then + return type(value) == "string" and value or nil + end + raw = raw or {} + local probability = E.chance(raw, gen) + local authored = raw._api3EffectId + local binding = type(authored) == "string" and C.bindings[authored] + if binding and binding[tostring(gen)] then + local row = binding[tostring(gen)] + for _, name in ipairs(C.native3[tostring(row.semantic)] or {}) do + if name == value then + return authored + end + end + end + if authored == value and not C.reserved[authored] then + return authored + end + local semantic = nativeNames[value] + if gen == 3 and type(raw.gen3Effect) == "number" then + local names = C.native3[tostring(raw.gen3Effect)] + for _, name in ipairs(names or {}) do + if name == value then + semantic = raw.gen3Effect + end + end + elseif value == "HEAL_EFFECT" and raw.id == "REST" then + semantic = 37 + end + if not semantic then + return opaque(value, gen) + end + for _, id in ipairs(variants) do + local row = C.variants[id] + if row.semantic == semantic and row.chance == probability and C.bindings[id][tostring(gen)] then + return id + end + end + local id = C.read3[tostring(semantic)] + if id and C.bindings[id] and C.bindings[id][tostring(gen)] then + return id + end + return opaque(value, gen) +end +function E.write(id, gen, old, registered) + if type(id) ~= "string" or id == "" then + return nil, "move effect must be a string id" + end + id = ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id + if old and (id == E.read(old.effect, gen, old) or id == opaque(old.effect, gen)) then + return { effect = old.effect }, false + end + if gen > 3 then + return nil, "changing move effects has no native adapter for this generation" + end + if id:match("^native:") then + return nil, "native effect token requires its original record" + end + local binding = C.bindings[id] + local row = binding and binding[tostring(gen)] + if not row then + if binding or C.reserved[id] or not registered(id) then + return nil, "effect has no native handler: " .. id + end + return { effect = id, _api3EffectId = gen > 1 and id or nil }, true + end + if + gen > 1 + and ( + (row.semantic == 37 and (not old or old.id ~= "REST")) + or row.semantic == 32 and old and old.id == "REST" + or row.semantic == 130 and (not old or old.id ~= "SONICBOOM") + or row.semantic == 41 and old and old.id == "SONICBOOM" + ) + then + return nil, "native effect depends on move identity: " .. id + end + local target = native(row, gen) + if not target or not registered(target) then + return nil, "effect has no native handler: " .. id + end + local out = { effect = target } + if gen > 1 then + out._api3EffectId = id + out.probability = row.chance + out.gen3Effect = gen == 3 and row.semantic or nil + end + return out, true +end +return E diff --git a/src/mods/UnifiedIds.lua b/src/mods/UnifiedIds.lua new file mode 100644 index 000000000..50866a636 --- /dev/null +++ b/src/mods/UnifiedIds.lua @@ -0,0 +1,85 @@ +-- Resolve portable names through this boot's own data, never a hard-coded +-- National Dex/item-number table (ROM hacks can assign entirely different IDs). +local Ids = {} +local function slug(name) + if type(name) ~= "string" then return nil end + name = name:gsub("é", "e"):gsub("É", "E"):gsub("♀", "_F"):gsub("♂", "_M") + :gsub("['’]", ""):upper():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "") + name = name:gsub("^(TM)(%d+)$", "%1_%2"):gsub("^(HM)(%d+)$", "%1_%2") + return name ~= "" and name or nil +end +local aliases = { FARFETCH_D = "FARFETCHD", MR__MIME = "MR_MIME" } +local function canonical(key, row, kind) + if kind == "pokemon" or kind == "moves" or kind == "items" then + if type(key) ~= "string" or key:match("^SPECIES_%d+$") + or key:match("^MOVE_%d+$") or key:match("^ITEM_%d+$") then + return aliases[slug(row.name)] or slug(row.name) + end + return aliases[key] or key + end + -- Trainer classes, individual trainers and map IDs are game-specific. + return type(key) == "number" and tostring(key) or key +end + +function Ids.new(loader) + local indexes = {} + local function index(kind) + if indexes[kind] then return indexes[kind] end + local out = { public = {}, native = {} } + indexes[kind] = out + local registry = assert(loader.content[kind], "unknown registry " .. tostring(kind)) + -- Preserve identity/name metadata before the loader merges alias keys + -- into separate result tables. Presentation-name patches cannot rename IDs. + loader._unifiedIdBase = loader._unifiedIdBase or {} + local base = loader._unifiedIdBase[kind] + if not base then + base = {} + for key, row in pairs(registry.base and registry.base() or {}) do + if type(row) == "table" then + base[key] = { identity = row, id = canonical(key, row, kind) } + end + end + loader._unifiedIdBase[kind] = base + end + local groups = {} + for key, row in registry:each() do + if type(row) == "table" and (row.name or kind == "encounters") and key ~= 0 then + -- Aliases pointing at one pristine record form one identity even + -- after an earlier native mod patched only one of its keys. + local identity = base[key] and base[key].identity or row + local group = groups[identity] + if not group then + local id = base[key] and base[key].id or canonical(key, row, kind) + if id then + group = { id = id, key = key, keys = {} } + groups[identity] = group + local previous = out.public[id] + -- false is an ambiguity marker, never last-writer-wins. + if previous ~= nil then out.public[id] = false else out.public[id] = group end + end + end + if group then + group.keys[#group.keys + 1] = key + out.native[key] = group + if type(key) == "number" then group.key = key end + end + end + end + return out + end + return { + resolve = function(_, kind, id) + local found = index(kind).public[id] + if found == false then return nil, "ambiguous " .. kind .. " ID: " .. tostring(id) end + if not found then return nil, "unknown " .. kind .. " ID: " .. tostring(id) end + return found + end, + read = function(_, kind, id) + if id == nil or id == false or id == 0 or id == "" then return nil end + local found = index(kind).native[id] + return found and found.id or tostring(id) + end, + entries = function(_, kind) return index(kind).public end, + } +end +return Ids diff --git a/src/mods/UnifiedSchemas.lua b/src/mods/UnifiedSchemas.lua new file mode 100644 index 000000000..aa9a91cbe --- /dev/null +++ b/src/mods/UnifiedSchemas.lua @@ -0,0 +1,60 @@ +-- The opt-in content format is separate from the legacy/native schemas. +-- These are definition records, not saved Pokemon or callback payloads. +local Schemas = require("src.mods.Schemas") +local f = Schemas.f +local id = f.str +local ids = f.list(id) +local level = f.int(1, 100) +local stats = f.rec { + hp = f.int(1, 255), attack = f.int(1, 255), defense = f.int(1, 255), + speed = f.int(1, 255), special = f.int(1, 255), + specialAttack = f.opt(f.int(1, 255)), specialDefense = f.opt(f.int(1, 255)), +} +local evolution = f.rec { + method = id, species = id, level = f.opt(level), item = f.opt(id), + time = f.opt(f.enum { "ANYTIME", "MORNDAY", "NITE" }), + comparison = f.opt(f.enum { "ATK_GT_DEF", "ATK_LT_DEF", "ATK_EQ_DEF" }), +} +local slot = f.rec { + species = id, level = f.opt(level), minLevel = f.opt(level), maxLevel = f.opt(level), + chance = f.opt(f.numRange(0, 100)), +} +local area = f.rec { rate = f.numRange(0, 256), slots = f.list(slot), + times = f.opt(f.map(f.enum { "MORN", "DAY", "NITE" }, + f.rec { rate = f.opt(f.numRange(0, 256)), slots = f.opt(f.list(slot)) })) } +local U = { + pokemon = { fields = { + id = id, name = f.str, dex = f.opt(f.int(1)), types = ids, baseStats = stats, + catchRate = f.int(0, 255), baseExp = f.int(0), growthRate = id, + level1Moves = ids, learnset = f.list(f.rec { level = level, move = id }), + evolutions = f.list(evolution), tmhm = f.opt(ids), eggMoves = f.opt(ids), + genderRatio = f.opt(f.int(0, 255)), eggCycles = f.opt(f.int(0, 255)), + spriteFront = f.opt(f.path), spriteBack = f.opt(f.path), frontSize = f.opt(f.int(1, 7)), + trueColor = f.opt(f.bool), + } }, + moves = { fields = { + id = id, name = f.str, type = id, power = f.int(0, 255), accuracy = f.int(0, 100), + pp = f.int(0, 64), effect = f.opt(id), secondaryChance = f.opt(f.int(0, 100)), + category = f.opt(f.enum { "physical", "special", "status" }), + priority = f.opt(f.int(-7, 7)), description = f.opt(f.str), + } }, + items = { fields = { + id = id, name = f.str, price = f.int(0), + machine = f.opt(f.rec { kind = f.str, move = id, number = f.int(0) }), + effect = f.opt(id), ball = f.opt(id), tossable = f.opt(f.bool), + needsTarget = f.opt(f.bool), description = f.opt(f.str), + } }, + trainers = { fields = { + id = id, name = f.str, pic = f.opt(f.path), baseMoney = f.opt(f.int(0)), + battleTheme = f.opt(id), partyNames = f.opt(f.list(f.str)), + parties = f.list(f.list(f.rec { + species = id, level = level, item = f.opt(id), moves = f.opt(ids), + })), + } }, + encounters = { fields = { id = f.opt(id), grass = f.opt(area), water = f.opt(area) } }, +} +for _, spec in pairs(U) do + spec.fields.native = f.opt(f.any) + spec.semantics = "record" +end +return U diff --git a/src/mods/WorldOverview.lua b/src/mods/WorldOverview.lua new file mode 100644 index 000000000..491963d48 --- /dev/null +++ b/src/mods/WorldOverview.lua @@ -0,0 +1,153 @@ +-- Read-only world summaries. Do not call timeOfDay(), rollTable(), map loading, +-- or object visibility refreshes here: each can change the live simulation. +local Ids = require("src.mods.UnifiedIds") +local GameVersion = require("src.core.GameVersion") +local Overview = {} + +function Overview.context(loader) + Ids.new(loader):entries("pokemon") + return function() + return Ids.new(loader) + end +end + +function Overview.install(api, loader) + local context = Overview.context(loader) + if api.supports then + local supports = api.supports + function api:supports(surface, name) + if surface == "world" and (name == "mapOverview" or name == "effectiveEncounters") then + return true + end + return supports(self, surface, name) + end + end + return context +end + +local function dataIds(data) + local rows = data.pokemon or {} + return Ids.new({ + content = { + pokemon = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + }, + }, + }) +end + +local periods = { MORN = "MORNING", MORNING = "MORNING", DAY = "DAY", NITE = "NITE", NIGHT = "NITE" } +local hours = { MORNING = 6, DAY = 12, NITE = 22 } + +function Overview.encounters(game, ow, context, mapId, terrain, opts) + if terrain ~= "grass" and terrain ~= "water" and terrain ~= "indoor" then + return nil, "invalid terrain: " .. tostring(terrain) + end + if opts ~= nil and type(opts) ~= "table" then + return nil, "opts must be a table" + end + local daytime = opts and opts.daytime + if daytime ~= nil then + daytime = type(daytime) == "string" and periods[daytime:upper()] + if not daytime then + return nil, "invalid daytime" + end + end + local data = game and game.data + if not (data and type(data.encounters) == "table") then + return nil, "encounters data not loaded" + end + local def = data.maps and data.maps[mapId] + if not def then + return nil, "unknown map: " .. tostring(mapId) + end + -- HGSS's Safari table depends on the player's position in a six-area matrix. + -- Encounter.forMap resolves it through the Game singleton and seeds Safari + -- save state. Refuse this exceptional table instead of querying another game. + if def.cameraFamily == "hgss" and require("src.world.HgssSafariZone").isSafariMap(def) then + return nil, "position-dependent Safari encounters unavailable" + end + if GameVersion.get() == "emerald" then + if + require("src.script.Gen3BattlePike").inWildRoom(mapId) + or require("src.script.Gen3BattlePyramid").onFloor(mapId) + then + return nil, "facility encounters unavailable" + end + end + local Encounter = require("src.world.Encounter") + local hour = daytime and hours[daytime] + local enc = Encounter.forMap(data, def, mapId, hour, game.save) + if not daytime then + -- A world.tod hook's most recent effective period is already on the world. + -- Calling it again would execute mod code and can emit/change world state. + local Runtime = require("src.mods.Runtime") + if Runtime.wantsHook("world.tod") and ow then + daytime = ow.tod + end + if GameVersion.isGen2() then + if not daytime then + hour = tonumber(os.date("%H")) or 12 + daytime = hour < 4 and "NITE" or hour < 10 and "MORNING" or hour < 18 and "DAY" or "NITE" + end + else + daytime = daytime or (ow and ow.tod) or "DAY" + end + end + local slots = enc and enc[terrain == "indoor" and "grass" or terrain] + if terrain ~= "water" then + slots = Encounter.atTime(slots, daytime) + end + if not slots then + return { chance = 0, dist = {} } + end + local maximum = tonumber(slots.rateMax) or 256 + local rate = tonumber(slots.rate) or 0 + local span = tonumber(slots.bucketSpan) or 256 + if + maximum <= 0 + or maximum ~= math.floor(maximum) + or maximum == math.huge + or rate ~= rate + or rate == math.huge + or rate == -math.huge + or span <= 0 + or span ~= math.floor(span) + or span == math.huge + then + return nil, "invalid encounter rate/span" + end + local dist, prev = {}, 0 + local weights = slots.buckets or require("src.world.FieldDefaults").constant(data, "encounterBuckets") + if type(weights) ~= "table" or (slots.slots ~= nil and type(slots.slots) ~= "table") then + return nil, "invalid encounter buckets/slots" + end + local ids = context and context() or dataIds(data) + for i, threshold in ipairs(weights or {}) do + if + type(threshold) ~= "number" + or threshold ~= math.floor(threshold) + or threshold < prev + or threshold > span + then + return nil, "invalid encounter buckets" + end + local slot = slots.slots and slots.slots[i] + if rate > 0 and slot and slot.species and threshold > prev then + local id = ids:read("pokemon", slot.species) + if id then + dist[id] = (dist[id] or 0) + threshold - prev + end + end + prev = threshold + end + -- Same independent integer rate gate as rollTable, without drawing a slot. + return { chance = math.max(0, math.min(maximum, math.ceil(rate))) / maximum, dist = dist } +end + +return Overview diff --git a/src/mods/WorldReads.lua b/src/mods/WorldReads.lua new file mode 100644 index 000000000..2ca8f2504 --- /dev/null +++ b/src/mods/WorldReads.lua @@ -0,0 +1,227 @@ +-- Data-only readers shared by mod.world. Nothing here constructs a Pokemon, +-- loads a map, calls gameplay methods or advances either random generator. +local Ids = require("src.mods.UnifiedIds") +local Reads = {} +local METHODS = { party = true, activeBlockAt = true, liveMaps = true } + +function Reads.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +-- Capture native alias identity/name metadata before the loader splits alias +-- rows during merging. Fresh indexes at read time still include later mods. +function Reads.context(loader) + local ids = Ids.new(loader) + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + ids:entries(kind) + end + return { + ids = function() + return Ids.new(loader) + end, + } +end + +-- Engine callers can construct WorldAPI without a mod loader too. The +-- private registry facade only reads data; Ids' bookkeeping stays on it. +local function dataIds(data) + local loader = { content = {} } + for _, kind in ipairs({ "pokemon", "moves", "items" }) do + local rows = type(data[kind]) == "table" and data[kind] or {} + loader.content[kind] = { + base = function() + return rows + end, + each = function() + return pairs(rows) + end, + } + end + return Ids.new(loader) +end + +local function copy(value, seen) + local kind = type(value) + if kind ~= "table" then + if kind == "number" or kind == "string" or kind == "boolean" then + return value + end + return nil + end + seen = seen or {} + if seen[value] then + return seen[value] + end + local out = {} + seen[value] = out + for key, sub in pairs(value) do + if type(key) == "string" or type(key) == "number" then + out[key] = copy(sub, seen) + end + end + return out +end +local STATUS = + { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local STAT = { + hp = "hp", + attack = "attack", + defense = "defense", + speed = "speed", + special = "special", + specialAttack = "spatk", + specialDefense = "spdef", +} + +-- The same common live-Pokemon shape as the unified callback boundary. +-- Native diagnostics are data-only copies, including unknown save extensions. +function Reads.party(game, context) + local save = game and game.save + if not save then + return nil, "no save" + end + local data = game.data or {} + local ids = context and context.ids() or dataIds(data) + local out = {} + for i, raw in ipairs(save.party or {}) do + local stats, moves = {}, {} + for key, native in pairs(STAT) do + stats[key] = (raw.stats or {})[native] + end + stats.specialAttack = stats.specialAttack or stats.special + stats.specialDefense = stats.specialDefense or stats.special + for slot, move in ipairs(raw.moves or {}) do + local id = type(move) == "table" and move.id or move + local def = (data.moves or {})[id] + local ups = type(move) == "table" and (move.ppUps or 0) or 0 + moves[slot] = { + id = ids:read("moves", id), + pp = type(move) == "table" and move.pp or nil, + maxPp = def and ((def.pp or 0) + ups * math.floor((def.pp or 0) / 5)) or nil, + ppUps = ups, + } + end + out[i] = { + species = ids:read("pokemon", raw.species), + item = ids:read("items", raw.item), + nickname = raw.nickname, + name = raw.name, + level = raw.level, + hp = raw.hp, + maxHp = raw.stats and raw.stats.hp, + status = STATUS[raw.status] or raw.status, + isEgg = raw.isEgg == true, + stats = stats, + moves = moves, + native = copy(raw), + } + end + return out +end + +local function integer(value, low) + return type(value) == "number" + and value == value + and value < math.huge + and value > -math.huge + and value % 1 == 0 + and value >= low +end + +-- Public block coordinates are zero-based, not walk-cell coordinates. Derive +-- geometry from this map's runtime tileset, not the selected game's number. +function Reads.metadata(map) + local def = map and map.def + if + not def + or not integer(def.width, 1) + or not integer(def.height, 1) + or not integer(map.blockCells, 1) + or not integer(map.blockTiles, 1) + or map.blockTiles * 8 ~= map.blockCells * 16 + then + return nil, "block geometry unavailable" + end + return { + tileSize = map.blockTiles * 8, + cellSize = 16, + coordinateBase = 0, + kind = (def.generation == 4 or def.generation == 5 or def.gen5) and "runtime_cell" + or map.blockCells == 1 and "metatile" + or "block", + width = def.width, + height = def.height, + } +end + +function Reads.activeBlockAt(map, mapId, bx, by) + if map.id ~= mapId then + return nil, "map is not active" + end + if not integer(bx, 0) or not integer(by, 0) then + return nil, "invalid block coordinates" + end + local metadata, reason = Reads.metadata(map) + if not metadata then + return nil, reason + end + if bx >= metadata.width or by >= metadata.height then + return nil, "block coordinates out of bounds" + end + local index = by * metadata.width + bx + 1 + local block = map.blockPatch and map.blockPatch[index] + if block == nil then + local blocks = map.def.blocks + if type(blocks) == "table" then + block = blocks[index] + elseif type(blocks) == "string" then + -- Respect a cache already used by Map:blockAt, without populating it. + if map.def._blockArray then + block = map.def._blockArray[index] + else + local lo, hi = blocks:byte(index * 2 - 1, index * 2) + if hi then + block = (lo + hi * 256) % 1024 + end + end + end + end + if not integer(block, 0) then + return nil, "block unavailable" + end + return block, metadata +end + +function Reads.liveMaps(world) + local active = world.map + local out = {} + local seen = {} + local function add(map, x, y, isActive) + if not map then + return + end + -- A connected layout can show one map at several different offsets. + -- Collapse duplicate placements only, retaining the spatial instances. + local key = tostring(map.id) .. ":" .. tostring(x) .. ":" .. tostring(y) + if seen[key] then + return + end + seen[key] = true + local metadata = Reads.metadata(map) + out[#out + 1] = { + mapId = map.id, + x = x, + y = y, + active = isActive, + width = map.widthCells, + height = map.heightCells, + block = metadata, + } + end + add(active, 0, 0, true) + for _, neighbor in ipairs(world.neighbors or {}) do + add(neighbor.map, neighbor.cx or 0, neighbor.cy or 0, false) + end + return out +end +return Reads diff --git a/src/mods/flag_names/emerald.lua b/src/mods/flag_names/emerald.lua new file mode 100644 index 000000000..815b89f80 --- /dev/null +++ b/src/mods/flag_names/emerald.lua @@ -0,0 +1,1545 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + limit = 2400, + records = { + [1] = { "FLAG_TEMP_1", "FLAG_TEMP_SKIP_GABBY_INTERVIEW", "TEMP_1", "TEMP_SKIP_GABBY_INTERVIEW" }, + [2] = { "FLAG_TEMP_2", "FLAG_TEMP_REGICE_PUZZLE_STARTED", "TEMP_2", "TEMP_REGICE_PUZZLE_STARTED" }, + [3] = { "FLAG_TEMP_3", "FLAG_TEMP_REGICE_PUZZLE_FAILED", "TEMP_3", "TEMP_REGICE_PUZZLE_FAILED" }, + [4] = { "FLAG_TEMP_4", "TEMP_4" }, + [5] = { "FLAG_TEMP_5", "TEMP_5" }, + [6] = { "FLAG_TEMP_6", "TEMP_6" }, + [7] = { "FLAG_TEMP_7", "TEMP_7" }, + [8] = { "FLAG_TEMP_8", "NUM_BADGES", "TEMP_8" }, + [9] = { "FLAG_TEMP_9", "TEMP_9" }, + [10] = { "FLAG_TEMP_A", "TEMP_A" }, + [11] = { "FLAG_TEMP_B", "TEMP_B" }, + [12] = { "FLAG_TEMP_C", "TEMP_C" }, + [13] = { "FLAG_TEMP_D", "TEMP_D" }, + [14] = { "FLAG_TEMP_E", "TEMP_E" }, + [15] = { "FLAG_TEMP_F", "TEMP_F" }, + [16] = { "FLAG_TEMP_10", "NUM_TEMP_VARS", "TEMP_10" }, + [17] = { "FLAG_TEMP_11", "FLAG_TEMP_HIDE_MIRAGE_ISLAND_BERRY_TREE", "TEMP_11", "TEMP_HIDE_MIRAGE_ISLAND_BERRY_TREE" }, + [18] = { "FLAG_TEMP_12", "TEMP_12" }, + [19] = { "FLAG_TEMP_13", "TEMP_13" }, + [20] = { "FLAG_TEMP_14", "NUM_WONDER_CARD_FLAGS", "TEMP_14" }, + [21] = { "FLAG_TEMP_15", "TEMP_15" }, + [22] = { "FLAG_TEMP_16", "TEMP_16" }, + [23] = { "FLAG_TEMP_17", "TEMP_17" }, + [24] = { "FLAG_TEMP_18", "TEMP_18" }, + [25] = { "FLAG_TEMP_19", "TEMP_19" }, + [26] = { "FLAG_TEMP_1A", "TEMP_1A" }, + [27] = { "FLAG_TEMP_1B", "TEMP_1B" }, + [28] = { "FLAG_TEMP_1C", "TEMP_1C" }, + [29] = { "FLAG_TEMP_1D", "TEMP_1D" }, + [30] = { "FLAG_TEMP_1E", "TEMP_1E" }, + [31] = { "FLAG_TEMP_1F", "TEMP_1F", "TEMP_FLAGS_END" }, + [32] = { "FLAG_UNUSED_0x020", "NUM_TEMP_FLAGS", "UNUSED_0x020" }, + [33] = { "FLAG_UNUSED_0x021", "UNUSED_0x021" }, + [34] = { "FLAG_UNUSED_0x022", "UNUSED_0x022" }, + [35] = { "FLAG_UNUSED_0x023", "UNUSED_0x023" }, + [36] = { "FLAG_UNUSED_0x024", "UNUSED_0x024" }, + [37] = { "FLAG_UNUSED_0x025", "UNUSED_0x025" }, + [38] = { "FLAG_UNUSED_0x026", "UNUSED_0x026" }, + [39] = { "FLAG_UNUSED_0x027", "UNUSED_0x027" }, + [40] = { "FLAG_UNUSED_0x028", "UNUSED_0x028" }, + [41] = { "FLAG_UNUSED_0x029", "UNUSED_0x029" }, + [42] = { "FLAG_UNUSED_0x02A", "UNUSED_0x02A" }, + [43] = { "FLAG_UNUSED_0x02B", "UNUSED_0x02B" }, + [44] = { "FLAG_UNUSED_0x02C", "UNUSED_0x02C" }, + [45] = { "FLAG_UNUSED_0x02D", "UNUSED_0x02D" }, + [46] = { "FLAG_UNUSED_0x02E", "UNUSED_0x02E" }, + [47] = { "FLAG_UNUSED_0x02F", "UNUSED_0x02F" }, + [48] = { "FLAG_UNUSED_0x030", "UNUSED_0x030" }, + [49] = { "FLAG_UNUSED_0x031", "UNUSED_0x031" }, + [50] = { "FLAG_UNUSED_0x032", "UNUSED_0x032" }, + [51] = { "FLAG_UNUSED_0x033", "UNUSED_0x033" }, + [52] = { "FLAG_UNUSED_0x034", "UNUSED_0x034" }, + [53] = { "FLAG_UNUSED_0x035", "UNUSED_0x035" }, + [54] = { "FLAG_UNUSED_0x036", "UNUSED_0x036" }, + [55] = { "FLAG_UNUSED_0x037", "UNUSED_0x037" }, + [56] = { "FLAG_UNUSED_0x038", "UNUSED_0x038" }, + [57] = { "FLAG_UNUSED_0x039", "UNUSED_0x039" }, + [58] = { "FLAG_UNUSED_0x03A", "UNUSED_0x03A" }, + [59] = { "FLAG_UNUSED_0x03B", "UNUSED_0x03B" }, + [60] = { "FLAG_UNUSED_0x03C", "UNUSED_0x03C" }, + [61] = { "FLAG_UNUSED_0x03D", "UNUSED_0x03D" }, + [62] = { "FLAG_UNUSED_0x03E", "UNUSED_0x03E" }, + [63] = { "FLAG_UNUSED_0x03F", "UNUSED_0x03F" }, + [64] = { "FLAG_UNUSED_0x040", "NUM_DAILY_FLAGS", "UNUSED_0x040" }, + [65] = { "FLAG_UNUSED_0x041", "UNUSED_0x041" }, + [66] = { "FLAG_UNUSED_0x042", "UNUSED_0x042" }, + [67] = { "FLAG_UNUSED_0x043", "UNUSED_0x043" }, + [68] = { "FLAG_UNUSED_0x044", "UNUSED_0x044" }, + [69] = { "FLAG_UNUSED_0x045", "UNUSED_0x045" }, + [70] = { "FLAG_UNUSED_0x046", "UNUSED_0x046" }, + [71] = { "FLAG_UNUSED_0x047", "UNUSED_0x047" }, + [72] = { "FLAG_UNUSED_0x048", "UNUSED_0x048" }, + [73] = { "FLAG_UNUSED_0x049", "UNUSED_0x049" }, + [74] = { "FLAG_UNUSED_0x04A", "UNUSED_0x04A" }, + [75] = { "FLAG_UNUSED_0x04B", "UNUSED_0x04B" }, + [76] = { "FLAG_UNUSED_0x04C", "UNUSED_0x04C" }, + [77] = { "FLAG_UNUSED_0x04D", "UNUSED_0x04D" }, + [78] = { "FLAG_UNUSED_0x04E", "UNUSED_0x04E" }, + [79] = { "FLAG_UNUSED_0x04F", "UNUSED_0x04F" }, + [80] = { "FLAG_HIDE_SKY_PILLAR_TOP_RAYQUAZA_STILL", "HIDE_SKY_PILLAR_TOP_RAYQUAZA_STILL" }, + [81] = { "FLAG_SET_WALL_CLOCK", "SET_WALL_CLOCK" }, + [82] = { "FLAG_RESCUED_BIRCH", "RESCUED_BIRCH" }, + [83] = { "FLAG_LEGENDARIES_IN_SOOTOPOLIS", "LEGENDARIES_IN_SOOTOPOLIS" }, + [84] = { "FLAG_UNUSED_0x054", "UNUSED_0x054" }, + [85] = { "FLAG_UNUSED_0x055", "UNUSED_0x055" }, + [86] = { "FLAG_HIDE_CONTEST_POKE_BALL", "HIDE_CONTEST_POKE_BALL" }, + [87] = { "FLAG_MET_RIVAL_MOM", "MET_RIVAL_MOM" }, + [88] = { "FLAG_BIRCH_AIDE_MET", "BIRCH_AIDE_MET" }, + [89] = { "FLAG_DECLINED_BIKE", "DECLINED_BIKE" }, + [90] = { "FLAG_RECEIVED_BIKE", "RECEIVED_BIKE" }, + [91] = { "FLAG_WATTSON_REMATCH_AVAILABLE", "WATTSON_REMATCH_AVAILABLE" }, + [92] = { "FLAG_COLLECTED_ALL_SILVER_SYMBOLS", "COLLECTED_ALL_SILVER_SYMBOLS" }, + [93] = { "FLAG_GOOD_LUCK_SAFARI_ZONE", "GOOD_LUCK_SAFARI_ZONE" }, + [94] = { "FLAG_RECEIVED_WAILMER_PAIL", "RECEIVED_WAILMER_PAIL" }, + [95] = { "FLAG_RECEIVED_POKEBLOCK_CASE", "RECEIVED_POKEBLOCK_CASE" }, + [96] = { "FLAG_RECEIVED_SECRET_POWER", "RECEIVED_SECRET_POWER" }, + [97] = { "FLAG_MET_TEAM_AQUA_HARBOR", "MET_TEAM_AQUA_HARBOR" }, + [98] = { "FLAG_TV_EXPLAINED", "TV_EXPLAINED" }, + [99] = { "FLAG_MAUVILLE_GYM_BARRIERS_STATE", "MAUVILLE_GYM_BARRIERS_STATE" }, + [100] = { "FLAG_MOSSDEEP_GYM_SWITCH_1", "MOSSDEEP_GYM_SWITCH_1" }, + [101] = { "FLAG_MOSSDEEP_GYM_SWITCH_2", "MOSSDEEP_GYM_SWITCH_2" }, + [102] = { "FLAG_MOSSDEEP_GYM_SWITCH_3", "MOSSDEEP_GYM_SWITCH_3" }, + [103] = { "FLAG_MOSSDEEP_GYM_SWITCH_4", "MOSSDEEP_GYM_SWITCH_4" }, + [104] = { "FLAG_UNUSED_0x068", "UNUSED_0x068" }, + [105] = { "FLAG_OCEANIC_MUSEUM_MET_REPORTER", "OCEANIC_MUSEUM_MET_REPORTER" }, + [106] = { "FLAG_RECEIVED_HM_STRENGTH", "RECEIVED_HM_STRENGTH" }, + [107] = { "FLAG_RECEIVED_HM_ROCK_SMASH", "RECEIVED_HM_ROCK_SMASH" }, + [108] = { "FLAG_WHITEOUT_TO_LAVARIDGE", "WHITEOUT_TO_LAVARIDGE" }, + [109] = { "FLAG_RECEIVED_HM_FLASH", "RECEIVED_HM_FLASH" }, + [110] = { "FLAG_RECEIVED_HM_FLY", "RECEIVED_HM_FLY" }, + [111] = { "FLAG_GROUDON_AWAKENED_MAGMA_HIDEOUT", "GROUDON_AWAKENED_MAGMA_HIDEOUT" }, + [112] = { "FLAG_TEAM_AQUA_ESCAPED_IN_SUBMARINE", "TEAM_AQUA_ESCAPED_IN_SUBMARINE" }, + [113] = { "FLAG_UNUSED_RS_LEGENDARY_BATTLE_DONE", "UNUSED_RS_LEGENDARY_BATTLE_DONE" }, + [114] = { "FLAG_SCOTT_CALL_BATTLE_FRONTIER", "SCOTT_CALL_BATTLE_FRONTIER" }, + [115] = { "FLAG_RECEIVED_METEORITE", "RECEIVED_METEORITE" }, + [116] = { "FLAG_ADVENTURE_STARTED", "ADVENTURE_STARTED" }, + [117] = { "FLAG_DEFEATED_MAGMA_SPACE_CENTER", "DEFEATED_MAGMA_SPACE_CENTER" }, + [118] = { "FLAG_MET_HIDDEN_POWER_GIVER", "MET_HIDDEN_POWER_GIVER" }, + [119] = { "FLAG_CANCEL_BATTLE_ROOM_CHALLENGE", "CANCEL_BATTLE_ROOM_CHALLENGE" }, + [120] = { "FLAG_LANDMARK_MIRAGE_TOWER", "LANDMARK_MIRAGE_TOWER" }, + [121] = { "FLAG_RECEIVED_TM_BRICK_BREAK", "RECEIVED_TM_BRICK_BREAK" }, + [122] = { "FLAG_RECEIVED_HM_SURF", "RECEIVED_HM_SURF" }, + [123] = { "FLAG_RECEIVED_HM_DIVE", "RECEIVED_HM_DIVE" }, + [124] = { "FLAG_REGISTER_RIVAL_POKENAV", "REGISTER_RIVAL_POKENAV" }, + [125] = { "FLAG_DEFEATED_RIVAL_ROUTE_104", "DEFEATED_RIVAL_ROUTE_104" }, + [126] = { "FLAG_DEFEATED_WALLY_VICTORY_ROAD", "DEFEATED_WALLY_VICTORY_ROAD" }, + [127] = { "FLAG_MET_PRETTY_PETAL_SHOP_OWNER", "MET_PRETTY_PETAL_SHOP_OWNER" }, + [128] = { "FLAG_ENABLE_ROXANNE_FIRST_CALL", "ENABLE_ROXANNE_FIRST_CALL", "NUM_SPECIAL_FLAGS" }, + [129] = { "FLAG_KYOGRE_ESCAPED_SEAFLOOR_CAVERN", "KYOGRE_ESCAPED_SEAFLOOR_CAVERN" }, + [130] = { "FLAG_DEFEATED_RIVAL_ROUTE103", "DEFEATED_RIVAL_ROUTE103" }, + [131] = { "FLAG_RECEIVED_DOLL_LANETTE", "RECEIVED_DOLL_LANETTE" }, + [132] = { "FLAG_RECEIVED_POTION_OLDALE", "RECEIVED_POTION_OLDALE" }, + [133] = { "FLAG_RECEIVED_AMULET_COIN", "RECEIVED_AMULET_COIN" }, + [134] = { "FLAG_PENDING_DAYCARE_EGG", "PENDING_DAYCARE_EGG" }, + [135] = { "FLAG_THANKED_FOR_PLAYING_WITH_WALLY", "THANKED_FOR_PLAYING_WITH_WALLY" }, + [136] = { "FLAG_ENABLE_FIRST_WALLY_POKENAV_CALL", "ENABLE_FIRST_WALLY_POKENAV_CALL" }, + [137] = { "FLAG_RECEIVED_HM_CUT", "RECEIVED_HM_CUT" }, + [138] = { "FLAG_SCOTT_CALL_FORTREE_GYM", "SCOTT_CALL_FORTREE_GYM" }, + [139] = { "FLAG_DEFEATED_EVIL_TEAM_MT_CHIMNEY", "DEFEATED_EVIL_TEAM_MT_CHIMNEY" }, + [140] = { "FLAG_RECEIVED_6_SODA_POP", "RECEIVED_6_SODA_POP" }, + [141] = { "FLAG_DEFEATED_SEASHORE_HOUSE", "DEFEATED_SEASHORE_HOUSE" }, + [142] = { "FLAG_DEVON_GOODS_STOLEN", "DEVON_GOODS_STOLEN" }, + [143] = { "FLAG_RECOVERED_DEVON_GOODS", "RECOVERED_DEVON_GOODS" }, + [144] = { "FLAG_RETURNED_DEVON_GOODS", "RETURNED_DEVON_GOODS" }, + [145] = { "FLAG_CAUGHT_LUGIA", "CAUGHT_LUGIA" }, + [146] = { "FLAG_CAUGHT_HO_OH", "CAUGHT_HO_OH" }, + [147] = { "FLAG_MR_BRINEY_SAILING_INTRO", "MR_BRINEY_SAILING_INTRO" }, + [148] = { "FLAG_DOCK_REJECTED_DEVON_GOODS", "DOCK_REJECTED_DEVON_GOODS" }, + [149] = { "FLAG_DELIVERED_DEVON_GOODS", "DELIVERED_DEVON_GOODS" }, + [150] = { "FLAG_RECEIVED_CONTEST_PASS", "RECEIVED_CONTEST_PASS" }, + [151] = { "FLAG_RECEIVED_CASTFORM", "RECEIVED_CASTFORM" }, + [152] = { "FLAG_RECEIVED_SUPER_ROD", "RECEIVED_SUPER_ROD" }, + [153] = { "FLAG_RUSTBORO_NPC_TRADE_COMPLETED", "RUSTBORO_NPC_TRADE_COMPLETED" }, + [154] = { "FLAG_PACIFIDLOG_NPC_TRADE_COMPLETED", "PACIFIDLOG_NPC_TRADE_COMPLETED" }, + [155] = { "FLAG_FORTREE_NPC_TRADE_COMPLETED", "FORTREE_NPC_TRADE_COMPLETED" }, + [156] = { "FLAG_BATTLE_FRONTIER_TRADE_DONE", "BATTLE_FRONTIER_TRADE_DONE" }, + [157] = { "FLAG_FORCE_MIRAGE_TOWER_VISIBLE", "FORCE_MIRAGE_TOWER_VISIBLE" }, + [158] = { "FLAG_SOOTOPOLIS_ARCHIE_MAXIE_LEAVE", "SOOTOPOLIS_ARCHIE_MAXIE_LEAVE" }, + [159] = { "FLAG_INTERACTED_WITH_DEVON_EMPLOYEE_GOODS_STOLEN", "INTERACTED_WITH_DEVON_EMPLOYEE_GOODS_STOLEN" }, + [160] = { "FLAG_COOL_PAINTING_MADE", "COOL_PAINTING_MADE" }, + [161] = { "FLAG_BEAUTY_PAINTING_MADE", "BEAUTY_PAINTING_MADE" }, + [162] = { "FLAG_CUTE_PAINTING_MADE", "CUTE_PAINTING_MADE" }, + [163] = { "FLAG_SMART_PAINTING_MADE", "SMART_PAINTING_MADE" }, + [164] = { "FLAG_TOUGH_PAINTING_MADE", "TOUGH_PAINTING_MADE" }, + [165] = { "FLAG_RECEIVED_TM_ROCK_TOMB", "RECEIVED_TM_ROCK_TOMB" }, + [166] = { "FLAG_RECEIVED_TM_BULK_UP", "RECEIVED_TM_BULK_UP" }, + [167] = { "FLAG_RECEIVED_TM_SHOCK_WAVE", "RECEIVED_TM_SHOCK_WAVE" }, + [168] = { "FLAG_RECEIVED_TM_OVERHEAT", "RECEIVED_TM_OVERHEAT" }, + [169] = { "FLAG_RECEIVED_TM_FACADE", "RECEIVED_TM_FACADE" }, + [170] = { "FLAG_RECEIVED_TM_AERIAL_ACE", "RECEIVED_TM_AERIAL_ACE" }, + [171] = { "FLAG_RECEIVED_TM_CALM_MIND", "RECEIVED_TM_CALM_MIND" }, + [172] = { "FLAG_RECEIVED_TM_WATER_PULSE", "RECEIVED_TM_WATER_PULSE" }, + [173] = { "FLAG_HIDE_SECRET_BASE_TRAINER", "HIDE_SECRET_BASE_TRAINER" }, + [174] = { "FLAG_DECORATION_1", "DECORATION_1" }, + [175] = { "FLAG_DECORATION_2", "DECORATION_2" }, + [176] = { "FLAG_DECORATION_3", "DECORATION_3" }, + [177] = { "FLAG_DECORATION_4", "DECORATION_4" }, + [178] = { "FLAG_DECORATION_5", "DECORATION_5" }, + [179] = { "FLAG_DECORATION_6", "DECORATION_6" }, + [180] = { "FLAG_DECORATION_7", "DECORATION_7" }, + [181] = { "FLAG_DECORATION_8", "DECORATION_8" }, + [182] = { "FLAG_DECORATION_9", "DECORATION_9" }, + [183] = { "FLAG_DECORATION_10", "DECORATION_10" }, + [184] = { "FLAG_DECORATION_11", "DECORATION_11" }, + [185] = { "FLAG_DECORATION_12", "DECORATION_12" }, + [186] = { "FLAG_DECORATION_13", "DECORATION_13" }, + [187] = { "FLAG_DECORATION_14", "DECORATION_14" }, + [188] = { "FLAG_RECEIVED_POKENAV", "RECEIVED_POKENAV" }, + [189] = { "FLAG_DELIVERED_STEVEN_LETTER", "DELIVERED_STEVEN_LETTER" }, + [190] = { "FLAG_DEFEATED_WALLY_MAUVILLE", "DEFEATED_WALLY_MAUVILLE" }, + [191] = { "FLAG_DEFEATED_GRUNT_SPACE_CENTER_1F", "DEFEATED_GRUNT_SPACE_CENTER_1F" }, + [192] = { "FLAG_RECEIVED_SUN_STONE_MOSSDEEP", "RECEIVED_SUN_STONE_MOSSDEEP" }, + [193] = { "FLAG_WALLY_SPEECH", "WALLY_SPEECH" }, + [194] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_1", "TRICK_HOUSE_PUZZLE_7_SWITCH_1" }, + [195] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_2", "TRICK_HOUSE_PUZZLE_7_SWITCH_2" }, + [196] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_3", "TRICK_HOUSE_PUZZLE_7_SWITCH_3" }, + [197] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_4", "TRICK_HOUSE_PUZZLE_7_SWITCH_4" }, + [198] = { "FLAG_TRICK_HOUSE_PUZZLE_7_SWITCH_5", "TRICK_HOUSE_PUZZLE_7_SWITCH_5" }, + [199] = { "FLAG_RUSTURF_TUNNEL_OPENED", "RUSTURF_TUNNEL_OPENED" }, + [200] = { "FLAG_RECEIVED_RED_SCARF", "RECEIVED_RED_SCARF" }, + [201] = { "FLAG_RECEIVED_BLUE_SCARF", "RECEIVED_BLUE_SCARF" }, + [202] = { "FLAG_RECEIVED_PINK_SCARF", "RECEIVED_PINK_SCARF" }, + [203] = { "FLAG_RECEIVED_GREEN_SCARF", "RECEIVED_GREEN_SCARF" }, + [204] = { "FLAG_RECEIVED_YELLOW_SCARF", "RECEIVED_YELLOW_SCARF" }, + [205] = { "FLAG_INTERACTED_WITH_STEVEN_SPACE_CENTER", "INTERACTED_WITH_STEVEN_SPACE_CENTER" }, + [206] = { "FLAG_ENCOUNTERED_LATIAS_OR_LATIOS", "ENCOUNTERED_LATIAS_OR_LATIOS" }, + [207] = { "FLAG_MET_ARCHIE_METEOR_FALLS", "MET_ARCHIE_METEOR_FALLS" }, + [208] = { "FLAG_GOT_BASEMENT_KEY_FROM_WATTSON", "GOT_BASEMENT_KEY_FROM_WATTSON" }, + [209] = { "FLAG_GOT_TM_THUNDERBOLT_FROM_WATTSON", "GOT_TM_THUNDERBOLT_FROM_WATTSON" }, + [210] = { "FLAG_FAN_CLUB_STRENGTH_SHARED", "FAN_CLUB_STRENGTH_SHARED" }, + [211] = { "FLAG_DEFEATED_RIVAL_RUSTBORO", "DEFEATED_RIVAL_RUSTBORO" }, + [212] = { "FLAG_RECEIVED_RED_OR_BLUE_ORB", "RECEIVED_RED_OR_BLUE_ORB" }, + [213] = { "FLAG_RECEIVED_PREMIER_BALL_RUSTBORO", "RECEIVED_PREMIER_BALL_RUSTBORO" }, + [214] = { "FLAG_ENABLE_WALLY_MATCH_CALL", "ENABLE_WALLY_MATCH_CALL" }, + [215] = { "FLAG_ENABLE_SCOTT_MATCH_CALL", "ENABLE_SCOTT_MATCH_CALL" }, + [216] = { "FLAG_ENABLE_MOM_MATCH_CALL", "ENABLE_MOM_MATCH_CALL" }, + [217] = { "FLAG_MET_DIVING_TREASURE_HUNTER", "MET_DIVING_TREASURE_HUNTER" }, + [218] = { "FLAG_MET_WAILMER_TRAINER", "MET_WAILMER_TRAINER" }, + [219] = { "FLAG_EVIL_LEADER_PLEASE_STOP", "EVIL_LEADER_PLEASE_STOP" }, + [220] = { "FLAG_NEVER_SET_0x0DC", "NEVER_SET_0x0DC" }, + [221] = { "FLAG_RECEIVED_GO_GOGGLES", "RECEIVED_GO_GOGGLES" }, + [222] = { "FLAG_WINGULL_SENT_ON_ERRAND", "WINGULL_SENT_ON_ERRAND" }, + [223] = { "FLAG_RECEIVED_MENTAL_HERB", "RECEIVED_MENTAL_HERB" }, + [224] = { "FLAG_WINGULL_DELIVERED_MAIL", "WINGULL_DELIVERED_MAIL" }, + [225] = { "FLAG_RECEIVED_20_COINS", "RECEIVED_20_COINS" }, + [226] = { "FLAG_RECEIVED_STARTER_DOLL", "RECEIVED_STARTER_DOLL" }, + [227] = { "FLAG_RECEIVED_GOOD_ROD", "RECEIVED_GOOD_ROD" }, + [228] = { "FLAG_REGI_DOORS_OPENED", "REGI_DOORS_OPENED" }, + [229] = { "FLAG_RECEIVED_TM_RETURN", "RECEIVED_TM_RETURN" }, + [230] = { "FLAG_RECEIVED_TM_SLUDGE_BOMB", "RECEIVED_TM_SLUDGE_BOMB" }, + [231] = { "FLAG_RECEIVED_TM_ROAR", "RECEIVED_TM_ROAR" }, + [232] = { "FLAG_RECEIVED_TM_GIGA_DRAIN", "RECEIVED_TM_GIGA_DRAIN" }, + [233] = { "FLAG_UNUSED_0x0E9", "UNUSED_0x0E9" }, + [234] = { "FLAG_RECEIVED_TM_REST", "RECEIVED_TM_REST" }, + [235] = { "FLAG_RECEIVED_TM_ATTRACT", "RECEIVED_TM_ATTRACT" }, + [236] = { "FLAG_RECEIVED_GLASS_ORNAMENT", "RECEIVED_GLASS_ORNAMENT" }, + [237] = { "FLAG_RECEIVED_SILVER_SHIELD", "RECEIVED_SILVER_SHIELD" }, + [238] = { "FLAG_RECEIVED_GOLD_SHIELD", "RECEIVED_GOLD_SHIELD" }, + [239] = { "FLAG_USED_STORAGE_KEY", "USED_STORAGE_KEY" }, + [240] = { "FLAG_USED_ROOM_1_KEY", "USED_ROOM_1_KEY" }, + [241] = { "FLAG_USED_ROOM_2_KEY", "USED_ROOM_2_KEY" }, + [242] = { "FLAG_USED_ROOM_4_KEY", "USED_ROOM_4_KEY" }, + [243] = { "FLAG_USED_ROOM_6_KEY", "USED_ROOM_6_KEY" }, + [244] = { "FLAG_MET_PROF_COZMO", "MET_PROF_COZMO" }, + [245] = { "FLAG_RECEIVED_WAILMER_DOLL", "RECEIVED_WAILMER_DOLL" }, + [246] = { "FLAG_RECEIVED_CHESTO_BERRY_ROUTE_104", "RECEIVED_CHESTO_BERRY_ROUTE_104" }, + [247] = { "FLAG_DEFEATED_SS_TIDAL_TRAINERS", "DEFEATED_SS_TIDAL_TRAINERS" }, + [248] = { "FLAG_RECEIVED_SPELON_BERRY", "RECEIVED_SPELON_BERRY" }, + [249] = { "FLAG_RECEIVED_PAMTRE_BERRY", "RECEIVED_PAMTRE_BERRY" }, + [250] = { "FLAG_RECEIVED_WATMEL_BERRY", "RECEIVED_WATMEL_BERRY" }, + [251] = { "FLAG_RECEIVED_DURIN_BERRY", "RECEIVED_DURIN_BERRY" }, + [252] = { "FLAG_RECEIVED_BELUE_BERRY", "RECEIVED_BELUE_BERRY" }, + [253] = { "FLAG_ENABLE_RIVAL_MATCH_CALL", "ENABLE_RIVAL_MATCH_CALL" }, + [254] = { "FLAG_RECEIVED_CHARCOAL", "RECEIVED_CHARCOAL" }, + [255] = { "FLAG_LATIOS_OR_LATIAS_ROAMING", "LATIOS_OR_LATIAS_ROAMING" }, + [256] = { "FLAG_RECEIVED_REPEAT_BALL", "RECEIVED_REPEAT_BALL", "VARS_COUNT" }, + [257] = { "FLAG_RECEIVED_OLD_ROD", "RECEIVED_OLD_ROD" }, + [258] = { "FLAG_RECEIVED_COIN_CASE", "RECEIVED_COIN_CASE" }, + [259] = { "FLAG_RETURNED_RED_OR_BLUE_ORB", "RETURNED_RED_OR_BLUE_ORB" }, + [260] = { "FLAG_RECEIVED_TM_SNATCH", "RECEIVED_TM_SNATCH" }, + [261] = { "FLAG_RECEIVED_TM_DIG", "RECEIVED_TM_DIG" }, + [262] = { "FLAG_RECEIVED_TM_BULLET_SEED", "RECEIVED_TM_BULLET_SEED" }, + [263] = { "FLAG_ENTERED_ELITE_FOUR", "ENTERED_ELITE_FOUR" }, + [264] = { "FLAG_RECEIVED_TM_HIDDEN_POWER", "RECEIVED_TM_HIDDEN_POWER" }, + [265] = { "FLAG_RECEIVED_TM_TORMENT", "RECEIVED_TM_TORMENT" }, + [266] = { "FLAG_RECEIVED_LAVARIDGE_EGG", "RECEIVED_LAVARIDGE_EGG" }, + [267] = { "FLAG_RECEIVED_REVIVED_FOSSIL_MON", "RECEIVED_REVIVED_FOSSIL_MON" }, + [268] = { "FLAG_SECRET_BASE_REGISTRY_ENABLED", "SECRET_BASE_REGISTRY_ENABLED" }, + [269] = { "FLAG_RECEIVED_TM_THIEF", "RECEIVED_TM_THIEF" }, + [270] = { "FLAG_CONTEST_SKETCH_CREATED", "CONTEST_SKETCH_CREATED" }, + [271] = { "FLAG_EVIL_TEAM_ESCAPED_STERN_SPOKE", "EVIL_TEAM_ESCAPED_STERN_SPOKE" }, + [272] = { "FLAG_RECEIVED_EXP_SHARE", "RECEIVED_EXP_SHARE" }, + [273] = { "FLAG_POKERUS_EXPLAINED", "POKERUS_EXPLAINED" }, + [274] = { "FLAG_RECEIVED_RUNNING_SHOES", "RECEIVED_RUNNING_SHOES" }, + [275] = { "FLAG_RECEIVED_QUICK_CLAW", "RECEIVED_QUICK_CLAW" }, + [276] = { "FLAG_RECEIVED_KINGS_ROCK", "RECEIVED_KINGS_ROCK" }, + [277] = { "FLAG_RECEIVED_MACHO_BRACE", "RECEIVED_MACHO_BRACE" }, + [278] = { "FLAG_RECEIVED_SOOTHE_BELL", "RECEIVED_SOOTHE_BELL" }, + [279] = { "FLAG_RECEIVED_WHITE_HERB", "RECEIVED_WHITE_HERB" }, + [280] = { "FLAG_RECEIVED_SOFT_SAND", "RECEIVED_SOFT_SAND" }, + [281] = { "FLAG_ENABLE_PROF_BIRCH_MATCH_CALL", "ENABLE_PROF_BIRCH_MATCH_CALL" }, + [282] = { "FLAG_RECEIVED_CLEANSE_TAG", "RECEIVED_CLEANSE_TAG" }, + [283] = { "FLAG_RECEIVED_FOCUS_BAND", "RECEIVED_FOCUS_BAND" }, + [284] = { "FLAG_DECLINED_WALLY_BATTLE_MAUVILLE", "DECLINED_WALLY_BATTLE_MAUVILLE" }, + [285] = { "FLAG_RECEIVED_DEVON_SCOPE", "RECEIVED_DEVON_SCOPE" }, + [286] = { "FLAG_DECLINED_RIVAL_BATTLE_LILYCOVE", "DECLINED_RIVAL_BATTLE_LILYCOVE" }, + [287] = { "FLAG_MET_DEVON_EMPLOYEE", "MET_DEVON_EMPLOYEE" }, + [288] = { "FLAG_MET_RIVAL_RUSTBORO", "MET_RIVAL_RUSTBORO" }, + [289] = { "FLAG_RECEIVED_SILK_SCARF", "RECEIVED_SILK_SCARF" }, + [290] = { "FLAG_NOT_READY_FOR_BATTLE_ROUTE_120", "NOT_READY_FOR_BATTLE_ROUTE_120" }, + [291] = { "FLAG_RECEIVED_SS_TICKET", "RECEIVED_SS_TICKET" }, + [292] = { "FLAG_MET_RIVAL_LILYCOVE", "MET_RIVAL_LILYCOVE" }, + [293] = { "FLAG_MET_RIVAL_IN_HOUSE_AFTER_LILYCOVE", "MET_RIVAL_IN_HOUSE_AFTER_LILYCOVE" }, + [294] = { "FLAG_EXCHANGED_SCANNER", "EXCHANGED_SCANNER" }, + [295] = { "FLAG_KECLEON_FLED_FORTREE", "KECLEON_FLED_FORTREE" }, + [296] = { "FLAG_PETALBURG_MART_EXPANDED_ITEMS", "PETALBURG_MART_EXPANDED_ITEMS" }, + [297] = { "FLAG_RECEIVED_MIRACLE_SEED", "RECEIVED_MIRACLE_SEED" }, + [298] = { "FLAG_RECEIVED_BELDUM", "RECEIVED_BELDUM" }, + [299] = { "FLAG_RECEIVED_FANCLUB_TM_THIS_WEEK", "RECEIVED_FANCLUB_TM_THIS_WEEK" }, + [300] = { "FLAG_MET_FANCLUB_YOUNGER_BROTHER", "MET_FANCLUB_YOUNGER_BROTHER" }, + [301] = { "FLAG_RIVAL_LEFT_FOR_ROUTE103", "RIVAL_LEFT_FOR_ROUTE103" }, + [302] = { "FLAG_OMIT_DIVE_FROM_STEVEN_LETTER", "OMIT_DIVE_FROM_STEVEN_LETTER" }, + [303] = { "FLAG_HAS_MATCH_CALL", "HAS_MATCH_CALL" }, + [304] = { "FLAG_ADDED_MATCH_CALL_TO_POKENAV", "ADDED_MATCH_CALL_TO_POKENAV" }, + [305] = { "FLAG_REGISTERED_STEVEN_POKENAV", "REGISTERED_STEVEN_POKENAV" }, + [306] = { "FLAG_ENABLE_NORMAN_MATCH_CALL", "ENABLE_NORMAN_MATCH_CALL" }, + [307] = { "FLAG_STEVEN_GUIDES_TO_CAVE_OF_ORIGIN", "STEVEN_GUIDES_TO_CAVE_OF_ORIGIN" }, + [308] = { "FLAG_MET_ARCHIE_SOOTOPOLIS", "MET_ARCHIE_SOOTOPOLIS" }, + [309] = { "FLAG_MET_MAXIE_SOOTOPOLIS", "MET_MAXIE_SOOTOPOLIS" }, + [310] = { "FLAG_MET_SCOTT_RUSTBORO", "MET_SCOTT_RUSTBORO" }, + [311] = { "FLAG_WALLACE_GOES_TO_SKY_PILLAR", "WALLACE_GOES_TO_SKY_PILLAR" }, + [312] = { "FLAG_RECEIVED_HM_WATERFALL", "RECEIVED_HM_WATERFALL" }, + [313] = { "FLAG_BEAT_MAGMA_GRUNT_JAGGED_PASS", "BEAT_MAGMA_GRUNT_JAGGED_PASS" }, + [314] = { "FLAG_RECEIVED_AURORA_TICKET", "RECEIVED_AURORA_TICKET" }, + [315] = { "FLAG_RECEIVED_MYSTIC_TICKET", "RECEIVED_MYSTIC_TICKET" }, + [316] = { "FLAG_RECEIVED_OLD_SEA_MAP", "RECEIVED_OLD_SEA_MAP" }, + [317] = { "FLAG_WONDER_CARD_UNUSED_1", "WONDER_CARD_UNUSED_1" }, + [318] = { "FLAG_WONDER_CARD_UNUSED_2", "WONDER_CARD_UNUSED_2" }, + [319] = { "FLAG_WONDER_CARD_UNUSED_3", "WONDER_CARD_UNUSED_3" }, + [320] = { "FLAG_WONDER_CARD_UNUSED_4", "WONDER_CARD_UNUSED_4" }, + [321] = { "FLAG_WONDER_CARD_UNUSED_5", "WONDER_CARD_UNUSED_5" }, + [322] = { "FLAG_WONDER_CARD_UNUSED_6", "WONDER_CARD_UNUSED_6" }, + [323] = { "FLAG_WONDER_CARD_UNUSED_7", "WONDER_CARD_UNUSED_7" }, + [324] = { "FLAG_WONDER_CARD_UNUSED_8", "WONDER_CARD_UNUSED_8" }, + [325] = { "FLAG_WONDER_CARD_UNUSED_9", "WONDER_CARD_UNUSED_9" }, + [326] = { "FLAG_WONDER_CARD_UNUSED_10", "WONDER_CARD_UNUSED_10" }, + [327] = { "FLAG_WONDER_CARD_UNUSED_11", "WONDER_CARD_UNUSED_11" }, + [328] = { "FLAG_WONDER_CARD_UNUSED_12", "WONDER_CARD_UNUSED_12" }, + [329] = { "FLAG_WONDER_CARD_UNUSED_13", "WONDER_CARD_UNUSED_13" }, + [330] = { "FLAG_WONDER_CARD_UNUSED_14", "WONDER_CARD_UNUSED_14" }, + [331] = { "FLAG_WONDER_CARD_UNUSED_15", "WONDER_CARD_UNUSED_15" }, + [332] = { "FLAG_WONDER_CARD_UNUSED_16", "WONDER_CARD_UNUSED_16" }, + [333] = { "FLAG_WONDER_CARD_UNUSED_17", "WONDER_CARD_UNUSED_17" }, + [334] = { "FLAG_MIRAGE_TOWER_VISIBLE", "MIRAGE_TOWER_VISIBLE" }, + [335] = { "FLAG_CHOSE_ROOT_FOSSIL", "CHOSE_ROOT_FOSSIL" }, + [336] = { "FLAG_CHOSE_CLAW_FOSSIL", "CHOSE_CLAW_FOSSIL" }, + [337] = { "FLAG_RECEIVED_POWDER_JAR", "RECEIVED_POWDER_JAR" }, + [338] = { "FLAG_CHOSEN_MULTI_BATTLE_NPC_PARTNER", "CHOSEN_MULTI_BATTLE_NPC_PARTNER" }, + [339] = { "FLAG_MET_BATTLE_FRONTIER_BREEDER", "MET_BATTLE_FRONTIER_BREEDER" }, + [340] = { "FLAG_MET_BATTLE_FRONTIER_MANIAC", "MET_BATTLE_FRONTIER_MANIAC" }, + [341] = { "FLAG_ENTERED_CONTEST", "ENTERED_CONTEST" }, + [342] = { "FLAG_MET_SLATEPORT_FANCLUB_CHAIRMAN", "MET_SLATEPORT_FANCLUB_CHAIRMAN" }, + [343] = { "FLAG_MET_BATTLE_FRONTIER_GAMBLER", "MET_BATTLE_FRONTIER_GAMBLER" }, + [344] = { "FLAG_ENABLE_MR_STONE_POKENAV", "ENABLE_MR_STONE_POKENAV" }, + [345] = { "FLAG_NURSE_MENTIONS_GOLD_CARD", "NURSE_MENTIONS_GOLD_CARD" }, + [346] = { "FLAG_MET_FRONTIER_BEAUTY_MOVE_TUTOR", "MET_FRONTIER_BEAUTY_MOVE_TUTOR" }, + [347] = { "FLAG_MET_FRONTIER_SWIMMER_MOVE_TUTOR", "MET_FRONTIER_SWIMMER_MOVE_TUTOR" }, + [348] = { "FLAG_REGISTERED_ROSE", "REGISTERED_ROSE", "TRAINER_REGISTERED_FLAGS_START" }, + [349] = { "FLAG_REGISTERED_ANDRES", "REGISTERED_ANDRES" }, + [350] = { "FLAG_REGISTERED_DUSTY", "REGISTERED_DUSTY" }, + [351] = { "FLAG_REGISTERED_LOLA", "REGISTERED_LOLA" }, + [352] = { "FLAG_REGISTERED_RICKY", "REGISTERED_RICKY" }, + [353] = { "FLAG_REGISTERED_LILA_AND_ROY", "REGISTERED_LILA_AND_ROY" }, + [354] = { "FLAG_REGISTERED_CRISTIN", "REGISTERED_CRISTIN" }, + [355] = { "FLAG_REGISTERED_BROOKE", "REGISTERED_BROOKE" }, + [356] = { "FLAG_REGISTERED_WILTON", "REGISTERED_WILTON" }, + [357] = { "FLAG_REGISTERED_VALERIE", "REGISTERED_VALERIE" }, + [358] = { "FLAG_REGISTERED_CINDY", "REGISTERED_CINDY" }, + [359] = { "FLAG_REGISTERED_THALIA", "REGISTERED_THALIA" }, + [360] = { "FLAG_REGISTERED_JESSICA", "REGISTERED_JESSICA" }, + [361] = { "FLAG_REGISTERED_WINSTON", "REGISTERED_WINSTON" }, + [362] = { "FLAG_REGISTERED_STEVE", "REGISTERED_STEVE" }, + [363] = { "FLAG_REGISTERED_TONY", "REGISTERED_TONY" }, + [364] = { "FLAG_REGISTERED_NOB", "REGISTERED_NOB" }, + [365] = { "FLAG_REGISTERED_KOJI", "REGISTERED_KOJI" }, + [366] = { "FLAG_REGISTERED_FERNANDO", "REGISTERED_FERNANDO" }, + [367] = { "FLAG_REGISTERED_DALTON", "REGISTERED_DALTON" }, + [368] = { "FLAG_REGISTERED_BERNIE", "REGISTERED_BERNIE" }, + [369] = { "FLAG_REGISTERED_ETHAN", "REGISTERED_ETHAN" }, + [370] = { "FLAG_REGISTERED_JOHN_AND_JAY", "REGISTERED_JOHN_AND_JAY" }, + [371] = { "FLAG_REGISTERED_JEFFREY", "REGISTERED_JEFFREY" }, + [372] = { "FLAG_REGISTERED_CAMERON", "REGISTERED_CAMERON" }, + [373] = { "FLAG_REGISTERED_JACKI", "REGISTERED_JACKI" }, + [374] = { "FLAG_REGISTERED_WALTER", "REGISTERED_WALTER" }, + [375] = { "FLAG_REGISTERED_KAREN", "REGISTERED_KAREN" }, + [376] = { "FLAG_REGISTERED_JERRY", "REGISTERED_JERRY" }, + [377] = { "FLAG_REGISTERED_ANNA_AND_MEG", "REGISTERED_ANNA_AND_MEG" }, + [378] = { "FLAG_REGISTERED_ISABEL", "REGISTERED_ISABEL" }, + [379] = { "FLAG_REGISTERED_MIGUEL", "REGISTERED_MIGUEL" }, + [380] = { "FLAG_REGISTERED_TIMOTHY", "REGISTERED_TIMOTHY" }, + [381] = { "FLAG_REGISTERED_SHELBY", "REGISTERED_SHELBY" }, + [382] = { "FLAG_REGISTERED_CALVIN", "REGISTERED_CALVIN" }, + [383] = { "FLAG_REGISTERED_ELLIOT", "REGISTERED_ELLIOT" }, + [384] = { "FLAG_REGISTERED_ISAIAH", "REGISTERED_ISAIAH" }, + [385] = { "FLAG_REGISTERED_MARIA", "REGISTERED_MARIA" }, + [386] = { "FLAG_REGISTERED_ABIGAIL", "REGISTERED_ABIGAIL" }, + [387] = { "FLAG_REGISTERED_DYLAN", "REGISTERED_DYLAN" }, + [388] = { "FLAG_REGISTERED_KATELYN", "REGISTERED_KATELYN" }, + [389] = { "FLAG_REGISTERED_BENJAMIN", "REGISTERED_BENJAMIN" }, + [390] = { "FLAG_REGISTERED_PABLO", "REGISTERED_PABLO" }, + [391] = { "FLAG_REGISTERED_NICOLAS", "REGISTERED_NICOLAS" }, + [392] = { "FLAG_REGISTERED_ROBERT", "REGISTERED_ROBERT" }, + [393] = { "FLAG_REGISTERED_LAO", "REGISTERED_LAO" }, + [394] = { "FLAG_REGISTERED_CYNDY", "REGISTERED_CYNDY" }, + [395] = { "FLAG_REGISTERED_MADELINE", "REGISTERED_MADELINE" }, + [396] = { "FLAG_REGISTERED_JENNY", "REGISTERED_JENNY" }, + [397] = { "FLAG_REGISTERED_DIANA", "REGISTERED_DIANA" }, + [398] = { "FLAG_REGISTERED_AMY_AND_LIV", "REGISTERED_AMY_AND_LIV" }, + [399] = { "FLAG_REGISTERED_ERNEST", "REGISTERED_ERNEST" }, + [400] = { "FLAG_REGISTERED_CORY", "REGISTERED_CORY" }, + [401] = { "FLAG_REGISTERED_EDWIN", "REGISTERED_EDWIN" }, + [402] = { "FLAG_REGISTERED_LYDIA", "REGISTERED_LYDIA" }, + [403] = { "FLAG_REGISTERED_ISAAC", "REGISTERED_ISAAC" }, + [404] = { "FLAG_REGISTERED_GABRIELLE", "REGISTERED_GABRIELLE" }, + [405] = { "FLAG_REGISTERED_CATHERINE", "REGISTERED_CATHERINE" }, + [406] = { "FLAG_REGISTERED_JACKSON", "REGISTERED_JACKSON" }, + [407] = { "FLAG_REGISTERED_HALEY", "REGISTERED_HALEY" }, + [408] = { "FLAG_REGISTERED_JAMES", "REGISTERED_JAMES" }, + [409] = { "FLAG_REGISTERED_TRENT", "REGISTERED_TRENT" }, + [410] = { "FLAG_REGISTERED_SAWYER", "REGISTERED_SAWYER" }, + [411] = { "FLAG_REGISTERED_KIRA_AND_DAN", "REGISTERED_KIRA_AND_DAN" }, + [412] = { "FLAG_REGISTERED_WALLY", "REGISTERED_WALLY" }, + [413] = { "FLAG_REGISTERED_ROXANNE", "REGISTERED_ROXANNE" }, + [414] = { "FLAG_REGISTERED_BRAWLY", "REGISTERED_BRAWLY" }, + [415] = { "FLAG_REGISTERED_WATTSON", "REGISTERED_WATTSON" }, + [416] = { "FLAG_REGISTERED_FLANNERY", "REGISTERED_FLANNERY" }, + [417] = { "FLAG_REGISTERED_NORMAN", "REGISTERED_NORMAN" }, + [418] = { "FLAG_REGISTERED_WINONA", "REGISTERED_WINONA" }, + [419] = { "FLAG_REGISTERED_TATE_AND_LIZA", "REGISTERED_TATE_AND_LIZA" }, + [420] = { "FLAG_REGISTERED_JUAN", "REGISTERED_JUAN" }, + [421] = { "FLAG_REGISTERED_SIDNEY", "REGISTERED_SIDNEY" }, + [422] = { "FLAG_REGISTERED_PHOEBE", "REGISTERED_PHOEBE" }, + [423] = { "FLAG_REGISTERED_GLACIA", "REGISTERED_GLACIA" }, + [424] = { "FLAG_REGISTERED_DRAKE", "REGISTERED_DRAKE" }, + [425] = { "FLAG_REGISTERED_WALLACE", "REGISTERED_WALLACE" }, + [426] = { "FLAG_UNUSED_0x1AA", "UNUSED_0x1AA" }, + [427] = { "FLAG_UNUSED_0x1AB", "UNUSED_0x1AB" }, + [428] = { "FLAG_DEFEATED_DEOXYS", "DEFEATED_DEOXYS" }, + [429] = { "FLAG_BATTLED_DEOXYS", "BATTLED_DEOXYS" }, + [430] = { "FLAG_SHOWN_EON_TICKET", "SHOWN_EON_TICKET" }, + [431] = { "FLAG_SHOWN_AURORA_TICKET", "SHOWN_AURORA_TICKET" }, + [432] = { "FLAG_SHOWN_OLD_SEA_MAP", "SHOWN_OLD_SEA_MAP" }, + [433] = { "FLAG_MOVE_TUTOR_TAUGHT_SWAGGER", "MOVE_TUTOR_TAUGHT_SWAGGER" }, + [434] = { "FLAG_MOVE_TUTOR_TAUGHT_ROLLOUT", "MOVE_TUTOR_TAUGHT_ROLLOUT" }, + [435] = { "FLAG_MOVE_TUTOR_TAUGHT_FURY_CUTTER", "MOVE_TUTOR_TAUGHT_FURY_CUTTER" }, + [436] = { "FLAG_MOVE_TUTOR_TAUGHT_MIMIC", "MOVE_TUTOR_TAUGHT_MIMIC" }, + [437] = { "FLAG_MOVE_TUTOR_TAUGHT_METRONOME", "MOVE_TUTOR_TAUGHT_METRONOME" }, + [438] = { "FLAG_MOVE_TUTOR_TAUGHT_SLEEP_TALK", "MOVE_TUTOR_TAUGHT_SLEEP_TALK" }, + [439] = { "FLAG_MOVE_TUTOR_TAUGHT_SUBSTITUTE", "MOVE_TUTOR_TAUGHT_SUBSTITUTE" }, + [440] = { "FLAG_MOVE_TUTOR_TAUGHT_DYNAMICPUNCH", "MOVE_TUTOR_TAUGHT_DYNAMICPUNCH" }, + [441] = { "FLAG_MOVE_TUTOR_TAUGHT_DOUBLE_EDGE", "MOVE_TUTOR_TAUGHT_DOUBLE_EDGE" }, + [442] = { "FLAG_MOVE_TUTOR_TAUGHT_EXPLOSION", "MOVE_TUTOR_TAUGHT_EXPLOSION" }, + [443] = { "FLAG_DEFEATED_REGIROCK", "DEFEATED_REGIROCK" }, + [444] = { "FLAG_DEFEATED_REGICE", "DEFEATED_REGICE" }, + [445] = { "FLAG_DEFEATED_REGISTEEL", "DEFEATED_REGISTEEL" }, + [446] = { "FLAG_DEFEATED_KYOGRE", "DEFEATED_KYOGRE" }, + [447] = { "FLAG_DEFEATED_GROUDON", "DEFEATED_GROUDON" }, + [448] = { "FLAG_DEFEATED_RAYQUAZA", "DEFEATED_RAYQUAZA" }, + [449] = { "FLAG_DEFEATED_VOLTORB_1_NEW_MAUVILLE", "DEFEATED_VOLTORB_1_NEW_MAUVILLE" }, + [450] = { "FLAG_DEFEATED_VOLTORB_2_NEW_MAUVILLE", "DEFEATED_VOLTORB_2_NEW_MAUVILLE" }, + [451] = { "FLAG_DEFEATED_VOLTORB_3_NEW_MAUVILLE", "DEFEATED_VOLTORB_3_NEW_MAUVILLE" }, + [452] = { "FLAG_DEFEATED_ELECTRODE_1_AQUA_HIDEOUT", "DEFEATED_ELECTRODE_1_AQUA_HIDEOUT" }, + [453] = { "FLAG_DEFEATED_ELECTRODE_2_AQUA_HIDEOUT", "DEFEATED_ELECTRODE_2_AQUA_HIDEOUT" }, + [454] = { "FLAG_DEFEATED_SUDOWOODO", "DEFEATED_SUDOWOODO" }, + [455] = { "FLAG_DEFEATED_MEW", "DEFEATED_MEW" }, + [456] = { "FLAG_DEFEATED_LATIAS_OR_LATIOS", "DEFEATED_LATIAS_OR_LATIOS" }, + [457] = { "FLAG_CAUGHT_LATIAS_OR_LATIOS", "CAUGHT_LATIAS_OR_LATIOS" }, + [458] = { "FLAG_CAUGHT_MEW", "CAUGHT_MEW" }, + [459] = { "FLAG_MET_SCOTT_AFTER_OBTAINING_STONE_BADGE", "MET_SCOTT_AFTER_OBTAINING_STONE_BADGE" }, + [460] = { "FLAG_MET_SCOTT_IN_VERDANTURF", "MET_SCOTT_IN_VERDANTURF" }, + [461] = { "FLAG_MET_SCOTT_IN_FALLARBOR", "MET_SCOTT_IN_FALLARBOR" }, + [462] = { "FLAG_MET_SCOTT_IN_LILYCOVE", "MET_SCOTT_IN_LILYCOVE" }, + [463] = { "FLAG_MET_SCOTT_IN_EVERGRANDE", "MET_SCOTT_IN_EVERGRANDE" }, + [464] = { "FLAG_MET_SCOTT_ON_SS_TIDAL", "MET_SCOTT_ON_SS_TIDAL" }, + [465] = { "FLAG_SCOTT_GIVES_BATTLE_POINTS", "SCOTT_GIVES_BATTLE_POINTS" }, + [466] = { "FLAG_COLLECTED_ALL_GOLD_SYMBOLS", "COLLECTED_ALL_GOLD_SYMBOLS" }, + [467] = { "FLAG_ENABLE_ROXANNE_MATCH_CALL", "ENABLE_ROXANNE_MATCH_CALL" }, + [468] = { "FLAG_ENABLE_BRAWLY_MATCH_CALL", "ENABLE_BRAWLY_MATCH_CALL" }, + [469] = { "FLAG_ENABLE_WATTSON_MATCH_CALL", "ENABLE_WATTSON_MATCH_CALL" }, + [470] = { "FLAG_ENABLE_FLANNERY_MATCH_CALL", "ENABLE_FLANNERY_MATCH_CALL" }, + [471] = { "FLAG_ENABLE_WINONA_MATCH_CALL", "ENABLE_WINONA_MATCH_CALL" }, + [472] = { "FLAG_ENABLE_TATE_AND_LIZA_MATCH_CALL", "ENABLE_TATE_AND_LIZA_MATCH_CALL" }, + [473] = { "FLAG_ENABLE_JUAN_MATCH_CALL", "ENABLE_JUAN_MATCH_CALL" }, + [474] = { "FLAG_UNUSED_0x1DA", "UNUSED_0x1DA" }, + [475] = { "FLAG_SHOWN_MYSTIC_TICKET", "SHOWN_MYSTIC_TICKET" }, + [476] = { "FLAG_DEFEATED_HO_OH", "DEFEATED_HO_OH" }, + [477] = { "FLAG_DEFEATED_LUGIA", "DEFEATED_LUGIA" }, + [478] = { "FLAG_UNUSED_0x1DE", "UNUSED_0x1DE" }, + [479] = { "FLAG_UNUSED_0x1DF", "UNUSED_0x1DF" }, + [480] = { "FLAG_UNUSED_0x1E0", "UNUSED_0x1E0" }, + [481] = { "FLAG_UNUSED_0x1E1", "UNUSED_0x1E1" }, + [482] = { "FLAG_UNUSED_0x1E2", "UNUSED_0x1E2" }, + [483] = { "FLAG_UNUSED_0x1E3", "UNUSED_0x1E3" }, + [484] = { "FLAG_MYSTERY_GIFT_DONE", "MYSTERY_GIFT_DONE" }, + [485] = { "FLAG_MYSTERY_GIFT_1", "MYSTERY_GIFT_1" }, + [486] = { "FLAG_MYSTERY_GIFT_2", "MYSTERY_GIFT_2" }, + [487] = { "FLAG_MYSTERY_GIFT_3", "MYSTERY_GIFT_3" }, + [488] = { "FLAG_MYSTERY_GIFT_4", "MYSTERY_GIFT_4" }, + [489] = { "FLAG_MYSTERY_GIFT_5", "MYSTERY_GIFT_5" }, + [490] = { "FLAG_MYSTERY_GIFT_6", "MYSTERY_GIFT_6" }, + [491] = { "FLAG_MYSTERY_GIFT_7", "MYSTERY_GIFT_7" }, + [492] = { "FLAG_MYSTERY_GIFT_8", "MYSTERY_GIFT_8" }, + [493] = { "FLAG_MYSTERY_GIFT_9", "MYSTERY_GIFT_9" }, + [494] = { "FLAG_MYSTERY_GIFT_10", "MYSTERY_GIFT_10" }, + [495] = { "FLAG_MYSTERY_GIFT_11", "MYSTERY_GIFT_11" }, + [496] = { "FLAG_MYSTERY_GIFT_12", "MYSTERY_GIFT_12" }, + [497] = { "FLAG_MYSTERY_GIFT_13", "MYSTERY_GIFT_13" }, + [498] = { "FLAG_MYSTERY_GIFT_14", "MYSTERY_GIFT_14" }, + [499] = { "FLAG_MYSTERY_GIFT_15", "MYSTERY_GIFT_15" }, + [500] = { "FLAG_HIDDEN_ITEM_LAVARIDGE_TOWN_ICE_HEAL", "FLAG_HIDDEN_ITEMS_START", "HIDDEN_ITEMS_START", "HIDDEN_ITEM_LAVARIDGE_TOWN_ICE_HEAL" }, + [501] = { "FLAG_HIDDEN_ITEM_TRICK_HOUSE_NUGGET", "HIDDEN_ITEM_TRICK_HOUSE_NUGGET" }, + [502] = { "FLAG_HIDDEN_ITEM_ROUTE_111_STARDUST", "HIDDEN_ITEM_ROUTE_111_STARDUST" }, + [503] = { "FLAG_HIDDEN_ITEM_ROUTE_113_ETHER", "HIDDEN_ITEM_ROUTE_113_ETHER" }, + [504] = { "FLAG_HIDDEN_ITEM_ROUTE_114_CARBOS", "HIDDEN_ITEM_ROUTE_114_CARBOS" }, + [505] = { "FLAG_HIDDEN_ITEM_ROUTE_119_CALCIUM", "HIDDEN_ITEM_ROUTE_119_CALCIUM" }, + [506] = { "FLAG_HIDDEN_ITEM_ROUTE_119_ULTRA_BALL", "HIDDEN_ITEM_ROUTE_119_ULTRA_BALL" }, + [507] = { "FLAG_HIDDEN_ITEM_ROUTE_123_SUPER_REPEL", "HIDDEN_ITEM_ROUTE_123_SUPER_REPEL" }, + [508] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_CARBOS", "HIDDEN_ITEM_UNDERWATER_124_CARBOS" }, + [509] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_GREEN_SHARD", "HIDDEN_ITEM_UNDERWATER_124_GREEN_SHARD" }, + [510] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_PEARL", "HIDDEN_ITEM_UNDERWATER_124_PEARL" }, + [511] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_BIG_PEARL", "HIDDEN_ITEM_UNDERWATER_124_BIG_PEARL" }, + [512] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_BLUE_SHARD", "HIDDEN_ITEM_UNDERWATER_126_BLUE_SHARD" }, + [513] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_1", "HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_1" }, + [514] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_HEART_SCALE", "HIDDEN_ITEM_UNDERWATER_126_HEART_SCALE" }, + [515] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_ULTRA_BALL", "HIDDEN_ITEM_UNDERWATER_126_ULTRA_BALL" }, + [516] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_STARDUST", "HIDDEN_ITEM_UNDERWATER_126_STARDUST" }, + [517] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_PEARL", "HIDDEN_ITEM_UNDERWATER_126_PEARL" }, + [518] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_YELLOW_SHARD", "HIDDEN_ITEM_UNDERWATER_126_YELLOW_SHARD" }, + [519] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_IRON", "HIDDEN_ITEM_UNDERWATER_126_IRON" }, + [520] = { "FLAG_HIDDEN_ITEM_UNDERWATER_126_BIG_PEARL", "HIDDEN_ITEM_UNDERWATER_126_BIG_PEARL" }, + [521] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_STAR_PIECE", "HIDDEN_ITEM_UNDERWATER_127_STAR_PIECE" }, + [522] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_HP_UP", "HIDDEN_ITEM_UNDERWATER_127_HP_UP" }, + [523] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_HEART_SCALE", "HIDDEN_ITEM_UNDERWATER_127_HEART_SCALE" }, + [524] = { "FLAG_HIDDEN_ITEM_UNDERWATER_127_RED_SHARD", "HIDDEN_ITEM_UNDERWATER_127_RED_SHARD" }, + [525] = { "FLAG_HIDDEN_ITEM_UNDERWATER_128_PROTEIN", "HIDDEN_ITEM_UNDERWATER_128_PROTEIN" }, + [526] = { "FLAG_HIDDEN_ITEM_UNDERWATER_128_PEARL", "HIDDEN_ITEM_UNDERWATER_128_PEARL" }, + [527] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_HEART_SCALE", "HIDDEN_ITEM_LILYCOVE_CITY_HEART_SCALE" }, + [528] = { "FLAG_HIDDEN_ITEM_FALLARBOR_TOWN_NUGGET", "HIDDEN_ITEM_FALLARBOR_TOWN_NUGGET" }, + [529] = { "FLAG_HIDDEN_ITEM_MT_PYRE_EXTERIOR_ULTRA_BALL", "HIDDEN_ITEM_MT_PYRE_EXTERIOR_ULTRA_BALL" }, + [530] = { "FLAG_HIDDEN_ITEM_ROUTE_113_TM_DOUBLE_TEAM", "HIDDEN_ITEM_ROUTE_113_TM_DOUBLE_TEAM" }, + [531] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_1_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_1_KEY" }, + [532] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_2_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_2_KEY" }, + [533] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_4_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_4_KEY" }, + [534] = { "FLAG_HIDDEN_ITEM_ABANDONED_SHIP_RM_6_KEY", "HIDDEN_ITEM_ABANDONED_SHIP_RM_6_KEY" }, + [535] = { "FLAG_HIDDEN_ITEM_SS_TIDAL_LOWER_DECK_LEFTOVERS", "HIDDEN_ITEM_SS_TIDAL_LOWER_DECK_LEFTOVERS" }, + [536] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_CALCIUM", "HIDDEN_ITEM_UNDERWATER_124_CALCIUM" }, + [537] = { "FLAG_HIDDEN_ITEM_ROUTE_104_POTION", "HIDDEN_ITEM_ROUTE_104_POTION" }, + [538] = { "FLAG_HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_2", "HIDDEN_ITEM_UNDERWATER_124_HEART_SCALE_2" }, + [539] = { "FLAG_HIDDEN_ITEM_ROUTE_121_HP_UP", "HIDDEN_ITEM_ROUTE_121_HP_UP" }, + [540] = { "FLAG_HIDDEN_ITEM_ROUTE_121_NUGGET", "HIDDEN_ITEM_ROUTE_121_NUGGET" }, + [541] = { "FLAG_HIDDEN_ITEM_ROUTE_123_REVIVE", "HIDDEN_ITEM_ROUTE_123_REVIVE" }, + [542] = { "FLAG_HIDDEN_ITEM_ROUTE_114_REVIVE", "HIDDEN_ITEM_ROUTE_114_REVIVE" }, + [543] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_PP_UP", "HIDDEN_ITEM_LILYCOVE_CITY_PP_UP" }, + [544] = { "FLAG_HIDDEN_ITEM_ROUTE_104_SUPER_POTION", "HIDDEN_ITEM_ROUTE_104_SUPER_POTION" }, + [545] = { "FLAG_HIDDEN_ITEM_ROUTE_116_SUPER_POTION", "HIDDEN_ITEM_ROUTE_116_SUPER_POTION" }, + [546] = { "FLAG_HIDDEN_ITEM_ROUTE_106_STARDUST", "HIDDEN_ITEM_ROUTE_106_STARDUST" }, + [547] = { "FLAG_HIDDEN_ITEM_ROUTE_106_HEART_SCALE", "HIDDEN_ITEM_ROUTE_106_HEART_SCALE" }, + [548] = { "FLAG_HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_1", "HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_1" }, + [549] = { "FLAG_HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_2", "HIDDEN_ITEM_GRANITE_CAVE_B2F_EVERSTONE_2" }, + [550] = { "FLAG_HIDDEN_ITEM_ROUTE_109_REVIVE", "HIDDEN_ITEM_ROUTE_109_REVIVE" }, + [551] = { "FLAG_HIDDEN_ITEM_ROUTE_109_GREAT_BALL", "HIDDEN_ITEM_ROUTE_109_GREAT_BALL" }, + [552] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_1", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_1" }, + [553] = { "FLAG_HIDDEN_ITEM_ROUTE_110_GREAT_BALL", "HIDDEN_ITEM_ROUTE_110_GREAT_BALL" }, + [554] = { "FLAG_HIDDEN_ITEM_ROUTE_110_REVIVE", "HIDDEN_ITEM_ROUTE_110_REVIVE" }, + [555] = { "FLAG_HIDDEN_ITEM_ROUTE_110_FULL_HEAL", "HIDDEN_ITEM_ROUTE_110_FULL_HEAL" }, + [556] = { "FLAG_HIDDEN_ITEM_ROUTE_111_PROTEIN", "HIDDEN_ITEM_ROUTE_111_PROTEIN" }, + [557] = { "FLAG_HIDDEN_ITEM_ROUTE_111_RARE_CANDY", "HIDDEN_ITEM_ROUTE_111_RARE_CANDY" }, + [558] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_POTION", "HIDDEN_ITEM_PETALBURG_WOODS_POTION" }, + [559] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_1", "HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_1" }, + [560] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_2", "HIDDEN_ITEM_PETALBURG_WOODS_TINY_MUSHROOM_2" }, + [561] = { "FLAG_HIDDEN_ITEM_PETALBURG_WOODS_POKE_BALL", "HIDDEN_ITEM_PETALBURG_WOODS_POKE_BALL" }, + [562] = { "FLAG_HIDDEN_ITEM_ROUTE_104_POKE_BALL", "HIDDEN_ITEM_ROUTE_104_POKE_BALL" }, + [563] = { "FLAG_HIDDEN_ITEM_ROUTE_106_POKE_BALL", "HIDDEN_ITEM_ROUTE_106_POKE_BALL" }, + [564] = { "FLAG_HIDDEN_ITEM_ROUTE_109_ETHER", "HIDDEN_ITEM_ROUTE_109_ETHER" }, + [565] = { "FLAG_HIDDEN_ITEM_ROUTE_110_POKE_BALL", "HIDDEN_ITEM_ROUTE_110_POKE_BALL" }, + [566] = { "FLAG_HIDDEN_ITEM_ROUTE_118_HEART_SCALE", "HIDDEN_ITEM_ROUTE_118_HEART_SCALE" }, + [567] = { "FLAG_HIDDEN_ITEM_ROUTE_118_IRON", "HIDDEN_ITEM_ROUTE_118_IRON" }, + [568] = { "FLAG_HIDDEN_ITEM_ROUTE_119_FULL_HEAL", "HIDDEN_ITEM_ROUTE_119_FULL_HEAL" }, + [569] = { "FLAG_HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2", "HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2" }, + [570] = { "FLAG_HIDDEN_ITEM_ROUTE_120_ZINC", "HIDDEN_ITEM_ROUTE_120_ZINC" }, + [571] = { "FLAG_HIDDEN_ITEM_ROUTE_120_RARE_CANDY_1", "HIDDEN_ITEM_ROUTE_120_RARE_CANDY_1" }, + [572] = { "FLAG_HIDDEN_ITEM_ROUTE_117_REPEL", "HIDDEN_ITEM_ROUTE_117_REPEL" }, + [573] = { "FLAG_HIDDEN_ITEM_ROUTE_121_FULL_HEAL", "HIDDEN_ITEM_ROUTE_121_FULL_HEAL" }, + [574] = { "FLAG_HIDDEN_ITEM_ROUTE_123_HYPER_POTION", "HIDDEN_ITEM_ROUTE_123_HYPER_POTION" }, + [575] = { "FLAG_HIDDEN_ITEM_LILYCOVE_CITY_POKE_BALL", "HIDDEN_ITEM_LILYCOVE_CITY_POKE_BALL" }, + [576] = { "FLAG_HIDDEN_ITEM_JAGGED_PASS_GREAT_BALL", "HIDDEN_ITEM_JAGGED_PASS_GREAT_BALL" }, + [577] = { "FLAG_HIDDEN_ITEM_JAGGED_PASS_FULL_HEAL", "HIDDEN_ITEM_JAGGED_PASS_FULL_HEAL" }, + [578] = { "FLAG_HIDDEN_ITEM_MT_PYRE_EXTERIOR_MAX_ETHER", "HIDDEN_ITEM_MT_PYRE_EXTERIOR_MAX_ETHER" }, + [579] = { "FLAG_HIDDEN_ITEM_MT_PYRE_SUMMIT_ZINC", "HIDDEN_ITEM_MT_PYRE_SUMMIT_ZINC" }, + [580] = { "FLAG_HIDDEN_ITEM_MT_PYRE_SUMMIT_RARE_CANDY", "HIDDEN_ITEM_MT_PYRE_SUMMIT_RARE_CANDY" }, + [581] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL", "HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL" }, + [582] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_B2F_ELIXIR", "HIDDEN_ITEM_VICTORY_ROAD_B2F_ELIXIR" }, + [583] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_B2F_MAX_REPEL", "HIDDEN_ITEM_VICTORY_ROAD_B2F_MAX_REPEL" }, + [584] = { "FLAG_HIDDEN_ITEM_ROUTE_120_REVIVE", "HIDDEN_ITEM_ROUTE_120_REVIVE" }, + [585] = { "FLAG_HIDDEN_ITEM_ROUTE_104_ANTIDOTE", "HIDDEN_ITEM_ROUTE_104_ANTIDOTE" }, + [586] = { "FLAG_HIDDEN_ITEM_ROUTE_108_RARE_CANDY", "HIDDEN_ITEM_ROUTE_108_RARE_CANDY" }, + [587] = { "FLAG_HIDDEN_ITEM_ROUTE_119_MAX_ETHER", "HIDDEN_ITEM_ROUTE_119_MAX_ETHER" }, + [588] = { "FLAG_HIDDEN_ITEM_ROUTE_104_HEART_SCALE", "HIDDEN_ITEM_ROUTE_104_HEART_SCALE" }, + [589] = { "FLAG_HIDDEN_ITEM_ROUTE_105_HEART_SCALE", "HIDDEN_ITEM_ROUTE_105_HEART_SCALE" }, + [590] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_2", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_2" }, + [591] = { "FLAG_HIDDEN_ITEM_ROUTE_109_HEART_SCALE_3", "HIDDEN_ITEM_ROUTE_109_HEART_SCALE_3" }, + [592] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_1", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_1" }, + [593] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_2", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_2" }, + [594] = { "FLAG_HIDDEN_ITEM_ROUTE_128_HEART_SCALE_3", "HIDDEN_ITEM_ROUTE_128_HEART_SCALE_3" }, + [595] = { "FLAG_HIDDEN_ITEM_PETALBURG_CITY_RARE_CANDY", "HIDDEN_ITEM_PETALBURG_CITY_RARE_CANDY" }, + [596] = { "FLAG_HIDDEN_ITEM_ROUTE_116_BLACK_GLASSES", "HIDDEN_ITEM_ROUTE_116_BLACK_GLASSES" }, + [597] = { "FLAG_HIDDEN_ITEM_ROUTE_115_HEART_SCALE", "HIDDEN_ITEM_ROUTE_115_HEART_SCALE" }, + [598] = { "FLAG_HIDDEN_ITEM_ROUTE_113_NUGGET", "HIDDEN_ITEM_ROUTE_113_NUGGET" }, + [599] = { "FLAG_HIDDEN_ITEM_ROUTE_123_PP_UP", "HIDDEN_ITEM_ROUTE_123_PP_UP" }, + [600] = { "FLAG_HIDDEN_ITEM_ROUTE_121_MAX_REVIVE", "HIDDEN_ITEM_ROUTE_121_MAX_REVIVE" }, + [601] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_CALCIUM", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_CALCIUM" }, + [602] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_ZINC", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_ZINC" }, + [603] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_PROTEIN", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_PROTEIN" }, + [604] = { "FLAG_HIDDEN_ITEM_ARTISAN_CAVE_B1F_IRON", "HIDDEN_ITEM_ARTISAN_CAVE_B1F_IRON" }, + [605] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_FULL_RESTORE", "HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_FULL_RESTORE" }, + [606] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_RARE_CANDY", "HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_RARE_CANDY" }, + [607] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_ZINC", "HIDDEN_ITEM_SAFARI_ZONE_NORTH_EAST_ZINC" }, + [608] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_PP_UP", "HIDDEN_ITEM_SAFARI_ZONE_SOUTH_EAST_PP_UP" }, + [609] = { "FLAG_HIDDEN_ITEM_NAVEL_ROCK_TOP_SACRED_ASH", "HIDDEN_ITEM_NAVEL_ROCK_TOP_SACRED_ASH" }, + [610] = { "FLAG_HIDDEN_ITEM_ROUTE_123_RARE_CANDY", "HIDDEN_ITEM_ROUTE_123_RARE_CANDY" }, + [611] = { "FLAG_HIDDEN_ITEM_ROUTE_105_BIG_PEARL", "HIDDEN_ITEM_ROUTE_105_BIG_PEARL" }, + [612] = { "FLAG_UNUSED_0x264", "UNUSED_0x264" }, + [613] = { "FLAG_UNUSED_0x265", "UNUSED_0x265" }, + [614] = { "FLAG_UNUSED_0x266", "UNUSED_0x266" }, + [615] = { "FLAG_UNUSED_0x267", "UNUSED_0x267" }, + [616] = { "FLAG_UNUSED_0x268", "UNUSED_0x268" }, + [617] = { "FLAG_UNUSED_0x269", "UNUSED_0x269" }, + [618] = { "FLAG_UNUSED_0x26A", "UNUSED_0x26A" }, + [619] = { "FLAG_UNUSED_0x26B", "UNUSED_0x26B" }, + [620] = { "FLAG_UNUSED_0x26C", "UNUSED_0x26C" }, + [621] = { "FLAG_UNUSED_0x26D", "UNUSED_0x26D" }, + [622] = { "FLAG_UNUSED_0x26E", "UNUSED_0x26E" }, + [623] = { "FLAG_UNUSED_0x26F", "UNUSED_0x26F" }, + [624] = { "FLAG_UNUSED_0x270", "UNUSED_0x270" }, + [625] = { "FLAG_UNUSED_0x271", "UNUSED_0x271" }, + [626] = { "FLAG_UNUSED_0x272", "UNUSED_0x272" }, + [627] = { "FLAG_UNUSED_0x273", "UNUSED_0x273" }, + [628] = { "FLAG_UNUSED_0x274", "UNUSED_0x274" }, + [629] = { "FLAG_UNUSED_0x275", "UNUSED_0x275" }, + [630] = { "FLAG_UNUSED_0x276", "UNUSED_0x276" }, + [631] = { "FLAG_UNUSED_0x277", "UNUSED_0x277" }, + [632] = { "FLAG_UNUSED_0x278", "UNUSED_0x278" }, + [633] = { "FLAG_UNUSED_0x279", "UNUSED_0x279" }, + [634] = { "FLAG_UNUSED_0x27A", "UNUSED_0x27A" }, + [635] = { "FLAG_UNUSED_0x27B", "UNUSED_0x27B" }, + [636] = { "FLAG_UNUSED_0x27C", "UNUSED_0x27C" }, + [637] = { "FLAG_UNUSED_0x27D", "UNUSED_0x27D" }, + [638] = { "FLAG_UNUSED_0x27E", "UNUSED_0x27E" }, + [639] = { "FLAG_UNUSED_0x27F", "UNUSED_0x27F" }, + [640] = { "FLAG_UNUSED_0x280", "UNUSED_0x280" }, + [641] = { "FLAG_UNUSED_0x281", "UNUSED_0x281" }, + [642] = { "FLAG_UNUSED_0x282", "UNUSED_0x282" }, + [643] = { "FLAG_UNUSED_0x283", "UNUSED_0x283" }, + [644] = { "FLAG_UNUSED_0x284", "UNUSED_0x284" }, + [645] = { "FLAG_UNUSED_0x285", "UNUSED_0x285" }, + [646] = { "FLAG_UNUSED_0x286", "UNUSED_0x286" }, + [647] = { "FLAG_UNUSED_0x287", "UNUSED_0x287" }, + [648] = { "FLAG_UNUSED_0x288", "UNUSED_0x288" }, + [649] = { "FLAG_UNUSED_0x289", "UNUSED_0x289" }, + [650] = { "FLAG_UNUSED_0x28A", "UNUSED_0x28A" }, + [651] = { "FLAG_UNUSED_0x28B", "UNUSED_0x28B" }, + [652] = { "FLAG_UNUSED_0x28C", "UNUSED_0x28C" }, + [653] = { "FLAG_UNUSED_0x28D", "UNUSED_0x28D" }, + [654] = { "FLAG_UNUSED_0x28E", "UNUSED_0x28E" }, + [655] = { "FLAG_UNUSED_0x28F", "UNUSED_0x28F" }, + [656] = { "FLAG_UNUSED_0x290", "UNUSED_0x290" }, + [657] = { "FLAG_UNUSED_0x291", "UNUSED_0x291" }, + [658] = { "FLAG_UNUSED_0x292", "UNUSED_0x292" }, + [659] = { "FLAG_UNUSED_0x293", "UNUSED_0x293" }, + [660] = { "FLAG_UNUSED_0x294", "UNUSED_0x294" }, + [661] = { "FLAG_UNUSED_0x295", "UNUSED_0x295" }, + [662] = { "FLAG_UNUSED_0x296", "UNUSED_0x296" }, + [663] = { "FLAG_UNUSED_0x297", "UNUSED_0x297" }, + [664] = { "FLAG_UNUSED_0x298", "UNUSED_0x298" }, + [665] = { "FLAG_UNUSED_0x299", "UNUSED_0x299" }, + [666] = { "FLAG_UNUSED_0x29A", "UNUSED_0x29A" }, + [667] = { "FLAG_UNUSED_0x29B", "UNUSED_0x29B" }, + [668] = { "FLAG_UNUSED_0x29C", "UNUSED_0x29C" }, + [669] = { "FLAG_UNUSED_0x29D", "UNUSED_0x29D" }, + [670] = { "FLAG_UNUSED_0x29E", "UNUSED_0x29E" }, + [671] = { "FLAG_UNUSED_0x29F", "UNUSED_0x29F" }, + [672] = { "FLAG_UNUSED_0x2A0", "UNUSED_0x2A0" }, + [673] = { "FLAG_UNUSED_0x2A1", "UNUSED_0x2A1" }, + [674] = { "FLAG_UNUSED_0x2A2", "UNUSED_0x2A2" }, + [675] = { "FLAG_UNUSED_0x2A3", "UNUSED_0x2A3" }, + [676] = { "FLAG_UNUSED_0x2A4", "UNUSED_0x2A4" }, + [677] = { "FLAG_UNUSED_0x2A5", "UNUSED_0x2A5" }, + [678] = { "FLAG_UNUSED_0x2A6", "UNUSED_0x2A6" }, + [679] = { "FLAG_UNUSED_0x2A7", "UNUSED_0x2A7" }, + [680] = { "FLAG_UNUSED_0x2A8", "UNUSED_0x2A8" }, + [681] = { "FLAG_UNUSED_0x2A9", "UNUSED_0x2A9" }, + [682] = { "FLAG_UNUSED_0x2AA", "UNUSED_0x2AA" }, + [683] = { "FLAG_UNUSED_0x2AB", "UNUSED_0x2AB" }, + [684] = { "FLAG_UNUSED_0x2AC", "UNUSED_0x2AC" }, + [685] = { "FLAG_UNUSED_0x2AD", "UNUSED_0x2AD" }, + [686] = { "FLAG_UNUSED_0x2AE", "UNUSED_0x2AE" }, + [687] = { "FLAG_UNUSED_0x2AF", "UNUSED_0x2AF" }, + [688] = { "FLAG_UNUSED_0x2B0", "UNUSED_0x2B0" }, + [689] = { "FLAG_UNUSED_0x2B1", "UNUSED_0x2B1" }, + [690] = { "FLAG_UNUSED_0x2B2", "UNUSED_0x2B2" }, + [691] = { "FLAG_UNUSED_0x2B3", "UNUSED_0x2B3" }, + [692] = { "FLAG_UNUSED_0x2B4", "UNUSED_0x2B4" }, + [693] = { "FLAG_UNUSED_0x2B5", "UNUSED_0x2B5" }, + [694] = { "FLAG_UNUSED_0x2B6", "UNUSED_0x2B6" }, + [695] = { "FLAG_UNUSED_0x2B7", "UNUSED_0x2B7" }, + [696] = { "FLAG_UNUSED_0x2B8", "UNUSED_0x2B8" }, + [697] = { "FLAG_UNUSED_0x2B9", "UNUSED_0x2B9" }, + [698] = { "FLAG_UNUSED_0x2BA", "UNUSED_0x2BA" }, + [699] = { "FLAG_UNUSED_0x2BB", "UNUSED_0x2BB" }, + [700] = { "FLAG_HIDE_ROUTE_101_BIRCH_STARTERS_BAG", "HIDE_ROUTE_101_BIRCH_STARTERS_BAG" }, + [701] = { "FLAG_HIDE_APPRENTICE", "HIDE_APPRENTICE" }, + [702] = { "FLAG_HIDE_POKEMON_CENTER_2F_MYSTERY_GIFT_MAN", "HIDE_POKEMON_CENTER_2F_MYSTERY_GIFT_MAN" }, + [703] = { "FLAG_HIDE_UNION_ROOM_PLAYER_1", "HIDE_UNION_ROOM_PLAYER_1" }, + [704] = { "FLAG_HIDE_UNION_ROOM_PLAYER_2", "HIDE_UNION_ROOM_PLAYER_2" }, + [705] = { "FLAG_HIDE_UNION_ROOM_PLAYER_3", "HIDE_UNION_ROOM_PLAYER_3" }, + [706] = { "FLAG_HIDE_UNION_ROOM_PLAYER_4", "HIDE_UNION_ROOM_PLAYER_4" }, + [707] = { "FLAG_HIDE_UNION_ROOM_PLAYER_5", "HIDE_UNION_ROOM_PLAYER_5" }, + [708] = { "FLAG_HIDE_UNION_ROOM_PLAYER_6", "HIDE_UNION_ROOM_PLAYER_6" }, + [709] = { "FLAG_HIDE_UNION_ROOM_PLAYER_7", "HIDE_UNION_ROOM_PLAYER_7" }, + [710] = { "FLAG_HIDE_UNION_ROOM_PLAYER_8", "HIDE_UNION_ROOM_PLAYER_8" }, + [711] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_1", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_1" }, + [712] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_2", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_2" }, + [713] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_3", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_3" }, + [714] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_4", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_4" }, + [715] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_5", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_5" }, + [716] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_6", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_6" }, + [717] = { "FLAG_HIDE_SAFARI_ZONE_SOUTH_CONSTRUCTION_WORKERS", "HIDE_SAFARI_ZONE_SOUTH_CONSTRUCTION_WORKERS" }, + [718] = { "FLAG_HIDE_MEW", "HIDE_MEW" }, + [719] = { "FLAG_HIDE_ROUTE_104_RIVAL", "HIDE_ROUTE_104_RIVAL" }, + [720] = { "FLAG_HIDE_ROUTE_101_BIRCH_ZIGZAGOON_BATTLE", "HIDE_ROUTE_101_BIRCH_ZIGZAGOON_BATTLE" }, + [721] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_BIRCH", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_BIRCH" }, + [722] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_BEDROOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_BEDROOM" }, + [723] = { "FLAG_HIDE_ROUTE_103_RIVAL", "HIDE_ROUTE_103_RIVAL" }, + [724] = { "FLAG_HIDE_PETALBURG_WOODS_DEVON_EMPLOYEE", "HIDE_PETALBURG_WOODS_DEVON_EMPLOYEE" }, + [725] = { "FLAG_HIDE_PETALBURG_WOODS_AQUA_GRUNT", "HIDE_PETALBURG_WOODS_AQUA_GRUNT" }, + [726] = { "FLAG_HIDE_PETALBURG_CITY_WALLY", "HIDE_PETALBURG_CITY_WALLY" }, + [727] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_INVISIBLE_NINJA_BOY", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_INVISIBLE_NINJA_BOY" }, + [728] = { "FLAG_HIDE_PETALBURG_CITY_WALLYS_MOM", "HIDE_PETALBURG_CITY_WALLYS_MOM" }, + [729] = { "FLAG_UNUSED_0x2D9", "UNUSED_0x2D9" }, + [730] = { "FLAG_HIDE_LILYCOVE_FAN_CLUB_INTERVIEWER", "HIDE_LILYCOVE_FAN_CLUB_INTERVIEWER" }, + [731] = { "FLAG_HIDE_RUSTBORO_CITY_AQUA_GRUNT", "HIDE_RUSTBORO_CITY_AQUA_GRUNT" }, + [732] = { "FLAG_HIDE_RUSTBORO_CITY_DEVON_EMPLOYEE_1", "HIDE_RUSTBORO_CITY_DEVON_EMPLOYEE_1" }, + [733] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE_ASLEEP", "HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE_ASLEEP" }, + [734] = { "FLAG_HIDE_PLAYERS_HOUSE_DAD", "HIDE_PLAYERS_HOUSE_DAD" }, + [735] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_SIBLING", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_SIBLING" }, + [736] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_SIBLING", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_SIBLING" }, + [737] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_MAGMA_NOTE", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_MAGMA_NOTE" }, + [738] = { "FLAG_HIDE_ROUTE_104_MR_BRINEY", "HIDE_ROUTE_104_MR_BRINEY" }, + [739] = { "FLAG_HIDE_BRINEYS_HOUSE_MR_BRINEY", "HIDE_BRINEYS_HOUSE_MR_BRINEY" }, + [740] = { "FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN", "HIDE_MR_BRINEY_DEWFORD_TOWN" }, + [741] = { "FLAG_HIDE_ROUTE_109_MR_BRINEY", "HIDE_ROUTE_109_MR_BRINEY" }, + [742] = { "FLAG_HIDE_ROUTE_104_MR_BRINEY_BOAT", "HIDE_ROUTE_104_MR_BRINEY_BOAT" }, + [743] = { "FLAG_HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN", "HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN" }, + [744] = { "FLAG_HIDE_ROUTE_109_MR_BRINEY_BOAT", "HIDE_ROUTE_109_MR_BRINEY_BOAT" }, + [745] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_BRENDAN", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_BRENDAN" }, + [746] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MAY", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MAY" }, + [747] = { "FLAG_HIDE_SAFARI_ZONE_SOUTH_EAST_EXPANSION", "HIDE_SAFARI_ZONE_SOUTH_EAST_EXPANSION" }, + [748] = { "FLAG_HIDE_LILYCOVE_HARBOR_EVENT_TICKET_TAKER", "HIDE_LILYCOVE_HARBOR_EVENT_TICKET_TAKER" }, + [749] = { "FLAG_HIDE_SLATEPORT_CITY_SCOTT", "HIDE_SLATEPORT_CITY_SCOTT" }, + [750] = { "FLAG_HIDE_ROUTE_101_ZIGZAGOON", "HIDE_ROUTE_101_ZIGZAGOON" }, + [751] = { "FLAG_HIDE_VICTORY_ROAD_EXIT_WALLY", "HIDE_VICTORY_ROAD_EXIT_WALLY" }, + [752] = { "FLAG_HIDE_LITTLEROOT_TOWN_MOM_OUTSIDE", "HIDE_LITTLEROOT_TOWN_MOM_OUTSIDE" }, + [753] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_STEVEN", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_STEVEN" }, + [754] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_1", "HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_1" }, + [755] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_2", "HIDE_LITTLEROOT_TOWN_PLAYERS_HOUSE_VIGOROTH_2" }, + [756] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_1F_TEAM_MAGMA" }, + [757] = { "FLAG_HIDE_LITTLEROOT_TOWN_PLAYERS_BEDROOM_MOM", "HIDE_LITTLEROOT_TOWN_PLAYERS_BEDROOM_MOM" }, + [758] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_MOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_MOM" }, + [759] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_MOM" }, + [760] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_BEDROOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_BEDROOM" }, + [761] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_TRUCK", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_TRUCK" }, + [762] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_TRUCK", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_TRUCK" }, + [763] = { "FLAG_HIDE_DEOXYS", "HIDE_DEOXYS" }, + [764] = { "FLAG_HIDE_BIRTH_ISLAND_DEOXYS_TRIANGLE", "HIDE_BIRTH_ISLAND_DEOXYS_TRIANGLE" }, + [765] = { "FLAG_HIDE_MAUVILLE_CITY_SCOTT", "HIDE_MAUVILLE_CITY_SCOTT" }, + [766] = { "FLAG_HIDE_VERDANTURF_TOWN_SCOTT", "HIDE_VERDANTURF_TOWN_SCOTT" }, + [767] = { "FLAG_HIDE_FALLARBOR_TOWN_BATTLE_TENT_SCOTT", "HIDE_FALLARBOR_TOWN_BATTLE_TENT_SCOTT" }, + [768] = { "FLAG_HIDE_ROUTE_111_VICTOR_WINSTRATE", "HIDE_ROUTE_111_VICTOR_WINSTRATE" }, + [769] = { "FLAG_HIDE_ROUTE_111_VICTORIA_WINSTRATE", "HIDE_ROUTE_111_VICTORIA_WINSTRATE" }, + [770] = { "FLAG_HIDE_ROUTE_111_VIVI_WINSTRATE", "HIDE_ROUTE_111_VIVI_WINSTRATE" }, + [771] = { "FLAG_HIDE_ROUTE_111_VICKY_WINSTRATE", "HIDE_ROUTE_111_VICKY_WINSTRATE" }, + [772] = { "FLAG_HIDE_PETALBURG_GYM_NORMAN", "HIDE_PETALBURG_GYM_NORMAN" }, + [773] = { "FLAG_HIDE_SKY_PILLAR_TOP_RAYQUAZA", "HIDE_SKY_PILLAR_TOP_RAYQUAZA" }, + [774] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_1", "HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_1" }, + [775] = { "FLAG_HIDE_LILYCOVE_MUSEUM_CURATOR", "HIDE_LILYCOVE_MUSEUM_CURATOR" }, + [776] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_1", "HIDE_LILYCOVE_MUSEUM_PATRON_1" }, + [777] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_2", "HIDE_LILYCOVE_MUSEUM_PATRON_2" }, + [778] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_3", "HIDE_LILYCOVE_MUSEUM_PATRON_3" }, + [779] = { "FLAG_HIDE_LILYCOVE_MUSEUM_PATRON_4", "HIDE_LILYCOVE_MUSEUM_PATRON_4" }, + [780] = { "FLAG_HIDE_LILYCOVE_MUSEUM_TOURISTS", "HIDE_LILYCOVE_MUSEUM_TOURISTS" }, + [781] = { "FLAG_HIDE_PETALBURG_GYM_GREETER", "HIDE_PETALBURG_GYM_GREETER" }, + [782] = { "FLAG_HIDE_MARINE_CAVE_KYOGRE", "HIDE_MARINE_CAVE_KYOGRE" }, + [783] = { "FLAG_HIDE_TERRA_CAVE_GROUDON", "HIDE_TERRA_CAVE_GROUDON" }, + [784] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_MOM", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_RIVAL_MOM" }, + [785] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_MOM", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_RIVAL_MOM" }, + [786] = { "FLAG_HIDE_ROUTE_119_SCOTT", "HIDE_ROUTE_119_SCOTT" }, + [787] = { "FLAG_HIDE_LILYCOVE_MOTEL_SCOTT", "HIDE_LILYCOVE_MOTEL_SCOTT" }, + [788] = { "FLAG_HIDE_MOSSDEEP_CITY_SCOTT", "HIDE_MOSSDEEP_CITY_SCOTT" }, + [789] = { "FLAG_HIDE_FANCLUB_OLD_LADY", "HIDE_FANCLUB_OLD_LADY" }, + [790] = { "FLAG_HIDE_FANCLUB_BOY", "HIDE_FANCLUB_BOY" }, + [791] = { "FLAG_HIDE_FANCLUB_LITTLE_BOY", "HIDE_FANCLUB_LITTLE_BOY" }, + [792] = { "FLAG_HIDE_FANCLUB_LADY", "HIDE_FANCLUB_LADY" }, + [793] = { "FLAG_HIDE_EVER_GRANDE_POKEMON_CENTER_1F_SCOTT", "HIDE_EVER_GRANDE_POKEMON_CENTER_1F_SCOTT" }, + [794] = { "FLAG_HIDE_LITTLEROOT_TOWN_RIVAL", "HIDE_LITTLEROOT_TOWN_RIVAL" }, + [795] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCH", "HIDE_LITTLEROOT_TOWN_BIRCH" }, + [796] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_1", "HIDE_ROUTE_111_GABBY_AND_TY_1" }, + [797] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_1", "HIDE_ROUTE_118_GABBY_AND_TY_1" }, + [798] = { "FLAG_HIDE_ROUTE_120_GABBY_AND_TY_1", "HIDE_ROUTE_120_GABBY_AND_TY_1" }, + [799] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_2", "HIDE_ROUTE_111_GABBY_AND_TY_2" }, + [800] = { "FLAG_HIDE_LUGIA", "HIDE_LUGIA" }, + [801] = { "FLAG_HIDE_HO_OH", "HIDE_HO_OH" }, + [802] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_REPORTER", "HIDE_LILYCOVE_CONTEST_HALL_REPORTER" }, + [803] = { "FLAG_HIDE_SLATEPORT_CITY_CONTEST_REPORTER", "HIDE_SLATEPORT_CITY_CONTEST_REPORTER" }, + [804] = { "FLAG_HIDE_MAUVILLE_CITY_WALLY", "HIDE_MAUVILLE_CITY_WALLY" }, + [805] = { "FLAG_HIDE_MAUVILLE_CITY_WALLYS_UNCLE", "HIDE_MAUVILLE_CITY_WALLYS_UNCLE" }, + [806] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLY", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLY" }, + [807] = { "FLAG_HIDE_RUSTURF_TUNNEL_WANDAS_BOYFRIEND", "HIDE_RUSTURF_TUNNEL_WANDAS_BOYFRIEND" }, + [808] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDAS_BOYFRIEND", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDAS_BOYFRIEND" }, + [809] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLYS_UNCLE", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WALLYS_UNCLE" }, + [810] = { "FLAG_HIDE_SS_TIDAL_CORRIDOR_SCOTT", "HIDE_SS_TIDAL_CORRIDOR_SCOTT" }, + [811] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CYNDAQUIL", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CYNDAQUIL" }, + [812] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_TOTODILE", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_TOTODILE" }, + [813] = { "FLAG_HIDE_ROUTE_116_DROPPED_GLASSES_MAN", "HIDE_ROUTE_116_DROPPED_GLASSES_MAN" }, + [814] = { "FLAG_HIDE_RUSTBORO_CITY_RIVAL", "HIDE_RUSTBORO_CITY_RIVAL" }, + [815] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_SWABLU_DOLL", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_SWABLU_DOLL" }, + [816] = { "FLAG_HIDE_SOOTOPOLIS_CITY_WALLACE", "HIDE_SOOTOPOLIS_CITY_WALLACE" }, + [817] = { "FLAG_HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_POKE_BALL", "HIDE_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F_POKE_BALL" }, + [818] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_POKE_BALL", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_POKE_BALL" }, + [819] = { "FLAG_HIDE_ROUTE_112_TEAM_MAGMA", "HIDE_ROUTE_112_TEAM_MAGMA" }, + [820] = { "FLAG_HIDE_CAVE_OF_ORIGIN_B1F_WALLACE", "HIDE_CAVE_OF_ORIGIN_B1F_WALLACE" }, + [821] = { "FLAG_HIDE_AQUA_HIDEOUT_1F_GRUNT_1_BLOCKING_ENTRANCE", "HIDE_AQUA_HIDEOUT_1F_GRUNT_1_BLOCKING_ENTRANCE" }, + [822] = { "FLAG_HIDE_AQUA_HIDEOUT_1F_GRUNT_2_BLOCKING_ENTRANCE", "HIDE_AQUA_HIDEOUT_1F_GRUNT_2_BLOCKING_ENTRANCE" }, + [823] = { "FLAG_HIDE_MOSSDEEP_CITY_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_TEAM_MAGMA" }, + [824] = { "FLAG_HIDE_PETALBURG_GYM_WALLYS_DAD", "HIDE_PETALBURG_GYM_WALLYS_DAD" }, + [825] = { "FLAG_HIDE_LEGEND_MON_CAVE_OF_ORIGIN", "HIDE_LEGEND_MON_CAVE_OF_ORIGIN" }, + [826] = { "FLAG_HIDE_SOOTOPOLIS_CITY_ARCHIE", "HIDE_SOOTOPOLIS_CITY_ARCHIE" }, + [827] = { "FLAG_HIDE_SOOTOPOLIS_CITY_MAXIE", "HIDE_SOOTOPOLIS_CITY_MAXIE" }, + [828] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_ARCHIE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_ARCHIE" }, + [829] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_MAXIE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_MAXIE" }, + [830] = { "FLAG_HIDE_PETALBURG_CITY_WALLYS_DAD", "HIDE_PETALBURG_CITY_WALLYS_DAD" }, + [831] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_MAGMA_GRUNTS", "HIDE_SEAFLOOR_CAVERN_ROOM_9_MAGMA_GRUNTS" }, + [832] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER", "HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER" }, + [833] = { "FLAG_HIDE_GRANITE_CAVE_STEVEN", "HIDE_GRANITE_CAVE_STEVEN" }, + [834] = { "FLAG_HIDE_ROUTE_128_STEVEN", "HIDE_ROUTE_128_STEVEN" }, + [835] = { "FLAG_HIDE_SLATEPORT_CITY_GABBY_AND_TY", "HIDE_SLATEPORT_CITY_GABBY_AND_TY" }, + [836] = { "FLAG_HIDE_BATTLE_FRONTIER_RECEPTION_GATE_SCOTT", "HIDE_BATTLE_FRONTIER_RECEPTION_GATE_SCOTT" }, + [837] = { "FLAG_HIDE_ROUTE_110_BIRCH", "HIDE_ROUTE_110_BIRCH" }, + [838] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CHIKORITA", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_POKEBALL_CHIKORITA" }, + [839] = { "FLAG_HIDE_SOOTOPOLIS_CITY_MAN_1", "HIDE_SOOTOPOLIS_CITY_MAN_1" }, + [840] = { "FLAG_HIDE_SLATEPORT_CITY_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_CAPTAIN_STERN" }, + [841] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_HARBOR_CAPTAIN_STERN" }, + [842] = { "FLAG_HIDE_BATTLE_FRONTIER_SUDOWOODO", "HIDE_BATTLE_FRONTIER_SUDOWOODO" }, + [843] = { "FLAG_HIDE_ROUTE_111_ROCK_SMASH_TIP_GUY", "HIDE_ROUTE_111_ROCK_SMASH_TIP_GUY" }, + [844] = { "FLAG_HIDE_RUSTBORO_CITY_SCIENTIST", "HIDE_RUSTBORO_CITY_SCIENTIST" }, + [845] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_AQUA_GRUNT", "HIDE_SLATEPORT_CITY_HARBOR_AQUA_GRUNT" }, + [846] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_ARCHIE", "HIDE_SLATEPORT_CITY_HARBOR_ARCHIE" }, + [847] = { "FLAG_HIDE_JAGGED_PASS_MAGMA_GUARD", "HIDE_JAGGED_PASS_MAGMA_GUARD" }, + [848] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_SUBMARINE_SHADOW", "HIDE_SLATEPORT_CITY_HARBOR_SUBMARINE_SHADOW" }, + [849] = { "FLAG_HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_PICHU_DOLL", "HIDE_LITTLEROOT_TOWN_MAYS_HOUSE_2F_PICHU_DOLL" }, + [850] = { "FLAG_HIDE_MAGMA_HIDEOUT_4F_GROUDON_ASLEEP", "HIDE_MAGMA_HIDEOUT_4F_GROUDON_ASLEEP" }, + [851] = { "FLAG_HIDE_ROUTE_119_RIVAL", "HIDE_ROUTE_119_RIVAL" }, + [852] = { "FLAG_HIDE_LILYCOVE_CITY_AQUA_GRUNTS", "HIDE_LILYCOVE_CITY_AQUA_GRUNTS" }, + [853] = { "FLAG_HIDE_MAGMA_HIDEOUT_4F_GROUDON", "HIDE_MAGMA_HIDEOUT_4F_GROUDON" }, + [854] = { "FLAG_HIDE_SOOTOPOLIS_CITY_RESIDENTS", "HIDE_SOOTOPOLIS_CITY_RESIDENTS" }, + [855] = { "FLAG_HIDE_SKY_PILLAR_WALLACE", "HIDE_SKY_PILLAR_WALLACE" }, + [856] = { "FLAG_HIDE_MT_PYRE_SUMMIT_MAXIE", "HIDE_MT_PYRE_SUMMIT_MAXIE" }, + [857] = { "FLAG_HIDE_MAGMA_HIDEOUT_GRUNTS", "HIDE_MAGMA_HIDEOUT_GRUNTS" }, + [858] = { "FLAG_HIDE_VICTORY_ROAD_ENTRANCE_WALLY", "HIDE_VICTORY_ROAD_ENTRANCE_WALLY" }, + [859] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE", "HIDE_SEAFLOOR_CAVERN_ROOM_9_KYOGRE" }, + [860] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_SS_TIDAL", "HIDE_SLATEPORT_CITY_HARBOR_SS_TIDAL" }, + [861] = { "FLAG_HIDE_LILYCOVE_HARBOR_SSTIDAL", "HIDE_LILYCOVE_HARBOR_SSTIDAL" }, + [862] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_TEAM_MAGMA", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_TEAM_MAGMA" }, + [863] = { "FLAG_HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_STEVEN", "HIDE_MOSSDEEP_CITY_SPACE_CENTER_2F_STEVEN" }, + [864] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_1", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_1" }, + [865] = { "FLAG_HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_2", "HIDE_BATTLE_TOWER_MULTI_BATTLE_PARTNER_ALT_2" }, + [866] = { "FLAG_HIDE_PETALBURG_GYM_WALLY", "HIDE_PETALBURG_GYM_WALLY" }, + [867] = { "FLAG_UNKNOWN_0x363", "UNKNOWN_0x363" }, + [868] = { "FLAG_HIDE_LITTLEROOT_TOWN_FAT_MAN", "HIDE_LITTLEROOT_TOWN_FAT_MAN" }, + [869] = { "FLAG_HIDE_SLATEPORT_CITY_STERNS_SHIPYARD_MR_BRINEY", "HIDE_SLATEPORT_CITY_STERNS_SHIPYARD_MR_BRINEY" }, + [870] = { "FLAG_HIDE_LANETTES_HOUSE_LANETTE", "HIDE_LANETTES_HOUSE_LANETTE" }, + [871] = { "FLAG_HIDE_FALLARBOR_POKEMON_CENTER_LANETTE", "HIDE_FALLARBOR_POKEMON_CENTER_LANETTE" }, + [872] = { "FLAG_HIDE_TRICK_HOUSE_ENTRANCE_MAN", "HIDE_TRICK_HOUSE_ENTRANCE_MAN" }, + [873] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER_REPLACEMENT", "HIDE_LILYCOVE_CONTEST_HALL_BLEND_MASTER_REPLACEMENT" }, + [874] = { "FLAG_HIDE_DESERT_UNDERPASS_FOSSIL", "HIDE_DESERT_UNDERPASS_FOSSIL" }, + [875] = { "FLAG_HIDE_ROUTE_111_PLAYER_DESCENT", "HIDE_ROUTE_111_PLAYER_DESCENT" }, + [876] = { "FLAG_HIDE_ROUTE_111_DESERT_FOSSIL", "HIDE_ROUTE_111_DESERT_FOSSIL" }, + [877] = { "FLAG_HIDE_MT_CHIMNEY_TRAINERS", "HIDE_MT_CHIMNEY_TRAINERS" }, + [878] = { "FLAG_HIDE_RUSTURF_TUNNEL_AQUA_GRUNT", "HIDE_RUSTURF_TUNNEL_AQUA_GRUNT" }, + [879] = { "FLAG_HIDE_RUSTURF_TUNNEL_BRINEY", "HIDE_RUSTURF_TUNNEL_BRINEY" }, + [880] = { "FLAG_HIDE_RUSTURF_TUNNEL_PEEKO", "HIDE_RUSTURF_TUNNEL_PEEKO" }, + [881] = { "FLAG_HIDE_BRINEYS_HOUSE_PEEKO", "HIDE_BRINEYS_HOUSE_PEEKO" }, + [882] = { "FLAG_HIDE_SLATEPORT_CITY_TEAM_AQUA", "HIDE_SLATEPORT_CITY_TEAM_AQUA" }, + [883] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_AQUA_GRUNTS", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_AQUA_GRUNTS" }, + [884] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_1", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_1" }, + [885] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_2", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_AQUA_GRUNT_2" }, + [886] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_ARCHIE", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_ARCHIE" }, + [887] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_CAPTAIN_STERN", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_2F_CAPTAIN_STERN" }, + [888] = { "FLAG_HIDE_BATTLE_TOWER_OPPONENT", "HIDE_BATTLE_TOWER_OPPONENT" }, + [889] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_RIVAL", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_RIVAL" }, + [890] = { "FLAG_HIDE_ROUTE_119_TEAM_AQUA", "HIDE_ROUTE_119_TEAM_AQUA" }, + [891] = { "FLAG_HIDE_ROUTE_116_MR_BRINEY", "HIDE_ROUTE_116_MR_BRINEY" }, + [892] = { "FLAG_HIDE_WEATHER_INSTITUTE_1F_WORKERS", "HIDE_WEATHER_INSTITUTE_1F_WORKERS" }, + [893] = { "FLAG_HIDE_WEATHER_INSTITUTE_2F_WORKERS", "HIDE_WEATHER_INSTITUTE_2F_WORKERS" }, + [894] = { "FLAG_HIDE_ROUTE_116_WANDAS_BOYFRIEND", "HIDE_ROUTE_116_WANDAS_BOYFRIEND" }, + [895] = { "FLAG_HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_2", "HIDE_LILYCOVE_CONTEST_HALL_CONTEST_ATTENDANT_2" }, + [896] = { "FLAG_HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_UNKNOWN_0x380", "HIDE_LITTLEROOT_TOWN_BIRCHS_LAB_UNKNOWN_0x380" }, + [897] = { "FLAG_HIDE_ROUTE_101_BIRCH", "HIDE_ROUTE_101_BIRCH" }, + [898] = { "FLAG_HIDE_ROUTE_103_BIRCH", "HIDE_ROUTE_103_BIRCH" }, + [899] = { "FLAG_HIDE_TRICK_HOUSE_END_MAN", "HIDE_TRICK_HOUSE_END_MAN" }, + [900] = { "FLAG_HIDE_ROUTE_110_TEAM_AQUA", "HIDE_ROUTE_110_TEAM_AQUA" }, + [901] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_2", "HIDE_ROUTE_118_GABBY_AND_TY_2" }, + [902] = { "FLAG_HIDE_ROUTE_120_GABBY_AND_TY_2", "HIDE_ROUTE_120_GABBY_AND_TY_2" }, + [903] = { "FLAG_HIDE_ROUTE_111_GABBY_AND_TY_3", "HIDE_ROUTE_111_GABBY_AND_TY_3" }, + [904] = { "FLAG_HIDE_ROUTE_118_GABBY_AND_TY_3", "HIDE_ROUTE_118_GABBY_AND_TY_3" }, + [905] = { "FLAG_HIDE_SLATEPORT_CITY_HARBOR_PATRONS", "HIDE_SLATEPORT_CITY_HARBOR_PATRONS" }, + [906] = { "FLAG_HIDE_ROUTE_104_WHITE_HERB_FLORIST", "HIDE_ROUTE_104_WHITE_HERB_FLORIST" }, + [907] = { "FLAG_HIDE_FALLARBOR_AZURILL", "HIDE_FALLARBOR_AZURILL" }, + [908] = { "FLAG_HIDE_LILYCOVE_HARBOR_FERRY_ATTENDANT", "HIDE_LILYCOVE_HARBOR_FERRY_ATTENDANT" }, + [909] = { "FLAG_HIDE_LILYCOVE_HARBOR_FERRY_SAILOR", "HIDE_LILYCOVE_HARBOR_FERRY_SAILOR" }, + [910] = { "FLAG_HIDE_SOUTHERN_ISLAND_EON_STONE", "HIDE_SOUTHERN_ISLAND_EON_STONE" }, + [911] = { "FLAG_HIDE_SOUTHERN_ISLAND_UNCHOSEN_EON_DUO_MON", "HIDE_SOUTHERN_ISLAND_UNCHOSEN_EON_DUO_MON" }, + [912] = { "FLAG_HIDE_MAUVILLE_CITY_WATTSON", "HIDE_MAUVILLE_CITY_WATTSON" }, + [913] = { "FLAG_HIDE_MAUVILLE_GYM_WATTSON", "HIDE_MAUVILLE_GYM_WATTSON" }, + [914] = { "FLAG_HIDE_ROUTE_121_TEAM_AQUA_GRUNTS", "HIDE_ROUTE_121_TEAM_AQUA_GRUNTS" }, + [915] = { "FLAG_UNKNOWN_0x393", "UNKNOWN_0x393" }, + [916] = { "FLAG_HIDE_MT_PYRE_SUMMIT_ARCHIE", "HIDE_MT_PYRE_SUMMIT_ARCHIE" }, + [917] = { "FLAG_HIDE_MT_PYRE_SUMMIT_TEAM_AQUA", "HIDE_MT_PYRE_SUMMIT_TEAM_AQUA" }, + [918] = { "FLAG_HIDE_BATTLE_TOWER_REPORTER", "HIDE_BATTLE_TOWER_REPORTER" }, + [919] = { "FLAG_HIDE_ROUTE_110_RIVAL", "HIDE_ROUTE_110_RIVAL" }, + [920] = { "FLAG_HIDE_CHAMPIONS_ROOM_RIVAL", "HIDE_CHAMPIONS_ROOM_RIVAL" }, + [921] = { "FLAG_HIDE_CHAMPIONS_ROOM_BIRCH", "HIDE_CHAMPIONS_ROOM_BIRCH" }, + [922] = { "FLAG_HIDE_ROUTE_110_RIVAL_ON_BIKE", "HIDE_ROUTE_110_RIVAL_ON_BIKE" }, + [923] = { "FLAG_HIDE_ROUTE_119_RIVAL_ON_BIKE", "HIDE_ROUTE_119_RIVAL_ON_BIKE" }, + [924] = { "FLAG_HIDE_AQUA_HIDEOUT_GRUNTS", "HIDE_AQUA_HIDEOUT_GRUNTS" }, + [925] = { "FLAG_HIDE_LILYCOVE_MOTEL_GAME_DESIGNERS", "HIDE_LILYCOVE_MOTEL_GAME_DESIGNERS" }, + [926] = { "FLAG_HIDE_MT_CHIMNEY_TEAM_AQUA", "HIDE_MT_CHIMNEY_TEAM_AQUA" }, + [927] = { "FLAG_HIDE_MT_CHIMNEY_TEAM_MAGMA", "HIDE_MT_CHIMNEY_TEAM_MAGMA" }, + [928] = { "FLAG_HIDE_FALLARBOR_HOUSE_PROF_COZMO", "HIDE_FALLARBOR_HOUSE_PROF_COZMO" }, + [929] = { "FLAG_HIDE_LAVARIDGE_TOWN_RIVAL", "HIDE_LAVARIDGE_TOWN_RIVAL" }, + [930] = { "FLAG_HIDE_LAVARIDGE_TOWN_RIVAL_ON_BIKE", "HIDE_LAVARIDGE_TOWN_RIVAL_ON_BIKE" }, + [931] = { "FLAG_HIDE_RUSTURF_TUNNEL_ROCK_1", "HIDE_RUSTURF_TUNNEL_ROCK_1" }, + [932] = { "FLAG_HIDE_RUSTURF_TUNNEL_ROCK_2", "HIDE_RUSTURF_TUNNEL_ROCK_2" }, + [933] = { "FLAG_HIDE_FORTREE_CITY_HOUSE_4_WINGULL", "HIDE_FORTREE_CITY_HOUSE_4_WINGULL" }, + [934] = { "FLAG_HIDE_MOSSDEEP_CITY_HOUSE_2_WINGULL", "HIDE_MOSSDEEP_CITY_HOUSE_2_WINGULL" }, + [935] = { "FLAG_HIDE_REGIROCK", "HIDE_REGIROCK" }, + [936] = { "FLAG_HIDE_REGICE", "HIDE_REGICE" }, + [937] = { "FLAG_HIDE_REGISTEEL", "HIDE_REGISTEEL" }, + [938] = { "FLAG_HIDE_METEOR_FALLS_TEAM_AQUA", "HIDE_METEOR_FALLS_TEAM_AQUA" }, + [939] = { "FLAG_HIDE_METEOR_FALLS_TEAM_MAGMA", "HIDE_METEOR_FALLS_TEAM_MAGMA" }, + [940] = { "FLAG_HIDE_DEWFORD_HALL_SLUDGE_BOMB_MAN", "HIDE_DEWFORD_HALL_SLUDGE_BOMB_MAN" }, + [941] = { "FLAG_HIDE_SEAFLOOR_CAVERN_ENTRANCE_AQUA_GRUNT", "HIDE_SEAFLOOR_CAVERN_ENTRANCE_AQUA_GRUNT" }, + [942] = { "FLAG_HIDE_METEOR_FALLS_1F_1R_COZMO", "HIDE_METEOR_FALLS_1F_1R_COZMO" }, + [943] = { "FLAG_HIDE_AQUA_HIDEOUT_B2F_SUBMARINE_SHADOW", "HIDE_AQUA_HIDEOUT_B2F_SUBMARINE_SHADOW" }, + [944] = { "FLAG_HIDE_ROUTE_128_ARCHIE", "HIDE_ROUTE_128_ARCHIE" }, + [945] = { "FLAG_HIDE_ROUTE_128_MAXIE", "HIDE_ROUTE_128_MAXIE" }, + [946] = { "FLAG_HIDE_SEAFLOOR_CAVERN_AQUA_GRUNTS", "HIDE_SEAFLOOR_CAVERN_AQUA_GRUNTS" }, + [947] = { "FLAG_HIDE_ROUTE_116_DEVON_EMPLOYEE", "HIDE_ROUTE_116_DEVON_EMPLOYEE" }, + [948] = { "FLAG_HIDE_SLATEPORT_CITY_TM_SALESMAN", "HIDE_SLATEPORT_CITY_TM_SALESMAN" }, + [949] = { "FLAG_HIDE_RUSTBORO_CITY_DEVON_CORP_3F_EMPLOYEE", "HIDE_RUSTBORO_CITY_DEVON_CORP_3F_EMPLOYEE" }, + [950] = { "FLAG_HIDE_SS_TIDAL_CORRIDOR_MR_BRINEY", "HIDE_SS_TIDAL_CORRIDOR_MR_BRINEY" }, + [951] = { "FLAG_HIDE_SS_TIDAL_ROOMS_SNATCH_GIVER", "HIDE_SS_TIDAL_ROOMS_SNATCH_GIVER" }, + [952] = { "FLAG_RECEIVED_SHOAL_SALT_1", "RECEIVED_SHOAL_SALT_1" }, + [953] = { "FLAG_RECEIVED_SHOAL_SALT_2", "RECEIVED_SHOAL_SALT_2" }, + [954] = { "FLAG_RECEIVED_SHOAL_SALT_3", "RECEIVED_SHOAL_SALT_3" }, + [955] = { "FLAG_RECEIVED_SHOAL_SALT_4", "RECEIVED_SHOAL_SALT_4" }, + [956] = { "FLAG_RECEIVED_SHOAL_SHELL_1", "RECEIVED_SHOAL_SHELL_1" }, + [957] = { "FLAG_RECEIVED_SHOAL_SHELL_2", "RECEIVED_SHOAL_SHELL_2" }, + [958] = { "FLAG_RECEIVED_SHOAL_SHELL_3", "RECEIVED_SHOAL_SHELL_3" }, + [959] = { "FLAG_RECEIVED_SHOAL_SHELL_4", "RECEIVED_SHOAL_SHELL_4" }, + [960] = { "FLAG_HIDE_ROUTE_111_SECRET_POWER_MAN", "HIDE_ROUTE_111_SECRET_POWER_MAN" }, + [961] = { "FLAG_HIDE_SLATEPORT_MUSEUM_POPULATION", "HIDE_SLATEPORT_MUSEUM_POPULATION" }, + [962] = { "FLAG_HIDE_LILYCOVE_DEPARTMENT_STORE_ROOFTOP_SALE_WOMAN", "HIDE_LILYCOVE_DEPARTMENT_STORE_ROOFTOP_SALE_WOMAN" }, + [963] = { "FLAG_HIDE_MIRAGE_TOWER_ROOT_FOSSIL", "HIDE_MIRAGE_TOWER_ROOT_FOSSIL" }, + [964] = { "FLAG_HIDE_MIRAGE_TOWER_CLAW_FOSSIL", "HIDE_MIRAGE_TOWER_CLAW_FOSSIL" }, + [965] = { "FLAG_HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_FAMILIAR_AQUA_GRUNT", "HIDE_SLATEPORT_CITY_OCEANIC_MUSEUM_FAMILIAR_AQUA_GRUNT" }, + [966] = { "FLAG_HIDE_ROUTE_118_STEVEN", "HIDE_ROUTE_118_STEVEN" }, + [967] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_STEVEN", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_STEVEN" }, + [968] = { "FLAG_HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_BELDUM_POKEBALL", "HIDE_MOSSDEEP_CITY_STEVENS_HOUSE_BELDUM_POKEBALL" }, + [969] = { "FLAG_HIDE_FORTREE_CITY_KECLEON", "HIDE_FORTREE_CITY_KECLEON" }, + [970] = { "FLAG_HIDE_ROUTE_120_KECLEON_BRIDGE", "HIDE_ROUTE_120_KECLEON_BRIDGE" }, + [971] = { "FLAG_HIDE_LILYCOVE_CITY_RIVAL", "HIDE_LILYCOVE_CITY_RIVAL" }, + [972] = { "FLAG_HIDE_ROUTE_120_STEVEN", "HIDE_ROUTE_120_STEVEN" }, + [973] = { "FLAG_HIDE_SOOTOPOLIS_CITY_STEVEN", "HIDE_SOOTOPOLIS_CITY_STEVEN" }, + [974] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_1", "HIDE_NEW_MAUVILLE_VOLTORB_1" }, + [975] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_2", "HIDE_NEW_MAUVILLE_VOLTORB_2" }, + [976] = { "FLAG_HIDE_NEW_MAUVILLE_VOLTORB_3", "HIDE_NEW_MAUVILLE_VOLTORB_3" }, + [977] = { "FLAG_HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_1", "HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_1" }, + [978] = { "FLAG_HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_2", "HIDE_AQUA_HIDEOUT_B1F_ELECTRODE_2" }, + [979] = { "FLAG_HIDE_OLDALE_TOWN_RIVAL", "HIDE_OLDALE_TOWN_RIVAL" }, + [980] = { "FLAG_HIDE_UNDERWATER_SEA_FLOOR_CAVERN_STOLEN_SUBMARINE", "HIDE_UNDERWATER_SEA_FLOOR_CAVERN_STOLEN_SUBMARINE" }, + [981] = { "FLAG_HIDE_ROUTE_120_KECLEON_BRIDGE_SHADOW", "HIDE_ROUTE_120_KECLEON_BRIDGE_SHADOW" }, + [982] = { "FLAG_HIDE_ROUTE_120_KECLEON_1", "HIDE_ROUTE_120_KECLEON_1" }, + [983] = { "FLAG_HIDE_RUSTURF_TUNNEL_WANDA", "HIDE_RUSTURF_TUNNEL_WANDA" }, + [984] = { "FLAG_HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDA", "HIDE_VERDANTURF_TOWN_WANDAS_HOUSE_WANDA" }, + [985] = { "FLAG_HIDE_ROUTE_120_KECLEON_2", "HIDE_ROUTE_120_KECLEON_2" }, + [986] = { "FLAG_HIDE_ROUTE_120_KECLEON_3", "HIDE_ROUTE_120_KECLEON_3" }, + [987] = { "FLAG_HIDE_ROUTE_120_KECLEON_4", "HIDE_ROUTE_120_KECLEON_4" }, + [988] = { "FLAG_HIDE_ROUTE_120_KECLEON_5", "HIDE_ROUTE_120_KECLEON_5" }, + [989] = { "FLAG_HIDE_ROUTE_119_KECLEON_1", "HIDE_ROUTE_119_KECLEON_1" }, + [990] = { "FLAG_HIDE_ROUTE_119_KECLEON_2", "HIDE_ROUTE_119_KECLEON_2" }, + [991] = { "FLAG_HIDE_ROUTE_101_BOY", "HIDE_ROUTE_101_BOY" }, + [992] = { "FLAG_HIDE_WEATHER_INSTITUTE_2F_AQUA_GRUNT_M", "HIDE_WEATHER_INSTITUTE_2F_AQUA_GRUNT_M" }, + [993] = { "FLAG_HIDE_LILYCOVE_POKEMON_CENTER_CONTEST_LADY_MON", "HIDE_LILYCOVE_POKEMON_CENTER_CONTEST_LADY_MON" }, + [994] = { "FLAG_HIDE_MT_CHIMNEY_LAVA_COOKIE_LADY", "HIDE_MT_CHIMNEY_LAVA_COOKIE_LADY" }, + [995] = { "FLAG_HIDE_PETALBURG_CITY_SCOTT", "HIDE_PETALBURG_CITY_SCOTT" }, + [996] = { "FLAG_HIDE_SOOTOPOLIS_CITY_RAYQUAZA", "HIDE_SOOTOPOLIS_CITY_RAYQUAZA" }, + [997] = { "FLAG_HIDE_SOOTOPOLIS_CITY_KYOGRE", "HIDE_SOOTOPOLIS_CITY_KYOGRE" }, + [998] = { "FLAG_HIDE_SOOTOPOLIS_CITY_GROUDON", "HIDE_SOOTOPOLIS_CITY_GROUDON" }, + [999] = { "FLAG_HIDE_RUSTBORO_CITY_POKEMON_SCHOOL_SCOTT", "HIDE_RUSTBORO_CITY_POKEMON_SCHOOL_SCOTT" }, + [1000] = { "FLAG_ITEM_ROUTE_102_POTION", "ITEM_ROUTE_102_POTION" }, + [1001] = { "FLAG_ITEM_ROUTE_116_X_SPECIAL", "ITEM_ROUTE_116_X_SPECIAL" }, + [1002] = { "FLAG_ITEM_ROUTE_104_PP_UP", "ITEM_ROUTE_104_PP_UP" }, + [1003] = { "FLAG_ITEM_ROUTE_105_IRON", "ITEM_ROUTE_105_IRON" }, + [1004] = { "FLAG_ITEM_ROUTE_106_PROTEIN", "ITEM_ROUTE_106_PROTEIN" }, + [1005] = { "FLAG_ITEM_ROUTE_109_PP_UP", "ITEM_ROUTE_109_PP_UP" }, + [1006] = { "FLAG_ITEM_ROUTE_110_RARE_CANDY", "ITEM_ROUTE_110_RARE_CANDY" }, + [1007] = { "FLAG_ITEM_ROUTE_110_DIRE_HIT", "ITEM_ROUTE_110_DIRE_HIT" }, + [1008] = { "FLAG_ITEM_ROUTE_111_TM_SANDSTORM", "ITEM_ROUTE_111_TM_SANDSTORM" }, + [1009] = { "FLAG_ITEM_ROUTE_111_STARDUST", "ITEM_ROUTE_111_STARDUST" }, + [1010] = { "FLAG_ITEM_ROUTE_111_HP_UP", "ITEM_ROUTE_111_HP_UP" }, + [1011] = { "FLAG_ITEM_ROUTE_112_NUGGET", "ITEM_ROUTE_112_NUGGET" }, + [1012] = { "FLAG_ITEM_ROUTE_113_MAX_ETHER", "ITEM_ROUTE_113_MAX_ETHER" }, + [1013] = { "FLAG_ITEM_ROUTE_113_SUPER_REPEL", "ITEM_ROUTE_113_SUPER_REPEL" }, + [1014] = { "FLAG_ITEM_ROUTE_114_RARE_CANDY", "ITEM_ROUTE_114_RARE_CANDY" }, + [1015] = { "FLAG_ITEM_ROUTE_114_PROTEIN", "ITEM_ROUTE_114_PROTEIN" }, + [1016] = { "FLAG_ITEM_ROUTE_115_SUPER_POTION", "ITEM_ROUTE_115_SUPER_POTION" }, + [1017] = { "FLAG_ITEM_ROUTE_115_TM_FOCUS_PUNCH", "ITEM_ROUTE_115_TM_FOCUS_PUNCH" }, + [1018] = { "FLAG_ITEM_ROUTE_115_IRON", "ITEM_ROUTE_115_IRON" }, + [1019] = { "FLAG_ITEM_ROUTE_116_ETHER", "ITEM_ROUTE_116_ETHER" }, + [1020] = { "FLAG_ITEM_ROUTE_116_REPEL", "ITEM_ROUTE_116_REPEL" }, + [1021] = { "FLAG_ITEM_ROUTE_116_HP_UP", "ITEM_ROUTE_116_HP_UP" }, + [1022] = { "FLAG_ITEM_ROUTE_117_GREAT_BALL", "ITEM_ROUTE_117_GREAT_BALL" }, + [1023] = { "FLAG_ITEM_ROUTE_117_REVIVE", "ITEM_ROUTE_117_REVIVE" }, + [1024] = { "FLAG_ITEM_ROUTE_119_SUPER_REPEL", "ITEM_ROUTE_119_SUPER_REPEL" }, + [1025] = { "FLAG_ITEM_ROUTE_119_ZINC", "ITEM_ROUTE_119_ZINC" }, + [1026] = { "FLAG_ITEM_ROUTE_119_ELIXIR_1", "ITEM_ROUTE_119_ELIXIR_1" }, + [1027] = { "FLAG_ITEM_ROUTE_119_LEAF_STONE", "ITEM_ROUTE_119_LEAF_STONE" }, + [1028] = { "FLAG_ITEM_ROUTE_119_RARE_CANDY", "ITEM_ROUTE_119_RARE_CANDY" }, + [1029] = { "FLAG_ITEM_ROUTE_119_HYPER_POTION_1", "ITEM_ROUTE_119_HYPER_POTION_1" }, + [1030] = { "FLAG_ITEM_ROUTE_120_NUGGET", "ITEM_ROUTE_120_NUGGET" }, + [1031] = { "FLAG_ITEM_ROUTE_120_FULL_HEAL", "ITEM_ROUTE_120_FULL_HEAL" }, + [1032] = { "FLAG_ITEM_ROUTE_123_CALCIUM", "ITEM_ROUTE_123_CALCIUM" }, + [1033] = { "FLAG_ITEM_ROUTE_123_RARE_CANDY", "ITEM_ROUTE_123_RARE_CANDY" }, + [1034] = { "FLAG_ITEM_ROUTE_127_ZINC", "ITEM_ROUTE_127_ZINC" }, + [1035] = { "FLAG_ITEM_ROUTE_127_CARBOS", "ITEM_ROUTE_127_CARBOS" }, + [1036] = { "FLAG_ITEM_ROUTE_132_RARE_CANDY", "ITEM_ROUTE_132_RARE_CANDY" }, + [1037] = { "FLAG_ITEM_ROUTE_133_BIG_PEARL", "ITEM_ROUTE_133_BIG_PEARL" }, + [1038] = { "FLAG_ITEM_ROUTE_133_STAR_PIECE", "ITEM_ROUTE_133_STAR_PIECE" }, + [1039] = { "FLAG_ITEM_PETALBURG_CITY_MAX_REVIVE", "ITEM_PETALBURG_CITY_MAX_REVIVE" }, + [1040] = { "FLAG_ITEM_PETALBURG_CITY_ETHER", "ITEM_PETALBURG_CITY_ETHER" }, + [1041] = { "FLAG_ITEM_RUSTBORO_CITY_X_DEFEND", "ITEM_RUSTBORO_CITY_X_DEFEND" }, + [1042] = { "FLAG_ITEM_LILYCOVE_CITY_MAX_REPEL", "ITEM_LILYCOVE_CITY_MAX_REPEL" }, + [1043] = { "FLAG_ITEM_MOSSDEEP_CITY_NET_BALL", "ITEM_MOSSDEEP_CITY_NET_BALL" }, + [1044] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_TM_IRON_TAIL", "ITEM_METEOR_FALLS_1F_1R_TM_IRON_TAIL" }, + [1045] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_FULL_HEAL", "ITEM_METEOR_FALLS_1F_1R_FULL_HEAL" }, + [1046] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_MOON_STONE", "ITEM_METEOR_FALLS_1F_1R_MOON_STONE" }, + [1047] = { "FLAG_ITEM_METEOR_FALLS_1F_1R_PP_UP", "ITEM_METEOR_FALLS_1F_1R_PP_UP" }, + [1048] = { "FLAG_ITEM_RUSTURF_TUNNEL_POKE_BALL", "ITEM_RUSTURF_TUNNEL_POKE_BALL" }, + [1049] = { "FLAG_ITEM_RUSTURF_TUNNEL_MAX_ETHER", "ITEM_RUSTURF_TUNNEL_MAX_ETHER" }, + [1050] = { "FLAG_ITEM_GRANITE_CAVE_1F_ESCAPE_ROPE", "ITEM_GRANITE_CAVE_1F_ESCAPE_ROPE" }, + [1051] = { "FLAG_ITEM_GRANITE_CAVE_B1F_POKE_BALL", "ITEM_GRANITE_CAVE_B1F_POKE_BALL" }, + [1052] = { "FLAG_ITEM_MT_PYRE_5F_LAX_INCENSE", "ITEM_MT_PYRE_5F_LAX_INCENSE" }, + [1053] = { "FLAG_ITEM_GRANITE_CAVE_B2F_REPEL", "ITEM_GRANITE_CAVE_B2F_REPEL" }, + [1054] = { "FLAG_ITEM_GRANITE_CAVE_B2F_RARE_CANDY", "ITEM_GRANITE_CAVE_B2F_RARE_CANDY" }, + [1055] = { "FLAG_ITEM_PETALBURG_WOODS_X_ATTACK", "ITEM_PETALBURG_WOODS_X_ATTACK" }, + [1056] = { "FLAG_ITEM_PETALBURG_WOODS_GREAT_BALL", "ITEM_PETALBURG_WOODS_GREAT_BALL" }, + [1057] = { "FLAG_ITEM_ROUTE_104_POKE_BALL", "ITEM_ROUTE_104_POKE_BALL" }, + [1058] = { "FLAG_ITEM_PETALBURG_WOODS_ETHER", "ITEM_PETALBURG_WOODS_ETHER" }, + [1059] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_3R_ECAPE_ROPE", "ITEM_MAGMA_HIDEOUT_3F_3R_ECAPE_ROPE" }, + [1060] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_1_ORANGE_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_1_ORANGE_MAIL" }, + [1061] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_2_HARBOR_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_2_HARBOR_MAIL" }, + [1062] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_2_WAVE_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_2_WAVE_MAIL" }, + [1063] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_3_SHADOW_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_3_SHADOW_MAIL" }, + [1064] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_3_WOOD_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_3_WOOD_MAIL" }, + [1065] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_4_MECH_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_4_MECH_MAIL" }, + [1066] = { "FLAG_ITEM_ROUTE_124_YELLOW_SHARD", "ITEM_ROUTE_124_YELLOW_SHARD" }, + [1067] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_6_GLITTER_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_6_GLITTER_MAIL" }, + [1068] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_7_TROPIC_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_7_TROPIC_MAIL" }, + [1069] = { "FLAG_ITEM_TRICK_HOUSE_PUZZLE_8_BEAD_MAIL", "ITEM_TRICK_HOUSE_PUZZLE_8_BEAD_MAIL" }, + [1070] = { "FLAG_ITEM_JAGGED_PASS_BURN_HEAL", "ITEM_JAGGED_PASS_BURN_HEAL" }, + [1071] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_MAX_ELIXIR", "ITEM_AQUA_HIDEOUT_B1F_MAX_ELIXIR" }, + [1072] = { "FLAG_ITEM_AQUA_HIDEOUT_B2F_NEST_BALL", "ITEM_AQUA_HIDEOUT_B2F_NEST_BALL" }, + [1073] = { "FLAG_ITEM_MT_PYRE_EXTERIOR_MAX_POTION", "ITEM_MT_PYRE_EXTERIOR_MAX_POTION" }, + [1074] = { "FLAG_ITEM_MT_PYRE_EXTERIOR_TM_SKILL_SWAP", "ITEM_MT_PYRE_EXTERIOR_TM_SKILL_SWAP" }, + [1075] = { "FLAG_ITEM_NEW_MAUVILLE_ULTRA_BALL", "ITEM_NEW_MAUVILLE_ULTRA_BALL" }, + [1076] = { "FLAG_ITEM_NEW_MAUVILLE_ESCAPE_ROPE", "ITEM_NEW_MAUVILLE_ESCAPE_ROPE" }, + [1077] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_6_LUXURY_BALL", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_6_LUXURY_BALL" }, + [1078] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_2_SCANNER", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_2_SCANNER" }, + [1079] = { "FLAG_ITEM_SCORCHED_SLAB_TM_SUNNY_DAY", "ITEM_SCORCHED_SLAB_TM_SUNNY_DAY" }, + [1080] = { "FLAG_ITEM_METEOR_FALLS_B1F_2R_TM_DRAGON_CLAW", "ITEM_METEOR_FALLS_B1F_2R_TM_DRAGON_CLAW" }, + [1081] = { "FLAG_ITEM_SHOAL_CAVE_ENTRANCE_BIG_PEARL", "ITEM_SHOAL_CAVE_ENTRANCE_BIG_PEARL" }, + [1082] = { "FLAG_ITEM_SHOAL_CAVE_INNER_ROOM_RARE_CANDY", "ITEM_SHOAL_CAVE_INNER_ROOM_RARE_CANDY" }, + [1083] = { "FLAG_ITEM_SHOAL_CAVE_STAIRS_ROOM_ICE_HEAL", "ITEM_SHOAL_CAVE_STAIRS_ROOM_ICE_HEAL" }, + [1084] = { "FLAG_ITEM_VICTORY_ROAD_1F_MAX_ELIXIR", "ITEM_VICTORY_ROAD_1F_MAX_ELIXIR" }, + [1085] = { "FLAG_ITEM_VICTORY_ROAD_1F_PP_UP", "ITEM_VICTORY_ROAD_1F_PP_UP" }, + [1086] = { "FLAG_ITEM_VICTORY_ROAD_B1F_TM_PSYCHIC", "ITEM_VICTORY_ROAD_B1F_TM_PSYCHIC" }, + [1087] = { "FLAG_ITEM_VICTORY_ROAD_B1F_FULL_RESTORE", "ITEM_VICTORY_ROAD_B1F_FULL_RESTORE" }, + [1088] = { "FLAG_ITEM_VICTORY_ROAD_B2F_FULL_HEAL", "ITEM_VICTORY_ROAD_B2F_FULL_HEAL" }, + [1089] = { "FLAG_ITEM_MT_PYRE_6F_TM_SHADOW_BALL", "ITEM_MT_PYRE_6F_TM_SHADOW_BALL" }, + [1090] = { "FLAG_ITEM_SEAFLOOR_CAVERN_ROOM_9_TM_EARTHQUAKE", "ITEM_SEAFLOOR_CAVERN_ROOM_9_TM_EARTHQUAKE" }, + [1091] = { "FLAG_ITEM_FIERY_PATH_TM_TOXIC", "ITEM_FIERY_PATH_TM_TOXIC" }, + [1092] = { "FLAG_ITEM_ROUTE_124_RED_SHARD", "ITEM_ROUTE_124_RED_SHARD" }, + [1093] = { "FLAG_ITEM_ROUTE_124_BLUE_SHARD", "ITEM_ROUTE_124_BLUE_SHARD" }, + [1094] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_WEST_TM_SOLAR_BEAM", "ITEM_SAFARI_ZONE_NORTH_WEST_TM_SOLAR_BEAM" }, + [1095] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_1F_HARBOR_MAIL", "ITEM_ABANDONED_SHIP_ROOMS_1F_HARBOR_MAIL" }, + [1096] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_B1F_ESCAPE_ROPE", "ITEM_ABANDONED_SHIP_ROOMS_B1F_ESCAPE_ROPE" }, + [1097] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_2_B1F_DIVE_BALL", "ITEM_ABANDONED_SHIP_ROOMS_2_B1F_DIVE_BALL" }, + [1098] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_B1F_TM_ICE_BEAM", "ITEM_ABANDONED_SHIP_ROOMS_B1F_TM_ICE_BEAM" }, + [1099] = { "FLAG_ITEM_ABANDONED_SHIP_ROOMS_2_1F_REVIVE", "ITEM_ABANDONED_SHIP_ROOMS_2_1F_REVIVE" }, + [1100] = { "FLAG_ITEM_ABANDONED_SHIP_CAPTAINS_OFFICE_STORAGE_KEY", "ITEM_ABANDONED_SHIP_CAPTAINS_OFFICE_STORAGE_KEY" }, + [1101] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_3_WATER_STONE", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_3_WATER_STONE" }, + [1102] = { "FLAG_ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_1_TM_RAIN_DANCE", "ITEM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOM_1_TM_RAIN_DANCE" }, + [1103] = { "FLAG_ITEM_ROUTE_121_CARBOS", "ITEM_ROUTE_121_CARBOS" }, + [1104] = { "FLAG_ITEM_ROUTE_123_ULTRA_BALL", "ITEM_ROUTE_123_ULTRA_BALL" }, + [1105] = { "FLAG_ITEM_ROUTE_126_GREEN_SHARD", "ITEM_ROUTE_126_GREEN_SHARD" }, + [1106] = { "FLAG_ITEM_ROUTE_119_HYPER_POTION_2", "ITEM_ROUTE_119_HYPER_POTION_2" }, + [1107] = { "FLAG_ITEM_ROUTE_120_HYPER_POTION", "ITEM_ROUTE_120_HYPER_POTION" }, + [1108] = { "FLAG_ITEM_ROUTE_120_NEST_BALL", "ITEM_ROUTE_120_NEST_BALL" }, + [1109] = { "FLAG_ITEM_ROUTE_123_ELIXIR", "ITEM_ROUTE_123_ELIXIR" }, + [1110] = { "FLAG_ITEM_NEW_MAUVILLE_THUNDER_STONE", "ITEM_NEW_MAUVILLE_THUNDER_STONE" }, + [1111] = { "FLAG_ITEM_FIERY_PATH_FIRE_STONE", "ITEM_FIERY_PATH_FIRE_STONE" }, + [1112] = { "FLAG_ITEM_SHOAL_CAVE_ICE_ROOM_TM_HAIL", "ITEM_SHOAL_CAVE_ICE_ROOM_TM_HAIL" }, + [1113] = { "FLAG_ITEM_SHOAL_CAVE_ICE_ROOM_NEVER_MELT_ICE", "ITEM_SHOAL_CAVE_ICE_ROOM_NEVER_MELT_ICE" }, + [1114] = { "FLAG_ITEM_ROUTE_103_GUARD_SPEC", "ITEM_ROUTE_103_GUARD_SPEC" }, + [1115] = { "FLAG_ITEM_ROUTE_104_X_ACCURACY", "ITEM_ROUTE_104_X_ACCURACY" }, + [1116] = { "FLAG_ITEM_MAUVILLE_CITY_X_SPEED", "ITEM_MAUVILLE_CITY_X_SPEED" }, + [1117] = { "FLAG_ITEM_PETALBURG_WOODS_PARALYZE_HEAL", "ITEM_PETALBURG_WOODS_PARALYZE_HEAL" }, + [1118] = { "FLAG_ITEM_ROUTE_115_GREAT_BALL", "ITEM_ROUTE_115_GREAT_BALL" }, + [1119] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_CALCIUM", "ITEM_SAFARI_ZONE_NORTH_CALCIUM" }, + [1120] = { "FLAG_ITEM_MT_PYRE_3F_SUPER_REPEL", "ITEM_MT_PYRE_3F_SUPER_REPEL" }, + [1121] = { "FLAG_ITEM_ROUTE_118_HYPER_POTION", "ITEM_ROUTE_118_HYPER_POTION" }, + [1122] = { "FLAG_ITEM_NEW_MAUVILLE_FULL_HEAL", "ITEM_NEW_MAUVILLE_FULL_HEAL" }, + [1123] = { "FLAG_ITEM_NEW_MAUVILLE_PARALYZE_HEAL", "ITEM_NEW_MAUVILLE_PARALYZE_HEAL" }, + [1124] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_MASTER_BALL", "ITEM_AQUA_HIDEOUT_B1F_MASTER_BALL" }, + [1125] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B1F_MASTER_BALL", "ITEM_OLD_MAGMA_HIDEOUT_B1F_MASTER_BALL" }, + [1126] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B1F_MAX_ELIXIR", "ITEM_OLD_MAGMA_HIDEOUT_B1F_MAX_ELIXIR" }, + [1127] = { "FLAG_ITEM_OLD_MAGMA_HIDEOUT_B2F_NEST_BALL", "ITEM_OLD_MAGMA_HIDEOUT_B2F_NEST_BALL" }, + [1128] = { "FLAG_UNUSED_0x468", "UNUSED_0x468" }, + [1129] = { "FLAG_ITEM_MT_PYRE_2F_ULTRA_BALL", "ITEM_MT_PYRE_2F_ULTRA_BALL" }, + [1130] = { "FLAG_ITEM_MT_PYRE_4F_SEA_INCENSE", "ITEM_MT_PYRE_4F_SEA_INCENSE" }, + [1131] = { "FLAG_ITEM_SAFARI_ZONE_SOUTH_WEST_MAX_REVIVE", "ITEM_SAFARI_ZONE_SOUTH_WEST_MAX_REVIVE" }, + [1132] = { "FLAG_ITEM_AQUA_HIDEOUT_B1F_NUGGET", "ITEM_AQUA_HIDEOUT_B1F_NUGGET" }, + [1133] = { "FLAG_ITEM_MOSSDEEP_STEVENS_HOUSE_HM08", "ITEM_MOSSDEEP_STEVENS_HOUSE_HM08" }, + [1134] = { "FLAG_ITEM_ROUTE_119_NUGGET", "ITEM_ROUTE_119_NUGGET" }, + [1135] = { "FLAG_ITEM_ROUTE_104_POTION", "ITEM_ROUTE_104_POTION" }, + [1136] = { "FLAG_UNUSED_0x470", "UNUSED_0x470" }, + [1137] = { "FLAG_ITEM_ROUTE_103_PP_UP", "ITEM_ROUTE_103_PP_UP" }, + [1138] = { "FLAG_UNUSED_0x472", "UNUSED_0x472" }, + [1139] = { "FLAG_ITEM_ROUTE_108_STAR_PIECE", "ITEM_ROUTE_108_STAR_PIECE" }, + [1140] = { "FLAG_ITEM_ROUTE_109_POTION", "ITEM_ROUTE_109_POTION" }, + [1141] = { "FLAG_ITEM_ROUTE_110_ELIXIR", "ITEM_ROUTE_110_ELIXIR" }, + [1142] = { "FLAG_ITEM_ROUTE_111_ELIXIR", "ITEM_ROUTE_111_ELIXIR" }, + [1143] = { "FLAG_ITEM_ROUTE_113_HYPER_POTION", "ITEM_ROUTE_113_HYPER_POTION" }, + [1144] = { "FLAG_ITEM_ROUTE_115_HEAL_POWDER", "ITEM_ROUTE_115_HEAL_POWDER" }, + [1145] = { "FLAG_UNUSED_0x479", "UNUSED_0x479" }, + [1146] = { "FLAG_ITEM_ROUTE_116_POTION", "ITEM_ROUTE_116_POTION" }, + [1147] = { "FLAG_ITEM_ROUTE_119_ELIXIR_2", "ITEM_ROUTE_119_ELIXIR_2" }, + [1148] = { "FLAG_ITEM_ROUTE_120_REVIVE", "ITEM_ROUTE_120_REVIVE" }, + [1149] = { "FLAG_ITEM_ROUTE_121_REVIVE", "ITEM_ROUTE_121_REVIVE" }, + [1150] = { "FLAG_ITEM_ROUTE_121_ZINC", "ITEM_ROUTE_121_ZINC" }, + [1151] = { "FLAG_ITEM_MAGMA_HIDEOUT_1F_RARE_CANDY", "ITEM_MAGMA_HIDEOUT_1F_RARE_CANDY" }, + [1152] = { "FLAG_ITEM_ROUTE_123_PP_UP", "ITEM_ROUTE_123_PP_UP" }, + [1153] = { "FLAG_ITEM_ROUTE_123_REVIVAL_HERB", "ITEM_ROUTE_123_REVIVAL_HERB" }, + [1154] = { "FLAG_ITEM_ROUTE_125_BIG_PEARL", "ITEM_ROUTE_125_BIG_PEARL" }, + [1155] = { "FLAG_ITEM_ROUTE_127_RARE_CANDY", "ITEM_ROUTE_127_RARE_CANDY" }, + [1156] = { "FLAG_ITEM_ROUTE_132_PROTEIN", "ITEM_ROUTE_132_PROTEIN" }, + [1157] = { "FLAG_ITEM_ROUTE_133_MAX_REVIVE", "ITEM_ROUTE_133_MAX_REVIVE" }, + [1158] = { "FLAG_ITEM_ROUTE_134_CARBOS", "ITEM_ROUTE_134_CARBOS" }, + [1159] = { "FLAG_ITEM_ROUTE_134_STAR_PIECE", "ITEM_ROUTE_134_STAR_PIECE" }, + [1160] = { "FLAG_ITEM_ROUTE_114_ENERGY_POWDER", "ITEM_ROUTE_114_ENERGY_POWDER" }, + [1161] = { "FLAG_ITEM_ROUTE_115_PP_UP", "ITEM_ROUTE_115_PP_UP" }, + [1162] = { "FLAG_ITEM_ARTISAN_CAVE_B1F_HP_UP", "ITEM_ARTISAN_CAVE_B1F_HP_UP" }, + [1163] = { "FLAG_ITEM_ARTISAN_CAVE_1F_CARBOS", "ITEM_ARTISAN_CAVE_1F_CARBOS" }, + [1164] = { "FLAG_ITEM_MAGMA_HIDEOUT_2F_2R_MAX_ELIXIR", "ITEM_MAGMA_HIDEOUT_2F_2R_MAX_ELIXIR" }, + [1165] = { "FLAG_ITEM_MAGMA_HIDEOUT_2F_2R_FULL_RESTORE", "ITEM_MAGMA_HIDEOUT_2F_2R_FULL_RESTORE" }, + [1166] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_1R_NUGGET", "ITEM_MAGMA_HIDEOUT_3F_1R_NUGGET" }, + [1167] = { "FLAG_ITEM_MAGMA_HIDEOUT_3F_2R_PP_MAX", "ITEM_MAGMA_HIDEOUT_3F_2R_PP_MAX" }, + [1168] = { "FLAG_ITEM_MAGMA_HIDEOUT_4F_MAX_REVIVE", "ITEM_MAGMA_HIDEOUT_4F_MAX_REVIVE" }, + [1169] = { "FLAG_ITEM_SAFARI_ZONE_NORTH_EAST_NUGGET", "ITEM_SAFARI_ZONE_NORTH_EAST_NUGGET" }, + [1170] = { "FLAG_ITEM_SAFARI_ZONE_SOUTH_EAST_BIG_PEARL", "ITEM_SAFARI_ZONE_SOUTH_EAST_BIG_PEARL" }, + [1171] = { "FLAG_UNUSED_0x493", "UNUSED_0x493" }, + [1172] = { "FLAG_UNUSED_0x494", "UNUSED_0x494" }, + [1173] = { "FLAG_UNUSED_0x495", "UNUSED_0x495" }, + [1174] = { "FLAG_UNUSED_0x496", "UNUSED_0x496" }, + [1175] = { "FLAG_UNUSED_0x497", "UNUSED_0x497" }, + [1176] = { "FLAG_UNUSED_0x498", "UNUSED_0x498" }, + [1177] = { "FLAG_UNUSED_0x499", "UNUSED_0x499" }, + [1178] = { "FLAG_UNUSED_0x49A", "UNUSED_0x49A" }, + [1179] = { "FLAG_UNUSED_0x49B", "UNUSED_0x49B" }, + [1180] = { "FLAG_UNUSED_0x49C", "UNUSED_0x49C" }, + [1181] = { "FLAG_UNUSED_0x49D", "UNUSED_0x49D" }, + [1182] = { "FLAG_UNUSED_0x49E", "UNUSED_0x49E" }, + [1183] = { "FLAG_UNUSED_0x49F", "UNUSED_0x49F" }, + [1184] = { "FLAG_UNUSED_0x4A0", "UNUSED_0x4A0" }, + [1185] = { "FLAG_UNUSED_0x4A1", "UNUSED_0x4A1" }, + [1186] = { "FLAG_UNUSED_0x4A2", "UNUSED_0x4A2" }, + [1187] = { "FLAG_UNUSED_0x4A3", "UNUSED_0x4A3" }, + [1188] = { "FLAG_UNUSED_0x4A4", "UNUSED_0x4A4" }, + [1189] = { "FLAG_UNUSED_0x4A5", "UNUSED_0x4A5" }, + [1190] = { "FLAG_UNUSED_0x4A6", "UNUSED_0x4A6" }, + [1191] = { "FLAG_UNUSED_0x4A7", "UNUSED_0x4A7" }, + [1192] = { "FLAG_UNUSED_0x4A8", "UNUSED_0x4A8" }, + [1193] = { "FLAG_UNUSED_0x4A9", "UNUSED_0x4A9" }, + [1194] = { "FLAG_UNUSED_0x4AA", "UNUSED_0x4AA" }, + [1195] = { "FLAG_UNUSED_0x4AB", "UNUSED_0x4AB" }, + [1196] = { "FLAG_UNUSED_0x4AC", "UNUSED_0x4AC" }, + [1197] = { "FLAG_UNUSED_0x4AD", "UNUSED_0x4AD" }, + [1198] = { "FLAG_UNUSED_0x4AE", "UNUSED_0x4AE" }, + [1199] = { "FLAG_UNUSED_0x4AF", "UNUSED_0x4AF" }, + [1200] = { "FLAG_UNUSED_0x4B0", "UNUSED_0x4B0" }, + [1201] = { "FLAG_UNUSED_0x4B1", "UNUSED_0x4B1" }, + [1202] = { "FLAG_UNUSED_0x4B2", "UNUSED_0x4B2" }, + [1203] = { "FLAG_UNUSED_0x4B3", "UNUSED_0x4B3" }, + [1204] = { "FLAG_UNUSED_0x4B4", "UNUSED_0x4B4" }, + [1205] = { "FLAG_UNUSED_0x4B5", "UNUSED_0x4B5" }, + [1206] = { "FLAG_UNUSED_0x4B6", "UNUSED_0x4B6" }, + [1207] = { "FLAG_UNUSED_0x4B7", "UNUSED_0x4B7" }, + [1208] = { "FLAG_UNUSED_0x4B8", "UNUSED_0x4B8" }, + [1209] = { "FLAG_UNUSED_0x4B9", "UNUSED_0x4B9" }, + [1210] = { "FLAG_UNUSED_0x4BA", "UNUSED_0x4BA" }, + [1211] = { "FLAG_UNUSED_0x4BB", "UNUSED_0x4BB" }, + [1212] = { "FLAG_UNUSED_0x4BC", "UNUSED_0x4BC" }, + [1213] = { "FLAG_UNUSED_0x4BD", "UNUSED_0x4BD" }, + [1214] = { "FLAG_UNUSED_0x4BE", "UNUSED_0x4BE" }, + [1215] = { "FLAG_UNUSED_0x4BF", "UNUSED_0x4BF" }, + [1216] = { "FLAG_UNUSED_0x4C0", "UNUSED_0x4C0" }, + [1217] = { "FLAG_UNUSED_0x4C1", "UNUSED_0x4C1" }, + [1218] = { "FLAG_UNUSED_0x4C2", "UNUSED_0x4C2" }, + [1219] = { "FLAG_UNUSED_0x4C3", "UNUSED_0x4C3" }, + [1220] = { "FLAG_UNUSED_0x4C4", "UNUSED_0x4C4" }, + [1221] = { "FLAG_UNUSED_0x4C5", "UNUSED_0x4C5" }, + [1222] = { "FLAG_UNUSED_0x4C6", "UNUSED_0x4C6" }, + [1223] = { "FLAG_UNUSED_0x4C7", "UNUSED_0x4C7" }, + [1224] = { "FLAG_UNUSED_0x4C8", "UNUSED_0x4C8" }, + [1225] = { "FLAG_UNUSED_0x4C9", "UNUSED_0x4C9" }, + [1226] = { "FLAG_UNUSED_0x4CA", "UNUSED_0x4CA" }, + [1227] = { "FLAG_UNUSED_0x4CB", "UNUSED_0x4CB" }, + [1228] = { "FLAG_UNUSED_0x4CC", "UNUSED_0x4CC" }, + [1229] = { "FLAG_UNUSED_0x4CD", "UNUSED_0x4CD" }, + [1230] = { "FLAG_UNUSED_0x4CE", "UNUSED_0x4CE" }, + [1231] = { "FLAG_UNUSED_0x4CF", "UNUSED_0x4CF" }, + [1232] = { "FLAG_UNUSED_0x4D0", "UNUSED_0x4D0" }, + [1233] = { "FLAG_UNUSED_0x4D1", "UNUSED_0x4D1" }, + [1234] = { "FLAG_UNUSED_0x4D2", "UNUSED_0x4D2" }, + [1235] = { "FLAG_UNUSED_0x4D3", "UNUSED_0x4D3" }, + [1236] = { "FLAG_UNUSED_0x4D4", "UNUSED_0x4D4" }, + [1237] = { "FLAG_UNUSED_0x4D5", "UNUSED_0x4D5" }, + [1238] = { "FLAG_UNUSED_0x4D6", "UNUSED_0x4D6" }, + [1239] = { "FLAG_UNUSED_0x4D7", "UNUSED_0x4D7" }, + [1240] = { "FLAG_UNUSED_0x4D8", "UNUSED_0x4D8" }, + [1241] = { "FLAG_UNUSED_0x4D9", "UNUSED_0x4D9" }, + [1242] = { "FLAG_UNUSED_0x4DA", "UNUSED_0x4DA" }, + [1243] = { "FLAG_UNUSED_0x4DB", "UNUSED_0x4DB" }, + [1244] = { "FLAG_UNUSED_0x4DC", "UNUSED_0x4DC" }, + [1245] = { "FLAG_UNUSED_0x4DD", "UNUSED_0x4DD" }, + [1246] = { "FLAG_UNUSED_0x4DE", "UNUSED_0x4DE" }, + [1247] = { "FLAG_UNUSED_0x4DF", "UNUSED_0x4DF" }, + [1248] = { "FLAG_UNUSED_0x4E0", "UNUSED_0x4E0" }, + [1249] = { "FLAG_UNUSED_0x4E1", "UNUSED_0x4E1" }, + [1250] = { "FLAG_UNUSED_0x4E2", "UNUSED_0x4E2" }, + [1251] = { "FLAG_UNUSED_0x4E3", "UNUSED_0x4E3" }, + [1252] = { "FLAG_UNUSED_0x4E4", "UNUSED_0x4E4" }, + [1253] = { "FLAG_UNUSED_0x4E5", "UNUSED_0x4E5" }, + [1254] = { "FLAG_UNUSED_0x4E6", "UNUSED_0x4E6" }, + [1255] = { "FLAG_UNUSED_0x4E7", "UNUSED_0x4E7" }, + [1256] = { "FLAG_UNUSED_0x4E8", "UNUSED_0x4E8" }, + [1257] = { "FLAG_UNUSED_0x4E9", "UNUSED_0x4E9" }, + [1258] = { "FLAG_UNUSED_0x4EA", "UNUSED_0x4EA" }, + [1259] = { "FLAG_UNUSED_0x4EB", "UNUSED_0x4EB" }, + [1260] = { "FLAG_UNUSED_0x4EC", "UNUSED_0x4EC" }, + [1261] = { "FLAG_UNUSED_0x4ED", "UNUSED_0x4ED" }, + [1262] = { "FLAG_UNUSED_0x4EE", "UNUSED_0x4EE" }, + [1263] = { "FLAG_UNUSED_0x4EF", "UNUSED_0x4EF" }, + [1264] = { "FLAG_DEFEATED_RUSTBORO_GYM", "DEFEATED_RUSTBORO_GYM" }, + [1265] = { "FLAG_DEFEATED_DEWFORD_GYM", "DEFEATED_DEWFORD_GYM" }, + [1266] = { "FLAG_DEFEATED_MAUVILLE_GYM", "DEFEATED_MAUVILLE_GYM" }, + [1267] = { "FLAG_DEFEATED_LAVARIDGE_GYM", "DEFEATED_LAVARIDGE_GYM" }, + [1268] = { "FLAG_DEFEATED_PETALBURG_GYM", "DEFEATED_PETALBURG_GYM" }, + [1269] = { "FLAG_DEFEATED_FORTREE_GYM", "DEFEATED_FORTREE_GYM" }, + [1270] = { "FLAG_DEFEATED_MOSSDEEP_GYM", "DEFEATED_MOSSDEEP_GYM" }, + [1271] = { "FLAG_DEFEATED_SOOTOPOLIS_GYM", "DEFEATED_SOOTOPOLIS_GYM" }, + [1272] = { "FLAG_DEFEATED_METEOR_FALLS_STEVEN", "DEFEATED_METEOR_FALLS_STEVEN" }, + [1273] = { "FLAG_UNUSED_0x4F9", "UNUSED_0x4F9" }, + [1274] = { "FLAG_UNUSED_0x4FA", "UNUSED_0x4FA" }, + [1275] = { "FLAG_DEFEATED_ELITE_4_SIDNEY", "DEFEATED_ELITE_4_SIDNEY" }, + [1276] = { "FLAG_DEFEATED_ELITE_4_PHOEBE", "DEFEATED_ELITE_4_PHOEBE" }, + [1277] = { "FLAG_DEFEATED_ELITE_4_GLACIA", "DEFEATED_ELITE_4_GLACIA" }, + [1278] = { "FLAG_DEFEATED_ELITE_4_DRAKE", "DEFEATED_ELITE_4_DRAKE" }, + [1279] = { "FLAG_UNUSED_0x4FF", "UNUSED_0x4FF" }, + [1280] = { false, "TRAINER_FLAGS_START" }, + [2143] = { false, "TRAINER_FLAGS_END" }, + [2144] = { "FLAG_SYS_POKEMON_GET", "SYSTEM_FLAGS", "SYS_POKEMON_GET" }, + [2145] = { "FLAG_SYS_POKEDEX_GET", "SYS_POKEDEX_GET" }, + [2146] = { "FLAG_SYS_POKENAV_GET", "SYS_POKENAV_GET" }, + [2147] = { "FLAG_UNUSED_0x863", "UNUSED_0x863" }, + [2148] = { "FLAG_SYS_GAME_CLEAR", "SYS_GAME_CLEAR" }, + [2149] = { "FLAG_SYS_CHAT_USED", "SYS_CHAT_USED" }, + [2150] = { "FLAG_UNLOCKED_TRENDY_SAYINGS", "UNLOCKED_TRENDY_SAYINGS" }, + [2151] = { "FLAG_BADGE01_GET", "BADGE01_GET" }, + [2152] = { "FLAG_BADGE02_GET", "BADGE02_GET" }, + [2153] = { "FLAG_BADGE03_GET", "BADGE03_GET" }, + [2154] = { "FLAG_BADGE04_GET", "BADGE04_GET" }, + [2155] = { "FLAG_BADGE05_GET", "BADGE05_GET" }, + [2156] = { "FLAG_BADGE06_GET", "BADGE06_GET" }, + [2157] = { "FLAG_BADGE07_GET", "BADGE07_GET" }, + [2158] = { "FLAG_BADGE08_GET", "BADGE08_GET" }, + [2159] = { "FLAG_VISITED_LITTLEROOT_TOWN", "VISITED_LITTLEROOT_TOWN" }, + [2160] = { "FLAG_VISITED_OLDALE_TOWN", "VISITED_OLDALE_TOWN" }, + [2161] = { "FLAG_VISITED_DEWFORD_TOWN", "VISITED_DEWFORD_TOWN" }, + [2162] = { "FLAG_VISITED_LAVARIDGE_TOWN", "VISITED_LAVARIDGE_TOWN" }, + [2163] = { "FLAG_VISITED_FALLARBOR_TOWN", "VISITED_FALLARBOR_TOWN" }, + [2164] = { "FLAG_VISITED_VERDANTURF_TOWN", "VISITED_VERDANTURF_TOWN" }, + [2165] = { "FLAG_VISITED_PACIFIDLOG_TOWN", "VISITED_PACIFIDLOG_TOWN" }, + [2166] = { "FLAG_VISITED_PETALBURG_CITY", "VISITED_PETALBURG_CITY" }, + [2167] = { "FLAG_VISITED_SLATEPORT_CITY", "VISITED_SLATEPORT_CITY" }, + [2168] = { "FLAG_VISITED_MAUVILLE_CITY", "VISITED_MAUVILLE_CITY" }, + [2169] = { "FLAG_VISITED_RUSTBORO_CITY", "VISITED_RUSTBORO_CITY" }, + [2170] = { "FLAG_VISITED_FORTREE_CITY", "VISITED_FORTREE_CITY" }, + [2171] = { "FLAG_VISITED_LILYCOVE_CITY", "VISITED_LILYCOVE_CITY" }, + [2172] = { "FLAG_VISITED_MOSSDEEP_CITY", "VISITED_MOSSDEEP_CITY" }, + [2173] = { "FLAG_VISITED_SOOTOPOLIS_CITY", "VISITED_SOOTOPOLIS_CITY" }, + [2174] = { "FLAG_VISITED_EVER_GRANDE_CITY", "VISITED_EVER_GRANDE_CITY" }, + [2175] = { "FLAG_IS_CHAMPION", "IS_CHAMPION" }, + [2176] = { "FLAG_NURSE_UNION_ROOM_REMINDER", "NURSE_UNION_ROOM_REMINDER" }, + [2177] = { "FLAG_UNUSED_0x881", "UNUSED_0x881" }, + [2178] = { "FLAG_UNUSED_0x882", "UNUSED_0x882" }, + [2179] = { "FLAG_UNUSED_0x883", "UNUSED_0x883" }, + [2180] = { "FLAG_UNUSED_0x884", "UNUSED_0x884" }, + [2181] = { "FLAG_UNUSED_0x885", "UNUSED_0x885" }, + [2182] = { "FLAG_UNUSED_0x886", "UNUSED_0x886" }, + [2183] = { "FLAG_UNUSED_0x887", "UNUSED_0x887" }, + [2184] = { "FLAG_SYS_USE_FLASH", "SYS_USE_FLASH" }, + [2185] = { "FLAG_SYS_USE_STRENGTH", "SYS_USE_STRENGTH" }, + [2186] = { "FLAG_SYS_WEATHER_CTRL", "SYS_WEATHER_CTRL" }, + [2187] = { "FLAG_SYS_CYCLING_ROAD", "SYS_CYCLING_ROAD" }, + [2188] = { "FLAG_SYS_SAFARI_MODE", "SYS_SAFARI_MODE" }, + [2189] = { "FLAG_SYS_CRUISE_MODE", "SYS_CRUISE_MODE" }, + [2190] = { "FLAG_UNUSED_0x88E", "UNUSED_0x88E" }, + [2191] = { "FLAG_UNUSED_0x88F", "UNUSED_0x88F" }, + [2192] = { "FLAG_SYS_TV_HOME", "SYS_TV_HOME" }, + [2193] = { "FLAG_SYS_TV_WATCH", "SYS_TV_WATCH" }, + [2194] = { false, "FLAG_SYS_TV_START", "SYS_TV_START" }, + [2195] = { "FLAG_SYS_CHANGED_DEWFORD_TREND", "SYS_CHANGED_DEWFORD_TREND" }, + [2196] = { "FLAG_SYS_MIX_RECORD", "SYS_MIX_RECORD" }, + [2197] = { "FLAG_SYS_CLOCK_SET", "SYS_CLOCK_SET" }, + [2198] = { "FLAG_SYS_NATIONAL_DEX", "SYS_NATIONAL_DEX" }, + [2199] = { "FLAG_SYS_CAVE_SHIP", "SYS_CAVE_SHIP" }, + [2200] = { "FLAG_SYS_CAVE_WONDER", "SYS_CAVE_WONDER" }, + [2201] = { "FLAG_SYS_CAVE_BATTLE", "SYS_CAVE_BATTLE" }, + [2202] = { "FLAG_SYS_SHOAL_TIDE", "SYS_SHOAL_TIDE" }, + [2203] = { "FLAG_SYS_RIBBON_GET", "SYS_RIBBON_GET" }, + [2204] = { "FLAG_LANDMARK_FLOWER_SHOP", "LANDMARK_FLOWER_SHOP" }, + [2205] = { "FLAG_LANDMARK_MR_BRINEY_HOUSE", "LANDMARK_MR_BRINEY_HOUSE" }, + [2206] = { "FLAG_LANDMARK_ABANDONED_SHIP", "LANDMARK_ABANDONED_SHIP" }, + [2207] = { "FLAG_LANDMARK_SEASHORE_HOUSE", "LANDMARK_SEASHORE_HOUSE" }, + [2208] = { "FLAG_LANDMARK_NEW_MAUVILLE", "LANDMARK_NEW_MAUVILLE" }, + [2209] = { "FLAG_LANDMARK_OLD_LADY_REST_SHOP", "LANDMARK_OLD_LADY_REST_SHOP" }, + [2210] = { "FLAG_LANDMARK_TRICK_HOUSE", "LANDMARK_TRICK_HOUSE" }, + [2211] = { "FLAG_LANDMARK_WINSTRATE_FAMILY", "LANDMARK_WINSTRATE_FAMILY" }, + [2212] = { "FLAG_LANDMARK_GLASS_WORKSHOP", "LANDMARK_GLASS_WORKSHOP" }, + [2213] = { "FLAG_LANDMARK_LANETTES_HOUSE", "LANDMARK_LANETTES_HOUSE" }, + [2214] = { "FLAG_LANDMARK_POKEMON_DAYCARE", "LANDMARK_POKEMON_DAYCARE" }, + [2215] = { "FLAG_LANDMARK_SEAFLOOR_CAVERN", "LANDMARK_SEAFLOOR_CAVERN" }, + [2216] = { "FLAG_LANDMARK_BATTLE_FRONTIER", "LANDMARK_BATTLE_FRONTIER" }, + [2217] = { "FLAG_LANDMARK_SOUTHERN_ISLAND", "LANDMARK_SOUTHERN_ISLAND" }, + [2218] = { "FLAG_LANDMARK_FIERY_PATH", "LANDMARK_FIERY_PATH" }, + [2219] = { "FLAG_SYS_PC_LANETTE", "SYS_PC_LANETTE" }, + [2220] = { "FLAG_SYS_MYSTERY_EVENT_ENABLE", "SYS_MYSTERY_EVENT_ENABLE" }, + [2221] = { "FLAG_SYS_ENC_UP_ITEM", "SYS_ENC_UP_ITEM" }, + [2222] = { "FLAG_SYS_ENC_DOWN_ITEM", "SYS_ENC_DOWN_ITEM" }, + [2223] = { "FLAG_SYS_BRAILLE_DIG", "SYS_BRAILLE_DIG" }, + [2224] = { "FLAG_SYS_REGIROCK_PUZZLE_COMPLETED", "SYS_REGIROCK_PUZZLE_COMPLETED" }, + [2225] = { "FLAG_SYS_BRAILLE_REGICE_COMPLETED", "SYS_BRAILLE_REGICE_COMPLETED" }, + [2226] = { "FLAG_SYS_REGISTEEL_PUZZLE_COMPLETED", "SYS_REGISTEEL_PUZZLE_COMPLETED" }, + [2227] = { "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", "ENABLE_SHIP_SOUTHERN_ISLAND" }, + [2228] = { "FLAG_LANDMARK_POKEMON_LEAGUE", "LANDMARK_POKEMON_LEAGUE" }, + [2229] = { "FLAG_LANDMARK_ISLAND_CAVE", "LANDMARK_ISLAND_CAVE" }, + [2230] = { "FLAG_LANDMARK_DESERT_RUINS", "LANDMARK_DESERT_RUINS" }, + [2231] = { "FLAG_LANDMARK_FOSSIL_MANIACS_HOUSE", "LANDMARK_FOSSIL_MANIACS_HOUSE" }, + [2232] = { "FLAG_LANDMARK_SCORCHED_SLAB", "LANDMARK_SCORCHED_SLAB" }, + [2233] = { "FLAG_LANDMARK_ANCIENT_TOMB", "LANDMARK_ANCIENT_TOMB" }, + [2234] = { "FLAG_LANDMARK_TUNNELERS_REST_HOUSE", "LANDMARK_TUNNELERS_REST_HOUSE" }, + [2235] = { "FLAG_LANDMARK_HUNTERS_HOUSE", "LANDMARK_HUNTERS_HOUSE" }, + [2236] = { "FLAG_LANDMARK_SEALED_CHAMBER", "LANDMARK_SEALED_CHAMBER" }, + [2237] = { "FLAG_SYS_TV_LATIAS_LATIOS", "SYS_TV_LATIAS_LATIOS" }, + [2238] = { "FLAG_LANDMARK_SKY_PILLAR", "LANDMARK_SKY_PILLAR" }, + [2239] = { "FLAG_SYS_SHOAL_ITEM", "SYS_SHOAL_ITEM" }, + [2240] = { "FLAG_SYS_B_DASH", "SYS_B_DASH" }, + [2241] = { "FLAG_SYS_CTRL_OBJ_DELETE", "SYS_CTRL_OBJ_DELETE" }, + [2242] = { "FLAG_SYS_RESET_RTC_ENABLE", "SYS_RESET_RTC_ENABLE" }, + [2243] = { "FLAG_LANDMARK_BERRY_MASTERS_HOUSE", "LANDMARK_BERRY_MASTERS_HOUSE" }, + [2244] = { "FLAG_SYS_TOWER_SILVER", "SYS_TOWER_SILVER" }, + [2245] = { "FLAG_SYS_TOWER_GOLD", "SYS_TOWER_GOLD" }, + [2246] = { "FLAG_SYS_DOME_SILVER", "SYS_DOME_SILVER" }, + [2247] = { "FLAG_SYS_DOME_GOLD", "SYS_DOME_GOLD" }, + [2248] = { "FLAG_SYS_PALACE_SILVER", "SYS_PALACE_SILVER" }, + [2249] = { "FLAG_SYS_PALACE_GOLD", "SYS_PALACE_GOLD" }, + [2250] = { "FLAG_SYS_ARENA_SILVER", "SYS_ARENA_SILVER" }, + [2251] = { "FLAG_SYS_ARENA_GOLD", "SYS_ARENA_GOLD" }, + [2252] = { "FLAG_SYS_FACTORY_SILVER", "SYS_FACTORY_SILVER" }, + [2253] = { "FLAG_SYS_FACTORY_GOLD", "SYS_FACTORY_GOLD" }, + [2254] = { "FLAG_SYS_PIKE_SILVER", "SYS_PIKE_SILVER" }, + [2255] = { "FLAG_SYS_PIKE_GOLD", "SYS_PIKE_GOLD" }, + [2256] = { "FLAG_SYS_PYRAMID_SILVER", "SYS_PYRAMID_SILVER" }, + [2257] = { "FLAG_SYS_PYRAMID_GOLD", "SYS_PYRAMID_GOLD" }, + [2258] = { "FLAG_SYS_FRONTIER_PASS", "SYS_FRONTIER_PASS" }, + [2259] = { "FLAG_MAP_SCRIPT_CHECKED_DEOXYS", "MAP_SCRIPT_CHECKED_DEOXYS" }, + [2260] = { "FLAG_DEOXYS_ROCK_COMPLETE", "DEOXYS_ROCK_COMPLETE" }, + [2261] = { "FLAG_ENABLE_SHIP_BIRTH_ISLAND", "ENABLE_SHIP_BIRTH_ISLAND" }, + [2262] = { "FLAG_ENABLE_SHIP_FARAWAY_ISLAND", "ENABLE_SHIP_FARAWAY_ISLAND" }, + [2263] = { "FLAG_SHOWN_BOX_WAS_FULL_MESSAGE", "SHOWN_BOX_WAS_FULL_MESSAGE" }, + [2264] = { "FLAG_ARRIVED_ON_FARAWAY_ISLAND", "ARRIVED_ON_FARAWAY_ISLAND" }, + [2265] = { "FLAG_ARRIVED_AT_MARINE_CAVE_EMERGE_SPOT", "ARRIVED_AT_MARINE_CAVE_EMERGE_SPOT" }, + [2266] = { "FLAG_ARRIVED_AT_TERRA_CAVE_ENTRANCE", "ARRIVED_AT_TERRA_CAVE_ENTRANCE" }, + [2267] = { "FLAG_SYS_MYSTERY_GIFT_ENABLE", "SYS_MYSTERY_GIFT_ENABLE" }, + [2268] = { "FLAG_ENTERED_MIRAGE_TOWER", "ENTERED_MIRAGE_TOWER" }, + [2269] = { "FLAG_LANDMARK_ALTERING_CAVE", "LANDMARK_ALTERING_CAVE" }, + [2270] = { "FLAG_LANDMARK_DESERT_UNDERPASS", "LANDMARK_DESERT_UNDERPASS" }, + [2271] = { "FLAG_LANDMARK_ARTISAN_CAVE", "LANDMARK_ARTISAN_CAVE" }, + [2272] = { "FLAG_ENABLE_SHIP_NAVEL_ROCK", "ENABLE_SHIP_NAVEL_ROCK" }, + [2273] = { "FLAG_ARRIVED_AT_NAVEL_ROCK", "ARRIVED_AT_NAVEL_ROCK" }, + [2274] = { "FLAG_LANDMARK_TRAINER_HILL", "LANDMARK_TRAINER_HILL" }, + [2275] = { "FLAG_UNUSED_0x8E3", "UNUSED_0x8E3" }, + [2276] = { "FLAG_RECEIVED_POKEDEX_FROM_BIRCH", "RECEIVED_POKEDEX_FROM_BIRCH" }, + [2277] = { "FLAG_UNUSED_0x8E5", "UNUSED_0x8E5" }, + [2278] = { "FLAG_UNUSED_0x8E6", "UNUSED_0x8E6" }, + [2279] = { "FLAG_UNUSED_0x8E7", "UNUSED_0x8E7" }, + [2280] = { "FLAG_UNUSED_0x8E8", "UNUSED_0x8E8" }, + [2281] = { "FLAG_UNUSED_0x8E9", "UNUSED_0x8E9" }, + [2282] = { "FLAG_UNUSED_0x8EA", "UNUSED_0x8EA" }, + [2283] = { "FLAG_UNUSED_0x8EB", "UNUSED_0x8EB" }, + [2284] = { "FLAG_UNUSED_0x8EC", "UNUSED_0x8EC" }, + [2285] = { "FLAG_UNUSED_0x8ED", "UNUSED_0x8ED" }, + [2286] = { "FLAG_UNUSED_0x8EE", "UNUSED_0x8EE" }, + [2287] = { "FLAG_UNUSED_0x8EF", "UNUSED_0x8EF" }, + [2288] = { "FLAG_UNUSED_0x8F0", "UNUSED_0x8F0" }, + [2289] = { "FLAG_UNUSED_0x8F1", "UNUSED_0x8F1" }, + [2290] = { "FLAG_UNUSED_0x8F2", "UNUSED_0x8F2" }, + [2291] = { "FLAG_UNUSED_0x8F3", "UNUSED_0x8F3" }, + [2292] = { "FLAG_UNUSED_0x8F4", "UNUSED_0x8F4" }, + [2293] = { "FLAG_UNUSED_0x8F5", "UNUSED_0x8F5" }, + [2294] = { "FLAG_UNUSED_0x8F6", "UNUSED_0x8F6" }, + [2295] = { "FLAG_UNUSED_0x8F7", "UNUSED_0x8F7" }, + [2296] = { "FLAG_UNUSED_0x8F8", "UNUSED_0x8F8" }, + [2297] = { "FLAG_UNUSED_0x8F9", "UNUSED_0x8F9" }, + [2298] = { "FLAG_UNUSED_0x8FA", "UNUSED_0x8FA" }, + [2299] = { "FLAG_UNUSED_0x8FB", "UNUSED_0x8FB" }, + [2300] = { "FLAG_UNUSED_0x8FC", "UNUSED_0x8FC" }, + [2301] = { "FLAG_UNUSED_0x8FD", "UNUSED_0x8FD" }, + [2302] = { "FLAG_UNUSED_0x8FE", "UNUSED_0x8FE" }, + [2303] = { "FLAG_UNUSED_0x8FF", "UNUSED_0x8FF" }, + [2304] = { "FLAG_UNUSED_0x900", "UNUSED_0x900" }, + [2305] = { "FLAG_UNUSED_0x901", "UNUSED_0x901" }, + [2306] = { "FLAG_UNUSED_0x902", "UNUSED_0x902" }, + [2307] = { "FLAG_UNUSED_0x903", "UNUSED_0x903" }, + [2308] = { "FLAG_UNUSED_0x904", "UNUSED_0x904" }, + [2309] = { "FLAG_UNUSED_0x905", "UNUSED_0x905" }, + [2310] = { "FLAG_UNUSED_0x906", "UNUSED_0x906" }, + [2311] = { "FLAG_UNUSED_0x907", "UNUSED_0x907" }, + [2312] = { "FLAG_UNUSED_0x908", "UNUSED_0x908" }, + [2313] = { "FLAG_UNUSED_0x909", "UNUSED_0x909" }, + [2314] = { "FLAG_UNUSED_0x90A", "UNUSED_0x90A" }, + [2315] = { "FLAG_UNUSED_0x90B", "UNUSED_0x90B" }, + [2316] = { "FLAG_UNUSED_0x90C", "UNUSED_0x90C" }, + [2317] = { "FLAG_UNUSED_0x90D", "UNUSED_0x90D" }, + [2318] = { "FLAG_UNUSED_0x90E", "UNUSED_0x90E" }, + [2319] = { "FLAG_UNUSED_0x90F", "UNUSED_0x90F" }, + [2320] = { "FLAG_UNUSED_0x910", "UNUSED_0x910" }, + [2321] = { "FLAG_UNUSED_0x911", "UNUSED_0x911" }, + [2322] = { "FLAG_UNUSED_0x912", "UNUSED_0x912" }, + [2323] = { "FLAG_UNUSED_0x913", "UNUSED_0x913" }, + [2324] = { "FLAG_UNUSED_0x914", "UNUSED_0x914" }, + [2325] = { "FLAG_UNUSED_0x915", "UNUSED_0x915" }, + [2326] = { "FLAG_UNUSED_0x916", "UNUSED_0x916" }, + [2327] = { "FLAG_UNUSED_0x917", "UNUSED_0x917" }, + [2328] = { "FLAG_UNUSED_0x918", "UNUSED_0x918" }, + [2329] = { "FLAG_UNUSED_0x919", "UNUSED_0x919" }, + [2330] = { "FLAG_UNUSED_0x91A", "UNUSED_0x91A" }, + [2331] = { "FLAG_UNUSED_0x91B", "UNUSED_0x91B" }, + [2332] = { "FLAG_UNUSED_0x91C", "UNUSED_0x91C" }, + [2333] = { "FLAG_UNUSED_0x91D", "UNUSED_0x91D" }, + [2334] = { "FLAG_UNUSED_0x91E", "UNUSED_0x91E" }, + [2335] = { "FLAG_UNUSED_0x91F", "UNUSED_0x91F" }, + [2336] = { "FLAG_UNUSED_0x920", "DAILY_FLAGS_START", "UNUSED_0x920" }, + [2337] = { "FLAG_DAILY_CONTEST_LOBBY_RECEIVED_BERRY", "DAILY_CONTEST_LOBBY_RECEIVED_BERRY" }, + [2338] = { "FLAG_DAILY_SECRET_BASE", "DAILY_SECRET_BASE" }, + [2339] = { "FLAG_UNUSED_0x923", "UNUSED_0x923" }, + [2340] = { "FLAG_UNUSED_0x924", "UNUSED_0x924" }, + [2341] = { "FLAG_UNUSED_0x925", "UNUSED_0x925" }, + [2342] = { "FLAG_UNUSED_0x926", "UNUSED_0x926" }, + [2343] = { "FLAG_UNUSED_0x927", "UNUSED_0x927" }, + [2344] = { "FLAG_UNUSED_0x928", "UNUSED_0x928" }, + [2345] = { "FLAG_UNUSED_0x929", "UNUSED_0x929" }, + [2346] = { "FLAG_DAILY_PICKED_LOTO_TICKET", "DAILY_PICKED_LOTO_TICKET" }, + [2347] = { "FLAG_DAILY_ROUTE_114_RECEIVED_BERRY", "DAILY_ROUTE_114_RECEIVED_BERRY" }, + [2348] = { "FLAG_DAILY_ROUTE_111_RECEIVED_BERRY", "DAILY_ROUTE_111_RECEIVED_BERRY" }, + [2349] = { "FLAG_DAILY_BERRY_MASTER_RECEIVED_BERRY", "DAILY_BERRY_MASTER_RECEIVED_BERRY" }, + [2350] = { "FLAG_DAILY_ROUTE_120_RECEIVED_BERRY", "DAILY_ROUTE_120_RECEIVED_BERRY" }, + [2351] = { "FLAG_DAILY_LILYCOVE_RECEIVED_BERRY", "DAILY_LILYCOVE_RECEIVED_BERRY" }, + [2352] = { "FLAG_DAILY_FLOWER_SHOP_RECEIVED_BERRY", "DAILY_FLOWER_SHOP_RECEIVED_BERRY" }, + [2353] = { "FLAG_DAILY_BERRY_MASTERS_WIFE", "DAILY_BERRY_MASTERS_WIFE" }, + [2354] = { "FLAG_DAILY_SOOTOPOLIS_RECEIVED_BERRY", "DAILY_SOOTOPOLIS_RECEIVED_BERRY" }, + [2355] = { "FLAG_UNUSED_0x933", "UNUSED_0x933" }, + [2356] = { "FLAG_DAILY_APPRENTICE_LEAVES", "DAILY_APPRENTICE_LEAVES" }, + [2357] = { "FLAG_UNUSED_0x935", "UNUSED_0x935" }, + [2358] = { "FLAG_UNUSED_0x936", "UNUSED_0x936" }, + [2359] = { "FLAG_UNUSED_0x937", "UNUSED_0x937" }, + [2360] = { "FLAG_UNUSED_0x938", "UNUSED_0x938" }, + [2361] = { "FLAG_UNUSED_0x939", "UNUSED_0x939" }, + [2362] = { "FLAG_UNUSED_0x93A", "UNUSED_0x93A" }, + [2363] = { "FLAG_UNUSED_0x93B", "UNUSED_0x93B" }, + [2364] = { "FLAG_UNUSED_0x93C", "UNUSED_0x93C" }, + [2365] = { "FLAG_UNUSED_0x93D", "UNUSED_0x93D" }, + [2366] = { "FLAG_UNUSED_0x93E", "UNUSED_0x93E" }, + [2367] = { "FLAG_UNUSED_0x93F", "UNUSED_0x93F" }, + [2368] = { "FLAG_UNUSED_0x940", "UNUSED_0x940" }, + [2369] = { "FLAG_UNUSED_0x941", "UNUSED_0x941" }, + [2370] = { "FLAG_UNUSED_0x942", "UNUSED_0x942" }, + [2371] = { "FLAG_UNUSED_0x943", "UNUSED_0x943" }, + [2372] = { "FLAG_UNUSED_0x944", "UNUSED_0x944" }, + [2373] = { "FLAG_UNUSED_0x945", "UNUSED_0x945" }, + [2374] = { "FLAG_UNUSED_0x946", "UNUSED_0x946" }, + [2375] = { "FLAG_UNUSED_0x947", "UNUSED_0x947" }, + [2376] = { "FLAG_UNUSED_0x948", "UNUSED_0x948" }, + [2377] = { "FLAG_UNUSED_0x949", "UNUSED_0x949" }, + [2378] = { "FLAG_UNUSED_0x94A", "UNUSED_0x94A" }, + [2379] = { "FLAG_UNUSED_0x94B", "UNUSED_0x94B" }, + [2380] = { "FLAG_UNUSED_0x94C", "UNUSED_0x94C" }, + [2381] = { "FLAG_UNUSED_0x94D", "UNUSED_0x94D" }, + [2382] = { "FLAG_UNUSED_0x94E", "UNUSED_0x94E" }, + [2383] = { "FLAG_UNUSED_0x94F", "UNUSED_0x94F" }, + [2384] = { "FLAG_UNUSED_0x950", "UNUSED_0x950" }, + [2385] = { "FLAG_UNUSED_0x951", "UNUSED_0x951" }, + [2386] = { "FLAG_UNUSED_0x952", "UNUSED_0x952" }, + [2387] = { "FLAG_UNUSED_0x953", "UNUSED_0x953" }, + [2388] = { "FLAG_UNUSED_0x954", "UNUSED_0x954" }, + [2389] = { "FLAG_UNUSED_0x955", "UNUSED_0x955" }, + [2390] = { "FLAG_UNUSED_0x956", "UNUSED_0x956" }, + [2391] = { "FLAG_UNUSED_0x957", "UNUSED_0x957" }, + [2392] = { "FLAG_UNUSED_0x958", "UNUSED_0x958" }, + [2393] = { "FLAG_UNUSED_0x959", "UNUSED_0x959" }, + [2394] = { "FLAG_UNUSED_0x95A", "UNUSED_0x95A" }, + [2395] = { "FLAG_UNUSED_0x95B", "UNUSED_0x95B" }, + [2396] = { "FLAG_UNUSED_0x95C", "UNUSED_0x95C" }, + [2397] = { "FLAG_UNUSED_0x95D", "UNUSED_0x95D" }, + [2398] = { "FLAG_UNUSED_0x95E", "UNUSED_0x95E" }, + [2399] = { "FLAG_UNUSED_0x95F", "DAILY_FLAGS_END", "UNUSED_0x95F" }, + }, +} diff --git a/src/mods/flag_names/firered.lua b/src/mods/flag_names/firered.lua new file mode 100644 index 000000000..4123b7412 --- /dev/null +++ b/src/mods/flag_names/firered.lua @@ -0,0 +1,1477 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + limit = 2304, + records = { + [1] = { "FLAG_TEMP_1", "TEMP_1" }, + [2] = { "FLAG_TEMP_2", "TEMP_2" }, + [3] = { "FLAG_TEMP_3", "TEMP_3" }, + [4] = { "FLAG_TEMP_4", "TEMP_4" }, + [5] = { "FLAG_TEMP_5", "TEMP_5" }, + [6] = { "FLAG_TEMP_6", "TEMP_6" }, + [7] = { "FLAG_TEMP_7", "TEMP_7" }, + [8] = { "FLAG_TEMP_8", "NUM_BADGES", "TEMP_8" }, + [9] = { "FLAG_TEMP_9", "TEMP_9" }, + [10] = { "FLAG_TEMP_A", "TEMP_A" }, + [11] = { "FLAG_TEMP_B", "TEMP_B" }, + [12] = { "FLAG_TEMP_C", "TEMP_C" }, + [13] = { "FLAG_TEMP_D", "TEMP_D" }, + [14] = { "FLAG_TEMP_E", "TEMP_E" }, + [15] = { "FLAG_TEMP_F", "TEMP_F" }, + [16] = { "FLAG_TEMP_10", "TEMP_10" }, + [17] = { "FLAG_TEMP_11", "TEMP_11" }, + [18] = { "FLAG_TEMP_12", "TEMP_12" }, + [19] = { "FLAG_TEMP_13", "TEMP_13" }, + [20] = { "FLAG_TEMP_14", "NUM_WONDER_CARD_FLAGS", "TEMP_14" }, + [21] = { "FLAG_TEMP_15", "TEMP_15" }, + [22] = { "FLAG_TEMP_16", "TEMP_16" }, + [23] = { "FLAG_TEMP_17", "TEMP_17" }, + [24] = { "FLAG_TEMP_18", "TEMP_18" }, + [25] = { "FLAG_TEMP_19", "TEMP_19" }, + [26] = { "FLAG_TEMP_1A", "TEMP_1A" }, + [27] = { "FLAG_TEMP_1B", "TEMP_1B" }, + [28] = { "FLAG_TEMP_1C", "TEMP_1C" }, + [29] = { "FLAG_TEMP_1D", "TEMP_1D" }, + [30] = { "FLAG_TEMP_1E", "TEMP_1E" }, + [31] = { "FLAG_TEMP_1F", "TEMP_1F", "TEMP_FLAGS_END" }, + [32] = { "FLAG_0x020", "0x020" }, + [33] = { "FLAG_0x021", "0x021" }, + [34] = { "FLAG_0x022", "0x022" }, + [35] = { "FLAG_0x023", "0x023" }, + [36] = { "FLAG_0x024", "0x024" }, + [37] = { "FLAG_0x025", "0x025" }, + [38] = { "FLAG_0x026", "0x026" }, + [39] = { "FLAG_0x027", "0x027" }, + [40] = { "FLAG_HIDE_BULBASAUR_BALL", "HIDE_BULBASAUR_BALL" }, + [41] = { "FLAG_HIDE_SQUIRTLE_BALL", "HIDE_SQUIRTLE_BALL" }, + [42] = { "FLAG_HIDE_CHARMANDER_BALL", "HIDE_CHARMANDER_BALL" }, + [43] = { "FLAG_HIDE_OAK_IN_HIS_LAB", "HIDE_OAK_IN_HIS_LAB" }, + [44] = { "FLAG_HIDE_OAK_IN_PALLET_TOWN", "HIDE_OAK_IN_PALLET_TOWN" }, + [45] = { "FLAG_HIDE_RIVAL_IN_LAB", "HIDE_RIVAL_IN_LAB" }, + [46] = { "FLAG_HIDE_PEWTER_CITY_GYM_GUIDE", "HIDE_PEWTER_CITY_GYM_GUIDE" }, + [47] = { "FLAG_HIDE_DOME_FOSSIL", "HIDE_DOME_FOSSIL" }, + [48] = { "FLAG_HIDE_HELIX_FOSSIL", "HIDE_HELIX_FOSSIL" }, + [49] = { "FLAG_HIDE_NUGGET_BRIDGE_ROCKET", "HIDE_NUGGET_BRIDGE_ROCKET" }, + [50] = { "FLAG_HIDE_BILL_CLEFAIRY", "HIDE_BILL_CLEFAIRY" }, + [51] = { "FLAG_HIDE_BILL_HUMAN_SEA_COTTAGE", "HIDE_BILL_HUMAN_SEA_COTTAGE" }, + [52] = { "FLAG_HIDE_TOWER_FUJI", "HIDE_TOWER_FUJI" }, + [53] = { "FLAG_HIDE_POKEHOUSE_FUJI", "HIDE_POKEHOUSE_FUJI" }, + [54] = { "FLAG_HIDE_LIFT_KEY", "HIDE_LIFT_KEY" }, + [55] = { "FLAG_HIDE_SILPH_SCOPE", "HIDE_SILPH_SCOPE" }, + [56] = { "FLAG_HIDE_HIDEOUT_GIOVANNI", "HIDE_HIDEOUT_GIOVANNI" }, + [57] = { "FLAG_HIDE_TOWN_MAP", "HIDE_TOWN_MAP" }, + [58] = { "FLAG_HIDE_POKEDEX", "HIDE_POKEDEX" }, + [59] = { "FLAG_HIDE_CERULEAN_ROCKET", "HIDE_CERULEAN_ROCKET" }, + [60] = { "FLAG_HIDE_CERULEAN_RIVAL", "HIDE_CERULEAN_RIVAL" }, + [61] = { "FLAG_HIDE_SS_ANNE_RIVAL", "HIDE_SS_ANNE_RIVAL" }, + [62] = { "FLAG_HIDE_SAFFRON_ROCKETS", "HIDE_SAFFRON_ROCKETS" }, + [63] = { "FLAG_HIDE_SAFFRON_CIVILIANS", "HIDE_SAFFRON_CIVILIANS" }, + [64] = { "FLAG_HIDE_SEAFOAM_1F_BOULDER_1", "HIDE_SEAFOAM_1F_BOULDER_1" }, + [65] = { "FLAG_HIDE_SEAFOAM_1F_BOULDER_2", "HIDE_SEAFOAM_1F_BOULDER_2" }, + [66] = { "FLAG_HIDE_SEAFOAM_B1F_BOULDER_1", "HIDE_SEAFOAM_B1F_BOULDER_1" }, + [67] = { "FLAG_HIDE_SEAFOAM_B1F_BOULDER_2", "HIDE_SEAFOAM_B1F_BOULDER_2" }, + [68] = { "FLAG_HIDE_SEAFOAM_B2F_BOULDER_1", "HIDE_SEAFOAM_B2F_BOULDER_1" }, + [69] = { "FLAG_HIDE_SEAFOAM_B2F_BOULDER_2", "HIDE_SEAFOAM_B2F_BOULDER_2" }, + [70] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_1", "HIDE_SEAFOAM_B3F_BOULDER_1" }, + [71] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_2", "HIDE_SEAFOAM_B3F_BOULDER_2" }, + [72] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_3", "HIDE_SEAFOAM_B3F_BOULDER_3" }, + [73] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_4", "HIDE_SEAFOAM_B3F_BOULDER_4" }, + [74] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_5", "HIDE_SEAFOAM_B3F_BOULDER_5" }, + [75] = { "FLAG_HIDE_SEAFOAM_B3F_BOULDER_6", "HIDE_SEAFOAM_B3F_BOULDER_6" }, + [76] = { "FLAG_HIDE_SEAFOAM_B4F_BOULDER_1", "HIDE_SEAFOAM_B4F_BOULDER_1" }, + [77] = { "FLAG_HIDE_SEAFOAM_B4F_BOULDER_2", "HIDE_SEAFOAM_B4F_BOULDER_2" }, + [78] = { "FLAG_HIDE_SILPH_RIVAL", "HIDE_SILPH_RIVAL" }, + [79] = { "FLAG_HIDE_ROUTE_22_RIVAL", "HIDE_ROUTE_22_RIVAL" }, + [80] = { "FLAG_HIDE_PEWTER_MUSEUM_GUIDE", "HIDE_PEWTER_MUSEUM_GUIDE" }, + [81] = { "FLAG_HIDE_TOWER_RIVAL", "HIDE_TOWER_RIVAL" }, + [82] = { "FLAG_HIDE_MOLTRES", "HIDE_MOLTRES" }, + [83] = { "FLAG_HIDE_SILPH_ROCKETS", "HIDE_SILPH_ROCKETS" }, + [84] = { "FLAG_HIDE_ROUTE_12_SNORLAX", "HIDE_ROUTE_12_SNORLAX" }, + [85] = { "FLAG_HIDE_VIRIDIAN_GIOVANNI", "HIDE_VIRIDIAN_GIOVANNI" }, + [86] = { "FLAG_HIDE_OLD_AMBER", "HIDE_OLD_AMBER" }, + [87] = { "FLAG_HIDE_EEVEE_BALL", "HIDE_EEVEE_BALL" }, + [88] = { "FLAG_HIDE_VICTORY_ROAD_2F_BOULDER", "HIDE_VICTORY_ROAD_2F_BOULDER" }, + [89] = { "FLAG_HIDE_VICTORY_ROAD_3F_BOULDER", "HIDE_VICTORY_ROAD_3F_BOULDER" }, + [90] = { "FLAG_HIDE_OAK_IN_CHAMP_ROOM", "HIDE_OAK_IN_CHAMP_ROOM" }, + [91] = { "FLAG_HIDE_GAME_CORNER_ROCKET", "HIDE_GAME_CORNER_ROCKET" }, + [92] = { "FLAG_HIDE_CERULEAN_CAVE_GUARD", "HIDE_CERULEAN_CAVE_GUARD" }, + [93] = { "FLAG_HIDE_ZAPDOS", "HIDE_ZAPDOS" }, + [94] = { "FLAG_HIDE_TOWER_ROCKET_1", "HIDE_TOWER_ROCKET_1" }, + [95] = { "FLAG_HIDE_CELADON_ROCKETS", "HIDE_CELADON_ROCKETS" }, + [96] = { "FLAG_HIDE_DOJO_HITMONLEE_BALL", "HIDE_DOJO_HITMONLEE_BALL" }, + [97] = { "FLAG_HIDE_DOJO_HITMONCHAN_BALL", "HIDE_DOJO_HITMONCHAN_BALL" }, + [98] = { "FLAG_HIDE_CINNABAR_BILL", "HIDE_CINNABAR_BILL" }, + [99] = { "FLAG_HIDE_UNION_ROOM_PLAYER_1", "HIDE_UNION_ROOM_PLAYER_1" }, + [100] = { "FLAG_HIDE_UNION_ROOM_PLAYER_2", "HIDE_UNION_ROOM_PLAYER_2" }, + [101] = { "FLAG_HIDE_UNION_ROOM_PLAYER_3", "HIDE_UNION_ROOM_PLAYER_3" }, + [102] = { "FLAG_HIDE_UNION_ROOM_PLAYER_4", "HIDE_UNION_ROOM_PLAYER_4" }, + [103] = { "FLAG_HIDE_UNION_ROOM_PLAYER_5", "HIDE_UNION_ROOM_PLAYER_5" }, + [104] = { "FLAG_HIDE_UNION_ROOM_PLAYER_6", "HIDE_UNION_ROOM_PLAYER_6" }, + [105] = { "FLAG_HIDE_UNION_ROOM_PLAYER_7", "HIDE_UNION_ROOM_PLAYER_7" }, + [106] = { "FLAG_HIDE_UNION_ROOM_PLAYER_8", "HIDE_UNION_ROOM_PLAYER_8" }, + [107] = { "FLAG_HIDE_CINNABAR_SEAGALLOP", "HIDE_CINNABAR_SEAGALLOP" }, + [108] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_BLACK_BELT", "HIDE_SAFFRON_FAN_CLUB_BLACK_BELT" }, + [109] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_ROCKER", "HIDE_SAFFRON_FAN_CLUB_ROCKER" }, + [110] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_WOMAN", "HIDE_SAFFRON_FAN_CLUB_WOMAN" }, + [111] = { "FLAG_HIDE_SAFFRON_FAN_CLUB_BEAUTY", "HIDE_SAFFRON_FAN_CLUB_BEAUTY" }, + [112] = { "FLAG_HIDE_MG_DELIVERYMEN", "HIDE_MG_DELIVERYMEN" }, + [113] = { "FLAG_HIDE_ONE_ISLAND_BILL", "HIDE_ONE_ISLAND_BILL" }, + [114] = { "FLAG_HIDE_ONE_ISLAND_POKECENTER_BILL", "HIDE_ONE_ISLAND_POKECENTER_BILL" }, + [115] = { "FLAG_HIDE_ONE_ISLAND_POKECENTER_CELIO", "HIDE_ONE_ISLAND_POKECENTER_CELIO" }, + [116] = { "FLAG_HIDE_TWO_ISLAND_GAME_CORNER_BIKER", "HIDE_TWO_ISLAND_GAME_CORNER_BIKER" }, + [117] = { "FLAG_HIDE_TWO_ISLAND_GAME_CORNER_LOSTELLE", "HIDE_TWO_ISLAND_GAME_CORNER_LOSTELLE" }, + [118] = { "FLAG_HIDE_LOSTELLE_IN_HER_HOME", "HIDE_LOSTELLE_IN_HER_HOME" }, + [119] = { "FLAG_0x077", "0x077" }, + [120] = { "FLAG_0x078", "0x078" }, + [121] = { "FLAG_HIDE_THREE_ISLAND_BIKERS", "HIDE_THREE_ISLAND_BIKERS" }, + [122] = { "FLAG_HIDE_LOSTELLE_IN_BERRY_FOREST", "HIDE_LOSTELLE_IN_BERRY_FOREST" }, + [123] = { "FLAG_HIDE_TWO_ISLAND_WOMAN", "HIDE_TWO_ISLAND_WOMAN" }, + [124] = { "FLAG_HIDE_TWO_ISLAND_BEAUTY", "HIDE_TWO_ISLAND_BEAUTY" }, + [125] = { "FLAG_HIDE_TWO_ISLAND_POKE_MANIAC", "HIDE_TWO_ISLAND_POKE_MANIAC" }, + [126] = { "FLAG_HIDE_THREE_ISLAND_ANTIBIKERS", "HIDE_THREE_ISLAND_ANTIBIKERS" }, + [127] = { "FLAG_0x07F", "0x07F" }, + [128] = { "FLAG_HIDE_ROUTE_16_SNORLAX", "HIDE_ROUTE_16_SNORLAX" }, + [129] = { "FLAG_HIDE_MEWTWO", "HIDE_MEWTWO" }, + [130] = { "FLAG_HIDE_ARTICUNO", "HIDE_ARTICUNO" }, + [131] = { "FLAG_HIDE_TOWER_ROCKET_2", "HIDE_TOWER_ROCKET_2" }, + [132] = { "FLAG_HIDE_TOWER_ROCKET_3", "HIDE_TOWER_ROCKET_3" }, + [133] = { "FLAG_HIDE_POWER_PLANT_ELECTRODE_1", "HIDE_POWER_PLANT_ELECTRODE_1" }, + [134] = { "FLAG_HIDE_POWER_PLANT_ELECTRODE_2", "HIDE_POWER_PLANT_ELECTRODE_2" }, + [135] = { "FLAG_HIDE_SS_ANNE", "HIDE_SS_ANNE" }, + [136] = { "FLAG_HIDE_FIVE_ISLAND_ROCKETS", "HIDE_FIVE_ISLAND_ROCKETS" }, + [137] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_ROCKETS", "HIDE_MT_EMBER_EXTERIOR_ROCKETS" }, + [138] = { "FLAG_HIDE_RUBY", "HIDE_RUBY" }, + [139] = { "FLAG_HIDE_ICEFALL_CAVE_LORELEI", "HIDE_ICEFALL_CAVE_LORELEI" }, + [140] = { "FLAG_HIDE_LORELEI_IN_HER_HOUSE", "HIDE_LORELEI_IN_HER_HOUSE" }, + [141] = { "FLAG_HIDE_ICEFALL_CAVE_ROCKETS", "HIDE_ICEFALL_CAVE_ROCKETS" }, + [142] = { "FLAG_HIDE_RUIN_VALLEY_SCIENTIST", "HIDE_RUIN_VALLEY_SCIENTIST" }, + [143] = { "FLAG_HIDE_SAPPHIRE", "HIDE_SAPPHIRE" }, + [144] = { "FLAG_HIDE_DOTTED_HOLE_SCIENTIST", "HIDE_DOTTED_HOLE_SCIENTIST" }, + [145] = { "FLAG_HIDE_THREE_ISLAND_LONE_BIKER", "HIDE_THREE_ISLAND_LONE_BIKER" }, + [146] = { "FLAG_HIDE_PEWTER_CITY_RUNNING_SHOES_GUY", "HIDE_PEWTER_CITY_RUNNING_SHOES_GUY" }, + [147] = { "FLAG_HIDE_LOST_CAVE_SELPHY", "HIDE_LOST_CAVE_SELPHY" }, + [148] = { "FLAG_HIDE_RESORT_GORGEOUS_SELPHY", "HIDE_RESORT_GORGEOUS_SELPHY" }, + [149] = { "FLAG_HIDE_RESORT_GORGEOUS_INSIDE_SELPHY", "HIDE_RESORT_GORGEOUS_INSIDE_SELPHY" }, + [150] = { "FLAG_HIDE_SELPHYS_BUTLER", "HIDE_SELPHYS_BUTLER" }, + [151] = { "FLAG_HIDE_FOUR_ISLAND_RIVAL", "HIDE_FOUR_ISLAND_RIVAL" }, + [152] = { "FLAG_HIDE_SIX_ISLAND_POKECENTER_RIVAL", "HIDE_SIX_ISLAND_POKECENTER_RIVAL" }, + [153] = { "FLAG_HIDE_DEOXYS", "HIDE_DEOXYS" }, + [154] = { "FLAG_HIDE_BIRTH_ISLAND_METEORITE", "HIDE_BIRTH_ISLAND_METEORITE" }, + [155] = { "FLAG_HIDE_LUGIA", "HIDE_LUGIA" }, + [156] = { "FLAG_HIDE_HO_OH", "HIDE_HO_OH" }, + [157] = { "FLAG_HIDE_POSTGAME_GOSSIPERS", "HIDE_POSTGAME_GOSSIPERS" }, + [158] = { "FLAG_HIDE_FAME_CHECKER_ERIKA_JOURNALS", "HIDE_FAME_CHECKER_ERIKA_JOURNALS" }, + [159] = { "FLAG_HIDE_FAME_CHECKER_KOGA_JOURNAL", "HIDE_FAME_CHECKER_KOGA_JOURNAL" }, + [160] = { "FLAG_HIDE_FAME_CHECKER_LT_SURGE_JOURNAL", "HIDE_FAME_CHECKER_LT_SURGE_JOURNAL" }, + [161] = { "FLAG_HIDE_VERMILION_CITY_OAKS_AIDE", "HIDE_VERMILION_CITY_OAKS_AIDE" }, + [162] = { "FLAG_HIDE_CINNABAR_POKECENTER_BILL", "HIDE_CINNABAR_POKECENTER_BILL" }, + [163] = { "FLAG_HIDE_CREDITS_RIVAL", "HIDE_CREDITS_RIVAL" }, + [164] = { "FLAG_HIDE_CREDITS_OAK", "HIDE_CREDITS_OAK" }, + [165] = { "FLAG_HIDE_LORELEI_HOUSE_MEOWTH_DOLL", "HIDE_LORELEI_HOUSE_MEOWTH_DOLL" }, + [166] = { "FLAG_HIDE_LORELEI_HOUSE_CHANSEY_DOLL", "HIDE_LORELEI_HOUSE_CHANSEY_DOLL" }, + [167] = { "FLAG_HIDE_LORELEIS_HOUSE_NIDORAN_F_DOLL", "HIDE_LORELEIS_HOUSE_NIDORAN_F_DOLL" }, + [168] = { "FLAG_HIDE_LORELEI_HOUSE_JIGGLYPUFF_DOLL", "HIDE_LORELEI_HOUSE_JIGGLYPUFF_DOLL" }, + [169] = { "FLAG_HIDE_LORELEIS_HOUSE_NIDORAN_M_DOLL", "HIDE_LORELEIS_HOUSE_NIDORAN_M_DOLL" }, + [170] = { "FLAG_HIDE_LORELEIS_HOUSE_FEAROW_DOLL", "HIDE_LORELEIS_HOUSE_FEAROW_DOLL" }, + [171] = { "FLAG_HIDE_LORELEIS_HOUSE_PIDGEOT_DOLL", "HIDE_LORELEIS_HOUSE_PIDGEOT_DOLL" }, + [172] = { "FLAG_HIDE_LORELEIS_HOUSE_LAPRAS_DOLL", "HIDE_LORELEIS_HOUSE_LAPRAS_DOLL" }, + [173] = { "FLAG_HIDE_MISC_KANTO_ROCKETS", "HIDE_MISC_KANTO_ROCKETS" }, + [174] = { "FLAG_HIDE_SAFFRON_CITY_POKECENTER_SABRINA_JOURNALS", "HIDE_SAFFRON_CITY_POKECENTER_SABRINA_JOURNALS" }, + [175] = { "FLAG_0x0AF", "0x0AF" }, + [176] = { "FLAG_0x0B0", "0x0B0" }, + [177] = { "FLAG_0x0B1", "0x0B1" }, + [178] = { "FLAG_0x0B2", "0x0B2" }, + [179] = { "FLAG_0x0B3", "0x0B3" }, + [180] = { "FLAG_0x0B4", "0x0B4" }, + [181] = { "FLAG_0x0B5", "0x0B5" }, + [182] = { "FLAG_0x0B6", "0x0B6" }, + [183] = { "FLAG_0x0B7", "0x0B7" }, + [184] = { "FLAG_0x0B8", "0x0B8" }, + [185] = { "FLAG_0x0B9", "0x0B9" }, + [186] = { "FLAG_0x0BA", "0x0BA" }, + [187] = { "FLAG_0x0BB", "0x0BB" }, + [188] = { "FLAG_0x0BC", "0x0BC" }, + [189] = { "FLAG_0x0BD", "0x0BD" }, + [190] = { "FLAG_0x0BE", "0x0BE" }, + [191] = { "FLAG_0x0BF", "0x0BF" }, + [192] = { "FLAG_0x0C0", "0x0C0" }, + [193] = { "FLAG_0x0C1", "0x0C1" }, + [194] = { "FLAG_0x0C2", "0x0C2" }, + [195] = { "FLAG_0x0C3", "0x0C3" }, + [196] = { "FLAG_0x0C4", "0x0C4" }, + [197] = { "FLAG_0x0C5", "0x0C5" }, + [198] = { "FLAG_0x0C6", "0x0C6" }, + [199] = { "FLAG_0x0C7", "0x0C7" }, + [200] = { "FLAG_0x0C8", "0x0C8" }, + [201] = { "FLAG_0x0C9", "0x0C9" }, + [202] = { "FLAG_0x0CA", "0x0CA" }, + [203] = { "FLAG_0x0CB", "0x0CB" }, + [204] = { "FLAG_0x0CC", "0x0CC" }, + [205] = { "FLAG_0x0CD", "0x0CD" }, + [206] = { "FLAG_0x0CE", "0x0CE" }, + [207] = { "FLAG_0x0CF", "0x0CF" }, + [208] = { "FLAG_0x0D0", "0x0D0" }, + [209] = { "FLAG_0x0D1", "0x0D1" }, + [210] = { "FLAG_0x0D2", "0x0D2" }, + [211] = { "FLAG_0x0D3", "0x0D3" }, + [212] = { "FLAG_0x0D4", "0x0D4" }, + [213] = { "FLAG_0x0D5", "0x0D5" }, + [214] = { "FLAG_0x0D6", "0x0D6" }, + [215] = { "FLAG_0x0D7", "0x0D7" }, + [216] = { "FLAG_0x0D8", "0x0D8" }, + [217] = { "FLAG_0x0D9", "0x0D9" }, + [218] = { "FLAG_0x0DA", "0x0DA" }, + [219] = { "FLAG_0x0DB", "0x0DB" }, + [220] = { "FLAG_0x0DC", "0x0DC" }, + [221] = { "FLAG_0x0DD", "0x0DD" }, + [222] = { "FLAG_0x0DE", "0x0DE" }, + [223] = { "FLAG_0x0DF", "0x0DF" }, + [224] = { "FLAG_0x0E0", "0x0E0" }, + [225] = { "FLAG_0x0E1", "0x0E1" }, + [226] = { "FLAG_0x0E2", "0x0E2" }, + [227] = { "FLAG_0x0E3", "0x0E3" }, + [228] = { "FLAG_0x0E4", "0x0E4" }, + [229] = { "FLAG_0x0E5", "0x0E5" }, + [230] = { "FLAG_0x0E6", "0x0E6" }, + [231] = { "FLAG_0x0E7", "0x0E7" }, + [232] = { "FLAG_0x0E8", "0x0E8" }, + [233] = { "FLAG_0x0E9", "0x0E9" }, + [234] = { "FLAG_0x0EA", "0x0EA" }, + [235] = { "FLAG_0x0EB", "0x0EB" }, + [236] = { "FLAG_0x0EC", "0x0EC" }, + [237] = { "FLAG_0x0ED", "0x0ED" }, + [238] = { "FLAG_0x0EE", "0x0EE" }, + [239] = { "FLAG_0x0EF", "0x0EF" }, + [240] = { "FLAG_0x0F0", "0x0F0" }, + [241] = { "FLAG_0x0F1", "0x0F1" }, + [242] = { "FLAG_0x0F2", "0x0F2" }, + [243] = { "FLAG_0x0F3", "0x0F3" }, + [244] = { "FLAG_0x0F4", "0x0F4" }, + [245] = { "FLAG_0x0F5", "0x0F5" }, + [246] = { "FLAG_0x0F6", "0x0F6" }, + [247] = { "FLAG_0x0F7", "0x0F7" }, + [248] = { "FLAG_0x0F8", "0x0F8" }, + [249] = { "FLAG_0x0F9", "0x0F9" }, + [250] = { "FLAG_0x0FA", "0x0FA" }, + [251] = { "FLAG_0x0FB", "0x0FB" }, + [252] = { "FLAG_0x0FC", "0x0FC" }, + [253] = { "FLAG_0x0FD", "0x0FD" }, + [254] = { "FLAG_0x0FE", "0x0FE" }, + [255] = { "FLAG_0x0FF", "0x0FF" }, + [256] = { "FLAG_0x100", "0x100", "VARS_COUNT" }, + [257] = { "FLAG_0x101", "0x101" }, + [258] = { "FLAG_0x102", "0x102" }, + [259] = { "FLAG_0x103", "0x103" }, + [260] = { "FLAG_0x104", "0x104" }, + [261] = { "FLAG_0x105", "0x105" }, + [262] = { "FLAG_0x106", "0x106" }, + [263] = { "FLAG_0x107", "0x107" }, + [264] = { "FLAG_0x108", "0x108" }, + [265] = { "FLAG_0x109", "0x109" }, + [266] = { "FLAG_0x10A", "0x10A" }, + [267] = { "FLAG_0x10B", "0x10B" }, + [268] = { "FLAG_0x10C", "0x10C" }, + [269] = { "FLAG_0x10D", "0x10D" }, + [270] = { "FLAG_0x10E", "0x10E" }, + [271] = { "FLAG_0x10F", "0x10F" }, + [272] = { "FLAG_0x110", "0x110" }, + [273] = { "FLAG_0x111", "0x111" }, + [274] = { "FLAG_0x112", "0x112" }, + [275] = { "FLAG_0x113", "0x113" }, + [276] = { "FLAG_0x114", "0x114" }, + [277] = { "FLAG_0x115", "0x115" }, + [278] = { "FLAG_0x116", "0x116" }, + [279] = { "FLAG_0x117", "0x117" }, + [280] = { "FLAG_0x118", "0x118" }, + [281] = { "FLAG_0x119", "0x119" }, + [282] = { "FLAG_0x11A", "0x11A" }, + [283] = { "FLAG_0x11B", "0x11B" }, + [284] = { "FLAG_0x11C", "0x11C" }, + [285] = { "FLAG_0x11D", "0x11D" }, + [286] = { "FLAG_0x11E", "0x11E" }, + [287] = { "FLAG_0x11F", "0x11F" }, + [288] = { "FLAG_0x120", "0x120" }, + [289] = { "FLAG_0x121", "0x121" }, + [290] = { "FLAG_0x122", "0x122" }, + [291] = { "FLAG_0x123", "0x123" }, + [292] = { "FLAG_0x124", "0x124" }, + [293] = { "FLAG_0x125", "0x125" }, + [294] = { "FLAG_0x126", "0x126" }, + [295] = { "FLAG_0x127", "0x127" }, + [296] = { "FLAG_0x128", "0x128" }, + [297] = { "FLAG_0x129", "0x129" }, + [298] = { "FLAG_0x12A", "0x12A" }, + [299] = { "FLAG_0x12B", "0x12B" }, + [300] = { "FLAG_0x12C", "0x12C" }, + [301] = { "FLAG_0x12D", "0x12D" }, + [302] = { "FLAG_0x12E", "0x12E" }, + [303] = { "FLAG_0x12F", "0x12F" }, + [304] = { "FLAG_0x130", "0x130" }, + [305] = { "FLAG_0x131", "0x131" }, + [306] = { "FLAG_0x132", "0x132" }, + [307] = { "FLAG_0x133", "0x133" }, + [308] = { "FLAG_0x134", "0x134" }, + [309] = { "FLAG_0x135", "0x135" }, + [310] = { "FLAG_0x136", "0x136" }, + [311] = { "FLAG_0x137", "0x137" }, + [312] = { "FLAG_0x138", "0x138" }, + [313] = { "FLAG_0x139", "0x139" }, + [314] = { "FLAG_0x13A", "0x13A" }, + [315] = { "FLAG_0x13B", "0x13B" }, + [316] = { "FLAG_0x13C", "0x13C" }, + [317] = { "FLAG_0x13D", "0x13D" }, + [318] = { "FLAG_0x13E", "0x13E" }, + [319] = { "FLAG_0x13F", "0x13F" }, + [320] = { "FLAG_0x140", "0x140" }, + [321] = { "FLAG_0x141", "0x141" }, + [322] = { "FLAG_0x142", "0x142" }, + [323] = { "FLAG_0x143", "0x143" }, + [324] = { "FLAG_0x144", "0x144" }, + [325] = { "FLAG_0x145", "0x145" }, + [326] = { "FLAG_0x146", "0x146" }, + [327] = { "FLAG_0x147", "0x147" }, + [328] = { "FLAG_0x148", "0x148" }, + [329] = { "FLAG_0x149", "0x149" }, + [330] = { "FLAG_0x14A", "0x14A" }, + [331] = { "FLAG_0x14B", "0x14B" }, + [332] = { "FLAG_0x14C", "0x14C" }, + [333] = { "FLAG_0x14D", "0x14D" }, + [334] = { "FLAG_0x14E", "0x14E" }, + [335] = { "FLAG_0x14F", "0x14F" }, + [336] = { "FLAG_0x150", "0x150" }, + [337] = { "FLAG_0x151", "0x151" }, + [338] = { "FLAG_0x152", "0x152" }, + [339] = { "FLAG_0x153", "0x153" }, + [340] = { "FLAG_HIDE_ROUTE2_ETHER", "HIDE_ROUTE2_ETHER" }, + [341] = { "FLAG_HIDE_ROUTE2_PARALYZE_HEAL", "HIDE_ROUTE2_PARALYZE_HEAL" }, + [342] = { "FLAG_HIDE_VIRIDIAN_FOREST_POKE_BALL", "HIDE_VIRIDIAN_FOREST_POKE_BALL" }, + [343] = { "FLAG_HIDE_VIRIDIAN_FOREST_ANTIDOTE", "HIDE_VIRIDIAN_FOREST_ANTIDOTE" }, + [344] = { "FLAG_HIDE_VIRIDIAN_FOREST_POTION", "HIDE_VIRIDIAN_FOREST_POTION" }, + [345] = { "FLAG_HIDE_MT_MOON_1F_PARALYZE_HEAL", "HIDE_MT_MOON_1F_PARALYZE_HEAL" }, + [346] = { "FLAG_HIDE_MT_MOON_1F_TM09", "HIDE_MT_MOON_1F_TM09" }, + [347] = { "FLAG_HIDE_MT_MOON_1F_POTION", "HIDE_MT_MOON_1F_POTION" }, + [348] = { "FLAG_HIDE_MT_MOON_1F_RARE_CANDY", "HIDE_MT_MOON_1F_RARE_CANDY" }, + [349] = { "FLAG_HIDE_MT_MOON_1F_ESCAPE_ROPE", "HIDE_MT_MOON_1F_ESCAPE_ROPE" }, + [350] = { "FLAG_HIDE_MT_MOON_1F_MOON_STONE", "HIDE_MT_MOON_1F_MOON_STONE" }, + [351] = { "FLAG_HIDE_MT_MOON_B2F_STAR_PIECE", "HIDE_MT_MOON_B2F_STAR_PIECE" }, + [352] = { "FLAG_HIDE_MT_MOON_B2F_TM46", "HIDE_MT_MOON_B2F_TM46" }, + [353] = { "FLAG_HIDE_ROUTE4_TM05", "HIDE_ROUTE4_TM05" }, + [354] = { "FLAG_HIDE_ROUTE24_TM45", "HIDE_ROUTE24_TM45" }, + [355] = { "FLAG_HIDE_ROUTE25_TM43", "HIDE_ROUTE25_TM43" }, + [356] = { "FLAG_HIDE_SSANNE_1F_ROOM2_TM31", "HIDE_SSANNE_1F_ROOM2_TM31" }, + [357] = { "FLAG_HIDE_SSANNE_2F_ROOM2_STARDUST", "HIDE_SSANNE_2F_ROOM2_STARDUST" }, + [358] = { "FLAG_HIDE_SSANNE_2F_ROOM4_X_ATTACK", "HIDE_SSANNE_2F_ROOM4_X_ATTACK" }, + [359] = { "FLAG_HIDE_SSANNE_B1F_ROOM2_TM44", "HIDE_SSANNE_B1F_ROOM2_TM44" }, + [360] = { "FLAG_HIDE_SSANNE_B1F_ROOM3_ETHER", "HIDE_SSANNE_B1F_ROOM3_ETHER" }, + [361] = { "FLAG_HIDE_SSANNE_B1F_ROOM5_SUPER_POTION", "HIDE_SSANNE_B1F_ROOM5_SUPER_POTION" }, + [362] = { "FLAG_HIDE_SSANNE_KITCHEN_GREAT_BALL", "HIDE_SSANNE_KITCHEN_GREAT_BALL" }, + [363] = { "FLAG_HIDE_ROUTE9_TM40", "HIDE_ROUTE9_TM40" }, + [364] = { "FLAG_HIDE_ROCKET_HIDEOUT_B1F_ESCAPE_ROPE", "HIDE_ROCKET_HIDEOUT_B1F_ESCAPE_ROPE" }, + [365] = { "FLAG_HIDE_ROCKET_HIDEOUT_B1F_HYPER_POTION", "HIDE_ROCKET_HIDEOUT_B1F_HYPER_POTION" }, + [366] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_X_SPEED", "HIDE_ROCKET_HIDEOUT_B2F_X_SPEED" }, + [367] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_MOON_STONE", "HIDE_ROCKET_HIDEOUT_B2F_MOON_STONE" }, + [368] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_TM12", "HIDE_ROCKET_HIDEOUT_B2F_TM12" }, + [369] = { "FLAG_HIDE_ROCKET_HIDEOUT_B2F_SUPER_POTION", "HIDE_ROCKET_HIDEOUT_B2F_SUPER_POTION" }, + [370] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_RARE_CANDY", "HIDE_ROCKET_HIDEOUT_B3F_RARE_CANDY" }, + [371] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_TM21", "HIDE_ROCKET_HIDEOUT_B3F_TM21" }, + [372] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_TM49", "HIDE_ROCKET_HIDEOUT_B4F_TM49" }, + [373] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_MAX_ETHER", "HIDE_ROCKET_HIDEOUT_B4F_MAX_ETHER" }, + [374] = { "FLAG_HIDE_ROCKET_HIDEOUT_B4F_CALCIUM", "HIDE_ROCKET_HIDEOUT_B4F_CALCIUM" }, + [375] = { "FLAG_HIDE_POKEMON_TOWER_3F_ESCAPE_ROPE", "HIDE_POKEMON_TOWER_3F_ESCAPE_ROPE" }, + [376] = { "FLAG_HIDE_POKEMON_TOWER_4F_ELIXIR", "HIDE_POKEMON_TOWER_4F_ELIXIR" }, + [377] = { "FLAG_HIDE_POKEMON_TOWER_4F_AWAKENING", "HIDE_POKEMON_TOWER_4F_AWAKENING" }, + [378] = { "FLAG_HIDE_POKEMON_TOWER_4F_GREAT_BALL", "HIDE_POKEMON_TOWER_4F_GREAT_BALL" }, + [379] = { "FLAG_HIDE_POKEMON_TOWER_5F_NUGGET", "HIDE_POKEMON_TOWER_5F_NUGGET" }, + [380] = { "FLAG_HIDE_POKEMON_TOWER_6F_RARE_CANDY", "HIDE_POKEMON_TOWER_6F_RARE_CANDY" }, + [381] = { "FLAG_HIDE_POKEMON_TOWER_6F_X_ACCURACY", "HIDE_POKEMON_TOWER_6F_X_ACCURACY" }, + [382] = { "FLAG_HIDE_ROUTE12_TM48", "HIDE_ROUTE12_TM48" }, + [383] = { "FLAG_HIDE_ROUTE12_IRON", "HIDE_ROUTE12_IRON" }, + [384] = { "FLAG_HIDE_ROUTE15_TM18", "HIDE_ROUTE15_TM18" }, + [385] = { "FLAG_HIDE_SAFARI_ZONE_CENTER_NUGGET", "HIDE_SAFARI_ZONE_CENTER_NUGGET" }, + [386] = { "FLAG_HIDE_SAFARI_ZONE_EAST_MAX_POTION", "HIDE_SAFARI_ZONE_EAST_MAX_POTION" }, + [387] = { "FLAG_HIDE_SAFARI_ZONE_EAST_FULL_RESTORE", "HIDE_SAFARI_ZONE_EAST_FULL_RESTORE" }, + [388] = { "FLAG_HIDE_SAFARI_ZONE_EAST_TM11", "HIDE_SAFARI_ZONE_EAST_TM11" }, + [389] = { "FLAG_HIDE_SAFARI_ZONE_EAST_LEAF_STONE", "HIDE_SAFARI_ZONE_EAST_LEAF_STONE" }, + [390] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_PROTEIN", "HIDE_SAFARI_ZONE_NORTH_PROTEIN" }, + [391] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_TM47", "HIDE_SAFARI_ZONE_NORTH_TM47" }, + [392] = { "FLAG_HIDE_SAFARI_ZONE_WEST_TM32", "HIDE_SAFARI_ZONE_WEST_TM32" }, + [393] = { "FLAG_HIDE_SAFARI_ZONE_WEST_GOLD_TEETH", "HIDE_SAFARI_ZONE_WEST_GOLD_TEETH" }, + [394] = { "FLAG_HIDE_SAFARI_ZONE_WEST_MAX_POTION", "HIDE_SAFARI_ZONE_WEST_MAX_POTION" }, + [395] = { "FLAG_HIDE_SAFARI_ZONE_WEST_MAX_REVIVE", "HIDE_SAFARI_ZONE_WEST_MAX_REVIVE" }, + [396] = { "FLAG_HIDE_SILPH_CO_3F_HYPER_POTION", "HIDE_SILPH_CO_3F_HYPER_POTION" }, + [397] = { "FLAG_HIDE_SILPH_CO_4F_MAX_REVIVE", "HIDE_SILPH_CO_4F_MAX_REVIVE" }, + [398] = { "FLAG_HIDE_SILPH_CO_4F_ESCAPE_ROPE", "HIDE_SILPH_CO_4F_ESCAPE_ROPE" }, + [399] = { "FLAG_HIDE_SILPH_CO_4F_FULL_HEAL", "HIDE_SILPH_CO_4F_FULL_HEAL" }, + [400] = { "FLAG_HIDE_SILPH_CO_5F_PROTEIN", "HIDE_SILPH_CO_5F_PROTEIN" }, + [401] = { "FLAG_HIDE_SILPH_CO_5F_TM01", "HIDE_SILPH_CO_5F_TM01" }, + [402] = { "FLAG_HIDE_SILPH_CO_5F_CARD_KEY", "HIDE_SILPH_CO_5F_CARD_KEY" }, + [403] = { "FLAG_HIDE_SILPH_CO_6F_HP_UP", "HIDE_SILPH_CO_6F_HP_UP" }, + [404] = { "FLAG_HIDE_SILPH_CO_6F_X_SPECIAL", "HIDE_SILPH_CO_6F_X_SPECIAL" }, + [405] = { "FLAG_HIDE_SILPH_CO_7F_CALCIUM", "HIDE_SILPH_CO_7F_CALCIUM" }, + [406] = { "FLAG_HIDE_SILPH_CO_7F_TM08", "HIDE_SILPH_CO_7F_TM08" }, + [407] = { "FLAG_HIDE_SILPH_CO_10F_CARBOS", "HIDE_SILPH_CO_10F_CARBOS" }, + [408] = { "FLAG_HIDE_SILPH_CO_10F_ULTRA_BALL", "HIDE_SILPH_CO_10F_ULTRA_BALL" }, + [409] = { "FLAG_HIDE_SILPH_CO_10F_RARE_CANDY", "HIDE_SILPH_CO_10F_RARE_CANDY" }, + [410] = { "FLAG_HIDE_POWER_PLANT_MAX_POTION", "HIDE_POWER_PLANT_MAX_POTION" }, + [411] = { "FLAG_HIDE_POWER_PLANT_TM17", "HIDE_POWER_PLANT_TM17" }, + [412] = { "FLAG_HIDE_POWER_PLANT_TM25", "HIDE_POWER_PLANT_TM25" }, + [413] = { "FLAG_HIDE_POWER_PLANT_THUNDER_STONE", "HIDE_POWER_PLANT_THUNDER_STONE" }, + [414] = { "FLAG_HIDE_POWER_PLANT_ELIXIR", "HIDE_POWER_PLANT_ELIXIR" }, + [415] = { "FLAG_HIDE_POKEMON_MANSION_1F_CARBOS", "HIDE_POKEMON_MANSION_1F_CARBOS" }, + [416] = { "FLAG_HIDE_POKEMON_MANSION_1F_ESCAPE_ROPE", "HIDE_POKEMON_MANSION_1F_ESCAPE_ROPE" }, + [417] = { "FLAG_HIDE_POKEMON_MANSION_2F_CALCIUM", "HIDE_POKEMON_MANSION_2F_CALCIUM" }, + [418] = { "FLAG_HIDE_POKEMON_MANSION_3F_MAX_POTION", "HIDE_POKEMON_MANSION_3F_MAX_POTION" }, + [419] = { "FLAG_HIDE_POKEMON_MANSION_3F_IRON", "HIDE_POKEMON_MANSION_3F_IRON" }, + [420] = { "FLAG_HIDE_POKEMON_MANSION_B1F_TM14", "HIDE_POKEMON_MANSION_B1F_TM14" }, + [421] = { "FLAG_HIDE_POKEMON_MANSION_B1F_FULL_RESTORE", "HIDE_POKEMON_MANSION_B1F_FULL_RESTORE" }, + [422] = { "FLAG_0x1A6", "0x1A6" }, + [423] = { "FLAG_HIDE_POKEMON_MANSION_B1F_TM22", "HIDE_POKEMON_MANSION_B1F_TM22" }, + [424] = { "FLAG_HIDE_POKEMON_MANSION_B1F_SECRET_KEY", "HIDE_POKEMON_MANSION_B1F_SECRET_KEY" }, + [425] = { "FLAG_HIDE_VICTORY_ROAD_1F_RARE_CANDY", "HIDE_VICTORY_ROAD_1F_RARE_CANDY" }, + [426] = { "FLAG_HIDE_VICTORY_ROAD_1F_TM02", "HIDE_VICTORY_ROAD_1F_TM02" }, + [427] = { "FLAG_HIDE_VICTORY_ROAD_2F_GUARD_SPEC", "HIDE_VICTORY_ROAD_2F_GUARD_SPEC" }, + [428] = { "FLAG_HIDE_VICTORY_ROAD_2F_TM07", "HIDE_VICTORY_ROAD_2F_TM07" }, + [429] = { "FLAG_HIDE_VICTORY_ROAD_2F_FULL_HEAL", "HIDE_VICTORY_ROAD_2F_FULL_HEAL" }, + [430] = { "FLAG_HIDE_VICTORY_ROAD_2F_TM37", "HIDE_VICTORY_ROAD_2F_TM37" }, + [431] = { "FLAG_HIDE_VICTORY_ROAD_3F_MAX_REVIVE", "HIDE_VICTORY_ROAD_3F_MAX_REVIVE" }, + [432] = { "FLAG_HIDE_VICTORY_ROAD_3F_TM50", "HIDE_VICTORY_ROAD_3F_TM50" }, + [433] = { "FLAG_HIDE_CERULEAN_CAVE_1F_MAX_ELIXIR", "HIDE_CERULEAN_CAVE_1F_MAX_ELIXIR" }, + [434] = { "FLAG_HIDE_CERULEAN_CAVE_1F_NUGGET", "HIDE_CERULEAN_CAVE_1F_NUGGET" }, + [435] = { "FLAG_HIDE_CERULEAN_CAVE_1F_FULL_RESTORE", "HIDE_CERULEAN_CAVE_1F_FULL_RESTORE" }, + [436] = { "FLAG_HIDE_CERULEAN_CAVE_2F_FULL_RESTORE", "HIDE_CERULEAN_CAVE_2F_FULL_RESTORE" }, + [437] = { "FLAG_HIDE_CERULEAN_CAVE_2F_PP_UP", "HIDE_CERULEAN_CAVE_2F_PP_UP" }, + [438] = { "FLAG_HIDE_CERULEAN_CAVE_2F_ULTRA_BALL", "HIDE_CERULEAN_CAVE_2F_ULTRA_BALL" }, + [439] = { "FLAG_HIDE_CERULEAN_CAVE_B1F_MAX_REVIVE", "HIDE_CERULEAN_CAVE_B1F_MAX_REVIVE" }, + [440] = { "FLAG_HIDE_CERULEAN_CAVE_B1F_ULTRA_BALL", "HIDE_CERULEAN_CAVE_B1F_ULTRA_BALL" }, + [441] = { "FLAG_HIDE_FUCHSIA_CITY_WARDENS_HOUSE_RARE_CANDY", "HIDE_FUCHSIA_CITY_WARDENS_HOUSE_RARE_CANDY" }, + [442] = { "FLAG_HIDE_TWO_ISLAND_REVIVE", "HIDE_TWO_ISLAND_REVIVE" }, + [443] = { "FLAG_HIDE_THREE_ISLAND_ZINC", "HIDE_THREE_ISLAND_ZINC" }, + [444] = { "FLAG_0x1BC", "0x1BC" }, + [445] = { "FLAG_0x1BD", "0x1BD" }, + [446] = { "FLAG_HIDE_VIRIDIAN_FOREST_POTION_2", "HIDE_VIRIDIAN_FOREST_POTION_2" }, + [447] = { "FLAG_HIDE_MT_MOON_B2F_REVIVE", "HIDE_MT_MOON_B2F_REVIVE" }, + [448] = { "FLAG_HIDE_MT_MOON_B2F_ANTIDOTE", "HIDE_MT_MOON_B2F_ANTIDOTE" }, + [449] = { "FLAG_HIDE_ROUTE11_X_DEFEND", "HIDE_ROUTE11_X_DEFEND" }, + [450] = { "FLAG_HIDE_ROUTE9_BURN_HEAL", "HIDE_ROUTE9_BURN_HEAL" }, + [451] = { "FLAG_HIDE_ROCK_TUNNEL_1F_REPEL", "HIDE_ROCK_TUNNEL_1F_REPEL" }, + [452] = { "FLAG_HIDE_ROCK_TUNNEL_1F_PEARL", "HIDE_ROCK_TUNNEL_1F_PEARL" }, + [453] = { "FLAG_HIDE_ROCK_TUNNEL_1F_ESCAPE_ROPE", "HIDE_ROCK_TUNNEL_1F_ESCAPE_ROPE" }, + [454] = { "FLAG_HIDE_ROCK_TUNNEL_B1F_REVIVE", "HIDE_ROCK_TUNNEL_B1F_REVIVE" }, + [455] = { "FLAG_HIDE_ROCK_TUNNEL_B1F_MAX_ETHER", "HIDE_ROCK_TUNNEL_B1F_MAX_ETHER" }, + [456] = { "FLAG_HIDE_SILPH_CO_8F_IRON", "HIDE_SILPH_CO_8F_IRON" }, + [457] = { "FLAG_HIDE_SILPH_CO_11F_ZINC", "HIDE_SILPH_CO_11F_ZINC" }, + [458] = { "FLAG_HIDE_POKEMON_MANSION_1F_PROTEIN", "HIDE_POKEMON_MANSION_1F_PROTEIN" }, + [459] = { "FLAG_HIDE_POKEMON_MANSION_2F_ZINC", "HIDE_POKEMON_MANSION_2F_ZINC" }, + [460] = { "FLAG_HIDE_POKEMON_MANSION_2F_HP_UP", "HIDE_POKEMON_MANSION_2F_HP_UP" }, + [461] = { "FLAG_HIDE_VIRIDIAN_CITY_POTION", "HIDE_VIRIDIAN_CITY_POTION" }, + [462] = { "FLAG_HIDE_ROUTE11_GREAT_BALL", "HIDE_ROUTE11_GREAT_BALL" }, + [463] = { "FLAG_HIDE_ROUTE11_AWAKENING", "HIDE_ROUTE11_AWAKENING" }, + [464] = { "FLAG_HIDE_POKEMON_TOWER_5F_CLEANSE_TAG", "HIDE_POKEMON_TOWER_5F_CLEANSE_TAG" }, + [465] = { "FLAG_HIDE_CELADON_CITY_ETHER", "HIDE_CELADON_CITY_ETHER" }, + [466] = { "FLAG_HIDE_ROCKET_HIDEOUT_B3F_BLACK_GLASSES", "HIDE_ROCKET_HIDEOUT_B3F_BLACK_GLASSES" }, + [467] = { "FLAG_HIDE_SAFARI_ZONE_NORTH_QUICK_CLAW", "HIDE_SAFARI_ZONE_NORTH_QUICK_CLAW" }, + [468] = { "FLAG_HIDE_SEAFOAM_ISLANDS_1F_ICE_HEAL", "HIDE_SEAFOAM_ISLANDS_1F_ICE_HEAL" }, + [469] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B1F_WATER_STONE", "HIDE_SEAFOAM_ISLANDS_B1F_WATER_STONE" }, + [470] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B1F_REVIVE", "HIDE_SEAFOAM_ISLANDS_B1F_REVIVE" }, + [471] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B2F_BIG_PEARL", "HIDE_SEAFOAM_ISLANDS_B2F_BIG_PEARL" }, + [472] = { "FLAG_HIDE_SEAFOAM_ISLANDS_B4F_ULTRA_BALL", "HIDE_SEAFOAM_ISLANDS_B4F_ULTRA_BALL" }, + [473] = { "FLAG_HIDE_FOUR_ISLAND_STAR_PIECE", "HIDE_FOUR_ISLAND_STAR_PIECE" }, + [474] = { "FLAG_HIDE_FOUR_ISLAND_STARDUST", "HIDE_FOUR_ISLAND_STARDUST" }, + [475] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_ETHER", "HIDE_ONE_ISLAND_KINDLE_ROAD_ETHER" }, + [476] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_MAX_REPEL", "HIDE_ONE_ISLAND_KINDLE_ROAD_MAX_REPEL" }, + [477] = { "FLAG_HIDE_ONE_ISLAND_KINDLE_ROAD_CARBOS", "HIDE_ONE_ISLAND_KINDLE_ROAD_CARBOS" }, + [478] = { "FLAG_HIDE_FIVE_ISLAND_MEADOW_MAX_POTION", "HIDE_FIVE_ISLAND_MEADOW_MAX_POTION" }, + [479] = { "FLAG_HIDE_FIVE_ISLAND_MEADOW_PP_UP", "HIDE_FIVE_ISLAND_MEADOW_PP_UP" }, + [480] = { "FLAG_HIDE_FIVE_ISLAND_MEMORIAL_PILLAR_METAL_COAT", "HIDE_FIVE_ISLAND_MEMORIAL_PILLAR_METAL_COAT" }, + [481] = { "FLAG_HIDE_SIX_ISLAND_OUTCAST_ISLAND_PP_UP", "HIDE_SIX_ISLAND_OUTCAST_ISLAND_PP_UP" }, + [482] = { "FLAG_HIDE_SIX_ISLAND_WATER_PATH_ELIXIR", "HIDE_SIX_ISLAND_WATER_PATH_ELIXIR" }, + [483] = { "FLAG_HIDE_SIX_ISLAND_WATER_PATH_DRAGON_SCALE", "HIDE_SIX_ISLAND_WATER_PATH_DRAGON_SCALE" }, + [484] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_FULL_RESTORE", "HIDE_SIX_ISLAND_RUIN_VALLEY_FULL_RESTORE" }, + [485] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_HP_UP", "HIDE_SIX_ISLAND_RUIN_VALLEY_HP_UP" }, + [486] = { "FLAG_HIDE_SIX_ISLAND_RUIN_VALLEY_SUN_STONE", "HIDE_SIX_ISLAND_RUIN_VALLEY_SUN_STONE" }, + [487] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_KINGS_ROCK", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_KINGS_ROCK" }, + [488] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_MAX_ELIXIR", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_MAX_ELIXIR" }, + [489] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_NUGGET", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_NUGGET" }, + [490] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ETHER", "HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ETHER" }, + [491] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_FULL_HEAL", "HIDE_THREE_ISLAND_BERRY_FOREST_FULL_HEAL" }, + [492] = { "FLAG_HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ELIXIR", "HIDE_THREE_ISLAND_BERRY_FOREST_MAX_ELIXIR" }, + [493] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_ULTRA_BALL", "HIDE_MT_EMBER_EXTERIOR_ULTRA_BALL" }, + [494] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_FIRE_STONE", "HIDE_MT_EMBER_EXTERIOR_FIRE_STONE" }, + [495] = { "FLAG_HIDE_MT_EMBER_EXTERIOR_DIRE_HIT", "HIDE_MT_EMBER_EXTERIOR_DIRE_HIT" }, + [496] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_ULTRA_BALL", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_ULTRA_BALL" }, + [497] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_HM07", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_1F_HM07" }, + [498] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_FULL_RESTORE", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_FULL_RESTORE" }, + [499] = { "FLAG_HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_NEVER_MELT_ICE", "HIDE_FOUR_ISLAND_ICEFALL_CAVE_B1F_NEVER_MELT_ICE" }, + [500] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_BIG_PEARL", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_BIG_PEARL" }, + [501] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_TM36", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_TM36" }, + [502] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_PEARL", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_PEARL" }, + [503] = { "FLAG_HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_UP_GRADE", "HIDE_FIVE_ISLAND_ROCKET_WAREHOUSE_UP_GRADE" }, + [504] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM10_SILK_SCARF", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM10_SILK_SCARF" }, + [505] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM11_LAX_INCENSE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM11_LAX_INCENSE" }, + [506] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM12_SEA_INCENSE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM12_SEA_INCENSE" }, + [507] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM13_MAX_REVIVE", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM13_MAX_REVIVE" }, + [508] = { "FLAG_HIDE_FIVE_ISLAND_LOST_CAVE_ROOM14_RARE_CANDY", "HIDE_FIVE_ISLAND_LOST_CAVE_ROOM14_RARE_CANDY" }, + [509] = { "FLAG_HIDE_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE_LUCKY_PUNCH", "HIDE_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE_LUCKY_PUNCH" }, + [510] = { "FLAG_HIDE_SILPH_CO_4F_TM41", "HIDE_SILPH_CO_4F_TM41" }, + [511] = { "FLAG_0x1FF", "0x1FF" }, + [512] = { "FLAG_0x200", "0x200" }, + [513] = { "FLAG_0x201", "0x201" }, + [514] = { "FLAG_0x202", "0x202" }, + [515] = { "FLAG_0x203", "0x203" }, + [516] = { "FLAG_0x204", "0x204" }, + [517] = { "FLAG_0x205", "0x205" }, + [518] = { "FLAG_0x206", "0x206" }, + [519] = { "FLAG_0x207", "0x207" }, + [520] = { "FLAG_0x208", "0x208" }, + [521] = { "FLAG_0x209", "0x209" }, + [522] = { "FLAG_0x20A", "0x20A" }, + [523] = { "FLAG_0x20B", "0x20B" }, + [524] = { "FLAG_0x20C", "0x20C" }, + [525] = { "FLAG_0x20D", "0x20D" }, + [526] = { "FLAG_0x20E", "0x20E" }, + [527] = { "FLAG_0x20F", "0x20F" }, + [528] = { "FLAG_0x210", "0x210" }, + [529] = { "FLAG_0x211", "0x211" }, + [530] = { "FLAG_0x212", "0x212" }, + [531] = { "FLAG_0x213", "0x213" }, + [532] = { "FLAG_0x214", "0x214" }, + [533] = { "FLAG_0x215", "0x215" }, + [534] = { "FLAG_0x216", "0x216" }, + [535] = { "FLAG_0x217", "0x217" }, + [536] = { "FLAG_0x218", "0x218" }, + [537] = { "FLAG_0x219", "0x219" }, + [538] = { "FLAG_0x21A", "0x21A" }, + [539] = { "FLAG_0x21B", "0x21B" }, + [540] = { "FLAG_0x21C", "0x21C" }, + [541] = { "FLAG_0x21D", "0x21D" }, + [542] = { "FLAG_0x21E", "0x21E" }, + [543] = { "FLAG_0x21F", "0x21F" }, + [544] = { "FLAG_0x220", "0x220" }, + [545] = { "FLAG_0x221", "0x221" }, + [546] = { "FLAG_0x222", "0x222" }, + [547] = { "FLAG_0x223", "0x223" }, + [548] = { "FLAG_0x224", "0x224" }, + [549] = { "FLAG_0x225", "0x225" }, + [550] = { "FLAG_0x226", "0x226" }, + [551] = { "FLAG_0x227", "0x227" }, + [552] = { "FLAG_0x228", "0x228" }, + [553] = { "FLAG_0x229", "0x229" }, + [554] = { "FLAG_0x22A", "0x22A" }, + [555] = { "FLAG_0x22B", "0x22B" }, + [556] = { "FLAG_0x22C", "0x22C" }, + [557] = { "FLAG_0x22D", "0x22D" }, + [558] = { "FLAG_0x22E", "0x22E" }, + [559] = { "FLAG_0x22F", "0x22F" }, + [560] = { "FLAG_GOT_POTION_ON_ROUTE_1", "GOT_POTION_ON_ROUTE_1", "STORY_FLAGS_START" }, + [561] = { "FLAG_GOT_TM34_FROM_SURGE", "GOT_TM34_FROM_SURGE" }, + [562] = { "FLAG_GOT_FOSSIL_FROM_MT_MOON", "GOT_FOSSIL_FROM_MT_MOON" }, + [563] = { "FLAG_HELPED_BILL_IN_SEA_COTTAGE", "HELPED_BILL_IN_SEA_COTTAGE" }, + [564] = { "FLAG_GOT_SS_TICKET", "GOT_SS_TICKET" }, + [565] = { "FLAG_GOT_SS_TICKET_DUP", "GOT_SS_TICKET_DUP" }, + [566] = { "FLAG_GOT_TM42_AT_MEMORIAL_PILLAR", "GOT_TM42_AT_MEMORIAL_PILLAR" }, + [567] = { "FLAG_GOT_HM01", "GOT_HM01" }, + [568] = { "FLAG_GOT_HM02", "GOT_HM02" }, + [569] = { "FLAG_GOT_HM03", "GOT_HM03" }, + [570] = { "FLAG_GOT_HM04", "GOT_HM04" }, + [571] = { "FLAG_GOT_HM05", "GOT_HM05" }, + [572] = { "FLAG_RESCUED_MR_FUJI", "RESCUED_MR_FUJI" }, + [573] = { "FLAG_GOT_POKE_FLUTE", "GOT_POKE_FLUTE" }, + [574] = { "FLAG_0x23E", "0x23E" }, + [575] = { "FLAG_GOT_TM28_FROM_ROCKET", "GOT_TM28_FROM_ROCKET" }, + [576] = { "FLAG_GOT_OLD_ROD", "GOT_OLD_ROD" }, + [577] = { "FLAG_GOT_BIKE_VOUCHER", "GOT_BIKE_VOUCHER" }, + [578] = { "FLAG_0x242", "0x242" }, + [579] = { "FLAG_GOT_COIN_CASE", "GOT_COIN_CASE" }, + [580] = { "FLAG_GOT_GOOD_ROD", "GOT_GOOD_ROD" }, + [581] = { "FLAG_GOT_TM29_FROM_MR_PSYCHIC", "GOT_TM29_FROM_MR_PSYCHIC" }, + [582] = { "FLAG_GOT_LAPRAS_FROM_SILPH", "GOT_LAPRAS_FROM_SILPH" }, + [583] = { "FLAG_GOT_POKEBALLS_FROM_OAK_AFTER_22_RIVAL", "GOT_POKEBALLS_FROM_OAK_AFTER_22_RIVAL" }, + [584] = { "FLAG_DID_MIMIEN_TRADE", "DID_MIMIEN_TRADE" }, + [585] = { "FLAG_BOUGHT_MAGIKARP", "BOUGHT_MAGIKARP" }, + [586] = { "FLAG_DID_ZYNX_TRADE", "DID_ZYNX_TRADE" }, + [587] = { "FLAG_DID_MS_NIDO_TRADE", "DID_MS_NIDO_TRADE" }, + [588] = { "FLAG_0x24C", "0x24C" }, + [589] = { "FLAG_DID_CH_DING_TRADE", "DID_CH_DING_TRADE" }, + [590] = { "FLAG_GOT_TM38_FROM_BLAINE", "GOT_TM38_FROM_BLAINE" }, + [591] = { "FLAG_OAK_SKIP_22_RIVAL_CHECK", "OAK_SKIP_22_RIVAL_CHECK" }, + [592] = { "FLAG_GOT_MASTER_BALL_FROM_SILPH", "GOT_MASTER_BALL_FROM_SILPH" }, + [593] = { "FLAG_DID_NINA_TRADE", "DID_NINA_TRADE" }, + [594] = { "FLAG_GOT_ITEMFINDER", "GOT_ITEMFINDER" }, + [595] = { "FLAG_WOKE_UP_ROUTE_12_SNORLAX", "WOKE_UP_ROUTE_12_SNORLAX" }, + [596] = { "FLAG_GOT_TM39_FROM_BROCK", "GOT_TM39_FROM_BROCK" }, + [597] = { "FLAG_GOT_SUPER_ROD", "GOT_SUPER_ROD" }, + [598] = { "FLAG_GOT_EXP_SHARE_FROM_OAKS_AIDE", "GOT_EXP_SHARE_FROM_OAKS_AIDE" }, + [599] = { "FLAG_DID_MARC_TRADE", "DID_MARC_TRADE" }, + [600] = { "FLAG_BEAT_RIVAL_IN_OAKS_LAB", "BEAT_RIVAL_IN_OAKS_LAB" }, + [601] = { "FLAG_GOT_TM06_FROM_KOGA", "GOT_TM06_FROM_KOGA" }, + [602] = { "FLAG_0x25A", "0x25A" }, + [603] = { "FLAG_GOT_TM27", "GOT_TM27" }, + [604] = { "FLAG_0x25C", "0x25C" }, + [605] = { "FLAG_0x25D", "0x25D" }, + [606] = { "FLAG_GOT_OLD_AMBER", "GOT_OLD_AMBER" }, + [607] = { "FLAG_0x25F", "0x25F" }, + [608] = { "FLAG_0x260", "0x260" }, + [609] = { "FLAG_0x261", "0x261" }, + [610] = { "FLAG_0x262", "0x262" }, + [611] = { "FLAG_GOT_EEVEE", "GOT_EEVEE" }, + [612] = { "FLAG_FOUND_BOTH_VERMILION_GYM_SWITCHES", "FOUND_BOTH_VERMILION_GYM_SWITCHES" }, + [613] = { "FLAG_CINNABAR_GYM_QUIZ_1", "CINNABAR_GYM_QUIZ_1" }, + [614] = { "FLAG_PENDING_DAYCARE_EGG", "PENDING_DAYCARE_EGG" }, + [615] = { "FLAG_CINNABAR_GYM_QUIZ_2", "CINNABAR_GYM_QUIZ_2" }, + [616] = { "FLAG_CINNABAR_GYM_QUIZ_3", "CINNABAR_GYM_QUIZ_3" }, + [617] = { "FLAG_CINNABAR_GYM_QUIZ_4", "CINNABAR_GYM_QUIZ_4" }, + [618] = { "FLAG_CINNABAR_GYM_QUIZ_5", "CINNABAR_GYM_QUIZ_5" }, + [619] = { "FLAG_CINNABAR_GYM_QUIZ_6", "CINNABAR_GYM_QUIZ_6" }, + [620] = { "FLAG_POKEMON_MANSION_SWITCH_STATE", "POKEMON_MANSION_SWITCH_STATE" }, + [621] = { "FLAG_OPENED_ROCKET_HIDEOUT", "OPENED_ROCKET_HIDEOUT" }, + [622] = { "FLAG_GOT_10_COINS_FROM_GAMBLER", "GOT_10_COINS_FROM_GAMBLER" }, + [623] = { "FLAG_GOT_20_COINS_FROM_GAMBLER", "GOT_20_COINS_FROM_GAMBLER" }, + [624] = { "FLAG_GOT_20_COINS_FROM_GAMBLER_2", "GOT_20_COINS_FROM_GAMBLER_2" }, + [625] = { "FLAG_GOT_BICYCLE", "GOT_BICYCLE" }, + [626] = { "FLAG_GOT_DOME_FOSSIL", "GOT_DOME_FOSSIL" }, + [627] = { "FLAG_GOT_HELIX_FOSSIL", "GOT_HELIX_FOSSIL" }, + [628] = { "FLAG_DID_ESPHERE_TRADE", "DID_ESPHERE_TRADE" }, + [629] = { "FLAG_DID_TANGENY_TRADE", "DID_TANGENY_TRADE" }, + [630] = { "FLAG_DID_SEELOR_TRADE", "DID_SEELOR_TRADE" }, + [631] = { "FLAG_0x277", "0x277" }, + [632] = { "FLAG_GOT_HITMON_FROM_DOJO", "GOT_HITMON_FROM_DOJO" }, + [633] = { "FLAG_0x279", "0x279" }, + [634] = { "FLAG_SILPH_2F_DOOR_1", "SILPH_2F_DOOR_1" }, + [635] = { "FLAG_SILPH_2F_DOOR_2", "SILPH_2F_DOOR_2" }, + [636] = { "FLAG_SILPH_3F_DOOR_1", "SILPH_3F_DOOR_1" }, + [637] = { "FLAG_SILPH_3F_DOOR_2", "SILPH_3F_DOOR_2" }, + [638] = { "FLAG_SILPH_4F_DOOR_1", "SILPH_4F_DOOR_1" }, + [639] = { "FLAG_SILPH_4F_DOOR_2", "SILPH_4F_DOOR_2" }, + [640] = { "FLAG_SILPH_5F_DOOR_1", "SILPH_5F_DOOR_1" }, + [641] = { "FLAG_SILPH_5F_DOOR_2", "SILPH_5F_DOOR_2" }, + [642] = { "FLAG_SILPH_5F_DOOR_3", "SILPH_5F_DOOR_3" }, + [643] = { "FLAG_SILPH_6F_DOOR", "SILPH_6F_DOOR" }, + [644] = { "FLAG_SILPH_7F_DOOR_1", "SILPH_7F_DOOR_1" }, + [645] = { "FLAG_SILPH_7F_DOOR_2", "SILPH_7F_DOOR_2" }, + [646] = { "FLAG_SILPH_7F_DOOR_3", "SILPH_7F_DOOR_3" }, + [647] = { "FLAG_SILPH_8F_DOOR", "SILPH_8F_DOOR" }, + [648] = { "FLAG_SILPH_9F_DOOR_1", "SILPH_9F_DOOR_1" }, + [649] = { "FLAG_SILPH_9F_DOOR_2", "SILPH_9F_DOOR_2" }, + [650] = { "FLAG_SILPH_9F_DOOR_3", "SILPH_9F_DOOR_3" }, + [651] = { "FLAG_SILPH_9F_DOOR_4", "SILPH_9F_DOOR_4" }, + [652] = { "FLAG_SILPH_10F_DOOR", "SILPH_10F_DOOR" }, + [653] = { "FLAG_SILPH_11F_DOOR", "SILPH_11F_DOOR" }, + [654] = { "FLAG_0x28E", "0x28E" }, + [655] = { "FLAG_0x28F", "0x28F" }, + [656] = { "FLAG_MET_STICKER_MAN", "MET_STICKER_MAN" }, + [657] = { "FLAG_PALLET_LADY_NOT_BLOCKING_SIGN", "PALLET_LADY_NOT_BLOCKING_SIGN" }, + [658] = { "FLAG_GOT_VS_SEEKER", "GOT_VS_SEEKER" }, + [659] = { "FLAG_GOT_TM19_FROM_ERIKA", "GOT_TM19_FROM_ERIKA" }, + [660] = { "FLAG_GOT_TM33_FROM_THIRSTY_GIRL", "GOT_TM33_FROM_THIRSTY_GIRL" }, + [661] = { "FLAG_GOT_TM20_FROM_THIRSTY_GIRL", "GOT_TM20_FROM_THIRSTY_GIRL" }, + [662] = { "FLAG_GOT_TM16_FROM_THIRSTY_GIRL", "GOT_TM16_FROM_THIRSTY_GIRL" }, + [663] = { "FLAG_GOT_TM03_FROM_MISTY", "GOT_TM03_FROM_MISTY" }, + [664] = { "FLAG_GOT_TM26_FROM_GIOVANNI", "GOT_TM26_FROM_GIOVANNI" }, + [665] = { "FLAG_0x299", "0x299" }, + [666] = { "FLAG_GOT_TM04_FROM_SABRINA", "GOT_TM04_FROM_SABRINA" }, + [667] = { "FLAG_GOT_FAME_CHECKER", "GOT_FAME_CHECKER" }, + [668] = { "FLAG_GOT_RECORD_SETTING_MAGIKARP", "GOT_RECORD_SETTING_MAGIKARP" }, + [669] = { "FLAG_TWO_ISLAND_SHOP_INTRODUCED", "TWO_ISLAND_SHOP_INTRODUCED" }, + [670] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_1", "TWO_ISLAND_SHOP_EXPANDED_1" }, + [671] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_2", "TWO_ISLAND_SHOP_EXPANDED_2" }, + [672] = { "FLAG_TWO_ISLAND_SHOP_EXPANDED_3", "TWO_ISLAND_SHOP_EXPANDED_3" }, + [673] = { "FLAG_SEVII_DETOUR_FINISHED", "SEVII_DETOUR_FINISHED" }, + [674] = { "FLAG_VISITED_TWO_ISLAND", "VISITED_TWO_ISLAND" }, + [675] = { "FLAG_RESCUED_LOSTELLE", "RESCUED_LOSTELLE" }, + [676] = { "FLAG_0x2A4", "0x2A4" }, + [677] = { "FLAG_CAN_USE_ROCKET_HIDEOUT_LIFT", "CAN_USE_ROCKET_HIDEOUT_LIFT" }, + [678] = { "FLAG_GOT_TEA", "GOT_TEA" }, + [679] = { "FLAG_RECEIVED_AURORA_TICKET", "RECEIVED_AURORA_TICKET" }, + [680] = { "FLAG_RECEIVED_MYSTIC_TICKET", "RECEIVED_MYSTIC_TICKET" }, + [681] = { "FLAG_RECEIVED_OLD_SEA_MAP", "RECEIVED_OLD_SEA_MAP" }, + [682] = { "FLAG_WONDER_CARD_UNUSED_1", "WONDER_CARD_UNUSED_1" }, + [683] = { "FLAG_WONDER_CARD_UNUSED_2", "WONDER_CARD_UNUSED_2" }, + [684] = { "FLAG_WONDER_CARD_UNUSED_3", "WONDER_CARD_UNUSED_3" }, + [685] = { "FLAG_WONDER_CARD_UNUSED_4", "WONDER_CARD_UNUSED_4" }, + [686] = { "FLAG_WONDER_CARD_UNUSED_5", "WONDER_CARD_UNUSED_5" }, + [687] = { "FLAG_WONDER_CARD_UNUSED_6", "WONDER_CARD_UNUSED_6" }, + [688] = { "FLAG_WONDER_CARD_UNUSED_7", "WONDER_CARD_UNUSED_7" }, + [689] = { "FLAG_WONDER_CARD_UNUSED_8", "WONDER_CARD_UNUSED_8" }, + [690] = { "FLAG_WONDER_CARD_UNUSED_9", "WONDER_CARD_UNUSED_9" }, + [691] = { "FLAG_WONDER_CARD_UNUSED_10", "WONDER_CARD_UNUSED_10" }, + [692] = { "FLAG_WONDER_CARD_UNUSED_11", "WONDER_CARD_UNUSED_11" }, + [693] = { "FLAG_WONDER_CARD_UNUSED_12", "WONDER_CARD_UNUSED_12" }, + [694] = { "FLAG_WONDER_CARD_UNUSED_13", "WONDER_CARD_UNUSED_13" }, + [695] = { "FLAG_WONDER_CARD_UNUSED_14", "WONDER_CARD_UNUSED_14" }, + [696] = { "FLAG_WONDER_CARD_UNUSED_15", "WONDER_CARD_UNUSED_15" }, + [697] = { "FLAG_WONDER_CARD_UNUSED_16", "WONDER_CARD_UNUSED_16" }, + [698] = { "FLAG_WONDER_CARD_UNUSED_17", "WONDER_CARD_UNUSED_17" }, + [699] = { "FLAG_GOT_POWDER_JAR", "GOT_POWDER_JAR" }, + [700] = { "FLAG_FOUGHT_MEWTWO", "FOUGHT_MEWTWO" }, + [701] = { "FLAG_FOUGHT_MOLTRES", "FOUGHT_MOLTRES" }, + [702] = { "FLAG_FOUGHT_ARTICUNO", "FOUGHT_ARTICUNO" }, + [703] = { "FLAG_FOUGHT_ZAPDOS", "FOUGHT_ZAPDOS" }, + [704] = { "FLAG_TUTOR_DOUBLE_EDGE", "TUTOR_DOUBLE_EDGE" }, + [705] = { "FLAG_TUTOR_THUNDER_WAVE", "TUTOR_THUNDER_WAVE" }, + [706] = { "FLAG_TUTOR_ROCK_SLIDE", "TUTOR_ROCK_SLIDE" }, + [707] = { "FLAG_TUTOR_EXPLOSION", "TUTOR_EXPLOSION" }, + [708] = { "FLAG_TUTOR_MEGA_PUNCH", "TUTOR_MEGA_PUNCH" }, + [709] = { "FLAG_TUTOR_MEGA_KICK", "TUTOR_MEGA_KICK" }, + [710] = { "FLAG_TUTOR_DREAM_EATER", "TUTOR_DREAM_EATER" }, + [711] = { "FLAG_TUTOR_SOFT_BOILED", "TUTOR_SOFT_BOILED" }, + [712] = { "FLAG_TUTOR_SUBSTITUTE", "TUTOR_SUBSTITUTE" }, + [713] = { "FLAG_TUTOR_SWORDS_DANCE", "TUTOR_SWORDS_DANCE" }, + [714] = { "FLAG_TUTOR_SEISMIC_TOSS", "TUTOR_SEISMIC_TOSS" }, + [715] = { "FLAG_TUTOR_COUNTER", "TUTOR_COUNTER" }, + [716] = { "FLAG_TUTOR_METRONOME", "TUTOR_METRONOME" }, + [717] = { "FLAG_TUTOR_MIMIC", "TUTOR_MIMIC" }, + [718] = { "FLAG_TUTOR_BODY_SLAM", "TUTOR_BODY_SLAM" }, + [719] = { "FLAG_VISITED_OAKS_LAB", "VISITED_OAKS_LAB" }, + [720] = { "FLAG_FOUGHT_POWER_PLANT_ELECTRODE_1", "FOUGHT_POWER_PLANT_ELECTRODE_1" }, + [721] = { "FLAG_FOUGHT_POWER_PLANT_ELECTRODE_2", "FOUGHT_POWER_PLANT_ELECTRODE_2" }, + [722] = { "FLAG_STOPPED_SEAFOAM_B3F_CURRENT", "STOPPED_SEAFOAM_B3F_CURRENT" }, + [723] = { "FLAG_STOPPED_SEAFOAM_B4F_CURRENT", "STOPPED_SEAFOAM_B4F_CURRENT" }, + [724] = { "FLAG_TALKED_TO_LORELEI_AFTER_WAREHOUSE", "TALKED_TO_LORELEI_AFTER_WAREHOUSE" }, + [725] = { "FLAG_DEFEATED_ROCKETS_IN_WAREHOUSE", "DEFEATED_ROCKETS_IN_WAREHOUSE" }, + [726] = { "FLAG_UNLOCKED_ROCKET_WAREHOUSE", "UNLOCKED_ROCKET_WAREHOUSE" }, + [727] = { "FLAG_NO_ROOM_FOR_TM42_AT_MEMORIAL_PILLAR", "NO_ROOM_FOR_TM42_AT_MEMORIAL_PILLAR" }, + [728] = { "FLAG_LEARNED_YES_NAH_CHANSEY", "LEARNED_YES_NAH_CHANSEY" }, + [729] = { "FLAG_GOT_NEST_BALL_FROM_WATER_PATH_HOUSE_1", "GOT_NEST_BALL_FROM_WATER_PATH_HOUSE_1" }, + [730] = { "FLAG_GOT_TOGEPI_EGG", "GOT_TOGEPI_EGG" }, + [731] = { "FLAG_NO_ROOM_FOR_TOGEPI_EGG", "NO_ROOM_FOR_TOGEPI_EGG" }, + [732] = { "FLAG_RECOVERED_SAPPHIRE", "RECOVERED_SAPPHIRE" }, + [733] = { "FLAG_GOT_RUBY", "GOT_RUBY" }, + [734] = { "FLAG_TUTOR_FRENZY_PLANT", "TUTOR_FRENZY_PLANT" }, + [735] = { "FLAG_TUTOR_BLAST_BURN", "TUTOR_BLAST_BURN" }, + [736] = { "FLAG_TUTOR_HYDRO_CANNON", "TUTOR_HYDRO_CANNON" }, + [737] = { "FLAG_LEARNED_ALL_MOVES_AT_CAPE_BRINK", "LEARNED_ALL_MOVES_AT_CAPE_BRINK" }, + [738] = { "FLAG_GOT_NUGGET_FROM_DUNSPARCE_TUNNEL", "GOT_NUGGET_FROM_DUNSPARCE_TUNNEL" }, + [739] = { "FLAG_USED_CUT_ON_RUIN_VALLEY_BRAILLE", "USED_CUT_ON_RUIN_VALLEY_BRAILLE" }, + [740] = { "FLAG_FOUGHT_DEOXYS", "FOUGHT_DEOXYS" }, + [741] = { "FLAG_0x2E5", "0x2E5" }, + [742] = { "FLAG_0x2E6", "0x2E6" }, + [743] = { "FLAG_0x2E7", "0x2E7" }, + [744] = { "FLAG_0x2E8", "0x2E8" }, + [745] = { "FLAG_0x2E9", "0x2E9" }, + [746] = { "FLAG_0x2EA", "0x2EA" }, + [747] = { "FLAG_0x2EB", "0x2EB" }, + [748] = { "FLAG_REVIVED_DOME", "REVIVED_DOME" }, + [749] = { "FLAG_REVIVED_HELIX", "REVIVED_HELIX" }, + [750] = { "FLAG_REVIVED_AMBER", "REVIVED_AMBER" }, + [751] = { "FLAG_GOT_HM06", "GOT_HM06" }, + [752] = { "FLAG_SHOWN_MYSTIC_TICKET", "SHOWN_MYSTIC_TICKET" }, + [753] = { "FLAG_SHOWN_AURORA_TICKET", "SHOWN_AURORA_TICKET" }, + [754] = { "FLAG_FOUGHT_LUGIA", "FOUGHT_LUGIA" }, + [755] = { "FLAG_FOUGHT_HO_OH", "FOUGHT_HO_OH" }, + [756] = { "FLAG_OAK_SAW_DEX_COMPLETION", "OAK_SAW_DEX_COMPLETION" }, + [757] = { "FLAG_LUGIA_FLEW_AWAY", "LUGIA_FLEW_AWAY" }, + [758] = { "FLAG_HO_OH_FLEW_AWAY", "HO_OH_FLEW_AWAY" }, + [759] = { "FLAG_DEOXYS_FLEW_AWAY", "DEOXYS_FLEW_AWAY" }, + [760] = { "FLAG_TALKED_TO_TEA_LADY_AFTER_HOF", "TALKED_TO_TEA_LADY_AFTER_HOF" }, + [761] = { "FLAG_TALKED_TO_OAKS_AIDE_IN_VERMILION", "TALKED_TO_OAKS_AIDE_IN_VERMILION" }, + [762] = { "FLAG_GOT_EVERSTONE_FROM_OAKS_AIDE", "GOT_EVERSTONE_FROM_OAKS_AIDE" }, + [763] = { "FLAG_GOT_MOON_STONE_FROM_JOYFUL_GAME_CORNER", "GOT_MOON_STONE_FROM_JOYFUL_GAME_CORNER" }, + [764] = { "FLAG_GOT_FULL_RESTORE_FROM_THREE_ISLAND_DEFENDER", "GOT_FULL_RESTORE_FROM_THREE_ISLAND_DEFENDER" }, + [765] = { "FLAG_GOT_AMULET_COIN_FROM_OAKS_AIDE", "GOT_AMULET_COIN_FROM_OAKS_AIDE" }, + [766] = { "FLAG_NO_ROOM_FOR_JOYFUL_GAME_CORNER_MOON_STONE", "NO_ROOM_FOR_JOYFUL_GAME_CORNER_MOON_STONE" }, + [767] = { "FLAG_OAKS_RATING_IS_VIA_PC", "OAKS_RATING_IS_VIA_PC" }, + [768] = { "FLAG_0x300", "0x300" }, + [769] = { "FLAG_0x301", "0x301" }, + [770] = { "FLAG_0x302", "0x302" }, + [771] = { "FLAG_0x303", "0x303" }, + [772] = { "FLAG_0x304", "0x304" }, + [773] = { "FLAG_0x305", "0x305" }, + [774] = { "FLAG_0x306", "0x306" }, + [775] = { "FLAG_0x307", "0x307" }, + [776] = { "FLAG_0x308", "0x308" }, + [777] = { "FLAG_0x309", "0x309" }, + [778] = { "FLAG_0x30A", "0x30A" }, + [779] = { "FLAG_0x30B", "0x30B" }, + [780] = { "FLAG_0x30C", "0x30C" }, + [781] = { "FLAG_0x30D", "0x30D" }, + [782] = { "FLAG_0x30E", "0x30E" }, + [783] = { "FLAG_0x30F", "0x30F" }, + [784] = { "FLAG_0x310", "0x310" }, + [785] = { "FLAG_0x311", "0x311" }, + [786] = { "FLAG_0x312", "0x312" }, + [787] = { "FLAG_0x313", "0x313" }, + [788] = { "FLAG_0x314", "0x314" }, + [789] = { "FLAG_0x315", "0x315" }, + [790] = { "FLAG_0x316", "0x316" }, + [791] = { "FLAG_0x317", "0x317" }, + [792] = { "FLAG_0x318", "0x318" }, + [793] = { "FLAG_0x319", "0x319" }, + [794] = { "FLAG_0x31A", "0x31A" }, + [795] = { "FLAG_0x31B", "0x31B" }, + [796] = { "FLAG_0x31C", "0x31C" }, + [797] = { "FLAG_0x31D", "0x31D" }, + [798] = { "FLAG_0x31E", "0x31E" }, + [799] = { "FLAG_0x31F", "0x31F" }, + [800] = { "FLAG_0x320", "0x320" }, + [801] = { "FLAG_0x321", "0x321" }, + [802] = { "FLAG_0x322", "0x322" }, + [803] = { "FLAG_0x323", "0x323" }, + [804] = { "FLAG_0x324", "0x324" }, + [805] = { "FLAG_0x325", "0x325" }, + [806] = { "FLAG_0x326", "0x326" }, + [807] = { "FLAG_0x327", "0x327" }, + [808] = { "FLAG_0x328", "0x328" }, + [809] = { "FLAG_0x329", "0x329" }, + [810] = { "FLAG_0x32A", "0x32A" }, + [811] = { "FLAG_0x32B", "0x32B" }, + [812] = { "FLAG_0x32C", "0x32C" }, + [813] = { "FLAG_0x32D", "0x32D" }, + [814] = { "FLAG_0x32E", "0x32E" }, + [815] = { "FLAG_0x32F", "0x32F" }, + [816] = { "FLAG_0x330", "0x330" }, + [817] = { "FLAG_0x331", "0x331" }, + [818] = { "FLAG_0x332", "0x332" }, + [819] = { "FLAG_0x333", "0x333" }, + [820] = { "FLAG_0x334", "0x334" }, + [821] = { "FLAG_0x335", "0x335" }, + [822] = { "FLAG_0x336", "0x336" }, + [823] = { "FLAG_0x337", "0x337" }, + [824] = { "FLAG_0x338", "0x338" }, + [825] = { "FLAG_0x339", "0x339" }, + [826] = { "FLAG_0x33A", "0x33A" }, + [827] = { "FLAG_0x33B", "0x33B" }, + [828] = { "FLAG_0x33C", "0x33C" }, + [829] = { "FLAG_0x33D", "0x33D" }, + [830] = { "FLAG_0x33E", "0x33E" }, + [831] = { "FLAG_0x33F", "0x33F" }, + [832] = { "FLAG_0x340", "0x340" }, + [833] = { "FLAG_0x341", "0x341" }, + [834] = { "FLAG_0x342", "0x342" }, + [835] = { "FLAG_0x343", "0x343" }, + [836] = { "FLAG_0x344", "0x344" }, + [837] = { "FLAG_0x345", "0x345" }, + [838] = { "FLAG_0x346", "0x346" }, + [839] = { "FLAG_0x347", "0x347" }, + [840] = { "FLAG_0x348", "0x348" }, + [841] = { "FLAG_0x349", "0x349" }, + [842] = { "FLAG_0x34A", "0x34A" }, + [843] = { "FLAG_0x34B", "0x34B" }, + [844] = { "FLAG_0x34C", "0x34C" }, + [845] = { "FLAG_0x34D", "0x34D" }, + [846] = { "FLAG_0x34E", "0x34E" }, + [847] = { "FLAG_0x34F", "0x34F" }, + [848] = { "FLAG_0x350", "0x350" }, + [849] = { "FLAG_0x351", "0x351" }, + [850] = { "FLAG_0x352", "0x352" }, + [851] = { "FLAG_0x353", "0x353" }, + [852] = { "FLAG_0x354", "0x354" }, + [853] = { "FLAG_0x355", "0x355" }, + [854] = { "FLAG_0x356", "0x356" }, + [855] = { "FLAG_0x357", "0x357" }, + [856] = { "FLAG_0x358", "0x358" }, + [857] = { "FLAG_0x359", "0x359" }, + [858] = { "FLAG_0x35A", "0x35A" }, + [859] = { "FLAG_0x35B", "0x35B" }, + [860] = { "FLAG_0x35C", "0x35C" }, + [861] = { "FLAG_0x35D", "0x35D" }, + [862] = { "FLAG_0x35E", "0x35E" }, + [863] = { "FLAG_0x35F", "0x35F" }, + [864] = { "FLAG_0x360", "0x360" }, + [865] = { "FLAG_0x361", "0x361" }, + [866] = { "FLAG_0x362", "0x362" }, + [867] = { "FLAG_0x363", "0x363" }, + [868] = { "FLAG_0x364", "0x364" }, + [869] = { "FLAG_0x365", "0x365" }, + [870] = { "FLAG_0x366", "0x366" }, + [871] = { "FLAG_0x367", "0x367" }, + [872] = { "FLAG_0x368", "0x368" }, + [873] = { "FLAG_0x369", "0x369" }, + [874] = { "FLAG_0x36A", "0x36A" }, + [875] = { "FLAG_0x36B", "0x36B" }, + [876] = { "FLAG_0x36C", "0x36C" }, + [877] = { "FLAG_0x36D", "0x36D" }, + [878] = { "FLAG_0x36E", "0x36E" }, + [879] = { "FLAG_0x36F", "0x36F" }, + [880] = { "FLAG_0x370", "0x370" }, + [881] = { "FLAG_0x371", "0x371" }, + [882] = { "FLAG_0x372", "0x372" }, + [883] = { "FLAG_0x373", "0x373" }, + [884] = { "FLAG_0x374", "0x374" }, + [885] = { "FLAG_0x375", "0x375" }, + [886] = { "FLAG_0x376", "0x376" }, + [887] = { "FLAG_0x377", "0x377" }, + [888] = { "FLAG_0x378", "0x378" }, + [889] = { "FLAG_0x379", "0x379" }, + [890] = { "FLAG_0x37A", "0x37A" }, + [891] = { "FLAG_0x37B", "0x37B" }, + [892] = { "FLAG_0x37C", "0x37C" }, + [893] = { "FLAG_0x37D", "0x37D" }, + [894] = { "FLAG_0x37E", "0x37E" }, + [895] = { "FLAG_0x37F", "0x37F" }, + [896] = { "FLAG_0x380", "0x380" }, + [897] = { "FLAG_0x381", "0x381" }, + [898] = { "FLAG_0x382", "0x382" }, + [899] = { "FLAG_0x383", "0x383" }, + [900] = { "FLAG_0x384", "0x384" }, + [901] = { "FLAG_0x385", "0x385" }, + [902] = { "FLAG_0x386", "0x386" }, + [903] = { "FLAG_0x387", "0x387" }, + [904] = { "FLAG_0x388", "0x388" }, + [905] = { "FLAG_0x389", "0x389" }, + [906] = { "FLAG_0x38A", "0x38A" }, + [907] = { "FLAG_0x38B", "0x38B" }, + [908] = { "FLAG_0x38C", "0x38C" }, + [909] = { "FLAG_0x38D", "0x38D" }, + [910] = { "FLAG_0x38E", "0x38E" }, + [911] = { "FLAG_0x38F", "0x38F" }, + [912] = { "FLAG_0x390", "0x390" }, + [913] = { "FLAG_0x391", "0x391" }, + [914] = { "FLAG_0x392", "0x392" }, + [915] = { "FLAG_0x393", "0x393" }, + [916] = { "FLAG_0x394", "0x394" }, + [917] = { "FLAG_0x395", "0x395" }, + [918] = { "FLAG_0x396", "0x396" }, + [919] = { "FLAG_0x397", "0x397" }, + [920] = { "FLAG_0x398", "0x398" }, + [921] = { "FLAG_0x399", "0x399" }, + [922] = { "FLAG_0x39A", "0x39A" }, + [923] = { "FLAG_0x39B", "0x39B" }, + [924] = { "FLAG_0x39C", "0x39C" }, + [925] = { "FLAG_0x39D", "0x39D" }, + [926] = { "FLAG_0x39E", "0x39E" }, + [927] = { "FLAG_0x39F", "0x39F" }, + [928] = { "FLAG_0x3A0", "0x3A0" }, + [929] = { "FLAG_0x3A1", "0x3A1" }, + [930] = { "FLAG_0x3A2", "0x3A2" }, + [931] = { "FLAG_0x3A3", "0x3A3" }, + [932] = { "FLAG_0x3A4", "0x3A4" }, + [933] = { "FLAG_0x3A5", "0x3A5" }, + [934] = { "FLAG_0x3A6", "0x3A6" }, + [935] = { "FLAG_0x3A7", "0x3A7" }, + [936] = { "FLAG_0x3A8", "0x3A8" }, + [937] = { "FLAG_0x3A9", "0x3A9" }, + [938] = { "FLAG_0x3AA", "0x3AA" }, + [939] = { "FLAG_0x3AB", "0x3AB" }, + [940] = { "FLAG_0x3AC", "0x3AC" }, + [941] = { "FLAG_0x3AD", "0x3AD" }, + [942] = { "FLAG_0x3AE", "0x3AE" }, + [943] = { "FLAG_0x3AF", "0x3AF" }, + [944] = { "FLAG_0x3B0", "0x3B0" }, + [945] = { "FLAG_0x3B1", "0x3B1" }, + [946] = { "FLAG_0x3B2", "0x3B2" }, + [947] = { "FLAG_0x3B3", "0x3B3" }, + [948] = { "FLAG_0x3B4", "0x3B4" }, + [949] = { "FLAG_0x3B5", "0x3B5" }, + [950] = { "FLAG_0x3B6", "0x3B6" }, + [951] = { "FLAG_0x3B7", "0x3B7" }, + [952] = { "FLAG_0x3B8", "0x3B8" }, + [953] = { "FLAG_0x3B9", "0x3B9" }, + [954] = { "FLAG_0x3BA", "0x3BA" }, + [955] = { "FLAG_0x3BB", "0x3BB" }, + [956] = { "FLAG_0x3BC", "0x3BC" }, + [957] = { "FLAG_0x3BD", "0x3BD" }, + [958] = { "FLAG_0x3BE", "0x3BE" }, + [959] = { "FLAG_0x3BF", "0x3BF" }, + [960] = { "FLAG_0x3C0", "0x3C0" }, + [961] = { "FLAG_0x3C1", "0x3C1" }, + [962] = { "FLAG_0x3C2", "0x3C2" }, + [963] = { "FLAG_0x3C3", "0x3C3" }, + [964] = { "FLAG_0x3C4", "0x3C4" }, + [965] = { "FLAG_0x3C5", "0x3C5" }, + [966] = { "FLAG_0x3C6", "0x3C6" }, + [967] = { "FLAG_0x3C7", "0x3C7" }, + [968] = { "FLAG_0x3C8", "0x3C8" }, + [969] = { "FLAG_0x3C9", "0x3C9" }, + [970] = { "FLAG_0x3CA", "0x3CA" }, + [971] = { "FLAG_0x3CB", "0x3CB" }, + [972] = { "FLAG_0x3CC", "0x3CC" }, + [973] = { "FLAG_0x3CD", "0x3CD" }, + [974] = { "FLAG_0x3CE", "0x3CE" }, + [975] = { "FLAG_0x3CF", "0x3CF" }, + [976] = { "FLAG_0x3D0", "0x3D0" }, + [977] = { "FLAG_0x3D1", "0x3D1" }, + [978] = { "FLAG_0x3D2", "0x3D2" }, + [979] = { "FLAG_0x3D3", "0x3D3" }, + [980] = { "FLAG_0x3D4", "0x3D4" }, + [981] = { "FLAG_0x3D5", "0x3D5" }, + [982] = { "FLAG_0x3D6", "0x3D6" }, + [983] = { "FLAG_0x3D7", "0x3D7" }, + [984] = { "FLAG_MYSTERY_GIFT_DONE", "MYSTERY_GIFT_DONE" }, + [985] = { "FLAG_MYSTERY_GIFT_1", "MYSTERY_GIFT_1" }, + [986] = { "FLAG_MYSTERY_GIFT_2", "MYSTERY_GIFT_2" }, + [987] = { "FLAG_MYSTERY_GIFT_3", "MYSTERY_GIFT_3" }, + [988] = { "FLAG_MYSTERY_GIFT_4", "MYSTERY_GIFT_4" }, + [989] = { "FLAG_MYSTERY_GIFT_5", "MYSTERY_GIFT_5" }, + [990] = { "FLAG_MYSTERY_GIFT_6", "MYSTERY_GIFT_6" }, + [991] = { "FLAG_MYSTERY_GIFT_7", "MYSTERY_GIFT_7" }, + [992] = { "FLAG_MYSTERY_GIFT_8", "MYSTERY_GIFT_8" }, + [993] = { "FLAG_MYSTERY_GIFT_9", "MYSTERY_GIFT_9" }, + [994] = { "FLAG_MYSTERY_GIFT_10", "MYSTERY_GIFT_10" }, + [995] = { "FLAG_MYSTERY_GIFT_11", "MYSTERY_GIFT_11" }, + [996] = { "FLAG_MYSTERY_GIFT_12", "MYSTERY_GIFT_12" }, + [997] = { "FLAG_MYSTERY_GIFT_13", "MYSTERY_GIFT_13" }, + [998] = { "FLAG_MYSTERY_GIFT_14", "MYSTERY_GIFT_14" }, + [999] = { "FLAG_MYSTERY_GIFT_15", "MYSTERY_GIFT_15" }, + [1000] = { "FLAG_HIDDEN_ITEMS_START", "FLAG_HIDDEN_ITEM_VIRIDIAN_FOREST_POTION", "HIDDEN_ITEMS_START", "HIDDEN_ITEM_VIRIDIAN_FOREST_POTION" }, + [1001] = { "FLAG_HIDDEN_ITEM_VIRIDIAN_FOREST_ANTIDOTE", "HIDDEN_ITEM_VIRIDIAN_FOREST_ANTIDOTE" }, + [1002] = { "FLAG_HIDDEN_ITEM_MT_MOON_B2F_MOON_STONE", "HIDDEN_ITEM_MT_MOON_B2F_MOON_STONE" }, + [1003] = { "FLAG_HIDDEN_ITEM_MT_MOON_B2F_ETHER", "HIDDEN_ITEM_MT_MOON_B2F_ETHER" }, + [1004] = { "FLAG_HIDDEN_ITEM_ROUTE25_ELIXIR", "HIDDEN_ITEM_ROUTE25_ELIXIR" }, + [1005] = { "FLAG_HIDDEN_ITEM_ROUTE25_ETHER", "HIDDEN_ITEM_ROUTE25_ETHER" }, + [1006] = { "FLAG_HIDDEN_ITEM_ROUTE9_ETHER", "HIDDEN_ITEM_ROUTE9_ETHER" }, + [1007] = { "FLAG_HIDDEN_ITEM_UNUSED_0x07", "HIDDEN_ITEM_UNUSED_0x07" }, + [1008] = { "FLAG_HIDDEN_ITEM_SSANNE_B1F_CORRIDOR_HYPER_POTION", "HIDDEN_ITEM_SSANNE_B1F_CORRIDOR_HYPER_POTION" }, + [1009] = { "FLAG_HIDDEN_ITEM_ROUTE10_SUPER_POTION", "HIDDEN_ITEM_ROUTE10_SUPER_POTION" }, + [1010] = { "FLAG_HIDDEN_ITEM_ROUTE10_MAX_ETHER", "HIDDEN_ITEM_ROUTE10_MAX_ETHER" }, + [1011] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B1F_PP_UP", "HIDDEN_ITEM_ROCKET_HIDEOUT_B1F_PP_UP" }, + [1012] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B3F_NUGGET", "HIDDEN_ITEM_ROCKET_HIDEOUT_B3F_NUGGET" }, + [1013] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NEST_BALL", "HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NEST_BALL" }, + [1014] = { "FLAG_HIDDEN_ITEM_POKEMON_TOWER_5F_BIG_MUSHROOM", "HIDDEN_ITEM_POKEMON_TOWER_5F_BIG_MUSHROOM" }, + [1015] = { "FLAG_HIDDEN_ITEM_ROUTE13_PP_UP", "HIDDEN_ITEM_ROUTE13_PP_UP" }, + [1016] = { "FLAG_HIDDEN_ITEM_UNUSED_0x10", "HIDDEN_ITEM_UNUSED_0x10" }, + [1017] = { "FLAG_HIDDEN_ITEM_ROUTE17_RARE_CANDY", "HIDDEN_ITEM_ROUTE17_RARE_CANDY" }, + [1018] = { "FLAG_HIDDEN_ITEM_ROUTE17_FULL_RESTORE", "HIDDEN_ITEM_ROUTE17_FULL_RESTORE" }, + [1019] = { "FLAG_HIDDEN_ITEM_ROUTE17_PP_UP", "HIDDEN_ITEM_ROUTE17_PP_UP" }, + [1020] = { "FLAG_HIDDEN_ITEM_ROUTE17_MAX_REVIVE", "HIDDEN_ITEM_ROUTE17_MAX_REVIVE" }, + [1021] = { "FLAG_HIDDEN_ITEM_ROUTE17_MAX_ELIXIR", "HIDDEN_ITEM_ROUTE17_MAX_ELIXIR" }, + [1022] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_CENTER_LEAF_STONE", "HIDDEN_ITEM_SAFARI_ZONE_CENTER_LEAF_STONE" }, + [1023] = { "FLAG_HIDDEN_ITEM_SAFARI_ZONE_WEST_REVIVE", "HIDDEN_ITEM_SAFARI_ZONE_WEST_REVIVE" }, + [1024] = { "FLAG_HIDDEN_ITEM_SILPH_CO_5F_ELIXIR", "HIDDEN_ITEM_SILPH_CO_5F_ELIXIR" }, + [1025] = { "FLAG_HIDDEN_ITEM_SILPH_CO_9F_MAX_POTION", "HIDDEN_ITEM_SILPH_CO_9F_MAX_POTION" }, + [1026] = { "FLAG_HIDDEN_ITEM_SAFFRON_CITY_COPYCATS_HOUSE_2F_NUGGET", "HIDDEN_ITEM_SAFFRON_CITY_COPYCATS_HOUSE_2F_NUGGET" }, + [1027] = { "FLAG_HIDDEN_ITEM_POWER_PLANT_MAX_ELIXIR", "HIDDEN_ITEM_POWER_PLANT_MAX_ELIXIR" }, + [1028] = { "FLAG_HIDDEN_ITEM_POWER_PLANT_THUNDER_STONE", "HIDDEN_ITEM_POWER_PLANT_THUNDER_STONE" }, + [1029] = { "FLAG_HIDDEN_ITEM_SEAFOAM_ISLANDS_B3F_NUGGET", "HIDDEN_ITEM_SEAFOAM_ISLANDS_B3F_NUGGET" }, + [1030] = { "FLAG_HIDDEN_ITEM_SEAFOAM_ISLANDS_B4F_WATER_STONE", "HIDDEN_ITEM_SEAFOAM_ISLANDS_B4F_WATER_STONE" }, + [1031] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_1F_MOON_STONE", "HIDDEN_ITEM_POKEMON_MANSION_1F_MOON_STONE" }, + [1032] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_3F_RARE_CANDY", "HIDDEN_ITEM_POKEMON_MANSION_3F_RARE_CANDY" }, + [1033] = { "FLAG_HIDDEN_ITEM_POKEMON_MANSION_B1F_ELIXIR", "HIDDEN_ITEM_POKEMON_MANSION_B1F_ELIXIR" }, + [1034] = { "FLAG_HIDDEN_ITEM_ROUTE23_FULL_RESTORE", "HIDDEN_ITEM_ROUTE23_FULL_RESTORE" }, + [1035] = { "FLAG_HIDDEN_ITEM_ROUTE23_ULTRA_BALL", "HIDDEN_ITEM_ROUTE23_ULTRA_BALL" }, + [1036] = { "FLAG_HIDDEN_ITEM_ROUTE23_MAX_ETHER", "HIDDEN_ITEM_ROUTE23_MAX_ETHER" }, + [1037] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL", "HIDDEN_ITEM_VICTORY_ROAD_1F_ULTRA_BALL" }, + [1038] = { "FLAG_HIDDEN_ITEM_VICTORY_ROAD_1F_FULL_RESTORE", "HIDDEN_ITEM_VICTORY_ROAD_1F_FULL_RESTORE" }, + [1039] = { "FLAG_HIDDEN_ITEM_CERULEAN_CAVE_1F_ULTRA_BALL", "HIDDEN_ITEM_CERULEAN_CAVE_1F_ULTRA_BALL" }, + [1040] = { "FLAG_HIDDEN_ITEM_UNUSED_0x28", "HIDDEN_ITEM_UNUSED_0x28" }, + [1041] = { "FLAG_HIDDEN_ITEM_ROUTE11_ESCAPE_ROPE", "HIDDEN_ITEM_ROUTE11_ESCAPE_ROPE" }, + [1042] = { "FLAG_HIDDEN_ITEM_ROUTE12_HYPER_POTION", "HIDDEN_ITEM_ROUTE12_HYPER_POTION" }, + [1043] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2B", "HIDDEN_ITEM_UNUSED_0x2B" }, + [1044] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2C", "HIDDEN_ITEM_UNUSED_0x2C" }, + [1045] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2D", "HIDDEN_ITEM_UNUSED_0x2D" }, + [1046] = { "FLAG_HIDDEN_ITEM_UNUSED_0x2E", "HIDDEN_ITEM_UNUSED_0x2E" }, + [1047] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_PP_UP", "HIDDEN_ITEM_CELADON_CITY_PP_UP" }, + [1048] = { "FLAG_HIDDEN_ITEM_VERMILION_CITY_MAX_ETHER", "HIDDEN_ITEM_VERMILION_CITY_MAX_ETHER" }, + [1049] = { "FLAG_HIDDEN_ITEM_CERULEAN_CITY_RARE_CANDY", "HIDDEN_ITEM_CERULEAN_CITY_RARE_CANDY" }, + [1050] = { "FLAG_HIDDEN_ITEM_ROUTE4_GREAT_BALL", "HIDDEN_ITEM_ROUTE4_GREAT_BALL" }, + [1051] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS" }, + [1052] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_2", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_2" }, + [1053] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_3", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_3" }, + [1054] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_4", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_4" }, + [1055] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_5", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_5" }, + [1056] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_6", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_6" }, + [1057] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_7", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_7" }, + [1058] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_8", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_8" }, + [1059] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_9", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_9" }, + [1060] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_10", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_10" }, + [1061] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_11", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_11" }, + [1062] = { "FLAG_HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_12", "HIDDEN_ITEM_CELADON_CITY_GAME_CORNER_COINS_12" }, + [1063] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_CHERI_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_CHERI_BERRY" }, + [1064] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_4", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_4" }, + [1065] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE" }, + [1066] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_2", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_2" }, + [1067] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_3", "HIDDEN_ITEM_SEVEN_ISLAND_TANOBY_RUINS_HEART_SCALE_3" }, + [1068] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NEST_BALL", "HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NEST_BALL" }, + [1069] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NET_BALL", "HIDDEN_ITEM_FIVE_ISLAND_ROCKET_WAREHOUSE_NET_BALL" }, + [1070] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_POTION", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_POTION" }, + [1071] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ANTIDOTE", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ANTIDOTE" }, + [1072] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_PARALYZE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_PARALYZE_HEAL" }, + [1073] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_AWAKENING", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_AWAKENING" }, + [1074] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_BURN_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_BURN_HEAL" }, + [1075] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ICE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ICE_HEAL" }, + [1076] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ETHER", "HIDDEN_ITEM_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL_ETHER" }, + [1077] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_POTION", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_POTION" }, + [1078] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ANTIDOTE", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ANTIDOTE" }, + [1079] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_PARALYZE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_PARALYZE_HEAL" }, + [1080] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_AWAKENING", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_AWAKENING" }, + [1081] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_BURN_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_BURN_HEAL" }, + [1082] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ICE_HEAL", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ICE_HEAL" }, + [1083] = { "FLAG_HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ETHER", "HIDDEN_ITEM_UNDERGROUND_PATH_EAST_WEST_TUNNEL_ETHER" }, + [1084] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM" }, + [1085] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_2" }, + [1086] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3", "HIDDEN_ITEM_MT_MOON_B1F_TINY_MUSHROOM_3" }, + [1087] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM" }, + [1088] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_2", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_2" }, + [1089] = { "FLAG_HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_3", "HIDDEN_ITEM_MT_MOON_B1F_BIG_MUSHROOM_3" }, + [1090] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAZZ_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAZZ_BERRY" }, + [1091] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_BLUK_BERRY" }, + [1092] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_NANAB_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_NANAB_BERRY" }, + [1093] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_WEPEAR_BERRY" }, + [1094] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ORAN_BERRY" }, + [1095] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHERI_BERRY" }, + [1096] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHESTO_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_CHESTO_BERRY" }, + [1097] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PECHA_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PECHA_BERRY" }, + [1098] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAWST_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_RAWST_BERRY" }, + [1099] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_ASPEAR_BERRY" }, + [1100] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PERSIM_BERRY" }, + [1101] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_PINAP_BERRY" }, + [1102] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_LUM_BERRY", "HIDDEN_ITEM_THREE_ISLAND_BERRY_FOREST_LUM_BERRY" }, + [1103] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST" }, + [1104] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STARDUST_2" }, + [1105] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL" }, + [1106] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_PEARL_2" }, + [1107] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL" }, + [1108] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_ULTRA_BALL_2" }, + [1109] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_STAR_PIECE" }, + [1110] = { "FLAG_HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL", "HIDDEN_ITEM_ONE_ISLAND_TREASURE_BEACH_BIG_PEARL" }, + [1111] = { "FLAG_HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_RARE_CANDY", "HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_RARE_CANDY" }, + [1112] = { "FLAG_HIDDEN_ITEM_PEWTER_CITY_POKE_BALL", "HIDDEN_ITEM_PEWTER_CITY_POKE_BALL" }, + [1113] = { "FLAG_HIDDEN_ITEM_ROUTE3_ORAN_BERRY", "HIDDEN_ITEM_ROUTE3_ORAN_BERRY" }, + [1114] = { "FLAG_HIDDEN_ITEM_ROUTE4_PERSIM_BERRY", "HIDDEN_ITEM_ROUTE4_PERSIM_BERRY" }, + [1115] = { "FLAG_HIDDEN_ITEM_ROUTE24_PECHA_BERRY", "HIDDEN_ITEM_ROUTE24_PECHA_BERRY" }, + [1116] = { "FLAG_HIDDEN_ITEM_ROUTE25_ORAN_BERRY", "HIDDEN_ITEM_ROUTE25_ORAN_BERRY" }, + [1117] = { "FLAG_HIDDEN_ITEM_ROUTE25_BLUK_BERRY", "HIDDEN_ITEM_ROUTE25_BLUK_BERRY" }, + [1118] = { "FLAG_HIDDEN_ITEM_ROUTE6_SITRUS_BERRY", "HIDDEN_ITEM_ROUTE6_SITRUS_BERRY" }, + [1119] = { "FLAG_HIDDEN_ITEM_ROUTE6_RARE_CANDY", "HIDDEN_ITEM_ROUTE6_RARE_CANDY" }, + [1120] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_PECHA_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_PECHA_BERRY" }, + [1121] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_CHERI_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_CHERI_BERRY" }, + [1122] = { "FLAG_HIDDEN_ITEM_SSANNE_KITCHEN_CHESTO_BERRY", "HIDDEN_ITEM_SSANNE_KITCHEN_CHESTO_BERRY" }, + [1123] = { "FLAG_HIDDEN_ITEM_ROUTE9_RARE_CANDY", "HIDDEN_ITEM_ROUTE9_RARE_CANDY" }, + [1124] = { "FLAG_HIDDEN_ITEM_UNUSED_0x7C", "HIDDEN_ITEM_UNUSED_0x7C" }, + [1125] = { "FLAG_HIDDEN_ITEM_ROUTE10_PERSIM_BERRY", "HIDDEN_ITEM_ROUTE10_PERSIM_BERRY" }, + [1126] = { "FLAG_HIDDEN_ITEM_ROUTE10_CHERI_BERRY", "HIDDEN_ITEM_ROUTE10_CHERI_BERRY" }, + [1127] = { "FLAG_HIDDEN_ITEM_ROUTE8_RAWST_BERRY", "HIDDEN_ITEM_ROUTE8_RAWST_BERRY" }, + [1128] = { "FLAG_HIDDEN_ITEM_ROUTE8_LUM_BERRY", "HIDDEN_ITEM_ROUTE8_LUM_BERRY" }, + [1129] = { "FLAG_HIDDEN_ITEM_ROUTE8_LEPPA_BERRY", "HIDDEN_ITEM_ROUTE8_LEPPA_BERRY" }, + [1130] = { "FLAG_HIDDEN_ITEM_ROUTE12_RARE_CANDY", "HIDDEN_ITEM_ROUTE12_RARE_CANDY" }, + [1131] = { "FLAG_HIDDEN_ITEM_ROUTE12_LEFTOVERS", "HIDDEN_ITEM_ROUTE12_LEFTOVERS" }, + [1132] = { "FLAG_HIDDEN_ITEM_ROUTE16_LEFTOVERS", "HIDDEN_ITEM_ROUTE16_LEFTOVERS" }, + [1133] = { "FLAG_HIDDEN_ITEM_FUCHSIA_CITY_MAX_REVIVE", "HIDDEN_ITEM_FUCHSIA_CITY_MAX_REVIVE" }, + [1134] = { "FLAG_HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NET_BALL", "HIDDEN_ITEM_ROCKET_HIDEOUT_B4F_NET_BALL" }, + [1135] = { "FLAG_HIDDEN_ITEM_SILPH_CO_2F_ULTRA_BALL", "HIDDEN_ITEM_SILPH_CO_2F_ULTRA_BALL" }, + [1136] = { "FLAG_HIDDEN_ITEM_SILPH_CO_3F_PROTEIN", "HIDDEN_ITEM_SILPH_CO_3F_PROTEIN" }, + [1137] = { "FLAG_HIDDEN_ITEM_SILPH_CO_4F_IRON", "HIDDEN_ITEM_SILPH_CO_4F_IRON" }, + [1138] = { "FLAG_HIDDEN_ITEM_SILPH_CO_5F_PP_UP", "HIDDEN_ITEM_SILPH_CO_5F_PP_UP" }, + [1139] = { "FLAG_HIDDEN_ITEM_SILPH_CO_6F_CARBOS", "HIDDEN_ITEM_SILPH_CO_6F_CARBOS" }, + [1140] = { "FLAG_HIDDEN_ITEM_SILPH_CO_7F_ZINC", "HIDDEN_ITEM_SILPH_CO_7F_ZINC" }, + [1141] = { "FLAG_HIDDEN_ITEM_SILPH_CO_8F_NUGGET", "HIDDEN_ITEM_SILPH_CO_8F_NUGGET" }, + [1142] = { "FLAG_HIDDEN_ITEM_SILPH_CO_9F_CALCIUM", "HIDDEN_ITEM_SILPH_CO_9F_CALCIUM" }, + [1143] = { "FLAG_HIDDEN_ITEM_SILPH_CO_10F_HP_UP", "HIDDEN_ITEM_SILPH_CO_10F_HP_UP" }, + [1144] = { "FLAG_HIDDEN_ITEM_SILPH_CO_11F_REVIVE", "HIDDEN_ITEM_SILPH_CO_11F_REVIVE" }, + [1145] = { "FLAG_HIDDEN_ITEM_ROUTE23_LUM_BERRY", "HIDDEN_ITEM_ROUTE23_LUM_BERRY" }, + [1146] = { "FLAG_HIDDEN_ITEM_ROUTE23_SITRUS_BERRY", "HIDDEN_ITEM_ROUTE23_SITRUS_BERRY" }, + [1147] = { "FLAG_HIDDEN_ITEM_ROUTE23_ASPEAR_BERRY", "HIDDEN_ITEM_ROUTE23_ASPEAR_BERRY" }, + [1148] = { "FLAG_HIDDEN_ITEM_ROUTE23_LEPPA_BERRY", "HIDDEN_ITEM_ROUTE23_LEPPA_BERRY" }, + [1149] = { "FLAG_HIDDEN_ITEM_ROUTE14_ZINC", "HIDDEN_ITEM_ROUTE14_ZINC" }, + [1150] = { "FLAG_HIDDEN_ITEM_ROUTE9_CHESTO_BERRY", "HIDDEN_ITEM_ROUTE9_CHESTO_BERRY" }, + [1151] = { "FLAG_HIDDEN_ITEM_ROUTE10_NANAB_BERRY", "HIDDEN_ITEM_ROUTE10_NANAB_BERRY" }, + [1152] = { "FLAG_HIDDEN_ITEM_ROUTE7_WEPEAR_BERRY", "HIDDEN_ITEM_ROUTE7_WEPEAR_BERRY" }, + [1153] = { "FLAG_HIDDEN_ITEM_ROUTE20_STARDUST", "HIDDEN_ITEM_ROUTE20_STARDUST" }, + [1154] = { "FLAG_HIDDEN_ITEM_ROUTE21_NORTH_PEARL", "HIDDEN_ITEM_ROUTE21_NORTH_PEARL" }, + [1155] = { "FLAG_HIDDEN_ITEM_ROUTE23_MAX_ELIXIR", "HIDDEN_ITEM_ROUTE23_MAX_ELIXIR" }, + [1156] = { "FLAG_HIDDEN_ITEM_ROUTE4_RAZZ_BERRY", "HIDDEN_ITEM_ROUTE4_RAZZ_BERRY" }, + [1157] = { "FLAG_HIDDEN_ITEM_ROUTE14_PINAP_BERRY", "HIDDEN_ITEM_ROUTE14_PINAP_BERRY" }, + [1158] = { "FLAG_HIDDEN_ITEM_MT_EMBER_EXTERIOR_FIRE_STONE", "HIDDEN_ITEM_MT_EMBER_EXTERIOR_FIRE_STONE" }, + [1159] = { "FLAG_HIDDEN_ITEM_POKEMON_TOWER_7F_SOOTHE_BELL", "HIDDEN_ITEM_POKEMON_TOWER_7F_SOOTHE_BELL" }, + [1160] = { "FLAG_HIDDEN_ITEM_NAVEL_ROCK_SUMMIT_SACRED_ASH", "HIDDEN_ITEM_NAVEL_ROCK_SUMMIT_SACRED_ASH" }, + [1161] = { "FLAG_HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_PP_MAX", "HIDDEN_ITEM_TWO_ISLAND_CAPE_BRINK_PP_MAX" }, + [1162] = { "FLAG_HIDDEN_ITEM_MT_EMBER_EXTERIOR_ULTRA_BALL", "HIDDEN_ITEM_MT_EMBER_EXTERIOR_ULTRA_BALL" }, + [1163] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_DUNSPARCE_TUNNEL_NUGGET", "HIDDEN_ITEM_THREE_ISLAND_DUNSPARCE_TUNNEL_NUGGET" }, + [1164] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_PP_UP", "HIDDEN_ITEM_THREE_ISLAND_PP_UP" }, + [1165] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_MAX_REPEL", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_MAX_REPEL" }, + [1166] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_PEARL", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_PEARL" }, + [1167] = { "FLAG_HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_STARDUST", "HIDDEN_ITEM_THREE_ISLAND_BOND_BRIDGE_STARDUST" }, + [1168] = { "FLAG_HIDDEN_ITEM_FOUR_ISLAND_PEARL", "HIDDEN_ITEM_FOUR_ISLAND_PEARL" }, + [1169] = { "FLAG_HIDDEN_ITEM_FOUR_ISLAND_ULTRA_BALL", "HIDDEN_ITEM_FOUR_ISLAND_ULTRA_BALL" }, + [1170] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BIG_PEARL", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BIG_PEARL" }, + [1171] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_RAZZ_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_RAZZ_BERRY" }, + [1172] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_SITRUS_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_SITRUS_BERRY" }, + [1173] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BLUK_BERRY", "HIDDEN_ITEM_FIVE_ISLAND_MEMORIAL_PILLAR_BLUK_BERRY" }, + [1174] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_NEST_BALL", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_NEST_BALL" }, + [1175] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST" }, + [1176] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STAR_PIECE", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STAR_PIECE" }, + [1177] = { "FLAG_HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST_2", "HIDDEN_ITEM_FIVE_ISLAND_RESORT_GORGEOUS_STARDUST_2" }, + [1178] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_STAR_PIECE", "HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_STAR_PIECE" }, + [1179] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_NET_BALL", "HIDDEN_ITEM_SIX_ISLAND_OUTCAST_ISLAND_NET_BALL" }, + [1180] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_GREEN_PATH_ULTRA_BALL", "HIDDEN_ITEM_SIX_ISLAND_GREEN_PATH_ULTRA_BALL" }, + [1181] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ASPEAR_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ASPEAR_BERRY" }, + [1182] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ORAN_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_ORAN_BERRY" }, + [1183] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_PINAP_BERRY", "HIDDEN_ITEM_SIX_ISLAND_WATER_PATH_PINAP_BERRY" }, + [1184] = { "FLAG_HIDDEN_ITEM_SIX_ISLAND_LEPPA_BERRY", "HIDDEN_ITEM_SIX_ISLAND_LEPPA_BERRY" }, + [1185] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_BIG_PEARL", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_BIG_PEARL" }, + [1186] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_PEARL", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_PEARL" }, + [1187] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_NANAB_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_TRAINER_TOWER_NANAB_BERRY" }, + [1188] = { "FLAG_HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE_RAWST_BERRY", "HIDDEN_ITEM_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE_RAWST_BERRY" }, + [1189] = { "FLAG_HIDDEN_ITEM_VIRIDIAN_CITY_GYM_MACHO_BRACE", "HIDDEN_ITEM_VIRIDIAN_CITY_GYM_MACHO_BRACE" }, + [1190] = { "FLAG_HIDDEN_ITEM_SSANNE_EXTERIOR_LAVA_COOKIE", "HIDDEN_ITEM_SSANNE_EXTERIOR_LAVA_COOKIE" }, + [1191] = { "FLAG_UNUSED_0x4A7", "UNUSED_0x4A7" }, + [1192] = { "FLAG_UNUSED_0x4A8", "UNUSED_0x4A8" }, + [1193] = { "FLAG_UNUSED_0x4A9", "UNUSED_0x4A9" }, + [1194] = { "FLAG_UNUSED_0x4AA", "UNUSED_0x4AA" }, + [1195] = { "FLAG_UNUSED_0x4AB", "UNUSED_0x4AB" }, + [1196] = { "FLAG_UNUSED_0x4AC", "UNUSED_0x4AC" }, + [1197] = { "FLAG_UNUSED_0x4AD", "UNUSED_0x4AD" }, + [1198] = { "FLAG_UNUSED_0x4AE", "UNUSED_0x4AE" }, + [1199] = { "FLAG_UNUSED_0x4AF", "UNUSED_0x4AF" }, + [1200] = { "FLAG_DEFEATED_BROCK", "DEFEATED_BROCK" }, + [1201] = { "FLAG_DEFEATED_MISTY", "DEFEATED_MISTY" }, + [1202] = { "FLAG_DEFEATED_LT_SURGE", "DEFEATED_LT_SURGE" }, + [1203] = { "FLAG_DEFEATED_ERIKA", "DEFEATED_ERIKA" }, + [1204] = { "FLAG_DEFEATED_KOGA", "DEFEATED_KOGA" }, + [1205] = { "FLAG_DEFEATED_SABRINA", "DEFEATED_SABRINA" }, + [1206] = { "FLAG_DEFEATED_BLAINE", "DEFEATED_BLAINE" }, + [1207] = { "FLAG_DEFEATED_LEADER_GIOVANNI", "DEFEATED_LEADER_GIOVANNI" }, + [1208] = { "FLAG_DEFEATED_LORELEI", "DEFEATED_LORELEI" }, + [1209] = { "FLAG_DEFEATED_BRUNO", "DEFEATED_BRUNO" }, + [1210] = { "FLAG_DEFEATED_AGATHA", "DEFEATED_AGATHA" }, + [1211] = { "FLAG_DEFEATED_LANCE", "DEFEATED_LANCE" }, + [1212] = { "FLAG_DEFEATED_CHAMP", "DEFEATED_CHAMP" }, + [1213] = { "FLAG_0x4BD", "0x4BD" }, + [1214] = { "FLAG_0x4BE", "0x4BE" }, + [1215] = { "FLAG_0x4BF", "0x4BF" }, + [1216] = { "FLAG_0x4C0", "0x4C0" }, + [1217] = { "FLAG_0x4C1", "0x4C1" }, + [1218] = { "FLAG_0x4C2", "0x4C2" }, + [1219] = { "FLAG_0x4C3", "0x4C3" }, + [1220] = { "FLAG_0x4C4", "0x4C4" }, + [1221] = { "FLAG_0x4C5", "0x4C5" }, + [1222] = { "FLAG_0x4C6", "0x4C6" }, + [1223] = { "FLAG_0x4C7", "0x4C7" }, + [1224] = { "FLAG_0x4C8", "0x4C8" }, + [1225] = { "FLAG_0x4C9", "0x4C9" }, + [1226] = { "FLAG_0x4CA", "0x4CA" }, + [1227] = { "FLAG_0x4CB", "0x4CB" }, + [1228] = { "FLAG_0x4CC", "0x4CC" }, + [1229] = { "FLAG_0x4CD", "0x4CD" }, + [1230] = { "FLAG_0x4CE", "0x4CE" }, + [1231] = { "FLAG_0x4CF", "0x4CF" }, + [1232] = { "FLAG_0x4D0", "0x4D0" }, + [1233] = { "FLAG_0x4D1", "0x4D1" }, + [1234] = { "FLAG_0x4D2", "0x4D2" }, + [1235] = { "FLAG_0x4D3", "0x4D3" }, + [1236] = { "FLAG_0x4D4", "0x4D4" }, + [1237] = { "FLAG_0x4D5", "0x4D5" }, + [1238] = { "FLAG_0x4D6", "0x4D6" }, + [1239] = { "FLAG_0x4D7", "0x4D7" }, + [1240] = { "FLAG_0x4D8", "0x4D8" }, + [1241] = { "FLAG_0x4D9", "0x4D9" }, + [1242] = { "FLAG_0x4DA", "0x4DA" }, + [1243] = { "FLAG_0x4DB", "0x4DB" }, + [1244] = { "FLAG_0x4DC", "0x4DC" }, + [1245] = { "FLAG_0x4DD", "0x4DD" }, + [1246] = { "FLAG_0x4DE", "0x4DE" }, + [1247] = { "FLAG_0x4DF", "0x4DF" }, + [1248] = { "FLAG_0x4E0", "0x4E0" }, + [1249] = { "FLAG_0x4E1", "0x4E1" }, + [1250] = { "FLAG_0x4E2", "0x4E2" }, + [1251] = { "FLAG_0x4E3", "0x4E3" }, + [1252] = { "FLAG_0x4E4", "0x4E4" }, + [1253] = { "FLAG_0x4E5", "0x4E5" }, + [1254] = { "FLAG_0x4E6", "0x4E6" }, + [1255] = { "FLAG_0x4E7", "0x4E7" }, + [1256] = { "FLAG_0x4E8", "0x4E8" }, + [1257] = { "FLAG_0x4E9", "0x4E9" }, + [1258] = { "FLAG_0x4EA", "0x4EA" }, + [1259] = { "FLAG_0x4EB", "0x4EB" }, + [1260] = { "FLAG_0x4EC", "0x4EC" }, + [1261] = { "FLAG_0x4ED", "0x4ED" }, + [1262] = { "FLAG_0x4EE", "0x4EE" }, + [1263] = { "FLAG_0x4EF", "0x4EF" }, + [1264] = { "FLAG_0x4F0", "0x4F0" }, + [1265] = { "FLAG_0x4F1", "0x4F1" }, + [1266] = { "FLAG_0x4F2", "0x4F2" }, + [1267] = { "FLAG_0x4F3", "0x4F3" }, + [1268] = { "FLAG_0x4F4", "0x4F4" }, + [1269] = { "FLAG_0x4F5", "0x4F5" }, + [1270] = { "FLAG_0x4F6", "0x4F6" }, + [1271] = { "FLAG_0x4F7", "0x4F7" }, + [1272] = { "FLAG_0x4F8", "0x4F8" }, + [1273] = { "FLAG_0x4F9", "0x4F9" }, + [1274] = { "FLAG_0x4FA", "0x4FA" }, + [1275] = { "FLAG_0x4FB", "0x4FB" }, + [1276] = { "FLAG_0x4FC", "0x4FC" }, + [1277] = { "FLAG_0x4FD", "0x4FD" }, + [1278] = { "FLAG_0x4FE", "0x4FE" }, + [1279] = { "FLAG_0x4FF", "0x4FF" }, + [1280] = { false, "TRAINER_FLAGS_START" }, + [2047] = { false, "TRAINER_FLAGS_END" }, + [2048] = { "FLAG_SYS_SAFARI_MODE", "SYS_FLAGS", "SYS_SAFARI_MODE" }, + [2049] = { "FLAG_SYS_VS_SEEKER_CHARGING", "SYS_VS_SEEKER_CHARGING" }, + [2050] = { "FLAG_SYS_CRUISE_MODE", "SYS_CRUISE_MODE" }, + [2051] = { "FLAG_SYS_WHITE_FLUTE_ACTIVE", "SYS_WHITE_FLUTE_ACTIVE" }, + [2052] = { "FLAG_SYS_BLACK_FLUTE_ACTIVE", "SYS_BLACK_FLUTE_ACTIVE" }, + [2053] = { "FLAG_SYS_USE_STRENGTH", "SYS_USE_STRENGTH" }, + [2054] = { "FLAG_SYS_FLASH_ACTIVE", "SYS_FLASH_ACTIVE" }, + [2055] = { "FLAG_SYS_SPECIAL_WILD_BATTLE", "SYS_SPECIAL_WILD_BATTLE" }, + [2056] = { "FLAG_SYS_QL_DEPARTED", "SYS_QL_DEPARTED" }, + [2057] = { "FLAG_0x809", "0x809" }, + [2058] = { "FLAG_0x80A", "0x80A" }, + [2059] = { "FLAG_0x80B", "0x80B" }, + [2060] = { "FLAG_0x80C", "0x80C" }, + [2061] = { "FLAG_0x80D", "0x80D" }, + [2062] = { "FLAG_0x80E", "0x80E" }, + [2063] = { "FLAG_0x80F", "0x80F" }, + [2064] = { "FLAG_0x810", "0x810" }, + [2065] = { "FLAG_0x811", "0x811" }, + [2066] = { "FLAG_0x812", "0x812" }, + [2067] = { "FLAG_0x813", "0x813" }, + [2068] = { "FLAG_0x814", "0x814" }, + [2069] = { "FLAG_0x815", "0x815" }, + [2070] = { "FLAG_0x816", "0x816" }, + [2071] = { "FLAG_0x817", "0x817" }, + [2072] = { "FLAG_0x818", "0x818" }, + [2073] = { "FLAG_0x819", "0x819" }, + [2074] = { "FLAG_0x81A", "0x81A" }, + [2075] = { "FLAG_0x81B", "0x81B" }, + [2076] = { "FLAG_0x81C", "0x81C" }, + [2077] = { "FLAG_0x81D", "0x81D" }, + [2078] = { "FLAG_0x81E", "0x81E" }, + [2079] = { "FLAG_0x81F", "0x81F" }, + [2080] = { "FLAG_BADGE01_GET", "BADGE01_GET", "PERMA_SYS_FLAGS_START" }, + [2081] = { "FLAG_BADGE02_GET", "BADGE02_GET" }, + [2082] = { "FLAG_BADGE03_GET", "BADGE03_GET" }, + [2083] = { "FLAG_BADGE04_GET", "BADGE04_GET" }, + [2084] = { "FLAG_BADGE05_GET", "BADGE05_GET" }, + [2085] = { "FLAG_BADGE06_GET", "BADGE06_GET" }, + [2086] = { "FLAG_BADGE07_GET", "BADGE07_GET" }, + [2087] = { "FLAG_BADGE08_GET", "BADGE08_GET" }, + [2088] = { "FLAG_SYS_POKEMON_GET", "SYS_POKEMON_GET" }, + [2089] = { "FLAG_SYS_POKEDEX_GET", "SYS_POKEDEX_GET" }, + [2090] = { "FLAG_0x82A", "0x82A" }, + [2091] = { "FLAG_0x82B", "0x82B" }, + [2092] = { "FLAG_SYS_GAME_CLEAR", "SYS_GAME_CLEAR" }, + [2093] = { "FLAG_SYS_SET_TRAINER_CARD_PROFILE", "SYS_SET_TRAINER_CARD_PROFILE" }, + [2094] = { "FLAG_0x82E", "0x82E" }, + [2095] = { "FLAG_SYS_B_DASH", "SYS_B_DASH" }, + [2096] = { "FLAG_SYS_ON_CYCLING_ROAD", "SYS_ON_CYCLING_ROAD" }, + [2097] = { "FLAG_0x831", "0x831" }, + [2098] = { "FLAG_0x832", "0x832" }, + [2099] = { "FLAG_0x833", "0x833" }, + [2100] = { "FLAG_SYS_NOT_SOMEONES_PC", "SYS_NOT_SOMEONES_PC" }, + [2101] = { "FLAG_0x835", "0x835" }, + [2102] = { "FLAG_0x836", "0x836" }, + [2103] = { "FLAG_SYS_RESET_RTC_ENABLE", "SYS_RESET_RTC_ENABLE" }, + [2104] = { "FLAG_0x838", "0x838" }, + [2105] = { "FLAG_SYS_MYSTERY_GIFT_ENABLED", "SYS_MYSTERY_GIFT_ENABLED" }, + [2106] = { "FLAG_0x83A", "0x83A" }, + [2107] = { "FLAG_SYS_RIBBON_GET", "SYS_RIBBON_GET" }, + [2108] = { "FLAG_SYS_SAW_HELP_SYSTEM_INTRO", "SYS_SAW_HELP_SYSTEM_INTRO" }, + [2109] = { "FLAG_0x83D", "0x83D" }, + [2110] = { "FLAG_OPENED_START_MENU", "OPENED_START_MENU" }, + [2111] = { "FLAG_0x83F", "0x83F" }, + [2112] = { "FLAG_SYS_NATIONAL_DEX", "SYS_NATIONAL_DEX" }, + [2113] = { "FLAG_SYS_PC_STORAGE_DISABLED", "SYS_PC_STORAGE_DISABLED" }, + [2114] = { "FLAG_SYS_INFORMED_OF_LOCAL_WIRELESS_PLAYER", "SYS_INFORMED_OF_LOCAL_WIRELESS_PLAYER" }, + [2115] = { "FLAG_SHOWN_BOX_WAS_FULL_MESSAGE", "SHOWN_BOX_WAS_FULL_MESSAGE" }, + [2116] = { "FLAG_SYS_CAN_LINK_WITH_RS", "SYS_CAN_LINK_WITH_RS" }, + [2117] = { "FLAG_SYS_SEVII_MAP_123", "SYS_SEVII_MAP_123" }, + [2118] = { "FLAG_SYS_SEVII_MAP_4567", "SYS_SEVII_MAP_4567" }, + [2119] = { "FLAG_SYS_GOT_BERRY_POUCH", "SYS_GOT_BERRY_POUCH" }, + [2120] = { "FLAG_SYS_DEOXYS_AWAKENED", "SYS_DEOXYS_AWAKENED" }, + [2121] = { "FLAG_SYS_UNLOCKED_TANOBY_RUINS", "SYS_UNLOCKED_TANOBY_RUINS" }, + [2122] = { "FLAG_ENABLE_SHIP_NAVEL_ROCK", "ENABLE_SHIP_NAVEL_ROCK" }, + [2123] = { "FLAG_ENABLE_SHIP_BIRTH_ISLAND", "ENABLE_SHIP_BIRTH_ISLAND" }, + [2192] = { "FLAG_WORLD_MAP_PALLET_TOWN", "WORLD_MAP_PALLET_TOWN" }, + [2193] = { "FLAG_WORLD_MAP_VIRIDIAN_CITY", "WORLD_MAP_VIRIDIAN_CITY" }, + [2194] = { "FLAG_WORLD_MAP_PEWTER_CITY", "WORLD_MAP_PEWTER_CITY" }, + [2195] = { "FLAG_WORLD_MAP_CERULEAN_CITY", "WORLD_MAP_CERULEAN_CITY" }, + [2196] = { "FLAG_WORLD_MAP_LAVENDER_TOWN", "WORLD_MAP_LAVENDER_TOWN" }, + [2197] = { "FLAG_WORLD_MAP_VERMILION_CITY", "WORLD_MAP_VERMILION_CITY" }, + [2198] = { "FLAG_WORLD_MAP_CELADON_CITY", "WORLD_MAP_CELADON_CITY" }, + [2199] = { "FLAG_WORLD_MAP_FUCHSIA_CITY", "WORLD_MAP_FUCHSIA_CITY" }, + [2200] = { "FLAG_WORLD_MAP_CINNABAR_ISLAND", "WORLD_MAP_CINNABAR_ISLAND" }, + [2201] = { "FLAG_WORLD_MAP_INDIGO_PLATEAU_EXTERIOR", "WORLD_MAP_INDIGO_PLATEAU_EXTERIOR" }, + [2202] = { "FLAG_WORLD_MAP_SAFFRON_CITY", "WORLD_MAP_SAFFRON_CITY" }, + [2203] = { "FLAG_WORLD_MAP_ONE_ISLAND", "WORLD_MAP_ONE_ISLAND" }, + [2204] = { "FLAG_WORLD_MAP_TWO_ISLAND", "WORLD_MAP_TWO_ISLAND" }, + [2205] = { "FLAG_WORLD_MAP_THREE_ISLAND", "WORLD_MAP_THREE_ISLAND" }, + [2206] = { "FLAG_WORLD_MAP_FOUR_ISLAND", "WORLD_MAP_FOUR_ISLAND" }, + [2207] = { "FLAG_WORLD_MAP_FIVE_ISLAND", "WORLD_MAP_FIVE_ISLAND" }, + [2208] = { "FLAG_WORLD_MAP_SEVEN_ISLAND", "WORLD_MAP_SEVEN_ISLAND" }, + [2209] = { "FLAG_WORLD_MAP_SIX_ISLAND", "WORLD_MAP_SIX_ISLAND" }, + [2210] = { "FLAG_WORLD_MAP_ROUTE4_POKEMON_CENTER_1F", "WORLD_MAP_ROUTE4_POKEMON_CENTER_1F" }, + [2211] = { "FLAG_WORLD_MAP_ROUTE10_POKEMON_CENTER_1F", "WORLD_MAP_ROUTE10_POKEMON_CENTER_1F" }, + [2212] = { "FLAG_WORLD_MAP_VIRIDIAN_FOREST", "WORLD_MAP_VIRIDIAN_FOREST" }, + [2213] = { "FLAG_WORLD_MAP_MT_MOON_1F", "WORLD_MAP_MT_MOON_1F" }, + [2214] = { "FLAG_WORLD_MAP_SSANNE_EXTERIOR", "WORLD_MAP_SSANNE_EXTERIOR" }, + [2215] = { "FLAG_WORLD_MAP_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL", "WORLD_MAP_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL" }, + [2216] = { "FLAG_WORLD_MAP_UNDERGROUND_PATH_EAST_WEST_TUNNEL", "WORLD_MAP_UNDERGROUND_PATH_EAST_WEST_TUNNEL" }, + [2217] = { "FLAG_WORLD_MAP_DIGLETTS_CAVE_B1F", "WORLD_MAP_DIGLETTS_CAVE_B1F" }, + [2218] = { "FLAG_WORLD_MAP_VICTORY_ROAD_1F", "WORLD_MAP_VICTORY_ROAD_1F" }, + [2219] = { "FLAG_WORLD_MAP_ROCKET_HIDEOUT_B1F", "WORLD_MAP_ROCKET_HIDEOUT_B1F" }, + [2220] = { "FLAG_WORLD_MAP_SILPH_CO_1F", "WORLD_MAP_SILPH_CO_1F" }, + [2221] = { "FLAG_WORLD_MAP_POKEMON_MANSION_1F", "WORLD_MAP_POKEMON_MANSION_1F" }, + [2222] = { "FLAG_WORLD_MAP_SAFARI_ZONE_CENTER", "WORLD_MAP_SAFARI_ZONE_CENTER" }, + [2223] = { "FLAG_WORLD_MAP_POKEMON_LEAGUE_LORELEIS_ROOM", "WORLD_MAP_POKEMON_LEAGUE_LORELEIS_ROOM" }, + [2224] = { "FLAG_WORLD_MAP_ROCK_TUNNEL_1F", "WORLD_MAP_ROCK_TUNNEL_1F" }, + [2225] = { "FLAG_WORLD_MAP_SEAFOAM_ISLANDS_1F", "WORLD_MAP_SEAFOAM_ISLANDS_1F" }, + [2226] = { "FLAG_WORLD_MAP_POKEMON_TOWER_1F", "WORLD_MAP_POKEMON_TOWER_1F" }, + [2227] = { "FLAG_WORLD_MAP_CERULEAN_CAVE_1F", "WORLD_MAP_CERULEAN_CAVE_1F" }, + [2228] = { "FLAG_WORLD_MAP_POWER_PLANT", "WORLD_MAP_POWER_PLANT" }, + [2229] = { "FLAG_WORLD_MAP_NAVEL_ROCK_EXTERIOR", "WORLD_MAP_NAVEL_ROCK_EXTERIOR" }, + [2230] = { "FLAG_WORLD_MAP_MT_EMBER_EXTERIOR", "WORLD_MAP_MT_EMBER_EXTERIOR" }, + [2231] = { "FLAG_WORLD_MAP_THREE_ISLAND_BERRY_FOREST", "WORLD_MAP_THREE_ISLAND_BERRY_FOREST" }, + [2232] = { "FLAG_WORLD_MAP_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE", "WORLD_MAP_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE" }, + [2233] = { "FLAG_WORLD_MAP_FIVE_ISLAND_ROCKET_WAREHOUSE", "WORLD_MAP_FIVE_ISLAND_ROCKET_WAREHOUSE" }, + [2234] = { "FLAG_WORLD_MAP_TRAINER_TOWER_LOBBY", "WORLD_MAP_TRAINER_TOWER_LOBBY" }, + [2235] = { "FLAG_WORLD_MAP_SIX_ISLAND_DOTTED_HOLE_1F", "WORLD_MAP_SIX_ISLAND_DOTTED_HOLE_1F" }, + [2236] = { "FLAG_WORLD_MAP_FIVE_ISLAND_LOST_CAVE_ENTRANCE", "WORLD_MAP_FIVE_ISLAND_LOST_CAVE_ENTRANCE" }, + [2237] = { "FLAG_WORLD_MAP_SIX_ISLAND_PATTERN_BUSH", "WORLD_MAP_SIX_ISLAND_PATTERN_BUSH" }, + [2238] = { "FLAG_WORLD_MAP_SIX_ISLAND_ALTERING_CAVE", "WORLD_MAP_SIX_ISLAND_ALTERING_CAVE" }, + [2239] = { "FLAG_WORLD_MAP_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER", "WORLD_MAP_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER" }, + [2240] = { "FLAG_WORLD_MAP_THREE_ISLAND_DUNSPARCE_TUNNEL", "WORLD_MAP_THREE_ISLAND_DUNSPARCE_TUNNEL" }, + [2241] = { "FLAG_WORLD_MAP_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY", "WORLD_MAP_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY" }, + [2242] = { "FLAG_WORLD_MAP_BIRTH_ISLAND_EXTERIOR", "WORLD_MAP_BIRTH_ISLAND_EXTERIOR" }, + [2243] = { "FLAG_0x8C3", "0x8C3" }, + [2244] = { "FLAG_0x8C4", "0x8C4" }, + [2245] = { "FLAG_0x8C5", "0x8C5" }, + [2246] = { "FLAG_0x8C6", "0x8C6" }, + [2247] = { "FLAG_0x8C7", "0x8C7" }, + [2248] = { "FLAG_0x8C8", "0x8C8" }, + [2249] = { "FLAG_0x8C9", "0x8C9" }, + [2250] = { "FLAG_0x8CA", "0x8CA" }, + [2251] = { "FLAG_0x8CB", "0x8CB" }, + [2252] = { "FLAG_0x8CC", "0x8CC" }, + [2253] = { "FLAG_0x8CD", "0x8CD" }, + [2254] = { "FLAG_0x8CE", "0x8CE" }, + [2255] = { "FLAG_0x8CF", "0x8CF" }, + [2256] = { "FLAG_0x8D0", "0x8D0" }, + [2257] = { "FLAG_0x8D1", "0x8D1" }, + [2258] = { "FLAG_0x8D2", "0x8D2" }, + [2259] = { "FLAG_0x8D3", "0x8D3" }, + [2260] = { "FLAG_0x8D4", "0x8D4" }, + [2261] = { "FLAG_0x8D5", "0x8D5" }, + [2262] = { "FLAG_0x8D6", "0x8D6" }, + [2263] = { "FLAG_0x8D7", "0x8D7" }, + [2264] = { "FLAG_0x8D8", "0x8D8" }, + [2265] = { "FLAG_0x8D9", "0x8D9" }, + [2266] = { "FLAG_0x8DA", "0x8DA" }, + [2267] = { "FLAG_0x8DB", "0x8DB" }, + [2268] = { "FLAG_0x8DC", "0x8DC" }, + [2269] = { "FLAG_0x8DD", "0x8DD" }, + [2270] = { "FLAG_0x8DE", "0x8DE" }, + [2271] = { "FLAG_0x8DF", "0x8DF" }, + [2272] = { "FLAG_0x8E0", "0x8E0" }, + [2273] = { "FLAG_0x8E1", "0x8E1" }, + [2274] = { "FLAG_0x8E2", "0x8E2" }, + [2275] = { "FLAG_0x8E3", "0x8E3" }, + [2276] = { "FLAG_0x8E4", "0x8E4" }, + [2277] = { "FLAG_0x8E5", "0x8E5" }, + [2278] = { "FLAG_0x8E6", "0x8E6" }, + [2279] = { "FLAG_0x8E7", "0x8E7" }, + [2280] = { "FLAG_0x8E8", "0x8E8" }, + [2281] = { "FLAG_0x8E9", "0x8E9" }, + [2282] = { "FLAG_0x8EA", "0x8EA" }, + [2283] = { "FLAG_0x8EB", "0x8EB" }, + [2284] = { "FLAG_0x8EC", "0x8EC" }, + [2285] = { "FLAG_0x8ED", "0x8ED" }, + [2286] = { "FLAG_0x8EE", "0x8EE" }, + [2287] = { "FLAG_0x8EF", "0x8EF" }, + [2288] = { "FLAG_0x8F0", "0x8F0" }, + [2289] = { "FLAG_0x8F1", "0x8F1" }, + [2290] = { "FLAG_0x8F2", "0x8F2" }, + [2291] = { "FLAG_0x8F3", "0x8F3" }, + [2292] = { "FLAG_0x8F4", "0x8F4" }, + [2293] = { "FLAG_0x8F5", "0x8F5" }, + [2294] = { "FLAG_0x8F6", "0x8F6" }, + [2295] = { "FLAG_0x8F7", "0x8F7" }, + [2296] = { "FLAG_0x8F8", "0x8F8" }, + [2297] = { "FLAG_0x8F9", "0x8F9" }, + [2298] = { "FLAG_0x8FA", "0x8FA" }, + [2299] = { "FLAG_0x8FB", "0x8FB" }, + [2300] = { "FLAG_0x8FC", "0x8FC" }, + [2301] = { "FLAG_0x8FD", "0x8FD" }, + [2302] = { "FLAG_0x8FE", "0x8FE" }, + [2303] = { "FLAG_0x8FF", "0x8FF" }, + }, +} diff --git a/src/mods/flag_names/red.lua b/src/mods/flag_names/red.lua new file mode 100644 index 000000000..14899c835 --- /dev/null +++ b/src/mods/flag_names/red.lua @@ -0,0 +1,514 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + records = { + [0] = { "EVENT_FOLLOWED_OAK_INTO_LAB" }, + [3] = { "EVENT_HALL_OF_FAME_DEX_RATING" }, + [6] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS" }, + [24] = { "EVENT_GOT_TOWN_MAP" }, + [25] = { "EVENT_ENTERED_BLUES_HOUSE" }, + [26] = { "EVENT_DAISY_WALKING" }, + [32] = { "EVENT_FOLLOWED_OAK_INTO_LAB_2" }, + [33] = { "EVENT_OAK_ASKED_TO_CHOOSE_MON" }, + [34] = { "EVENT_GOT_STARTER" }, + [35] = { "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, + [36] = { "EVENT_GOT_POKEBALLS_FROM_OAK" }, + [37] = { "EVENT_GOT_POKEDEX" }, + [38] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2" }, + [39] = { "EVENT_OAK_APPEARED_IN_PALLET" }, + [40] = { "EVENT_VIRIDIAN_GYM_OPEN" }, + [41] = { "EVENT_GOT_TM42" }, + [56] = { "EVENT_OAK_GOT_PARCEL" }, + [57] = { "EVENT_GOT_OAKS_PARCEL" }, + [80] = { "EVENT_GOT_TM27" }, + [81] = { "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI" }, + [82] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0" }, + [83] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1" }, + [84] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2" }, + [85] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3" }, + [86] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4" }, + [87] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5" }, + [88] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6" }, + [89] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7" }, + [104] = { "EVENT_BOUGHT_MUSEUM_TICKET" }, + [105] = { "EVENT_GOT_OLD_AMBER" }, + [114] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, + [118] = { "EVENT_GOT_TM34" }, + [119] = { "EVENT_BEAT_BROCK" }, + [152] = { "EVENT_BEAT_CERULEAN_RIVAL" }, + [167] = { "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, + [186] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0" }, + [187] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_1" }, + [190] = { "EVENT_GOT_TM11" }, + [191] = { "EVENT_BEAT_MISTY" }, + [192] = { "EVENT_GOT_BICYCLE" }, + [238] = { "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT" }, + [239] = { "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, + [241] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0" }, + [242] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1" }, + [243] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2" }, + [249] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0" }, + [250] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1" }, + [251] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2" }, + [258] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0" }, + [259] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1" }, + [260] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2" }, + [261] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3" }, + [263] = { "EVENT_IN_PURIFIED_ZONE" }, + [265] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0" }, + [266] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1" }, + [267] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2" }, + [271] = { "EVENT_BEAT_GHOST_MAROWAK" }, + [273] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0" }, + [274] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1" }, + [275] = { "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2" }, + [279] = { "EVENT_RESCUED_MR_FUJI_2" }, + [296] = { "EVENT_GOT_POKE_FLUTE" }, + [337] = { "EVENT_GOT_BIKE_VOUCHER" }, + [342] = { "EVENT_SEEL_FAN_BOAST" }, + [343] = { "EVENT_PIKACHU_FAN_BOAST" }, + [352] = { "EVENT_2ND_LOCK_OPENED" }, + [353] = { "EVENT_1ST_LOCK_OPENED" }, + [354] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0" }, + [355] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_1" }, + [356] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_2" }, + [358] = { "EVENT_GOT_TM24" }, + [359] = { "EVENT_BEAT_LT_SURGE" }, + [384] = { "EVENT_GOT_TM41" }, + [396] = { "EVENT_GOT_TM13" }, + [397] = { "EVENT_GOT_TM48" }, + [398] = { "EVENT_GOT_TM49" }, + [399] = { "EVENT_GOT_TM18" }, + [424] = { "EVENT_GOT_TM21" }, + [425] = { "EVENT_BEAT_ERIKA" }, + [426] = { "EVENT_BEAT_CELADON_GYM_TRAINER_0" }, + [427] = { "EVENT_BEAT_CELADON_GYM_TRAINER_1" }, + [428] = { "EVENT_BEAT_CELADON_GYM_TRAINER_2" }, + [429] = { "EVENT_BEAT_CELADON_GYM_TRAINER_3" }, + [430] = { "EVENT_BEAT_CELADON_GYM_TRAINER_4" }, + [431] = { "EVENT_BEAT_CELADON_GYM_TRAINER_5" }, + [432] = { "EVENT_BEAT_CELADON_GYM_TRAINER_6" }, + [440] = { "EVENT_1B8" }, + [441] = { "EVENT_FOUND_ROCKET_HIDEOUT" }, + [442] = { "EVENT_GOT_10_COINS" }, + [443] = { "EVENT_GOT_20_COINS" }, + [444] = { "EVENT_GOT_20_COINS_2" }, + [447] = { "EVENT_1BF" }, + [480] = { "EVENT_GOT_COIN_CASE" }, + [568] = { "EVENT_GOT_HM04" }, + [569] = { "EVENT_GAVE_GOLD_TEETH" }, + [590] = { "EVENT_SAFARI_GAME_OVER" }, + [591] = { "EVENT_IN_SAFARI_ZONE" }, + [600] = { "EVENT_GOT_TM06" }, + [601] = { "EVENT_BEAT_KOGA" }, + [602] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0" }, + [603] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1" }, + [604] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2" }, + [605] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3" }, + [606] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4" }, + [607] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5" }, + [632] = { "EVENT_MANSION_SWITCH_ON" }, + [649] = { "EVENT_BEAT_MANSION_1_TRAINER_0" }, + [664] = { "EVENT_GOT_TM38" }, + [665] = { "EVENT_BEAT_BLAINE" }, + [666] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_0" }, + [667] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_1" }, + [668] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_2" }, + [669] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_3" }, + [670] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_4" }, + [671] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_5" }, + [672] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_6" }, + [679] = { "EVENT_2A7" }, + [680] = { "EVENT_CINNABAR_GYM_GATE0_UNLOCKED" }, + [681] = { "EVENT_CINNABAR_GYM_GATE1_UNLOCKED" }, + [682] = { "EVENT_CINNABAR_GYM_GATE2_UNLOCKED" }, + [683] = { "EVENT_CINNABAR_GYM_GATE3_UNLOCKED" }, + [684] = { "EVENT_CINNABAR_GYM_GATE4_UNLOCKED" }, + [685] = { "EVENT_CINNABAR_GYM_GATE5_UNLOCKED" }, + [686] = { "EVENT_CINNABAR_GYM_GATE6_UNLOCKED" }, + [727] = { "EVENT_GOT_TM35" }, + [736] = { "EVENT_GAVE_FOSSIL_TO_LAB" }, + [737] = { "EVENT_LAB_STILL_REVIVING_FOSSIL" }, + [738] = { "EVENT_LAB_HANDING_OVER_FOSSIL_MON" }, + [832] = { "EVENT_GOT_TM31" }, + [848] = { "EVENT_DEFEATED_FIGHTING_DOJO" }, + [849] = { "EVENT_BEAT_KARATE_MASTER" }, + [850] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0" }, + [851] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1" }, + [852] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2" }, + [853] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3" }, + [854] = { "EVENT_GOT_HITMONLEE" }, + [855] = { "EVENT_GOT_HITMONCHAN" }, + [864] = { "EVENT_GOT_TM46" }, + [865] = { "EVENT_BEAT_SABRINA" }, + [866] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_0" }, + [867] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_1" }, + [868] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_2" }, + [869] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_3" }, + [870] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_4" }, + [871] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_5" }, + [872] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_6" }, + [919] = { "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK" }, + [944] = { "EVENT_GOT_TM29" }, + [960] = { "EVENT_GOT_POTION_SAMPLE" }, + [984] = { "EVENT_GOT_HM05" }, + [994] = { "EVENT_BEAT_ROUTE_3_TRAINER_0" }, + [995] = { "EVENT_BEAT_ROUTE_3_TRAINER_1" }, + [996] = { "EVENT_BEAT_ROUTE_3_TRAINER_2" }, + [997] = { "EVENT_BEAT_ROUTE_3_TRAINER_3" }, + [998] = { "EVENT_BEAT_ROUTE_3_TRAINER_4" }, + [999] = { "EVENT_BEAT_ROUTE_3_TRAINER_5" }, + [1000] = { "EVENT_BEAT_ROUTE_3_TRAINER_6" }, + [1001] = { "EVENT_BEAT_ROUTE_3_TRAINER_7" }, + [1010] = { "EVENT_BEAT_ROUTE_4_TRAINER_0" }, + [1023] = { "EVENT_BOUGHT_MAGIKARP" }, + [1041] = { "EVENT_BEAT_ROUTE_6_TRAINER_0" }, + [1042] = { "EVENT_BEAT_ROUTE_6_TRAINER_1" }, + [1043] = { "EVENT_BEAT_ROUTE_6_TRAINER_2" }, + [1044] = { "EVENT_BEAT_ROUTE_6_TRAINER_3" }, + [1045] = { "EVENT_BEAT_ROUTE_6_TRAINER_4" }, + [1046] = { "EVENT_BEAT_ROUTE_6_TRAINER_5" }, + [1073] = { "EVENT_BEAT_ROUTE_8_TRAINER_0" }, + [1074] = { "EVENT_BEAT_ROUTE_8_TRAINER_1" }, + [1075] = { "EVENT_BEAT_ROUTE_8_TRAINER_2" }, + [1076] = { "EVENT_BEAT_ROUTE_8_TRAINER_3" }, + [1077] = { "EVENT_BEAT_ROUTE_8_TRAINER_4" }, + [1078] = { "EVENT_BEAT_ROUTE_8_TRAINER_5" }, + [1079] = { "EVENT_BEAT_ROUTE_8_TRAINER_6" }, + [1080] = { "EVENT_BEAT_ROUTE_8_TRAINER_7" }, + [1081] = { "EVENT_BEAT_ROUTE_8_TRAINER_8" }, + [1089] = { "EVENT_BEAT_ROUTE_9_TRAINER_0" }, + [1090] = { "EVENT_BEAT_ROUTE_9_TRAINER_1" }, + [1091] = { "EVENT_BEAT_ROUTE_9_TRAINER_2" }, + [1092] = { "EVENT_BEAT_ROUTE_9_TRAINER_3" }, + [1093] = { "EVENT_BEAT_ROUTE_9_TRAINER_4" }, + [1094] = { "EVENT_BEAT_ROUTE_9_TRAINER_5" }, + [1095] = { "EVENT_BEAT_ROUTE_9_TRAINER_6" }, + [1096] = { "EVENT_BEAT_ROUTE_9_TRAINER_7" }, + [1097] = { "EVENT_BEAT_ROUTE_9_TRAINER_8" }, + [1105] = { "EVENT_BEAT_ROUTE_10_TRAINER_0" }, + [1106] = { "EVENT_BEAT_ROUTE_10_TRAINER_1" }, + [1107] = { "EVENT_BEAT_ROUTE_10_TRAINER_2" }, + [1108] = { "EVENT_BEAT_ROUTE_10_TRAINER_3" }, + [1109] = { "EVENT_BEAT_ROUTE_10_TRAINER_4" }, + [1110] = { "EVENT_BEAT_ROUTE_10_TRAINER_5" }, + [1113] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0" }, + [1114] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1" }, + [1115] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2" }, + [1116] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3" }, + [1117] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4" }, + [1118] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5" }, + [1119] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6" }, + [1121] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_0" }, + [1122] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_1" }, + [1123] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_2" }, + [1124] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_3" }, + [1125] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_4" }, + [1126] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_5" }, + [1127] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_6" }, + [1128] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_7" }, + [1129] = { "EVENT_BEAT_ZAPDOS" }, + [1137] = { "EVENT_BEAT_ROUTE_11_TRAINER_0" }, + [1138] = { "EVENT_BEAT_ROUTE_11_TRAINER_1" }, + [1139] = { "EVENT_BEAT_ROUTE_11_TRAINER_2" }, + [1140] = { "EVENT_BEAT_ROUTE_11_TRAINER_3" }, + [1141] = { "EVENT_BEAT_ROUTE_11_TRAINER_4" }, + [1142] = { "EVENT_BEAT_ROUTE_11_TRAINER_5" }, + [1143] = { "EVENT_BEAT_ROUTE_11_TRAINER_6" }, + [1144] = { "EVENT_BEAT_ROUTE_11_TRAINER_7" }, + [1145] = { "EVENT_BEAT_ROUTE_11_TRAINER_8" }, + [1146] = { "EVENT_BEAT_ROUTE_11_TRAINER_9" }, + [1151] = { "EVENT_GOT_ITEMFINDER" }, + [1152] = { "EVENT_GOT_TM39" }, + [1154] = { "EVENT_BEAT_ROUTE_12_TRAINER_0" }, + [1155] = { "EVENT_BEAT_ROUTE_12_TRAINER_1" }, + [1156] = { "EVENT_BEAT_ROUTE_12_TRAINER_2" }, + [1157] = { "EVENT_BEAT_ROUTE_12_TRAINER_3" }, + [1158] = { "EVENT_BEAT_ROUTE_12_TRAINER_4" }, + [1159] = { "EVENT_BEAT_ROUTE_12_TRAINER_5" }, + [1160] = { "EVENT_BEAT_ROUTE_12_TRAINER_6" }, + [1166] = { "EVENT_FIGHT_ROUTE12_SNORLAX" }, + [1167] = { "EVENT_BEAT_ROUTE12_SNORLAX" }, + [1169] = { "EVENT_BEAT_ROUTE_13_TRAINER_0" }, + [1170] = { "EVENT_BEAT_ROUTE_13_TRAINER_1" }, + [1171] = { "EVENT_BEAT_ROUTE_13_TRAINER_2" }, + [1172] = { "EVENT_BEAT_ROUTE_13_TRAINER_3" }, + [1173] = { "EVENT_BEAT_ROUTE_13_TRAINER_4" }, + [1174] = { "EVENT_BEAT_ROUTE_13_TRAINER_5" }, + [1175] = { "EVENT_BEAT_ROUTE_13_TRAINER_6" }, + [1176] = { "EVENT_BEAT_ROUTE_13_TRAINER_7" }, + [1177] = { "EVENT_BEAT_ROUTE_13_TRAINER_8" }, + [1178] = { "EVENT_BEAT_ROUTE_13_TRAINER_9" }, + [1185] = { "EVENT_BEAT_ROUTE_14_TRAINER_0" }, + [1186] = { "EVENT_BEAT_ROUTE_14_TRAINER_1" }, + [1187] = { "EVENT_BEAT_ROUTE_14_TRAINER_2" }, + [1188] = { "EVENT_BEAT_ROUTE_14_TRAINER_3" }, + [1189] = { "EVENT_BEAT_ROUTE_14_TRAINER_4" }, + [1190] = { "EVENT_BEAT_ROUTE_14_TRAINER_5" }, + [1191] = { "EVENT_BEAT_ROUTE_14_TRAINER_6" }, + [1192] = { "EVENT_BEAT_ROUTE_14_TRAINER_7" }, + [1193] = { "EVENT_BEAT_ROUTE_14_TRAINER_8" }, + [1194] = { "EVENT_BEAT_ROUTE_14_TRAINER_9" }, + [1200] = { "EVENT_GOT_EXP_ALL" }, + [1201] = { "EVENT_BEAT_ROUTE_15_TRAINER_0" }, + [1202] = { "EVENT_BEAT_ROUTE_15_TRAINER_1" }, + [1203] = { "EVENT_BEAT_ROUTE_15_TRAINER_2" }, + [1204] = { "EVENT_BEAT_ROUTE_15_TRAINER_3" }, + [1205] = { "EVENT_BEAT_ROUTE_15_TRAINER_4" }, + [1206] = { "EVENT_BEAT_ROUTE_15_TRAINER_5" }, + [1207] = { "EVENT_BEAT_ROUTE_15_TRAINER_6" }, + [1208] = { "EVENT_BEAT_ROUTE_15_TRAINER_7" }, + [1209] = { "EVENT_BEAT_ROUTE_15_TRAINER_8" }, + [1210] = { "EVENT_BEAT_ROUTE_15_TRAINER_9" }, + [1217] = { "EVENT_BEAT_ROUTE_16_TRAINER_0" }, + [1218] = { "EVENT_BEAT_ROUTE_16_TRAINER_1" }, + [1219] = { "EVENT_BEAT_ROUTE_16_TRAINER_2" }, + [1220] = { "EVENT_BEAT_ROUTE_16_TRAINER_3" }, + [1221] = { "EVENT_BEAT_ROUTE_16_TRAINER_4" }, + [1222] = { "EVENT_BEAT_ROUTE_16_TRAINER_5" }, + [1224] = { "EVENT_FIGHT_ROUTE16_SNORLAX" }, + [1225] = { "EVENT_BEAT_ROUTE16_SNORLAX" }, + [1230] = { "EVENT_GOT_HM02" }, + [1231] = { "EVENT_RESCUED_MR_FUJI" }, + [1233] = { "EVENT_BEAT_ROUTE_17_TRAINER_0" }, + [1234] = { "EVENT_BEAT_ROUTE_17_TRAINER_1" }, + [1235] = { "EVENT_BEAT_ROUTE_17_TRAINER_2" }, + [1236] = { "EVENT_BEAT_ROUTE_17_TRAINER_3" }, + [1237] = { "EVENT_BEAT_ROUTE_17_TRAINER_4" }, + [1238] = { "EVENT_BEAT_ROUTE_17_TRAINER_5" }, + [1239] = { "EVENT_BEAT_ROUTE_17_TRAINER_6" }, + [1240] = { "EVENT_BEAT_ROUTE_17_TRAINER_7" }, + [1241] = { "EVENT_BEAT_ROUTE_17_TRAINER_8" }, + [1242] = { "EVENT_BEAT_ROUTE_17_TRAINER_9" }, + [1249] = { "EVENT_BEAT_ROUTE_18_TRAINER_0" }, + [1250] = { "EVENT_BEAT_ROUTE_18_TRAINER_1" }, + [1251] = { "EVENT_BEAT_ROUTE_18_TRAINER_2" }, + [1265] = { "EVENT_BEAT_ROUTE_19_TRAINER_0" }, + [1266] = { "EVENT_BEAT_ROUTE_19_TRAINER_1" }, + [1267] = { "EVENT_BEAT_ROUTE_19_TRAINER_2" }, + [1268] = { "EVENT_BEAT_ROUTE_19_TRAINER_3" }, + [1269] = { "EVENT_BEAT_ROUTE_19_TRAINER_4" }, + [1270] = { "EVENT_BEAT_ROUTE_19_TRAINER_5" }, + [1271] = { "EVENT_BEAT_ROUTE_19_TRAINER_6" }, + [1272] = { "EVENT_BEAT_ROUTE_19_TRAINER_7" }, + [1273] = { "EVENT_BEAT_ROUTE_19_TRAINER_8" }, + [1274] = { "EVENT_BEAT_ROUTE_19_TRAINER_9" }, + [1280] = { "EVENT_IN_SEAFOAM_ISLANDS" }, + [1281] = { "EVENT_BEAT_ROUTE_20_TRAINER_0" }, + [1282] = { "EVENT_BEAT_ROUTE_20_TRAINER_1" }, + [1283] = { "EVENT_BEAT_ROUTE_20_TRAINER_2" }, + [1284] = { "EVENT_BEAT_ROUTE_20_TRAINER_3" }, + [1285] = { "EVENT_BEAT_ROUTE_20_TRAINER_4" }, + [1286] = { "EVENT_BEAT_ROUTE_20_TRAINER_5" }, + [1287] = { "EVENT_BEAT_ROUTE_20_TRAINER_6" }, + [1288] = { "EVENT_BEAT_ROUTE_20_TRAINER_7" }, + [1289] = { "EVENT_BEAT_ROUTE_20_TRAINER_8" }, + [1290] = { "EVENT_BEAT_ROUTE_20_TRAINER_9" }, + [1294] = { "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE" }, + [1295] = { "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE" }, + [1297] = { "EVENT_BEAT_ROUTE_21_TRAINER_0" }, + [1298] = { "EVENT_BEAT_ROUTE_21_TRAINER_1" }, + [1299] = { "EVENT_BEAT_ROUTE_21_TRAINER_2" }, + [1300] = { "EVENT_BEAT_ROUTE_21_TRAINER_3" }, + [1301] = { "EVENT_BEAT_ROUTE_21_TRAINER_4" }, + [1302] = { "EVENT_BEAT_ROUTE_21_TRAINER_5" }, + [1303] = { "EVENT_BEAT_ROUTE_21_TRAINER_6" }, + [1304] = { "EVENT_BEAT_ROUTE_21_TRAINER_7" }, + [1305] = { "EVENT_BEAT_ROUTE_21_TRAINER_8" }, + [1312] = { "EVENT_1ST_ROUTE22_RIVAL_BATTLE" }, + [1313] = { "EVENT_2ND_ROUTE22_RIVAL_BATTLE" }, + [1317] = { "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, + [1318] = { "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE" }, + [1319] = { "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" }, + [1328] = { "EVENT_PASSED_CASCADEBADGE_CHECK" }, + [1329] = { "EVENT_PASSED_THUNDERBADGE_CHECK" }, + [1330] = { "EVENT_PASSED_RAINBOWBADGE_CHECK" }, + [1331] = { "EVENT_PASSED_SOULBADGE_CHECK" }, + [1332] = { "EVENT_PASSED_MARSHBADGE_CHECK" }, + [1333] = { "EVENT_PASSED_VOLCANOBADGE_CHECK" }, + [1334] = { "EVENT_PASSED_EARTHBADGE_CHECK" }, + [1336] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1" }, + [1337] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0" }, + [1338] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1" }, + [1339] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2" }, + [1340] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3" }, + [1341] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4" }, + [1342] = { "EVENT_BEAT_MOLTRES" }, + [1343] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2" }, + [1344] = { "EVENT_GOT_NUGGET" }, + [1345] = { "EVENT_BEAT_ROUTE24_ROCKET" }, + [1346] = { "EVENT_BEAT_ROUTE_24_TRAINER_0" }, + [1347] = { "EVENT_BEAT_ROUTE_24_TRAINER_1" }, + [1348] = { "EVENT_BEAT_ROUTE_24_TRAINER_2" }, + [1349] = { "EVENT_BEAT_ROUTE_24_TRAINER_3" }, + [1350] = { "EVENT_BEAT_ROUTE_24_TRAINER_4" }, + [1351] = { "EVENT_BEAT_ROUTE_24_TRAINER_5" }, + [1353] = { "EVENT_NUGGET_REWARD_AVAILABLE" }, + [1360] = { "EVENT_MET_BILL" }, + [1361] = { "EVENT_BEAT_ROUTE_25_TRAINER_0" }, + [1362] = { "EVENT_BEAT_ROUTE_25_TRAINER_1" }, + [1363] = { "EVENT_BEAT_ROUTE_25_TRAINER_2" }, + [1364] = { "EVENT_BEAT_ROUTE_25_TRAINER_3" }, + [1365] = { "EVENT_BEAT_ROUTE_25_TRAINER_4" }, + [1366] = { "EVENT_BEAT_ROUTE_25_TRAINER_5" }, + [1367] = { "EVENT_BEAT_ROUTE_25_TRAINER_6" }, + [1368] = { "EVENT_BEAT_ROUTE_25_TRAINER_7" }, + [1369] = { "EVENT_BEAT_ROUTE_25_TRAINER_8" }, + [1371] = { "EVENT_USED_CELL_SEPARATOR_ON_BILL" }, + [1372] = { "EVENT_GOT_SS_TICKET" }, + [1373] = { "EVENT_MET_BILL_2" }, + [1374] = { "EVENT_BILL_SAID_USE_CELL_SEPARATOR" }, + [1375] = { "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING" }, + [1378] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0" }, + [1379] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1" }, + [1380] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2" }, + [1393] = { "EVENT_BEAT_MT_MOON_1_TRAINER_0" }, + [1394] = { "EVENT_BEAT_MT_MOON_1_TRAINER_1" }, + [1395] = { "EVENT_BEAT_MT_MOON_1_TRAINER_2" }, + [1396] = { "EVENT_BEAT_MT_MOON_1_TRAINER_3" }, + [1397] = { "EVENT_BEAT_MT_MOON_1_TRAINER_4" }, + [1398] = { "EVENT_BEAT_MT_MOON_1_TRAINER_5" }, + [1399] = { "EVENT_BEAT_MT_MOON_1_TRAINER_6" }, + [1401] = { "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD" }, + [1402] = { "EVENT_BEAT_MT_MOON_3_TRAINER_0" }, + [1403] = { "EVENT_BEAT_MT_MOON_3_TRAINER_1" }, + [1404] = { "EVENT_BEAT_MT_MOON_3_TRAINER_2" }, + [1405] = { "EVENT_BEAT_MT_MOON_3_TRAINER_3" }, + [1406] = { "EVENT_GOT_DOME_FOSSIL" }, + [1407] = { "EVENT_GOT_HELIX_FOSSIL" }, + [1476] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_0" }, + [1477] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_1" }, + [1504] = { "EVENT_GOT_HM01" }, + [1505] = { "EVENT_RUBBED_CAPTAINS_BACK" }, + [1506] = { "EVENT_SS_ANNE_LEFT" }, + [1507] = { "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT" }, + [1508] = { "EVENT_STARTED_WALKING_OUT_OF_DOCK" }, + [1509] = { "EVENT_WALKED_OUT_OF_DOCK" }, + [1521] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_0" }, + [1522] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_1" }, + [1523] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_2" }, + [1524] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_3" }, + [1537] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_0" }, + [1538] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_1" }, + [1539] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_2" }, + [1540] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_3" }, + [1553] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_0" }, + [1554] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_1" }, + [1555] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_2" }, + [1556] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_3" }, + [1557] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_4" }, + [1558] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_5" }, + [1632] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1" }, + [1633] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0" }, + [1634] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1" }, + [1635] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2" }, + [1636] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3" }, + [1638] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2" }, + [1649] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0" }, + [1650] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1" }, + [1651] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2" }, + [1652] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3" }, + [1653] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" }, + [1655] = { "EVENT_ENTERED_ROCKET_HIDEOUT" }, + [1663] = { "EVENT_67F" }, + [1665] = { "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0" }, + [1681] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0" }, + [1682] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1" }, + [1698] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0" }, + [1699] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" }, + [1700] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2" }, + [1701] = { "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED" }, + [1702] = { "EVENT_ROCKET_DROPPED_LIFT_KEY" }, + [1703] = { "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI" }, + [1778] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_0" }, + [1779] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_1" }, + [1780] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_2" }, + [1781] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_3" }, + [1789] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR1" }, + [1790] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR2" }, + [1791] = { "EVENT_GOT_TM36" }, + [1794] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_0" }, + [1795] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_1" }, + [1800] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR1" }, + [1801] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR2" }, + [1810] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_0" }, + [1811] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_1" }, + [1812] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_2" }, + [1816] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR1" }, + [1817] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR2" }, + [1826] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_0" }, + [1827] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_1" }, + [1828] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_2" }, + [1829] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_3" }, + [1832] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR1" }, + [1833] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR2" }, + [1834] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR3" }, + [1846] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_0" }, + [1847] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_1" }, + [1848] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_2" }, + [1855] = { "EVENT_SILPH_CO_6_UNLOCKED_DOOR" }, + [1856] = { "EVENT_BEAT_SILPH_CO_RIVAL" }, + [1861] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_0" }, + [1862] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_1" }, + [1863] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_2" }, + [1864] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_3" }, + [1868] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR1" }, + [1869] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR2" }, + [1870] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR3" }, + [1874] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_0" }, + [1875] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_1" }, + [1876] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_2" }, + [1880] = { "EVENT_SILPH_CO_8_UNLOCKED_DOOR" }, + [1890] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_0" }, + [1891] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_1" }, + [1892] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_2" }, + [1896] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR1" }, + [1897] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR2" }, + [1898] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR3" }, + [1899] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR4" }, + [1905] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_0" }, + [1906] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_1" }, + [1912] = { "EVENT_SILPH_CO_10_UNLOCKED_DOOR" }, + [1924] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_0" }, + [1925] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_1" }, + [1928] = { "EVENT_SILPH_CO_11_UNLOCKED_DOOR" }, + [1933] = { "EVENT_GOT_MASTER_BALL" }, + [1935] = { "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + [2049] = { "EVENT_BEAT_MANSION_2_TRAINER_0" }, + [2065] = { "EVENT_BEAT_MANSION_3_TRAINER_0" }, + [2066] = { "EVENT_BEAT_MANSION_3_TRAINER_1" }, + [2081] = { "EVENT_BEAT_MANSION_4_TRAINER_0" }, + [2082] = { "EVENT_BEAT_MANSION_4_TRAINER_1" }, + [2176] = { "EVENT_GOT_HM03" }, + [2241] = { "EVENT_BEAT_MEWTWO" }, + [2273] = { "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0" }, + [2278] = { "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM" }, + [2281] = { "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0" }, + [2286] = { "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM" }, + [2289] = { "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0" }, + [2294] = { "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM" }, + [2297] = { "EVENT_BEAT_LANCES_ROOM_TRAINER_0" }, + [2302] = { "EVENT_BEAT_LANCE" }, + [2303] = { "EVENT_LANCES_ROOM_LOCK_DOOR" }, + [2305] = { "EVENT_BEAT_CHAMPION_RIVAL" }, + [2321] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0" }, + [2322] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1" }, + [2327] = { "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH" }, + [2481] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0" }, + [2482] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1" }, + [2483] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2" }, + [2484] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3" }, + [2485] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4" }, + [2486] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5" }, + [2487] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6" }, + [2488] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7" }, + [2496] = { "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE" }, + [2497] = { "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE" }, + [2504] = { "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE" }, + [2505] = { "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE" }, + [2512] = { "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE" }, + [2513] = { "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE" }, + [2522] = { "EVENT_BEAT_ARTICUNO" }, + }, +} diff --git a/src/mods/flag_names/yellow.lua b/src/mods/flag_names/yellow.lua new file mode 100644 index 000000000..21cbb4acd --- /dev/null +++ b/src/mods/flag_names/yellow.lua @@ -0,0 +1,529 @@ +-- Generated flag identifiers only; no engine implementation. +-- Source: bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 +-- Regenerate with tools/flag-catalog-check/generate.py. +return { + records = { + [0] = { "EVENT_FOLLOWED_OAK_INTO_LAB" }, + [3] = { "EVENT_HALL_OF_FAME_DEX_RATING" }, + [5] = { "EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN" }, + [6] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS" }, + [24] = { "EVENT_GOT_TOWN_MAP" }, + [25] = { "EVENT_ENTERED_BLUES_HOUSE" }, + [26] = { "EVENT_DAISY_WALKING" }, + [32] = { "EVENT_FOLLOWED_OAK_INTO_LAB_2" }, + [33] = { "EVENT_OAK_ASKED_TO_CHOOSE_MON" }, + [34] = { "EVENT_GOT_STARTER" }, + [35] = { "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, + [36] = { "EVENT_GOT_POKEBALLS_FROM_OAK" }, + [37] = { "EVENT_GOT_POKEDEX" }, + [38] = { "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2" }, + [39] = { "EVENT_OAK_APPEARED_IN_PALLET" }, + [40] = { "EVENT_VIRIDIAN_GYM_OPEN" }, + [41] = { "EVENT_GOT_TM42" }, + [44] = { "EVENT_SPAWNED_OLD_MAN_1" }, + [45] = { "EVENT_COMPLETED_CATCH_TRAINING" }, + [46] = { "EVENT_COMPLETED_CATCH_TRAINING_AGAIN" }, + [47] = { "EVENT_INITIAL_CATCH_TRAINING" }, + [56] = { "EVENT_OAK_GOT_PARCEL" }, + [57] = { "EVENT_GOT_OAKS_PARCEL" }, + [80] = { "EVENT_GOT_TM27" }, + [81] = { "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI" }, + [82] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0" }, + [83] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1" }, + [84] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2" }, + [85] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3" }, + [86] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4" }, + [87] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5" }, + [88] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6" }, + [89] = { "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7" }, + [104] = { "EVENT_BOUGHT_MUSEUM_TICKET" }, + [105] = { "EVENT_GOT_OLD_AMBER" }, + [114] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, + [118] = { "EVENT_GOT_TM34" }, + [119] = { "EVENT_BEAT_BROCK" }, + [152] = { "EVENT_BEAT_CERULEAN_RIVAL" }, + [167] = { "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, + [168] = { "EVENT_GOT_BULBASAUR_IN_CERULEAN" }, + [186] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0" }, + [187] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_1" }, + [190] = { "EVENT_GOT_TM11" }, + [191] = { "EVENT_BEAT_MISTY" }, + [192] = { "EVENT_GOT_BICYCLE" }, + [238] = { "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT" }, + [239] = { "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, + [241] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0" }, + [242] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1" }, + [243] = { "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2" }, + [249] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0" }, + [250] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1" }, + [251] = { "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2" }, + [258] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0" }, + [259] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1" }, + [260] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2" }, + [261] = { "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3" }, + [263] = { "EVENT_IN_PURIFIED_ZONE" }, + [265] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0" }, + [266] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1" }, + [267] = { "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2" }, + [271] = { "EVENT_BEAT_GHOST_MAROWAK" }, + [273] = { "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" }, + [274] = { "EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT" }, + [279] = { "EVENT_RESCUED_MR_FUJI_2" }, + [296] = { "EVENT_GOT_POKE_FLUTE" }, + [327] = { "EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }, + [337] = { "EVENT_GOT_BIKE_VOUCHER" }, + [338] = { "EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER" }, + [342] = { "EVENT_SEEL_FAN_BOAST" }, + [343] = { "EVENT_PIKACHU_FAN_BOAST" }, + [352] = { "EVENT_2ND_LOCK_OPENED" }, + [353] = { "EVENT_1ST_LOCK_OPENED" }, + [354] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0" }, + [355] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_1" }, + [356] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_2" }, + [358] = { "EVENT_GOT_TM24" }, + [359] = { "EVENT_BEAT_LT_SURGE" }, + [384] = { "EVENT_GOT_TM41" }, + [396] = { "EVENT_GOT_TM13" }, + [397] = { "EVENT_GOT_TM48" }, + [398] = { "EVENT_GOT_TM49" }, + [399] = { "EVENT_GOT_TM18" }, + [424] = { "EVENT_GOT_TM21" }, + [425] = { "EVENT_BEAT_ERIKA" }, + [426] = { "EVENT_BEAT_CELADON_GYM_TRAINER_0" }, + [427] = { "EVENT_BEAT_CELADON_GYM_TRAINER_1" }, + [428] = { "EVENT_BEAT_CELADON_GYM_TRAINER_2" }, + [429] = { "EVENT_BEAT_CELADON_GYM_TRAINER_3" }, + [430] = { "EVENT_BEAT_CELADON_GYM_TRAINER_4" }, + [431] = { "EVENT_BEAT_CELADON_GYM_TRAINER_5" }, + [432] = { "EVENT_BEAT_CELADON_GYM_TRAINER_6" }, + [440] = { "EVENT_1B8" }, + [441] = { "EVENT_FOUND_ROCKET_HIDEOUT" }, + [442] = { "EVENT_GOT_10_COINS" }, + [443] = { "EVENT_GOT_20_COINS" }, + [444] = { "EVENT_GOT_20_COINS_2" }, + [447] = { "EVENT_1BF" }, + [480] = { "EVENT_GOT_COIN_CASE" }, + [568] = { "EVENT_GOT_HM04" }, + [569] = { "EVENT_GAVE_GOLD_TEETH" }, + [590] = { "EVENT_SAFARI_GAME_OVER" }, + [591] = { "EVENT_IN_SAFARI_ZONE" }, + [600] = { "EVENT_GOT_TM06" }, + [601] = { "EVENT_BEAT_KOGA" }, + [602] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0" }, + [603] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1" }, + [604] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2" }, + [605] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3" }, + [606] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4" }, + [607] = { "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5" }, + [632] = { "EVENT_MANSION_SWITCH_ON" }, + [649] = { "EVENT_BEAT_MANSION_1_TRAINER_0" }, + [664] = { "EVENT_GOT_TM38" }, + [665] = { "EVENT_BEAT_BLAINE" }, + [666] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_0" }, + [667] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_1" }, + [668] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_2" }, + [669] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_3" }, + [670] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_4" }, + [671] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_5" }, + [672] = { "EVENT_BEAT_CINNABAR_GYM_TRAINER_6" }, + [679] = { "EVENT_2A7" }, + [680] = { "EVENT_CINNABAR_GYM_GATE0_UNLOCKED" }, + [681] = { "EVENT_CINNABAR_GYM_GATE1_UNLOCKED" }, + [682] = { "EVENT_CINNABAR_GYM_GATE2_UNLOCKED" }, + [683] = { "EVENT_CINNABAR_GYM_GATE3_UNLOCKED" }, + [684] = { "EVENT_CINNABAR_GYM_GATE4_UNLOCKED" }, + [685] = { "EVENT_CINNABAR_GYM_GATE5_UNLOCKED" }, + [686] = { "EVENT_CINNABAR_GYM_GATE6_UNLOCKED" }, + [727] = { "EVENT_GOT_TM35" }, + [736] = { "EVENT_GAVE_FOSSIL_TO_LAB" }, + [737] = { "EVENT_LAB_STILL_REVIVING_FOSSIL" }, + [738] = { "EVENT_LAB_HANDING_OVER_FOSSIL_MON" }, + [832] = { "EVENT_GOT_TM31" }, + [848] = { "EVENT_DEFEATED_FIGHTING_DOJO" }, + [849] = { "EVENT_BEAT_KARATE_MASTER" }, + [850] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0" }, + [851] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1" }, + [852] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2" }, + [853] = { "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3" }, + [854] = { "EVENT_GOT_HITMONLEE" }, + [855] = { "EVENT_GOT_HITMONCHAN" }, + [864] = { "EVENT_GOT_TM46" }, + [865] = { "EVENT_BEAT_SABRINA" }, + [866] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_0" }, + [867] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_1" }, + [868] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_2" }, + [869] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_3" }, + [870] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_4" }, + [871] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_5" }, + [872] = { "EVENT_BEAT_SAFFRON_GYM_TRAINER_6" }, + [919] = { "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK" }, + [944] = { "EVENT_GOT_TM29" }, + [960] = { "EVENT_GOT_POTION_SAMPLE" }, + [984] = { "EVENT_GOT_HM05" }, + [994] = { "EVENT_BEAT_ROUTE_3_TRAINER_0" }, + [995] = { "EVENT_BEAT_ROUTE_3_TRAINER_1" }, + [996] = { "EVENT_BEAT_ROUTE_3_TRAINER_2" }, + [997] = { "EVENT_BEAT_ROUTE_3_TRAINER_3" }, + [998] = { "EVENT_BEAT_ROUTE_3_TRAINER_4" }, + [999] = { "EVENT_BEAT_ROUTE_3_TRAINER_5" }, + [1000] = { "EVENT_BEAT_ROUTE_3_TRAINER_6" }, + [1001] = { "EVENT_BEAT_ROUTE_3_TRAINER_7" }, + [1010] = { "EVENT_BEAT_ROUTE_4_TRAINER_0" }, + [1023] = { "EVENT_BOUGHT_MAGIKARP" }, + [1041] = { "EVENT_BEAT_ROUTE_6_TRAINER_0" }, + [1042] = { "EVENT_BEAT_ROUTE_6_TRAINER_1" }, + [1043] = { "EVENT_BEAT_ROUTE_6_TRAINER_2" }, + [1044] = { "EVENT_BEAT_ROUTE_6_TRAINER_3" }, + [1045] = { "EVENT_BEAT_ROUTE_6_TRAINER_4" }, + [1046] = { "EVENT_BEAT_ROUTE_6_TRAINER_5" }, + [1073] = { "EVENT_BEAT_ROUTE_8_TRAINER_0" }, + [1074] = { "EVENT_BEAT_ROUTE_8_TRAINER_1" }, + [1075] = { "EVENT_BEAT_ROUTE_8_TRAINER_2" }, + [1076] = { "EVENT_BEAT_ROUTE_8_TRAINER_3" }, + [1077] = { "EVENT_BEAT_ROUTE_8_TRAINER_4" }, + [1078] = { "EVENT_BEAT_ROUTE_8_TRAINER_5" }, + [1079] = { "EVENT_BEAT_ROUTE_8_TRAINER_6" }, + [1080] = { "EVENT_BEAT_ROUTE_8_TRAINER_7" }, + [1081] = { "EVENT_BEAT_ROUTE_8_TRAINER_8" }, + [1089] = { "EVENT_BEAT_ROUTE_9_TRAINER_0" }, + [1090] = { "EVENT_BEAT_ROUTE_9_TRAINER_1" }, + [1091] = { "EVENT_BEAT_ROUTE_9_TRAINER_2" }, + [1092] = { "EVENT_BEAT_ROUTE_9_TRAINER_3" }, + [1093] = { "EVENT_BEAT_ROUTE_9_TRAINER_4" }, + [1094] = { "EVENT_BEAT_ROUTE_9_TRAINER_5" }, + [1095] = { "EVENT_BEAT_ROUTE_9_TRAINER_6" }, + [1096] = { "EVENT_BEAT_ROUTE_9_TRAINER_7" }, + [1097] = { "EVENT_BEAT_ROUTE_9_TRAINER_8" }, + [1105] = { "EVENT_BEAT_ROUTE_10_TRAINER_0" }, + [1106] = { "EVENT_BEAT_ROUTE_10_TRAINER_1" }, + [1107] = { "EVENT_BEAT_ROUTE_10_TRAINER_2" }, + [1108] = { "EVENT_BEAT_ROUTE_10_TRAINER_3" }, + [1109] = { "EVENT_BEAT_ROUTE_10_TRAINER_4" }, + [1110] = { "EVENT_BEAT_ROUTE_10_TRAINER_5" }, + [1113] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0" }, + [1114] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1" }, + [1115] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2" }, + [1116] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3" }, + [1117] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4" }, + [1118] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5" }, + [1119] = { "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6" }, + [1121] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_0" }, + [1122] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_1" }, + [1123] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_2" }, + [1124] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_3" }, + [1125] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_4" }, + [1126] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_5" }, + [1127] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_6" }, + [1128] = { "EVENT_BEAT_POWER_PLANT_VOLTORB_7" }, + [1129] = { "EVENT_BEAT_ZAPDOS" }, + [1137] = { "EVENT_BEAT_ROUTE_11_TRAINER_0" }, + [1138] = { "EVENT_BEAT_ROUTE_11_TRAINER_1" }, + [1139] = { "EVENT_BEAT_ROUTE_11_TRAINER_2" }, + [1140] = { "EVENT_BEAT_ROUTE_11_TRAINER_3" }, + [1141] = { "EVENT_BEAT_ROUTE_11_TRAINER_4" }, + [1142] = { "EVENT_BEAT_ROUTE_11_TRAINER_5" }, + [1143] = { "EVENT_BEAT_ROUTE_11_TRAINER_6" }, + [1144] = { "EVENT_BEAT_ROUTE_11_TRAINER_7" }, + [1145] = { "EVENT_BEAT_ROUTE_11_TRAINER_8" }, + [1146] = { "EVENT_BEAT_ROUTE_11_TRAINER_9" }, + [1151] = { "EVENT_GOT_ITEMFINDER" }, + [1152] = { "EVENT_GOT_TM39" }, + [1154] = { "EVENT_BEAT_ROUTE_12_TRAINER_0" }, + [1155] = { "EVENT_BEAT_ROUTE_12_TRAINER_1" }, + [1156] = { "EVENT_BEAT_ROUTE_12_TRAINER_2" }, + [1157] = { "EVENT_BEAT_ROUTE_12_TRAINER_3" }, + [1158] = { "EVENT_BEAT_ROUTE_12_TRAINER_4" }, + [1159] = { "EVENT_BEAT_ROUTE_12_TRAINER_5" }, + [1160] = { "EVENT_BEAT_ROUTE_12_TRAINER_6" }, + [1166] = { "EVENT_FIGHT_ROUTE12_SNORLAX" }, + [1167] = { "EVENT_BEAT_ROUTE12_SNORLAX" }, + [1169] = { "EVENT_BEAT_ROUTE_13_TRAINER_0" }, + [1170] = { "EVENT_BEAT_ROUTE_13_TRAINER_1" }, + [1171] = { "EVENT_BEAT_ROUTE_13_TRAINER_2" }, + [1172] = { "EVENT_BEAT_ROUTE_13_TRAINER_3" }, + [1173] = { "EVENT_BEAT_ROUTE_13_TRAINER_4" }, + [1174] = { "EVENT_BEAT_ROUTE_13_TRAINER_5" }, + [1175] = { "EVENT_BEAT_ROUTE_13_TRAINER_6" }, + [1176] = { "EVENT_BEAT_ROUTE_13_TRAINER_7" }, + [1177] = { "EVENT_BEAT_ROUTE_13_TRAINER_8" }, + [1178] = { "EVENT_BEAT_ROUTE_13_TRAINER_9" }, + [1185] = { "EVENT_BEAT_ROUTE_14_TRAINER_0" }, + [1186] = { "EVENT_BEAT_ROUTE_14_TRAINER_1" }, + [1187] = { "EVENT_BEAT_ROUTE_14_TRAINER_2" }, + [1188] = { "EVENT_BEAT_ROUTE_14_TRAINER_3" }, + [1189] = { "EVENT_BEAT_ROUTE_14_TRAINER_4" }, + [1190] = { "EVENT_BEAT_ROUTE_14_TRAINER_5" }, + [1191] = { "EVENT_BEAT_ROUTE_14_TRAINER_6" }, + [1192] = { "EVENT_BEAT_ROUTE_14_TRAINER_7" }, + [1193] = { "EVENT_BEAT_ROUTE_14_TRAINER_8" }, + [1194] = { "EVENT_BEAT_ROUTE_14_TRAINER_9" }, + [1200] = { "EVENT_GOT_EXP_ALL" }, + [1201] = { "EVENT_BEAT_ROUTE_15_TRAINER_0" }, + [1202] = { "EVENT_BEAT_ROUTE_15_TRAINER_1" }, + [1203] = { "EVENT_BEAT_ROUTE_15_TRAINER_2" }, + [1204] = { "EVENT_BEAT_ROUTE_15_TRAINER_3" }, + [1205] = { "EVENT_BEAT_ROUTE_15_TRAINER_4" }, + [1206] = { "EVENT_BEAT_ROUTE_15_TRAINER_5" }, + [1207] = { "EVENT_BEAT_ROUTE_15_TRAINER_6" }, + [1208] = { "EVENT_BEAT_ROUTE_15_TRAINER_7" }, + [1209] = { "EVENT_BEAT_ROUTE_15_TRAINER_8" }, + [1210] = { "EVENT_BEAT_ROUTE_15_TRAINER_9" }, + [1217] = { "EVENT_BEAT_ROUTE_16_TRAINER_0" }, + [1218] = { "EVENT_BEAT_ROUTE_16_TRAINER_1" }, + [1219] = { "EVENT_BEAT_ROUTE_16_TRAINER_2" }, + [1220] = { "EVENT_BEAT_ROUTE_16_TRAINER_3" }, + [1221] = { "EVENT_BEAT_ROUTE_16_TRAINER_4" }, + [1222] = { "EVENT_BEAT_ROUTE_16_TRAINER_5" }, + [1224] = { "EVENT_FIGHT_ROUTE16_SNORLAX" }, + [1225] = { "EVENT_BEAT_ROUTE16_SNORLAX" }, + [1230] = { "EVENT_GOT_HM02" }, + [1231] = { "EVENT_RESCUED_MR_FUJI" }, + [1233] = { "EVENT_BEAT_ROUTE_17_TRAINER_0" }, + [1234] = { "EVENT_BEAT_ROUTE_17_TRAINER_1" }, + [1235] = { "EVENT_BEAT_ROUTE_17_TRAINER_2" }, + [1236] = { "EVENT_BEAT_ROUTE_17_TRAINER_3" }, + [1237] = { "EVENT_BEAT_ROUTE_17_TRAINER_4" }, + [1238] = { "EVENT_BEAT_ROUTE_17_TRAINER_5" }, + [1239] = { "EVENT_BEAT_ROUTE_17_TRAINER_6" }, + [1240] = { "EVENT_BEAT_ROUTE_17_TRAINER_7" }, + [1241] = { "EVENT_BEAT_ROUTE_17_TRAINER_8" }, + [1242] = { "EVENT_BEAT_ROUTE_17_TRAINER_9" }, + [1249] = { "EVENT_BEAT_ROUTE_18_TRAINER_0" }, + [1250] = { "EVENT_BEAT_ROUTE_18_TRAINER_1" }, + [1251] = { "EVENT_BEAT_ROUTE_18_TRAINER_2" }, + [1265] = { "EVENT_BEAT_ROUTE_19_TRAINER_0" }, + [1266] = { "EVENT_BEAT_ROUTE_19_TRAINER_1" }, + [1267] = { "EVENT_BEAT_ROUTE_19_TRAINER_2" }, + [1268] = { "EVENT_BEAT_ROUTE_19_TRAINER_3" }, + [1269] = { "EVENT_BEAT_ROUTE_19_TRAINER_4" }, + [1270] = { "EVENT_BEAT_ROUTE_19_TRAINER_5" }, + [1271] = { "EVENT_BEAT_ROUTE_19_TRAINER_6" }, + [1272] = { "EVENT_BEAT_ROUTE_19_TRAINER_7" }, + [1273] = { "EVENT_BEAT_ROUTE_19_TRAINER_8" }, + [1274] = { "EVENT_BEAT_ROUTE_19_TRAINER_9" }, + [1280] = { "EVENT_IN_SEAFOAM_ISLANDS" }, + [1281] = { "EVENT_BEAT_ROUTE_20_TRAINER_0" }, + [1282] = { "EVENT_BEAT_ROUTE_20_TRAINER_1" }, + [1283] = { "EVENT_BEAT_ROUTE_20_TRAINER_2" }, + [1284] = { "EVENT_BEAT_ROUTE_20_TRAINER_3" }, + [1285] = { "EVENT_BEAT_ROUTE_20_TRAINER_4" }, + [1286] = { "EVENT_BEAT_ROUTE_20_TRAINER_5" }, + [1287] = { "EVENT_BEAT_ROUTE_20_TRAINER_6" }, + [1288] = { "EVENT_BEAT_ROUTE_20_TRAINER_7" }, + [1289] = { "EVENT_BEAT_ROUTE_20_TRAINER_8" }, + [1290] = { "EVENT_BEAT_ROUTE_20_TRAINER_9" }, + [1294] = { "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE" }, + [1295] = { "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE" }, + [1297] = { "EVENT_BEAT_ROUTE_21_TRAINER_0" }, + [1298] = { "EVENT_BEAT_ROUTE_21_TRAINER_1" }, + [1299] = { "EVENT_BEAT_ROUTE_21_TRAINER_2" }, + [1300] = { "EVENT_BEAT_ROUTE_21_TRAINER_3" }, + [1301] = { "EVENT_BEAT_ROUTE_21_TRAINER_4" }, + [1302] = { "EVENT_BEAT_ROUTE_21_TRAINER_5" }, + [1303] = { "EVENT_BEAT_ROUTE_21_TRAINER_6" }, + [1304] = { "EVENT_BEAT_ROUTE_21_TRAINER_7" }, + [1305] = { "EVENT_BEAT_ROUTE_21_TRAINER_8" }, + [1312] = { "EVENT_1ST_ROUTE22_RIVAL_BATTLE" }, + [1313] = { "EVENT_2ND_ROUTE22_RIVAL_BATTLE" }, + [1317] = { "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, + [1318] = { "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE" }, + [1319] = { "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" }, + [1328] = { "EVENT_PASSED_CASCADEBADGE_CHECK" }, + [1329] = { "EVENT_PASSED_THUNDERBADGE_CHECK" }, + [1330] = { "EVENT_PASSED_RAINBOWBADGE_CHECK" }, + [1331] = { "EVENT_PASSED_SOULBADGE_CHECK" }, + [1332] = { "EVENT_PASSED_MARSHBADGE_CHECK" }, + [1333] = { "EVENT_PASSED_VOLCANOBADGE_CHECK" }, + [1334] = { "EVENT_PASSED_EARTHBADGE_CHECK" }, + [1336] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1" }, + [1337] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0" }, + [1338] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1" }, + [1339] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2" }, + [1340] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3" }, + [1341] = { "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4" }, + [1342] = { "EVENT_BEAT_MOLTRES" }, + [1343] = { "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2" }, + [1344] = { "EVENT_GOT_NUGGET" }, + [1345] = { "EVENT_BEAT_ROUTE24_ROCKET" }, + [1346] = { "EVENT_BEAT_ROUTE_24_TRAINER_0" }, + [1347] = { "EVENT_BEAT_ROUTE_24_TRAINER_1" }, + [1348] = { "EVENT_BEAT_ROUTE_24_TRAINER_2" }, + [1349] = { "EVENT_BEAT_ROUTE_24_TRAINER_3" }, + [1350] = { "EVENT_BEAT_ROUTE_24_TRAINER_4" }, + [1351] = { "EVENT_BEAT_ROUTE_24_TRAINER_5" }, + [1353] = { "EVENT_NUGGET_REWARD_AVAILABLE" }, + [1359] = { "EVENT_54F" }, + [1360] = { "EVENT_MET_BILL" }, + [1361] = { "EVENT_BEAT_ROUTE_25_TRAINER_0" }, + [1362] = { "EVENT_BEAT_ROUTE_25_TRAINER_1" }, + [1363] = { "EVENT_BEAT_ROUTE_25_TRAINER_2" }, + [1364] = { "EVENT_BEAT_ROUTE_25_TRAINER_3" }, + [1365] = { "EVENT_BEAT_ROUTE_25_TRAINER_4" }, + [1366] = { "EVENT_BEAT_ROUTE_25_TRAINER_5" }, + [1367] = { "EVENT_BEAT_ROUTE_25_TRAINER_6" }, + [1368] = { "EVENT_BEAT_ROUTE_25_TRAINER_7" }, + [1369] = { "EVENT_BEAT_ROUTE_25_TRAINER_8" }, + [1371] = { "EVENT_USED_CELL_SEPARATOR_ON_BILL" }, + [1372] = { "EVENT_GOT_SS_TICKET" }, + [1373] = { "EVENT_MET_BILL_2" }, + [1374] = { "EVENT_BILL_SAID_USE_CELL_SEPARATOR" }, + [1375] = { "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING" }, + [1378] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0" }, + [1379] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1" }, + [1380] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2" }, + [1381] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_3" }, + [1382] = { "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_4" }, + [1393] = { "EVENT_BEAT_MT_MOON_1_TRAINER_0" }, + [1394] = { "EVENT_BEAT_MT_MOON_1_TRAINER_1" }, + [1395] = { "EVENT_BEAT_MT_MOON_1_TRAINER_2" }, + [1396] = { "EVENT_BEAT_MT_MOON_1_TRAINER_3" }, + [1397] = { "EVENT_BEAT_MT_MOON_1_TRAINER_4" }, + [1398] = { "EVENT_BEAT_MT_MOON_1_TRAINER_5" }, + [1399] = { "EVENT_BEAT_MT_MOON_1_TRAINER_6" }, + [1400] = { "EVENT_GOT_DOME_FOSSIL" }, + [1401] = { "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD" }, + [1402] = { "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" }, + [1403] = { "EVENT_BEAT_MT_MOON_3_TRAINER_0" }, + [1404] = { "EVENT_BEAT_MT_MOON_3_TRAINER_1" }, + [1405] = { "EVENT_BEAT_MT_MOON_3_TRAINER_2" }, + [1406] = { "EVENT_57E" }, + [1407] = { "EVENT_GOT_HELIX_FOSSIL" }, + [1476] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_0" }, + [1477] = { "EVENT_BEAT_SS_ANNE_5_TRAINER_1" }, + [1504] = { "EVENT_GOT_HM01" }, + [1505] = { "EVENT_RUBBED_CAPTAINS_BACK" }, + [1506] = { "EVENT_SS_ANNE_LEFT" }, + [1507] = { "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT" }, + [1508] = { "EVENT_STARTED_WALKING_OUT_OF_DOCK" }, + [1509] = { "EVENT_WALKED_OUT_OF_DOCK" }, + [1521] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_0" }, + [1522] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_1" }, + [1523] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_2" }, + [1524] = { "EVENT_BEAT_SS_ANNE_8_TRAINER_3" }, + [1537] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_0" }, + [1538] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_1" }, + [1539] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_2" }, + [1540] = { "EVENT_BEAT_SS_ANNE_9_TRAINER_3" }, + [1553] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_0" }, + [1554] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_1" }, + [1555] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_2" }, + [1556] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_3" }, + [1557] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_4" }, + [1558] = { "EVENT_BEAT_SS_ANNE_10_TRAINER_5" }, + [1632] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1" }, + [1633] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0" }, + [1634] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1" }, + [1635] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2" }, + [1636] = { "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3" }, + [1638] = { "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2" }, + [1649] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0" }, + [1650] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1" }, + [1651] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2" }, + [1652] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3" }, + [1653] = { "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" }, + [1655] = { "EVENT_ENTERED_ROCKET_HIDEOUT" }, + [1663] = { "EVENT_67F" }, + [1665] = { "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0" }, + [1681] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0" }, + [1682] = { "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1" }, + [1696] = { "EVENT_6A0" }, + [1698] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" }, + [1699] = { "EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT" }, + [1700] = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2" }, + [1701] = { "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED" }, + [1702] = { "EVENT_ROCKET_DROPPED_LIFT_KEY" }, + [1703] = { "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI" }, + [1778] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_0" }, + [1779] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_1" }, + [1780] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_2" }, + [1781] = { "EVENT_BEAT_SILPH_CO_2F_TRAINER_3" }, + [1789] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR1" }, + [1790] = { "EVENT_SILPH_CO_2_UNLOCKED_DOOR2" }, + [1791] = { "EVENT_GOT_TM36" }, + [1794] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_0" }, + [1795] = { "EVENT_BEAT_SILPH_CO_3F_TRAINER_1" }, + [1800] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR1" }, + [1801] = { "EVENT_SILPH_CO_3_UNLOCKED_DOOR2" }, + [1810] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_0" }, + [1811] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_1" }, + [1812] = { "EVENT_BEAT_SILPH_CO_4F_TRAINER_2" }, + [1816] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR1" }, + [1817] = { "EVENT_SILPH_CO_4_UNLOCKED_DOOR2" }, + [1826] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_0" }, + [1827] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_1" }, + [1828] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_2" }, + [1829] = { "EVENT_BEAT_SILPH_CO_5F_TRAINER_3" }, + [1832] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR1" }, + [1833] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR2" }, + [1834] = { "EVENT_SILPH_CO_5_UNLOCKED_DOOR3" }, + [1846] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_0" }, + [1847] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_1" }, + [1848] = { "EVENT_BEAT_SILPH_CO_6F_TRAINER_2" }, + [1855] = { "EVENT_SILPH_CO_6_UNLOCKED_DOOR" }, + [1856] = { "EVENT_BEAT_SILPH_CO_RIVAL" }, + [1861] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_0" }, + [1862] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_1" }, + [1863] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_2" }, + [1864] = { "EVENT_BEAT_SILPH_CO_7F_TRAINER_3" }, + [1868] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR1" }, + [1869] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR2" }, + [1870] = { "EVENT_SILPH_CO_7_UNLOCKED_DOOR3" }, + [1874] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_0" }, + [1875] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_1" }, + [1876] = { "EVENT_BEAT_SILPH_CO_8F_TRAINER_2" }, + [1880] = { "EVENT_SILPH_CO_8_UNLOCKED_DOOR" }, + [1890] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_0" }, + [1891] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_1" }, + [1892] = { "EVENT_BEAT_SILPH_CO_9F_TRAINER_2" }, + [1896] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR1" }, + [1897] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR2" }, + [1898] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR3" }, + [1899] = { "EVENT_SILPH_CO_9_UNLOCKED_DOOR4" }, + [1905] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_0" }, + [1906] = { "EVENT_BEAT_SILPH_CO_10F_TRAINER_1" }, + [1912] = { "EVENT_SILPH_CO_10_UNLOCKED_DOOR" }, + [1920] = { "EVENT_780" }, + [1921] = { "EVENT_781" }, + [1922] = { "EVENT_782" }, + [1924] = { "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" }, + [1925] = { "EVENT_BEAT_SILPH_CO_11F_TRAINER_0" }, + [1928] = { "EVENT_SILPH_CO_11_UNLOCKED_DOOR" }, + [1933] = { "EVENT_GOT_MASTER_BALL" }, + [1935] = { "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + [2049] = { "EVENT_BEAT_MANSION_2_TRAINER_0" }, + [2065] = { "EVENT_BEAT_MANSION_3_TRAINER_0" }, + [2066] = { "EVENT_BEAT_MANSION_3_TRAINER_1" }, + [2081] = { "EVENT_BEAT_MANSION_4_TRAINER_0" }, + [2082] = { "EVENT_BEAT_MANSION_4_TRAINER_1" }, + [2176] = { "EVENT_GOT_HM03" }, + [2241] = { "EVENT_BEAT_MEWTWO" }, + [2273] = { "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0" }, + [2278] = { "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM" }, + [2281] = { "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0" }, + [2286] = { "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM" }, + [2289] = { "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0" }, + [2294] = { "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM" }, + [2297] = { "EVENT_BEAT_LANCES_ROOM_TRAINER_0" }, + [2302] = { "EVENT_BEAT_LANCE" }, + [2303] = { "EVENT_LANCES_ROOM_LOCK_DOOR" }, + [2305] = { "EVENT_BEAT_CHAMPION_RIVAL" }, + [2321] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0" }, + [2322] = { "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1" }, + [2327] = { "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH" }, + [2481] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0" }, + [2482] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1" }, + [2483] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2" }, + [2484] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3" }, + [2485] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4" }, + [2486] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5" }, + [2487] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6" }, + [2488] = { "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7" }, + [2496] = { "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE" }, + [2497] = { "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE" }, + [2504] = { "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE" }, + [2505] = { "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE" }, + [2512] = { "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE" }, + [2513] = { "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE" }, + [2522] = { "EVENT_BEAT_ARTICUNO" }, + }, +} diff --git a/src/pokemon/Gen45Hatch.lua b/src/pokemon/Gen45Hatch.lua index d03ff8bf1..edc23cab0 100644 --- a/src/pokemon/Gen45Hatch.lua +++ b/src/pokemon/Gen45Hatch.lua @@ -6,6 +6,7 @@ -- bank (Pt 357, HGSS 187). Black / White keep all three in one bank, 260. -- Each falls back to the engine's English when the cache lacks the line. +local Strings = require("src.core.Strings") local Gen45Hatch = {} Gen45Hatch.LINES = { @@ -37,9 +38,9 @@ end -- The three lines, with `name` (the species) filled in. function Gen45Hatch.lines(game, name) local rows = Gen45Hatch.LINES[family() or "platinum"] - return line(game, rows.oh, name, "Oh?"), - line(game, rows.hatched, name, ("%s hatched from the Egg!"):format(name)), - line(game, rows.ask, name, ("Would you like to nickname the newly\nhatched %s?"):format(name)) + return line(game, rows.oh, name, Strings("Oh?")), + line(game, rows.hatched, name, Strings("%s hatched from the Egg!", name)), + line(game, rows.ask, name, Strings("Would you like to nickname the newly\nhatched %s?", name)) end return Gen45Hatch diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 17c5685c0..816441180 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -468,10 +468,10 @@ function Pokemon.restorePP(mon, moves) end end -function Pokemon.heal(mon) +function Pokemon.heal(mon, moves) mon.hp = mon.stats.hp mon.status = nil - Pokemon.restorePP(mon) + Pokemon.restorePP(mon, moves) end function Pokemon.isFainted(mon) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 07f605295..359964018 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -420,6 +420,44 @@ function TextBox:beginLine() table.insert(self.shown, {}) end +-- Only the manual input branches; typing, automatic text and holds still +-- belong to update(), including the delay after a page/line advance. +function TextBox:advance() + if (self.holdFrames or 0) > 0 or (self.preWait or 0) > 0 then + return + end + if self.done then + if self.stay or self.auto or self.choice then + return + end + require("src.core.Sound").play(self.game.data, "Press_AB") + self.game.stack:pop() + if self.onDone then + self.onDone() + end + elseif self.waiting then + require("src.core.Sound").play(self.game.data, "Press_AB") + self.waiting = false + if self.contAdvance then + -- ContText / ManualTextScroll: keep the box, scroll one line + self.contAdvance = false + self.lineIndex = self.lineIndex + 1 + self:beginLine() + -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment + -- says, is "always called twice in a row" (home/text.asm:280-305) + self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR + else + self.shown = {} + self.pageIndex = self.pageIndex + 1 + self.lineIndex = 1 + self:beginLine() + -- ClearScreenArea then DelayFrames 20: the box sits empty before the + -- next page starts typing (home/text.asm:236-240) + self.holdFrames = Timing.TEXT_PAGE_CLEAR + end + end +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 @@ -519,9 +557,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.game.stack:pop() - if self.onDone then self.onDone() end + self:advance() end return end @@ -534,25 +570,7 @@ function TextBox:update(dt) return end if input:wasPressed("a") or input:wasPressed("b") then - require("src.core.Sound").play(self.game.data, "Press_AB") - self.waiting = false - if self.contAdvance then - -- ContText / ManualTextScroll: keep the box, scroll one line - self.contAdvance = false - self.lineIndex = self.lineIndex + 1 - self:beginLine() - -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment - -- says, is "always called twice in a row" (home/text.asm:280-305) - self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR - else - self.shown = {} - self.pageIndex = self.pageIndex + 1 - self.lineIndex = 1 - self:beginLine() - -- ClearScreenArea then DelayFrames 20: the box sits empty before the - -- next page starts typing (home/text.asm:236-240) - self.holdFrames = Timing.TEXT_PAGE_CLEAR - end + self:advance() end return end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 53cef91fa..0ab5cda3b 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -370,9 +370,9 @@ function Commands.show_text(ctx, textId, subs, extraOpts) -- ctx.lastCheck untouched -- the prompt never appears and the script reads -- the stale value as the answer. if opts and opts.choice then opts.auto = nil end - ctx.game.stack:push(TextBox.new(ctx.game, text, function() - runner:resume() - end, opts)) + local onDone = function() runner:resume() end + if ctx.portableCallback then onDone = ctx.portableCallback(onDone) end + ctx.game.stack:push(TextBox.new(ctx.game, text, onDone, opts)) runner:yield() end @@ -570,7 +570,7 @@ function Commands.give_item(ctx, itemId, count, gotText) if not require("src.inventory.Bag").add( ctx.save, itemId, count or 1, ctx.game.data) then ctx.lastCheck = false - Commands.show_text(ctx, ctx.game.data.text + Commands.show_text(ctx, ctx.portableBagFullText or ctx.game.data.text and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!")) return math.huge end @@ -1245,21 +1245,24 @@ local function askNickname(ctx, mon) -- opts.choice (TextBox.lua): the YES/NO box rides over the still-visible -- question instead of a bare ChoiceBox popping up after an A press -- already cleared it (same fix as Commands.ask). - Commands.show_text(ctx, textId, subs, { choice = function(yes) + local choice = function(yes) if not yes then ctx.lastCheck = success runner:resume() return end + local onDone = function(nick) + if nick and #nick > 0 then mon.nickname = nick end + ctx.lastCheck = success + runner:resume() + end + if ctx.portableCallback then onDone = ctx.portableCallback(onDone) end Screens.push(ctx.game, "NamingScreen", { - title = Strings("NICKNAME?"), maxLen = 10, - onDone = function(nick) - if nick and #nick > 0 then mon.nickname = nick end - ctx.lastCheck = success - runner:resume() - end, + title = Strings("NICKNAME?"), maxLen = 10, onDone = onDone, }) - end }) + end + if ctx.portableCallback then choice = ctx.portableCallback(choice) end + Commands.show_text(ctx, textId, subs, { choice = choice }) end -- give_pokemon : _GivePokemon (engine/events/ @@ -1289,6 +1292,7 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) ctx.game.mods.events:emit("pokemon.before_give", gift) species, level = gift.species, gift.level end + if ctx.portableAlive and not ctx.portableAlive() then return end local Pokemon = require("src.pokemon.Pokemon") local Party = require("src.pokemon.Party") local Boxes = require("src.pokemon.Boxes") @@ -1338,6 +1342,17 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) ctx.addedToParty = addedToParty require('src.pokemon.Gen4PoketchState').remember(ctx.game, mon) ctx.boxNum = boxNum + if ctx.portableGiftText and ctx.runner then + local wait = true + if not gift.nickname and not skipNickname then wait = false end + ctx.textOpts = ctx.textOpts or {} + ctx.textOpts.auto = { + sound = function() return require("src.core.Sound").play(ctx.game.data, "Get_Item1") end, + wait = wait, + } + Commands.show_text(ctx, ctx.game.data.text and ctx.game.data.text._GotMonText + or Strings("{PLAYER} received\n%s!", ctx.game.stringBuffer)) + end -- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks -- and callback-style callers with no script runner to yield on. if not gift.nickname and not skipNickname and ctx.runner then diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..7219012da 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -1098,6 +1098,12 @@ function Commands.g2_object(ctx, index, visible) obj, mapId = objectByIndex(ctx, index) end if not obj or not mapId then return end + return Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) +end + +-- Shared native effect after resolving a map object. Public API adapters can +-- supply the resolved template without depending on cached script-ID numbering. +function Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) local OverworldState = require("src.world.OverworldController") local name = OverworldState.objectToggleKey(obj) -- The PERSISTENT half, which this used to leave to a `setevent` the ROM diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 13566a36a..8fee26e5a 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -1244,11 +1244,14 @@ function BagMenu.new(game, opts) options[#options + 1] = { label = Strings("QUIT"), onSelect = function() end } end local th = #options * 2 + 1 - game.stack:push(Menu.new(game, options, gen2 + local actions = Menu.new(game, options, gen2 and { tx = 0, ty = 12 - th, tw = 7, th = th } - or { tx = 13, ty = 10, tw = 7, th = th })) + or { tx = 13, ty = 10, tw = 7, th = th }) + actions.publicUIKind = "bag_actions" + game.stack:push(actions) end, }) + list.publicUIKind, list.publicUIBattle = "bag", battle if gen2 then list.sgbPalettes = packPalettes end return list end diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 1f9f41f81..ba7edbdd0 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -30,6 +30,18 @@ function ChoiceBox.new(game, onChoose, opts) return self end +function ChoiceBox:choose(index) + if self.pending ~= nil then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.index = index + self.pending = index == 1 + self.holdFrames = Timing.YES_NO_ANSWER +end + function ChoiceBox:update(dt) local input = self.game.input -- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still @@ -48,21 +60,9 @@ function ChoiceBox:update(dt) if input:wasPressed("up") or input:wasPressed("down") then self.index = self.index == 1 and 2 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on A and B alike - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - self.pending = (self.index == 1) - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(self.index) elseif input:wasPressed("b") then - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - -- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so - -- the cursor visibly snaps to NO for those 15 frames - self.index = 2 - self.pending = false - self.holdFrames = Timing.YES_NO_ANSWER + self:choose(2) end end diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..855f2c805 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -1015,6 +1015,38 @@ function Gen3PartyMenu:animateTo(mon, fromHP, onDone) self.heal = { mon = mon, from = from, shown = from, onDone = onDone } end +function Gen3PartyMenu:confirm() + if self.index > #self:party() then + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- the CANCEL button VALIDATES when a team is being chosen; everywhere + -- else it is the way out + if self.chooseOrder then + return self:confirmOrder() + end + return self:close() + end + self:choose() +end + +function Gen3PartyMenu:cancel() + if self.hpTransferFrom then + self.hpTransferFrom = nil + return + end + -- B drops a SWITCH that was half-made before it closes the screen + if self.switchFrom then + self.switchFrom = nil + return + end + if self.chooseOrder then + return self:handOrder(nil) + end + self:close() +end + function Gen3PartyMenu:update(dt) -- RECONSTRUCTED: every icon bounces, all the time, on the same beat. On -- the cartridge each one is an OBJ with its own callback; here it is one @@ -1073,20 +1105,9 @@ function Gen3PartyMenu:update(dt) self.index = 1 end elseif input:wasPressed("a") then - if self.index > #self:party() then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- the CANCEL button VALIDATES when a team is being chosen; everywhere - -- else it is the way out - if self.chooseOrder then return self:confirmOrder() end - return self:close() - end - self:choose() + self:confirm() elseif input:wasPressed("b") then - if self.hpTransferFrom then self.hpTransferFrom=nil;return end - -- B drops a SWITCH that was half-made before it closes the screen - if self.switchFrom then self.switchFrom = nil return end - if self.chooseOrder then return self:handOrder(nil) end - self:close() + self:cancel() end end diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index 670908c8c..c717f362f 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -886,6 +886,28 @@ function Gen4PartyMenu:step(dir) end end +function Gen4PartyMenu:cancel() + if self.switchFrom then + self.switchFrom = nil + elseif self.hpTransfer then + self.hpTransfer = nil + -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". + -- + -- Out of a battle, handing the answer back as nil is how a caller learns + -- the player cancelled, and that is right. IN a battle it crashed: the + -- battle's own onSwitch is written for a Pokemon and went straight into + -- `mon.hp`. The battle already knows what to do when this screen simply + -- closes, so closing is both the safe answer and the correct one. + elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) and not self.battle then + self:handOff(nil) + elseif self.select then + self.index = self:count() + self:choose() + else + self:close() + end +end + function Gen4PartyMenu:update(dt) self.t = (self.t or 0) + (dt or 1 / 60) local input = self.game.input @@ -918,23 +940,7 @@ function Gen4PartyMenu:update(dt) elseif input:wasPressed("a") then self:choose() elseif input:wasPressed("b") or input:wasPressed("start") then - if self.switchFrom then self.switchFrom = nil - elseif self.hpTransfer then self.hpTransfer = nil - -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". - -- - -- Out of a battle, handing the answer back as nil is how a caller learns - -- the player cancelled, and that is right. IN a battle it crashed: the - -- battle's own onSwitch is written for a Pokemon and went straight into - -- `mon.hp`. The battle already knows what to do when this screen simply - -- closes, so closing is both the safe answer and the correct one. - elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) - and not self.battle then - self:handOff(nil) - elseif self.select then - self.index = self:count(); self:choose() - else - self:close() - end + self:cancel() end end diff --git a/src/ui/Gen5PokeTransferScreen.lua b/src/ui/Gen5PokeTransferScreen.lua index 0f4be03ad..a710de122 100644 --- a/src/ui/Gen5PokeTransferScreen.lua +++ b/src/ui/Gen5PokeTransferScreen.lua @@ -103,11 +103,12 @@ function S:picked(e) for i, p in ipairs(self.picks) do if p == e then return i end end end -local REFUSE = { - egg = "An Egg cannot be transferred.", - item = "A Pokémon holding an item cannot\nbe transferred.", - hm = "This Pokémon knows a hidden move.\nIt cannot be transferred.", -} +local function refusal(reason) + if reason == "egg" then return Strings("An Egg cannot be transferred.") end + if reason == "item" then return Strings("A Pokémon holding an item cannot\nbe transferred.") end + if reason == "hm" then return Strings("This Pokémon knows a hidden move.\nIt cannot be transferred.") end + return Strings("This Pokémon cannot be transferred.") +end function S:toggle(e) local at = self:picked(e) @@ -115,7 +116,7 @@ function S:toggle(e) if #self.picks >= T().MONS then return end if not e.walker then local why = T().refusal(self.srcData, e.raw) - if why then return self:say(Strings(REFUSE[why] or "This Pokémon cannot be transferred.")) end + if why then return self:say(refusal(why)) end end self.picks[#self.picks + 1] = e if #self.picks == T().MONS then diff --git a/src/ui/HgssAthleteShop.lua b/src/ui/HgssAthleteShop.lua index d9d51199d..7db88187b 100644 --- a/src/ui/HgssAthleteShop.lua +++ b/src/ui/HgssAthleteShop.lua @@ -30,6 +30,7 @@ -- (overlay 31's line for that case was not traced); a taken prize is drawn -- like any other (whether overlay 31 dims it was not read). +local Strings = require("src.core.Strings") local T = require("src.import.Gen4Text") local M = {} @@ -101,7 +102,7 @@ local function choose(self) return end self.mode, self.yesNo = "confirm", 1 - self.message = bank(self, 46, "Would you like\n" .. (def.name or "") .. "?", def.name or "") + self.message = bank(self, 46, Strings("Would you like\n%s?", def.name or ""), def.name or "") end local function transact(self) @@ -115,15 +116,15 @@ local function transact(self) if self.athlon.kind == "daily" then if isApricorn(id) then require("src.world.HgssApricorns").give(save, id - M.APRICORN_FIRST, 1) - self.message = bank(self, 49, "All right, here you go.\nCome back anytime!") + self.message = bank(self, 49, Strings("All right, here you go.\nCome back anytime!")) else require("src.inventory.Bag").add(save, id, 1, self.game.data) - self.message = self:line(6, "Here you are!\nThank you!", def.name or "", self:pocketName(def)) + self.message = self:line(6, Strings("Here you are!\nThank you!"), def.name or "", self:pocketName(def)) end S.takeDaily(save, self.cursor - 1) else S.giveCard(save, (tonumber(id) or 0) - M.DATA_CARD_01) - self.message = bank(self, 49, "All right, here you go.\nCome back anytime!") + self.message = bank(self, 49, Strings("All right, here you go.\nCome back anytime!")) end -- MartData_SubCurrency (PokeathlonSave_SubAthletePoints: not below 0) and -- GAME_STAT_CURRENCY_SPENT diff --git a/src/ui/HgssPokeathlonTeamInfo.lua b/src/ui/HgssPokeathlonTeamInfo.lua index aa75d3eaa..7a5c33749 100644 --- a/src/ui/HgssPokeathlonTeamInfo.lua +++ b/src/ui/HgssPokeathlonTeamInfo.lua @@ -48,6 +48,7 @@ -- * the OBJ set's sequence 5 (a sparkle, created hidden at the Medals and -- never shown by ov97) and 6 are not drawn +local Strings = require("src.core.Strings") local Font = require("src.render.Font") local T = require("src.import.Gen4Text") @@ -199,7 +200,7 @@ function M.draw(game, opts) elseif r.gender == "female" then text(game, line(game, 3, "♀"), 76, 120, 3, 4) end for _, l in ipairs(M.LABELS) do text(game, line(game, l[1], ""), 104, l[2]) end local sp = speciesName(game, mon) - text(game, line(game, 9, "Medals won by\n" .. sp, sp), 8, 152) + text(game, line(game, 9, Strings("Medals won by\n%s", sp), sp), 8, 152) -- the OBJs: the picture, the stars and arrows, the Medals local pic = picture(game, mon) if pic then diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index e93098790..cf42a71f2 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -172,6 +172,25 @@ local function navPressed(self, dir) return true end +function ListMenu:choose() + if #self.items == 0 then + return self:cancel() + end + beep(self) + local item = self.items[self.index] + if self.onChoose then + self.onChoose(item, self) + end +end + +function ListMenu:cancel() + beep(self) + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function ListMenu:update(dt) if self.script then self.script(self) @@ -211,16 +230,10 @@ function ListMenu:update(dt) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then - beep(self) - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() return elseif input:wasPressed("a") then - beep(self) - local item = self.items[self.index] - if self.onChoose then - self.onChoose(item, self) - end + self:choose() return end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 2415d798b..24b36069d 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -120,6 +120,35 @@ function Menu:clampScroll() end end +function Menu:choose() + -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + local item = self.items[self.index] + -- keepOpen entries run without closing the menu (e.g. the + -- Pokédex CRY option keeps the side menu up) + if not item.keepOpen then + self.game.stack:pop() + end + if item.onSelect then + item.onSelect() + end +end + +function Menu:cancel() + if not self.cancelable then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function Menu:update(dt) local input = self.game.input local wasIndex = self.index @@ -128,15 +157,7 @@ function Menu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #self.items and self.index + 1 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - local item = self.items[self.index] - -- keepOpen entries run without closing the menu (e.g. the - -- Pokédex CRY option keeps the side menu up) - if not item.keepOpen then self.game.stack:pop() end - if item.onSelect then item.onSelect() end + self:choose() elseif self.cancelable and (input:wasPressed("b") or (self.startCloses and input:wasPressed("start"))) then -- HandleMenuInput_ returns for any watched key, but only replays diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..4b487d623 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -472,6 +472,399 @@ function PartyMenu:close() if self.game.stack:top() == self then self.game.stack:pop() end end +-- Native confirmation shared by input and the public UI facade. +function PartyMenu:confirm() + local party = self.party or self.game.save.party + if self.submenu then + local mon = party[self.index] + local entry = self.subItems[self.subIndex] + local action = entry.action + if not action and entry.onSelect then + -- hook-injected entries carry a callback instead of an action id + entry.onSelect(mon, self.game) + elseif action == "stats" then + -- battle and field alike return to the party list afterwards + -- (core.asm .partyMenuWasSelected) + Screens.push(self.game, "SummaryMenu", mon, { + mons = party, + index = self.index, + onMonChange = function(index) self.index = index end, + }) + elseif action == "battle_switch" then + self.game.stack:pop() + self.onSwitch(mon) + return + elseif action == "cancel" then + self.game.stack:pop() + if self.onCancel then self.onCancel() end + return + elseif action == "switch" then + self.swapFrom = self.index + elseif action == "item" then + self:openItemMenu(mon) + elseif action == "quit" then + self.submenu = nil + elseif action == "fly" then + -- FLY opens the TOWN MAP with a cursor over the visited fly towns, + -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). + -- flyTo (OverworldController) validates the fly-warp + runs the + -- departure/warp, so we just hand it the chosen mapId (#195). + local ow = self.game.overworld + self.game.stack:pop() -- close the party menu + Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) + if ow then ow:flyTo(mapId) end + end }) + return + elseif action == "flash" then -- FLASH lights dark tunnels + -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs + -- with the party menu still on screen, and only then + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads + -- over the menu, and the cave is lit when the blink hands the + -- screen back, never under the text (#385). + local ow = self.game.overworld + local TextBox = require("src.render.TextBox") + local Transition = require("src.render.Transition") + -- The other half of CheckUseFlash: using it in the Aerodactyl chamber + -- opens the wall. The chamber's scene script plays the opening the + -- next time the map loads, exactly as the escape-rope one does. + require("src.script.RuinsOfAlph").aerodactylChamber(self.game) + self.game.save.flashLit = true + 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). + ow:setDark(false) + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + elseif action == "surf" then + -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time + -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F + -- current refusals, both of which loop back to the submenu), then + -- ItemUseSurfboard: while surfing it tries to dismount instead; + -- otherwise it mounts only if the FACING tile is water, else + -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the + -- submenu. useSurfFieldMove reports which; trySurf does the mount. + local ow = self.game.overworld + local reason = ow:useSurfFieldMove() + local Transition = require("src.render.Transition") + if reason == "ok" then + -- UseItem prints _SurfingGotOnText with the party menu still up; + -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so + -- trySurf closes this menu when its text does (#385) + local fx, fy = ow.player:facingCell() + ow:trySurf(fx, fy, function() self:close() end) + return + end + if reason == "dismount" then + -- ItemUseSurfboard .stopSurfing: no text -- the walking state + -- and music return first (PlayDefaultMusic + + -- LoadWalkingPlayerSpriteGraphics), the menu closes with the + -- 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)) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = ({ no_badge = "_NewBadgeRequiredText", + forced_bike = "_CyclingIsFunText", + current = "_CurrentTooFastText", + no_place = "_SurfingNoPlaceToGetOffText" })[reason] + or "_NoSurfingHereText" + local txt = (self.game.data.text[key] or Strings("No SURFing here!")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + if reason == "no_place" then + -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but + -- never zeroes wActionResultOrTookBattleTurn, so unlike the + -- other refusals the menu still closes afterwards + -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints + -- over the still-open menu like every other .loop refusal (#385) + self.game.stack:push(TextBox.new(self.game, txt, function() + self:close() + self.game.stack:push(Transition.whiteFlash(self.game)) + end)) + return + end + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "cut" then + -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): + -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the + -- submenu when the FACING tile isn't a cuttable tree. + local ow = self.game.overworld + local reason = ow:useCutFieldMove() + if reason == "ok" then + self.game.stack:pop() -- close the party menu (CloseTextDisplay) + local fx, fy = ow.player:facingCell() + ow:tryCut(fx, fy) + return + end + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local key = (reason == "no_badge") and "_NewBadgeRequiredText" + or "_NothingToCutText" + local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + elseif action == "strength" then + -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); + -- predef PrintStrengthText (field_move_messages.asm) sets + -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate + -- push_boulder.asm reads -- then prints _UseStrengthText (no + -- prompt: after the text, the text_asm tail plays the chosen + -- mon's cry, Delay3, and it auto-advances) and + -- _MoveBoulderText (`prompt`: waits for A/B). Back in + -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white + -- before CloseTextDisplay returns to the map. + local ow = self.game.overworld + 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 and def.name) or mon.species + ow.strengthActive = true + -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; + -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. + local t1raw = self.game.data.text._UseStrengthText + or self.game.data.text._UsedStrengthText + local t2raw = self.game.data.text._MoveBoulderText + or self.game.data.text._CanMoveBouldersText + local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) + local t2 = (t2raw 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 } })) + return + elseif action == "gen2_field" then + -- GSC runs the same handler from the menu as from the overworld + -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the + -- tile check lives in one place. + local ow = self.game.overworld + local fx, fy = ow.player:facingCell() + self:close() + if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then + local TextBox = require("src.render.TextBox") + -- _CantSurfText is "You can't SURF here", so using it for every + -- field move told a player holding HEADBUTT that the game thought + -- they had picked SURF. Only SURF gets the surf line. + local text = (entry.move == "SURF") + and self.game.data.text._CantSurfText or nil + self.game.stack:push(TextBox.new(self.game, + text or Strings("You can't use that\nhere."))) + end + return + elseif action == "softboiled" then + -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer + -- 1/5 of the user's max HP to a chosen teammate + self.softboiledFrom = self.index + elseif action == "dig" then + -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as + -- a DOOR warp so the player steps out of the opening rather than + -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares + -- TELEPORT's Pokemon Center warp, so it falls through to the same + -- departure with no target. + local ow = self.game.overworld + self.game.stack:pop() + if ow then + local escape = require("src.core.GameVersion").isGen2() + and ow.escapePoint and ow:escapePoint() and true or nil + ow:beginTeleportOut(nil, { escape = escape }) + end + return + elseif action == "escape" then + -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, + -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction + -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup + -- / wLastSpawnMapNumber, so this destination is right for both + -- generations. Only DIG diverged; it has its own branch above. + -- pokered's .dig/.teleport spin the + -- player up (LeaveMapAnim), white/fade out, then land it; this port + -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut + -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares + -- the exact departure; the fade + warp fire when the spin ends. + local ow = self.game.overworld + self.game.stack:pop() + if ow then ow:beginTeleportOut() end + return + end + self.submenu = nil + return + end + if #party == 0 then return end + local mon = party[self.index] + if self.softboiledFrom then + local user = party[self.softboiledFrom] + local heal = math.floor(user.stats.hp / 5) + if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp + or user.hp <= heal then + self.softboiledFrom = nil + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) + else + user.hp = user.hp - heal + mon.hp = math.min(mon.stats.hp, mon.hp + heal) + self.softboiledFrom = nil + require("src.core.Sound").play(self.game.data, "Heal_HP") + local def = self.game.data.pokemon[mon.species] + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + Strings("%s's HP\nwas restored!", mon.nickname or def.name))) + end + elseif self.swapFrom then + if self.swapFrom ~= self.index then + party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] + require("src.core.Sound").play(self.game.data, "Swap") + end + self.swapFrom = nil + elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then + -- keepOpen callers (HP medicine) need the menu still drawn while the + -- bar fills and the message prints, and close it themselves; everyone + -- else keeps the old pop-then-call order. Popping first is what made + -- a POTION snap the picker shut before the item had even run (#252). + if not self.keepOpen then self.game.stack:pop() end + self.onSwitch(mon, self) + else + self.submenu = true + self.subIndex = 1 + local items + local ow = self.game.overworld + if self.battle and self.onSwitch then + -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) + items = { { label = Strings("SWITCH"), action = "battle_switch" }, + { label = Strings("STATS"), action = "stats" }, + { label = Strings("CANCEL"), action = "cancel" } } + else + -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm + -- builds the same dynamic list) + items = { { label = Strings("STATS"), action = "stats" }, + { label = Strings("SWITCH"), action = "switch" } } + -- Field moves (HMs/TMs) are usable out of battle even when the mon + -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. + -- Battle still excludes this list via `not self.battle`. Softboiled + -- can appear for a fainted user; its heal transfer then no-ops. + if not self.battle and ow then + -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- + -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) + local outside = Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) + -- The badge gate per field move comes from constants.hmBadges and + -- the badge itself through Badges.has: Gen2 gates the same moves on + -- the Johto badges and stores them as engine flags rather than bag + -- items, so the hard-coded R/B item names listed nothing at all. + local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} + local function badged(moveId) + local gate = gates[moveId] + if not (gate and gate.badge) then return true end + return Badges.has(self.game.save, { id = gate.badge }) + end + for _, mv in ipairs(mon.moves) do + if mv.id == "FLY" and outside and badged("FLY") then + table.insert(items, { label = Strings("FLY"), action = "fly" }) + -- LIGHT is carved on the Aerodactyl chamber wall, and the + -- chamber is not a dark cave -- so CheckUseFlash asks + -- SpecialAerodactylChamber FIRST and lets FLASH be used on its + -- carry, before it ever looks at the palset + -- (engine/events/overworld.asm:285). Without this the move is + -- never even offered there and the wall can never open. + elseif mv.id == "FLASH" and badged("FLASH") + and (ow.dark or (ow.map and ow.map.id + == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then + table.insert(items, { label = Strings("FLASH"), action = "flash" }) + elseif mv.id == "CUT" and badged("CUT") then + -- CUT/SURF/STRENGTH are party-menu field moves too + -- (start_sub_menus.asm .outOfBattleMovePointers); listed here + -- with the same list-time badge filter this file already uses + -- for FLY/FLASH. The facing-tile/activation check happens on + -- selection (useCutFieldMove/useSurfFieldMove). + table.insert(items, { label = Strings("CUT"), action = "cut" }) + elseif mv.id == "SURF" and badged("SURF") then + table.insert(items, { label = Strings("SURF"), action = "surf" }) + elseif mv.id == "STRENGTH" and badged("STRENGTH") then + table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) + elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then + table.insert(items, { label = Strings(mv.id:gsub("_", " ")), + action = "softboiled" }) + elseif mv.id == "TELEPORT" and outside then + -- TELEPORT works only OUTDOORS (start_sub_menus.asm + -- .teleport -> CheckIfInOutsideMap); dark maps don't + -- block it + table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) + elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" + and (DIG_TILESETS[ow.map.def.tileset] + or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] + and ow.escapePoint and ow:escapePoint())) then + -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = + -- ESCAPE_ROPE): usable in the dungeon tilesets of + -- escape_rope_tilesets.asm minus Agatha's room, even in + -- the dark (Rock Tunnel). + -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a + -- recorded entrance to come back out of. Its own action, + -- because Gen 2's DIG and TELEPORT no longer share a + -- destination the way Gen 1's did. + table.insert(items, { label = Strings("DIG"), action = "dig" }) + elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then + -- GSC's own field moves; the tile check happens on selection, + -- exactly like CUT's (start_sub_menus.asm never gates the list + -- on where the player is standing) + table.insert(items, { label = Strings(mv.id:gsub("_", " ")), + action = "gen2_field", move = mv.id }) + end + end + end + if require("src.core.GameVersion").isGen2() then + -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, + -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. + local stats, switch = table.remove(items, 1), table.remove(items, 1) + items[#items + 1] = stats + items[#items + 1] = switch + items[#items + 1] = { label = Strings("ITEM"), action = "item" } + items[#items + 1] = { label = Strings("QUIT"), action = "quit" } + end + end + local ctx = { battle = self.battle, overworld = ow } + local hooked = Runtime.call("ui.party.submenu", sameItems, + self.game, items, mon, ctx) + if type(hooked) == "table" then + items = hooked + else + Logger.error("ui.party.submenu returned %s; keeping the vanilla list", + type(hooked)) + end + self.subItems = items + end +end + +function PartyMenu:cancel() + if self.submenu then self.submenu = nil; return end + self.game.stack:pop() + if self.onCancel then self.onCancel() end +end + function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 @@ -505,245 +898,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.subIndex = self.subIndex < n and self.subIndex + 1 or 1 elseif input:wasPressed("b") then - self.submenu = nil + self:cancel() elseif input:wasPressed("a") then - local mon = party[self.index] - local entry = self.subItems[self.subIndex] - local action = entry.action - if not action and entry.onSelect then - -- hook-injected entries carry a callback instead of an action id - entry.onSelect(mon, self.game) - elseif action == "stats" then - -- battle and field alike return to the party list afterwards - -- (core.asm .partyMenuWasSelected) - Screens.push(self.game, "SummaryMenu", mon, { - mons = party, - index = self.index, - onMonChange = function(index) self.index = index end, - }) - elseif action == "battle_switch" then - self.game.stack:pop() - self.onSwitch(mon) - return - elseif action == "cancel" then - self.game.stack:pop() - if self.onCancel then self.onCancel() end - return - elseif action == "switch" then - self.swapFrom = self.index - elseif action == "item" then - self:openItemMenu(mon) - elseif action == "quit" then - self.submenu = nil - elseif action == "fly" then - -- FLY opens the TOWN MAP with a cursor over the visited fly towns, - -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). - -- flyTo (OverworldController) validates the fly-warp + runs the - -- departure/warp, so we just hand it the chosen mapId (#195). - local ow = self.game.overworld - self.game.stack:pop() -- close the party menu - Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) - if ow then ow:flyTo(mapId) end - end }) - return - elseif action == "flash" then -- FLASH lights dark tunnels - -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs - -- with the party menu still on screen, and only then - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads - -- over the menu, and the cave is lit when the blink hands the - -- screen back, never under the text (#385). - local ow = self.game.overworld - local TextBox = require("src.render.TextBox") - local Transition = require("src.render.Transition") - -- The other half of CheckUseFlash: using it in the Aerodactyl chamber - -- opens the wall. The chamber's scene script plays the opening the - -- next time the map loads, exactly as the escape-rope one does. - require("src.script.RuinsOfAlph").aerodactylChamber(self.game) - self.game.save.flashLit = true - 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). - ow:setDark(false) - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - elseif action == "surf" then - -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time - -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F - -- current refusals, both of which loop back to the submenu), then - -- ItemUseSurfboard: while surfing it tries to dismount instead; - -- otherwise it mounts only if the FACING tile is water, else - -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the - -- submenu. useSurfFieldMove reports which; trySurf does the mount. - local ow = self.game.overworld - local reason = ow:useSurfFieldMove() - local Transition = require("src.render.Transition") - if reason == "ok" then - -- UseItem prints _SurfingGotOnText with the party menu still up; - -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so - -- trySurf closes this menu when its text does (#385) - local fx, fy = ow.player:facingCell() - ow:trySurf(fx, fy, function() self:close() end) - return - end - if reason == "dismount" then - -- ItemUseSurfboard .stopSurfing: no text -- the walking state - -- and music return first (PlayDefaultMusic + - -- LoadWalkingPlayerSpriteGraphics), the menu closes with the - -- 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)) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = ({ no_badge = "_NewBadgeRequiredText", - forced_bike = "_CyclingIsFunText", - current = "_CurrentTooFastText", - no_place = "_SurfingNoPlaceToGetOffText" })[reason] - or "_NoSurfingHereText" - local txt = (self.game.data.text[key] or Strings("No SURFing here!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - if reason == "no_place" then - -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but - -- never zeroes wActionResultOrTookBattleTurn, so unlike the - -- other refusals the menu still closes afterwards - -- (GBPalWhiteOutWithDelay3 + .goBackToMap), and the text prints - -- over the still-open menu like every other .loop refusal (#385) - self.game.stack:push(TextBox.new(self.game, txt, function() - self:close() - self.game.stack:push(Transition.whiteFlash(self.game)) - end)) - return - end - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "cut" then - -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): - -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the - -- submenu when the FACING tile isn't a cuttable tree. - local ow = self.game.overworld - local reason = ow:useCutFieldMove() - if reason == "ok" then - self.game.stack:pop() -- close the party menu (CloseTextDisplay) - local fx, fy = ow.player:facingCell() - ow:tryCut(fx, fy) - return - end - local TextBox = require("src.render.TextBox") - local def = self.game.data.pokemon[mon.species] - local key = (reason == "no_badge") and "_NewBadgeRequiredText" - or "_NothingToCutText" - local txt = (self.game.data.text[key] or Strings("Nothing to CUT!")) - :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) - self.game.stack:push(TextBox.new(self.game, txt)) - return -- .loop: submenu stays open behind the message - elseif action == "strength" then - -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); - -- predef PrintStrengthText (field_move_messages.asm) sets - -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate - -- push_boulder.asm reads -- then prints _UseStrengthText (no - -- prompt: after the text, the text_asm tail plays the chosen - -- mon's cry, Delay3, and it auto-advances) and - -- _MoveBoulderText (`prompt`: waits for A/B). Back in - -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white - -- before CloseTextDisplay returns to the map. - local ow = self.game.overworld - 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 and def.name) or mon.species - ow.strengthActive = true - -- Gen2 text keys are _UseStrengthText / _MoveBoulderText; - -- Gen1 uses _UsedStrengthText / _CanMoveBouldersText. Try both. - local t1raw = self.game.data.text._UseStrengthText - or self.game.data.text._UsedStrengthText - local t2raw = self.game.data.text._MoveBoulderText - or self.game.data.text._CanMoveBouldersText - local t1 = (t1raw or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) - local t2 = (t2raw 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 } })) - return - elseif action == "gen2_field" then - -- GSC runs the same handler from the menu as from the overworld - -- A press (SurfFromMenuScript / HeadbuttFromMenuScript), so the - -- tile check lives in one place. - local ow = self.game.overworld - local fx, fy = ow.player:facingCell() - self:close() - if not ow:gen2FieldMoveAt(entry.move, mon, fx, fy) then - local TextBox = require("src.render.TextBox") - -- _CantSurfText is "You can't SURF here", so using it for every - -- field move told a player holding HEADBUTT that the game thought - -- they had picked SURF. Only SURF gets the surf line. - local text = (entry.move == "SURF") - and self.game.data.text._CantSurfText or nil - self.game.stack:push(TextBox.new(self.game, - text or Strings("You can't use that\nhere."))) - end - return - elseif action == "softboiled" then - -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer - -- 1/5 of the user's max HP to a chosen teammate - self.softboiledFrom = self.index - elseif action == "dig" then - -- GEN 2 (.DoDig): back out through the recorded entrance, arriving as - -- a DOOR warp so the player steps out of the opening rather than - -- standing in it. GEN 1's DIG is ItemUseEscapeRope and shares - -- TELEPORT's Pokemon Center warp, so it falls through to the same - -- departure with no target. - local ow = self.game.overworld - self.game.stack:pop() - if ow then - local escape = require("src.core.GameVersion").isGen2() - and ow.escapePoint and ow:escapePoint() and true or nil - ow:beginTeleportOut(nil, { escape = escape }) - end - return - elseif action == "escape" then - -- TELEPORT warps to the last Pokémon Center TOWN (wLastBlackoutMap, - -- special_warps.asm escape warp) -- and Gen 2 agrees: TeleportFunction - -- .TryTeleport requires an OUTDOOR map and warps to wLastSpawnMapGroup - -- / wLastSpawnMapNumber, so this destination is right for both - -- generations. Only DIG diverged; it has its own branch above. - -- pokered's .dig/.teleport spin the - -- player up (LeaveMapAnim), white/fade out, then land it; this port - -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut - -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares - -- the exact departure; the fade + warp fire when the spin ends. - local ow = self.game.overworld - self.game.stack:pop() - if ow then ow:beginTeleportOut() end - return - end - self.submenu = nil + self:confirm() end return end @@ -753,150 +910,9 @@ function PartyMenu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 elseif input:wasPressed("b") then - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() elseif input:wasPressed("a") and #party > 0 then - local mon = party[self.index] - if self.softboiledFrom then - local user = party[self.softboiledFrom] - local heal = math.floor(user.stats.hp / 5) - if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp - or user.hp <= heal then - self.softboiledFrom = nil - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect."))) - else - user.hp = user.hp - heal - mon.hp = math.min(mon.stats.hp, mon.hp + heal) - self.softboiledFrom = nil - require("src.core.Sound").play(self.game.data, "Heal_HP") - local def = self.game.data.pokemon[mon.species] - local TextBox = require("src.render.TextBox") - self.game.stack:push(TextBox.new(self.game, - Strings("%s's HP\nwas restored!", mon.nickname or def.name))) - end - elseif self.swapFrom then - if self.swapFrom ~= self.index then - party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] - require("src.core.Sound").play(self.game.data, "Swap") - end - self.swapFrom = nil - elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then - -- keepOpen callers (HP medicine) need the menu still drawn while the - -- bar fills and the message prints, and close it themselves; everyone - -- else keeps the old pop-then-call order. Popping first is what made - -- a POTION snap the picker shut before the item had even run (#252). - if not self.keepOpen then self.game.stack:pop() end - self.onSwitch(mon, self) - else - self.submenu = true - self.subIndex = 1 - local items - local ow = self.game.overworld - if self.battle and self.onSwitch then - -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) - items = { { label = Strings("SWITCH"), action = "battle_switch" }, - { label = Strings("STATS"), action = "stats" }, - { label = Strings("CANCEL"), action = "cancel" } } - else - -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm - -- builds the same dynamic list) - items = { { label = Strings("STATS"), action = "stats" }, - { label = Strings("SWITCH"), action = "switch" } } - -- Field moves (HMs/TMs) are usable out of battle even when the mon - -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. - -- Battle still excludes this list via `not self.battle`. Softboiled - -- can appear for a fainted user; its heal transfer then no-ops. - if not self.battle and ow then - -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- - -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) - local outside = Map.isOutside(ow.map.def, - FieldDefaults.field(self.game.data, "outsideTilesets")) - -- The badge gate per field move comes from constants.hmBadges and - -- the badge itself through Badges.has: Gen2 gates the same moves on - -- the Johto badges and stores them as engine flags rather than bag - -- items, so the hard-coded R/B item names listed nothing at all. - local gates = FieldDefaults.constant(self.game.data, "hmBadges") or {} - local function badged(moveId) - local gate = gates[moveId] - if not (gate and gate.badge) then return true end - return Badges.has(self.game.save, { id = gate.badge }) - end - for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and outside and badged("FLY") then - table.insert(items, { label = Strings("FLY"), action = "fly" }) - -- LIGHT is carved on the Aerodactyl chamber wall, and the - -- chamber is not a dark cave -- so CheckUseFlash asks - -- SpecialAerodactylChamber FIRST and lets FLASH be used on its - -- carry, before it ever looks at the palset - -- (engine/events/overworld.asm:285). Without this the move is - -- never even offered there and the wall can never open. - elseif mv.id == "FLASH" and badged("FLASH") - and (ow.dark or (ow.map and ow.map.id - == require("src.script.RuinsOfAlph").MAPS.aerodactyl)) then - table.insert(items, { label = Strings("FLASH"), action = "flash" }) - elseif mv.id == "CUT" and badged("CUT") then - -- CUT/SURF/STRENGTH are party-menu field moves too - -- (start_sub_menus.asm .outOfBattleMovePointers); listed here - -- with the same list-time badge filter this file already uses - -- for FLY/FLASH. The facing-tile/activation check happens on - -- selection (useCutFieldMove/useSurfFieldMove). - table.insert(items, { label = Strings("CUT"), action = "cut" }) - elseif mv.id == "SURF" and badged("SURF") then - table.insert(items, { label = Strings("SURF"), action = "surf" }) - elseif mv.id == "STRENGTH" and badged("STRENGTH") then - table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) - elseif mv.id == "SOFTBOILED" or mv.id == "MILK_DRINK" then - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "softboiled" }) - elseif mv.id == "TELEPORT" and outside then - -- TELEPORT works only OUTDOORS (start_sub_menus.asm - -- .teleport -> CheckIfInOutsideMap); dark maps don't - -- block it - table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) - elseif mv.id == "DIG" and ow.map.id ~= "AGATHAS_ROOM" - and (DIG_TILESETS[ow.map.def.tileset] - or (GEN2_DIG_ENVIRONMENTS[ow.map.def.environment] - and ow.escapePoint and ow:escapePoint())) then - -- GEN 1: DIG runs ItemUseEscapeRope (.dig sets wCurItem = - -- ESCAPE_ROPE): usable in the dungeon tilesets of - -- escape_rope_tilesets.asm minus Agatha's room, even in - -- the dark (Rock Tunnel). - -- GEN 2: .CheckCanDig instead -- CAVE or DUNGEON, and a - -- recorded entrance to come back out of. Its own action, - -- because Gen 2's DIG and TELEPORT no longer share a - -- destination the way Gen 1's did. - table.insert(items, { label = Strings("DIG"), action = "dig" }) - elseif GEN2_FIELD_MOVES[mv.id] and badged(mv.id) then - -- GSC's own field moves; the tile check happens on selection, - -- exactly like CUT's (start_sub_menus.asm never gates the list - -- on where the player is standing) - table.insert(items, { label = Strings(mv.id:gsub("_", " ")), - action = "gen2_field", move = mv.id }) - end - end - end - if require("src.core.GameVersion").isGen2() then - -- GSC's MonMenu lists the field moves first, then STATS, SWITCH, - -- ITEM and QUIT; Gen 1 has no held items and no QUIT row. - local stats, switch = table.remove(items, 1), table.remove(items, 1) - items[#items + 1] = stats - items[#items + 1] = switch - items[#items + 1] = { label = Strings("ITEM"), action = "item" } - items[#items + 1] = { label = Strings("QUIT"), action = "quit" } - end - end - local ctx = { battle = self.battle, overworld = ow } - local hooked = Runtime.call("ui.party.submenu", sameItems, - self.game, items, mon, ctx) - if type(hooked) == "table" then - items = hooked - else - Logger.error("ui.party.submenu returned %s; keeping the vanilla list", - type(hooked)) - end - self.subItems = items - end + self:confirm() end end diff --git a/src/ui/UICommands.lua b/src/ui/UICommands.lua new file mode 100644 index 000000000..437932a88 --- /dev/null +++ b/src/ui/UICommands.lua @@ -0,0 +1,346 @@ +-- Admission and dispatch for native party, bag and dialogue screens. +-- No screen is loaded here: an already loaded native class must own the top. +local Commands = {} +local CLASSES = { + PartyMenu = "party_gb", + Gen3PartyMenu = "party_gba", + Gen4PartyMenu = "party_ds", + Gen3BagMenu = "bag_gba", + Gen4BagMenu = "bag_ds", + HgssBagMenu = "bag_hgss", + Gen5BagMenu = "bag_bw", + Gen4BattleBag = "battle_bag", + Gen5BattleBag = "battle_bag", + Gen3ItemMenu = "item_actions", + ChoiceBox = "choice", +} +local function class(screen) + local mt = getmetatable(screen) + if mt == nil then + return + end + if mt == package.loaded["src.render.TextBox"] then + return "text" + end + for name, kind in pairs(CLASSES) do + if mt == package.loaded["src.ui." .. name] then + return kind + end + end + if mt == package.loaded["src.ui.ListMenu"] and screen.publicUIKind == "bag" then + return "bag_gb" + end + if mt == package.loaded["src.ui.Menu"] and screen.publicUIKind == "bag_actions" then + return "bag_actions" + end +end +local function row(out, value, ids, item) + local i = #out.rows + 1 + local r = { index = i, enabled = true } + if type(value) == "table" then + r.label = type(value.label) == "string" and value.label or nil + r.action = type(value.action) == "string" and value.action or nil + if value.header then + r.enabled = false + end + if value.close then + r.action = "back" + end + if item then + r.item = ids:read("items", value.id or value.value) + r.count = type(value.qty) == "number" and value.qty or nil + if not r.label and type(value.def) == "table" and type(value.def.name) == "string" then + r.label = value.def.name + end + end + elseif type(value) == "string" then + r.action, r.label = value, value + end + out.rows[i] = r + return r +end +function Commands.describe(game, screen, ids) + local kind = class(screen) + if not kind then + return nil, "unsupported UI screen" + end + if screen.game ~= game then + return nil, "UI does not belong to current Game" + end + local out = { + kind = "party", + rows = {}, + index = screen.index, + canBack = true, + canAdvance = false, + canPocket = false, + locked = false, + } + local refs = { screen = screen, stack = game.stack, states = game.stack.states, save = game.save } + -- Keep callback and nested option identity private; no engine references escape. + for key, value in pairs(screen) do + if type(value) == "function" or type(value) == "table" then + refs[key] = value + end + end + local function rows(values, items) + for i, value in ipairs(values or {}) do + refs["row:" .. i] = value + if type(value) == "table" then + refs["callback:" .. i] = value.onSelect + end + row(out, value, ids, items) + end + end + if kind:match("^party_") then + local party = kind == "party_gb" and (screen.party or game.save.party) or screen:party() + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + end + if screen.selectNotice then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.submenu then + out.kind = "party_actions" + rows( + kind == "party_gb" and screen.subItems or kind == "party_gba" and screen.submenu or screen:actions() + ) + out.index = kind == "party_ds" and screen.submenu or screen.subIndex + else + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + local r = row(out, {}, ids) + r.partyIndex, r.species, r.label = i, ids:read("pokemon", mon.species), mon.nickname + r.hp, r.maxHp = mon.hp, mon.stats and mon.stats.hp or mon.maxHp + end + if kind ~= "party_gb" then + if kind == "party_ds" and screen.select then + row(out, "confirm", ids) + end + row(out, "back", ids) + end + end + elseif kind == "text" then + out.kind, out.index, out.canBack = "text", nil, false + out.canAdvance = ( + screen.waiting or (screen.done and not screen.auto and not screen.stay and not screen.choice) + ) + and (screen.preWait or 0) <= 0 + and (screen.holdFrames or 0) <= 0 + or false + out.locked = not out.canAdvance + elseif kind == "choice" then + out.kind = "choice" + rows({ "yes", "no" }) + out.locked = screen.pending ~= nil + elseif kind == "battle_bag" then + out.kind, out.page = "bag", screen.screen + local mem = screen.mem or screen.cur + out.pocket = screen.pocket + out.index = screen.cursor and screen.cursor.index + if screen.message then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.screen == "menu" then + rows({ "pocket:1", "pocket:2", "pocket:3", "pocket:4", "last_item", "back" }) + out.rows[5].enabled = mem.lastItem ~= nil + elseif screen.screen == "pocket" then + for i = 1, 6 do + local item = screen:itemAt(i) + refs["row:" .. i] = item + local r = row(out, item or {}, ids, true) + r.enabled = item ~= nil + end + row(out, "previous_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "next_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "back", ids) + else + rows({ "use", "back" }) + out.rows[1].enabled = screen:itemAt((mem.positions[screen.pocket] or 0) + 1) ~= nil + end + out.locked = screen.press ~= nil + elseif kind == "bag_actions" or kind == "item_actions" then + out.kind = "bag_actions" + rows(kind == "bag_actions" and screen.items or screen.entries) + if kind == "item_actions" then + for i, r in ipairs(out.rows) do + r.enabled = screen:pickable(i) + r.action = screen.entries[i].kind + end + else + out.canBack = screen.cancelable + end + else + out.kind = "bag" + out.pocket = screen.pocket or screen.pocketIndex + if screen.message or screen.resultMessage then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.menu then + out.kind, out.index = screen.menu.kind == "yesno" and "choice" or "bag_actions", screen.menu.index + rows(screen.menu.rows) + else + rows(kind == "bag_gb" and screen.items or kind == "bag_hgss" and screen:items() or screen.rows, true) + out.index = kind == "bag_hgss" and screen.page * 6 + screen.slot + or kind == "bag_bw" and screen.cursor + or screen.index + out.canPocket = not screen.lockPocket + and (kind == "bag_gb" and screen.onPocketSwitch ~= nil or kind ~= "bag_gb" and #(screen.pockets or {}) > 1) + or false + end + out.locked = screen.trash ~= nil and not screen.menu + or screen.moving ~= nil and screen.moving ~= false + or false + end + local battle = screen.battle or screen.publicUIBattle + if + battle and (battle.demo or battle.dudeDemo or battle.pokedudeDemo or battle.spectating or battle.dead) + then + out.locked = true + end + if screen.heal or screen.noInput or screen.script or screen.closed then + out.locked = true + end + if out.locked then + out.canAdvance, out.canPocket, out.canBack = false, false, false + end + return out, refs, kind +end +local function press(screen, ds) + local Sound = require("src.core.Sound") + if ds then + if Sound.playSe and screen.game.data then + Sound.playSe(screen.game.data, "SEQ_SE_DP_SELECT") + end + elseif screen.game.data then + Sound.play(screen.game.data, "Press_AB") + end +end +function Commands.dispatch(screen, kind, view, intent) + if intent.kind == "advance" then + if kind == "text" then + screen:advance() + elseif screen.selectNotice then + screen.selectNotice = nil + else + if screen.resultMessage then + press(screen) + end + screen.message, screen.resultMessage = nil, nil + end + elseif kind == "text" then + error("text only supports advance") + elseif kind == "choice" then + screen:choose(intent.kind == "back" and 2 or intent.index) + elseif kind == "battle_bag" then + if intent.kind == "select" then + screen.cursor.index = intent.index + end + screen:pick(intent.kind == "back" and ({ menu = 6, pocket = 9, use = 2 })[screen.screen] or intent.index) + elseif kind == "party_gb" then + press(screen) + if intent.kind == "back" then + screen:cancel() + else + if screen.submenu then + screen.subIndex = intent.index + else + screen.index = intent.index + end + screen:confirm() + end + elseif kind == "party_gba" or kind == "party_ds" then + if intent.kind == "back" then + if screen.submenu then + if screen.itemMenu then + screen.itemMenu, screen.submenu = nil, 1 + else + screen.submenu = nil + end + else + screen:cancel() + end + elseif screen.submenu then + if kind == "party_ds" then + screen.submenu = intent.index + screen:runAction(screen:actions()[intent.index]) + else + screen.subIndex = intent.index + local r = screen.submenu[intent.index] + screen:runAction(r.action, screen:party()[screen.index], r) + end + else + screen.index = intent.index + if kind == "party_gba" then + screen:confirm() + else + screen:choose() + end + end + elseif kind == "bag_actions" then + if intent.kind == "back" then + screen:cancel() + else + screen.index = intent.index + screen:choose() + end + elseif kind == "item_actions" then + press(screen) + if intent.kind == "select" then + screen.index = intent.index + end + screen:close(intent.kind == "back" and "cancel" or screen.entries[intent.index].kind) + elseif screen.menu then + if intent.kind == "back" then + if kind == "bag_ds" then + press(screen, true) + end + if screen.menu.kind == "yesno" then + screen:resolveTrash(false) + else + screen.menu = nil + end + else + screen.menu.index = intent.index + if kind ~= "bag_ds" then + screen:runMenuRow() + else + press(screen, true) + if screen.menu.kind == "yesno" then + screen:resolveTrash(intent.index == 1) + else + screen:runAction(screen.menu.rows[intent.index]) + end + end + end + elseif intent.kind == "pocket" then + if kind == "bag_gb" then + screen.onPocketSwitch(screen, intent.direction) + elseif kind == "bag_hgss" or kind == "bag_bw" then + screen:setPocket((screen.pocket - 1 + intent.direction) % #screen.pockets + 1) + else + screen.pocketLit = nil + screen:movePocket(intent.direction) + end + elseif intent.kind == "back" then + if kind == "bag_gb" then + screen:cancel() + else + if kind ~= "bag_gba" then + press(screen, true) + end + screen:close() + end + else + if kind == "bag_hgss" then + screen.page, screen.slot = math.floor((intent.index - 1) / 6), (intent.index - 1) % 6 + 1 + elseif kind == "bag_bw" then + screen.cursor = intent.index + screen.top = math.max(0, intent.index - 6) + else + screen.index = intent.index + if kind == "bag_ds" then + screen:clampScroll() + end + end + screen:choose() + end +end +return Commands diff --git a/src/world/MapOverview.lua b/src/world/MapOverview.lua new file mode 100644 index 000000000..adf912a6b --- /dev/null +++ b/src/world/MapOverview.lua @@ -0,0 +1,176 @@ +-- Generation-neutral terrain grid; optional GB tile luminance. All cells are +-- zero based. The scratch Map shares read-only data, but never the cold block +-- decoder's mutable definition or streamed renderer. +local Map = require("src.world.Map") +local Assets = require("src.render.Assets") +local Overview = {} +local shades = {} +Assets.register(function() + shades = {} +end) + +local function digit(sum, count) + return tostring(math.max(0, math.min(3, math.floor((1 - sum / count) * 3 + 0.5)))) +end + +local function visual(map) + local ts = map.tileset + -- GBA metatiles are composed/paletted by their renderer; their opaque tileAt + -- identifiers do not index a GB atlas. DS maps do not have one either. + if + map.blockCells ~= 2 + or not ts.blocks + or type(ts.image) ~= "string" + or type(ts.tilesPerRow) ~= "number" + or ts.tilesPerRow < 1 + or ts.tilesPerRow >= math.huge + or ts.tilesPerRow ~= math.floor(ts.tilesPerRow) + then + return nil + end + local path = Assets.resolve(ts.image) + local cache = shades[path] + if not cache then + if not (love and love.image and love.image.newImageData) then + return nil + end + local ok, pixels = pcall(love.image.newImageData, path) + if not ok or not pixels then + return nil + end + cache = { pixels = pixels, tiles = {} } + shades[path] = cache + end + local rows, details, pixels = {}, {}, cache.pixels + local width, height = pixels:getDimensions() + for y = 0, map.heightCells * 2 - 1 do + local row, top, bottom = {}, {}, {} + for x = 0, map.widthCells * 2 - 1 do + local tile = map:tileAt(x, y) + if type(tile) ~= "number" or tile < 0 or tile ~= math.floor(tile) then + return nil + end + local ox, oy = tile % ts.tilesPerRow * 8, math.floor(tile / ts.tilesPerRow) * 8 + if ox + 8 > width or oy + 8 > height then + return nil + end + local key = ts.tilesPerRow .. ":" .. tile + local s = cache.tiles[key] + if not s then + local sums = { 0, 0, 0, 0 } + for py = 0, 7 do + for px = 0, 7 do + local r, g, b = pixels:getPixel(ox + px, oy + py) + local q = math.floor(py / 4) * 2 + math.floor(px / 4) + 1 + sums[q] = sums[q] + r * 0.2126 + g * 0.7152 + b * 0.0722 + end + end + s = { + digit(sums[1] + sums[2] + sums[3] + sums[4], 64), + digit(sums[1], 16), + digit(sums[2], 16), + digit(sums[3], 16), + digit(sums[4], 16), + } + cache.tiles[key] = s + end + row[#row + 1], top[#top + 1], bottom[#bottom + 1] = s[1], s[2] .. s[3], s[4] .. s[5] + end + rows[#rows + 1] = table.concat(row) + details[#details + 1], details[#details + 2] = table.concat(top), table.concat(bottom) + end + return rows, details +end + +local function integer(n) + return type(n) == "number" and n >= 0 and n < math.huge and n == math.floor(n) +end + +function Overview.build(game, ow) + local map = ow.map + local w, h = map.widthCells, map.heightCells + if not integer(w) or not integer(h) or w == 0 or h == 0 then + return nil, "invalid map dimensions" + end + -- Outdoor DS terrain lives in lazily streamed renderer chunks. Walking that + -- renderer from a query can load/cache chunks. Interior permission grids are + -- already resident and can use the same pure Map classifiers below. + if map.def.generation == 4 and not map.def.behaviorCells then + return nil, "map overview unavailable for streamed terrain" + end + local scratch, def = {}, {} + for k, v in pairs(map) do + scratch[k] = v + end + for k, v in pairs(map.def) do + def[k] = v + end + scratch.def, scratch.renderer = def, nil + setmetatable(scratch, Map) + -- blockArray() writes only into the private definition on a cold GBA map. + -- It may reuse the warm immutable array, and honors the live block patches. + local rows, markers = {}, {} + local function marker(kind, x, y) + if integer(x) and integer(y) and x < w and y < h then + markers[#markers + 1] = { kind = kind, x = x, y = y } + end + end + for _, warp in ipairs(def.warps or {}) do + marker("warp", warp.x, warp.y) + end + -- The active object list is the result of native flag, session, time and + -- scripted spawn filters. Re-running those filters can consult another Game + -- singleton or initialize sprite state. Use the objects actually present. + for _, npc in ipairs(ow.npcs or {}) do + local obj = npc.def or {} + if obj.item and obj.item ~= 0 and obj.item ~= "0" and not npc.hidden and not obj.fruitTree then + marker("item", npc.cellX, npc.cellY) + end + end + local save = game.save or {} + local hidden = game.data and game.data.field and game.data.field.hiddenItems + for _, item in ipairs(hidden and hidden[map.id] or {}) do + if not (save.hiddenTaken and save.hiddenTaken[map.id .. "_" .. item.x .. "_" .. item.y]) then + marker("hidden", item.x, item.y) + end + end + -- Gen2/3 imports also carry hidden-item sign events, with their own taken + -- key and event flag (e.g. Cerulean Gym's Machine Part). + for _, item in ipairs(def.signs or {}) do + if + item.item + and item.item ~= 0 + and item.item ~= "0" + and not (item.eventFlag and save.flags and save.flags[item.eventFlag] == true) + and not (save.hiddenTaken and save.hiddenTaken[map.id .. "_sign_" .. item.x .. "_" .. item.y]) + then + marker("hidden", item.x, item.y) + end + end + for y = 0, h - 1 do + local row = {} + for x = 0, w - 1 do + row[#row + 1] = scratch:isWarpTileCell(x, y) and "+" + or scratch:isWaterCell(x, y) and "~" + or scratch:isWalkableCell(x, y) and "." + or " " + end + rows[#rows + 1] = table.concat(row) + end + local tiles, details = visual(scratch) + return { + mapId = map.id, + width = w, + height = h, + rows = rows, + markers = markers, + tileRows = tiles, + tileWidth = tiles and w * 2, + tileHeight = tiles and h * 2, + tileDetailRows = details, + tileDetailWidth = details and w * 4, + tileDetailHeight = details and h * 4, + } +end + +return Overview diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a17b2dd6e..6928ce3c4 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -15374,7 +15374,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.arriveWarp = nil local fromId = self.map.id local fromSection = self.map.def and self.map.def.regionMapSection - Game.stack:push(Transition.new(Game, function() + local transition = Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) self:noteGen2Spawn(fromId) -- FIRERED'S SIDE STAIRS (ExitStairsMovement): you land ON the stair tile @@ -15564,7 +15564,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- side, which is what the scripts already do. if self.player then self.player.inputLocked = false end if onDone then onDone() end - end)) + end) + -- API 3 exposes the Gen 3 no-fade option. Reuse the exact native midpoint + -- and completion path; ordinary/native callers keep their existing fade. + if opts and opts.fade == false then + transition.onMidpoint() + transition.onDone() + else + Game.stack:push(transition) + end end -- Re-read a map record after its data changed (WorldAPI:invalidateMap, diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..3e2ae3080 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,17 +6,21 @@ -- stays unsupported; anything a mod legitimately needs belongs here. local Logger = require("src.core.Logger") +local Actions = require("src.world.WorldActions") local MapLoader = require("src.world.MapLoader") local Gen3Elevation = require("src.world.Gen3Elevation") local Runtime = require("src.mods.Runtime") +local Reads = require("src.mods.WorldReads") +local WorldOverview = require("src.mods.WorldOverview") +local MapOverview = require("src.world.MapOverview") local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" -function WorldAPI.new(game, modId) - return setmetatable({ game = game, modId = modId }, WorldAPI) +function WorldAPI.new(game, modId, readContext) + return setmetatable({ game = game, modId = modId, readContext = readContext }, WorldAPI) end -- the live overworld, or nil. Game.overworld is the fast path; the stack @@ -58,6 +62,41 @@ function WorldAPI:current() elevation) } end +-- Detached common Pokemon records. This also works with a save at the title +-- screen: reading the party does not need an active overworld. +function WorldAPI:party() + return Reads.party(self.game, self.readContext) +end + +-- Require the expected active map to reject stale requests after a warp. +-- The second result describes block size/coordinates and the DS proxy grid. +function WorldAPI:activeBlockAt(mapId, bx, by) + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.activeBlockAt(ow.map, mapId, bx, by) +end + +-- Loaded maps only, active first and with distinct placements. loadedMaps() +-- retains its legacy shape/order; these records also describe block units. +function WorldAPI:liveMaps() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return Reads.liveMaps(ow) +end + +-- Detached, zero-based terrain and marker grids for companion UIs/minimaps. +function WorldAPI:mapOverview() + local ow = self:overworld() + if not ow or not ow.map then return nil, NO_OVERWORLD end + return MapOverview.build(self.game, ow) +end + +-- Table distribution before per-step RNG, repel, roamers and roll hooks. +-- Explicit map IDs also work at the title screen without loading that map. +function WorldAPI:effectiveEncounters(mapId, terrain, opts) + return WorldOverview.encounters(self.game, self:overworld(), self.overviewContext, mapId, terrain, opts) +end + -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) @@ -386,6 +425,14 @@ function WorldAPI:queueScript(rows, extra) return true end +-- Public field mutations are kept behind the same free-roam boundary. +function WorldAPI:canReorderParty() return Actions.canReorderParty(self.game, self:overworld()) end +function WorldAPI:reorderParty(fromSlot, toSlot) return Actions.reorderParty(self.game, self:overworld(), fromSlot, toSlot) end +function WorldAPI:availableFieldActions() return Actions.availableFieldActions(self.game, self:overworld()) end +function WorldAPI:useFieldAction(id, opts) return Actions.useFieldAction(self.game, self:overworld(), id, opts) end +function WorldAPI:canFly() return Actions.canFly(self.game, self:overworld()) end +function WorldAPI:flyTo(mapId) return Actions.flyTo(self.game, self:overworld(), mapId) end + -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua new file mode 100644 index 000000000..3244aa8a6 --- /dev/null +++ b/src/world/WorldActions.lua @@ -0,0 +1,471 @@ +-- Supported mutations go through the existing gameplay entry points. Shared +-- guards keep companion UIs out of screens/scripts/transitions that own input. +local GameVersion = require("src.core.GameVersion") +local Map = require("src.world.Map") +local FieldDefaults = require("src.world.FieldDefaults") +local Party = require("src.pokemon.Party") +local A = {} +local METHODS = { + canReorderParty = true, + reorderParty = true, + availableFieldActions = true, + useFieldAction = true, + canFly = true, + flyTo = true, +} +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", +} +local pending = setmetatable({}, { __mode = "k" }) +local planning = setmetatable({}, { __mode = "k" }) +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function A.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +local function ready(game, ow) + if not (game and game.save) then + return nil, "no save" + end + if not (ow and ow.map and ow.player) then + return nil, "no overworld" + end + local token = pending[game] + if token and token.state then + local held = false + for _, state in ipairs(game.stack and game.stack.states or {}) do + if state == token.state then + held = true + end + end + if game.stack and game.stack.top and game.stack:top() == token.state then + held = true + end + if + not held + or game.save ~= token.save + or game.data ~= token.data + or ow.map ~= token.map + or ow.player ~= token.player + then + pending[game] = nil + end + end + if not (game.stack and game.stack.top and game.stack:top() == ow) then + return nil, "world is busy" + end + if + pending[game] + or planning[game] + or (game.overworld and game.overworld ~= ow) + or game.linkSession + or (game.linkNet and not game.linkNet.closed) + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + local player = ow.player + if player.moving or player.inputLocked or player.stairExit or player.spinning or player.fieldMove then + return nil, "world is busy" + end + if not (ow.runner and type(ow.runner.isRunning) == "function") then + return nil, "world is busy" + end + if ow.runner:isRunning() or #(ow.scriptMoves or {}) > 0 or #(ow.pendingScripts or {}) > 0 then + return nil, "world is busy" + end + return true +end +-- Shared admission guard for the API-3 warp adapter. +A.ready = ready +local function guardedCall(game, fn, ...) + planning[game] = true + local result = { pcall(fn, game, ...) } + planning[game] = nil + if not result[1] then + error(result[2], 0) + end + return result[2], result[3] +end +local function party(game) + return game and game.save and game.save.party or {} +end +local function member(game, mon) + if type(mon) ~= "table" or Party.isEgg(mon) then + return nil + end + for _, p in ipairs(party(game)) do + if p == mon then + return mon + end + end +end +local function user(game, ow, move) + if type(ow.partyKnows) ~= "function" then + return nil + end + return member(game, ow:partyKnows(move)) +end +local function context(game, ow) + local p = ow.player + return { + save = game.save, + data = game.data, + map = ow.map, + player = p, + x = p.cellX, + y = p.cellY, + facing = p.facing, + version = GameVersion.get(), + } +end +local function same(game, ow, c) + return game.save == c.save + and game.data == c.data + and ow.map == c.map + and ow.player == c.player + and ow.player.cellX == c.x + and ow.player.cellY == c.y + and ow.player.facing == c.facing + and GameVersion.get() == c.version +end +local function currentWorld(game, ow) + -- A state replaced while a cut-in was active must not receive its effect. + return game.stack and game.stack.top and game.stack:top() == ow +end +local function present(game, ow, mon, check, run) + local c = context(game, ow) + local called = false + local token = { save = game.save, data = game.data, map = ow.map, player = ow.player } + pending[game] = token + local result, reason + local function finish() + if called then + return + end + called = true + if pending[game] ~= token then + return + end + pending[game] = nil + if + not currentWorld(game, ow) + or not same(game, ow, c) + or not ready(game, ow) + or not member(game, mon) + then + result, reason = nil, "world changed" + return + end + if not check() then + result, reason = nil, "action unavailable" + return + end + -- A capability hook invoked by check() may have changed the session. + if not same(game, ow, c) or not ready(game, ow) then + result, reason = nil, "world changed" + return + end + local answer = guardedCall(game, run) + if answer == false then + result, reason = nil, "action unavailable" + else + result = true + end + end + if GameVersion.generation() == 3 then + -- Never retry an engine callback that threw after invoking finish(). + local ok, shown = pcall(function() + return require("src.world.Gen3FieldMove").show(game, mon, finish) + end) + if not ok then + pending[game] = nil + error(shown, 0) + end + if shown then + token.state = game.stack:top() + return true + end + end + finish() + return result, reason +end + +function A.canReorderParty(game, ow) + return #party(game) > 1 and ready(game, ow) == true +end +function A.reorderParty(game, ow, from, to) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + local p = party(game) + if + not integer(from, 1, Party.MAX) + or not integer(to, 1, Party.MAX) + or type(p[from]) ~= "table" + or type(p[to]) ~= "table" + then + return nil, "invalid party slot" + end + -- Match the native party menus: swap references, never reconstruct mons. + if from ~= to then + p[from], p[to] = p[to], p[from] + end + return true +end + +local function outside(game, ow) + return Map.isOutside(ow.map.def, FieldDefaults.field(game.data, "outsideTilesets")) +end +local function validSpot(data, id, x, y, gen) + local def = data and data.maps and data.maps[id] + if not def or not integer(def.width, 1, 1000000) or not integer(def.height, 1, 1000000) then + return false + end + local tileset = data.tilesets and data.tilesets[def.tileset] + local cells = tileset and tileset.blockCells or (gen >= 3 and 1 or 2) + return integer(cells, 1, 1000000) + and integer(x, 0, def.width * cells - 1) + and integer(y, 0, def.height * cells - 1) +end +local function flyPlan(game, ow, mapId) + local gen = GameVersion.generation() + local data, save = game.data or {}, game.save + local mon + if gen >= 4 then + local F = require("src.world.Gen4FieldMoves") + for _, p in ipairs(party(game)) do + if not Party.isEgg(p) and F.knows(p, "FLY") then + mon = p + break + end + end + if not mon then + return nil, "fly unavailable" + end + if gen == 5 then + local Fly = require("src.world.Gen5Fly") + if not Fly.allowed(ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, loc in ipairs(Fly.destinations(data, save)) do + local dest = Fly.destination(data, loc) + if dest and (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = dest, ds = true } + end + end + else + local Fly = require("src.world.Gen4Fly") + local costume = require("src.inventory.Gen4FieldChecks").inCostume(data, save) + if costume or not Fly.check(data, save, ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, dest in ipairs(Fly.destinations(data, save)) do + if (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = { map = dest.map, x = dest.x, y = dest.y }, ds = true } + end + end + end + else + mon = user(game, ow, "FLY") + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not mon or not travel or type(ow.flyTo) ~= "function" then + return nil, "fly unavailable" + end + local field = data.field or {} + for _, id in ipairs(field.flyOrder or {}) do + local def = data.maps and data.maps[id] + local spot = field.flyWarps and field.flyWarps[id] + if + (not mapId or mapId == id) + and save.visited + and save.visited[id] + and spot + and def + and (Map.isOutdoor(def) or def.tileset == "PLATEAU") + and validSpot(data, id, spot.x, spot.y, gen) + then + return { mon = mon, dest = { map = id, x = spot.x, y = spot.y } } + end + end + end + return nil, "destination unavailable" +end +function A.canFly(game, ow) + if not ready(game, ow) then + return false + end + local c = context(game, ow) + return guardedCall(game, flyPlan, ow) ~= nil and same(game, ow, c) and ready(game, ow) == true +end +function A.flyTo(game, ow, mapId) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(mapId) ~= "string" or mapId == "" then + return nil, "invalid destination" + end + local c = context(game, ow) + local plan, why = guardedCall(game, flyPlan, ow, mapId) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, flyPlan, ow, mapId) + return fresh and fresh.mon == plan.mon and fresh.dest.x == plan.dest.x and fresh.dest.y == plan.dest.y + end, function() + if plan.ds then + return ow:gen4FlyTo(plan.dest, plan.mon) + end + return ow:flyTo(plan.dest.map, plan.mon) + end) +end + +local ORDER = { "cut", "surf", "dig", "teleport" } +local function fieldPlan(game, ow, id) + local gen = GameVersion.generation() + if gen >= 4 then + return nil, "DS field actions require a native script adapter" + end + if id == "cut" then + if gen == 3 then + return nil, "Gen 3 Cut requires an object-script adapter" + end + local mon = user(game, ow, "CUT") + if + mon + and type(ow.useCutFieldMove) == "function" + and ow:useCutFieldMove() == "ok" + and type(ow.tryCut) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:tryCut(x, y) + end, + } + end + elseif id == "surf" then + local mon = user(game, ow, "SURF") + if + mon + and not ow.player.surfing + and type(ow.useSurfFieldMove) == "function" + and ow:useSurfFieldMove() == "ok" + and type(ow.trySurf) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:trySurf(x, y, nil, mon) + end, + } + end + elseif id == "dig" or id == "teleport" then + local mon = user(game, ow, id:upper()) + if not mon or type(ow.beginTeleportOut) ~= "function" then + return nil, "field action unavailable" + end + local escape = false + if id == "teleport" then + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not travel or not game.save.lastHeal then + return nil, "field action unavailable" + end + elseif gen == 1 then + local tiles = { FOREST = true, CEMETERY = true, CAVERN = true, FACILITY = true, INTERIOR = true } + if not tiles[ow.map.def.tileset] or ow.map.id == "AGATHAS_ROOM" or not game.save.lastHeal then + return nil, "field action unavailable" + end + else + local point = type(ow.escapePoint) == "function" and ow:escapePoint() + if not point then + return nil, "field action unavailable" + end + if gen == 3 then + if not require("src.world.Gen3FieldRules").allowsEscape(ow.map.def, point) then + return nil, "field action unavailable" + end + elseif ow.map.def.environment ~= 4 and ow.map.def.environment ~= 7 then + return nil, "field action unavailable" + end + escape = true + end + return { + mon = mon, + run = function() + return ow:beginTeleportOut(nil, { escape = escape or nil }) + end, + } + end + return nil, "field action unavailable" +end +function A.availableFieldActions(game, ow) + local ok, reason = ready(game, ow) + if not ok then + return {}, reason + end + local c = context(game, ow) + local out = {} + for _, id in ipairs(ORDER) do + if guardedCall(game, fieldPlan, ow, id) then + out[#out + 1] = { id = id, label = id:upper() } + end + if not same(game, ow, c) or not ready(game, ow) then + return {}, "world changed" + end + end + return out +end +function A.useFieldAction(game, ow, id, opts) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(id) ~= "string" then + return nil, "invalid field action" + end + if opts ~= nil and (type(opts) ~= "table" or next(opts) ~= nil or getmetatable(opts) ~= nil) then + return nil, "unsupported field action options" + end + local c = context(game, ow) + local plan, why = guardedCall(game, fieldPlan, ow, id) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, fieldPlan, ow, id) + return fresh and fresh.mon == plan.mon + end, plan.run) +end +return A diff --git a/tests/api3_contract/datasets.lua b/tests/api3_contract/datasets.lua new file mode 100644 index 000000000..5fc4d1260 --- /dev/null +++ b/tests/api3_contract/datasets.lua @@ -0,0 +1,104 @@ +-- Native cache formats differ; the mod and assertions are identical. +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/datasets%.lua$") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation(version) + local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen) + local writer = require("src.import.LuaWriter") + local prefix = Version.cachePrefix(version) + local files = {} + if backend == "gen1" then + local Contract = require("src.import.CacheContract") + if gen < 3 then + require("tests.modkit.dataset_view_fixture").cache(files, version, { + pokemon = data.pokemon, + moves = data.moves, + items = data.items, + maps = data.maps, + trainers = data.trainers, + encounters = data.encounters, + type_chart = data.type_chart, + }) + else + files[prefix .. "rom-cache.complete"] = Contract.markerFor(version) + for _, path in ipairs(Contract.requiredFiles(version, true)) do + files[prefix .. path] = path:match("%.lua$") and writer.encode({}) or "fixture" + end + local p = data.gen3Pokemon + for _, key in ipairs({ + "names", + "types", + "stats", + "abilities", + "abilityNames", + "meta", + "learnsets", + "evolutions", + "dex", + "moveNames", + }) do + local file = ({ abilityNames = "ability_names", moveNames = "move_names" })[key] or key + files[prefix .. "data/generated/gba/pokemon/" .. file .. ".lua"] = + writer.encode(p[({ meta = "_speciesMeta" })[key] or "_" .. key] or {}) + end + files[prefix .. "data/generated/maps.lua"] = writer.encode(data.maps) + files[prefix .. "data/generated/gba/pokemon/battle_moves.lua"] = + writer.encode(data.gen3Moves._rom or {}) + files[prefix .. "data/generated/gba/items/pack.lua"] = writer.encode({ items = data.gen3Items._byId }) + end + else + local Contract = require("src.import.RomImporter").datasetContract(version) + files[prefix .. "rom-cache.complete"] = Contract.marker + for _, name in ipairs(Contract.modules) do + files[prefix .. "data/generated/" .. name .. ".lua"] = writer.encode(data[name] or {}) + end + end + files[prefix .. "assets/generated/probe.png"] = "PNG" + local function read(path) + local f = assert(io.open(path, "rb")) + local s = f:read("*a") + f:close() + return s + end + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/datasets/" .. name] = read(root .. "/mods/datasets/" .. name) + end + local fs = { + read = function(path) + return files[path] + end, + getInfo = function(path) + if files[path] then + return { type = "file", size = #files[path] } + end + if path == "mods" or path == "mods/datasets" then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "datasets" } or {} + end, + load = function(path) + return loadstring(assert(files[path]), "@" .. path) + end, + } + local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) + loader._game = function() + error("dataset access must not bootstrap Game") + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return { loader = loader, exports = assert(loader.exports.datasets), files = files, fs = fs, prefix = prefix } +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local before = require("src.core.GameVersion").get() + local report, why = f.exports.inspect(version) + assert(report, why) + assert(require("src.core.GameVersion").get() == before) + assert(f.exports.open("not_a_version") == nil) + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/effects.lua b/tests/api3_contract/effects.lua new file mode 100644 index 000000000..15430e813 --- /dev/null +++ b/tests/api3_contract/effects.lua @@ -0,0 +1,13 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/effects%.lua$") + return dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "effects") +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = f.exports.report() + assert(report.initial == "NO_ADDITIONAL_EFFECT" and report.final == "SLEEP_EFFECT") + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/flags.lua b/tests/api3_contract/flags.lua new file mode 100644 index 000000000..ec36d5ddf --- /dev/null +++ b/tests/api3_contract/flags.lua @@ -0,0 +1,72 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/flags%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "flags") + if backend == "gen1" and f.gen == 2 then + f.ow.events = require("src.world.gen2.Events").new() + elseif backend == "gen1" and f.gen == 3 then + package.loaded["src.core.game3.scripting.space"].store = { flags = f.game.save.flags, vars = {} } + end + if f.gen == 1 then + f.number, f.symbol = 0, "EVENT_FOLLOWED_OAK_INTO_LAB" + elseif f.gen == 2 then + f.number, f.symbol = 16, "EVENT_GOT_HM01_CUT" + else + f.symbol = "FLAG_SYS_POKEDEX_GET" + local catalog = dofile( + codeRoot .. "/../../src/mods/flag_names/" .. (version == "emerald" and "emerald" or "firered") .. ".lua" + ) + for number, row in pairs(catalog.records) do + for _, alias in ipairs(row) do + if alias == f.symbol then + f.number = number + end + end + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = { invalid = {} } + for _, name in ipairs({ -1, 0.5, math.huge, "UNKNOWN_NATIVE_FLAG", {}, false }) do + local value, why = f.exports.set(name, true) + assert( + value == nil + and ( + why == "invalid or unknown flag" + or version == "crystal" + and type(name) == "string" + and why == "symbolic event flags unavailable for Crystal; use native ids" + ), + tostring(why) + ) + assert(#f.exports.events() == 0) + report.invalid[#report.invalid + 1] = why + end + assert(f.exports.get(f.number) == false) + assert(f.exports.set(f.number, true)) + assert(f.exports.get(f.number) == true) + assert(f.exports.set(f.number, true)) + assert(#f.exports.events() == 1) + if version == "crystal" then + local value, why = f.exports.get(f.symbol) + assert(value == nil and why == "symbolic event flags unavailable for Crystal; use native ids") + report.symbol = why + else + assert(f.exports.get(f.symbol) == true) + assert(f.exports.set(f.symbol, false)) + assert(f.exports.get(f.number) == false) + assert(#f.exports.events() == 2) + report.symbol = true + end + report.events = f.exports.events() + assert(f.exports.set("mod:quest", true)) + assert(f.exports.get("mod.flags.quest") == true) + local value, why = f.exports.set("mod.other.quest", true) + assert(value == nil and why == "foreign mod flag") + report.private = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/invalidate.lua b/tests/api3_contract/invalidate.lua new file mode 100644 index 000000000..56b11041a --- /dev/null +++ b/tests/api3_contract/invalidate.lua @@ -0,0 +1,54 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/invalidate%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "invalidate") + local Runtime = require("src.mods.Runtime") + f.reloads, f.loads = 0, {} + function f:reload(id, why) + self.reloads = self.reloads + 1 + self.loads[#self.loads + 1] = { id, why } + if self.reloadError then + error("reload probe", 0) + end + if self.ow.map.id == id then + self.ow.map = { id = id, def = self.game.data.maps[id] } + end + end + function f.ow:reloadMap(id, why) + f:reload(id, why) + Runtime.emit("map.reloaded", { mapId = id, reason = why }) + end + function f.ow:reloadMapBadWarp() + f:reload(self.map.id, "invalidate") + end + if backend == "gen1" and f.gen == 3 then + function f.map.load(_, game, id, opts) + assert(game == f.game and opts.x == f.player.cellX and opts.facing == f.player.facing) + f:reload(id, "invalidate") + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = { invalid = {} } + for _, id in ipairs({ "", "NO_SUCH_MAP", "HOME\n", 42, false }) do + local value, why = f.exports.request(id) + assert(value == nil and why == "unknown map") + assert(f.reloads == 0 and #f.exports.events() == 0) + report.invalid[#report.invalid + 1] = why + end + local old = f.ow.map + assert(f.exports.request(f.home)) + assert(f.reloads == 1 and f.ow.map ~= old) + assert(f.player.cellX == 0 and f.player.cellY == 0 and f.player.facing == "left") + assert(#f.exports.events() == 1 and f.exports.events()[1].mapId == f.home) + report.active = f.exports.events() + f = M.fixture(root, backend, version) + assert(f.exports.request(f.dest)) + assert(f.ow.map.id == f.home) + report.inactive = true -- Native inactive notification differences are not normalized. + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/mods/datasets/main.lua b/tests/api3_contract/mods/datasets/main.lua new file mode 100644 index 000000000..f74b45333 --- /dev/null +++ b/tests/api3_contract/mods/datasets/main.lua @@ -0,0 +1,33 @@ +local mod = ... +mod.exports.inspect = function(version) + local view, why = mod.datasets:open(version) + if not view then + return nil, why + end + local row = assert(view.content.pokemon:get("MEW")) + local before = row.baseStats.hp + row.baseStats.hp = 250 + assert(view.content.pokemon:get("MEW").baseStats.hp == before) + assert(view.content.pokemon:has("MEW") and not view.content.pokemon:has("MISSING")) + local found = false + for id, value in view.content.pokemon:each() do + if id == "MEW" then + found = value.baseStats.hp == before + end + end + assert(found and view.content.pokemon.patch == nil and view.content.pokemon.register == nil) + return { + version = view.version, + generation = view.generation, + id = row.id, + hp = before, + special = view.content.pokemon:get("MEW").baseStats.special, + detached = true, + readonly = true, + path = type(view.assets:path("assets/generated/probe.png")) == "string", + size = view.assets:info("assets/generated/probe.png").size, + } +end +mod.exports.open = function(version) + return mod.datasets:open(version) +end diff --git a/tests/api3_contract/mods/datasets/manifest.json b/tests/api3_contract/mods/datasets/manifest.json new file mode 100644 index 000000000..31fa4cc90 --- /dev/null +++ b/tests/api3_contract/mods/datasets/manifest.json @@ -0,0 +1 @@ +{"id":"datasets","name":"Read-only version datasets","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/edits/main.lua b/tests/api3_contract/mods/edits/main.lua new file mode 100644 index 000000000..d26c5321e --- /dev/null +++ b/tests/api3_contract/mods/edits/main.lua @@ -0,0 +1,37 @@ +-- Identical source on both engines; native layout stays in the driver. +local mod = ... +local world = mod.world +assert(mod:supports("world", "toggleObject")) +assert(mod:supports("world", "replaceBlock")) +local ok, why = world:toggleObject("HOME", "NPC", false) +assert(ok == nil and why == "no game") +ok, why = world:replaceBlock(0, 0, 7) +assert(ok == nil and why == "no game") +local events, reenter, nested = {}, false, nil +mod.events:on("world.object_toggled", function(ev) + events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible } + if reenter then + nested = { world:replaceBlock(0, 0, 7) } + end +end) +mod.events:on("world.block_replaced", function(ev) + events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, block = ev.block } +end) +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.replace = function(...) + return world:replaceBlock(...) +end +mod.exports.read = function(...) + return world:activeBlockAt(...) +end +mod.exports.events = function() + return events +end +mod.exports.reenter = function(value) + reenter = value +end +mod.exports.nested = function() + return nested +end diff --git a/tests/api3_contract/mods/edits/manifest.json b/tests/api3_contract/mods/edits/manifest.json new file mode 100644 index 000000000..8d0912a07 --- /dev/null +++ b/tests/api3_contract/mods/edits/manifest.json @@ -0,0 +1 @@ +{"id":"edits","name":"Portable World Edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/effects/main.lua b/tests/api3_contract/mods/effects/main.lua new file mode 100644 index 000000000..a01378e23 --- /dev/null +++ b/tests/api3_contract/mods/effects/main.lua @@ -0,0 +1,33 @@ +local mod = ... +local moves = mod.content.moves +local report = { initial = assert(moves:get("POUND")).effect, effects = {} } +for _, id in ipairs({ + "SLEEP_EFFECT", + "BURN_SIDE_EFFECT1", + "BURN_SIDE_EFFECT2", + "SPECIAL_UP1_EFFECT", + "SPECIAL_DOWN_SIDE_EFFECT", + "NO_ADDITIONAL_EFFECT", +}) do + assert(moves:patch("POUND", { effect = id })) + local row = assert(moves:get("POUND")) + assert(row.effect == id) + report.effects[#report.effects + 1] = row.effect + if mod.generation > 1 then + assert(moves:patch("POUND", { secondaryChance = 25 })) + assert(moves:get("POUND").secondaryChance == 25) + report.chance = 25 + end + local detached = assert(moves:get("POUND")) + assert(moves:override("POUND", detached)) + assert(moves:get("POUND").effect == id) +end +local value, why = moves:patch("POUND", { effect = "native:1:not_original" }) +assert(value == nil and type(why) == "string") +assert(moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +assert(moves:patch("POUND", { effect = "SLEEP_EFFECT" })) +report.final = moves:get("POUND").effect +report.preflight = true +mod.exports.report = function() + return report +end diff --git a/tests/api3_contract/mods/effects/manifest.json b/tests/api3_contract/mods/effects/manifest.json new file mode 100644 index 000000000..48e0d38a3 --- /dev/null +++ b/tests/api3_contract/mods/effects/manifest.json @@ -0,0 +1 @@ +{"id":"effects","name":"Portable move effects","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/flags/main.lua b/tests/api3_contract/mods/flags/main.lua new file mode 100644 index 000000000..d15a56810 --- /dev/null +++ b/tests/api3_contract/mods/flags/main.lua @@ -0,0 +1,17 @@ +local mod = ... +local world = mod.world +local value, why = world:getFlag(1) +assert(value == nil and why == "no game") +local events = {} +mod.events:on("flag.changed", function(ev) + events[#events + 1] = { name = ev.id or ev.name, value = ev.value } +end) +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.set = function(name, value) + return world:setFlag(name, value) +end +mod.exports.events = function() + return events +end diff --git a/tests/api3_contract/mods/flags/manifest.json b/tests/api3_contract/mods/flags/manifest.json new file mode 100644 index 000000000..0394a27f6 --- /dev/null +++ b/tests/api3_contract/mods/flags/manifest.json @@ -0,0 +1 @@ +{"id":"flags","name":"Native flag contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/invalidate/main.lua b/tests/api3_contract/mods/invalidate/main.lua new file mode 100644 index 000000000..efed5c918 --- /dev/null +++ b/tests/api3_contract/mods/invalidate/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local world = mod.world +assert(mod:supports("world", "invalidateMap")) +local value, why = world:invalidateMap("HOME") +assert(value == nil and why == "no game") +local events = {} +mod.events:on("map.reloaded", function(ev) + events[#events + 1] = { mapId = ev.mapId, reason = ev.reason } +end) +mod.exports.request = function(...) + return world:invalidateMap(...) +end +mod.exports.events = function() + return events +end diff --git a/tests/api3_contract/mods/invalidate/manifest.json b/tests/api3_contract/mods/invalidate/manifest.json new file mode 100644 index 000000000..4a1728907 --- /dev/null +++ b/tests/api3_contract/mods/invalidate/manifest.json @@ -0,0 +1 @@ +{"id":"invalidate","name":"Map invalidation contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/npcs/main.lua b/tests/api3_contract/mods/npcs/main.lua new file mode 100644 index 000000000..696dfaafe --- /dev/null +++ b/tests/api3_contract/mods/npcs/main.lua @@ -0,0 +1,28 @@ +-- Same public-only mod on both engines. Native timing stays in the driver. +local mod = ... +local world = mod.world +for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + assert(mod:supports("world", name)) +end +local ok, why = world:npc("HOME", 1) +assert(ok == nil and why == "no game") +local calls, args = 0, nil +mod.exports.spawn = function(...) + return world:spawnNpc(...) +end +mod.exports.remove = function(...) + return world:removeNpc(...) +end +mod.exports.npc = function(...) + return world:npc(...) +end +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.done = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.result = function() + return { calls = calls, args = args } +end diff --git a/tests/api3_contract/mods/npcs/manifest.json b/tests/api3_contract/mods/npcs/manifest.json new file mode 100644 index 000000000..16a8a3457 --- /dev/null +++ b/tests/api3_contract/mods/npcs/manifest.json @@ -0,0 +1 @@ +{"id":"npcs","name":"Portable NPC Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/portable/main.lua b/tests/api3_contract/mods/portable/main.lua new file mode 100644 index 000000000..a428b942f --- /dev/null +++ b/tests/api3_contract/mods/portable/main.lua @@ -0,0 +1,110 @@ +-- This exact entry and manifest run on both engines. No engine imports, +-- format switches or engine-specific branches belong in this fixture. +local mod = ... +local world, battle = mod.world, mod.battle +assert(type(world) == "table" and type(battle) == "table", "early handles") +local function refused(value, reason) + assert(value == nil and type(reason) == "string" and #reason > 0, tostring(reason)) +end +refused(world:party()) +refused(battle:snapshot()) +local no, why = mod:supports("world", "not_a_method") +assert(no == false and type(why) == "string") +assert(mod:supports("content", "pokemon")) +assert(mod:supports("hooks", "evolution.check")) +assert(mod:supports("events", "pokemon.received")) +assert(mod:supports("world", "party")) +-- Known API-3 names are registrable even when this host cannot execute them. +mod.hooks:wrap("render.output", function(next, ctx) + return next(ctx) +end) +mod.events:on("checkpoint.restored", function() end) +local c = mod.content +assert(c.pokemon:has("MEW")) +assert(c.maps:supports() and c.maps.available) +assert(c.maps:has("ABSENT_MAP") == false) +local map = assert(c.maps:get("PROBE")) +map.width = 99 +assert(c.maps:get("PROBE").width == 2) +for id, row in c.maps:each() do + if id == "api3Probe" then + row.value = 88 + end +end +assert(c.maps:get("PROBE").width == 2) +local before = assert(c.pokemon:get("MEW")) +assert(before.id == "MEW" and before.baseStats.hp == 100) +assert(before.level1Moves[1] == "POUND") +assert(before.baseStats.special == 100) +assert(c.moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +before.baseStats.hp = 222 +assert(c.pokemon:get("MEW").baseStats.hp == 100, "detached read") +assert(c.pokemon:patch("MEW", { baseStats = { hp = 101 } })) +assert(c.moves:patch("POUND", { power = 45 })) +assert(c.items:patch("POTION", { price = 123 })) +local found = false +for id, row in c.pokemon:each() do + if id == "MEW" then + found = true + assert(row.baseStats.hp == 101) + end +end +assert(found) +local report = { species = "MEW", hp = 101, move = "POUND", power = 45, price = 123 } +local function monCheck(mon) + assert(mon.species == "MEW" and mon.nickname == "Scout" and mon.name == "Scout") + assert(mon.status == "PSN", "short status") + assert(type(mon.shiny) == "boolean", "shiny presentation") + assert(mon.stats.special == 24 and mon.stats.hp == 40, "common stats") + assert(mon.moves[1].id == "POUND" and mon.moves[1].pp == 30 and mon.moves[1].maxPp == 35) +end +mod.events:on("pokemon.received", function(payload) + monCheck(payload.mon) + payload.mon.hp = 1 + report.event = true +end) +mod.hooks:wrap("evolution.check", function(next, game, mon, evolution, trigger) + monCheck(mon) + mon.hp, mon.status = 27, "BRN" + mon.stats.special = 31 + report.hook = true + report.special = 31 + return next(game, mon, evolution, trigger) +end) +mod.events:on("mod.portable.probe", function(payload) + report.custom = payload.value +end) +mod.events:emit("mod.portable.probe", { value = 7 }) +mod.exports.exercise = function() + local first = assert(world:party()) + monCheck(first[1]) + first[1].hp = 1 + assert(world:party()[1].hp == 30, "detached party") + report.party = true + report.status, report.shiny = first[1].status, first[1].shiny + assert(world:getFlag("mod:contract.flag") == false) + assert(world:setFlag("mod:contract.flag", true)) + assert(world:getFlag("mod:contract.flag") == true) + assert(world:setFlag("mod:contract.flag", false)) + assert(world:getFlag("mod:contract.flag") == false) + report.flags = true +end +mod.exports.snapshot = function() + local first = assert(battle:snapshot()) + assert(first.kind == "wild" and first.prompt == "menu") + monCheck(first.player) + assert(first.player.hp == 30 and first.player.maxHp == 40) + assert(type(first.revision) == "number" and first.revision > 0) + first.player.hp = 1 + assert(battle:snapshot().player.hp == 30, "detached battle snapshot") + refused(battle:submit({ id = 1, revision = first.revision, kind = "not_an_intent" })) + report.battle = + { kind = first.kind, prompt = first.prompt, species = first.player.species, status = first.player.status } +end +mod.exports.late = function() + local ok, value, reason = pcall(function() + return c.moves:patch("POUND", { power = 99 }) + end) + assert(not ok or (value == nil and type(reason) == "string"), "frozen content") +end +mod.exports.world, mod.exports.battle, mod.exports.report = world, battle, report diff --git a/tests/api3_contract/mods/portable/manifest.json b/tests/api3_contract/mods/portable/manifest.json new file mode 100644 index 000000000..d98d5f821 --- /dev/null +++ b/tests/api3_contract/mods/portable/manifest.json @@ -0,0 +1 @@ +{"id":"portable","name":"Portable API contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/rollback/main.lua b/tests/api3_contract/mods/rollback/main.lua new file mode 100644 index 000000000..daf6abe4a --- /dev/null +++ b/tests/api3_contract/mods/rollback/main.lua @@ -0,0 +1,6 @@ +local mod = ... +assert(mod.content.pokemon:patch("MEW", { baseStats = { hp = 199 } })) +mod.events:on("mod.portable.probe", function() + error("failed subscription leaked") +end) +error("intentional API 3 rollback") diff --git a/tests/api3_contract/mods/rollback/manifest.json b/tests/api3_contract/mods/rollback/manifest.json new file mode 100644 index 000000000..ab532b029 --- /dev/null +++ b/tests/api3_contract/mods/rollback/manifest.json @@ -0,0 +1 @@ +{"id":"rollback","name":"Rollback contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","dependencies":["portable"]} diff --git a/tests/api3_contract/mods/script_audio/main.lua b/tests/api3_contract/mods/script_audio/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_audio/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_audio/manifest.json b/tests/api3_contract/mods/script_audio/manifest.json new file mode 100644 index 000000000..c4f8be8ba --- /dev/null +++ b/tests/api3_contract/mods/script_audio/manifest.json @@ -0,0 +1 @@ +{"id":"script_audio","name":"Portable audio","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_battles/main.lua b/tests/api3_contract/mods/script_battles/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_battles/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_battles/manifest.json b/tests/api3_contract/mods/script_battles/manifest.json new file mode 100644 index 000000000..33210dc51 --- /dev/null +++ b/tests/api3_contract/mods/script_battles/manifest.json @@ -0,0 +1 @@ +{"id":"script_battles","name":"Portable battles","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_dialogue_audio/main.lua b/tests/api3_contract/mods/script_dialogue_audio/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_dialogue_audio/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_dialogue_audio/manifest.json b/tests/api3_contract/mods/script_dialogue_audio/manifest.json new file mode 100644 index 000000000..d81be00df --- /dev/null +++ b/tests/api3_contract/mods/script_dialogue_audio/manifest.json @@ -0,0 +1 @@ +{"id":"script_dialogue_audio","name":"Portable dialogue audio","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_fields/main.lua b/tests/api3_contract/mods/script_fields/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_fields/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_fields/manifest.json b/tests/api3_contract/mods/script_fields/manifest.json new file mode 100644 index 000000000..c50380150 --- /dev/null +++ b/tests/api3_contract/mods/script_fields/manifest.json @@ -0,0 +1 @@ +{"id":"script_fields","name":"Portable script fields","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_flags/main.lua b/tests/api3_contract/mods/script_flags/main.lua new file mode 100644 index 000000000..3a96b1e6b --- /dev/null +++ b/tests/api3_contract/mods/script_flags/main.lua @@ -0,0 +1,20 @@ +local mod = ... +local world = mod.world +assert(mod:supports("world", "queueScript")) +local done, success = 0 +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.set = function(name, value) + return world:setFlag(name, value) +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.result = function() + return { done = done, success = success } +end diff --git a/tests/api3_contract/mods/script_flags/manifest.json b/tests/api3_contract/mods/script_flags/manifest.json new file mode 100644 index 000000000..2dcca5648 --- /dev/null +++ b/tests/api3_contract/mods/script_flags/manifest.json @@ -0,0 +1 @@ +{"id":"script_flags","name":"Portable script flags","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_gifts/main.lua b/tests/api3_contract/mods/script_gifts/main.lua new file mode 100644 index 000000000..4c1039daf --- /dev/null +++ b/tests/api3_contract/mods/script_gifts/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local world = mod.world +local done, success = 0 +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.result = function() + return { done = done, success = success } +end diff --git a/tests/api3_contract/mods/script_gifts/manifest.json b/tests/api3_contract/mods/script_gifts/manifest.json new file mode 100644 index 000000000..7129948d2 --- /dev/null +++ b/tests/api3_contract/mods/script_gifts/manifest.json @@ -0,0 +1 @@ +{"id":"script_gifts","name":"Portable gifts","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_motion/main.lua b/tests/api3_contract/mods/script_motion/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_motion/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_motion/manifest.json b/tests/api3_contract/mods/script_motion/manifest.json new file mode 100644 index 000000000..f6452d302 --- /dev/null +++ b/tests/api3_contract/mods/script_motion/manifest.json @@ -0,0 +1 @@ +{"id":"script_motion","name":"Portable motion","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_shops/main.lua b/tests/api3_contract/mods/script_shops/main.lua new file mode 100644 index 000000000..2d21c725b --- /dev/null +++ b/tests/api3_contract/mods/script_shops/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success, reason = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok, why) + done, success, reason = done + 1, ok, why + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, reason = reason } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_shops/manifest.json b/tests/api3_contract/mods/script_shops/manifest.json new file mode 100644 index 000000000..d53ea01c5 --- /dev/null +++ b/tests/api3_contract/mods/script_shops/manifest.json @@ -0,0 +1 @@ +{"id":"script_shops","name":"Portable shops","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_state/main.lua b/tests/api3_contract/mods/script_state/main.lua new file mode 100644 index 000000000..4c1039daf --- /dev/null +++ b/tests/api3_contract/mods/script_state/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local world = mod.world +local done, success = 0 +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.result = function() + return { done = done, success = success } +end diff --git a/tests/api3_contract/mods/script_state/manifest.json b/tests/api3_contract/mods/script_state/manifest.json new file mode 100644 index 000000000..3f29a475f --- /dev/null +++ b/tests/api3_contract/mods/script_state/manifest.json @@ -0,0 +1 @@ +{"id":"script_state","name":"Portable state actions","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_trainers/main.lua b/tests/api3_contract/mods/script_trainers/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_trainers/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_trainers/manifest.json b/tests/api3_contract/mods/script_trainers/manifest.json new file mode 100644 index 000000000..66dd1a92c --- /dev/null +++ b/tests/api3_contract/mods/script_trainers/manifest.json @@ -0,0 +1 @@ +{"id":"script_trainers","name":"Portable trainer battles","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_travel/main.lua b/tests/api3_contract/mods/script_travel/main.lua new file mode 100644 index 000000000..be627b337 --- /dev/null +++ b/tests/api3_contract/mods/script_travel/main.lua @@ -0,0 +1,15 @@ +local mod = ... +local done, success = 0 +mod.exports.queue = function(rows) + return mod.world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success } +end +mod.exports.get = function(name) + return mod.world:getFlag(name) +end diff --git a/tests/api3_contract/mods/script_travel/manifest.json b/tests/api3_contract/mods/script_travel/manifest.json new file mode 100644 index 000000000..f07cf280a --- /dev/null +++ b/tests/api3_contract/mods/script_travel/manifest.json @@ -0,0 +1 @@ +{"id":"script_travel","name":"Portable travel","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/script_world/main.lua b/tests/api3_contract/mods/script_world/main.lua new file mode 100644 index 000000000..56fd85ea7 --- /dev/null +++ b/tests/api3_contract/mods/script_world/main.lua @@ -0,0 +1,26 @@ +local mod = ... +local world = mod.world +local done, success = 0 +local events = {} +mod.events:on("world.object_toggled", function(ev) + events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible } +end) +mod.events:on("world.block_replaced", function(ev) + events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, value = ev.block } +end) +mod.exports.queue = function(rows) + return world:queueScript(rows, { + onDone = function(ok) + done, success = done + 1, ok + end, + }) +end +mod.exports.result = function() + return { done = done, success = success, events = events } +end +mod.exports.get = function(name) + return world:getFlag(name) +end +mod.exports.read = function(...) + return world:activeBlockAt(...) +end diff --git a/tests/api3_contract/mods/script_world/manifest.json b/tests/api3_contract/mods/script_world/manifest.json new file mode 100644 index 000000000..38990279c --- /dev/null +++ b/tests/api3_contract/mods/script_world/manifest.json @@ -0,0 +1 @@ +{"id":"script_world","name":"Portable world edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/warp/main.lua b/tests/api3_contract/mods/warp/main.lua new file mode 100644 index 000000000..2dd8de861 --- /dev/null +++ b/tests/api3_contract/mods/warp/main.lua @@ -0,0 +1,23 @@ +-- Identical public-only mod on both engines: the harness owns native timing. +local mod = ... +local world = mod.world +assert(mod:supports("world", "warpTo")) +local early, reason = world:warpTo("DEST", 0, 0) +assert(early == nil and reason == "no game") +local calls, args, events = 0, nil, {} +mod.events:on("map.entered", function(ev) + events[#events + 1] = { mapId = ev.mapId, fromMapId = ev.fromMapId, via = ev.via } +end) +mod.exports.request = function(...) + return world:warpTo(...) +end +mod.exports.notify = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.results = function() + return { calls = calls, args = args, events = events } +end +mod.exports.current = function() + return world:current() +end diff --git a/tests/api3_contract/mods/warp/manifest.json b/tests/api3_contract/mods/warp/manifest.json new file mode 100644 index 000000000..6bc2f80ea --- /dev/null +++ b/tests/api3_contract/mods/warp/manifest.json @@ -0,0 +1 @@ +{"id":"warp","name":"Portable Warp Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/wild_battle/main.lua b/tests/api3_contract/mods/wild_battle/main.lua new file mode 100644 index 000000000..ae198c01d --- /dev/null +++ b/tests/api3_contract/mods/wild_battle/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local world = mod.world +assert(mod:supports("world", "startWildBattle")) +local ok, why = world:startWildBattle("MEW", 5) +assert(ok == nil and why == "no game") +local calls, args = 0 +mod.exports.request = function(...) + return world:startWildBattle(...) +end +mod.exports.notify = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.result = function() + return { calls = calls, args = args } +end diff --git a/tests/api3_contract/mods/wild_battle/manifest.json b/tests/api3_contract/mods/wild_battle/manifest.json new file mode 100644 index 000000000..492c88378 --- /dev/null +++ b/tests/api3_contract/mods/wild_battle/manifest.json @@ -0,0 +1 @@ +{"id":"wild_battle","name":"Wild battle contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua new file mode 100644 index 000000000..888ad92e5 --- /dev/null +++ b/tests/api3_contract/npcs.lua @@ -0,0 +1,321 @@ +-- Real loaders/native public adapters with native actor/map/timing seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version, modId) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/npcs%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "npcs") + local Runtime = require("src.mods.Runtime") + love = love or require("tests.love_stub") + -- Render construction has no bearing on the public state contract. + local Renderer = require("src.render.SpriteRenderer") + local savedRenderer = Renderer.new + Renderer.new = function(def) + return { def = def, big = false } + end + f.restoreRenderer = function() + Renderer.new = savedRenderer + end + local game, ow = f.game, f.ow + game.data.sprites = { + PROBE = { image = "fixture.png", frames = 6, walker = true }, + ALT = { image = "alt.png", frames = 6, walker = true }, + } + game.data.gen2Sprites = game.data.sprites + ow.game, ow.sprites = game, game.data.sprites + local tiles = { + blocks = { [2] = {} }, + walkable = { 0 }, + collision = { [2] = { 0, 0, 0, 0 } }, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + f.tiles = tiles + for i = 1, 16 do + tiles.blocks[2][i] = 0 + end + if not (backend == "gen1" and f.gen == 2) then + tiles.collision = nil + end + for _, id in ipairs({ f.home, f.dest }) do + local def = game.data.maps[id] + def.width, def.height, def.objects, def.blocks = 8, 8, {}, {} + for i = 1, 64 do + def.blocks[i] = 1 + end + end + local base = { + index = 1, + localId = 1, + name = "IMPORTED", + sprite = "PROBE", + x = 6, + y = 6, + movement = "STAY", + range = "DOWN", + } + game.data.maps[f.home].objects = { base } + if backend == "gen1" and f.gen == 2 then + ow.map = require("src.world.gen2.Map").new(game.data.maps[f.home], tiles) + elseif not (backend == "gen1" and f.gen == 3) then + ow.map = require("src.world.Map").new(game.data.maps[f.home], tiles) + end + ow.npcs, ow.entities, ow.npcPool = {}, { f.player }, {} + f.player.cellX, f.player.cellY = 7, 7 + function f:make(id, row) + local actor + if backend == "gen1" and self.gen == 2 then + actor = require("src.world.gen2.Npc").new(id, row, game.data.sprites[row.sprite]) + elseif backend == "gen1" and self.gen == 3 then + actor = require("src.core.game3.object_prepare").instance(row, { mapId = id }) + actor.id, actor.publicId = id .. "_obj_" .. row.index, id .. "_obj_" .. row.index + else + actor = require("src.world.NPC").new(game.data, id, row) + end + function actor:queueApiMoves(_, callback) + f:begin(self, "march", nil, 1, callback) + return true + end + return actor + end + f.imported = f:make(f.home, base) + ow.npcs[1] = f.imported + ow.entities[2] = f.imported + ow.npcPool[f.imported.id] = f.imported + function f:begin(actor, kind, dir, count, callback) + self.pending = { actor = actor, kind = kind, dir = dir, count = count, callback = callback } + self.busy = true + actor.moving = true + if backend == "gen2" or self.gen == 1 then + local entry = + { entity = actor, dir = dir, remaining = count, onDone = callback, inPlace = kind == "march" } + ow.scriptMoves[#ow.scriptMoves + 1] = entry + end + if self.synchronous then + self:complete("sync", nil, "tail") + end + if self.failMovement then + return false, "movement refused" + end + return true + end + function ow:scriptMove(actor, dir, count, callback) + return f:begin(actor, "move", dir, count, callback) + end + function ow:marchInPlace(actor, callback) + return f:begin(actor, "march", nil, 1, callback) + end + function ow:beginMovement(objectId, bytes, callback) + local actor + for _, candidate in ipairs(self.npcs) do + if candidate.def.index + 1 == objectId then + actor = candidate + end + end + local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]] + return f:begin(assert(actor), "move", assert(dir), #bytes - 1, callback) + end + function ow:applySpritePalette(actor) + f.paletteActor = actor + end + function f:complete(...) + local pending = self.pending + assert(pending, "no pending move") + local actor = pending.actor + if pending.kind == "move" then + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[pending.dir] + actor.cellX, actor.cellY = + actor.cellX + delta[1] * pending.count, actor.cellY + delta[2] * pending.count + end + actor.moving, actor.marching = false, false + actor.px, actor.py = actor.cellX * 16, actor.cellY * 16 + self.busy = false + ow.scriptMoves = {} + if pending.callback then + pending.callback(...) + end + end + function f:spawn(id, row, owner) + local def = assert(game.data.maps[id]) + local index = 0 + for _, existing in ipairs(def.objects) do + index = math.max(index, existing.index) + end + row.index, row.localId, row.runtime, row.owner = index + 1, index + 1, true, owner + def.objects[#def.objects + 1] = row + local publicId = id .. "_obj_" .. row.index + if id == self.home then + local actor = self:make(id, row) + ow.npcs[#ow.npcs + 1] = actor + ow.entities[#ow.entities + 1] = actor + ow.npcPool[publicId] = actor + if self.byId then + self.byId[row.localId] = actor + self.order[#self.order + 1] = row.localId + end + end + return publicId + end + function ow:addRuntimeObject(id, row, owner) + return f:spawn(id, row, owner) + end + function f:remove(id, owner) + for _, def in pairs(game.data.maps) do + for i, row in ipairs(def.objects or {}) do + if row.runtime and def.id .. "_obj_" .. row.index == id then + if row.owner ~= owner then + return nil, "not owned by " .. tostring(owner) + end + table.remove(def.objects, i) + for _, list in ipairs({ ow.npcs, ow.entities }) do + for j = #list, 1, -1 do + if list[j].id == id then + table.remove(list, j) + end + end + end + ow.npcPool[id] = nil + if self.byId then + self.byId[row.localId] = nil + for j = #self.order, 1, -1 do + if self.order[j] == row.localId then + table.remove(self.order, j) + end + end + end + return true + end + end + end + return nil, "no runtime object " .. tostring(id) + end + function ow:removeRuntimeObject(id, owner) + return f:remove(id, owner) + end + if backend == "gen1" and f.gen == 3 then + f.byId, f.order = { [1] = f.imported }, { 1 } + package.loaded["src.core.game3.objects"] = { + _byId = f.byId, + _order = f.order, + addRuntimeObject = function(id, row, owner) + return f:spawn(id, row, owner) + end, + removeRuntimeObject = function(id, owner) + return f:remove(id, owner) + end, + scriptFace = function(actor, dir) + actor.facing = dir + end, + scriptStep = function(actor, dir) + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[dir] + actor.facing = dir + actor.targetX = actor.cellX + delta[1] + actor.targetY = actor.cellY + delta[2] + actor.moving = true + end, + startTrack = function(id, actions, callback) + local count = 0 + for _, action in ipairs(actions) do + if action.kind == "step" then + count = count + 1 + end + end + return f:begin(f.byId[id], count > 0 and "move" or "march", actions[1].dir, count, callback) + end, + } + package.loaded["src.core.game3.collision"] = { + canEnter = function() + return true + end, + isWater = function() + return false + end, + } + end + function f:update() + return self.loader.hooks:call("input.step", function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ex = f.exports + local report = { invalid = {}, actions = {} } + local function refused(value, why) + assert(value == nil and type(why) == "string" and #why > 0, tostring(why)) + end + refused(ex.spawn("UNKNOWN", { x = 0, y = 0 })) + refused(ex.spawn(f.home, { x = -1, y = 0 })) + refused(ex.spawn(f.home, { x = 0.5, y = 0 })) + refused(ex.npc(f.home, false)) + refused(ex.remove(false)) + assert(#f.game.data.maps[f.home].objects == 1 and #f.ow.npcs == 1) + local definition = { name = "RUNTIME", sprite = "PROBE", x = 0, y = 0, movement = "STAY", range = "DOWN" } + local id = assert(ex.spawn(f.home, definition)) + local handle = assert(ex.npc(f.home, id)) + assert(handle.id == id and ex.npc(f.home, "RUNTIME").id == id) + definition.x, definition.sprite = 99, "changed" + local actor = f.ow.npcPool[id] + assert(actor.cellX == 0 and actor.def.sprite == "PROBE") + for _, method in ipairs({ "scriptMove", "face", "stepNow", "canStep" }) do + refused(handle[method](handle, "north")) + end + refused(handle:scriptMove("right", 0)) + refused(handle:scriptMove("right", 1025)) + refused(handle:scriptMove("right", 1, false)) + refused(handle:marchInPlace(false)) + refused(handle:placeAt(-1, 0)) + refused(handle:setPassable("yes")) + refused(handle:setAppearance("")) + assert(handle:face("right")) + local allowed, blocked = handle:canStep("right") + assert(allowed == true, tostring(allowed) .. ": " .. tostring(blocked)) + assert(handle:setPassable(true) and actor.passable == true) + assert(handle:setAppearance("ALT")) + assert(handle:position() == 0 and handle:isMoving() == false) + assert(handle:stepNow("right")) + assert(handle:isMoving() == true and actor.targetX == 1) + refused(handle:stepNow("right")) + assert(handle:placeAt(2, 2, "up")) + assert(handle:isMoving() == false and actor.px == 32 and actor.py == 32) + local x, y = handle:position() + assert(x == 2 and y == 2) + assert(handle:scriptMove("right", 2, ex.done)) + assert(ex.result().calls == 0) + f:complete("done", nil, "tail") + x, y = handle:position() + assert(x == 4 and y == 2) + assert(ex.result().calls == 1 and ex.result().args.n == 3 and ex.result().args.third == "tail") + assert(handle:marchInPlace(ex.done)) + f:complete("march") + x, y = handle:position() + assert(x == 4 and y == 2 and ex.result().calls == 2) + assert(handle:scriptMove("left")) + f:complete() + assert(handle:position() == 3 and ex.result().calls == 2) + assert(handle:marchInPlace()) + f:complete() + assert(handle:placeAt(4, 2)) + local imported = assert(ex.npc(f.home, "IMPORTED")) + refused(ex.remove(imported.id)) + local other = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + refused(ex.remove(other)) + assert(ex.remove(id)) + refused(ex.npc(f.home, id)) + assert(ex.npc(f.home, other) and ex.npc(f.home, imported.id)) + report.actions = { + x = x, + y = y, + passable = actor.passable, + callbacks = ex.result().calls, + definitionCopied = true, + ownedRemoval = true, + } + report.invalid = true + assert(#f.loader.errors == 0) + f.restoreRenderer() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua new file mode 100644 index 000000000..c9fe68f51 --- /dev/null +++ b/tests/api3_contract/run.lua @@ -0,0 +1,257 @@ +-- Shared harness; only fixture construction knows the native engine layout. +local M = {} +local function read(path) + local f = assert(io.open(path, "rb")) + local body = f:read("*a") + f:close() + return body +end +local function dataFor(backend, generation) + if backend == "gen1" and generation == 3 then + local fixture = require("tests.modkit.sdk").gen3Data() + fixture.maps.PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } + return fixture + end + local stats = { hp = 100, attack = 100, defense = 100, speed = 100, special = 100 } + local mon = { + id = "MEW", + name = "MEW", + dex = 151, + types = { "PSYCHIC" }, + baseStats = stats, + catchRate = 45, + baseExp = 64, + growthRate = "MEDIUM_FAST", + spriteFront = "fixture.png", + spriteBack = "fixture.png", + frontSize = 5, + level1Moves = { "POUND" }, + learnset = {}, + evolutions = {}, + } + local effect = "NO_ADDITIONAL_EFFECT" + if generation > 1 then + stats.spatk, stats.spdef = 100, 100 + end + if backend == "gen1" and generation == 2 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 100, 100 + mon.level1Moves, mon.learnset, mon.frontSize = nil, nil, nil + mon.levelMoves, mon.picSize = { { level = 1, move = "POUND" } }, 5 + effect = "EFFECT_NORMAL_HIT" + end + local pokemon = { MEW = mon } + if backend == "gen1" and generation == 2 then + pokemon.growthRates = + { MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } } + end + return { + pokemon = pokemon, + moves = { + POUND = { + id = "POUND", + name = "POUND", + type = "NORMAL", + power = 40, + accuracy = 100, + pp = 35, + effect = effect, + }, + }, + items = { POTION = { id = "POTION", name = "POTION", price = 300, tossable = true } }, + maps = { PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } }, + trainers = {}, + encounters = {}, + audio = {}, + type_chart = { types = { NORMAL = {}, PSYCHIC = {} }, matchups = {} }, + gen2Maps = backend == "gen1" and generation == 2 and { + PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } }, + } or nil, + } +end +local function nativeMon(backend, gen) + local stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24 } + local mon = { + species = "MEW", + nickname = "Scout", + name = "Native name", + level = 20, + hp = 30, + status = "PSN", + stats = stats, + moves = { { id = "POUND", pp = 30, ppUps = 0 } }, + } + if gen > 1 then + stats.spatk, stats.spdef = 24, 25 + end + if backend == "gen1" and gen > 1 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 24, 25 + mon.status = gen == 2 and "poison" or 8 + if gen == 3 then + mon.species, mon.moves[1].id = 151, 1 + end + end + return mon +end +function M.run(root, backend, version, failing, scaffold) + local Loader = require("src.mods.Loader") + local Runtime = require("src.mods.Runtime") + local Version = require("src.core.GameVersion") + local Json = require("src.link.Json") + Version.set(version) + local generation = Version.generation(version) + local data = dataFor(backend, generation) + local files = {} + for _, id in ipairs(failing and { "portable", "rollback" } or { "portable" }) do + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/" .. id .. "/" .. name] = read(root .. "/mods/" .. id .. "/" .. name) + end + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(assert(files[p]), "@" .. p) + end, + getInfo = function(p) + if files[p] then + return { type = "file", size = #files[p] } + end + for key in pairs(files) do + if key:sub(1, #p + 1) == p .. "/" then + return { type = "directory" } + end + end + end, + getDirectoryItems = function(p) + if p == "mods" then + return failing and { "portable", "rollback" } or { "portable" } + end + return {} + end, + } + local loader = Loader.new({ fs = fs, dev = false, generation = generation, version = version }) + local game + loader._game = function(_, loadedOnly) + if backend == "gen2" then + assert(loadedOnly, "API 3 must not bootstrap Game") + end + return game + end + local ok = loader:load(data) + if scaffold then + assert(ok, table.concat(loader.errors, "; ")) + assert(loader.mods.portable.manifest.api == 3, "generated manifest selects API 3") + local power = backend == "gen1" and generation == 3 and data.gen3Moves._rom[1].power + or data.moves.POUND.power + assert(power == 45, "generated entry changed the native move") + local report = { scaffold = true, power = power } + print("API3_CONTRACT " .. Json.encode(report)) + return report + end + local errorCount = #loader.errors + local exported = assert(loader.exports.portable, table.concat(loader.errors, "; ")) + assert(exported.report, table.concat(loader.errors, "; ")) + if failing then + assert(not ok and table.concat(loader.errors, "; "):find("intentional API 3 rollback", 1, true)) + else + assert(ok, table.concat(loader.errors, "; ")) + end + local nativeHp = backend == "gen1" and generation == 3 and data.gen3Pokemon._stats[151].hp + or data.pokemon.MEW.baseStats.hp + assert(nativeHp == 101, "rollback/native merge") + assert( + (backend == "gen1" and generation == 2 and data.gen2Maps or data.maps).PROBE.width == 2, + "passthrough reads remain detached" + ) + local raw = nativeMon(backend, generation) + local save = { party = { raw }, flags = {}, modData = {}, money = 0 } + game = { data = data, save = save, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = save + end + exported.exercise() + Runtime.emit("pokemon.received", { mon = raw }) + assert(raw.hp == 30, "event read did not mutate native Pokemon") + loader.hooks:call("evolution.check", function(_, mon) + assert(mon == raw and mon.hp == 27, "native hook forwarding") + assert( + mon.status + == ( + backend == "gen1" and generation == 3 and 16 + or backend == "gen1" and generation == 2 and "burn" + or "BRN" + ), + "native status writeback" + ) + local attack = mon.stats.spatk or mon.stats.specialAttack or mon.stats.special + local defense = mon.stats.spdef or mon.stats.specialDefense or mon.stats.special + assert(attack == 31 and defense == 31, "common Special writes both later stats") + return "UNCHANGED" + end, game, raw, {}, {}) + assert(raw.hp == 27) + exported.late() + Runtime.emit("mod.portable.probe", { value = 8 }) + assert(exported.report.custom == 8, "custom callback reached live owner") + assert(#loader.errors == errorCount, "no callback errors or failed-owner subscription leaks") + if generation == 1 then + require("src.battle.TypeChart").load(data) + local player, enemy = nativeMon(backend, generation), nativeMon(backend, generation) + local function battler(mon) + return { + mon = mon, + species = mon.species, + name = mon.nickname, + curStats = mon.stats, + curTypes = { "PSYCHIC" }, + curMoves = mon.moves, + stages = {}, + } + end + local battle = { + isBattleState = true, + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(player), + enemy = battler(enemy), + enemyParty = { enemy }, + ruleset = {}, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + } + function battle:battleKind() + return "wild" + end + function battle:visibleText() + return {} + end + function battle:effectRecord() + return { accuracyChecked = true } + end + game.save.party, game.save.inventory = { player }, {} + game.stack.states = { battle } + exported.snapshot() + assert(player.hp == 30, "invalid intent never dispatched") + end + -- The entry-time world handle must follow a later Game instead of retaining the old save. + local replacement = nativeMon(backend, generation) + replacement.hp = 19 + local nextSave = { party = { replacement }, modData = {}, flags = {} } + game = { data = data, save = nextSave, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = nextSave + end + assert(exported.world:party()[1].hp == 19, "captured facade follows current game") + assert(exported.battle:snapshot() == nil, "no battle") + local result = exported.report + assert(result.event and result.hook and result.party and result.custom == 8) + print("API3_CONTRACT " .. Json.encode(result)) + return result +end +M.dataFor = dataFor +return M diff --git a/tests/api3_contract/script_audio.lua b/tests/api3_contract/script_audio.lua new file mode 100644 index 000000000..0548e0813 --- /dev/null +++ b/tests/api3_contract/script_audio.lua @@ -0,0 +1,155 @@ +-- Real loaders/adapters; an explicit source/clock seam avoids audio-device timing. +local M = {} +function M.fixture(root, backend, version, modId, base) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_audio%.lua$") + local f = base + or dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_audio") + f.game.data.audio = { + songs = { TUNE = { file = "tune.wav" }, JINGLE = { file = "jingle.wav" } }, + sfx = { CHIME = { file = "chime.wav" } }, + mapSongs = { [f.home] = "TUNE" }, + } + f.audioTrace = {} + f.source = { playing = false } + function f.source:isPlaying() + return self.playing + end + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + local restored = {} + local function patch(module, key, fn) + local old = module[key] + restored[#restored + 1] = function() + module[key] = old + end + module[key] = fn + end + local function sound(_, key) + f.audioTrace[#f.audioTrace + 1] = { kind = "sound", id = key } + f.source.playing = true + return f.source + end + local function music(_, key, _, opts) + f.audioTrace[#f.audioTrace + 1] = { kind = "music", id = key, keep = opts and opts.keep or false } + f.current = key + end + local function stop() + f.audioTrace[#f.audioTrace + 1] = { kind = "stop" } + f.current = nil + f.musicBusy = false + end + local function once(_, key) + f.audioTrace[#f.audioTrace + 1] = { kind = "once", id = key } + f.current = key + f.musicBusy = true + return true + end + local function fade(control) + f.audioTrace[#f.audioTrace + 1] = { kind = "fade", control = control } + end + local function default(_, map) + f.audioTrace[#f.audioTrace + 1] = { kind = "default", map = map } + end + patch(Sound, "play", sound) + patch(Sound, "playNamedEffect", sound) + patch(Sound, "sfxBusy", function() + return f.otherBusy == true + end) + patch(Sound, "anyPlaying", function() + return f.otherBusy == true + end) + patch(Music, "play", music) + patch(Music, "stop", stop) + patch(Music, "playOnce", once) + patch(Music, "oneShotPlaying", function() + return f.musicBusy + end) + patch(Music, "current", function() + return f.current + end) + patch(Music, "fadeOut", fade) + patch(Music, "playMap", default) + function f.ow:playMapMusic() + default(f.game.data, self.map.id) + end + if backend == "gen1" and f.gen == 3 then + local Audio = require("src.core.game3.audio") + patch(Audio, "songInfo", function(key) + return f.game.data.audio.songs[key] or f.game.data.audio.sfx[key] + end) + patch(Audio, "resolveSong", function(key) + return key + end) + patch(Audio, "playSe", function(key) + return sound(f.game.data, key) + end) + patch(Audio, "isSePlaying", function() + return f.source.playing or f.otherBusy == true + end) + patch(Audio, "playSong", function(key, opts) + if key == 0 then + return stop() + end + return music(f.game.data, key, nil, opts) + end) + patch(Audio, "playFanfare", function(key) + return once(f.game.data, key) + end) + patch(Audio, "isFanfareFinished", function() + return not f.musicBusy + end) + patch(Audio, "fadeOutBgm", fade) + patch(Audio, "restoreMapSong", function() + return default(f.game.data, f.ow.map.id) + end) + end + f.restore = function() + for i = #restored, 1, -1 do + restored[i]() + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "play_sound", "CHIME" }, + { "wait_sound" }, + { "play_music", "TUNE", { keep = true } }, + { "fade_music", 2 }, + { "stop_music" }, + { "play_once", "JINGLE" }, + { "play_default_music" }, + { "set_flag", "mod:after" }, + })) + for _ = 1, 3 do + f:tick() + end + assert(f.exports.result().done == 0 and #f.audioTrace == 1) + f.source.playing = false + f.otherBusy = true + for _ = 1, 3 do + f:tick() + end + assert(#f.audioTrace == 1 and f.exports.result().done == 0) + f.otherBusy = false + for _ = 1, 20 do + f:tick() + if f.musicBusy then + break + end + end + assert(f.musicBusy and f.exports.result().done == 0) + f.musicBusy = false + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + local report = { trace = f.audioTrace, complete = true } + f.restore() + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "play_sound", "CHIME" }, { "play_music", "" } }) + assert(value == nil and type(why) == "string" and #f.audioTrace == 0) + report.preflight = true + f.restore() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_battles.lua b/tests/api3_contract/script_battles.lua new file mode 100644 index 000000000..f29912824 --- /dev/null +++ b/tests/api3_contract/script_battles.lua @@ -0,0 +1,64 @@ +local M = {} +function M.fixture(root, backend, version, modId) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_battles%.lua$") + local f = dofile(codeRoot .. "/wild_battle.lua").fixture(root, backend, version, modId or "script_battles") + f.loader.game = f.game + f.ow.runner.update = function() end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + function f:tick() + self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + end + function f:drain() + for _ = 1, 50 do + if self.exports.result().done > 0 then + return + end + self:tick() + end + error("battle script did not finish") + end + return f +end +function M.run(root, backend, version) + local report = {} + for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do + local f = M.fixture(root, backend, version) + local beat = f.gen == 1 and "EVENT_BEAT_MEWTWO" or "mod:beat" + assert(f.exports.queue({ + { "static_battle", "MEW", 5, beat }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:result" }, + })) + assert(f.exports.result().done == 0 and f.pushed == 1) + f:complete(outcome) + f:drain() + assert(f.exports.result().success and f.exports.get("mod:result")) + assert( + f.exports.get(beat) == (outcome ~= "lose") and f.after == 1, + outcome .. " beat=" .. tostring(f.exports.get(beat)) .. " cleanup=" .. f.after + ) + report[outcome] = { beat = f.exports.get(beat), after = f.after, result = true } + f.restore() + end + local f = M.fixture(root, backend, version) + local value, why = + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "static_battle", "UNKNOWN", 5 } }) + assert(value == nil and type(why) == "string" and f.constructed == 0) + report.preflight = true + f.restore() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_dialogue_audio.lua b/tests/api3_contract/script_dialogue_audio.lua new file mode 100644 index 000000000..e0de68ec7 --- /dev/null +++ b/tests/api3_contract/script_dialogue_audio.lua @@ -0,0 +1,111 @@ +-- Actual native dialogue UI, with an explicit fake audio-device clock. +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_dialogue_audio%.lua$") + local base = + dofile(codeRoot .. "/script_gifts.lua").fixture(root, backend, version, "script_dialogue_audio") + local f = dofile(codeRoot .. "/script_audio.lua").fixture(root, backend, version, nil, base) + f.game.data.audio.cries = { MEW = { file = "mew.wav" }, [151] = { file = "mew.wav" } } + f.game.data.text.WELCOME = "WELCOME" + f.game.data.text_pointers = {} + f.ow.map.def.label = "DIALOGUE_HOME" + f.ow.game, f.ow.text = f.game, f.game.data.text + local Sound = require("src.core.Sound") + local oldCry, restore = Sound.playCry, f.restore + local function cry(_, key) + f.audioTrace[#f.audioTrace + 1] = { kind = "cry", id = "MEW" } + f.source.playing = true + return f.source + end + Sound.playCry = cry + if backend == "gen1" and f.gen == 2 then + local World = require("src.world.gen2.World") + f.ow.showText, f.ow.askYesNo = World.showText, World.askYesNo + end + local sourceRestore = function() end + if backend == "gen1" and f.gen == 3 then + local A = require("src.core.game3.audio") + local oldPlay, oldBusy = A.playCry, A.isCryFinished + A.playCry = function(key) + return cry(f.game.data, key) + end + A.isCryFinished = function() + return not f.source.playing + end + sourceRestore = function() + A.playCry, A.isCryFinished = oldPlay, oldBusy + end + end + f.restore = function() + Sound.playCry = oldCry + sourceRestore() + restore() + end + function f:tick(key) + self.game.input.wasPressed = function(_, button) + return button == key + end + self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + if backend == "gen1" and self.gen == 3 then + local Message = require("src.ui.game3.message") + Message.tick() + if key then + if Message.isWaiting() then + Message.advance() + else + Message.pressAB() + end + end + else + self.game.stack:update(1 / 60) + end + self.game.input.wasPressed = function() + return false + end + end + return f +end +function M.run(root, backend, version) + local report = {} + for _, verb in ipairs({ "play_cry", "text_sound", "engage_music" }) do + local f = M.fixture(root, backend, version) + local cue = verb == "play_cry" and "MEW" or verb == "text_sound" and "CHIME" or "TUNE" + assert(f.exports.queue({ { verb, cue }, { "show_text", "WELCOME" }, { "set_flag", "mod:after" } })) + assert(#f.audioTrace == 0, "cue must be deferred") + for _ = 1, 300 do + f:tick() + if #f.audioTrace > 0 then + break + end + end + assert(#f.audioTrace == 1, "dialogue must start cue") + if verb ~= "engage_music" then + for _ = 1, 3 do + f:tick("a") + end + assert(f.exports.result().done == 0, "sounding cue blocks completion") + end + f.source.playing, f.musicBusy = false, false + for _ = 1, 30 do + f:tick() + end + if verb == "play_cry" then + assert(f.exports.result().success == true, "ordinary cry auto closes") + else + assert(f.exports.result().done == 0, "text cue keeps button wait") + for _ = 1, 30 do + f:tick("a") + end + end + assert(f.exports.result().success == true and f.exports.get("mod:after")) + report[verb] = { deferred = true, complete = true } + f.restore() + end + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_fields.lua b/tests/api3_contract/script_fields.lua new file mode 100644 index 000000000..8b4f1a93f --- /dev/null +++ b/tests/api3_contract/script_fields.lua @@ -0,0 +1,41 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_fields%.lua$") + return dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, "script_fields") +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + f.game.save.modData.foreign = { marker = 17 } + local value = { phase = 2, enabled = false, items = { "A", 3 } } + assert( + f.exports.queue({ + { "wait", 1 }, + { "set_field", "portableRoot", true }, + { "set_field", "mod:story", value }, + { "set_option", "portableChoice", "FAST" }, + { "set_option", "mod:theme", false }, + { "set_flag", "mod:after" }, + }) + ) + value.phase = 9 + value.items[1] = "CHANGED" + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + local save = f.game.save + assert(save.portableRoot == true and save.modData.script_fields.story.phase == 2) + assert(save.modData.script_fields.story.items[1] == "A" and save.modData.foreign.marker == 17) + local report = { + root = save.portableRoot, + private = save.modData.script_fields.story, + options = save.options, + foreign = 17, + } + f = M.fixture(root, backend, version) + local result, why = + f.exports.queue({ { "set_field", "portableRoot", true }, { "set_field", "mod.foreign.field", 1 } }) + assert(result == nil and type(why) == "string" and f.game.save.portableRoot == nil) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_flags.lua b/tests/api3_contract/script_flags.lua new file mode 100644 index 000000000..af3020908 --- /dev/null +++ b/tests/api3_contract/script_flags.lua @@ -0,0 +1,89 @@ +local M = {} +function M.fixture(root, backend, version, modId) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_flags%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "script_flags") + f.loader.game = f.game + f.ow.runner.update = function() end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + function f:tick() + if backend == "gen1" then + self.loader.hooks:call("core.update", function() end, self.game, 1 / 60) + else + self.loader.hooks:call("input.step", function() + self.ow.runner:update() + end, self.game, 1 / 60) + end + end + function f:drain() + for _ = 1, 100 do + self:tick() + if self.exports.result().done > 0 then + return + end + end + error("script did not complete") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local rows = { + { "set_flag", "mod:ready" }, + { "check_flag", "mod:ready" }, + { "jump_if_false", "failed" }, + { "clearflag", "mod:ready" }, + { "check_flag", "mod:ready" }, + { "jump_if_true", "failed" }, + { "setflag", "mod:result" }, + { "jump", "end" }, + { "label", "failed" }, + { "set_flag", "mod:failure" }, + } + assert(f.exports.queue(rows)) + f:drain() + assert(f.exports.get("mod:result") == true and f.exports.get("mod:failure") == false) + assert(f.exports.get("mod:ready") == false and f.exports.result().success == true) + local report = { branch = true } + f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "wait_flag", "mod:signal", 20 }, + { "jump_if_false", "end" }, + { + "set_flag", + "mod:resumed", + }, + })) + f:tick() + assert(f.exports.result().done == 0) + assert(f.exports.set("mod:signal", true)) + f:drain() + assert(f.exports.get("mod:resumed") == true) + report.resumed = f.exports.result() + f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "wait_flag", "mod:signal", 2 }, + { "jump_if_true", "end" }, + { + "set_flag", + "mod:timeout", + }, + })) + f:drain() + assert(f.exports.get("mod:timeout") == true) + report.timeout = f.exports.result() + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "set_flag", "mod:prefix" }, { "set_flag", "not_a_real_flag" } }) + assert(value == nil and type(why) == "string") + assert(f.exports.get("mod:prefix") == false and f.exports.result().done == 0) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_gifts.lua b/tests/api3_contract/script_gifts.lua new file mode 100644 index 000000000..9545bb462 --- /dev/null +++ b/tests/api3_contract/script_gifts.lua @@ -0,0 +1,57 @@ +local M = {} +function M.fixture(root, backend, version, modId) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_gifts%.lua$") + local f = dofile(codeRoot .. "/script_state.lua").fixture(root, backend, version, modId or "script_gifts") + love = love or require("tests.love_stub") + local data, save = f.game.data, f.game.save + data.text = data.text or {} + data.font = data.font or dofile(codeRoot .. "/../fixture_data/font.lua") + if not getmetatable(data) then + setmetatable(data, { __index = require("src.core.Data") }) + end + save.player = save.player or {} + save.player.name, save.player.id = "TEST", 12345 + f.game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + local Stack = require("src.core.StateStack") + setmetatable(f.game.stack, { __index = Stack }) + f.ow.update = function(self) + self.runner:update() + end + require("src.render.Font").load(data) + if backend == "gen1" and f.gen == 3 then + local pokemon = require("src.core.game3.pokemon") + pokemon._learnsets, pokemon._national, pokemon._abilityNames = {}, { [151] = 151 }, {} + end + f.party = function() + local mon = save.party[#save.party] + return { count = #save.party, level = mon.level, species = (mon.species == 151 and "MEW" or mon.species) } + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "give_item", "POTION", 2, false }, + { "give_pokemon", "MEW", 5, true, false }, + { "set_flag", "mod:gifted" }, + })) + f:drain() + assert(f.exports.result().success == true and f.exports.get("mod:gifted") == true) + assert(f.quantity() == 5 and #f.game.save.party == 2) + local report = { items = f.quantity(), party = f.party() } + f = M.fixture(root, backend, version) + local value, why = + f.exports.queue({ { "give_item", "POTION", 1, false }, { "give_pokemon", "UNKNOWN", 5, true, false } }) + assert(value == nil and type(why) == "string" and f.quantity() == 3 and #f.game.save.party == 1) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_motion.lua b/tests/api3_contract/script_motion.lua new file mode 100644 index 000000000..e4f215fc7 --- /dev/null +++ b/tests/api3_contract/script_motion.lua @@ -0,0 +1,108 @@ +local M = {} +function M.fixture(root, backend, version, modId) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_motion%.lua$") + local f = dofile(codeRoot .. "/npcs.lua").fixture(root, backend, version, modId or "script_motion") + f.loader.game = f.game + f.ow.runner.update = function() end + function f.ow:npcByIndex(id) + return id == 1 and f.imported + end + function f.ow:objectEntity(id) + return id == 0 and f.player or id == 1 and f.imported + end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + if backend == "gen1" and f.gen == 2 then + function f.ow:beginMovement(id, bytes, callback) + local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]] + return f:begin(id == 0 and f.player or f.imported, "move", assert(dir), #bytes - 1, callback) + end + function f.ow:moveObject(id, x, y) + local actor = assert(self:objectEntity(id)) + actor.cellX, actor.cellY = x, y + end + elseif backend == "gen1" and f.gen == 3 then + local objects = require("src.core.game3.objects") + objects._defs = { f.imported.def } + objects.find = function(id) + return id == 0xFF and f.player or f.byId[id] + end + objects.setObjectXY = function(id, x, y) + local actor = assert(objects.find(id)) + actor.cellX, actor.cellY = x, y + end + objects.applyMovement = function(id, bytes, callback) + local dir = ({ [0x10] = "down", [0x11] = "up", [0x12] = "left", [0x13] = "right" })[bytes[1]] + return f:begin(assert(objects.find(id)), "move", assert(dir), #bytes - 1, callback) + end + local collision = require("src.core.game3.collision") + collision.inBounds = function(x, y) + return x >= 0 and y >= 0 and x < 8 and y < 8 + end + collision.isWalkable = function() + return true + end + end + function f:tick() + local hook = backend == "gen1" and "core.update" or "input.step" + self.loader.hooks:call(hook, function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + end + function f:drain() + for _ = 1, 100 do + if self.exports.result().done > 0 then + return + end + if self.pending and self.pending.actor.moving then + self:complete() + end + self:tick() + end + error("movement script did not finish") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "face_player_dir", "up" }, + { "move_player", "left", 2 }, + { "place_npc", 1, 2, 2, "left" }, + { "move_npc", 1, "right", 2 }, + { "move_npc_to", 1, 4, 4 }, + { "face_object", 1, "up" }, + { "set_flag", "mod:after" }, + })) + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + assert( + f.player.cellX == 5 + and f.player.cellY == 7 + and f.imported.cellX == 4 + and f.imported.cellY == 4 + and f.imported.facing == "up" + ) + local report = { + player = { f.player.cellX, f.player.cellY }, + npc = { f.imported.cellX, f.imported.cellY, f.imported.facing }, + complete = true, + } + f.restoreRenderer() + f = M.fixture(root, backend, version) + local ok, why = f.exports.queue({ { "face_player_dir", "up" }, { "move_npc", 99, "left", 1 } }) + assert(ok == nil and type(why) == "string" and f.player.facing == "left") + report.preflight = true + f.restoreRenderer() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_shops.lua b/tests/api3_contract/script_shops.lua new file mode 100644 index 000000000..adde51e2a --- /dev/null +++ b/tests/api3_contract/script_shops.lua @@ -0,0 +1,83 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_shops%.lua$") + local f = dofile(codeRoot .. "/script_gifts.lua").fixture(root, backend, version, "script_shops") + local data = f.game.data + f.ow.game, f.ow.text = f.game, data.text + f.ow.map.def.label = "SHOP_HOME" + data.text_pointers = { SHOP_HOME = { SHOP = { mart = { "POTION" } } } } + if data.items and data.items.POTION then + data.items.POTION.price = 100 + end + if backend == "gen1" and f.gen == 3 then + require("src.core.game3.scripting.space").ensureBundle = function() + local IR = require("src.core.game3.scripting.text_ir") + return { + text = { + Text_MayIHelpYou = IR.fromAscii("WELCOME"), + gText_HowMayIServeYou = IR.fromAscii("WELCOME"), + }, + } + end + end + if backend == "gen1" and f.gen == 2 then + f.ow.openMart = require("src.world.gen2.World").openMart + end + function f:tick(key) + self.game.input.wasPressed = function(_, button) + return button == key + end + self.loader.hooks:call(backend == "gen1" and "core.update" or "input.step", function() + if backend == "gen2" then + self.ow.runner:update() + end + end, self.game, 1 / 60) + self.game.stack:update(1 / 60) + self.game.input.wasPressed = function() + return false + end + end + function f:close() + if backend == "gen1" and f.gen == 3 then + require("src.ui.game3.shop_menu").close() + else + for _ = 1, 2000 do + if self.exports.result().done > 0 then + return + end + self:tick("b") + end + error("native shop did not close") + end + end + function f:drain() + for _ = 1, 100 do + if self.exports.result().done > 0 then + return + end + self:tick() + end + error("shop script did not finish") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ok, why = f.exports.queue({ { "open_mart", "SHOP" }, { "set_flag", "mod:after" } }) + assert(ok, why) + assert( + f.exports.result().done == 0 and not f.exports.get("mod:after"), + require("src.link.Json").encode(f.exports.result()) .. " " .. table.concat(f.loader.errors, "; ") + ) + f:close() + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + local report = { complete = true, quantity = f.quantity() } + f = M.fixture(root, backend, version) + local value, reason = f.exports.queue({ { "set_flag", "mod:prefix" }, { "open_mart", "UNKNOWN" } }) + assert(value == nil and type(reason) == "string" and not f.exports.get("mod:prefix")) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_state.lua b/tests/api3_contract/script_state.lua new file mode 100644 index 000000000..dc7148928 --- /dev/null +++ b/tests/api3_contract/script_state.lua @@ -0,0 +1,109 @@ +local M = {} +function M.fixture(root, backend, version, modId) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_state%.lua$") + local f = dofile(codeRoot .. "/script_flags.lua").fixture(root, backend, version, modId or "script_state") + local save, data = f.game.save, f.game.data + save.money, save.coins = 100, 50 + save.inventory, save.bagOrder = { POTION = 3 }, { "POTION" } + save.pokedex = { seen = {}, owned = { MEW = true } } + save.party = + { { species = "MEW", hp = 1, status = "PSN", stats = { hp = 20 }, moves = { { id = "POUND", pp = 1 } } } } + f.pp = function() + return save.party[1].moves[1].pp + end + f.seen = function() + return save.pokedex.seen.MEW == true + end + f.quantity = function() + return save.inventory.POTION or 0 + end + f.wallet = function() + return save.money, save.coins + end + if backend == "gen1" and f.gen == 1 then + require("src.core.Data").moves = data.moves -- Native heal uses the active data singleton. + elseif backend == "gen1" and f.gen == 2 then + f.ow.game = f.game + save.player = { money = 100, coins = 50 } + local World = require("src.world.gen2.World") + f.ow.money, f.ow.setMoney = World.money, World.setMoney + f.ow.coins, f.ow.setCoins = World.coins, World.setCoins + f.ow.healParty = World.healParty + save.party[1].maxHp = 20 + f.wallet = function() + return save.player.money, save.player.coins + end + elseif backend == "gen1" and f.gen == 3 then + local pokemon = require("src.core.game3.pokemon") + for key, value in pairs(data.gen3Pokemon) do + pokemon[key] = value + end + pokemon._byName = { MEW = 151 } + require("src.core.game3.items_data").installPack({ items = data.gen3Items._byId }) + local Bag = require("src.core.game3.bag") + save.bag = Bag.new() + save.bag.pockets.ITEMS = { { id = 13, qty = 3 } } + save.dex = { seen = {}, caught = { [151] = true }, owned = { [151] = true } } + save.party = + { { species = 151, hp = 1, maxHp = 20, status = 8, moves = { 1 }, pp = { 1 }, maxPp = { 35 } } } + f.pp = function() + return save.party[1].pp[1] + end + f.seen = function() + return require("src.core.game3.dex").isSeen(save.dex, 151) + end + f.quantity = function() + return Bag.get(save.bag, 13) + end + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "give_money", 50 }, + { "take_money", 20 }, + { "check_money", 130 }, + { "jump_if_false", "failed" }, + { "take_coins", 12 }, + { "check_item", "POTION" }, + { "jump_if_false", "failed" }, + { "take_item", "POTION", 2 }, + { "check_dex_owned", 1 }, + { "jump_if_false", "failed" }, + { "mark_seen", "MEW" }, + { "heal_party" }, + { "set_flag", "mod:passed" }, + { "jump", "end" }, + { "label", "failed" }, + { "set_flag", "mod:failed" }, + })) + f:drain() + assert(f.exports.result().success == true) + assert(f.exports.get("mod:passed") == true and f.exports.get("mod:failed") == false) + local money, coins = f.wallet() + local report = { + money = money, + coins = coins, + items = f.quantity(), + hp = f.game.save.party[1].hp, + pp = f.pp(), + seen = f.seen(), + } + assert( + money == 130 + and coins == 38 + and report.items == 1 + and report.hp == 20 + and report.pp == 35 + and report.seen == true + ) + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "give_money", 1 }, { "take_item", "NO_SUCH_ITEM" } }) + assert(value == nil and type(why) == "string") + assert(f.wallet() == 100 and f.quantity() == 3 and f.exports.result().done == 0) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_trainers.lua b/tests/api3_contract/script_trainers.lua new file mode 100644 index 000000000..81d2a4d8b --- /dev/null +++ b/tests/api3_contract/script_trainers.lua @@ -0,0 +1,82 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_trainers%.lua$") + local f = dofile(codeRoot .. "/script_battles.lua").fixture(root, backend, version, "script_trainers") + f.game.data.trainers = + { PROBE = { id = "PROBE", index = 7, name = "PROBE", parties = { { { species = "MEW", level = 5 } } } } } + f.game.data.constants = f.game.data.constants or {} + f.game.data.constants.trainerOrder = { [7] = "PROBE" } + f.game.data.constants.gen3TrainerFlagBase = 0x500 + local battle = package.loaded["src.battle.BattleState"] + battle.newTrainer = function(game, class, member) + f.constructed = f.constructed + 1 + if f.constructError then + error("trainer constructor probe", 0) + end + return { kind = "trainer", class = class, member = member } + end + f.trainerRef = f.gen == 1 and "PROBE" or 7 + if backend == "gen1" and f.gen == 2 then + f.game.data.trainers = { + classes = { + PROBE = { + id = "PROBE", + index = 7, + name = "PROBE", + trainers = { { id = "PROBE_1", name = "PROBE", party = { { species = "MEW", level = 5 } } } }, + }, + }, + } + function f.ow:trainerParty(class, member) + return require("src.world.gen2.TrainerHouse").lookup(f.game.data.trainers, f.game.save, class, member) + end + function f.ow:startScriptedBattle(trainer, _, done) + f.constructed = f.constructed + 1 + return f:admit({ kind = "trainer", trainer = trainer }, done, true) + end + elseif backend == "gen1" and f.gen == 3 then + package.loaded["src.core.game3.scripting.trainers"] = { + get = function(id) + return id == 7 and { party = { { species = 151, level = 5 } } } or nil + end, + foeFromId = function(id) + return id == 7 and { trainerId = id, party = { { species = 151, level = 5 } } } or nil + end, + } + package.loaded["src.core.game3.battle_bridge"].start = function(_, game, foe, opts) + f.constructed = f.constructed + 1 + local value, why = f:admit({ kind = "trainer", foe = foe }, opts.done, true) + if value == false then + return value, why + end + return true + end + end + return f +end +function M.run(root, backend, version) + local report = {} + for _, outcome in ipairs({ "win", "lose", "run" }) do + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "start_battle", "trainer", f.trainerRef, 1 }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:after" }, + })) + assert(f.constructed == 1 and f.pushed == 1 and f.exports.result().done == 0) + f:complete(outcome) + f:drain() + assert(f.after == 1 and f.exports.result().success and f.exports.get("mod:after")) + report[outcome] = { after = f.after, complete = true } + f.restore() + end + local f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 999, 1 } }) + assert(value == nil and type(why) == "string" and not f.exports.get("mod:prefix") and f.constructed == 0) + report.preflight = true + f.restore() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_travel.lua b/tests/api3_contract/script_travel.lua new file mode 100644 index 000000000..4894ed053 --- /dev/null +++ b/tests/api3_contract/script_travel.lua @@ -0,0 +1,119 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_travel%.lua$") + local f = dofile(codeRoot .. "/script_motion.lua").fixture(root, backend, version, "script_travel") + f.game.data.tilesets = f.game.data.tilesets or {} + f.game.data.tilesets.FIX = f.tiles + for _, def in pairs(f.game.data.maps) do + if def.midLayout then + def.midLayout.width, def.midLayout.height = def.width, def.height + end + end + local dest = f.game.data.maps[f.dest] + dest.objects = { + { + index = 1, + localId = 1, + name = "IMPORTED", + sprite = "PROBE", + x = 4, + y = 4, + movement = "STAY", + range = "DOWN", + }, + } + local arrive = f.arrive + function f:arrive(id) + arrive(self, id) + local def = self.game.data.maps[id or self.dest] + if not def then + return + end + if backend == "gen1" and f.gen == 2 then + self.ow.map = require("src.world.gen2.Map").new(def, self.tiles) + elseif not (backend == "gen1" and f.gen == 3) then + self.ow.map = require("src.world.Map").new(def, self.tiles) + end + if backend == "gen1" and f.gen > 1 then + self.ow.runner = { + isRunning = function() + return false + end, + update = function() end, + } + else + self.ow.runner = require("src.script.ScriptRunner").new(self.game, self.ow) + end + self.imported.cellX, self.imported.cellY = 4, 4 + self.imported.def = def.objects[1] + if self.byId then + require("src.core.game3.objects")._defs = def.objects + end + end + function f:drain() + for _ = 1, 150 do + if self.exports.result().done > 0 then + return + end + if self.destination and self.ow.transitioning then + self:arrive() + self:completeWarp() + end + if self.pending and self.pending.actor.moving then + self:complete() + end + self:tick() + end + error("travel script did not finish") + end + -- npc fixture's complete belongs to movement; preserve the warp seam separately. + function f:completeWarp() + self.busy, self.ow.transitioning = false, false + if self.callback then + self.callback() + end + end + local dispatch = f.dispatch + function f:dispatch(...) + local movementComplete = self.complete + self.complete = self.completeWarp + local called, a, b = pcall(dispatch, self, ...) + self.complete = movementComplete + if not called then + error(a, 0) + end + return a, b + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ok, why = f.exports.queue({ + { "remember_outdoor", f.home, 1, 1 }, + { "warp", f.dest, 2, 2, "right" }, + { "move_player", "left" }, + { "move_npc", 1, "down" }, + { "face_object", 1, "up" }, + { "set_flag", "mod:after" }, + }) + assert(ok, why) + assert(f.exports.result().done == 0) + f:drain() + assert(f.exports.result().success and f.exports.get("mod:after")) + assert(f.ow.map.id == f.dest and f.player.cellX == 1 and f.player.cellY == 2 and f.imported.cellY == 5) + local report = { + map = f.ow.map.id, + player = { f.player.cellX, f.player.cellY }, + npc = { f.imported.cellX, f.imported.cellY, f.imported.facing }, + complete = true, + } + f.restoreRenderer() + f = M.fixture(root, backend, version) + local value, reason = f.exports.queue({ { "face_player_dir", "up" }, { "warp", "UNKNOWN", 0, 0 } }) + assert(value == nil and type(reason) == "string" and f.player.facing == "left" and #f.trace == 0) + report.preflight = true + f.restoreRenderer() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/script_world.lua b/tests/api3_contract/script_world.lua new file mode 100644 index 000000000..e52511375 --- /dev/null +++ b/tests/api3_contract/script_world.lua @@ -0,0 +1,66 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/script_world%.lua$") + local f = dofile(codeRoot .. "/world_edits.lua").fixture(root, backend, version, "script_world") + f.loader.game = f.game + f.ow.runner.update = function() end + if f.field then + function f.field.lock(token) + f.field._locks[token] = true + end + function f.field.unlock(token) + f.field._locks[token] = nil + end + end + if f.gen == 1 then + local native = f.ow.setMap + function f.ow:setMap(...) + local old = self.map + native(self, ...) + self.map = require("src.world.Map").new(old.def, old.tileset) + self.map.renderer = old.renderer + self.runner = require("src.script.ScriptRunner").new(f.game, self) + end + end + function f:tick() + if backend == "gen1" then + self.loader.hooks:call("core.update", function() end, self.game, 1 / 60) + else + self.loader.hooks:call("input.step", function() + self.ow.runner:update() + end, self.game, 1 / 60) + end + end + function f:drain() + for _ = 1, 100 do + if self.exports.result().done > 0 then + return + end + self:tick() + end + error("world edit script did not finish") + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + assert(f.exports.queue({ + { "hide_object", f.home, "NPC" }, + { "show_object", f.home, "NPC" }, + { "replace_block", 0, 0, 8 }, + { "wait", 1 }, + { "set_flag", "mod:after" }, + })) + f:drain() + assert(f.exports.result().success == true and f.exports.get("mod:after") == true) + assert(f.visible.NPC == true and f.exports.read(f.home, 0, 0) == 8) + local report = + { events = f.exports.result().events, visible = f.visible.NPC, block = f.exports.read(f.home, 0, 0) } + f = M.fixture(root, backend, version) + local value, why = f.exports.queue({ { "hide_object", f.home, "NPC" }, { "replace_block", 99, 0, 8 } }) + assert(value == nil and type(why) == "string" and f.visible.NPC and #f.exports.result().events == 0) + report.preflight = true + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua new file mode 100644 index 000000000..5e7493d19 --- /dev/null +++ b/tests/api3_contract/warp.lua @@ -0,0 +1,388 @@ +-- Shared driver, real production loaders/adapters, native-shaped world seams. +-- No backend distinction is passed into the mod files or public assertions. +local M = {} +local unpack = table.unpack or unpack +local function read(path) + local f = assert(io.open(path, "rb")) + local value = f:read("*a") + f:close() + return value +end +function M.fixture(root, backend, version, modId) + modId = modId or "warp" + local Version = require("src.core.GameVersion") + local Runtime = require("src.mods.Runtime") + Version.set(version) + local gen = Version.generation(version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/warp%.lua$") + local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen) + local prefix = gen == 3 and (version == "emerald" and "EM_" or "FR_") or "" + local home, dest = prefix .. "HOME", prefix .. "DEST" + for _, id in ipairs({ home, dest }) do + data.maps[id] = { + id = id, + width = 2, + height = 3, + tileset = "FIX", + blocks = { 1 }, + midLayout = gen == 3 and { width = 2, height = 3 } or nil, + } + if data.gen2Maps then + data.gen2Maps[id] = data.maps[id] + end + end + local files = {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/" .. modId .. "/" .. name] = read(root .. "/mods/" .. modId .. "/" .. name) + end + local fs = { + read = function(path) + return files[path] + end, + load = function(path) + return loadstring(assert(files[path]), "@" .. path) + end, + getInfo = function(path) + if files[path] then + return { type = "file", size = #files[path] } + end + if path == "mods" or path == "mods/" .. modId then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { modId } or {} + end, + } + local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) + local f = { loader = loader, gen = gen, home = home, dest = dest, trace = {}, busy = false } + loader._game = function(_, loadedOnly) + assert(backend ~= "gen2" or loadedOnly, "no Game bootstrap") + return f.live + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + f.exports = assert(loader.exports[modId]) + local save = { party = {}, flags = {}, modData = {} } + local player = { cellX = 0, cellY = 0, facing = "left" } + local ow = { + isOverworld = true, + player = player, + map = { id = home, def = data.maps[home] }, + maps = data.maps, + runner = { + isRunning = function() + return false + end, + }, + scriptMoves = {}, + } + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + local game = { data = data, save = save, session = save, stack = stack, phase = "field", overworld = ow } + f.game, f.live, f.ow, f.player = game, game, ow, player + function f:dispatch(id, x, y, facing, opts) + self.trace[#self.trace + 1] = { + mapId = id, + x = x, + y = y, + facing = facing, + arrive = opts.arrive, + keepMusic = opts.keepMusic, + fade = opts.fade, + } + self.callback = opts.onDone + self.destination = { id, x, y, facing } + self.busy, ow.transitioning = true, true + if self.synchronous then + self:arrive() + self:complete("sync", nil, "tail") + end + if self.failure == "throw" then + error("native warp probe", 0) + end + if self.failure == "refuse" then + return false, "warp refused" + end + return true + end + function ow:startWarpTo(id, x, y, facing, onDone, opts) + local arrive = self.arriveWarp + self.arriveWarp = nil + local ok, why = f:dispatch( + id, + x, + y, + facing, + { onDone = onDone, arrive = arrive, keepMusic = opts and opts.keepMusic, fade = opts and opts.fade } + ) + if not ok then + return ok, why + end + -- Native Gen 1/controller returns no value on successful admission. + end + function ow:acceptsMenuInput() + return not (f.busy or player.moving) + end + function ow:warpToMapId(id, x, y, facing, opts) + return f:dispatch(id, x, y, facing, opts or {}) + end + if backend == "gen1" and gen == 2 then + game.world = ow + end + if backend == "gen1" and gen == 3 then + local field = { running = true, _game = game, _session = save, _locks = {} } + f.field = field + package.loaded["src.core.game3.field"] = field + package.loaded["src.core.game3.runtime"] = { + getSession = function() + return game.session + end, + uiBusy = function() + return f.busy + end, + } + local map = { + current = home, + currentDef = function() + return data.maps[f.map.current] + end, + } + f.map = map + package.loaded["src.core.game3.map"] = map + package.loaded["src.core.game3.player"] = player + package.loaded["src.core.game3.scripting.space"] = {} + package.loaded["src.core.game3.battle.init"] = { + isActive = function() + return false + end, + } + package.loaded["src.core.game3.warp"] = { + isBusy = function() + return f.busy + end, + request = function(_, owner, id, x, y, facing, opts) + assert(owner == game, "native request retains Game") + return f:dispatch(id, x, y, facing, opts) + end, + } + end + function f:arrive(id) + local d = assert(self.destination) + local from = ow.map.id + Runtime.emit("map.exited", { mapId = from, toMapId = id or d[1] }) + ow.map = { id = id or d[1], def = data.maps[id or d[1]] } + player.cellX, player.cellY, player.facing = d[2], d[3], d[4] + if self.map then + self.map.current = ow.map.id + end + Runtime.emit("map.entered", { mapId = ow.map.id, fromMapId = from, via = "warp", map = ow.map }) + end + function f:complete(...) + self.busy, ow.transitioning = false, false + if self.callback then + self.callback(...) + end + end + function f:update() + local name = backend == "gen1" and "core.update" or "input.step" + return loader.hooks:call(name, function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local Runtime = require("src.mods.Runtime") + local Json = require("src.link.Json") + local report = { invalid = {}, busy = {}, successful = {} } + local function fixture() + return M.fixture(root, backend, version) + end + local f = fixture() + local function refused(args, expected) + local before = #f.trace + local ok, why = f.exports.request(unpack(args, 1, args.n or #args)) + assert(ok == nil and why == expected, tostring(why) .. " ~= " .. expected) + assert(#f.trace == before and f.ow.map.id == f.home and f.player.cellX == 0) + assert(f.ow.arriveWarp == nil and #f.exports.results().events == 0) + report.invalid[#report.invalid + 1] = why + end + refused({ false, 0, 0 }, "invalid map id") + refused({ "", 0, 0 }, "invalid map id") + refused({ "bad\nmap", 0, 0 }, "invalid map id") + refused({ string.rep("x", 257), 0, 0 }, "invalid map id") + refused({ "UNKNOWN", 0, 0 }, "unknown map: UNKNOWN") + for _, coordinate in ipairs({ -1, 0.5, math.huge, -math.huge, 0 / 0, "0", false }) do + refused({ f.dest, coordinate, 0 }, "invalid coordinates") + refused({ f.dest, 0, coordinate }, "invalid coordinates") + end + local scale = f.gen == 3 and 1 or 2 + refused({ f.dest, 2 * scale, 0 }, "coordinates out of bounds") + refused({ f.dest, 0, 3 * scale }, "coordinates out of bounds") + refused({ f.dest, 0, 0, "north" }, "invalid facing") + refused({ f.dest, 0, 0, "up", false }, "invalid options") + refused({ f.dest, 0, 0, "up", { arrive = "boat" } }, "invalid arrival") + refused({ f.dest, 0, 0, "up", { onDone = false } }, "invalid callback") + refused({ f.dest, 0, 0, "up", { keepMusic = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { fade = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { extra = true } }, "unknown warp option: extra") + if f.gen ~= 3 then + refused({ f.dest, 0, 0, "up", { fade = false } }, "unsupported fade") + end + for _, lock in ipairs({ "transitioning", "moving", "script", "overlay", "modscript" }) do + f = fixture() + if lock == "transitioning" then + f.ow.transitioning, f.busy = true, true + if f.field then + f.field.locked = true + end + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + f.busy = true + if f.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + elseif lock == "overlay" then + f.game.stack.states[2] = {} + f.busy = true + else + f.ow._modScriptLock = {} + f.busy = true + if f.field then + f.field._modScriptLock = {} + end + end + local ok, why = f.exports.request(f.dest, 0, 0) + assert(ok == nil and type(why) == "string" and #f.trace == 0, lock .. ": " .. tostring(why)) + report.busy[#report.busy + 1] = lock + end + for _, options in ipairs({ + {}, + { arrive = "fly", keepMusic = true }, + { arrive = "teleport", keepMusic = false }, + }) do + f = fixture() + options.onDone = f.exports.notify + assert(f.exports.request(f.dest, 2 * scale - 1, 3 * scale - 1, "right", options)) + assert(f.exports.results().calls == 0 and #f.trace == 1) + local expectedArrival, expectedMusic = options.arrive, options.keepMusic + options.arrive, options.keepMusic, options.onDone = + "changed", true, function() + error("mutable options") + end + assert(f.trace[1].arrive == expectedArrival and f.trace[1].keepMusic == expectedMusic) + f:arrive() + f:complete("done", nil, "tail") + f:complete("replay") + assert(f.exports.results().calls == 1 and f.exports.results().args.n == 3) + assert(f.exports.results().args.first == "done" and f.exports.results().args.third == "tail") + local location = assert(f.exports.current()) + assert(location.mapId == f.dest and location.x == 2 * scale - 1 and location.y == 3 * scale - 1) + report.successful[#report.successful + 1] = { native = f.trace[1], result = f.exports.results() } + end + f = fixture() + assert(f.exports.request(f.dest, 0, 0)) + assert(f.trace[1].facing == (f.gen == 1 and "down" or "left")) + f:arrive() + f:complete() + report.defaultFacing = f.trace[1].facing + if f.gen == 3 then + f = fixture() + assert(f.exports.request("DEST", 1, 2, "up", { fade = false, onDone = f.exports.notify })) + assert(f.trace[1].mapId == f.dest and f.trace[1].fade == false) + f:arrive() + f:complete() + report.gen3Alias = true + end + f = fixture() + f.synchronous = true + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + assert(f.exports.results().calls == 1 and f.exports.results().args.first == "sync") + report.synchronous = true + for _, change in ipairs({ "game", "save", "world", "bus", "owner", "other_map" }) do + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + f:arrive() + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "save" then + local old = f.game.save + f.game.save = {} + f.game.session = f.game.save + restore = function() + f.game.save = old + f.game.session = old + end + elseif change == "world" then + if backend == "gen1" and f.gen == 3 then + local old = package.loaded["src.core.game3.field"] + package.loaded["src.core.game3.field"] = { running = true } + restore = function() + package.loaded["src.core.game3.field"] = old + end + else + local old = f.game.stack.states + f.game.stack.states = {} + f.game.overworld = nil + f.game.world = nil + restore = function() + f.game.stack.states = old + f.game.overworld = f.ow + if f.gen == 2 then + f.game.world = f.ow + end + end + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "owner" then + f.loader:_rollback("warp") + else + f:arrive(f.home) + end + f:update() + -- A stale native completion must suppress the callback permanently. + f:complete("stale") + if restore then + restore() + end + f:complete("revived") + assert(f.exports.results().calls == 0, change .. " callback survived invalidation") + end + report.stale = true + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { + onDone = function() + error("authored warp failure", 0) + end, + })) + f:arrive() + f:complete() + f:complete() + assert(#f.loader.errors == 1 and f.loader.errors[1]:find("warp: world warp callback failed:", 1, true)) + assert(Runtime.currentMod == nil) + report.attribution = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/api3_contract/wild_battle.lua b/tests/api3_contract/wild_battle.lua new file mode 100644 index 000000000..704addeb1 --- /dev/null +++ b/tests/api3_contract/wild_battle.lua @@ -0,0 +1,128 @@ +-- Production public facades/loaders; native battle entry/timing seams only. +local M = {} +function M.fixture(root, backend, version, modId) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/wild_battle%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "wild_battle") + love = love or require("tests.love_stub") + f.game.save.party = { { species = "MEW", hp = 20, level = 10, stats = { hp = 20 } } } + f.game.save.pokedex = { seen = {}, caught = {} } + f.constructed, f.pushed, f.after = 0, 0, 0 + function f.ow:afterBattle(result, battle) + f.after = f.after + 1 + f.lastResult, f.cleanedBattle = result, battle + if f.cleanupError then + error("native cleanup probe", 0) + end + end + function f:admit(battle, callback, nativeCleanup) + self.pushed = self.pushed + 1 + self.battle, self.finish, self.nativeCleanup = battle, callback, nativeCleanup + self.busy = true + self.game.stack.states[2] = battle + if self.synchronous then + self:complete("win", nil, "tail") + end + if self.pushFailure then + return false, "battle failed" + end + -- Native host admission is void on success. + end + function f:complete(...) + self.busy = false + self.game.stack.states[2] = nil + if self.nativeCleanup and not self.cleaned then + self.cleaned = true + self.ow:afterBattle((...), self.battle) + end + return self.finish(...) + end + local original = package.loaded["src.battle.BattleState"] + local function construct(game, species, level) + f.constructed = f.constructed + 1 + game.save.pokedex.seen[species] = true + if f.constructError then + error("native constructor probe", 0) + end + return { kind = "wild", species = species, level = level } + end + package.loaded["src.battle.BattleState"] = { newWild = construct } + f.restore = function() + package.loaded["src.battle.BattleState"] = original + end + function f.ow:pushBattle(battle) + return f:admit(battle, battle.onFinish, false) + end + if backend == "gen1" and f.gen == 2 then + package.loaded["src.battle.gen2.Mon"] = { + new = function(data, species, level) + return construct(f.game, species, level) + end, + } + function f.ow:startBattle(spec, onDone) + local result, reason = f:admit(spec.wild, onDone or function() end, true) + if result == false then + return result, reason + end + return true + end + elseif backend == "gen1" and f.gen == 3 then + local pokemon = require("src.core.game3.pokemon") + for key, value in pairs(f.game.data.gen3Pokemon) do + pokemon[key] = value + end + pokemon._byName = { MEW = 151 } + package.loaded["src.core.game3.battle_bridge"] = { + startWild = function(_, game, spec, opts) + local b = construct(game, spec.species, spec.level) + local result, reason = f:admit(b, opts and opts.done or function() end, true) + if result == false then + return result, reason + end + return true + end, + } + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local report = { invalid = {} } + for _, args in ipairs({ + { "", 5 }, + { "NO_SUCH_SPECIES", 5 }, + { "MEW", 0 }, + { "MEW", 1.5 }, + { "MEW", 101 }, + { "MEW", 5, false }, + }) do + local value, why = f.exports.request(unpack(args)) + assert(value == nil, "invalid battle accepted") + report.invalid[#report.invalid + 1] = why + assert(f.constructed == 0 and f.pushed == 0 and next(f.game.save.pokedex.seen) == nil) + end + f.game.save.party[1].hp = 0 + local value, why = f.exports.request("MEW", 5) + assert(value == nil and why == "no healthy party") + report.empty = why + f.game.save.party[1].hp = 20 + assert(f.exports.request("MEW", 5, f.exports.notify)) + assert(f.battle.level == 5 and f.pushed == 1) + value, why = f.exports.request("MEW", 5) + assert(value == nil and why == "world is busy") + f:complete("win", nil, "tail") + assert(f.after == 1) + f:complete("lose") + local r = f.exports.result() + assert(r.calls == 1 and r.args.n == 3 and r.args.third == "tail") + report.complete = r + f.restore() + f = M.fixture(root, backend, version) + f.synchronous = true + assert(f.exports.request("MEW", 100, f.exports.notify)) + assert(f.exports.result().calls == 1 and f.after == 1) + report.synchronous = f.exports.result() + f.restore() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua new file mode 100644 index 000000000..8aab2b5e6 --- /dev/null +++ b/tests/api3_contract/worker.lua @@ -0,0 +1,79 @@ +-- Separate process per engine/version: modules and runtime buses cannot leak. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = nil +local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4] +assert( + root + and backend + and version + and ( + mode == "normal" + or mode == "rollback" + or mode == "scaffold" + or mode == "warp" + or mode == "world_edits" + or mode == "npcs" + or mode == "wild_battle" + or mode == "invalidate" + or mode == "flags" + or mode == "script_flags" + or mode == "script_state" + or mode == "script_gifts" + or mode == "script_world" + or mode == "script_motion" + or mode == "script_travel" + or mode == "script_battles" + or mode == "script_trainers" + or mode == "script_shops" + or mode == "script_audio" + or mode == "script_dialogue_audio" + or mode == "script_fields" + or mode == "effects" + or mode == "datasets" + ), + "invalid worker arguments" +) +local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") +if mode == "datasets" then + dofile(codeRoot .. "/datasets.lua").run(root, backend, version) +elseif mode == "effects" then + dofile(codeRoot .. "/effects.lua").run(root, backend, version) +elseif mode == "script_fields" then + dofile(codeRoot .. "/script_fields.lua").run(root, backend, version) +elseif mode == "script_dialogue_audio" then + dofile(codeRoot .. "/script_dialogue_audio.lua").run(root, backend, version) +elseif mode == "script_audio" then + dofile(codeRoot .. "/script_audio.lua").run(root, backend, version) +elseif mode == "script_shops" then + dofile(codeRoot .. "/script_shops.lua").run(root, backend, version) +elseif mode == "script_trainers" then + dofile(codeRoot .. "/script_trainers.lua").run(root, backend, version) +elseif mode == "script_battles" then + dofile(codeRoot .. "/script_battles.lua").run(root, backend, version) +elseif mode == "script_travel" then + dofile(codeRoot .. "/script_travel.lua").run(root, backend, version) +elseif mode == "script_motion" then + dofile(codeRoot .. "/script_motion.lua").run(root, backend, version) +elseif mode == "script_world" then + dofile(codeRoot .. "/script_world.lua").run(root, backend, version) +elseif mode == "script_gifts" then + dofile(codeRoot .. "/script_gifts.lua").run(root, backend, version) +elseif mode == "script_state" then + dofile(codeRoot .. "/script_state.lua").run(root, backend, version) +elseif mode == "script_flags" then + dofile(codeRoot .. "/script_flags.lua").run(root, backend, version) +elseif mode == "flags" then + dofile(codeRoot .. "/flags.lua").run(root, backend, version) +elseif mode == "invalidate" then + dofile(codeRoot .. "/invalidate.lua").run(root, backend, version) +elseif mode == "wild_battle" then + dofile(codeRoot .. "/wild_battle.lua").run(root, backend, version) +elseif mode == "npcs" then + dofile(codeRoot .. "/npcs.lua").run(root, backend, version) +elseif mode == "world_edits" then + dofile(codeRoot .. "/world_edits.lua").run(root, backend, version) +elseif mode == "warp" then + dofile(codeRoot .. "/warp.lua").run(root, backend, version) +else + dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +end diff --git a/tests/api3_contract/world_edits.lua b/tests/api3_contract/world_edits.lua new file mode 100644 index 000000000..8f7527a30 --- /dev/null +++ b/tests/api3_contract/world_edits.lua @@ -0,0 +1,230 @@ +-- Real loaders, adapters and host commands, with native-shaped map/render seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version, modId) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/world_edits%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "edits") + local Runtime = require("src.mods.Runtime") + local ow, game = f.ow, f.game + local def = game.data.maps[f.home] + local flagged = + { index = 4, localId = 4, name = "NPC", id = "npc", x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 } + local flagless = { index = 8, localId = 8, name = "TEMP", x = 0, y = 1 } + def.objects = { flagged, flagless } + def.blocks = { 7, 8, 7, 8, 7, 8 } + local tiles = { + walkable = {}, + blocks = { [1] = {}, [8] = {}, [9] = {}, [256] = {} }, + collision = {}, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + f.flagged, f.flagless, f.visible = flagged, flagless, { NPC = true, TEMP = true } + f.rebuilds, f.syncs, f.reloads = 0, 0, 0 + if backend == "gen1" and f.gen == 2 then + -- Native Gen2 object definitions use list keys; public refs are still 1-based. + flagged.index, flagless.index, flagged.eventFlag = 1, 2, 7 + ow.map = require("src.world.gen2.Map").new(def, tiles) + elseif backend == "gen1" and f.gen == 3 then + -- Native Gen3 uses the reflected layout and field override service. + local overrides = {} + def.midLayout.midAt = function(_, x, y) + return def.blocks[y * def.width + x + 1] + end + f.field.metatileOverrideAt = function(_, x, y) + return overrides[y * 1024 + x] + end + f.field.setMetatile = function(x, y, block) + overrides[y * 1024 + x] = { metatile = block } + f.rebuilds = f.rebuilds + 1 + Runtime.emit("world.block_replaced", { mapId = f.home, bx = x, by = y, block = block }) + end + package.loaded["src.core.game3.objects"] = { + _defs = { flagged, flagless }, + addObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = true + if obj.flag then + game.save.flags[obj.flag] = false + end + end, + removeObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = false + if obj.flag then + game.save.flags[obj.flag] = true + end + end, + } + else + ow.map = require("src.world.Map").new(def, tiles) + end + ow.map.renderer = { + rebuild = function() + f.rebuilds = f.rebuilds + 1 + end, + } + function ow:replaceBlock(x, y, block) + self.map:setBlock(x, y, block) + self.map.renderer:rebuild() + self.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:changeBlock(x, y, block) + self.map.blocks[y * self.map.width + x + 1] = block + self.map.renderer:rebuild() + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:appearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = true + if obj.eventFlag then + game.save.flags[obj.eventFlag] = false + end + end + function ow:disappearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = false + if obj.eventFlag then + game.save.flags[obj.eventFlag] = true + end + end + function ow:syncObjectVisibility(obj) + f.syncs = f.syncs + 1 + local save = game.save + local session = f.gen == 2 and save.g2ObjectToggles or save.gen3SessionObjects + local key = f.gen == 2 and obj.name or obj.index + local value = session and session.mapId == f.home and session[key] + if obj.eventFlag and save.flags and save.flags[obj.eventFlag] ~= nil then + value = not save.flags[obj.eventFlag] + end + f.visible[obj.name] = value == nil and true or value + end + function ow:setMap(id, x, y, facing, opts) + f.reloads = f.reloads + 1 + assert(id == f.home and x == 0 and y == 0 and facing == "left", "seamless refresh preserves player") + assert(opts.seamless and opts.keepMusic and opts.via == "reload") + for _, obj in ipairs(def.objects) do + local toggles = game.save.objectToggles[id] + if toggles[obj.name] ~= nil then + f.visible[obj.name] = toggles[obj.name] + end + end + end + function f:flagValue() + local key = backend == "gen1" and self.gen > 1 and 7 or "FLAG_A" + return game.save.flags[key] + end + return f +end +function M.run(root, backend, version) + local Json = require("src.link.Json") + local f = M.fixture(root, backend, version) + local report = { invalid = {}, toggles = {}, blocks = {} } + local function unchanged() + assert(#f.exports.events() == 0 and f.rebuilds == 0 and f.reloads == 0 and f.syncs == 0) + assert(next(f.game.save.flags) == nil and f.game.save.objectToggles == nil) + assert(f.visible.NPC and f.visible.TEMP and f.player.cellX == 0 and f.player.cellY == 0) + end + local function refusal(method, args, reason) + local ok, why = f.exports[method](unpack(args, 1, args.n or #args)) + assert(ok == nil and why == reason, tostring(why) .. " ~= " .. reason) + unchanged() + report.invalid[#report.invalid + 1] = why + end + for _, bad in ipairs({ false, "", "bad\nname", string.rep("x", 257) }) do + refusal("toggle", { bad, "NPC", false }, "invalid object toggle") + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0 }) do + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + refusal("toggle", { "UNKNOWN", "NPC", false }, "unknown map: UNKNOWN") + refusal("toggle", { f.home, "NPC", "yes" }, "invalid object toggle") + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "7", false, f.gen == 3 and 1024 or 256 }) do + refusal("replace", { 0, 0, bad }, "invalid block id") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "0", false }) do + refusal("replace", { bad, 0, 7 }, "invalid block coordinates") + refusal("replace", { 0, bad, 7 }, "invalid block coordinates") + end + refusal("replace", { 2, 0, 7 }, "block coordinates out of bounds") + refusal("replace", { 0, 3, 7 }, "block coordinates out of bounds") + if f.gen ~= 3 then + refusal("replace", { 0, 0, 9 }, "unknown block id") + end + if f.gen > 1 then + refusal("toggle", { f.dest, "NPC", false }, "map is not active: " .. f.dest) + refusal("toggle", { f.home, "UNKNOWN", false }, "no such object: UNKNOWN") + refusal("toggle", { f.home, 0, false }, "no such object: 0") + end + for _, state in ipairs({ "overlay", "moving", "script", "transition" }) do + local other = M.fixture(root, backend, version) + if state == "overlay" then + other.game.stack.states[2] = {} + other.busy = true + elseif state == "moving" then + other.player.moving = true + elseif state == "script" then + other.busy = true + other.ow.runner.isRunning = function() + return true + end + if other.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + else + other.busy, other.ow.transitioning = true, true + if other.field then + other.field.locked = true + end + end + for _, method in ipairs({ "toggle", "replace" }) do + local ok, why + if method == "toggle" then + ok, why = other.exports.toggle(other.home, "NPC", false) + else + ok, why = other.exports.replace(0, 0, 7) + end + assert(ok == nil and type(why) == "string" and #other.exports.events() == 0) + end + end + f = M.fixture(root, backend, version) + for _, visible in ipairs({ false, true, true, false }) do + assert(f.exports.toggle(f.home, "NPC", visible)) + assert(f.visible.NPC == visible and f.visible.TEMP == true) + if f.gen > 1 then + assert(f:flagValue() == not visible) + end + report.toggles[#report.toggles + 1] = { visible = f.visible.NPC, flag = f:flagValue() } + end + -- Numeric refs retain native generation meaning: Gen1 a key, Gen2 list + -- position, Gen3 local id. A portable mod queries the active map definition. + if f.gen > 1 then + assert(f.exports.toggle(f.home, f.gen == 2 and 2 or 8, false)) + assert(not f.visible.TEMP) + assert(f.exports.toggle(f.home, "TEMP", true)) + assert(f.visible.TEMP) + else + assert(f.exports.toggle(f.dest, "LATER", false)) + assert(f.game.save.objectToggles[f.dest].LATER == false) + end + for _, op in ipairs({ { 0, 0, 8 }, { 1, 2, f.gen == 3 and 1023 or 255 }, { 0, 0, 0 } }) do + assert(f.exports.replace(unpack(op))) + local value, metadata = f.exports.read(f.home, op[1], op[2]) + assert(value == op[3] and metadata.coordinateBase == 0 and metadata.tileSize == (f.gen == 3 and 16 or 32)) + report.blocks[#report.blocks + 1] = { x = op[1], y = op[2], block = value, kind = metadata.kind } + end + assert(f.rebuilds == 3 and f.player.cellX == 0 and f.player.cellY == 0) + assert(#f.loader.errors == 0) + report.events = f.exports.events() + report.busy = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua new file mode 100644 index 000000000..83d92fc1b --- /dev/null +++ b/tests/engine/api3_compat.lua @@ -0,0 +1,223 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Manifest = require("src.mods.Manifest") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(api, extra) + local raw = { id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua", api = api } + for k, v in pairs(extra or {}) do + raw[k] = v + end + return raw +end +for _, api in ipairs({ 1, 2, 3 }) do + local m = Manifest.validate(manifest(api)) + local format = api == 3 and "unified" or "native" + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.eq(m[field], format, "API " .. api .. " default " .. field) + end +end +T.eq(Manifest.validate(manifest()).api, 1, "missing API remains legacy") +for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + T.raises(function() + Manifest.validate(manifest(3, { [field] = "native" })) + end, "API 3 requires unified", "API 3 refuses contradictory " .. field) + T.eq( + Manifest.validate(manifest(2, { [field] = "unified" }))[field], + "unified", + "API 2 keeps explicit opt-in" + ) + T.raises(function() + Manifest.validate(manifest(1, { [field] = "unified" })) + end, "mod API 2", "API 1 opt-in remains rejected") +end +T.raises(function() + Manifest.validate(manifest(4)) +end, "provides 3", "future API not silently accepted") +T.raises(function() + Manifest.validate(manifest(3.5)) +end, "positive integer", "fractional API rejected") +local function boot(api, extra, second, version) + Version.set(version or "red") + local rows = { probe = manifest(api, extra) } + if second then + rows.other = manifest(api, extra) + rows.other.id = "other" + end + local files = {} + for id, raw in pairs(rows) do + files["mods/" .. id .. "/manifest.json"] = Json.encode(raw) + files["mods/" .. id .. "/main.lua"] = "local mod = ...; mod.exports.api = mod" + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if files[p] then + return { type = "file" } + end + if p == "mods" or rows[p:match("^mods/(.*)$")] then + return { type = "directory" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and (second and { "probe", "other" } or { "probe" }) or {} + end, + } + local loader = Loader.new({ fs = fs, dev = true }) + local game, probes = nil, 0 + loader._game = function(_, loadedOnly) + probes = probes + 1 + if api == 3 then + assert(loadedOnly, "API 3 requested Game bootstrap") + end + return game + end + T.check(loader:load({ pokemon = {}, moves = {}, items = {} }), "real loader accepts API " .. api) + local mod = assert(loader.apis.probe) + return mod, loader, function(value) + game = value + end, function() + return probes + end +end +-- Real legacy loaders keep native defaults, API-2 status vocabulary and callback catalog. +for _, api in ipairs({ 1, 2 }) do + local m = boot(api) + T.eq(m.contentFormat, "native", "legacy content native") + T.eq(m.callbackFormat, "native", "legacy callbacks native") + T.eq(m.scriptFormat, "native", "legacy scripts native") +end +do + local m, loader, attach = + boot(2, { content_format = "unified", callback_format = "unified", script_format = "unified" }) + local mon = { + species = "MEW", + nickname = "Scout", + hp = 10, + status = "PSN", + stats = { hp = 20, special = 9 }, + moves = {}, + } + attach({ + save = { party = { mon } }, + data = { pokemon = {}, moves = {}, items = {} }, + stack = { + states = {}, + }, + }) + T.eq(m.world:party()[1].status, "poison", "API-2 unified party status unchanged") + local seen + m.events:on("pokemon.received", function(p) + seen = p.mon.status + end) + Runtime.emit("pokemon.received", { mon = mon }) + T.eq(seen, "poison", "API-2 unified event status unchanged") + T.raises(function() + m.events:on("checkpoint.restored", function() end) + end, "unknown unified callback", "API-2 catalog unchanged") +end +-- Availability must be honest, and a probe cannot construct Game or attach a world. +do + local m, loader, attach, probes = boot(3, nil, true) + T.eq(probes(), 0, "entry does not resolve Game") + local world, battle = m.world, m.battle + T.eq(type(world), "table", "world facade exists before Game") + T.eq(type(world.party), "function", "early world method callable") + T.eq(type(battle.snapshot), "function", "early battle method callable") + for _, name in ipairs({ + "invalidateMap", + "startWildBattle", + "spawnNpc", + "removeNpc", + "npc", + "warpTo", + "toggleObject", + "replaceBlock", + "party", + "current", + "activeBlockAt", + "liveMaps", + "queueScript", + "getFlag", + "setFlag", + }) do + T.eq(m:supports("world", name), true, "implemented world method " .. name) + end + T.eq(probes(), 0, "capability and unsupported calls do not resolve Game") + T.eq(world:party(), nil, "no-game party refuses safely") + T.eq(battle:snapshot(), nil, "no-game battle refuses safely") + T.eq(m.datasets:open("gold"), nil, "unavailable dataset API callable") + T.eq(m:supports("hooks", "render.output"), false, "recognized output hook is not falsely available") + T.check( + pcall(function() + m.hooks:wrap("render.output", function(next, ctx) + return next(ctx) + end) + end), + "known absent hook can be registered" + ) + T.check( + pcall(function() + m.events:on("checkpoint.restored", function() end) + end), + "known absent event can be registered" + ) + T.raises(function() + m.events:on("not.catalogued", function() end) + end, "unknown unified callback", "unknown API-3 name rejected") + local save = { party = {}, flags = {}, modData = {} } + attach({ save = save, data = {}, stack = { states = {} } }) + local flags = {} + m.events:on("flag.changed", function(p) + flags[#flags + 1] = { p.name, p.value } + end) + T.eq(world:getFlag("mod:quest.finished"), false, "flag read defaults false") + T.eq(next(save.modData), nil, "flag read allocates no bucket") + T.check(world:setFlag("mod:quest.finished", true), "private flag set") + T.check(world:setFlag("mod:quest.finished", true), "setting same flag accepted") + T.eq(#flags, 1, "unchanged flag emits no duplicate event") + T.eq(loader.apis.other.world:getFlag("mod:quest.finished"), false, "other mod cannot see flag") + T.check(world:setFlag("mod:quest.finished", false), "private flag cleared") + T.eq(save.modData.probe.worldFlags["quest.finished"], nil, "clear removes saved boolean") + T.same( + flags, + { { "mod.probe.quest.finished", true }, { "mod.probe.quest.finished", false } }, + "portable flag notifications" + ) + T.eq(next(save.flags), nil, "private flags never touch native flags") + T.eq(world:setFlag("mod:bad", "yes"), nil, "non-boolean flag rejected") + T.eq(world:setFlag("NATIVE_FLAG", true), nil, "unknown native flag refused") + T.eq(world:setFlag("mod:", true), nil, "empty private flag rejected") + save.modData.probe.worldFlags = false + T.eq(world:setFlag("mod:test", true), nil, "malformed store rejected") + T.eq(save.modData.probe.worldFlags, false, "malformed store never overwritten") + attach({ save = { party = {} }, data = {}, stack = { states = {} } }) + T.eq(world:getFlag("mod:quest.finished"), false, "same handle follows replacement save") + T.eq(m.world, world, "world handle identity stable") + T.eq(m.battle, battle, "battle handle identity stable") +end +for _, version in ipairs({ "platinum", "black" }) do + local m = boot(3, nil, false, version) + T.eq(m:supports("world", "party"), false, "no shared Gen1 contract claimed for " .. version) + for _, name in ipairs({ "spawnNpc", "removeNpc", "npc", "startWildBattle", "invalidateMap" }) do + T.eq(m:supports("world", name), false, "no shared NPC API promised for " .. version) + end + T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) + T.eq(m:supports("world", "toggleObject"), false, "no shared object edits promised for " .. version) + T.eq(m:supports("world", "replaceBlock"), false, "no shared block edits promised for " .. version) + local value, why = m.world:party() + T.eq(value, nil, "unsupported generation call refuses") + T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 entry compatibility") diff --git a/tests/engine/api3_entry.lua b/tests/engine/api3_entry.lua new file mode 100644 index 000000000..7eb1a2712 --- /dev/null +++ b/tests/engine/api3_entry.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.run") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, failing in ipairs({ false, true }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version, failing) + T.check(ok, version .. (failing and " rollback: " or ": ") .. tostring(why)) + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 cross-engine contracts") diff --git a/tests/engine/api3_flags.lua b/tests/engine/api3_flags.lua new file mode 100644 index 000000000..aa9366cf2 --- /dev/null +++ b/tests/engine/api3_flags.lua @@ -0,0 +1,113 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.flags") +local F = require("src.mods.Api3Flags") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared native flags: " .. tostring(why)) +end +-- Every generated catalog alias resolves to the live VM/save-converter key. +local vm = require("src.script.Gen3ScriptVM") +for _, version in ipairs({ "red", "blue", "yellow", "firered", "emerald" }) do + local key = version == "blue" and "red" or version + local data = require("src.mods.flag_names." .. key) + local gen = Version.generation(version) + for number, row in pairs(data.records) do + local expected = gen == 1 and row[1] or vm.flagName(number) + if row[1] then + T.eq(F.resolve(gen, version, number), expected, "catalog numeric resolves to native key") + end + for _, alias in ipairs(row) do + if alias then + T.eq(F.resolve(gen, version, alias), expected, "catalog alias resolves to native key") + end + end + end +end +for _, version in ipairs({ "gold", "silver", "crystal" }) do + for number = 0, 2047 do + T.eq( + F.resolve(2, version, number), + require("src.script.Gen2Flags").eventFlag(number), + "native Gen2 bit key" + ) + end +end +for _, version in ipairs({ "prism", "polishedcrystal" }) do + T.eq(F.resolve(2, version, 16), nil, "no cartridge flag numbering guessed for hacks") +end +for _, bad in ipairs({ {}, false, 0 / 0, math.huge, -1, 0.5, string.rep("A", 257), "FLAG\0BAD" }) do + local f = fixture() + T.eq(f.exports.set(bad, true), nil, "malformed native ref refused") + T.eq(next(f.game.save.flags), nil, "refusal leaves native store untouched") +end +for _, bad in ipairs({ 0, "yes", {}, function() end }) do + local f = fixture() + T.eq(f.exports.set(f.number, bad), nil, "real boolean required") + T.eq(next(f.game.save.flags), nil, "bad value not stored") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local storage = assert(F.resolve(f.gen, version, f.number)) + T.check(f.exports.set(f.number, true), "native set") + T.eq(f.game.save.flags[storage], true, "native VM sees same flag") + T.check(f.exports.set(f.number, false), "native clear") + T.eq(f.game.save.flags[storage], false, "clear distinguished from untouched for visibility") + T.eq(f.exports.get(f.number), false, "public clear boolean") + f.game.save.flags = false + T.eq(f.exports.get(f.number), nil, "malformed native store refused") + T.eq(f.exports.set(f.number, true), nil, "malformed native store not overwritten") + T.eq(f.game.save.flags, false, "malformed native store retained") +end +do + local f = fixture("firered") + local storage = assert(F.resolve(3, "firered", f.number)) + local own = { index = 1, eventFlag = storage } + local other = { index = 2, eventFlag = "unrelated" } + f.ow.map.def.objects = { own, other } + local calls = {} + function f.ow:syncObjectVisibility(object) + calls[#calls + 1] = object + end + T.check(f.exports.set(f.symbol, true), "visibility flag set") + T.eq(#calls, 1, "only affected actor synchronized") + T.eq(calls[1], own, "synchronization scoped to native flag") + T.eq(f.ow.map.def.objects[2], other, "unrelated template unchanged") +end +do + local f = fixture() + local observed = 0 + f.loader.events:on("flag.changed", function() + observed = observed + 1 + local ok, why = f.exports.set(f.number, false) + T.eq(ok, nil, "flag event cannot reenter a write") + T.eq(why, "world is busy", "flag reentry diagnosed") + end, 0, "probe") + T.check(f.exports.set(f.number, true), "outer write accepted") + T.eq(observed, 1, "no recursive event loop") + T.eq(f.exports.get(f.number), true, "reentrant attempt cannot alter committed flag") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if action == "rollback" then + f.loader:_rollback("flags") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(f.exports.set(f.number, true), nil, "retired native setter") + T.eq(f.exports.set("mod:key", true), nil, "retired private setter") + T.eq(next(f.game.save.flags), nil, "retired setter no effects") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 native flags") diff --git a/tests/engine/api3_invalidate.lua b/tests/engine/api3_invalidate.lua new file mode 100644 index 000000000..ee295e14f --- /dev/null +++ b/tests/engine/api3_invalidate.lua @@ -0,0 +1,126 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.invalidate") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local OW = require("src.world.OverworldController") +local MapLoader = require("src.world.MapLoader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared invalidation contract: " .. tostring(why)) +end +for _, lock in ipairs({ "overlay", "moving", "script", "parallel", "link", "transition", "no_save" }) do + local f = fixture() + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "parallel" then + f.ow.parallelQueue = { {} } + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "transition" then + f.ow.transitioning = true + else + f.game.save = nil + end + T.eq(f.exports.request(f.home), nil, lock .. " refuses reload") + T.eq(f.reloads, 0, lock .. " has no native effects") +end +for _, bad in ipairs({ {}, 1, 0 / 0, math.huge, false, string.rep("x", 257), "bad\0id" }) do + local f = fixture() + T.eq(f.exports.request(bad), nil, "malformed ID refused") + T.eq(f.reloads, 0, "malformed ID not dispatched") +end +do + local f = fixture() + f.reloadError = true + T.eq(f.exports.request(f.home), nil, "native failure returns refusal") + T.eq(#f.exports.events(), 0, "failed reload emits no success event") + f.reloadError = false + T.check(f.exports.request(f.home), "native exception releases guard") + local native = f.ow.reloadMap + function f.ow:reloadMap(...) + local value, why = f.exports.request(f.home) + T.eq(value, nil, "native callback cannot reenter") + T.eq(why, "world is busy", "reload reentry diagnosed") + return native(self, ...) + end + T.check(f.exports.request(f.home), "outer reload accepted") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if action == "rollback" then + f.loader:_rollback("invalidate") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(f.exports.request(f.home), nil, "retired facade refuses after " .. action) + T.eq(f.reloads, 0, "retired facade makes no native calls") +end +local function upvalue(fn, wanted) + for i = 1, 100 do + local name, value = debug.getupvalue(fn, i) + if name == wanted then + return value, i + end + if not name then + break + end + end + error("native upvalue missing: " .. wanted) +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + for _, outside in ipairs({ false, true }) do + local f = fixture(version) + local old, index = upvalue(OW.reloadMap, "Game") + debug.setupvalue(OW.reloadMap, index, f.game) + f.ow.reloadMap = OW.reloadMap + f.ow.neighbors = { { map = { id = f.dest } } } + local cache = upvalue(MapLoader.invalidate, "cache") + cache[f.home], cache[f.dest] = {}, {} + local loads = {} + function f.ow:setMap(id, x, y, facing, opts) + loads[#loads + 1] = { id = id, x = x, y = y, facing = facing, opts = opts } + self.map = { + id = id, + def = f.game.data.maps[id], + inBounds = function() + return not outside + end, + } + end + function f.ow:healPoint() + return { map = f.dest, x = 1, y = 2 } + end + T.check(f.exports.request(f.home), "native reload accepted") + T.eq(cache[f.home], nil, "native target cache evicted") + T.eq(cache[f.dest], nil, "native neighbor cache evicted") + T.eq(loads[1].x, 0, "native reload preserves x") + T.eq(loads[1].facing, "left", "native reload preserves facing") + T.eq(loads[1].opts.keepMusic, true, "native seamless reload keeps music") + T.eq(loads[1].opts.seamless, true, "native seamless reload selected") + T.eq(#loads, outside and 2 or 1, "native out-of-bounds fallback count") + if outside then + T.eq(loads[2].id, f.dest, "invalidated geometry uses heal map") + T.eq(loads[2].facing, "down", "heal fallback orientation") + end + T.eq(#f.exports.events(), 1, "one native reload notification") + debug.setupvalue(OW.reloadMap, index, old) + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 map invalidation") diff --git a/tests/engine/api3_npc.lua b/tests/engine/api3_npc.lua new file mode 100644 index 000000000..f3c4844c4 --- /dev/null +++ b/tests/engine/api3_npc.lua @@ -0,0 +1,423 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.npcs") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local Renderer = require("src.render.SpriteRenderer") +local originalRenderer = Renderer.new +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function spawn(f) + local id = assert( + f.exports.spawn( + f.home, + { x = 0, y = 0, name = "OWN", sprite = "PROBE", movement = "STAY", range = "DOWN" } + ) + ) + return assert(f.exports.npc(f.home, id)), f.ow.npcPool[id] +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared NPC contract: " .. tostring(why)) +end +local cycle = { x = 0, y = 0 } +cycle.self = cycle +local meta = setmetatable({ x = 0, y = 0 }, {}) +local deep = { x = 0, y = 0 } +local child = deep +for _ = 1, 18 do + child.next = {} + child = child.next +end +local wide = { x = 0, y = 0 } +for i = 1, 260 do + wide[i] = i +end +for _, row in ipairs({ + cycle, + meta, + deep, + wide, + { x = 0, y = 0, handler = function() end }, + { x = math.huge, y = 0 }, + { x = 0 / 0, y = 0 }, + { x = 1000001, y = 0 }, + { x = 0, y = -1 }, + { x = 0, y = 0.5 }, + { x = 0, y = 0, name = "bad\nname" }, + { x = 0, y = 0, sprite = "missing" }, + { x = 0, y = 0, facing = "north" }, + { x = 0, y = 0, payload = string.rep("x", 4097) }, +}) do + local f = fixture() + local value, why = f.exports.spawn(f.home, row) + T.eq(value, nil, "invalid definition refused") + T.check(type(why) == "string", "definition failure explained") + T.eq(#f.game.data.maps[f.home].objects, 1, "invalid definition adds no template") + T.eq(#f.ow.npcs, 1, "invalid definition adds no actor") +end +for _, version in ipairs({ "red", "gold", "firered" }) do + for _, lock in ipairs({ "overlay", "player", "script", "link", "parallel", "controller" }) do + local f = fixture(version) + local handle, actor = spawn(f) + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "player" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "parallel" then + f.ow.parallelRunners = { {} } + else + f.ow._modScriptLock = {} + end + T.eq(handle:position(), 0, "busy world permits position read") + T.eq(handle:isMoving(), false, "busy world permits motion read") + T.eq(handle:face("left"), nil, lock .. " blocks mutation") + T.eq(f.exports.spawn(f.home, { x = 0, y = 0 }), nil, lock .. " blocks spawn") + T.eq(f.exports.remove(handle.id), nil, lock .. " blocks removal") + T.eq(actor.facing, "down", "refusal does not change actor") + end +end +for _, method in ipairs({ "face", "stepNow", "scriptMove", "canStep" }) do + for _, bad in ipairs({ "north", false, {}, 1 }) do + local f = fixture() + local handle = spawn(f) + local ok, why = handle[method](handle, bad) + T.eq(ok, nil, "bad direction refused for " .. method) + T.eq(why, "invalid direction", "bad direction diagnosed") + T.eq(#f.ow.scriptMoves, 0, "no queued movement on invalid direction") + end +end +for _, bad in ipairs({ 0, -1, 0.5, math.huge, 0 / 0, 1025, "1" }) do + local f = fixture() + local handle = spawn(f) + T.eq(handle:scriptMove("up", bad), nil, "invalid move count refused") + T.eq(#f.ow.scriptMoves, 0, "invalid count has no effect") +end +do + local f = fixture() + local handle, actor = spawn(f) + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + local value, why = handle:face("left") + T.eq(value, nil, "native construction cannot reenter NPC mutation") + T.eq(why, "world is busy", "NPC reentry diagnosed") + value, why = f.exports.toggle(f.home, "IMPORTED", false) + T.eq(value, nil, "NPC construction cannot reenter object edit") + T.eq(why, "world is busy", "shared mutation guard diagnosed") + return native(self, ...) + end + T.check(f.exports.spawn(f.home, { x = 2, y = 2, sprite = "PROBE" }), "outer construction succeeds") + T.eq(actor.facing, "down", "reentry leaves actor untouched") + T.check(handle:face("right"), "construction releases guard") + local constructor = Renderer.new + Renderer.new = function() + error("renderer failed", 0) + end + local def, sprite = actor.def, actor.sprite + T.eq(handle:setAppearance("ALT"), nil, "failed renderer construction contained") + T.eq(actor.def, def, "failed renderer retains original definition") + T.eq(actor.sprite, sprite, "failed renderer retains original sprite") + Renderer.new = constructor + T.check(handle:face("up"), "renderer exception releases guard") +end +for _, change in ipairs({ "game", "save", "data", "stack", "world", "map", "actor", "version", "bus" }) do + local f = fixture() + local handle, actor = spawn(f) + T.check(handle:scriptMove("right", 2, f.exports.done), "movement starts before " .. change) + local callback = f.pending.callback + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + elseif change == "world" then + local old = f.game.overworld + f.game.overworld = {} + restore = function() + f.game.overworld = old + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "actor" then + table.remove(f.ow.npcs, #f.ow.npcs) + restore = function() + f.ow.npcs[#f.ow.npcs + 1] = actor + end + else + local object = (change == "map") and f.ow or f.game + local old = object[change] + object[change] = {} + restore = function() + object[change] = old + end + end + f:update() + T.eq(#f.ow.scriptMoves, 0, "changed context retires only owned movement") + restore() + callback("stale") + callback("replayed") + T.eq(f.exports.result().calls, 0, "restored " .. change .. " cannot revive callback") +end +for _, event in ipairs({ "map.exited", "map.entered" }) do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right", 1, f.exports.done), "movement starts before map lifecycle") + local callback = f.pending.callback + Runtime.emit(event, { mapId = f.home }) + T.eq(#f.ow.scriptMoves, 0, "map lifecycle retires movement immediately") + callback() + T.eq(f.exports.result().calls, 0, "old map callback suppressed") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + local handle, actor = spawn(f) + local foreign = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + T.check(handle:scriptMove("right", 1, f.exports.done), "queue before " .. action) + local callback = f.pending.callback + local foreignEntry = { + entity = f.imported, + remaining = 1, + onDone = function() + error("do not invoke") + end, + } + f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreignEntry + if action == "rollback" then + f.loader:_rollback("npcs") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(#f.ow.scriptMoves, 1, "unload removes only owned queue entries") + T.eq(f.ow.scriptMoves[1], foreignEntry, "foreign movement retained") + T.eq(#f.game.data.maps[f.home].objects, 2, "unload removes only own runtime template") + T.eq(f.ow.npcPool[handle.id], nil, "own actor removed from native pool") + T.check(f.ow.npcPool[foreign], "foreign owner retained") + callback() + T.eq(f.exports.result().calls, 0, "unload suppresses native callback") + T.eq(handle:position(), nil, "unloaded handle retired") + T.eq(f.exports.npc(f.home, "IMPORTED"), nil, "unloaded facade retired") +end +do + local f = fixture() + local handle, actor = spawn(f) + T.check(f.exports.remove(handle.id), "own removal succeeds") + local replacement = assert(f.exports.spawn(f.home, { name = "NEW", sprite = "PROBE", x = 1, y = 1 })) + T.eq(replacement, handle.id, "native ID can be reused") + local value, why = handle:face("left") + T.eq(value, nil, "old handle cannot change replacement with same ID") + T.eq(why, "NPC handle is stale", "stale handle diagnosed") + T.eq(f.exports.npc(f.home, replacement):position(), 1, "replacement remains untouched") +end +do + local f = fixture() + local handle, actor = spawn(f) + f.synchronous = true + T.check( + handle:scriptMove("right", 1, function(...) + T.eq(Runtime.currentMod, "npcs", "movement callback attributed") + f.exports.done(...) + f.synchronous = false + T.check(handle:face("up"), "synchronous callback runs after commit guard released") + end), + "synchronous move accepted" + ) + T.eq(f.exports.result().calls, 1, "synchronous callback called once") + T.eq(f.exports.result().args.n, 3, "nil argument holes preserved") + f.pending.callback() + T.eq(f.exports.result().calls, 1, "duplicate completion ignored") + T.eq(Runtime.currentMod, nil, "callback restores runtime owner") +end +for _, err in ipairs({ + "authored failure", + setmetatable({}, { + __tostring = function() + error("hostile tostring") + end, + }), +}) do + local f = fixture() + local handle = spawn(f) + T.check( + handle:scriptMove("right", 1, function() + error(err, 0) + end), + "throwing callback admitted" + ) + T.check( + pcall(function() + f:complete() + f.pending.callback() + end), + "callback errors cannot escape native completion" + ) + T.eq(#f.loader.errors, 1, "callback error attributed once") + T.check( + f.loader.errors[1]:find("npcs: NPC movement callback failed:", 1, true), + "callback error names owner" + ) +end +do + local f = fixture() + local handle = spawn(f) + f.failMovement, f.synchronous = true, true + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "native refusal never counts as admission") + T.eq(f.exports.result().calls, 0, "callback deferred until native result") + T.eq(#f.ow.scriptMoves, 0, "native refusal leaves no owned queue entry") +end +do + local f = fixture() + local handle = spawn(f) + local native = f.ow.scriptMove + function f.ow:scriptMove(...) + Runtime.emit("map.exited", { mapId = f.home }) + return native(self, ...) + end + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "map change during admission refuses completion") + T.eq(#f.ow.scriptMoves, 0, "late-created owned entry cleaned after cancellation race") +end +do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right"), "optional count/callback accepted") + f:complete() + T.eq(handle:position(), 1, "default count is one") + T.eq(#f.loader.errors, 0, "omitted callback never treated as a callable") +end +-- Real native object creation/removal, NPC updates and movement queue. +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.addRuntimeObject, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.addRuntimeObject, i, game) + return function() + debug.setupvalue(OW.addRuntimeObject, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + f.ow.addRuntimeObject, f.ow.removeRuntimeObject = OW.addRuntimeObject, OW.removeRuntimeObject + f.ow.scriptMove, f.ow.marchInPlace = OW.scriptMove, OW.marchInPlace + local handle, actor = spawn(f) + actor.frozen = true + T.check(handle:scriptMove("right", 2, f.exports.done), version .. " native movement starts") + for _ = 1, 40 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "native queue walks exactly two cells") + T.eq(f.exports.result().calls, 1, "native completion exactly once") + T.eq(#f.ow.scriptMoves, 0, "native movement releases world input") + T.check(handle:marchInPlace(f.exports.done), "native march starts") + for _ = 1, 20 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "march does not translate") + T.eq(f.exports.result().calls, 2, "native march completes") + T.check(handle:setAppearance("ALT"), "native renderer refresh path") + T.eq(actor.def.sprite, "ALT", "actor appearance changed") + T.eq( + f.game.data.maps[f.home].objects[2].sprite, + "PROBE", + "native template data not rewritten by appearance" + ) + T.check(f.exports.remove(handle.id), "native removal succeeds") + T.eq(#f.ow.npcs, 1, "native removal retains imported actor") + restore() +end +do + local f = fixture() + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + native(self, ...) + error("creation failure", 0) + end + T.eq(f.exports.spawn(f.home, { sprite = "PROBE", x = 0, y = 0 }), nil, "creation exception contained") + T.eq(#f.game.data.maps[f.home].objects, 1, "failed creation template rolled back") + T.eq(#f.ow.npcs, 1, "failed creation actor rolled back") + T.eq(next(f.ow.npcPool), f.imported.id, "failed creation leaves pool unchanged") +end +do + local f = fixture() + local row = + { name = "LATER", sprite = "PROBE", x = 1, y = 1, owner = "forged", runtime = false, index = 900 } + local id = assert(f.exports.spawn(f.dest, row)) + local record = f.game.data.maps[f.dest].objects[1] + T.eq(record.owner, "npcs", "caller cannot forge runtime ownership") + T.eq(record.runtime, true, "native runtime marker assigned") + T.eq(record.index, 1, "native identity replaces supplied index") + T.eq(#f.ow.npcs, 1, "inactive spawn adds no active actor") + local actor = f:make(f.dest, record) + actor.def = require("src.mods.Merge").deepCopy(record) -- Native placement/appearance clones. + local map = require("src.world.Map").new(f.game.data.maps[f.dest], f.ow.map.tileset) + f.ow.ghosts = { { npc = actor, map = map } } + f.ow.npcPool[id] = actor + local handle = assert(f.exports.npc(f.dest, id)) + T.eq(handle:position(), 1, "live neighbor uses its own coordinate frame") + T.eq(handle:canStep("right"), true, "neighbor collision uses neighbor map") + local homeHandle, homeActor = spawn(f) + homeActor.cellX, homeActor.cellY = 2, 1 + local nb = { map = map, cx = 0, cy = 0, peers = { actor } } + f.ow.neighbors = { nb } + f.ow.ghosts[1].nb = nb + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), false, "neighbor cast sees translated primary actor") + T.eq(homeHandle:canStep("left"), false, "primary cast sees translated neighbor actor") + homeActor.passable = true + actor.passable = true + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), true, "neighbor cast respects primary passability") + T.eq(homeHandle:canStep("left"), true, "primary cast respects neighbor passability") + T.check(handle:face("left"), "neighbor can face") + local ok, why = handle:scriptMove("right", 1) + T.eq(ok, nil, "neighbor track refused") + T.eq(why, "movement tracks require the active map", "neighbor restriction explicit") + Loader.teardown() + T.eq(#f.game.data.maps[f.dest].objects, 0, "inactive template cleaned on unload") + T.eq(#f.ow.ghosts, 0, "later neighbor actor clone cleaned on unload") + T.eq(f.ow.npcPool[id], nil, "neighbor pool cleaned on unload") +end +do + local f = fixture() + local handle, actor = spawn(f) + handle.id = f.imported.id + T.check(handle:face("left"), "public ID field cannot redirect private handle binding") + T.eq(actor.facing, "left", "original binding changed") + T.eq(f.imported.facing, "down", "imported actor unaffected") +end +Renderer.new = originalRenderer +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 public NPC actions") diff --git a/tests/engine/api3_warp.lua b/tests/engine/api3_warp.lua new file mode 100644 index 000000000..0bc8ddbb1 --- /dev/null +++ b/tests/engine/api3_warp.lua @@ -0,0 +1,249 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.warp") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared warp contract: " .. tostring(why)) +end +for _, lock in ipairs({ + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "scriptMoves", + "stairExit", + "spinning", + "inputLocked", + "fieldMove", + "linkSession", + "linkNet", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "stairExit" or lock == "spinning" or lock == "inputLocked" or lock == "fieldMove" then + f.player[lock] = true + elseif lock:find("Runners") or lock:find("Queue") or lock == "pendingScripts" or lock == "scriptMoves" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, lock .. " declines admission") + T.eq(why, "world is busy", lock .. " explains busy state") + T.eq(#f.trace, 0, lock .. " never dispatches") +end +do + local f = fixture() + f.game.save = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing save refuses") + f.live = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing game refuses") + T.eq(#f.trace, 0, "missing context never dispatches") +end +for _, size in ipairs({ false, -1, 0, 0.5, math.huge, 0 / 0 }) do + local f = fixture() + f.game.data.maps[f.dest].width = size + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, "malformed map dimensions refuse") + T.eq(why, "invalid map dimensions", "dimensions diagnosed before native work") + T.eq(#f.trace, 0, "malformed maps never dispatch") +end +do + local f = fixture() + local opts = setmetatable({}, { + __index = function() + error("must not run") + end, + }) + local ok, why = f.exports.request(f.dest, 0, 0, "down", opts) + T.eq(ok, nil, "metatable options rejected") + T.eq(why, "invalid options", "plain options required") + T.eq(#f.trace, 0, "metatable never reaches native warp") +end +for _, change in ipairs({ "data", "stack", "player", "version" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start " .. change) + f:arrive() + local restore + if change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + else + local obj = change == "player" and f.ow or f.game + local previous = obj[change] + obj[change] = {} + restore = function() + obj[change] = previous + end + end + f:update() + restore() + f:complete() + T.eq(f.exports.results().calls, 0, change .. " cannot revive callback after observed change") +end +for _, failure in ipairs({ "refuse", "throw" }) do + local f = fixture() + f.failure, f.synchronous = failure, true + f.ow.arriveWarp = "original" + local ok, why = f.exports.request(f.dest, 0, 0, "up", { arrive = "fly", onDone = f.exports.notify }) + T.eq(ok, nil, "native " .. failure .. " is not admitted") + T.check(type(why) == "string", "native failure explained") + T.eq(f.exports.results().calls, 0, "synchronous callback waits for native acceptance") + T.eq(f.ow.arriveWarp, "original", "failed dispatch restores borrowed arrival field") + f:complete() + T.eq(f.exports.results().calls, 0, "failed callback cannot replay") +end +do + local f = fixture() + f.synchronous = true + local ok = f.exports.request(f.dest, 0, 0, "up", { + onDone = function(...) + T.eq(Runtime.currentMod, "warp", "callback attributed to owning mod") + f.exports.notify(...) + f.synchronous = false + T.check( + f.exports.request(f.home, 1, 1, "down", { onDone = f.exports.notify }), + "callback may start next warp" + ) + end, + }) + T.check(ok, "synchronous native arrival admitted") + T.eq(#f.trace, 2, "one reentrant request dispatches once") + T.eq(Runtime.currentMod, nil, "callback restores previous runtime owner") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 2, "old completion does not clear next request") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before " .. operation) + f:arrive() + if operation == "rollback" then + f.loader:_rollback("warp") + elseif operation == "reload" then + fixture() -- A new loader/session retires every previous facade. + else + Loader.teardown() + end + f:complete() + T.eq(f.exports.results().calls, 0, operation .. " retires callback") + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, operation .. " retires facade") + T.eq(why, "warp facade is retired", "stale facade diagnostic") +end +for _, direction in ipairs({ "up", "down", "left", "right" }) do + local f = fixture() + T.check(f.exports.request(f.home, 1, 1, direction, { onDone = f.exports.notify }), "same-map " .. direction) + T.eq(f.trace[1].facing, direction, "direction reaches native warp unchanged") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 1, "same-map arrival not mistaken for stale completion") +end +do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before false arrival") + f:complete() + T.eq(f.exports.results().calls, 0, "completion on old map never invokes mod") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 0, "false completion cannot be revived by later arrival") +end +-- Exercise the actual native transition; only map materialization is synthetic. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local Transition = require("src.render.Transition") +local StateStack = require("src.core.StateStack") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.startWarpTo, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.startWarpTo, i, game) + return function() + debug.setupvalue(OW.startWarpTo, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, fade in ipairs({ true, false }) do + if fade or Version.generation(version) == 3 then + local f = fixture(version) + setmetatable(f.game.stack, { __index = StateStack }) + f.ow.startWarpTo = OW.startWarpTo + local restore = bind(f.game) + function f.ow:frlgBehaviours() + return false + end + function f.ow:noteGen2Spawn(from) + self.notedFrom = from + end + function f.ow:setMap(id, x, y, facing, opts) + f.destination = { id, x, y, facing } + T.eq(opts.via, "warp", "native transition retains warp reason") + T.eq(opts.keepMusic, true, "native transition retains music request") + f:arrive() + end + local opts = { onDone = f.exports.notify, keepMusic = true } + if f.gen == 3 then + opts.fade = fade + end + T.check(f.exports.request(f.dest, 1, 2, "right", opts), "actual native warp admitted") + if fade then + local state = f.game.stack:top() + T.eq(getmetatable(state), Transition, "ordinary warp pushes native fade") + T.eq(f.exports.results().calls, 0, "callback waits for native transition") + for _ = 1, state.frames + state.framesIn + 2 do + if f.game.stack:top() == state then + state:update(1 / 60) + end + end + else + T.eq(#f.game.stack.states, 1, "no-fade warp creates no transition screen") + end + T.eq(f.ow.map.id, f.dest, "native transition arrives at destination") + T.eq(f.ow.notedFrom, f.home, "native spawn bookkeeping retained") + T.same(f.ow.warpEntryCell, { x = 1, y = 2 }, "native arrival tile guard retained") + T.eq(f.ow.transitioning, false, "native transition releases lock") + T.eq(f.exports.results().calls, 1, "actual native callback runs exactly once") + restore() + end + end +end +-- Legacy public facade continues to ignore fade, rather than opting into API 3. +do + local f = fixture("firered") + local native = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(native:warpTo(f.dest, -1, 0, "north", { fade = false }), "legacy dispatch behavior unchanged") + T.eq(f.trace[1].fade, nil, "legacy fade option remains ignored") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 portable warp") diff --git a/tests/engine/api3_wild_battle.lua b/tests/engine/api3_wild_battle.lua new file mode 100644 index 000000000..71bafc967 --- /dev/null +++ b/tests/engine/api3_wild_battle.lua @@ -0,0 +1,179 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.wild_battle") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local Battle = require("src.battle.BattleState") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared battle contract: " .. tostring(why)) +end +for _, level in ipairs({ -1, 0, 1.5, 101, math.huge, 0 / 0, "5", false, {} }) do + local f = fixture() + T.eq(f.exports.request("MEW", level), nil, "invalid level refused") + T.eq(f.constructed, 0, "invalid level never constructs/marks seen") + f.restore() +end +for _, lock in ipairs({ "overlay", "moving", "script", "parallel", "link", "transition", "no_save" }) do + local f = fixture() + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "parallel" then + f.ow.parallelQueue = { {} } + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "transition" then + f.ow.transitioning = true + else + f.game.save = nil + end + T.eq(f.exports.request("MEW", 5), nil, lock .. " blocks battle") + T.eq(f.constructed, 0, lock .. " leaves dex/RNG untouched") + f.restore() +end +for _, change in ipairs({ "game", "save", "data", "stack", "world", "player", "version", "bus" }) do + local f = fixture() + T.check(f.exports.request("MEW", 5, f.exports.notify), "start before " .. change) + local restore + if change == "game" then + f.live = {} + restore = function() + f.live = f.game + end + elseif change == "world" then + f.game.overworld = {} + restore = function() + f.game.overworld = f.ow + end + elseif change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = {} + restore = function() + Runtime.events = old + end + else + local object = change == "player" and f.ow or f.game + local old = object[change] + object[change] = {} + restore = function() + object[change] = old + end + end + f.loader.hooks:call("input.step", function() end) + restore() + f:complete("win") + T.eq(f.exports.result().calls, 0, "observed replacement permanently retires callback") + T.eq(f.after, 1, "restored native session still completes cleanup") + f.restore() +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + T.check(f.exports.request("MEW", 5, f.exports.notify), "start before " .. action) + if action == "rollback" then + f.loader:_rollback("wild_battle") + elseif action == "reload" then + local nextFixture = fixture() + nextFixture.restore() + else + Loader.teardown() + end + f:complete("caught") + T.eq(f.after, 1, "unloading mod preserves native cleanup") + T.eq(f.exports.result().calls, 0, "unloading retires callback") + T.eq(f.exports.request("MEW", 5), nil, "unloaded facade retired") + f.restore() +end +do + local f = fixture() + T.check(f.exports.request("MEW", 5, f.exports.notify), "start before foreign save") + f.game.save = {} + f:complete("lose") + T.eq(f.after, 0, "late result cannot run cleanup against replaced save") + T.eq(f.exports.result().calls, 0, "late result cannot call mod on replaced save") + f.restore() +end +for _, mode in ipairs({ "constructError", "pushFailure", "cleanupError", "callbackError", "syncRefusal" }) do + local f = fixture() + local callback = f.exports.notify + if mode == "callbackError" then + callback = function() + error("authored callback probe") + end + elseif mode == "syncRefusal" then + f.synchronous, f.pushFailure = true, true + else + f[mode] = true + end + local value = f.exports.request("MEW", 5, callback) + if mode == "constructError" or mode == "pushFailure" or mode == "syncRefusal" then + T.eq(value, nil, "failed admission returns nil") + T.eq(f.after, 0, "failed admission never invokes native completion") + T.eq(f.exports.result().calls, 0, "failed admission never calls mod") + else + T.check(value, "completion-error battle admitted") + T.check(pcall(f.complete, f, "win"), "completion errors contained") + T.eq(#f.loader.errors, 1, "completion error attributed once") + f:complete("win") + T.eq(#f.loader.errors, 1, "replay cannot repeat error") + end + f.restore() +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.request("MEW", 5, function(...) + T.eq(Runtime.currentMod, "wild_battle", "completion owner restored") + f.exports.notify(...) + f.synchronous = false + T.check(f.exports.request("MEW", 6), "synchronous completion releases admission guard") + end), + "synchronous battle admitted" + ) + T.eq(f.pushed, 2, "callback can start next battle") + f:complete("run") + f.restore() +end +-- Real native BattleState/Pokemon construction with fixture data. The entry +-- transition and cleanup seams are independent from the public adapter. +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + f.restore() + local data = T.fixtures.fresh() + data.pokemon.MEW = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A) + data.pokemon.MEW.id, data.pokemon.MEW.name = "MEW", "MEW" + data.constants.gen = Version.generation(version) + f.game.data = data + f.game.save = require("src.core.SaveData").newGame() + f.game.session = f.game.save + f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) } + require("src.render.Font").load(data) + T.check(f.exports.request("MEW", 5, f.exports.notify), version .. " native constructor admitted") + T.eq(f.battle.kind, "wild", "native battle kind") + T.eq(f.battle.enemy.mon.species, "MEW", "native enemy species") + T.eq(f.battle.enemy.mon.level, 5, "native level") + T.eq(f.game.save.pokedex.seen.MEW, true, "native constructor marks seen") + f:complete("caught") + T.eq(f.after, 1, "native battle retains cleanup") + T.eq(f.exports.result().calls, 1, "native battle completes public callback") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 public wild battle") diff --git a/tests/engine/api3_world_edits.lua b/tests/engine/api3_world_edits.lua new file mode 100644 index 000000000..3ef071813 --- /dev/null +++ b/tests/engine/api3_world_edits.lua @@ -0,0 +1,241 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.world_edits") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared object/block contracts: " .. tostring(why)) +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, key in ipairs({ "flags", "objectToggles", "g2ObjectToggles", "gen3Spawned", "gen3SessionObjects" }) do + local gen = Version.generation(version) + if + gen == 1 and (key == "objectToggles" or key == "flags") + or gen == 2 and (key == "flags" or key == "g2ObjectToggles" or key == "objectToggles") + or gen == 3 + and (key == "flags" or key == "gen3Spawned" or key == "gen3SessionObjects" or key == "objectToggles") + then + local f = fixture(version) + f.game.save[key] = "keep" + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, key .. " malformed store rejected") + T.check(type(why) == "string", "store refusal explained") + T.eq(f.game.save[key], "keep", "malformed store retained") + T.eq(#f.exports.events(), 0, "malformed store emits no event") + T.eq(f.visible.NPC, true, "malformed store leaves native object untouched") + end + end +end +for _, version in ipairs({ "red", "firered" }) do + local f = fixture(version) + local key = f.gen == 1 and "objectToggles" or "gen3Spawned" + f.game.save[key] = { [f.home] = "keep" } + T.eq(f.exports.toggle(f.home, "NPC", false), nil, "malformed nested bucket rejected") + T.eq(f.game.save[key][f.home], "keep", "nested bucket never replaced") + T.eq(#f.exports.events(), 0, "no event for malformed nested bucket") +end +for _, lock in ipairs({ + "linkSession", + "linkNet", + "gen3Locked", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "_modScriptLock", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "parallelRunners" or lock == "parallelQueue" or lock == "pendingScripts" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + T.eq(f.exports.toggle(f.home, "NPC", false), nil, lock .. " blocks objects") + T.eq(f.exports.replace(0, 0, 7), nil, lock .. " blocks blocks") + T.eq(#f.exports.events(), 0, "busy requests never emit effects") +end +for _, failure in ipairs({ + "renderer", + "coordinates", + "geometry", + "current_block", + "objects", + "object_runtime", +}) do + local f = fixture("gold") + if failure == "renderer" then + f.ow.map.renderer = nil + elseif failure == "coordinates" then + f.ow.map.def.width = 0 + elseif failure == "geometry" then + f.ow.map.blockCells, f.ow.map.blockTiles = 1, 2 + elseif failure == "current_block" then + f.ow.map.def.blocks[1] = nil + elseif failure == "objects" then + f.ow.map.def.objects = nil + else + f.ow.syncObjectVisibility = nil + end + local ok, why + if failure == "objects" or failure == "object_runtime" then + ok, why = f.exports.toggle(f.home, "NPC", false) + else + ok, why = f.exports.replace(0, 0, 7) + end + T.eq(ok, nil, failure .. " refuses safely") + T.check(type(why) == "string", "native context refusal explained") + T.eq(#f.exports.events(), 0, "invalid context emits nothing") + T.eq(f.rebuilds + f.syncs + f.reloads, 0, "invalid context performs no native work") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if operation == "rollback" then + f.loader:_rollback("edits") + elseif operation == "reload" then + fixture() + else + Loader.teardown() + end + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, operation .. " retires object action") + T.eq(why, "world facade is retired", "retired object diagnostic") + ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, operation .. " retires block action") + T.eq(why, "world facade is retired", "retired block diagnostic") + T.eq(#f.exports.events(), 0, "retired facade emits nothing") +end +do + local f = fixture() + f.exports.reenter(true) + T.check(f.exports.toggle(f.home, "NPC", false), "outer edit accepted") + T.eq(f.exports.nested()[1], nil, "event cannot reenter a partially committed edit") + T.eq(f.exports.nested()[2], "world is busy", "reentry refused explicitly") + T.eq(f.rebuilds, 0, "nested edit never redraws") + T.eq(#f.exports.events(), 1, "outer edit emits once") + f.exports.reenter(false) + T.check(f.exports.replace(0, 0, 8), "guard released after successful edit") + local rebuild = f.ow.map.renderer.rebuild + f.ow.map.renderer.rebuild = function() + error("draw probe", 0) + end + local ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, "native redraw failure contained") + T.check(why:find("draw probe", 1, true), "native failure explained") + f.ow.map.renderer.rebuild = rebuild + T.check(f.exports.replace(0, 0, 8), "guard released after native exception") +end +-- Packed Gen3 source reads remain observational; replacement uses instance patches. +do + local f = fixture("emerald") + f.ow.map.def.blocks = string.rep(string.char(7, 0), 6) + f.ow.map.def._blockArray = nil + f.ow.map.collisionPatch[1] = 1 + f.ow.map.shutCells = { ["0:0"] = true } + T.check(f.exports.replace(0, 0, 8), "packed metatile map writable") + T.eq(f.exports.read(f.home, 0, 0), 8, "reads see metatile patch") + T.eq(f.ow.map.def.blocks, string.rep(string.char(7, 0), 6), "generated source bytes untouched") + T.eq(f.ow.map.def._blockArray, nil, "validation does not decode/cache source") + T.eq(f.ow.map.collisionPatch[1], 0, "native default clears stale collision override") + T.eq(f.ow.map.shutCells["0:0"], nil, "native default clears stale shut cell") +end +-- Native controller methods; no actor construction or graphics are mocked. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.syncObjectVisibility, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.syncObjectVisibility, i, game) + return function() + debug.setupvalue(OW.syncObjectVisibility, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + setmetatable(f.ow, { __index = OW }) + f.ow.syncObjectVisibility = OW.syncObjectVisibility + f.ow.replaceBlock = OW.replaceBlock + local function actor(def, x) + return { + id = f.home .. "_obj_" .. def.index, + def = def, + cellX = x, + cellY = 1, + px = x * 16, + py = 16, + facing = "right", + } + end + local target, sibling = actor(f.flagged, 3), actor(f.flagless, 2) + -- Distinct live actors share one persistent flag; only the named actor moves. + f.flagless.eventFlag = f.flagged.eventFlag + f.ow.npcs = { target, sibling } + f.ow.entities = { f.player, target, sibling } + f.ow.npcPool = { [target.id] = target, [sibling.id] = sibling } + T.check(f.exports.toggle(f.home, "NPC", false), version .. " actual native removal") + T.eq(#f.ow.npcs, 1, "only target removed") + T.eq(f.ow.npcs[1], sibling, "shared-flag sibling retained live") + T.eq(sibling.cellX, 2, "unrelated actor not relocated") + T.check(f.exports.toggle(f.home, "NPC", true), "actual native respawn") + T.eq(#f.ow.npcs, 2, "one actor restored") + T.eq(target.cellX, 3, "pooled position retained") + T.eq(target.facing, "right", "pooled facing retained") + f.flagless.eventFlag = nil + T.check(f.exports.toggle(f.home, "TEMP", false), "flagless object hidden for visit") + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), false, "native spawn predicate sees session hide") + f.game.save.g2ObjectToggles = nil + f.game.save.gen3SessionObjects = nil + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), true, "flagless default restored after visit reset") + T.check(f.exports.replace(1, 2, 7), "actual native block/redraw path") + T.eq(f.ow.map:blockAt(1, 2), 7, "actual Map returns patched block") + T.eq(f.ow.map.def.blocks[6], 8, "generated map record retained") + T.eq(f.ow.map.blocksDirty, nil, "native redraw consumes dirty flag") + restore() +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = fixture(version) + T.check(f.exports.toggle("HOME", "NPC", false), "unprefixed Gen3 active map resolves") + T.eq(f.exports.events()[1].mapId, f.home, "event reports resolved native map ID") +end +do + local f = fixture("gold") + require("src.script.Gen2Commands") + local Commands = require("src.script.Commands") + Commands.g2ResetObjectBase() + local ctx = { game = f.game, save = f.game.save, overworld = f.ow } + Commands.g2_object(ctx, 2, true) -- Cache the native script numbering first. + f.game.data.field = { objectScriptBase = 1 } + T.check(f.exports.toggle(f.home, 1, false), "public slot resolves independently of cached script IDs") + T.eq(f.visible.NPC, false, "current native template receives visibility effect") + Commands.g2_object(ctx, 1, true) + T.eq(f.visible.NPC, false, "public adapter leaves existing script-ID cache unchanged") + Commands.g2ResetObjectBase() +end +-- Existing native/API-2 facade retains its own coercion/validation behavior. +do + local f = fixture() + local api = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(api:toggleObject("UNKNOWN", "missing", "truthy"), "legacy off-map toggle unchanged") + T.eq(f.game.save.objectToggles.UNKNOWN.missing, true, "legacy boolean coercion unchanged") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 object and block edits") diff --git a/tests/engine/battle_actions.lua b/tests/engine/battle_actions.lua new file mode 100644 index 000000000..2c6799081 --- /dev/null +++ b/tests/engine/battle_actions.lua @@ -0,0 +1,758 @@ +-- Public intents exercised through real Loader, BattleState and native input. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Merge = require("src.mods.Merge") +local Commands = require("src.battle.BattleCommands") +local Intents = require("src.mods.BattleIntents") +local oldVersion = GameVersion.get() + +local function boot(version, trainer, level) + GameVersion.set(version) + math.randomseed(12345) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = level or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(type(mod.battle.submit)=="function") + assert(mod.battle:snapshot()==nil) + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + assert(loader:load(data), table.concat(loader.errors, "; ")) + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + game.stack.top = function(stack) + return stack.states[#stack.states] + end + game.stack.push = function(stack, state) + stack.states[#stack.states + 1] = state + end + game.stack.pop = function(stack) + return table.remove(stack.states) + end + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase = "menu" + b.gen4CursorActive = true + b.frame = 0 + b.queue = {} + b.menuIndex = 1 + b.moveIndex = 1 + local rolls = 0 + b.rng = function(lo, hi) + rolls = rolls + 1 + return lo + end + game.stack.states = { b } + return loader.apis.probe.battle, + game, + b, + loader.apis.other.battle, + loader, + function() + return rolls + end +end +local function intent(api, id, kind, fields) + local s = assert(api:snapshot()) + local out = { id = id, revision = s.revision, kind = kind } + for k, v in pairs(fields or {}) do + out[k] = v + end + return out +end +local function send(api, id, kind, fields) + return api:submit(intent(api, id, kind, fields)) +end +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local row = { values = {}, mt = getmetatable(value) } + graph[value] = row + for key, item in pairs(value) do + row.values[key] = item + visit(item) + end + end + visit(root) + return function() + for tbl, row in pairs(graph) do + if getmetatable(tbl) ~= row.mt then + return false + end + for key, value in pairs(row.values) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if row.values[key] == nil then + return false + end + end + end + return true + end +end +local function forbidden() + error("unexpected native side effect") +end +local function reject(api, game, raw, fragment, label) + local battler = game.stack.states[1] + local oldRng = battler and battler.rng + if battler and battler.isBattleState then + battler.rng = forbidden + end + local unchanged = freeze(game) + local oldLua, oldLove, oldEmit, oldCall = math.random, love.math.random, Runtime.emit, Runtime.call + math.random, love.math.random, Runtime.emit, Runtime.call = forbidden, forbidden, forbidden, forbidden + local ok, reason = api:submit(raw) + math.random, love.math.random, Runtime.emit, Runtime.call = oldLua, oldLove, oldEmit, oldCall + T.eq(ok, nil, label .. ": rejected") + T.check( + type(reason) == "string" and reason:find(fragment, 1, true) ~= nil, + label .. ": reason " .. tostring(reason) + ) + T.check(unchanged(), label .. ": whole game graph unchanged") + if battler and battler.isBattleState then + battler.rng = oldRng + end +end +local function view(api) + local s = assert(api:snapshot()) + s.revision = nil + return s +end +local function press(game, b, key) + game.input.wasPressed = function(_, name) + return name == key + end + b:update(1 / 60) + game.input.wasPressed = function() + return false + end +end + +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. ": " + -- Identical native input and API commands, including actual turn planning. + local api, game, b, other, loader, rolls = boot(version) + local control, cgame, cb, _, _, crolls = boot(version) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "FIGHT accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "FIGHT matches native A dispatch") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "move accepted") + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "move matches native A dispatch") + T.eq(rolls(), crolls(), label .. "native RNG consumption matches") + T.eq(#b.queue, #cb.queue, label .. "native action queue matches") + T.eq(b.turnCount, 1, label .. "exactly one native turn planned") + local function drain(g, bb) + g.input.wasPressed = function() + return true + end + g.input.isDown = function() + return true + end + for frame = 1, 5000 do + if not bb:updateQueue() then + return true + end + end + return false + end + T.check(drain(game, b), label .. "API native turn queue completes") + T.check(drain(cgame, cb), label .. "input native turn queue completes") + T.eq(b.enemy.mon.hp, cb.enemy.mon.hp, label .. "native damage identical after queue") + T.eq(b.player.mon.hp, cb.player.mon.hp, label .. "enemy retaliation identical after queue") + T.eq(b.player.curMoves[1].pp, cb.player.curMoves[1].pp, label .. "PP consumption identical after queue") + T.eq(rolls(), crolls(), label .. "full native turn RNG identical") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("battle", "submit"), true, label .. "submit capability") + end + -- Menu opening does not bypass the party/bag controller or consume items. + for _, choice in ipairs({ "party", "item", "run" }) do + local a, g, bb = boot(version) + local c, cg, cc = boot(version) + T.eq(send(a, 1, "menu", { choice = choice }), true, label .. choice .. " accepted") + local indices = cc:menuActions() + for i, value in ipairs(indices) do + if value == choice or (choice == "party" and value == "pkmn") then + cc.menuIndex = i + end + end + press(cg, cc, "a") + -- Native cursor is selected by the player; the public API addresses the + -- action by name, so it need not move that display cursor. + local sv, cv = view(a), view(c) + sv.menuIndex = nil + cv.menuIndex = nil + T.same(sv, cv, label .. choice .. " matches native input") + T.eq(#bb.queue, #cc.queue, label .. choice .. " native queue") + end + api, game, b = boot(version) + local first = assert(api:snapshot()) + local bads = { false, {}, setmetatable({ id = 1, revision = first.revision, kind = "back" }, {}) } + for _, raw in ipairs(bads) do + reject(api, game, raw, "", label .. "malformed intent") + end + for _, value in ipairs({ 0, -1, 1.5, math.huge, -math.huge, 0 / 0, "1", true, 9007199254740992 }) do + reject( + api, + game, + { id = value, revision = first.revision, kind = "menu", choice = "fight" }, + "integer", + label .. "unsafe ID" + ) + reject( + api, + game, + { id = 1, revision = value, kind = "menu", choice = "fight" }, + "revision", + label .. "unsafe revision" + ) + end + local malformed = { + { kind = "mystery" }, + { kind = "menu", choice = "catch" }, + { kind = "menu", choice = {} }, + { kind = "move", slot = "1" }, + { kind = "move", slot = 0 }, + { kind = "move", slot = 5 }, + { kind = "target", target = -1 }, + { kind = "target", target = 4 }, + { kind = "mimic", index = 0 }, + { kind = "mimic", index = 5 }, + { kind = "safari", action = "mud" }, + { kind = "safari", action = false }, + { kind = "back", extra = true }, + { kind = "move", slot = 1, idOverride = 3 }, + } + for _, fields in ipairs(malformed) do + local raw = { id = 1, revision = first.revision } + for k, v in pairs(fields) do + raw[k] = v + end + reject(api, game, raw, "", label .. "strict intent fields") + end + local current = intent(api, 1, "menu", { choice = "fight" }) + T.eq(api:submit(current), true, label .. "rejected IDs were not consumed") + reject(api, game, current, "replayed", label .. "accepted ID cannot replay") + current.id = 0 + reject(api, game, current, "integer", label .. "ID zero is invalid") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon.hp = b.player.mon.hp - 1 + reject(api, game, current, "stale", label .. "changed HP invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.curMoves[1] = Merge.deepCopy(b.player.curMoves[1]) + reject(api, game, current, "stale", label .. "identical replacement move invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + b.player.mon = Merge.deepCopy(b.player.mon) + reject(api, game, current, "stale", label .. "identical replacement Pokemon invalidates revision") + current = intent(api, 3, "move", { slot = 1 }) + game.stack.states[2] = { battle = b } + reject(api, game, current, "stale", label .. "new covering screen invalidates revision") + reject( + api, + game, + intent(api, 3, "move", { slot = 1 }), + "covered", + label .. "fresh covered snapshot still cannot act" + ) + game.stack.states[2] = nil + for _, phase in ipairs({ "intro", "messages", "waitRemote", "finished" }) do + b.phase = phase + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "active", + label .. "wrong phase " .. phase + ) + end + b.phase = "menu" + for _, flag in ipairs({ "demo", "dudeDemo", "pokedudeDemo", "spectating", "palace", "arena" }) do + b[flag] = true + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "controller", + label .. "automatic controller " .. flag + ) + b[flag] = nil + end + for _, flag in ipairs({ "result", "dead", "waitingUI" }) do + b[flag] = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "not ready " .. flag) + b[flag] = nil + end + b.kind = "link" + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "controller", label .. "link refused") + b.kind = "wild" + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + reject( + api, + game, + intent(api, 3, "menu", { choice = "fight" }), + "locked", + label .. flag .. " automatic lock" + ) + b.player[flag] = nil + end + b.player.ally = true + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "NPC ally refused") + b.player.ally = nil + b.player.mon.hp = 0 + reject(api, game, intent(api, 3, "menu", { choice = "fight" }), "ready", label .. "fainted chooser refused") + b.player.mon.hp = 30 + b.phase = "moveSelect" + for _, guard in ipairs({ "pp", "disable", "taunt", "torment", "swap" }) do + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + local power = def.power + if guard == "pp" then + mv.pp = 0 + end + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + def.power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = mv.id + end + if guard == "swap" then + b.moveSwapIndex = 1 + end + reject(api, game, intent(api, 3, "move", { slot = 1 }), "", label .. guard .. " rejected before action") + mv.pp = 20 + b.player.disabledSlot = nil + b.player.tauntTurns = nil + b.player.tormented = nil + b.player.lastMove = nil + b.moveSwapIndex = nil + def.power = power + end + reject(api, game, intent(api, 3, "move", { slot = 4 }), "slot", label .. "missing slot refused") + b.moveSwapIndex = 1 + T.eq(send(api, 3, "back"), true, label .. "back cancels reorder") + T.eq(b.moveSwapIndex, nil, label .. "reorder marker cleared") + T.eq(b.phase, "menu", label .. "back to menu") + reject(api, game, intent(api, 4, "back"), "nothing", label .. "single menu has no back action") + game.stack.states = {} + reject(api, game, { id = 4, revision = 1, kind = "back" }, "no battle", label .. "outside battle refused") +end +local function doubles(api, game, b) + math.randomseed(54321) + b.double = true + b:placeBattler(2, BattleState.makeBattler(game.data, game.save.party[2], true, game.save)) + local foe = Pokemon.new(game.data, "FIXMON_B", 10) + b.enemyParty = b.enemyParty or { b.enemy.mon } + b.enemyParty[2] = foe + b:placeBattler(3, BattleState.makeBattler(game.data, foe, false)) + b:syncSides() +end +for _, version in ipairs(GameVersion.ORDER) do + local label = version .. " doubles: " + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + doubles(api, game, b) + doubles(control, cgame, cb) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "first FIGHT") + press(cgame, cb, "a") + T.eq(send(api, 2, "move", { slot = 1 }), true, label .. "first move enters targets") + press(cgame, cb, "a") + T.eq(b.phase, "targetSelect", label .. "native targeting phase") + reject(api, game, intent(api, 3, "target", { target = 0 }), "selector", label .. "cannot target own slot") + local offered = intent(api, 3, "target", { target = 3 }) + b.targetChoices = { unpack(b.targetChoices) } + reject(api, game, offered, "stale", label .. "selector identity changed") + T.eq(send(api, 3, "target", { target = 3 }), true, label .. "position 3 selected") + cb.targetIndex = 2 + press(cgame, cb, "a") + T.same(view(api), view(control), label .. "position maps to correct native cursor") + T.eq(b.chosenTargets[0], b:battlerAt(3), label .. "chosen actual right foe") + T.eq(b.choosingSlot, 2, label .. "second chooser") + T.eq(send(api, 4, "back"), true, label .. "undo first selection from second menu") + press(cgame, cb, "b") + T.same(view(api), view(control), label .. "back matches native B") + T.eq(b.pendingActions[0], nil, label .. "pending first action cleared") + T.eq(b.chosenTargets[0], nil, label .. "pending target cleared") + -- Native B from target returns to moves, then from moves to action menu. + T.eq(send(api, 5, "menu", { choice = "fight" }), true, label .. "fight again") + T.eq(send(api, 6, "move", { slot = 1 }), true, label .. "target again") + T.eq(send(api, 7, "back"), true, label .. "cancel target") + T.eq(b.pendingMove, nil, label .. "pending move cleared") + T.eq(b.targetChoices, nil, label .. "target selector cleared") + T.eq(b.phase, "moveSelect", label .. "back to moves") + T.eq(send(api, 8, "back"), true, label .. "back to menu") + T.eq(send(api, 9, "menu", { choice = "fight" }), true, label .. "choose first action") + T.eq(send(api, 10, "move", { slot = 1 }), true, label .. "select first move") + local stale = intent(api, 11, "target", { target = 1 }) + b.pendingMove = Merge.deepCopy(b.pendingMove) + reject(api, game, stale, "stale", label .. "pending move identity changed") + reject(api, game, intent(api, 11, "target", { target = 1 }), "pending", label .. "orphaned pending move") + b.pendingMove = b.player.curMoves[1] + b.pendingMove.pp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "PP", label .. "pending move rechecks PP") + b.pendingMove.pp = 20 + b:battlerAt(1).mon.hp = 0 + reject(api, game, intent(api, 11, "target", { target = 1 }), "field", label .. "fainted target refused") + b:battlerAt(1).mon.hp = 30 + T.eq(send(api, 11, "target", { target = 1 }), true, label .. "first target") + T.eq(send(api, 12, "menu", { choice = "fight" }), true, label .. "second FIGHT") + T.eq(send(api, 13, "move", { slot = 1 }), true, label .. "second move") + T.eq(send(api, 14, "target", { target = 0 }), true, label .. "target position zero supported") + T.eq(b.chosenTargets[2], b.player, label .. "second actor aimed at left partner") + T.eq(b.turnCount, 1, label .. "second chooser resolves one whole turn") + -- A surviving right flank still owns a usable menu. + api, game, b = boot(version) + doubles(api, game, b) + b:placeBattler(0, nil) + T.eq(api:snapshot().active, 2, label .. "missing-left chooser") + T.eq(send(api, 1, "menu", { choice = "fight" }), true, label .. "right-only FIGHT") + T.eq(b.phase, "moveSelect", label .. "right-only moves") + -- Mimic belongs to its queued user, even when that is the right flank. + b.phase = "mimicSelect" + local user = b:battlerAt(2) + local target = b.enemy + b.mimicCtx = { user = user, target = target, moveInst = user.curMoves[1] } + b.mimicMoves = { { slot = 1, id = target.curMoves[1].id } } + b.mimicIndex = 1 + T.eq(api:snapshot().active, 2, label .. "Mimic uses queued right-flank user") + local picked = intent(api, 2, "mimic", { index = 1 }) + b.mimicCtx.target = b:battlerAt(3) + reject(api, game, picked, "stale", label .. "Mimic source identity changed") + b.mimicCtx.target = target + T.eq(send(api, 2, "mimic", { index = 1 }), true, label .. "native Mimic copy") + T.eq(user.curMoves[1].id, target.curMoves[1].id, label .. "Mimic installs selected source") + T.eq(b.phase, "messages", label .. "Mimic resumes queue") + T.eq(#b.mimicRestores, 1, label .. "native restoration bookkeeping preserved") +end + +-- Safari uses its native model, including Platinum's own action implementation. +for _, version in ipairs({ "red", "firered", "platinum", "heartgold" }) do + for _, action in ipairs({ "ball", "bait", "rock", "run" }) do + local api, game, b = boot(version) + local control, cgame, cb = boot(version) + for _, battle in ipairs({ b, cb }) do + battle.safari = { balls = 5 } + battle.safariCatchRate = 80 + battle.baitFactor = 0 + battle.escapeFactor = 0 + if version == "platinum" then + battle.gen4Safari = require("src.battle.Gen4Safari").init() + end + end + T.eq(send(api, 1, "safari", { action = action }), true, version .. ": Safari " .. action) + cb.menuIndex = ({ ball = 1, bait = 2, rock = 3, run = 4 })[action] + press(cgame, cb, "a") + T.same(view(api), view(control), version .. ": Safari matches native " .. action) + T.eq(b.safari.balls, action == "ball" and 4 or 5, version .. ": ball spent only for throw") + local old = intent(api, 2, "safari", { action = "ball" }) + reject(api, game, old, "", version .. ": cannot repeat Safari action during messages") + b.phase = "menu" + b.result = nil + b.safari.balls = 0 + reject( + api, + game, + intent(api, 2, "safari", { action = "run" }), + "balls", + version .. ": depleted Safari belongs to automatic exit" + ) + b.safari.balls = 1 + reject( + api, + game, + intent(api, 2, "menu", { choice = "fight" }), + "safari", + version .. ": regular menu cannot bypass Safari" + ) + end +end + +-- BW eligibility goes through the native read-only rules, not the snapshot's +-- deliberately partial disabled flag. +for _, version in ipairs({ "black", "white" }) do + for _, guard in ipairs({ "choice", "imprison", "gravity", "heal", "sky" }) do + local api, game, b = boot(version) + b.phase = "moveSelect" + b.bw = { field = {}, sides = { {}, {} } } + local mv = b.player.curMoves[1] + local def = game.data.moves[mv.id] + if guard == "choice" then + game.data.items.CHOICE = { name = "Choice", bwPocket = 1, holdEffect = 55 } + b.player.items = game.data.items + b.player.mon.item = "CHOICE" + b.player.bwChoiceLock = "FIX_CUT" + b.player.curMoves[2] = { id = "FIX_CUT", pp = 3 } + elseif guard == "imprison" then + b.enemy.bwImprison = true + b.enemy.curMoves = { { id = mv.id, pp = 2 } } + elseif guard == "gravity" then + b.bw.field.gravity = 3 + def.gravityBanned = true + elseif guard == "heal" then + b.player.bwHealBlock = 3 + def.healMove = true + else + b.player.bwSkyDropped = b.enemy + end + reject( + api, + game, + intent(api, 1, "move", { slot = 1 }), + "", + version .. ": native BW restriction " .. guard + ) + end +end + +-- Cross-facade atomicity, consumption and recovery are deliberately tested +-- with native boundary overrides; the real-input cases above verify gameplay. +do + local api, game, b, other = boot("crystal") + reject( + api, + game, + { id = 1, revision = 1, kind = "menu", choice = "fight" }, + "snapshot", + "unobserved context" + ) + local native = b.chooseMenu + local executions = 0 + local nested, mid + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.chooseMenu = function(self, choice) + executions = executions + 1 + nested = { other:submit(otherRequest) } + mid = other:snapshot() + return native(self, choice) + end + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "outer command accepted") + T.eq(nested[1], nil, "reentrant other mod rejected") + T.check(nested[2]:find("progress", 1, true) ~= nil, "reentrant rejection reason") + T.eq(mid.prompt, "locked", "mid-dispatch snapshot is locked") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "mid-dispatch revision cannot act after return" + ) + T.eq(executions, 1, "one native dispatch") + T.eq(send(other, 1, "back"), true, "other facade has independent intent ID sequence") + b.chooseMenu = function() + executions = executions + 1 + return true + end + local held = intent(other, 2, "menu", { choice = "fight" }) + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "same-shaped accepted native command") + reject(other, game, held, "stale", "shared epoch invalidates unchanged records across mods") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.chooseMenu = function() + executions = executions + 1 + b.phase = "messages" + error("native boom") + end + local accepted, err = api:submit(raw) + T.eq(accepted, nil, "native exception reported") + T.check( + err:find("consumed", 1, true) ~= nil and err:find("native boom", 1, true) ~= nil, + "native error explains consumed ID" + ) + reject(api, game, raw, "replayed", "partial failure cannot replay") + T.eq(Intents.state(game).busy, false, "error clears shared busy flag") + b.phase = "menu" + b.chooseMenu = native + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "new request recovers after native failure") +end + +do + local api, game, b, other, loader = boot("red") + local original = b.menuBattler + local request = intent(api, 1, "menu", { choice = "fight" }) + local nested + local otherRequest = intent(other, 1, "menu", { choice = "fight" }) + b.menuBattler = function(self) + nested = { other:submit(otherRequest) } + return original(self) + end + T.eq(api:submit(request), true, "validation rejects recursion while outer intent succeeds") + T.eq(nested[1], nil, "validation reentry rejected") + T.eq(send(api, 2, "back"), true, "validation reentry leaves facade usable") + b.menuBattler = function(self) + game.stack.states[2] = {} + return original(self) + end + local ok, err = api:submit(intent(api, 3, "menu", { choice = "fight" })) + T.eq(ok, nil, "preflight context change refused") + T.check(err:find("stale", 1, true) ~= nil, "preflight context change reason") + -- The intentionally overridden validator above changed the stack itself; + -- test the refusal separately from the normal no-side-effects contract. + game.stack.states[2] = nil + b.menuBattler = original + T.eq(Intents.state(game).busy, false, "preflight failure clears busy flag") + local raw = intent(api, 3, "menu", { choice = "fight" }) + b.menuBattler = function(self) + raw.choice = "run" + raw.id = 999 + return original(self) + end + T.eq(api:submit(raw), true, "intent copied before native validation") + T.eq(b.phase, "moveSelect", "caller mutation cannot change dispatch choice") + b.menuBattler = original + local _, newGame, newBattle = boot("blue") + loader.game = newGame + reject( + api, + newGame, + { id = 3, revision = raw.revision, kind = "back" }, + "replayed", + "consumption survives Game replacement" + ) + T.eq(send(api, 4, "menu", { choice = "fight" }), true, "captured facade follows new Game") + T.eq(newBattle.phase, "moveSelect", "action reaches replacement Game") +end + +-- Accepted calls can enter normal refusal/forced-action paths too. They do +-- not reinterpret a native trainer RUN, no-PP Struggle or trapped FIGHT. +for _, mode in ipairs({ "struggle", "trapping", "ghost", "trainerRun" }) do + local api, game, b = boot("crystal", mode == "trainerRun") + local control, cgame, cb = boot("crystal", mode == "trainerRun") + for _, bb in ipairs({ b, cb }) do + if mode == "struggle" then + for _, mv in ipairs(bb.player.curMoves) do + mv.pp = 0 + end + end + if mode == "trapping" then + bb.player.trappingTurns = 2 + end + if mode == "ghost" then + bb.ghost = true + end + end + local choice = mode == "trainerRun" and "run" or "fight" + T.eq(send(api, 1, "menu", { choice = choice }), true, mode .. ": public selection accepted") + cb.menuIndex = mode == "trainerRun" and 4 or 1 + press(cgame, cb, "a") + local actual, expected = view(api), view(control) + actual.menuIndex = nil + expected.menuIndex = nil + T.same(actual, expected, mode .. ": preserves native branch") + T.eq(#b.queue, #cb.queue, mode .. ": native queue preserved") +end + +do + local api, game, b = boot("red") + local raw = intent(api, 1, "menu", { choice = "fight" }) + local rev = raw.revision + raw.extra = "typo" + reject(api, game, raw, "field", "invalid field is observational") + T.eq(api:snapshot().revision, rev, "pure invalid intent does not advance revision") + raw.extra = nil + local native = b.chooseMenu + b.chooseMenu = function() + return nil, "native declined" + end + local ok, err = api:submit(raw) + T.eq(ok, nil, "native refusal returned") + T.eq(err, "native declined", "native refusal reason retained") + reject(api, game, raw, "replayed", "native invocation consumes even when it declines") + b.chooseMenu = native + T.eq(send(api, 2, "menu", { choice = "fight" }), true, "native decline clears busy lock") + local _, otherGame = boot("blue") + game.stack.states[1] = otherGame.stack.states[1] + reject(api, game, intent(api, 3, "back"), "Game", "foreign Game battle cannot be controlled") +end +do + local api, game, b = boot("red") + local owner = Runtime.currentMod + Runtime.currentMod = "probe" + b.chooseMenu = function() + T.eq(Runtime.currentMod, nil, "native dispatch uses engine attribution") + error("scope failure") + end + local ok = send(api, 1, "menu", { choice = "fight" }) + T.eq(ok, nil, "scoped native error returned") + T.eq(Runtime.currentMod, "probe", "native error restores caller attribution") + Runtime.currentMod = owner +end +do + local api, game, b, other, loader = boot("emerald") + local callbackCount, nested, mid = 0, nil, nil + local held = intent(other, 1, "menu", { choice = "fight" }) + loader.apis.probe.events:on("battle.turn_started", function() + callbackCount = callbackCount + 1 + T.eq(Runtime.currentMod, nil, "real event uses native engine scope") + mid = other:snapshot() + nested = { other:submit(held) } + end) + T.eq(send(api, 1, "menu", { choice = "fight" }), true, "real event test selects FIGHT") + T.eq(send(api, 2, "move", { slot = 1 }), true, "real event test plans native turn") + T.eq(callbackCount, 1, "one native turn-start callback") + T.eq(nested[1], nil, "real event cannot recursively submit") + T.eq(mid.prompt, "locked", "real event sees locked in-flight snapshot") + reject( + other, + game, + { id = 1, revision = mid.revision, kind = "back" }, + "stale", + "real event snapshot expires after dispatch" + ) + T.eq(Runtime.currentMod, nil, "real native event restores engine/caller scope") +end +local api = boot("red", false, 1) +T.eq(send(api, 1, "menu", { choice = "fight" }), true, "API 1 mod can use additive public actions") +GameVersion.set(oldVersion) +T.finish("battle actions") diff --git a/tests/engine/battle_snapshots.lua b/tests/engine/battle_snapshots.lua new file mode 100644 index 000000000..acf55273d --- /dev/null +++ b/tests/engine/battle_snapshots.lua @@ -0,0 +1,613 @@ +-- ROM-free contract tests through the real mod loader and BattleState. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local BattleState = require("src.battle.BattleState") +local GameVersion = require("src.core.GameVersion") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local oldVersion, oldLove = GameVersion.get(), love +love = nil + +-- Exercise the real loader resolver, including Lua 5.1's in-progress marker. +local cachedGame = package.loaded["src.core.Game"] +package.loaded["src.core.Game"] = nil +T.eq(Loader._game({}, true), nil, "loaded-only lookup does not bootstrap Game") +T.eq(package.loaded["src.core.Game"], nil, "lookup leaves module cache untouched") +package.loaded["src.core.Game"] = true +T.eq(Loader._game({}, true), nil, "in-progress Game is unavailable") +local injected = {} +T.eq(Loader._game({ game = injected }, true), injected, "explicit Game takes precedence") +package.loaded["src.core.Game"] = injected +T.eq(Loader._game({}, true), injected, "live cached Game is available") +package.loaded["src.core.Game"] = cachedGame + +local function boot(version, level) + GameVersion.set(version) + local ds = GameVersion.generation() >= 4 + local s, m, item = ds and 25 or "PIKACHU", ds and 33 or "TACKLE", ds and 4 or "POKE_BALL" + local species, move = + { name = "Pikachu", types = { "ELECTRIC" } }, + { name = "Tackle", pp = 35, power = 40, accuracy = 95, type = "NORMAL" } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { name = "Poké Ball", ball = "POKE_BALL" }, + Z_EXTRA = { name = "Extra" }, + A_EXTRA = { name = "Earlier" }, + }, + } + if ds then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local function mon(nick) + return { + species = s, + nickname = nick, + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, special = 12, speed = 13 }, + moves = { { id = m, pp = 29, ppUps = 1 } }, + isEgg = false, + extension = { flag = false }, + } + end + local a, b, e, f = mon("Left"), mon("Right"), mon("Foe"), mon("Other foe") + if GameVersion.generation() > 1 then + for _, raw in ipairs({ a, b, e, f }) do + raw.stats.spatk, raw.stats.spdef = 14, 15 + end + end + local party = { a, b } + local function battler(raw) + return { + mon = raw, + name = raw.nickname, + species = raw.species, + curStats = raw.stats, + curTypes = { "ELECTRIC" }, + curMoves = raw.moves, + stages = {}, + } + end + local game = { + data = data, + save = { + party = party, + inventory = { + [item] = 3, + Z_EXTRA = 2, + A_EXTRA = 1, + ZERO = 0, + }, + bagOrder = { item, item, "ZERO" }, + }, + stack = { states = {} }, + } + local btl = setmetatable({ + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(a), + enemy = battler(e), + enemyParty = { e, f }, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + }, BattleState) + btl:placeBattler(0, btl.player) + btl:placeBattler(1, btl.enemy) + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = level or 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = [[return function(mod) + assert(type(mod.battle.snapshot)=="function") + mod.exports.api=mod + mod.content.moves:register("MOVE_9000", {id="MOVE_9000", name="Late move", pp=10, power=10, + accuracy=100, type="NORMAL", effect="NO_ADDITIONAL_EFFECT"}) + end]], + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + return (p == "mods" or p == "mods/probe") and { type = "directory" } or files[p] and { type = "file" } + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, loader, game, btl, battler, { s, m, item }, function() + return resolves + end +end + +-- Freeze the whole graph as table identities + raw entries, including cycles. +local function freeze(root) + local graph = {} + local function visit(value) + if type(value) ~= "table" or graph[value] then + return + end + local entries = {} + graph[value] = entries + for key, child in pairs(value) do + entries[key] = child + visit(child) + end + end + visit(root) + return function() + for tbl, entries in pairs(graph) do + for key, value in pairs(entries) do + if tbl[key] ~= value then + return false + end + end + for key in pairs(tbl) do + if entries[key] == nil then + return false + end + end + end + return true + end +end + +local function forbidden() + error("snapshot crossed into gameplay") +end +local oldRandom = math.random +math.random = forbidden +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, b, battler, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry can capture facade before Game exists") + if api.supports then + T.eq(api:supports("battle", "snapshot"), true, label .. "snapshot capability") + T.eq(api:supports("battle", "submit"), true, label .. "additive input submission") + T.eq(resolves(), 0, label .. "probe does not resolve Game") + end + T.eq(api.battle, api.battle, label .. "stable facade") + T.eq(type(api.battle.submit), "function", label .. "additive submit method") + local no, reason = api.battle:snapshot() + T.eq(no, nil, label .. "no battle") + T.eq(reason, "no battle", label .. "no battle reason") + game.stack.states = { { battle = b, phase = "menu", player = b.player, enemy = b.enemy } } + T.eq(api.battle:snapshot(), nil, label .. "a UI holding battle is not battle") + game.stack.states = { b } + b.rng = forbidden + b.menuLockedAction, b.fightLockedAction, b.menuBattler, b.playerHasPP = + forbidden, forbidden, forbidden, forbidden + b.catchAttempt, b.ballDef, b.moveDef, b.choosingSlotNow = forbidden, forbidden, forbidden, forbidden + b.volatile, b.enemyAction, b.update, b.draw = forbidden, forbidden, forbidden, forbidden + local unchanged = freeze(game) + local snap = api.battle:snapshot() + T.check(unchanged(), label .. "complete game graph unchanged") + T.eq(snap.revision, 1, label .. "first revision") + T.eq(snap.prompt, "menu", label .. "menu phase") + T.eq(snap.double, false, label .. "single battle") + T.eq(snap.active, 0, label .. "left actor") + T.eq(snap.kind, "wild", label .. "wild kind") + T.eq(snap.turn, 0, label .. "before first turn") + T.eq(snap.catchable, true, label .. "single wild target") + T.eq(#snap.battlers, 2, label .. "both occupied slots") + T.eq(snap.player.species, "PIKACHU", label .. "portable species") + T.eq(snap.player.name, "Left", label .. "battler display name") + T.eq(snap.player.hp, 19, label .. "logical HP") + T.eq(snap.player.maxHp, 23, label .. "max HP") + T.eq(snap.player.status, "poison", label .. "portable status") + T.eq(snap.player.stats.specialAttack, GameVersion.generation() == 1 and 12 or 14, label .. "special attack") + T.eq( + snap.player.stats.specialDefense, + GameVersion.generation() == 1 and 12 or 15, + label .. "special defense" + ) + T.eq(snap.player.partyIndex, 1, label .. "party slot by identity") + T.eq(snap.party[1].active, true, label .. "lead active") + T.eq(snap.party[2].active, false, label .. "bench inactive") + T.eq(snap.party[2].slot, 2, label .. "party slot one based") + T.eq(snap.moves[1].id, "TACKLE", label .. "portable move") + T.eq(snap.moves[1].maxPp, 42, label .. "PP Up maximum") + T.eq(snap.moves[1].pp, 29, label .. "current PP") + T.eq(snap.moves[1].disabled, false, label .. "unrestricted move") + T.eq(snap.moves[1].accuracy, 95, label .. "base accuracy") + T.eq(#snap.items, 3, label .. "positive inventory deduplicated") + T.eq(snap.items[1].id, "POKE_BALL", label .. "portable ball and bag order") + T.eq(snap.items[1].count, 3, label .. "inventory quantity") + T.eq(snap.items[1].ball, true, label .. "ball classification") + T.eq(snap.items[2].id, "A_EXTRA", label .. "unlisted inventory sorted") + T.eq(snap.items[3].id, "Z_EXTRA", label .. "stable inventory order") + T.eq(snap.items[1].catchChance, nil, label .. "no guessed catch preview") + T.eq(snap.items[1].needsTarget, nil, label .. "no guessed item eligibility") + T.same(api.battle:snapshot(), snap, label .. "unchanged reads stable") + snap.player.hp = 0 + snap.player.native.hp = 0 + snap.player.native.extension.flag = true + snap.moves[1].pp = 0 + snap.items[1].count = 0 + snap.party[1].stats.hp = 0 + snap.player.types[1] = "WATER" + snap.player.stages.attack = 6 + local clean = api.battle:snapshot() + T.eq(clean.revision, 1, label .. "caller writes do not alter revision") + T.eq(clean.player.hp, 19, label .. "caller writes isolated") + T.eq(clean.player.native.extension.flag, false, label .. "false preserved in diagnostics") + T.eq(clean.moves[1].pp, 29, label .. "move tables isolated") + T.eq(clean.items[1].count, 3, label .. "item tables isolated") + T.eq(clean.party[1].stats.hp, 23, label .. "party stats isolated") + T.eq(clean.player.types[1], "ELECTRIC", label .. "types isolated") + T.eq(clean.player.stages.attack, nil, label .. "stages isolated") + + b.frame, b.player.shownHP, b.player.picAnim = 99, 12, { tick = 5 } + T.eq(api.battle:snapshot().revision, 1, label .. "animation ticks excluded") + b.player.mon.hp = 18 + local changed = api.battle:snapshot() + T.eq(changed.revision, 2, label .. "HP changes revision") + T.eq(changed.player.hp, 18, label .. "new logical HP visible") + T.eq(clean.player.hp, 19, label .. "old result remains detached") + b.turnCount = 1 + T.eq(api.battle:snapshot().turn, 1, label .. "turn number updates") + local rev = api.battle:snapshot().revision + game.stack.states[#game.stack.states + 1] = { battle = b } + T.eq(api.battle:snapshot().prompt, "locked", label .. "covered battle locked") + T.check(api.battle:snapshot().revision > rev, label .. "coverage changes revision") + table.remove(game.stack.states) + local cases = { + { "intro", "locked" }, + { "messages", "locked" }, + { "moveSelect", "moves" }, + { "targetSelect", "target" }, + { "mimicSelect", "mimic" }, + { "finished", "locked" }, + } + for _, c in ipairs(cases) do + b.phase = c[1] + T.eq(api.battle:snapshot().prompt, c[2], label .. "phase " .. c[1]) + end + b.phase = "messages" + b.current = { text = "Current text" } + b.msgPrompt = true + b.msgPromptWait = 3 + T.eq(api.battle:snapshot().prompt, "locked", label .. "pre-advance delay") + b.msgPromptWait = 0 + T.eq(api.battle:snapshot().prompt, "advance", label .. "manual message advance") + T.eq(api.battle:snapshot().messageSource, "Current text", label .. "current message source") + T.eq(api.battle:snapshot().message, nil, label .. "no invented visible message") + game.data.font = + { charmap = { { code = 128, seq = "A" }, { code = 129, seq = "B" }, { code = 130, seq = "C" } } } + b.shown = { { 128, 129 }, { 130 } } + local text = api.battle:snapshot() + T.same(text.message, { "AB", "C" }, label .. "only revealed glyphs in visible message") + b.shown[2][2] = 128 + T.check(api.battle:snapshot().revision > text.revision, label .. "revealed glyph changes revision") + T.same(text.message, { "AB", "C" }, label .. "older visible message detached") + b.shown[1][1] = 999999 + T.eq(api.battle:snapshot().message, nil, label .. "unknown glyph is not guessed") + game.data.font.pages = { extra = { charmap = { { code = 999999, seq = "Ω" } } } } + T.same(api.battle:snapshot().message, { "ΩB", "CA" }, label .. "font page charmap read") + local unicode = package.loaded["src.translation.UnicodeFont"] + package.loaded["src.translation.UnicodeFont"] = { chars = { [-1] = "漢" } } + b.shown = { { -1 } } + T.same(api.battle:snapshot().message, { "漢" }, label .. "already loaded Unicode mapping read") + package.loaded["src.translation.UnicodeFont"] = unicode + game.data.font = { source = "Gen2 scaffold" } + b.shown = { { 65, 66 } } + T.same(api.battle:snapshot().message, { "AB" }, label .. "scaffold ASCII glyphs read") + game.stack.states[2] = { battle = b } + T.eq(api.battle:snapshot().message, nil, label .. "covered screen has no guessed visible message") + game.stack.states[2] = nil + b.shown = nil + b.current.choice = function() end + T.eq(api.battle:snapshot().prompt, "locked", label .. "choice is its own controller") + b.current = nil + b.msgPrompt = nil + b.phase = "menu" + for _, flag in ipairs({ "waitingUI", "demo", "pokedudeDemo", "palace", "arena" }) do + b[flag] = true + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b[flag] = nil + end + for _, flag in ipairs({ "mustRecharge", "charging", "rageMove", "thrashTurns" }) do + b.player[flag] = 1 + T.eq(api.battle:snapshot().prompt, "locked", label .. flag .. " locked") + b.player[flag] = nil + end + b.bw = true + b.player.bwSkyDropped = true + T.eq(api.battle:snapshot().prompt, "locked", label .. "Sky Drop lock") + b.bw = nil + b.player.bwSkyDropped = nil + b.safari = { balls = 3 } + T.eq(api.battle:snapshot().kind, "safari", label .. "Safari battle kind") + T.eq(api.battle:snapshot().safariBalls, 3, label .. "Safari ball count") + T.eq(api.battle:snapshot().prompt, "safari", label .. "Safari prompt") + b.safari.balls = 0 + T.eq(api.battle:snapshot().prompt, "locked", label .. "no Safari balls") + b.safari = nil + b.demo = true + T.eq(api.battle:snapshot().kind, "oldman", label .. "automatic capture demo kind") + b.demo = nil + b.ghost = true + T.eq(api.battle:snapshot().kind, "ghost", label .. "ghost kind") + b.ghost = nil + b.menuIndex = 2 + b.moveIndex = 3 + b.moveSwapIndex = 1 + local cursors = api.battle:snapshot() + T.eq(cursors.menuIndex, 2, label .. "menu cursor") + T.eq(cursors.moveIndex, 3, label .. "move cursor") + T.eq(cursors.moveSwapIndex, 1, label .. "reorder cursor") + b.menuIndex = 1 + T.check(api.battle:snapshot().revision > cursors.revision, label .. "cursor changes revision") + for _, flag in ipairs({ "ghost", "noCatch", "result" }) do + b[flag] = true + T.eq(api.battle:snapshot().catchable, false, label .. flag .. " uncatchable") + b[flag] = nil + end + b.kind = "trainer" + T.eq(api.battle:snapshot().catchable, false, label .. "trainer uncatchable") + b.kind = "wild" + b.phase = "moveSelect" + for _, guard in ipairs({ "disable", "pp", "taunt", "torment" }) do + if guard == "disable" then + b.player.disabledSlot = 1 + end + if guard == "pp" then + b.player.curMoves[1].pp = 0 + end + if guard == "taunt" then + b.player.tauntTurns = 2 + game.data.moves[ids[2]].power = 0 + end + if guard == "torment" then + b.player.tormented = true + b.player.lastMove = ids[2] + end + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. guard .. " menu restriction") + b.player.disabledSlot = nil + b.player.curMoves[1].pp = 29 + b.player.tauntTurns = nil + game.data.moves[ids[2]].power = 40 + b.player.tormented = nil + b.player.lastMove = nil + end + -- Native alias keys may be split during merging; a name patch must not + -- rename a portable ID. Later registrations still appear in fresh reads. + game.data.pokemon[ids[1]].name = "Renamed presentation" + T.eq(api.battle:snapshot().player.species, "PIKACHU", label .. "name patches preserve identity") + local originalMoves = b.player.curMoves + b.player.curMoves = { { id = "MOVE_9000", pp = 8 } } + T.eq(api.battle:snapshot().moves[1].id, "LATE_MOVE", label .. "late mod ID resolved") + b.player.curMoves = originalMoves + b.bw = { field = {}, sides = { {}, {} } } + T.eq(api.battle:snapshot().moves[1].disabled, nil, label .. "BW eligibility remains unknown") + b.player.disabledSlot = 1 + T.eq(api.battle:snapshot().moves[1].disabled, true, label .. "BW known common restriction") + b.player.disabledSlot = nil + b.bw = nil + b.player.curMoves[1].ppUps = 99 + T.eq(api.battle:snapshot().moves[1].maxPp, 56, label .. "PP Ups clamped as native engine") + b.player.curMoves[1].ppUps = -1 + T.eq(api.battle:snapshot().moves[1].maxPp, 35, label .. "negative PP Ups clamped") + b.player.curMoves[1].ppUps = 1 + -- Transform changes the in-battle view while the stored party keeps its moves/stats. + game.data.moves.COPIED = { name = "Copied", pp = 10, power = 50, type = "WATER" } + b.player.curMoves = { { id = "COPIED", pp = 5 } } + b.player.curStats = { hp = 23, attack = 90, special = 80 } + b.player.species = "COPIED_SPECIES" + b.player.curTypes = { "WATER" } + local transformed = api.battle:snapshot() + T.eq(transformed.moves[1].id, "COPIED", label .. "current transformed move") + T.eq(transformed.party[1].moves[1].id, "TACKLE", label .. "stored party move") + T.eq(transformed.player.battleStats.attack, 90, label .. "current transformed stats") + T.eq(transformed.player.stats.attack, 10, label .. "base stats preserved") + T.eq(transformed.player.displaySpecies, "COPIED_SPECIES", label .. "display species separate") + T.eq(transformed.player.species, "PIKACHU", label .. "original species preserved") + -- All four native positional slots, including a missing left flank. + b.double = true + b:placeBattler(2, battler(game.save.party[2])) + b:placeBattler(3, battler(b.enemyParty[2])) + b.choosingSlot = 2 + b.phase = "menu" + local double = api.battle:snapshot() + T.eq(double.double, true, label .. "doubles flag") + T.eq(#double.battlers, 4, label .. "four occupied battlers") + T.eq(double.active, 2, label .. "second chooser") + T.eq(double.player.name, "Right", label .. "player follows chooser") + T.eq(double.player.partyIndex, 2, label .. "second chooser party index") + T.eq(double.catchable, false, label .. "cannot aim at two wild foes") + T.eq(double.party[2].active, true, label .. "both player slots active") + b.phase = "targetSelect" + b.targetChoices = { b.enemy, b.sides[2].battlers[2], b.player } + b.targetIndex = 2 + T.same(api.battle:snapshot().targets, { 1, 3, 0 }, label .. "target IDs, not array positions") + T.eq(api.battle:snapshot().targetIndex, 2, label .. "target cursor") + b:placeBattler(0, nil) + b:placeBattler(1, nil) + b.choosingSlot = nil + b.phase = "menu" + local missing = api.battle:snapshot() + T.eq(missing.active, 2, label .. "right flank after left faint") + T.eq(missing.enemy, nil, label .. "missing enemy left not silently aliased") + T.eq(missing.catchable, true, label .. "one wild foe remaining") + T.eq(#missing.battlers, 2, label .. "missing slots omitted") + T.eq(missing.battlers[1].id, 2, label .. "battler identity survives sparse slots") + b.phase = "mimicSelect" + b.mimicMoves = { { id = ids[2], slot = 3 } } + b.mimicIndex = 1 + local mimic = api.battle:snapshot() + T.eq(mimic.mimicMoves[1].id, "TACKLE", label .. "portable mimic ID") + T.eq(mimic.mimicMoves[1].slot, 3, label .. "original mimic move slot") + b.phase = "menu" + local ally = b.sides[1].battlers[2] + ally.ally = true + b.ally = { party = { ally.mon } } + local withAlly = api.battle:snapshot() + T.eq(withAlly.party[2].active, false, label .. "NPC ally not player active party") + T.eq(withAlly.battlers[1].ally, true, label .. "NPC ally marked") + T.eq(withAlly.prompt, "locked", label .. "NPC controlled actor menu locked") + ally.ally = nil + b.ally = nil + b.kind = "link" + b.playerParty = { game.save.party[2] } + local link = api.battle:snapshot() + T.eq(#link.party, 1, label .. "link uses clamped battle party") + T.eq(link.party[1].nickname, "Right", label .. "link party is actual participant") + T.eq(link.prompt, "locked", label .. "link controller owns input") + b.kind = "wild" + b.playerParty = nil + -- Snapshot diagnostics discard executable values and remain cycle-safe. + local raw = game.save.party[2] + raw.extension.self = raw.extension + raw.extension.fn = forbidden + raw.extension[{}] = "hidden" + setmetatable(raw.extension, { __index = { secret = true } }) + local cyc = api.battle:snapshot() + T.eq(cyc.party[2].native.extension.self, cyc.party[2].native.extension, label .. "cycles copied") + T.eq(cyc.party[2].native.extension.fn, nil, label .. "function dropped") + T.eq(getmetatable(cyc.party[2].native.extension), nil, label .. "metatable dropped") + T.eq(cyc.party[2].native.extension.secret, nil, label .. "no inherited value") + T.eq(api.battle:snapshot().revision, cyc.revision, label .. "cyclic record stable revision") + local last = cyc.revision + game.stack.states = {} + T.eq(api.battle:snapshot(), nil, label .. "battle exit") + game.stack.states = { b } + T.check(api.battle:snapshot().revision > last, label .. "re-entry invalidates revision") + last = api.battle:snapshot().revision + local replacement = setmetatable(Merge.deepCopy(b), BattleState) + game.stack.states = { replacement } + T.check(api.battle:snapshot().revision > last, label .. "identical new battle invalidates revision") + -- A previously captured facade follows the loader's current Game. + local other = { data = game.data, save = game.save, stack = game.stack } + loader._game = function() + return other + end + last = api.battle:snapshot().revision + loader._game = function() + return game + end + T.check(api.battle:snapshot().revision > last, label .. "Game replacement invalidates revision") +end +math.random = oldRandom + +-- Exercise constructors and a native damage action with the committed fixture +-- dataset, rather than asserting only hand-built record shapes. +love = require("tests.love_stub") +local Fixtures = require("tests.modkit.fixtures") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local function native(version, trainer) + local api, loader = boot(version) + love = require("tests.love_stub") + math.randomseed(12345) + local data = Fixtures.fresh() + Font.load(data) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + local game = { + data = data, + save = save, + input = { + wasPressed = function() + return false + end, + }, + stack = { + states = {}, + push = function() end, + pop = function() end, + top = function(self) + return self.states[#self.states] + end, + }, + } + local b = trainer and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1) + or BattleState.newWild(game, "FIXMON_C", 10) + game.stack.states = { b } + loader._game = function() + return game + end + local calls = 0 + b.rng = function(_, high) + calls = calls + 1 + return high + end + return api, game, b, function() + return calls + end +end +for _, version in ipairs(GameVersion.ORDER) do + for _, trainer in ipairs({ false, true }) do + local api, game, b, calls = native(version, trainer) + local unchanged = freeze(game) + local rngBefore = calls() + local oldLoveRandom, oldLuaRandom = love.math.random, math.random + love.math.random, math.random = forbidden, forbidden + local Runtime = require("src.mods.Runtime") + local oldEmit, oldCall = Runtime.emit, Runtime.call + Runtime.emit, Runtime.call = forbidden, forbidden + local loaded = {} + for name in pairs(package.loaded) do + loaded[name] = true + end + local s = api.battle:snapshot() + for _ = 1, 10 do + T.eq(api.battle:snapshot().revision, s.revision, version .. ": repeated native snapshot stable") + end + local noNewModules = true + for name in pairs(package.loaded) do + if not loaded[name] then + noNewModules = false + end + end + T.check(noNewModules, version .. ": snapshot loads no modules") + Runtime.emit, Runtime.call = oldEmit, oldCall + T.check(unchanged(), version .. ": native constructor graph unchanged") + T.eq(calls(), rngBefore, version .. ": native RNG unchanged") + T.eq(s.kind, trainer and "trainer" or "wild", version .. ": native constructor kind") + T.eq(s.player.species, "FIXMON_A", version .. ": real Pokemon record") + T.eq(s.player.maxHp, b.player.mon.stats.hp, version .. ": constructor HP") + T.eq(s.moves[1].id, b.player.curMoves[1].id, version .. ": constructor moves") + T.eq(s.catchable, not trainer, version .. ": constructor catchability") + love.math.random, math.random = oldLoveRandom, oldLuaRandom + -- The same native action on an independently constructed control battle + -- must produce the same HP, PP, queue and RNG consumption. + local _, _, control, controlCalls = native(version, trainer) + b:performMove(b.player, b.enemy, b.player.curMoves[1]) + control:performMove(control.player, control.enemy, control.player.curMoves[1]) + T.eq(b.enemy.mon.hp, control.enemy.mon.hp, version .. ": action damage unaffected") + T.eq(b.player.curMoves[1].pp, control.player.curMoves[1].pp, version .. ": action PP unaffected") + T.eq(calls(), controlCalls(), version .. ": action RNG consumption unaffected") + T.eq(#b.queue, #control.queue, version .. ": action queue unaffected") + local later = api.battle:snapshot() + T.eq(later.enemy.hp, b.enemy.mon.hp, version .. ": snapshot follows native damage") + T.check(later.revision > s.revision, version .. ": native damage changes revision") + end +end +local api = boot("crystal", 1) +T.eq(type(api.battle.snapshot), "function", "API 1 mods also get additive reader") +GameVersion.set(oldVersion) +love = oldLove +T.finish("battle snapshots") diff --git a/tests/engine/dataset_views.lua b/tests/engine/dataset_views.lua new file mode 100644 index 000000000..683746803 --- /dev/null +++ b/tests/engine/dataset_views.lua @@ -0,0 +1,194 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local Contract = require("tests.api3_contract.datasets") +local Views = require("src.mods.DatasetViews") +local Writer = require("src.import.LuaWriter") +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "gold") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " public dataset contract: " .. tostring(why)) +end +local function setMon(f, edit) + local data = require("tests.api3_contract.run").dataFor("gen2", Version.generation()) + edit(data.pokemon.MEW, data.pokemon) + f.files[f.prefix .. "data/generated/pokemon.lua"] = Writer.encode(data.pokemon) +end +for _, payload in ipairs({ + "return (function() _G.datasetExecuted=true; return {} end)()", + "while true do end", + "return {a=0/0}", + "return {a=1e999}", + "return {a=function()end}", + "return {a=setmetatable({},{})}", + "return {a={ [nil]=4}}", + "return {a=1,a=2}", + 'return {MEW={name="broken",baseStats=false}}', +}) do + local f = fixture() + local view = assert(f.exports.open("gold")) + f.files[f.prefix .. "data/generated/pokemon.lua"] = payload + _G.datasetExecuted = nil + T.eq(view.content.pokemon:get("MEW"), nil, "bad generated data refused") + T.eq(_G.datasetExecuted, nil, "generated bytes never execute") + T.eq(view.content.pokemon:has("MEW"), false, "bad view remains unavailable") + T.eq(view.content.pokemon:each()(), nil, "bad view has empty iterator") + setMon(f, function() end) + T.eq( + assert(f.exports.open("gold")).content.pokemon:get("MEW").name, + "MEW", + "new view recovers repaired cache" + ) + T.eq(view.content.pokemon:get("MEW"), nil, "rejected view remains retired") +end +for _, failure in ipairs({ "missing", "stale", "missing_module", "huge_module", "aggregate" }) do + local f = fixture() + if failure == "missing" then + f.files[f.prefix .. "rom-cache.complete"] = nil + elseif failure == "stale" then + f.files[f.prefix .. "rom-cache.complete"] = "wrong-format-hash" + elseif failure == "missing_module" then + f.files[f.prefix .. "data/generated/moves.lua"] = nil + else + local original = f.fs.getInfo + f.fs.getInfo = function(path, ...) + local info = original(path, ...) + if info and path:match("data/generated/.+%.lua$") then + info.size = failure == "huge_module" and 8 * 1024 * 1024 + 1 or 8 * 1024 * 1024 + end + return info + end + end + local view, why = f.exports.open("gold") + T.eq(view, nil, "unavailable cache: " .. failure) + T.eq( + why, + (failure == "huge_module" or failure == "aggregate") and "invalid_cache" or "not_imported", + "stable reason" + ) +end +do + local f = fixture() + local rawRead = f.fs.read + local reads = {} + f.fs.read = function(path, ...) + reads[path] = (reads[path] or 0) + 1 + return rawRead(path, ...) + end + f.fs.load = function() + error("dataset must not execute fs.load") + end + local view = assert(Views.new(f.fs):open("gold")) + local path = f.prefix .. "data/generated/pokemon.lua" + T.eq(reads[path], nil, "open leaves semantic data lazy") + T.eq(view.content.pokemon:get("MEW").name, "MEW", "first bounded native read") + local original = reads[path] + T.eq(view.content.pokemon:get("MEW").name, "MEW", "cached data remains available") + T.check(reads[path] > original, "cached bytes are checked for changes") + setMon(f, function(mon, mons) + local copy = require("src.mods.Merge").deepCopy(mon) + copy.id = "TESTMON" + copy.name = "TESTMON" + copy.dex = 152 + mons.TESTMON = copy + end) + local iter = view.content.pokemon:each() + T.eq(iter(), "MEW", "canonical iteration is sorted") + setMon(f, function(mon, mons) + mon.name = "NEW_NAME" + local copy = require("src.mods.Merge").deepCopy(mon) + copy.id = "TESTMON" + copy.name = "NEW_TESTMON" + copy.dex = 152 + mons.TESTMON = copy + end) + local id, row = iter() + T.eq(id, "TESTMON", "iterator retains its validated snapshot") + T.eq(row.name, "TESTMON", "snapshot never mixes edited bytes") + local stale = view.content.pokemon:each() + T.eq(view.content.pokemon:get("MEW").name, "NEW_NAME", "later read refreshes edited cache") + -- Create an iterator, then force another revision through a public read. + stale = view.content.pokemon:each() + setMon(f, function(mon) + mon.name = "LATEST" + end) + T.eq(view.content.pokemon:get("MEW").name, "LATEST", "second revision refreshed") + T.eq(stale(), nil, "iterator retires after observed revision change") + f.files[f.prefix .. "rom-cache.complete"] = nil + T.eq(view.content.pokemon:get("MEW"), nil, "removed marker retires existing view") +end +do + local f = fixture("red") + local view = assert(f.exports.open("red")) + T.eq( + view.assets:path("assets/generated/probe.png"), + "assets/generated/probe.png", + "host Red keeps native root path" + ) + for _, path in ipairs({ + "../secrets", + "assets/generated/../file", + "assets/generated/a\\b", + "assets/generated/a:b", + "assets/generated/a\0b", + }) do + T.check(not pcall(view.assets.path, view.assets, path), "unsafe asset path refused") + end + T.eq(view.content.pokemon:get("MEW").name, "MEW", "invalid input does not poison valid cache") + T.eq(view.content.commands:get("show_text"), nil, "function-bearing engine records not exposed") + T.eq(view.content.pokemon:get(151), nil, "only public canonical IDs accepted") + local result, why = f.exports.open("../gold") + T.eq(result, nil, "unknown version refused") + T.eq(why, "unknown_version", "unknown version reason") + result, why = f.exports.open("platinum") + T.eq(why, "unsupported_generation", "later native datasets honestly unavailable") +end +do + local f = fixture() + f.files[f.prefix .. "rom-cache.complete"] = nil + f.fs.getSource = function() + return "developer-source" + end + f.fs.getRealDirectory = function() + return "developer-source" + end + T.eq( + assert(f.exports.open("gold")).content.pokemon:get("MEW").name, + "MEW", + "complete verified developer source can open without marker" + ) + f.fs.getRealDirectory = function(path) + return path:match("pokemon.lua$") and "another-cache" or "developer-source" + end + T.eq(f.exports.open("gold"), nil, "mixed source/cache provenance refused without marker") +end +do + local f = fixture() + local oldRequire = require + local oldWarn = require("src.core.Logger").warn + local warnings = {} + require("src.core.Logger").warn = function(message, ...) + warnings[#warnings + 1] = tostring(message) + end + f.loader.dev = true + f.loader:_installDevShim() + local result = f.exports.inspect("gold") + T.check(result ~= nil, "lazy public dataset works under permission tripwire") + T.eq(#warnings, 0, "native facade work does not become a mod permission warning") + _G.require = oldRequire + require("src.core.Logger").warn = oldWarn +end +do + local f = fixture() + f.files[f.prefix .. "data/generated/constants.lua"] = Writer.encode({ generation = 1 }) + local view = assert(f.exports.open("gold")) + T.eq(view.content.pokemon:get("MEW"), nil, "contradictory native generation metadata rejected") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tests/engine/gate_fingerprint.lua b/tests/engine/gate_fingerprint.lua index 731996016..85fb65a72 100644 --- a/tests/engine/gate_fingerprint.lua +++ b/tests/engine/gate_fingerprint.lua @@ -9,8 +9,8 @@ -- itself, and would silently make two builds of the same engine refuse to -- link. Here it flips one hex string and fails. -- --- Regenerate deliberately with scripts/test.sh --bless after recording the --- intended parity change; never automatically. +-- Regenerate deliberately after auditing each changed surface section; see +-- tests/goldens/fixture_fingerprint.md. Never update expected values automatically. package.path = "./?.lua;./?/init.lua;" .. package.path @@ -37,6 +37,40 @@ local golden = readGolden(GOLDEN) T.check(golden ~= nil, "the committed fixture fingerprint golden exists: " .. GOLDEN) T.eq(actual, golden, "fixture fingerprint matches the committed golden") +-- Section pins make a registry addition reviewable without treating a whole +-- digest change as permission to bless unrelated species or move changes. +local sectionFile = assert(io.open("tests/goldens/fixture_fingerprint_sections.txt", "r")) +local expectedSections = {} +for line in sectionFile:lines() do + local name, expected = line:match("^(%S+)%s+(%x+)$") + if name then expectedSections[name] = expected end +end +sectionFile:close() +local sectionCount = 0 +for name, body in Fingerprint.surface(data, {}):gmatch("%[([^%]]+)%]([^%[]*)") do + sectionCount = sectionCount + 1 + T.eq(Fingerprint.digest("[" .. name .. "]" .. body), expectedSections[name], + "audited fixture surface section: " .. name) + expectedSections[name] = nil +end +T.eq(sectionCount, 7, "the fixture has seven populated fingerprint sections") +T.eq(next(expectedSections), nil, "all committed section pins were checked") + +-- Platinum's four dynamic-power effects were added in 3252aec. They are +-- native full handlers, and removing any one must change link compatibility. +local sinnoh = { + "POWER_BASED_ON_LOW_SPEED", "INCREASE_POWER_WITH_MORE_HP", + "DOUBLE_POWER_WHEN_BELOW_HALF", "DOUBLE_POWER_HEAL_SLEEP", +} +for _, id in ipairs(sinnoh) do + T.eq(data.move_effects[id].kind, "full", "Sinnoh effect is registered: " .. id) + local copy = T.fixtures.fresh() + local copyRun = T.sdk.loadNone({ data = copy }) + copy.move_effects[id] = nil + T.neq(Fingerprint.compute(copy, {}), actual, "omitting " .. id .. " changes compatibility") + copyRun.release() +end + -- determinism: same data, same digest. A fingerprint that folded a table -- address or a pairs() order would pass once and fail in CI. T.eq(Fingerprint.compute(data, {}), actual, "the fingerprint is stable within a process") diff --git a/tests/engine/gate_meta_coverage.lua b/tests/engine/gate_meta_coverage.lua index 2c29d47cc..d3bbee83b 100644 --- a/tests/engine/gate_meta_coverage.lua +++ b/tests/engine/gate_meta_coverage.lua @@ -111,13 +111,11 @@ local DEBT = { ["hook:encounter.fishing"] = "M5", ["hook:render.zones"] = "M9", - ["hook:trainer.party"] = "M7", ["hook:ui.pc.items"] = "M8", ["event:link.connected"] = "M12", ["event:link.ended"] = "M12", ["event:player.warped"] = "M5", - ["event:pokemon.before_give"] = "M7", ["event:pokemon.evolved"] = "M7", ["event:pokemon.level_up"] = "M7", ["event:pokemon.move_learned"] = "M7", diff --git a/tests/engine/generated_data_literal.lua b/tests/engine/generated_data_literal.lua new file mode 100644 index 000000000..e23b1c5ec --- /dev/null +++ b/tests/engine/generated_data_literal.lua @@ -0,0 +1,55 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local D = require("src.import.DataLiteral") +local Writer = require("src.import.LuaWriter") +local Serializer = require("src.core.SaveSerializer") +local data = { a = { 1, 2, false, "hello\n\0\255", { quoted = "a'\"b" } }, [0] = 17, bytes = "\1\127", map = { + name = "MEW", +} } +for _, encode in ipairs({ Writer.encode, Serializer.encode }) do + local value, why = D.decode(encode(data)) + T.check(value ~= nil, "native writer accepted: " .. tostring(why)) + T.same(value, data, "native literal roundtrip") +end +local value, why = D.decode( + "-- heading\nreturn {a='x', b=[=[\nlong]=], --[=[block]=]\n c={true,false,nil}, [false]=0x10, n=-2.5e2};-- tail" +) +T.check(value ~= nil, "arrays/comments/long strings/hex: " .. tostring(why)) +T.eq(value and value.b, "long", "long string newline convention") +T.eq(value and value.n, -250, "finite exponent") +T.eq(value and value[false], 16, "literal boolean key") +for _, text in ipairs({ + "return 1", + "return {} extra", + "return {a=1+2}", + "return os.execute('x')", + "return {a=1e999}", + "return {a=0/0}", + "return {a='\\999'}", + "return {a='\\x0g'}", + "return {a='\\q'}", + "return {a='bad\n'}", + "return {a=1,a=2}", + "return {[{}]=1}", + "return {a=}", + "return {", + "--[[unterminated", + "return {[=[bad}", + "return {function() while true do end end}", + "return {a=1}; while true do end", +}) do + local result, reason = D.decode(text) + T.eq(result, nil, "invalid/executable literal refused") + T.check(type(reason) == "string", "invalid literal has reason") +end +local limits = { bytes = 1000, depth = 2, nodes = 10, string = 4, entries = 3 } +for _, text in ipairs({ + string.rep(" ", 1001), + "return {{{}}}", + "return {'longstring'}", + "return {a=1,b=2,c=3,d=4}", + "return {{1,2,3},{4,5,6}}", +}) do + T.eq(D.decode(text, limits), nil, "literal parser resource bound") +end +T.finish() diff --git a/tests/engine/legacy_dataset_views.lua b/tests/engine/legacy_dataset_views.lua new file mode 100644 index 000000000..1514b7854 --- /dev/null +++ b/tests/engine/legacy_dataset_views.lua @@ -0,0 +1,29 @@ +local T=require('tests.harness') +love=require('tests.love_stub') +local SDK=require('tests.modkit.sdk') +local Json=require('src.link.Json') +local Views=require('src.mods.DatasetViews') +for _,level in ipairs({1,2})do + local id='legacy_datasets_'..level + local files={ + ['mods/'..id..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=level,entry='main.lua'}), + ['mods/'..id..'/main.lua']='return function(mod) mod.exports.datasets=mod.datasets end', + } + local fs=SDK.memfs(files) + local result=SDK.loadMods({'mods/'..id},{fs=fs}) + T.eq(#result.errors,0,'API-'..level..' entrypoint loads unchanged') + local datasets=assert(result.loader.exports[id].datasets) + local expected=Views.new(fs,require,function(fn,...)return fn(...)end) + for _,version in ipairs({'not-a-version','blue','gold','firered'})do + local value,reason=datasets:open(version) + local original,originalReason=expected:open(version) + T.eq(value,original,'legacy reader retains native availability for '..version) + T.eq(reason,originalReason,'legacy reader retains native refusal code for '..version) + end + T.check(result.loader.datasetViews~=nil,'native service constructed by first request') + local instance=result.loader.datasetViews + datasets:open('blue') + T.eq(result.loader.datasetViews,instance,'view service reused across requests') + result.release() +end +T.finish() diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua new file mode 100644 index 000000000..5d4562cd8 --- /dev/null +++ b/tests/engine/mod_supports.lua @@ -0,0 +1,251 @@ +-- API availability is observable through the real loader, before Game/world +-- construction. No imported content, graphics, or network is required. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.harness') +local GameVersion = require('src.core.GameVersion') +local Loader = require('src.mods.Loader') +local Runtime = require('src.mods.Runtime') +local WorldAPI = require('src.world.WorldAPI') +local Schemas = require('src.mods.Schemas') +local Catalog = require('tests.modkit.catalog') +local originalEvents, originalHooks, originalErrors = Runtime.events, Runtime.hooks, Runtime.errors +local originalVersion = GameVersion.get() +local savedLove = love +love = nil + +local files = { + ['mods/probe/manifest.json'] = [[{"id":"probe","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/probe/main.lua'] = [[ +return function(mod) + -- Runs inside the entry window, not after the loader has finished. + mod.exports.early = mod:supports('world', 'warpTo') + mod.exports.api = mod +end +]], +} +local fs = { + read = function(path) return files[path] end, + getDirectoryItems = function(path) + if path == 'mods' then return { 'probe' } end + return {} + end, + getInfo = function(path) + if path == 'mods' or path == 'mods/probe' then return { type = 'directory' } end + if files[path] then return { type = 'file' } end + end, + load = function(path) return loadstring(files[path], path) end, +} +local function probe(version) + GameVersion.set(version) + local data = { pokemon = {} } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error('supports must not construct Game') end + assert(loader:load(data)) + assert(loader.apis.probe, tostring(loader.errors.probe)) + return loader.apis.probe, loader, data +end +local function yes(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == true and reason == nil +end +local function no(api, surface, name) + local ok, reason = api:supports(surface, name) + return ok == false and type(reason) == 'string' and #reason > 0 +end + +-- Builtin registrants can allocate fresh closures on each load. Compare +-- record structure and values while treating function identity as opaque, +-- as the registry parity gate does. +local function record(value) + if type(value) == 'function' then return '' end + if type(value) ~= 'table' then return value end + local out = {}; for k, v in pairs(value) do out[k] = record(v) end; return out +end + +local allHooks, allEvents = {}, {} +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, data = probe(version) + T.check(api.exports.early, version .. ': query succeeds inside entry chunk') + T.check(yes(api, 'hooks', 'battle.damage') and yes(api, 'events', 'world.stepped'), + version .. ': shared simulation/world call sites available') + local registries = true + for name in pairs(Schemas.REGISTRIES) do + registries = registries and yes(api, 'content', name) + end + T.check(registries, version .. ': all API-2 registration surfaces available') + T.eq(yes(api, 'world', 'flyTo'), type(WorldAPI.flyTo) == 'function', + version .. ': additive native flight interface is reported when installed') + T.check(no(api, 'hooks', 'core.logic_speed') + and no(api, 'hooks', 'item.use') and no(api, 'events', 'egg.hatched'), + version .. ': absent Gen1Recomp API-3 features are not advertised') + T.check(yes(api, 'events', 'mod.probe.ready') and yes(api, 'events', 'mod.other.ready'), + version .. ': namespaced user events do not need a producer installed') + for _, name in ipairs(Catalog.hooks()) do + if yes(api, 'hooks', name) then allHooks[name] = true end + end + for _, name in ipairs(Catalog.events()) do + if yes(api, 'events', name) then allEvents[name] = true end + end + -- Mutation of the handed-out metadata cannot change a capability answer. + local statusHud = api:supports('hooks', 'battle.status_hud_visible') + api.gameVersion, api.generation, api.host.version = 'red', 1, 'red' + GameVersion.set('red') + T.eq(api:supports('hooks', 'battle.status_hud_visible'), statusHud, + version .. ': query retains captured host even after metadata/global changes') + T.check(next(loader.events.listeners) == nil and next(loader.hooks.chains) == nil, + version .. ': probing installs no listeners or wrappers') + local pristine = { pokemon = {} } + GameVersion.set(version) + local vanilla = Loader.new({ fs = fs, noMods = true, dev = false }) + assert(vanilla:load(pristine)) + T.same(record(data), record(pristine), version .. ': querying mod leaves merged data equal to no-mod boot') +end + +-- The union covers every existing native callback; stale catalog entries +-- also fail. This gate reads source rather than a second hand-kept name list. +T.same(allHooks, (function() + local out = {}; for _, name in ipairs(Catalog.hooks()) do out[name] = true end; return out +end)(), 'all native hook names have a queryable implementation') +T.same(allEvents, (function() + local out = {}; for _, name in ipairs(Catalog.events()) do out[name] = true end; return out +end)(), 'all native event names have a queryable implementation') + +-- Also reject advertised names whose native call site was removed. Read +-- the simple declaration lists without exporting mutable production state. +local h = assert(io.open('src/mods/Capabilities.lua', 'r')) +local body = h:read('*a'); h:close() +local declared = { hooks = {}, events = {} } +for surface, list in body:gmatch('add%("(%w+)", "%d+", ([^%)]+)%)') do + local names = list:match('^%[%[(.-)%]%]$') or list:match('^"(.-)"$') + assert(names, 'unrecognized capability declaration') + for name in names:gmatch('%S+') do declared[surface][name] = true end +end +T.same(declared.hooks, allHooks, 'advertised hook catalog has no stale call sites') +T.same(declared.events, allEvents, 'advertised event catalog has no stale call sites') + +local api, loader = probe('crystal') +local noWorldBefore = package.loaded['src.world.WorldAPI'] +T.check(yes(api, 'world', 'warpTo') and yes(api, 'world', 'heightAt'), + 'world methods available independently of save/map state') +T.eq(package.loaded['src.world.WorldAPI'], noWorldBefore, 'queries never require WorldAPI') +for _, surface in ipairs({ 'content', 'world', 'hooks', 'events' }) do + for _, invalid in ipairs({ '', 1, false, {}, function() end }) do + T.check(no(api, surface, invalid), surface .. ': malformed name declines with reason') + end + T.check(no(api, surface, nil), surface .. ': missing name declines with reason') + T.check(no(api, surface, 'does.not.exist'), surface .. ': unknown name declines with reason') +end +for _, surface in ipairs({ '', 'event', 'hook', 'storage', 1, false, {} }) do + T.check(no(api, surface, 'anything'), 'unknown/malformed surface declines with reason') +end +T.check(no(api, nil, 'anything'), 'missing surface declines with reason') +T.check(no(api, 'events', 'mod.probe.') and no(api, 'events', 'mod..ready'), + 'empty user-event namespace/name is unavailable') +T.check(no(api, 'world', 'new') and no(api, 'world', '_mapNamed') + and no(api, 'world', 'face') and no(api, 'world', '__index'), + 'constructors, internals and NPC-handle methods are not world capabilities') +T.check(no(api, 'content', 'Pokemon') and no(api, 'hooks', 'BATTLE.DAMAGE'), + 'names are case-sensitive') + +-- Verify world catalog against the real public method table, not just names +-- duplicated in a test. A supported method may still decline without a map. +love = savedLove or require('tests.love_stub') +local WorldAPI = require('src.world.WorldAPI') +for name, method in pairs(WorldAPI) do + if type(method) == 'function' and name ~= 'new' and name:sub(1, 1) ~= '_' then + T.check(yes(api, 'world', name), 'public WorldAPI method query: ' .. name) + end +end +local worldNames = assert(body:match('for name in %(%[%[(.-)%]%]%):gmatch')) +for name in worldNames:gmatch('%S+') do + T.eq(type(WorldAPI[name]), 'function', 'advertised world method exists: ' .. name) +end +local world = WorldAPI.new({}, 'probe') +local result, reason = world:warpTo('missing', 0, 0) +T.check(result == nil and reason == 'no overworld' and yes(api, 'world', 'warpTo'), + 'availability is distinct from current-state eligibility') + +-- Version-specific routing is deliberately different from Gen1Recomp. +for _, version in ipairs(GameVersion.ORDER) do + local mod = probe(version) + local gen = GameVersion.generation(version) + T.eq(mod:supports('hooks', 'battle.status_hud_visible'), gen >= 4, + version .. ': DS-only HUD seam') + T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3, + version .. ': GB/GBA-only overlay seam') + T.eq(mod:supports('hooks', 'ui.party.submenu'), gen <= 3, + version .. ': native party-menu seam') + T.eq(mod:supports('hooks', 'ui.pc.items'), gen <= 2, + version .. ': native PC seam') + T.eq(mod:supports('events', 'intro.birch_speech.started'), version == 'emerald', + version .. ': Emerald-only Birch intro') + T.eq(mod:supports('hooks', 'intro.oak_speech.build'), gen <= 2 and version ~= 'prism', + version .. ': Oak route excludes Prism and newer intros') +end + +-- A content query gates an actual registration through the entry API. +local entry = files['mods/probe/main.lua'] +files['mods/probe/main.lua'] = [[ +return function(mod) + if mod:supports('content', 'strings') then + mod.content.strings:register('QUERY_TEXT', 'Query succeeded') + end + if mod:supports('content', 'not_a_registry') then + error('unavailable registration must be skipped') + end +end +]] +local _, contentLoader, contentData = probe('crystal') +T.eq(contentData.strings.QUERY_TEXT, 'Query succeeded', 'supported registry accepts real registration') +T.eq(#contentLoader.errors, 0, 'unavailable content fallback loads without errors') +files['mods/probe/main.lua'] = entry + +-- A generation-specific query gates the real DS HUD call site. Returning +-- false avoids drawing and lets this run without a graphics context. +local Gen4Battle = require('src.battle.Gen4Battle') +for _, version in ipairs({ 'platinum', 'black' }) do + local mod = probe(version) + local calls = 0 + if mod:supports('hooks', 'battle.status_hud_visible') then + mod.hooks:wrap('battle.status_hud_visible', function() + calls = calls + 1 + return false + end) + end + T.eq(Gen4Battle.drawHealthboxes({}), true, version .. ': supported HUD hook suppresses native boxes') + T.eq(calls, 1, version .. ': native HUD call reaches mod once') +end + +-- A successful probe gates an actual native call: world.timeOfDay dispatches +-- the hook and resulting event. Queries themselves have no bus side effects. +api, loader = probe('crystal') +local calls, changes = 0, 0 +if api:supports('hooks', 'world.tod') then + api.hooks:wrap('world.tod', function(next, tod, ctx) + calls = calls + 1 + return 'TEST_NIGHT' + end) +end +if api:supports('events', 'world.tod_changed') then + api.events:on('world.tod_changed', function(payload) + changes = changes + 1 + T.eq(payload.tod, 'TEST_NIGHT', 'native event carries hook result') + end) +end +local Overworld = require('src.world.OverworldController') +local ow = setmetatable({ tod = 'DAY', map = { id = 'PROBE' }, player = {} }, { __index = Overworld }) +ow.syncGen2Tod = function() end +T.eq(ow:timeOfDay(), 'TEST_NIGHT', 'queried native hook executes through actual world method') +T.check(calls == 1 and changes == 1, 'native hook/event each dispatched once') +-- Unsubscribe and use the same native path: original time-of-day behavior. +loader.events:removeOwner('probe') +loader.hooks:removeOwner('probe') +GameVersion.set('red') +ow.tod = 'DAY' +T.eq(ow:timeOfDay(), 'DAY', 'unmodified native path retains vanilla result') +T.check(calls == 1 and changes == 1, 'no-mod path invokes no mod callbacks') + +GameVersion.set(originalVersion) +love = savedLove +Runtime.install(originalEvents, originalHooks, originalErrors) +T.finish('mod supports') diff --git a/tests/engine/native_mod_seams.lua b/tests/engine/native_mod_seams.lua new file mode 100644 index 000000000..6d3794059 --- /dev/null +++ b/tests/engine/native_mod_seams.lua @@ -0,0 +1,234 @@ +-- Native producers, observed through a sandboxed mod loaded by the real SDK. +-- Do not emit these events from the test: a missing call site must fail here. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") +local GameVersion = require("src.core.GameVersion") +local Overworld = require("src.world.OverworldController") +local BirchSpeech = require("src.ui.BirchSpeech") +local Gen4Battle = require("src.battle.Gen4Battle") +local Pokemon = require("src.pokemon.Pokemon") + +local savedVersion = GameVersion.get() +local savedGame = {} +for _, key in ipairs({ "data", "save", "stack", "input", "overworld", "world", "renderer" }) do + savedGame[key] = { Game[key] } +end +GameVersion.set("red") +local data = T.fixtures.fresh() +local run = T.sdk.loadMods( + { "mods/seam_probe" }, + { + data = data, + fs = T.sdk.memfs({ + ["mods/seam_probe/manifest.json"] = [[{ + "id":"seam_probe", "name":"Native seam probe", "version":"1.0.0", + "api":2, "entry":"main.lua", "affects_link":false + }]], + ["mods/seam_probe/main.lua"] = [[ + local mod = ... + local seen = {} + mod.exports.seen = seen + local names = { + "intro.birch_speech.started", "intro.birch_speech.finished", + "pokemon.egg_hatched", "world.blocks_replaced", "world.tick", + } + for _, name in ipairs(names) do + seen[name] = {} + mod.events:on(name, function(payload) + local events = seen[name] + events[#events + 1] = payload + end) + end + mod.exports.hideHUD = true + mod.exports.hudCalls = 0 + mod.hooks:wrap("battle.status_hud_visible", function(nextFn, battle) + mod.exports.hudCalls = mod.exports.hudCalls + 1 + mod.exports.battle = battle + if mod.exports.hideHUD then return false end + return nextFn(battle) + end) + ]], + }), + } +) +T.eq(#run.errors, 0, "the public-API probe loads without errors") +local exports = assert(run.loader.exports.seam_probe) +local seen = exports.seen +local stack = { states = {} } +function stack:push(state) + self.states[#self.states + 1] = state +end +function stack:pop() + return table.remove(self.states) +end +function stack:top() + return self.states[#self.states] +end +Game.data, Game.stack = data, stack +Game.save = { player = { map = "FIX_TOWN", name = "TEST", id = 123 }, options = {} } +Game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, +} +Game.renderer = { + worldViewSize = function() + return 160, 144 + end, +} + +-- enter() establishes the native module's Game reference and real runner. +-- Map loading is the ROM/graphics boundary; provide a tiny idle map there. +local map = { + id = "FIX_TOWN", + def = { width = 2, height = 2 }, + blockPatch = {}, + renderer = { + rebuilds = 0, + rebuild = function(self) + self.rebuilds = self.rebuilds + 1 + end, + }, + warpAtCell = function() + return nil + end, +} +function map:blockAt(bx, by) + return self.blockPatch[by * 2 + bx + 1] or 0 +end +local world = setmetatable({ + setMap = function(self) + self.map = map + self.player = { + cellX = 0, + cellY = 0, + px = 0, + py = 0, + update = function() + return false + end, + } + self.npcs, self.ghosts, self.entities, self.cast = {}, {}, {}, {} + end, +}, { __index = Overworld }) +world:enter("FIX_TOWN", 0, 0, "down") + +-- Birch start and finish reach subscribers at their real lifecycle boundary. +GameVersion.set("emerald") +data.constants.gen3Intro = { lines = { "WELCOME", "WORLD", "PLAYER", "GENDER" } } +data.text.WELCOME, data.text.GENDER = "Welcome!", "Boy or girl?" +local done = false +local speech = BirchSpeech.new(Game, function() + done = true +end) +stack:push(speech) +speech:enter() +T.eq(#seen["intro.birch_speech.started"], 1, "enter publishes one Birch start") +T.eq(seen["intro.birch_speech.started"][1].speech, speech, "start identifies the live speech") +T.check(stack:top() ~= speech, "start advances into the first real text box") +stack:pop() +speech.answers = { name = "TEST", gender = "girl" } +speech:finish() +T.eq(#seen["intro.birch_speech.finished"], 1, "finish publishes one Birch completion") +local finished = seen["intro.birch_speech.finished"][1] +T.eq(finished.speech, speech, "finish identifies the same speech") +T.eq(finished.answers, speech.answers, "finish carries the recorded answers") +T.check(done and #stack.states == 0, "finish then closes the speech and calls its continuation") + +-- Batch block changes publish only actual writes, clipped to map dimensions. +GameVersion.set("red") +T.eq(world:replaceBlocks({ 1, 0, 2, 0, 99 }), true, "native batch changes map blocks") +T.eq(#seen["world.blocks_replaced"], 1, "batch emits one event") +T.eq(seen["world.blocks_replaced"][1].mapId, "FIX_TOWN", "batch reports the map") +T.eq(seen["world.blocks_replaced"][1].count, 2, "batch counts changed cells only") +T.eq(map.blockPatch[1], 1, "first block is written") +T.eq(map.blockPatch[3], 2, "second changed block is written") +T.eq(map.blockPatch[5], nil, "extra input cannot extend the map") +T.eq(map.renderer.rebuilds, 1, "batch rebuilds once") +T.eq(world:replaceBlocks({ 1, 0, 2, 0 }), false, "identical batch does nothing") +T.eq(world:replaceBlocks(nil), false, "missing batch does nothing") +T.eq(#seen["world.blocks_replaced"], 1, "no-op batches publish no event") + +-- A headless logic update emits even though draw() was never called. +world:update(1 / 60) +T.eq(#seen["world.tick"], 1, "native update publishes one simulation tick") +local tick = seen["world.tick"][1] +T.eq(tick.dt, 1 / 60, "tick carries the simulation dt") +T.eq(tick.mapId, "FIX_TOWN", "tick carries the active map") +T.eq(tick.overworld, world, "tick identifies the native world") +T.eq(tick.scripted, false, "an idle runner is reported as idle") +world.runner:run({ { "wait", 5 } }) +T.eq(world.runner:isRunning(), true, "the native wait command holds a script open") +world:update(1 / 30) +T.eq(#seen["world.tick"], 2, "scripted updates still publish exactly one tick") +T.eq(seen["world.tick"][2].scripted, true, "tick reports the real running script") +T.eq(seen["world.tick"][2].dt, 1 / 30, "tick does not hardcode the frame interval") + +-- Hatching is announced after the native dialogue callback, with a healed +-- ordinary Pokemon rather than an egg. Use Emerald to avoid Johto's second +-- nickname prompt; the native hatch method itself remains under test. +GameVersion.set("emerald") +local egg = Pokemon.new(data, "FIXMON_A", 5, function(a) + return a +end) +egg.isEgg, egg.eggSteps, egg.eggCycles = true, 0, 0 +egg.hp, egg.status = 1, "PSN" +T.eq(world:hatchEgg(egg), true, "native hatch accepts the egg") +T.eq(#seen["pokemon.egg_hatched"], 0, "hatch is not announced before dialogue finishes") +local box = stack:pop() +T.check(box ~= nil, "hatch opens its native text box") +box.onDone() +T.eq(#seen["pokemon.egg_hatched"], 1, "dialogue completion publishes the hatch") +T.eq(seen["pokemon.egg_hatched"][1].mon, egg, "hatch identifies the live Pokemon") +T.eq(egg.isEgg, nil, "the announced Pokemon is no longer an egg") +T.eq(egg.eggSteps, nil, "hatch clears egg step bookkeeping") +T.eq(egg.eggCycles, nil, "hatch clears egg cycle bookkeeping") +T.eq(egg.hp, egg.stats.hp, "hatch heals HP before publication") +T.eq(egg.status, nil, "hatch cures status before publication") +T.eq(egg.happiness, 120, "hatch applies base happiness before publication") +T.eq(world:hatchEgg(nil), false, "missing egg is rejected") +T.eq(#seen["pokemon.egg_hatched"], 1, "rejected hatch does not publish") + +-- A public wrapper can suppress native Sinnoh/Unova healthboxes before any +-- battler/graphics access. Returning true prevents fallback Game Boy panels. +GameVersion.set("platinum") +local battle = {} +T.eq(Gen4Battle.drawHealthboxes(battle), true, "hidden native HUD is handled") +T.eq(exports.hudCalls, 1, "native healthbox draw consults the public hook") +T.eq(exports.battle, battle, "the hook receives the live battle") +exports.hideHUD = false +T.eq( + Gen4Battle.drawHealthboxes(battle), + false, + "delegating preserves the native fallback when healthbox art is absent" +) +T.eq(exports.hudCalls, 2, "delegation still goes through the public wrapper") +exports.hideHUD = true +GameVersion.set("black") +T.eq(Gen4Battle.drawHealthboxes(battle), true, "the shared native path also hides Unova boxes") +T.eq(exports.hudCalls, 3, "Unova consults the same public wrapper") + +T.eq(#run.errors, 0, "native callbacks produce no attributed mod errors") +run.loader.events:removeOwner("seam_probe") +run.loader.hooks:removeOwner("seam_probe") +for _, name in ipairs({ + "intro.birch_speech.started", + "intro.birch_speech.finished", + "pokemon.egg_hatched", + "world.blocks_replaced", + "world.tick", +}) do + T.eq(Runtime.wants(name), false, "unloading removes subscription: " .. name) +end +T.eq(Runtime.wantsHook("battle.status_hud_visible"), false, "unloading removes the HUD hook") +run.release() +GameVersion.set(savedVersion) +for key, value in pairs(savedGame) do + Game[key] = value[1] +end +T.finish("native public mod seams") diff --git a/tests/engine/portable_dialogue_audio.lua b/tests/engine/portable_dialogue_audio.lua new file mode 100644 index 000000000..a5882405f --- /dev/null +++ b/tests/engine/portable_dialogue_audio.lua @@ -0,0 +1,211 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_dialogue_audio") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +local function sounding(f) + for _ = 1, 300 do + f:tick() + if #f.audioTrace > 0 then + return f.game.stack:top() + end + end + error("no deferred cue") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " native dialogue audio contract: " .. tostring(why)) +end +for _, row in ipairs({ + { "play_cry", "UNKNOWN" }, + { "play_cry", 151 }, + { "play_cry", "MEW", 1 }, + { "play_cry", "MEW", true, 1 }, + { "text_sound", "UNKNOWN" }, + { "engage_music", "UNKNOWN" }, + { "text_sound", "CHIME", false }, + { "engage_music", false }, +}) do + local f = fixture() + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "bad deferred cue refused") + T.eq(f.exports.get("mod:prefix"), false, "whole script preflight") + T.eq(#f.audioTrace, 0, "preflight is silent") + f.restore() +end +for _, change in ipairs({ "rollback", "save", "data", "map" }) do + for _, phase in ipairs({ "typing", "sounding", "button" }) do + local f = fixture() + T.check( + f.exports.queue({ + { "play_cry", "MEW", true }, + { "engage_music", "TUNE" }, + { "show_text", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "retirement fixture queued" + ) + local box = f.game.stack:top() + local auto = box.auto + if phase ~= "typing" then + sounding(f) + end + if phase == "button" then + f.source.playing = false + f:tick() + end + if change == "rollback" then + f.loader:_rollback("script_dialogue_audio") + elseif change == "map" then + f.ow.map = { id = f.home } + else + f.game[change] = {} + end + local foreign = { update = function() end } + f.game.stack:push(foreign) + -- Retained callbacks after retirement cannot replay cues or remove UI. + auto.sound() + auto.tick() + f:tick() + T.eq(f.exports.get("mod:after"), false, "retired cue has no continuation") + T.eq(f.game.stack:top(), foreign, "foreign overlay retained") + T.eq(f.exports.result().done, 1, "retired cue completes once") + f.restore() + end +end +for _, callback in ipairs({ "sound", "poll", "engage" }) do + local f = fixture() + local Sound, Music = require("src.core.Sound"), require("src.core.Music") + local foreign = { update = function() end } + local function retire() + f.loader:_rollback("script_dialogue_audio") + f.game.stack:push(foreign) + end + local oldSound, oldMusic = Sound.playCry, Music.play + if callback == "sound" then + Sound.playCry = function(...) + local src = oldSound(...) + retire() + return src + end + elseif callback == "poll" then + f.source.isPlaying = function() + retire() + return false + end + else + Music.play = function(...) + oldMusic(...) + retire() + end + end + T.check( + f.exports.queue({ + { "play_cry", "MEW" }, + { "engage_music", "TUNE" }, + { "show_text", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "reentrant cue admitted" + ) + for _ = 1, 300 do + f:tick() + if callback == "engage" then + f.source.playing = false + end + if f.exports.result().done > 0 then + break + end + end + T.eq(f.game.stack:top(), foreign, "reentrant callback cannot pop foreign state") + T.eq(f.exports.result().done, 1, "reentrant completion once") + T.eq(f.exports.get("mod:after"), false, "reentrant continuation suppressed") + Sound.playCry, Music.play = oldSound, oldMusic + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ + { "play_cry", "MEW" }, + { "text_sound", "CHIME" }, + { "engage_music", "TUNE" }, + { "ask", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "combined cues with question" + ) + local box = sounding(f) + T.eq(#f.audioTrace, 2, "both cry and text effect play") + for _ = 1, 3 do + f:tick("a") + end + T.eq(f.game.stack:top(), box, "question waits for sound before choice") + local retained = box.auto + retained.sound() + T.eq(#f.audioTrace, 2, "sound callback is once only") + f.source.playing = false + for _ = 1, 3 do + f:tick() + end + T.check(f.game.stack:top() ~= box, "native choice after audio gate") + T.eq(f.audioTrace[3].kind, "music", "engage follows completed cue") + retained.tick() + retained.sound() + T.eq(#f.audioTrace, 3, "underlay callbacks add no playback") + for _ = 1, 30 do + f:tick("a") + end + T.eq(f.exports.result().success, true, "choice continues script once") + T.eq(f.exports.result().done, 1, "choice single completion") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ + { "play_cry", "MEW" }, + { "engage_music", "TUNE" }, + { "show_text", "WELCOME" }, + { "set_flag", "mod:after" }, + }), + "bounded cry fixture" + ) + sounding(f) + for _ = 1, 600 do + f:tick() + end + T.eq(f.exports.result().success, true, "600-frame native audio ceiling releases dialogue") + T.eq(#f.audioTrace, 2, "music starts once at sound ceiling") + f.restore() +end +for _, stage in ipairs({ "dispatch", "typed" }) do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 2 }, { "play_cry", "MEW" }, { "show_text", "WELCOME" }, { + "set_flag", + "mod:after", + } }), + "asset-change fixture" + ) + if stage == "typed" then + for _ = 1, 4 do + f:tick() + end + end + f.game.data.audio.cries.MEW = nil + for _ = 1, 300 do + f:tick() + end + T.eq(f.exports.result().success, false, "missing cry revalidated at " .. stage) + T.eq(f.exports.get("mod:after"), false, "changed asset prevents continuation") + T.eq(#f.audioTrace, 0, "missing asset never played") + f.restore() +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tests/engine/portable_script_audio.lua b/tests/engine/portable_script_audio.lua new file mode 100644 index 000000000..bdc15eb4d --- /dev/null +++ b/tests/engine/portable_script_audio.lua @@ -0,0 +1,288 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_audio") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared audio contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "play_sound", "UNKNOWN" } }, + { { "play_music", "UNKNOWN" } }, + { { "play_once", "UNKNOWN" } }, + { { "play_sound", "" } }, + { { "play_music", "TUNE", { keep = "yes" } } }, + { { "play_music", "TUNE", { volume = 1 } } }, + { { "fade_music", -1 } }, + { { "fade_music", 256 } }, + { { "wait_sound", 1 } }, + { { "stop_music", false } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid audio preflight") + T.eq(f.exports.get("mod:prefix"), false, "invalid audio suffix prevents prefix") + T.eq(#f.audioTrace, 0, "preflight plays no cue") + f.restore() +end +for _, phase in ipairs({ "sound", "once" }) do + for _, change in ipairs({ "rollback", "save", "data", "map" }) do + local f = fixture() + local original = f.ow.runner + local first = phase == "sound" and { "play_sound", "CHIME" } or { "play_once", "JINGLE" } + local rows = phase == "sound" and { first, { "wait_sound" }, { "set_flag", "mod:after" } } + or { first, { "set_flag", "mod:after" } } + T.check(f.exports.queue(rows), "audio admitted before context change") + if change == "rollback" then + f.loader:_rollback("script_audio") + elseif change == "map" then + f.ow.map = { id = f.home } + else + f.game[change] = {} + end + f:tick() + f.source.playing = false + f.musicBusy = false + f:tick() + T.eq(f.exports.result().success, false, "retired audio wait fails script") + T.eq(f.exports.result().done, 1, "retired audio finishes once") + T.eq(f.ow.runner, original, "retired audio restores idle runner") + T.eq(#f.audioTrace, 1, "retirement adds no audio or later effects") + f.restore() + end +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_sound", "CHIME" }, { "wait_sound" }, { "set_flag", "mod:after" } }), + "sound timeout admitted" + ) + for _ = 1, 600 do + f:tick() + end + T.eq(f.exports.result().success, true, "600-frame sound ceiling releases native wait") + T.eq(f.exports.get("mod:after"), true, "sound timeout keeps native continuation behavior") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }), + "stalled fanfare admitted" + ) + for _ = 1, 3600 do + f:tick() + end + T.eq(f.exports.result().success, false, "stalled one-shot fails at bounded deadline") + T.eq(f.exports.get("mod:after"), false, "stalled one-shot cannot burn later flag") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }), + "one-shot before foreign music" + ) + f.current = "FOREIGN" + f:drain() + T.eq(f.exports.result().success, true, "one-shot does not wait on a replacement song") + T.eq(f.current, "FOREIGN", "replacement song retained") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "play_sound", "CHIME" }, { "wait_sound" }, { "set_flag", "mod:after" } }), + "sound before overlay" + ) + f.source.playing = false + f.game.stack.states[2] = {} + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "audio continuation pauses beneath overlay") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.get("mod:after"), true, "audio resumes in foreground") + f.restore() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v) + f.game.data.audio.songs.SONG_007 = { index = 7, musName = "MUS_TEST", sfxName = "SE_TEST", tracks = {} } + f.game.data.audio.sfx.Get_Item1 = { m4a = 7, song = "SONG_007" } + T.check( + f.exports.queue({ + { "play_sound", "SE_TEST" }, + { "wait_sound" }, + { "play_music", 7 }, + { + "play_once", + "Get_Item1", + }, + }), + "native Gen3 names/number/fanfare role admitted" + ) + T.eq(f.audioTrace[1].id, "SONG_007", "native sequence resolved for effect") + f.source.playing = false + for _ = 1, 5 do + f:tick() + end + T.eq(f.audioTrace[2].id, "SONG_007", "numeric music id resolved") + T.eq(f.audioTrace[3].id, "SONG_007", "native obtain-item fanfare role resolved") + f.musicBusy = false + f:drain() + T.eq(f.exports.result().success, true, "native Gen3 resolved sequence completes") + f.restore() + f = fixture(v) + f.game.data.audio.songs.A = { index = 7, musName = "MUS_DUP" } + f.game.data.audio.songs.B = { index = 8, musName = "MUS_DUP" } + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "play_music", "MUS_DUP" } }), + nil, + "ambiguous sequence name preflight" + ) + T.eq(f.exports.get("mod:prefix"), false, "ambiguous cue prevents prefix") + T.check(f.exports.queue({ { "play_music", 0 } }), "Gen3 native zero music id admitted") + T.eq(f.audioTrace[1].kind, "stop", "Gen3 native zero means stop") + f.restore() +end +do + local f = fixture() + local opts = { keep = true, reason = "script" } + T.check( + f.exports.queue({ { "wait", 1 }, { "play_music", "TUNE", opts } }), + "music options snapshot admitted" + ) + opts.keep = false + f:drain() + T.eq(f.ow.keepMusicOnce, true, "caller cannot mutate queued music options") + f.restore() +end +do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "play_music", "TUNE" } }), "deferred cue admitted") + f.game.data.audio.songs.TUNE = nil + f:drain() + T.eq(f.exports.result().success, false, "live missing cue fails dispatch") + T.eq(#f.audioTrace, 0, "missing live cue does not play") + f.restore() +end +do + local f = fixture() + local Music = require("src.core.Music") + local native = Music.play + Music.play = function(...) + native(...) + f.loader:_rollback("script_audio") + end + T.check( + f.exports.queue({ { "play_music", "TUNE", { keep = true } }, { "set_flag", "mod:after" } }), + "retirement during native music admitted" + ) + T.eq(f.exports.result().success, false, "music callback retirement cancels script") + T.eq(f.ow.keepMusicOnce, nil, "retired cue cannot arm a later warp's music flag") + T.eq(#f.audioTrace, 1, "already committed native cue is retained") + f.restore() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v) + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "play_music", "MUS_UNKNOWN" } }), + nil, + "unknown Gen3 cue refused before prefix" + ) + T.eq(f.exports.get("mod:prefix"), false, "Gen3 cue refusal has no prefix") + f.game.data.audio.songs.TUNE = false + T.eq(f.exports.queue({ { "play_music", "TUNE" } }), nil, "disabled cue not accepted as a definition") + f.restore() +end +-- Native Sound file/effect dispatch, with only the actual device replaced. +do + love = require("tests.love_stub") + local Sound = require("src.core.Sound") + local previousAudio = love.audio + local calls = 0 + local src = { + playing = false, + setVolume = function() end, + stop = function(self) + self.playing = false + end, + play = function(self) + self.playing = true + end, + isPlaying = function(self) + return self.playing + end, + } + love.audio = { + newSource = function(file, kind) + T.eq(file, "native-cue.wav", "native file cue passed to device") + calls = calls + 1 + return src + end, + } + local data = { audio = { songs = { PORTABLE_NATIVE_FILE = { file = "native-cue.wav" } } } } + T.eq(Sound.playNamedEffect(data, "PORTABLE_NATIVE_FILE"), src, "native helper returns started source") + T.eq(calls, 1, "native helper builds source once") + T.eq(Sound.playNamedEffect(data, "PORTABLE_NATIVE_FILE"), src, "native helper reuses native cache") + T.eq(calls, 1, "native helper preserves cache") + Sound.invalidate("PORTABLE_NATIVE_FILE") + love.audio = previousAudio +end +-- Actual native music one-shot and restore state; only the device is a seam. +do + local f = fixture() + f.restore() + love = require("tests.love_stub") + local previousAudio = love.audio + local src = { + playing = false, + setVolume = function() end, + setLooping = function() end, + setPitch = function() end, + stop = function(self) + self.playing = false + end, + play = function(self) + self.playing = true + end, + isPlaying = function(self) + return self.playing + end, + } + love.audio = { + newSource = function(file) + T.eq(file, "native-jingle.wav", "native music resolves file definition") + return src + end, + } + local Music = require("src.core.Music") + Music.stop() + f.game.data.audio.songs.JINGLE = { file = "native-jingle.wav" } + T.check( + f.exports.queue({ { "play_once", "JINGLE" }, { "set_flag", "mod:after" } }), + "native music one-shot admitted" + ) + T.eq(Music.current(), "JINGLE", "native music controller owns current jingle") + T.eq(Music.oneShotPlaying(), true, "native music pending restore retained") + for _ = 1, 3 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "script waits on actual native restore state") + src.playing = false + Music.update(f.game.data) + f:drain() + T.eq(Music.oneShotPlaying(), false, "actual native controller drains restore") + T.eq(f.exports.get("mod:after"), true, "actual native restore resumes script") + Music.stop() + love.audio = previousAudio +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script audio") diff --git a/tests/engine/portable_script_battles.lua b/tests/engine/portable_script_battles.lua new file mode 100644 index 000000000..429afb3cf --- /dev/null +++ b/tests/engine/portable_script_battles.lua @@ -0,0 +1,192 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_battles") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared script battle: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "start_battle", "wild", "UNKNOWN", 5 } }, + { { "start_battle", "wild", "MEW", 0 } }, + { { "start_battle", "wild", "MEW", 101 } }, + { { "start_battle", "trainer", "ANY", 1 } }, + { { "static_battle", "MEW", 5, "mod.other.bad" } }, + { { "static_battle", "MEW", 5 }, { "check_battle_result", "invalid" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid battle list preflight") + T.eq(f.exports.get("mod:prefix"), false, "invalid suffix has no flag prefix") + T.eq(f.constructed, 0, "preflight constructs no native battle") + f.restore() +end +for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do + local f = fixture() + T.check( + f.exports.queue({ + { "static_battle", "MEW", 5, "mod:beat" }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:after" }, + }), + "private static beat flag admitted" + ) + f:complete(outcome) + f:drain() + T.eq(f.exports.get("mod:beat"), outcome ~= "lose", "private static flag is generation independent") + T.eq(f.exports.get("mod:after"), true, "battle result branch executes") + f.restore() +end +for _, change in ipairs({ "cancel", "rollback", "save", "data", "player" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }), + "battle admitted before retirement" + ) + local finish = f.finish + if change == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_battles") + elseif change == "rollback" then + f.loader:_rollback("script_battles") + elseif change == "player" then + f.ow.player = {} + else + f.game[change] = {} + end + f:tick() + T.eq(f.game.stack:top(), f.battle, "cancelling script preserves native battle screen") + T.eq(f.exports.result().done, 1, "retirement completes once") + T.eq(f.exports.result().success, false, "retired battle cannot finish script") + f:complete("caught") + finish("win") + finish("run") + T.eq( + f.after, + (change == "cancel" or change == "rollback") and 1 or 0, + "cleanup only for retained native session" + ) + T.eq(f.exports.result().done, 1, "native finish cannot replay retired completion") + T.eq(f.ow.runner, original, "idle runner restored") + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }), + "battle before foreign overlay" + ) + local callback = f.finish + f:complete("win") + local overlay = {} + f.game.stack.states[2] = overlay + callback("lose") + callback("run") + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "battle callback cannot resume under foreign UI") + T.eq(f.after, 1, "native cleanup is one-shot") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.get("mod:after"), true, "foreground battle continuation runs") + f.restore() +end +for _, failure in ipairs({ "constructError", "pushFailure", "cleanupError", "unhealthy", "constructUnload" }) do + local f = fixture() + if failure == "unhealthy" then + f.game.save.party[1].hp = 0 + elseif failure == "constructUnload" then + local native = package.loaded["src.battle.BattleState"].newWild + package.loaded["src.battle.BattleState"].newWild = function(...) + local result = native(...) + f.loader:_rollback("script_battles") + return result + end + else + f[failure] = true + end + T.check( + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }), + "admitted program reports native failure" + ) + if failure == "cleanupError" then + f:complete("win") + end + T.eq(f.exports.result().success, false, "native failure finishes script") + T.eq(f.exports.get("mod:after"), false, "native failure suppresses suffix") + if failure == "constructUnload" then + T.eq(f.pushed, 0, "owner retired during construction cannot push battle") + end + f.restore() +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.queue({ { "start_battle", "wild", "MEW", 5 }, { "set_flag", "mod:after" } }), + "synchronous battle admitted" + ) + T.eq(f.exports.result().success, true, "synchronous battle completion handled") + T.eq(f.after, 1, "synchronous cleanup once") + f.restore() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, outcome in ipairs({ "win", "lose", "run", "caught" }) do + local f = fixture(v) + local def = { name = "NPC", index = 1, localId = 1, x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 } + f.ow.map.def.objects = { def } + f.ow.talkNpc = { def = def, cellX = 1, cellY = 1 } + function f.ow:syncObjectVisibility() + f.hidden = true + end + function f.ow:setMap() + f.hidden = f.game.save.objectToggles[f.home].NPC == false + end + T.check( + f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }), + "static NPC battle admitted" + ) + T.eq(f.hidden, nil, "NPC stays visible during battle") + f:complete(outcome) + f:drain() + T.eq(f.exports.result().success, true, "static NPC continuation succeeds") + T.eq(f.hidden == true, outcome ~= "lose", "static NPC hidden on every non-loss outcome") + T.eq(f.exports.get("mod:after"), true, "static object update resumes later rows") + f.restore() + end +end +-- Actual native constructors and Pokémon creation, retaining the entry timing seam. +for _, v in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(v) + f.restore() + local data = T.fixtures.fresh() + data.pokemon.MEW = require("src.mods.Merge").deepCopy(data.pokemon.FIXMON_A) + data.pokemon.MEW.id, data.pokemon.MEW.name = "MEW", "MEW" + data.constants.gen = Version.generation(v) + f.game.data = data + f.game.save = require("src.core.SaveData").newGame() + f.game.session = f.game.save + f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) } + require("src.render.Font").load(data) + T.check( + f.exports.queue({ { "static_battle", "MEW", 5, "mod:beat" }, { "set_flag", "mod:after" } }), + v .. " native constructor admitted" + ) + T.eq(f.battle.kind, "wild", "native battle kind") + T.eq(f.battle.enemy.mon.species, "MEW", "native enemy species") + f:complete("caught") + f:drain() + T.eq(f.after, 1, "native cleanup retained") + T.eq(f.exports.get("mod:beat"), true, "actual native battle resumes static suffix") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script battles") diff --git a/tests/engine/portable_script_fields.lua b/tests/engine/portable_script_fields.lua new file mode 100644 index 000000000..81f85b718 --- /dev/null +++ b/tests/engine/portable_script_fields.lua @@ -0,0 +1,146 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_fields") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared field contract: " .. tostring(why)) +end +local cyclic = {} +cyclic.self = cyclic +local deep = {} +local node = deep +for _ = 1, 18 do + node.child = {} + node = node.child +end +local wide = {} +for i = 1, 600 do + wide[i] = i +end +for _, row in ipairs({ + { "set_field", "modData", {} }, + { "set_field", "mod:" }, + { "set_field", "mod.other.x", 1 }, + { "set_field", "foreign:x", 1 }, + { "set_option", "x\n", 1 }, + { "set_field", "", 1 }, + { "set_field", false, 1 }, + { "set_field", "x", function() end }, + { "set_field", "x", 0 / 0 }, + { "set_field", "x", math.huge }, + { "set_field", "x", cyclic }, + { "set_field", "x", deep }, + { "set_field", "x", wide }, + { "set_field", "x", string.rep("a", 32769) }, + { "set_field", "x", { [false] = 1 } }, + { "set_field", "x", setmetatable({}, {}) }, + { "set_option", "x", { nested = function() end } }, + { "set_option", "x", 1, 2 }, +}) do + local f = fixture() + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "invalid field rejected") + T.eq(f.exports.get("mod:prefix"), false, "invalid field prevents prefix") +end +for _, store in ipairs({ "options", "modData", "bucket" }) do + for _, value in ipairs({ false, setmetatable({}, {}) }) do + local f = fixture() + local verb, key = "set_option", "choice" + if store == "bucket" then + f.game.save.modData.script_fields = value + verb, key = "set_field", "mod:x" + else + f.game.save[store] = value + if store == "modData" then + verb, key = "set_field", "mod:x" + end + end + T.eq( + f.exports.queue({ { "set_field", "portableRoot", true }, { verb, key, 1 } }), + nil, + "invalid store refused" + ) + T.eq(f.game.save.portableRoot, nil, "store failure before prefix") + end +end +do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 1 }, { "set_field", "mod:x", 1 }, { "set_flag", "mod:after" } }), + "changed bucket fixture" + ) + f.game.save.modData.script_fields = false + f:drain() + T.eq(f.exports.result().success, false, "store revalidated at dispatch") + T.eq(f.game.save.modData.script_fields, false, "changed bucket retained") + T.eq(f.exports.get("mod:after"), nil, "changed bucket has no continuation") +end +do + local f = fixture() + T.eq( + f.exports.queue({ { "set_field", "options", false }, { "set_option", "x", 1 } }), + nil, + "future options shape preflight" + ) + T.eq(f.game.save.options, nil, "future invalid store leaves original untouched") +end +do + local f = fixture() + local value = { a = { 3, false, "x" } } + T.check( + f.exports.queue({ + { "set_field", "mod:a", value }, + { "set_field", "portableRoot", 9 }, + { "set_option", "portableChoice", true }, + }), + "values written" + ) + f:drain() + T.eq(f.game.save.modData.script_fields.a.a[2], false, "false preserved") + value.a[1] = 7 + T.eq(f.game.save.modData.script_fields.a.a[1], 3, "caller table not aliased") + T.check( + f.exports.queue({ { "set_field", "mod:a" }, { "set_field", "portableRoot" }, { + "set_option", + "portableChoice", + } }), + "omitted value deletes" + ) + f:drain() + T.eq(f.game.save.modData.script_fields.a, nil, "private field cleared") + T.eq(f.game.save.portableRoot, nil, "root field cleared") + T.eq(f.game.save.options.portableChoice, nil, "option cleared") +end +do + local F = require("src.mods.ScriptFields") + local game = { save = {} } + local rows = { { "set_field", "mod:x", { n = 1 } } } + local run = assert(F.prepare(game, "owner", rows)) + run({}, rows[1]) + game.save.modData.owner.x.n = 4 + run({}, rows[1]) + T.eq(game.save.modData.owner.x.n, 1, "repeated dispatch copies compiled value") + local rows = {} + for i = 1, 5 do + rows[i] = { "set_field", "mod:x", string.rep("a", 32768) } + end + T.eq(F.prepare(game, "owner", rows), nil, "aggregate data string limit") + local rows = {} + for i = 1, 20 do + local v = {} + for j = 1, 250 do + v[j] = j + end + rows[i] = { "set_field", "mod:x", v } + end + T.eq(F.prepare(game, "owner", rows), nil, "aggregate data node limit") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tests/engine/portable_script_flags.lua b/tests/engine/portable_script_flags.lua new file mode 100644 index 000000000..0f25d1d66 --- /dev/null +++ b/tests/engine/portable_script_flags.lua @@ -0,0 +1,141 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_flags") +local Program = require("src.mods.ScriptProgram") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared script flags: " .. tostring(why)) +end +for _, verb in ipairs({ "set_flag", "clear_flag", "check_flag", "wait_flag", "setflag", "clearflag" }) do + T.eq(Program.supports(verb), true, "flag verb advertised") +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "set_flag", "no_such_flag" } }, + { { "jump", "end" }, { "set_flag", "no_such_flag" } }, + { { "set_flag", "mod:prefix" }, { "check_flag", "mod.other.secret" } }, + { { "set_flag", {} } }, + { { "set_flag", "mod:" } }, + { { "set_flag", "mod:x", true } }, + { { "wait_flag", "mod:x", -1 } }, + { { "wait_flag", "mod:x", 0.5 } }, + { { "wait_flag", "mod:x", math.huge } }, + { { "wait_flag", "mod:x", 36001 } }, + { { "wait_flag", "mod:x", "5" } }, +}) do + local f = fixture() + local runner = f.ow.runner + local value, why = f.exports.queue(rows) + T.eq(value, nil, "entire invalid list declined") + T.check(type(why) == "string", "invalid row explained") + T.eq(f.exports.get("mod:prefix"), false, "no accepted prefix executes") + T.eq(f.ow.runner, runner, "no runner replacement on refusal") + T.eq(f.exports.result().done, 0, "no completion on refusal") +end +do + local f = fixture() + f.game.save.modData = false + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" } }), nil, "malformed store rejected before admission") + T.eq(f.game.save.modData, false, "malformed store not overwritten") +end +do + local f = fixture() + local rows = { { "wait_flag", "mod:signal" }, { "set_flag", "mod:copied" } } + T.check(f.exports.queue(rows), "indefinite wait admitted") + rows[2][2] = "mod:mutated" + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.result().done, 0, "omitted timeout keeps waiting") + T.check(f.exports.set("mod:signal", true), "external signal") + f:drain() + T.eq(f.exports.get("mod:copied"), true, "admitted rows detached") + T.eq(f.exports.get("mod:mutated"), false, "caller mutation ignored") +end +for _, timeout in ipairs({ 0, 1, 2 }) do + local f = fixture() + T.check( + f.exports.queue({ + { "wait_flag", "mod:signal", timeout }, + { "jump_if_true", "end" }, + { + "set_flag", + "mod:timed", + }, + }), + "timeout admitted" + ) + T.eq(f.exports.result().done, 0, "even zero yields once") + f:drain() + T.eq(f.exports.get("mod:timed"), true, "timeout sets lastCheck false") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "wait_flag", "mod:signal", 1 }, { "set_flag", "mod:resumed" } }), + "wait admitted before overlay" + ) + local overlay = {} + f.game.stack.states[2] = overlay + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.result().done, 0, "foreign overlay pauses timeout") + T.eq(f.game.stack.states[2], overlay, "owned wait does not pop foreign screen") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.result().success, true, "wait resumes after uncovering") +end +for _, change in ipairs({ "save", "data", "map", "version", "rollback", "teardown" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "wait_flag", "mod:signal" }, { "set_flag", "mod:late" } }), + "wait starts before " .. change + ) + local owned = f.ow.runner + if change == "version" then + Version.set("blue") + elseif change == "rollback" then + f.loader:_rollback("script_flags") + elseif change == "teardown" then + Loader.teardown() + elseif change == "map" then + f.ow.map = { id = f.home, def = f.ow.map.def } + else + f.game[change] = {} + end + f:tick() + T.eq(f.exports.result().done, 1, "changed context completes once") + T.eq(f.exports.result().success, false, "changed context cancels wait") + T.eq(f.ow.runner, original, "original runner restored") + owned:resume(true) + T.eq(f.exports.result().done, 1, "late condition cannot replay completion") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local ref = version == "red" and 0 or (f.gen == 2 and 16 or 1) + T.check( + f.exports.queue({ + { "set_flag", ref }, + { "check_flag", ref }, + { "jump_if_false", "end" }, + { "set_flag", "mod:native" }, + { "clear_flag", ref }, + }), + "native flag program admitted" + ) + f:drain() + T.eq(f.exports.get("mod:native"), true, "native condition drives control flow") + T.eq(f.exports.get(ref), false, "native flag cleared") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script flags") diff --git a/tests/engine/portable_script_gifts.lua b/tests/engine/portable_script_gifts.lua new file mode 100644 index 000000000..f1a42964e --- /dev/null +++ b/tests/engine/portable_script_gifts.lua @@ -0,0 +1,199 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_gifts") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function tick(f, key) + f.game.input.wasPressed = function(_, button) + return button == key + end + f.loader.hooks:call("input.step", function() end, f.game, 1 / 60) + f.game.stack:update(1 / 60) + f.game.input.wasPressed = function() + return false + end +end +local function close(f) + for _ = 1, 2000 do + if f.exports.result().done > 0 then + return + end + tick(f, "a") + end + error("gift UI did not close") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared gift contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "give_item", "POTION", 1, false }, { "give_pokemon", "UNKNOWN", 5, true, false } }, + { { "give_item", "POTION", 0 } }, + { { "give_item", "POTION", 1000 } }, + { { "give_item", "POTION", 1, true } }, + { { "give_item", "POTION", 1, "\255" } }, + { { "give_pokemon", "MEW", 0 } }, + { { "give_pokemon", "MEW", 101 } }, + { { "give_pokemon", "MEW", 5, "yes" } }, + { { "give_pokemon", "MEW", 5, true, "text" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid gift list refused") + T.eq(f.quantity(), 3, "gift prefix does not write inventory") + T.eq(#f.game.save.party, 1, "gift prefix does not create party mon") + T.eq(f.exports.result().done, 0, "refusal has no completion") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + T.check( + f.exports.queue({ { "give_item", "POTION", 1, "RECEIVED POTION!" }, { "set_flag", "mod:after" } }), + "native receipt admitted" + ) + T.eq(f.quantity(), 4, "native bag already contains received item") + T.eq(f.exports.result().done, 0, "script waits for native receipt") + close(f) + T.eq(f.exports.result().success, true, "receipt completes native script") + T.eq(f.exports.get("mod:after"), true, "next command runs after receipt only") + T.eq(f.game.stack:top(), f.ow, "owned receipt removed") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, true, true }, { "set_flag", "mod:after" } }), + "native mon receipt admitted" + ) + T.eq(#f.game.save.party, 2, "native mon gift created once") + T.eq(f.exports.result().done, 0, "native mon receipt waits") + close(f) + T.eq(f.exports.result().success, true, "native mon receipt complete") + T.eq(f.exports.get("mod:after"), true, "mon gift resumes script") +end +do + local f = fixture() + f.game.save.inventory.POTION = 99 + T.check( + f.exports.queue({ { "give_item", "POTION", 1, false, "BAG IS FULL!" }, { "set_flag", "mod:burned" } }), + "full bag admitted for native refusal UI" + ) + T.eq(f.quantity(), 99, "full bag adds nothing") + T.eq(f.exports.result().done, 0, "bag full message awaited") + close(f) + T.eq(f.exports.result().success, true, "native full bag halts cleanly") + T.eq(f.exports.get("mod:burned"), false, "full bag cannot burn later gift flag") +end +for _, action in ipairs({ "rollback", "teardown", "save" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "give_item", "POTION", 1, "RECEIVED!" }, { "set_flag", "mod:after" } }), + "receipt before " .. action + ) + local box = f.game.stack:top() + local overlay = { update = function() end } + f.game.stack:push(overlay) + if action == "rollback" then + f.loader:_rollback("script_gifts") + elseif action == "teardown" then + Loader.teardown() + else + f.game.save = {} + tick(f) + end + T.eq(f.game.stack:top(), overlay, "foreign overlay retained") + T.eq(#f.game.stack.states, 2, "only owned receipt removed") + T.eq(f.ow.runner, original, "native original runner restored") + T.eq(f.exports.result().done, 1, "cancelled receipt completes once") + T.eq(f.exports.result().success, false, "cancelled receipt reports failure") + box.onDone() + T.eq(f.exports.result().done, 1, "retained native callback cannot replay cancellation") + T.eq(f.game.stack:top(), overlay, "retained native callback preserves foreign overlay") +end +for _, field in ipairs({ "player", "party", "pokedex", "boxes", "boxNames", "currentBox" }) do + local f = fixture() + f.game.save[field] = false + T.eq( + f.exports.queue({ { "give_item", "POTION", 1, false }, { "give_pokemon", "MEW", 5, true, false } }), + nil, + "malformed " .. field .. " preflight" + ) + T.eq(f.quantity(), 3, "malformed gift destination prevents prefix") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, false, false }, { "wait", 5 }, { "set_flag", "mod:after" } }), + "native nickname flow admitted" + ) + local question = f.game.stack:top() + local Choice = require("src.ui.ChoiceBox") + for _ = 1, 2000 do + if getmetatable(f.game.stack:top()) == Choice then + break + end + tick(f, "a") + end + T.eq(getmetatable(f.game.stack:top()), Choice, "native question opens native choice") + tick(f, "a") + for _ = 1, 40 do + tick(f) + end + local naming = f.game.stack:top() + T.check( + naming ~= f.ow and naming ~= question and getmetatable(naming) ~= Choice, + "native naming screen opened" + ) + T.eq(type(naming.onDone), "function", "native naming completion exists") + question.onDone() + question.choice(false) + T.eq(f.game.stack:top(), naming, "old question callbacks cannot bypass naming") + T.eq(f.exports.result().done, 0, "old question cannot complete gift") + f.game.stack:pop() + naming.onDone("SCOUT") + naming.onDone("REPLAY") + T.eq(f.game.save.party[2].nickname, "SCOUT", "native naming callback commits once") + T.eq(f.exports.result().done, 0, "stale callback cannot skip later wait") + for _ = 1, 5 do + tick(f) + end + T.eq(f.exports.get("mod:after"), true, "native nickname flow resumes normally") +end +do + local f = fixture() + for i = 2, 6 do + f.game.save.party[i] = require("src.mods.Merge").deepCopy(f.game.save.party[1]) + end + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, true, false }, { "set_flag", "mod:boxed" } }), + "native box gift admitted" + ) + T.eq(#f.game.save.party, 6, "full party retained") + T.check(f.game.save.boxes[1][1], "actual native box receives gift") + T.eq(f.exports.result().done, 0, "native sent-to-box message awaited") + close(f) + T.eq(f.exports.get("mod:boxed"), true, "box message resumes script") +end +do + local f = fixture() + local party = f.game.save.party + f.game.mods = { events = f.loader.events } + f.loader.events:on("pokemon.before_give", function() + f.loader:_rollback("script_gifts") + end, 0, "probe") + T.check( + f.exports.queue({ { "give_pokemon", "MEW", 5, true, false } }), + "gift admission reaches native hook" + ) + T.eq(#party, 1, "owner retired in before_give cannot construct a gift") + T.eq(f.exports.result().done, 1, "mid-construction cancellation completes once") + T.eq(f.exports.result().success, false, "mid-construction cancellation reported") + T.eq(f.game.stack:top(), f.ow, "cancelled native gift opens no late UI") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script gifts") diff --git a/tests/engine/portable_script_movement.lua b/tests/engine/portable_script_movement.lua new file mode 100644 index 000000000..3cf4e8581 --- /dev/null +++ b/tests/engine/portable_script_movement.lua @@ -0,0 +1,189 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_motion") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function dispose(f) + f.restoreRenderer() +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " motion contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "face_player_dir", "up" }, { "move_npc", 99, "down" } }, + { { "move_player", "north" } }, + { { "move_player", "down", -1 } }, + { { "move_player", "down", 1025 } }, + { { "move_npc_to", 1, 0.5, 0 } }, + { { "place_npc", 1, 0, 0, "north" } }, + { { "place_npc", 1, 0, math.huge } }, + { { "face_object", 1, "down", { hold = -1 } } }, + { { "face_object", 1, "down", { other = 1 } } }, + { { "face", "down" } }, + { { "face_player" } }, + { { "face_npc" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid later command preflight") + T.eq(f.player.facing, "left", "invalid prefix does not turn player") + T.eq(#f.ow.scriptMoves, 0, "preflight creates no native track") + dispose(f) +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, kind in ipairs({ "player", "npc" }) do + local f = fixture(version) + local actor = kind == "player" and f.player or f.imported + local row = kind == "player" and { "move_player", "left", 2 } or { "move_npc", 1, "left", 2 } + local x = actor.cellX + T.check(f.exports.queue({ row, { "set_flag", "mod:after" } }), "two-step movement admitted") + local old = f.pending.callback + f:complete() + local overlay = {} + f.game.stack.states[2] = overlay + old() + old() + for _ = 1, 4 do + f:tick() + end + T.eq(actor.cellX, x - 1, "overlay pauses next owned step") + T.eq(f.exports.result().done, 0, "old callback cannot skip movement") + f.game.stack.states[2] = nil + f:drain() + T.eq(actor.cellX, x - 2, "two native steps completed once") + T.eq(f.exports.result().success, true, "native continuation completes") + T.eq(f.exports.get("mod:after"), true, "only finished walk resumes script") + dispose(f) + end +end +for _, action in ipairs({ "cancel", "rollback", "save", "map", "actor" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "move_npc", 1, "left", 2 }, { "set_flag", "mod:after" } }), + "cancellable track admitted" + ) + local callback = f.pending.callback + local foreign = { entity = f.imported, dir = "up", remaining = 1, onDone = function() end } + f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreign + if action == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_motion", "probe cancellation") + elseif action == "rollback" then + f.loader:_rollback("script_motion") + elseif action == "save" then + f.game.save = {} + elseif action == "map" then + f.ow.map = { id = f.home } + else + f.ow.npcs = {} + end + f:tick() + T.eq(f.exports.result().done, 1, "retired movement completes once") + T.eq(f.exports.result().success, false, "retirement fails script") + T.eq(#f.ow.scriptMoves, 1, "only owned track removed") + T.eq(f.ow.scriptMoves[1], foreign, "foreign actor track retained") + callback() + callback() + T.eq(f.exports.result().done, 1, "retained callback cannot replay") + T.eq(f.ow.runner, original, "original runner restored") + dispose(f) +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.queue({ { "move_player", "left", 2 }, { "set_flag", "mod:after" } }), + "synchronous native steps admitted" + ) + T.eq(f.exports.result().success, true, "synchronous callback completes without yielding forever") + T.eq(f.player.cellX, 5, "synchronous two-step position") + dispose(f) +end +for _, failure in ipairs({ "refuse", "throw", "unload" }) do + local f = fixture() + local native = f.ow.scriptMove + function f.ow:scriptMove(...) + native(self, ...) + if failure == "refuse" then + return false, "probe refusal" + elseif failure == "throw" then + error("probe native error") + else + f.loader:_rollback("script_motion") + end + end + T.check( + f.exports.queue({ { "move_player", "left", 2 }, { "set_flag", "mod:after" } }), + "native failure after admission" + ) + T.eq(f.exports.result().success, false, "native failure reported") + T.eq(#f.ow.scriptMoves, 0, "late native track cleaned") + T.eq(f.player.moving, false, "owned actor unlocked") + dispose(f) +end +do + local f = fixture() + f.ow.talkNpc = f.imported + T.check( + f.exports.queue({ + { "face", "left" }, + { "face_npc" }, + { "face_player" }, + { + "face_object", + 1, + "up", + { hold = 20 }, + }, + }), + "context facing admitted" + ) + T.eq(f.exports.result().success, true, "context facing completes") + T.eq(f.player.facing, "up", "player faces talking NPC") + T.eq(f.imported.facing, "up", "arbitrary object explicit direction") + T.eq(f.imported.timer, 20, "bounded facing hold carried") + dispose(f) +end +do + local f = fixture() + T.check(f.exports.queue({ { "move_player", "left" } }), "stalled native track admitted") + for _ = 1, 3601 do + f:tick() + end + T.eq(f.exports.result().success, false, "stalled track times out") + T.eq(#f.ow.scriptMoves, 0, "timeout removes owned track") + dispose(f) +end +-- Borrow the actual production movement queue and both walker updates. +local OW = require("src.world.OverworldController") +local Player = require("src.world.Player") +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + f.ow.scriptMove = OW.scriptMove + f.imported.frozen = true + f.player.turnTimer, f.player.progress = 0, 0 + T.check( + f.exports.queue({ { "move_player", "left", 2 }, { "move_npc", 1, "up", 2 }, { "set_flag", "mod:after" } }), + version .. " native queues admitted" + ) + for _ = 1, 100 do + OW.updateScriptMoves(f.ow) + Player.update(f.player) + f.imported:update(f.ow.map, f.ow.entities) + f:tick() + end + T.eq(f.player.cellX, 5, "actual player updater walks exactly two cells") + T.eq(f.imported.cellY, 4, "actual NPC updater walks exactly two cells") + T.eq(#f.ow.scriptMoves, 0, "actual native tracks released") + T.eq(f.exports.result().success, true, "actual native movement resumes script") + T.eq(f.exports.get("mod:after"), true, "native walk continuation runs once") + dispose(f) +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script movement") diff --git a/tests/engine/portable_script_shops.lua b/tests/engine/portable_script_shops.lua new file mode 100644 index 000000000..b900ac25f --- /dev/null +++ b/tests/engine/portable_script_shops.lua @@ -0,0 +1,329 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_shops") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v, native) + local f = Contract.fixture("tests/api3_contract", "gen2", v or "red") + f.game.save.money = 1000 + if native then + f.game.data.constants = f.game.data.constants or {} + f.game.data.constants.gen3MartText = { buy = "BUY", sell = "SELL", quit = "QUIT" } + end + return f +end +local function queue(f) + return f.exports.queue({ { "open_mart", "SHOP" }, { "set_flag", "mod:after" } }) +end +local function finishChoice(f) + f:tick("a") + for _ = 1, 20 do + f:tick() + end +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared shop contract: " .. tostring(why)) +end +for _, change in ipairs({ + "unknown", + "invalid_price", + "unknown_item", + "empty", + "sparse", + "wallet", + "inventory", + "overflow", +}) do + local f = fixture() + local ref = "SHOP" + if change == "unknown" then + ref = "UNKNOWN" + elseif change == "invalid_price" then + f.game.data.items.POTION.price = -1 + elseif change == "unknown_item" then + f.game.data.text_pointers.SHOP_HOME.SHOP.mart = { "UNKNOWN" } + elseif change == "empty" then + f.game.data.text_pointers.SHOP_HOME.SHOP.mart = {} + elseif change == "sparse" then + f.game.data.text_pointers.SHOP_HOME.SHOP.mart[3] = "POTION" + elseif change == "wallet" then + f.game.save.money = false + elseif change == "inventory" then + f.game.save.inventory.POTION = -1 + else + f.game.save.money = 9007199254740991 + end + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "open_mart", ref } }), + nil, + "invalid shop preflight: " .. change + ) + T.eq(f.exports.get("mod:prefix"), false, "invalid suffix prevents prefix") + T.eq(f.game.stack:top(), f.ow, "preflight opens no native UI") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local f = fixture(v) + T.check(queue(f), v .. " native classic shop admitted") + local root = f.game.stack:top() + f:tick("a") + local list = f.game.stack:top() + T.eq(getmetatable(list), require("src.ui.ListMenu"), "native buy list opened") + f:tick("a") + local quantity = f.game.stack:top() + local oldQuantity = quantity.onDone + T.eq(getmetatable(quantity), require("src.ui.QuantityBox"), "native quantity picker opened") + f:tick("a") + local choice = f.game.stack:top() + local oldChoice = choice.onChoose + finishChoice(f) + T.eq(f.quantity(), 4, "native bag receives purchase") + T.eq(f.game.save.money, 900, "native wallet charged once") + oldQuantity(99) + oldChoice(true) + T.eq(f.quantity(), 4, "old quantity/confirm callbacks cannot replay purchase") + T.eq(f.game.save.money, 900, "old callbacks cannot charge again") + T.eq(f.game.stack:top(), list, "nested callbacks retain native list") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "shop quit resumes script") + T.eq(f.exports.get("mod:after"), true, "script runs after native close") + root.onCancel() + oldQuantity(1) + oldChoice(true) + T.eq(f.game.stack:top(), f.ow, "finished UI cannot reopen a child") +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + T.check(queue(f), "native sale admitted") + f.game.stack:top().index = 2 + f:tick("a") + f:tick("a") + local quantity = f.game.stack:top() + quantity.qty = 3 + f:tick("a") + finishChoice(f) + T.eq(f.quantity(), 0, "native sale removes full item stack") + T.eq(f.game.save.money, 1150, "native sale credits half price") + T.eq(#f.game.save.bagOrder, 0, "native sale removes stale bag order entry") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "native sale closes normally") +end +for _, change in ipairs({ "rollback", "save", "data", "price", "inventory", "removed" }) do + local f = fixture() + T.check(queue(f), "shop before live change") + f:tick("a") + f:tick("a") + local quantity = f.game.stack:top() + local callback = quantity.onDone + local overlay = {} + f.game.stack:push(overlay) + if change == "rollback" then + f.loader:_rollback("script_shops") + elseif change == "price" then + f.game.data.items.POTION.price = 200 + elseif change == "inventory" then + f.game.save.inventory = { POTION = 3 } + elseif change == "removed" then + table.remove(f.game.stack.states, 2) + else + f.game[change] = {} + end + f.loader.hooks:call("input.step", function() end, f.game, 1 / 60) + T.eq(f.exports.result().success, false, "live context/item change cancels shop") + T.eq(f.game.stack:top(), overlay, "foreign overlay survives shop cancellation") + T.eq(#f.game.stack.states, 2, "only owned nested screens removed") + callback(1) + T.eq(#f.game.stack.states, 2, "stale quantity cannot open a confirmation") +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v, true) + T.check(queue(f), v .. " native Gen3 counter admitted") + local root = f.game.stack:top() + f:tick("a") + local counter = f.game.stack:top() + T.eq(getmetatable(counter), require("src.ui.Gen3ShopMenu"), "Gen3 buy counter selected") + f:tick("a") + local asking = counter.asking + local oldQuantity = asking and asking.onDone + T.check(asking, "native Gen3 inline quantity opened") + f:tick("a") + local box = f.game.stack:top() + local oldChoice = box.choice + for _ = 1, 2000 do + f:tick("a") + if f.quantity() == 4 then + break + end + end + T.eq(f.quantity(), 4, "native Gen3 confirmation purchases item") + T.eq(f.game.save.money, 900, "native Gen3 wallet charged once") + oldQuantity(5) + oldChoice(true) + T.eq(f.quantity(), 4, "old Gen3 inline callbacks cannot replay") + T.eq(f.game.save.money, 900, "old Gen3 callbacks cannot charge again") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "native Gen3 counter closes") +end +-- A retained confirmation is inert under a foreign overlay, then the native +-- answer still works after that overlay closes. +do + local f = fixture() + T.check(queue(f), "shop before foreign confirmation overlay") + f:tick("a") + f:tick("a") + f:tick("a") + local choice = f.game.stack:top() + local callback = choice.onChoose + local overlay = {} + f.game.stack:push(overlay) + callback(true) + T.eq(f.quantity(), 3, "foreign overlay blocks direct confirmation") + T.eq(f.game.save.money, 1000, "foreign overlay blocks debit") + f.game.stack:pop() + finishChoice(f) + T.eq(f.quantity(), 4, "legitimate answer still works after overlay") + f:close() + f:drain() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v, true) + T.check(queue(f), "Gen3 quantity before cancellation") + f:tick("a") + f:tick("a") + local counter = f.game.stack:top() + local old = counter.asking.onDone + f:tick("b") + old(1) + T.eq(f.game.stack:top(), counter, "cancelled inline quantity cannot create confirmation") + T.eq(f.quantity(), 3, "cancelled inline quantity cannot give item") + f:tick("a") + local current = counter.asking + old(1) + T.eq(counter.asking, current, "old inline callback cannot replace a new request") + f:close() + f:drain() +end +for _, v in ipairs({ "firered", "emerald" }) do + local f = fixture(v, true) + T.check(queue(f), "Gen3 native sale admitted") + f.game.stack:top().index = 2 + f:tick("a") + f:tick("a") + local quantity = f.game.stack:top() + if v == "firered" then + T.eq(getmetatable(quantity), require("src.ui.Gen3ShopMenu"), "FireRed native inline sale quantity") + T.check(quantity.asking, "FireRed sale quantity is active") + quantity.asking.qty = 2 + else + T.eq(getmetatable(quantity), require("src.ui.QuantityBox"), "Emerald native bag sale quantity") + quantity.qty = 2 + end + f:tick("a") + for _ = 1, 2000 do + f:tick("a") + if f.quantity() == 1 then + break + end + end + T.eq(f.quantity(), 1, "Gen3 native sale removes selected quantity") + T.eq(f.game.save.money, 1100, "Gen3 native sale credits wallet") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "Gen3 native sale returns to script") +end +for _, phase in ipairs({ "classic_quantity", "classic_choice", "gen3_quantity", "gen3_choice" }) do + local native = phase:find("gen3", 1, true) ~= nil + local f = fixture(native and "emerald" or "red", native) + T.check(queue(f), "native shop before phase unload") + f:tick("a") + f:tick("a") + local callbacks = {} + if native then + callbacks[#callbacks + 1] = f.game.stack:top().asking.onDone + else + callbacks[#callbacks + 1] = f.game.stack:top().onDone + end + if phase:find("choice", 1, true) then + f:tick("a") + local state = f.game.stack:top() + callbacks[#callbacks + 1] = native and state.choice or state.onChoose + end + local overlay = {} + f.game.stack:push(overlay) + f.loader:_rollback("script_shops") + for _, fn in ipairs(callbacks) do + fn(1) + fn(true) + end + T.eq(f.game.stack:top(), overlay, "unload keeps foreign overlay") + T.eq(#f.game.stack.states, 2, "unload removes exact native shop screens") + T.eq(f.quantity(), 3, "retired purchase callback cannot grant item") + T.eq(f.game.save.money, 1000, "retired purchase callback cannot debit wallet") +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + f.game.data.maps[f.dest].label = "SHOP_DEST" + f.game.data.text_pointers.SHOP_DEST = { DEST_SHOP = { mart = { "POTION" } } } + T.check( + f.exports.queue({ { "warp", f.dest, 1, 1 }, { "open_mart", "DEST_SHOP" }, { "set_flag", "mod:after" } }), + "destination shop preflight" + ) + f:arrive() + f:complete() + f:tick() + T.check(f.game.stack:top() ~= f.ow, "destination native shop opens after arrival") + f:close() + f:drain() + T.eq(f.exports.result().success, true, "destination native shop resumes program") + f = fixture(v) + f.game.data.maps[f.dest].label = "SHOP_DEST" + f.game.data.text_pointers.SHOP_DEST = { DEST_SHOP = { mart = { "UNKNOWN" } } } + T.eq( + f.exports.queue({ { "warp", f.dest, 1, 1 }, { "open_mart", "DEST_SHOP" } }), + nil, + "invalid destination stock refuses whole script" + ) + T.eq(#f.trace, 0, "invalid future stock prevents warp") +end +for _, change in ipairs({ "wallet", "inventory", "stock" }) do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "open_mart", "SHOP" } }), "deferred shop admitted") + if change == "wallet" then + f.game.save.money = false + elseif change == "inventory" then + f.game.save.inventory = false + else + f.game.data.items.POTION.price = -1 + end + f:drain() + T.eq(f.exports.result().success, false, "live shop stores revalidated") + T.eq(f.game.stack:top(), f.ow, "bad live data opens no native shop") +end +do + local f = fixture() + local overlay = {} + local Screens = require("src.ui.Screens") + f.game.data.screens = { + ShopMenu = { + new = function(game, stock, onQuit) + f.loader:_rollback("script_shops") + game.stack:push(overlay) + return require("src.ui.ShopMenu").new(game, stock, onQuit) + end, + }, + } + Screens.invalidate() + T.check(queue(f), "shop admitted before constructor hook retires owner") + T.eq(f.exports.result().success, false, "constructor retirement cancels program") + T.eq(f.game.stack:top(), overlay, "constructor retirement preserves foreign overlay") + T.eq(#f.game.stack.states, 2, "late native root removed after constructor returns") + Screens.invalidate() +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script shops") diff --git a/tests/engine/portable_script_state.lua b/tests/engine/portable_script_state.lua new file mode 100644 index 000000000..4dd84c487 --- /dev/null +++ b/tests/engine/portable_script_state.lua @@ -0,0 +1,109 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_state") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared state actions: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "give_money", 1 }, { "take_item", "UNKNOWN" } }, + { { "jump", "end" }, { "mark_seen", "UNKNOWN" } }, + { { "give_money", -1 } }, + { { "take_money", 0.5 } }, + { { "give_money", math.huge } }, + { { "check_money", "10" } }, + { { "take_coins", false } }, + { { "take_item", "POTION", 0 } }, + { { "take_item", "POTION", 1000 } }, + { { "check_dex_owned", -1 } }, + { { "heal_party", true } }, + { { "check_item", {} } }, +}) do + local f = fixture() + local runner = f.ow.runner + T.eq(f.exports.queue(rows), nil, "whole invalid program declined") + T.eq(f.wallet(), 100, "money prefix not executed") + T.eq(f.quantity(), 3, "inventory untouched") + T.eq(f.ow.runner, runner, "no runner installed on refusal") + T.eq(f.exports.result().done, 0, "no completion on refusal") +end +for _, store in ipairs({ "money", "coins", "inventory", "bagOrder", "pokedex", "party" }) do + local f = fixture() + f.game.save[store] = false + local row = ({ + money = { "give_money", 1 }, + coins = { "take_coins", 1 }, + inventory = { "take_item", "POTION" }, + bagOrder = { "take_item", "POTION" }, + pokedex = { "mark_seen", "MEW" }, + party = { "heal_party" }, + })[store] + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, row }), nil, "malformed " .. store .. " declined") + T.eq(f.exports.get("mod:prefix"), false, "store failure prevents prefix") + T.eq(f.game.save[store], false, "malformed store not overwritten") +end +do + local f = fixture() + f.game.save.party[1].stats = nil + T.eq( + f.exports.queue({ { "give_money", 1 }, { "heal_party" } }), + nil, + "malformed party record refused up front" + ) + T.eq(f.wallet(), 100, "party refusal preserves wallet") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(version) + f.game.save.money = 999990 + T.check( + f.exports.queue({ { "give_money", 50 }, { "take_coins", 999 }, { "take_item", "POTION", 999 } }), + "bounded state operations admitted" + ) + f:drain() + T.eq(f.wallet(), version == "red" and 1000040 or 999999, "native generation wallet cap") + T.eq(f.game.save.coins, 0, "coin subtraction saturates at zero") + T.eq(f.quantity(), 0, "item subtraction removes available quantity only") + T.eq(#f.game.save.bagOrder, 0, "native bag order entry removed") +end +do + local f = fixture() + f.game.save.money = 9007199254740991 + T.eq(f.exports.queue({ { "set_flag", "mod:prefix" }, { "give_money", 1 } }), nil, "wallet overflow refused") + T.eq(f.exports.get("mod:prefix"), false, "overflow refusal prevents prefix") + T.eq(f.game.save.money, 9007199254740991, "overflow cannot round native wallet") +end +do + local f = fixture() + T.check( + f.exports.queue({ { "check_dex_owned" }, { "jump_if_false", "end" }, { "set_flag", "mod:owned" } }), + "default dex threshold admitted" + ) + f:drain() + T.eq(f.exports.get("mod:owned"), true, "default dex threshold is one") +end +do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "take_item", "POTION", 1 } }), "state action waits") + f.game.save.inventory = false + f:drain() + T.eq(f.exports.result().success, false, "changed live store fails safely") + T.eq(f.game.save.inventory, false, "changed malformed store not overwritten") +end +do + local f = fixture() + local rows = { { "wait", 1 }, { "give_money", 10 } } + T.check(f.exports.queue(rows), "copy program admitted") + rows[2][2] = 100 + f:drain() + T.eq(f.wallet(), 110, "accepted scalar arguments detached") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script state actions") diff --git a/tests/engine/portable_script_trainers.lua b/tests/engine/portable_script_trainers.lua new file mode 100644 index 000000000..022ecd236 --- /dev/null +++ b/tests/engine/portable_script_trainers.lua @@ -0,0 +1,198 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_trainers") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared trainer contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 999, 1 } }, + { { "start_battle", "trainer", {}, 1 } }, + { { "start_battle", "trainer", "PROBE", 0 } }, + { { "start_battle", "trainer", "PROBE", 256 } }, + { { "start_battle", "trainer", "PROBE", 2 } }, + { { "start_battle", "trainer", "PROBE", 1, {} } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid trainer preflight") + T.eq(f.constructed, 0, "invalid trainer does not construct") + T.eq(f.exports.get("mod:prefix"), false, "invalid suffix prevents prefix") + f.restore() +end +for _, change in ipairs({ "species", "level", "empty", "oversized", "ambiguous" }) do + local f = fixture("gold") + local row = f.game.data.trainers.PROBE + if change == "species" then + row.parties[1][1].species = "UNKNOWN" + elseif change == "level" then + row.parties[1][1].level = 101 + elseif change == "empty" then + row.parties[1] = {} + elseif change == "oversized" then + for i = 2, 7 do + row.parties[1][i] = row.parties[1][1] + end + else + f.game.data.trainers.OTHER = { id = "OTHER", index = 7, parties = row.parties } + end + T.eq( + f.exports.queue({ { "set_flag", "mod:prefix" }, { "start_battle", "trainer", 7, 1 } }), + nil, + "invalid native trainer data: " .. change + ) + T.eq(f.constructed, 0, "invalid data never constructs") + T.eq(f.exports.get("mod:prefix"), false, "invalid data never runs prefix") + f.restore() +end +do + local f = fixture("gold") + f.game.data.trainers.ALIAS = require("src.mods.Merge").deepCopy(f.game.data.trainers.PROBE) + T.check(f.exports.queue({ { "start_battle", "trainer", 7, 1 } }), "canonical native alias resolves once") + T.eq(f.battle.class, "PROBE", "native class identity selected deterministically") + f:complete("win") + f:drain() + f.restore() +end +for _, change in ipairs({ "missing_order", "ambiguous_order", "missing_base", "invalid_flags", "member" }) do + local f = fixture("firered") + local row = { "start_battle", "trainer", 7, 1 } + if change == "missing_order" then + f.game.data.constants.trainerOrder = {} + elseif change == "ambiguous_order" then + f.game.data.constants.trainerOrder[8] = "PROBE" + elseif change == "missing_base" then + f.game.data.constants.gen3TrainerFlagBase = nil + elseif change == "invalid_flags" then + f.game.save.flags = false + else + row[4] = 2 + end + T.eq(f.exports.queue({ row }), nil, "unrepresentable Gen3 trainer refused: " .. change) + T.eq(f.constructed, 0, "Gen3 refusal has no constructor") + f.restore() +end +for _, change in ipairs({ "rollback", "save", "data", "player", "cancel" }) do + local f = fixture("firered") + T.check( + f.exports.queue({ { "start_battle", "trainer", 7, 1 }, { "set_flag", "mod:after" } }), + "trainer admitted before retirement" + ) + local callback = f.finish + if change == "rollback" then + f.loader:_rollback("script_trainers") + elseif change == "player" then + f.ow.player = {} + elseif change == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_trainers") + else + f.game[change] = {} + end + f:tick() + T.eq(f.game.stack:top(), f.battle, "trainer native screen survives script retirement") + f:complete("win") + callback("lose") + T.eq(f.exports.result().success, false, "retired trainer cannot continue") + T.eq( + f.after, + (change == "rollback" or change == "cancel") and 1 or 0, + "native cleanup only on same session" + ) + T.eq(f.exports.result().done, 1, "late trainer callback completes once") + if change == "rollback" or change == "cancel" then + T.eq( + f.game.save.flags.FLAG_G3_0507, + true, + "native trainer victory flag survives originating mod retirement" + ) + end + f.restore() +end +do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 1 }, { "start_battle", "trainer", "PROBE", 1 } }), + "deferred trainer preflight" + ) + f.game.data.trainers.PROBE.parties[1][1].species = "UNKNOWN" + f:drain() + T.eq(f.exports.result().success, false, "trainer data revalidated at dispatch") + T.eq(f.constructed, 0, "changed live roster cannot construct") + f.restore() +end +for _, failure in ipairs({ "constructError", "pushFailure", "cleanupError", "constructUnload" }) do + local f = fixture() + if failure == "constructUnload" then + local native = package.loaded["src.battle.BattleState"].newTrainer + package.loaded["src.battle.BattleState"].newTrainer = function(...) + local result = native(...) + f.loader:_rollback("script_trainers") + return result + end + else + f[failure] = true + end + T.check( + f.exports.queue({ { "start_battle", "trainer", "PROBE", 1 }, { "set_flag", "mod:after" } }), + "trainer native failure admitted" + ) + if failure == "cleanupError" then + f:complete("win") + end + T.eq(f.exports.result().success, false, "trainer native failure terminates") + T.eq(f.exports.get("mod:after"), false, "trainer native failure suppresses suffix") + if failure == "constructUnload" then + T.eq(f.pushed, 0, "retired constructor cannot push trainer battle") + end + f.restore() +end +for _, v in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + for _, outcome in ipairs({ "win", "lose" }) do + local f = fixture(v) + f.restore() + local data = T.fixtures.fresh() + data.trainers.PROBE = { + id = "PROBE", + index = 7, + name = "PROBE", + baseMoney = 10, + parties = { { { species = "FIXMON_B", level = 5 } } }, + } + data.constants.gen = Version.generation(v) + data.constants.trainerOrder = { [7] = "PROBE" } + data.constants.gen3TrainerFlagBase = 0x500 + f.game.data = data + f.game.save = require("src.core.SaveData").newGame() + f.game.session = f.game.save + f.game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 15) } + require("src.render.Font").load(data) + T.check( + f.exports.queue({ + { "start_battle", "trainer", f.trainerRef, 1 }, + { "check_battle_result", outcome }, + { "jump_if_false", "end" }, + { "set_flag", "mod:after" }, + }), + v .. " native trainer admitted" + ) + T.eq(f.battle.kind, "trainer", "actual native trainer kind") + T.eq(f.battle.enemy.mon.species, "FIXMON_B", "actual native trainer party built") + T.eq(f.battle.partyIndex, 1, "actual native member selected") + f:complete(outcome) + f:drain() + T.eq(f.after, 1, "actual trainer cleanup retained") + T.eq(f.exports.get("mod:after"), true, "native trainer outcome branch") + if f.gen == 3 then + T.eq(f.game.save.flags.FLAG_G3_0507 == true, outcome == "win", "native trainer flag tracks win only") + end + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script trainers") diff --git a/tests/engine/portable_script_travel.lua b/tests/engine/portable_script_travel.lua new file mode 100644 index 000000000..16397e435 --- /dev/null +++ b/tests/engine/portable_script_travel.lua @@ -0,0 +1,232 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_travel") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(v) + return Contract.fixture("tests/api3_contract", "gen2", v or "red") +end +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared travel: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "face_player_dir", "up" }, { "warp", "UNKNOWN", 0, 0 } }, + { { "warp", "DEST", 0.5, 0 } }, + { { "warp", "DEST", -1, 0 } }, + { { "warp", "DEST", 16, 0 } }, + { { "warp", "DEST", 0, 0, "north" } }, + { { "warp", "DEST", 0, 0, "down", {} } }, + { { "warp", "DEST", 0, 0 }, { "move_npc", 99, "down" } }, + { { "warp", "DEST", 0, 0 }, { "replace_block", 999, 0, 1 } }, + { { "warp", "DEST", 0, 0 }, { "face", "down" } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid destination/later row preflight") + T.eq(#f.trace, 0, "invalid suffix prevents native warp") + T.eq(f.player.facing, "left", "invalid list has no prefix effect") + f.restoreRenderer() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + T.check(f.exports.queue({ { "remember_outdoor", f.home, 1, 2 } }), "outdoor memory admitted") + T.same(f.game.save.lastOutdoor, { id = f.home, x = 1, y = 2 }, "canonical remembered destination") + if f.gen == 3 then + T.same(f.game.save.escapeWarp, { map = f.home, warpId = 255, x = 1, y = 2 }, "native Gen3 escape memory") + end + f.restoreRenderer() +end +for _, change in ipairs({ "cancel", "rollback", "save", "data", "stack", "player", "version" }) do + local f = fixture() + local original = f.ow.runner + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), + "cancellable warp admitted" + ) + local callback = f.callback + if change == "cancel" then + require("src.mods.PortableScripts").cancelAll(f.loader, "script_travel") + elseif change == "rollback" then + f.loader:_rollback("script_travel") + elseif change == "version" then + Version.set("blue") + elseif change == "player" then + f.ow.player = { cellX = 2, cellY = 2 } + else + f.game[change] = {} + end + local ok = pcall(function() + f:tick() + end) + T.check(ok, "changed context checked without crashing") + T.eq(f.exports.result().done, 1, "changed context completes once") + T.eq(f.exports.result().success, false, "changed warp fails script") + callback() + callback() + T.eq(f.exports.result().done, 1, "stale warp callback cannot replay") + T.eq(f.ow.runner, original, "source idle runner restored") + f.restoreRenderer() +end +do + local f = fixture() + T.check(f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), "warp before overlay") + local old = f.callback + f:arrive() + f:completeWarp() + local replacement = f.ow.runner + local overlay = {} + f.game.stack.states[2] = overlay + old() + old() + for _ = 1, 4 do + f:tick() + end + T.eq(f.exports.get("mod:after"), false, "arrival callback cannot run beneath foreign UI") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.result().success, true, "arrival resumes in foreground") + T.check(f.ow.runner ~= replacement and not f.ow.runner:isRunning(), "new destination idle runner restored") + f.restoreRenderer() +end +for _, failure in ipairs({ "throw", "refuse", "false_arrival", "wrong_map" }) do + local f = fixture() + if failure == "throw" or failure == "refuse" then + f.failure = failure + end + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), + "native warp admitted before result" + ) + if failure == "false_arrival" then + f:completeWarp() + elseif failure == "wrong_map" then + f:arrive(f.home) + f:completeWarp() + end + f:tick() + T.eq(f.exports.result().success, false, "native failure cannot complete script") + T.eq(f.exports.get("mod:after"), false, "native failure cannot burn later flag") + f.restoreRenderer() +end +do + local f = fixture() + f.synchronous = true + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "set_flag", "mod:after" } }), + "synchronous arrival admitted" + ) + T.eq(f.exports.result().success, true, "synchronous warp continuation does not hang") + f.restoreRenderer() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + function f.ow:startWarpTo(id, x, y, facing, callback) + self.player.cellX, self.player.cellY, self.player.facing = x, y, facing + callback() + end + T.check( + f.exports.queue({ { "warp", f.home, 1, 1 }, { "set_flag", "mod:after" } }), + "same-map reused runtime admitted" + ) + T.eq(f.exports.result().success, true, "same-map warp can retain native map identity") + T.eq(f.exports.get("mod:after"), true, "same-map native callback resumes once") + f.restoreRenderer() +end +do + local f = fixture() + T.check(f.exports.queue({ { "warp", f.dest, 2, 2 } }), "stalled warp admitted") + for _ = 1, 3601 do + f:tick() + end + T.eq(f.exports.result().success, false, "warp deadline expires") + f:arrive() + f:completeWarp() + T.eq(f.ow.transitioning, false, "engine can still finish timed-out warp") + T.eq(f.exports.result().done, 1, "timed-out script cannot resume") + f.restoreRenderer() +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + local f = fixture(v) + f.tiles.blocks[8] = {} + local arrive = f.arrive + function f:arrive(id) + arrive(self, id) + self.ow.map.renderer = { rebuild = function() end } + end + function f.ow:replaceBlock(x, y, block) + self.map:setBlock(x, y, block) + self.map.renderer:rebuild() + end + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2 }, { "replace_block", 0, 0, 7 }, { "set_flag", "mod:after" } }), + "future destination block preflight" + ) + f:drain() + T.eq(f.exports.result().success, true, "block edit executes on arrived map") + T.eq(f.ow.map:blockAt(0, 0), 7, "native block patch is on destination") + f.restoreRenderer() +end +-- Exercise the actual native transition; only destination materialization is a fixture. +local OW = require("src.world.OverworldController") +local Transition = require("src.render.Transition") +local StateStack = require("src.core.StateStack") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.startWarpTo, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.startWarpTo, i, game) + return function() + debug.setupvalue(OW.startWarpTo, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, v in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, cancel in ipairs({ false, true }) do + local f = fixture(v) + setmetatable(f.game.stack, { __index = StateStack }) + f.ow.startWarpTo = OW.startWarpTo + local restore = bind(f.game) + function f.ow:frlgBehaviours() + return false + end + function f.ow:noteGen2Spawn(from) + self.notedFrom = from + end + function f.ow:setMap(id, x, y, facing, opts) + f.destination = { id, x, y, facing } + f:arrive() + end + T.check( + f.exports.queue({ { "warp", f.dest, 2, 2, "right" }, { "set_flag", "mod:after" } }), + "actual transition admitted" + ) + local state = f.game.stack:top() + T.eq(getmetatable(state), Transition, "native warp transition retained") + if cancel then + f.loader:_rollback("script_travel") + T.eq(f.game.stack:top(), state, "unload retains engine-owned warp transition") + end + for _ = 1, state.frames + state.framesIn + 3 do + if f.game.stack:top() == state then + state:update(1 / 60) + end + f:tick() + end + T.eq(f.ow.map.id, f.dest, "actual transition arrives") + T.eq(f.ow.transitioning, false, "native warp releases its lock even after unload") + T.eq(f.exports.result().success, not cancel, "only live script continues") + T.eq(f.exports.get("mod:after"), not cancel, "no late effects after unload") + restore() + f.restoreRenderer() + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script travel") diff --git a/tests/engine/portable_script_world.lua b/tests/engine/portable_script_world.lua new file mode 100644 index 000000000..0faea304c --- /dev/null +++ b/tests/engine/portable_script_world.lua @@ -0,0 +1,117 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.script_world") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared world script contract: " .. tostring(why)) +end +for _, rows in ipairs({ + { { "hide_object", "HOME", "NPC" }, { "show_object", "HOME", "MISSING" } }, + { { "hide_object", "HOME", "NPC" }, { "replace_block", 99, 0, 8 } }, + { { "replace_block", 0, 0, 9 } }, + { { "replace_block", -1, 0, 8 } }, + { { "replace_block", 0, 0, 256 } }, + { { "show_object", "HOME", false } }, + { { "show_object", "MISSING", "NPC" } }, + { { "hide_object", "HOME", "NPC", true } }, +}) do + local f = fixture() + T.eq(f.exports.queue(rows), nil, "invalid list refused before prefix") + T.eq(f.visible.NPC, true, "object unchanged after refusal") + T.eq(f.rebuilds + f.reloads + f.syncs, 0, "no native effects on refusal") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, target in ipairs({ "object", "block" }) do + local f = fixture(version) + local row = target == "object" and { "hide_object", f.home, "NPC" } or { "replace_block", 0, 0, 8 } + T.check(f.exports.queue({ { "wait", 1 }, row, { "set_flag", "mod:after" } }), "world script queued") + local overlay = {} + f.game.stack.states[2] = overlay + for _ = 1, 5 do + f:tick() + end + T.eq(f.exports.result().done, 0, "foreign overlay pauses native edit") + T.eq(#f.exports.result().events, 0, "paused edit emits nothing") + f.game.stack.states[2] = nil + f:drain() + T.eq(f.exports.result().success, true, "edit resumes after overlay") + T.eq(f.exports.get("mod:after"), true, "script continues after native edit") + T.eq(#f.exports.result().events, 1, "single native event") + end +end +for _, field in ipairs({ "objectToggles", "flags" }) do + local f = fixture() + T.check( + f.exports.queue({ { "wait", 1 }, { "hide_object", f.home, "NPC" } }), + "deferred object edit admitted" + ) + f.game.save[field] = false + f:drain() + T.eq(f.exports.result().success, false, "live malformed store refuses edit") + T.eq(f.visible.NPC, true, "malformed live store has no write") +end +do + local f = fixture() + T.check(f.exports.queue({ { "wait", 1 }, { "replace_block", 0, 0, 8 } }), "deferred block edit admitted") + f.ow.map.renderer = nil + f:drain() + T.eq(f.exports.result().success, false, "live missing renderer refuses edit") + T.eq(f.rebuilds, 0, "no partial replacement") +end +for _, change in ipairs({ "rollback", "save", "map", "runner" }) do + local f = fixture() + local original = f.ow.runner + T.check(f.exports.queue({ { "wait", 1 }, { "hide_object", f.home, "NPC" } }), "owned edit admitted") + local replacement + if change == "rollback" then + f.loader:_rollback("script_world") + elseif change == "save" then + f.game.save = {} + elseif change == "map" then + f.ow.map = { id = f.home } + else + replacement = { update = function() end } + f.ow.runner = replacement + end + f:tick() + T.eq(f.exports.result().done, 1, "changed context cancels once") + T.eq(f.exports.result().success, false, "changed context fails") + T.eq(f.visible.NPC, true, "cancelled edit cannot run") + T.eq(f.ow.runner, replacement or original, "foreign or original runner preserved") +end +do + local f = fixture() + local original = f.ow.runner + f.loader.events:on("world.object_toggled", function() + f.loader:_rollback("script_world") + end, 0, "probe") + T.check( + f.exports.queue({ { "hide_object", f.home, "NPC" }, { "set_flag", "mod:after" } }), + "native event retires owner" + ) + T.eq(f.exports.result().done, 1, "event cancellation completes once") + T.eq(f.exports.result().success, false, "event cancellation reported") + T.check(f.ow.runner ~= original and not f.ow.runner:isRunning(), "reload's new idle runner retained") + T.eq(f.exports.get("mod:after"), false, "retired owner cannot execute following flag") +end +do + local f = fixture() + local old = f.ow.runner + T.check( + f.exports.queue({ { "hide_object", f.home, "NPC" }, { "wait", 2 }, { "set_flag", "mod:after" } }), + "native reload continuation admitted" + ) + f:drain() + T.eq(f.exports.result().success, true, "continuation survives native map and runner replacement") + T.check(f.ow.runner ~= old and not f.ow.runner:isRunning(), "destination idle runner restored") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("Portable script world edits") diff --git a/tests/engine/portable_world_scripts.lua b/tests/engine/portable_world_scripts.lua new file mode 100644 index 000000000..ffe1fd55d --- /dev/null +++ b/tests/engine/portable_world_scripts.lua @@ -0,0 +1,857 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Program = require("src.mods.ScriptProgram") +local Runtime = require("src.mods.Runtime") +local Runner = require("src.script.ScriptRunner") +local WorldAPI = require("src.world.WorldAPI") +local GameVersion = require("src.core.GameVersion") +local Font = require("src.render.Font") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Json = require("src.link.Json") +local originalVersion = GameVersion.get() +local function boot(version) + GameVersion.set(version) + local data, files = T.fixtures.fresh(), {} + for _, id in ipairs({ "probe", "other", "legacy" }) do + files["mods/" .. id .. "/manifest.json"] = Json.encode({ + id = id, + name = id, + version = "1.0.0", + api = 2, + entry = "main.lua", + script_format = id ~= "legacy" and "unified" or nil, + content_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified" + or nil, + callback_format = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and id ~= "legacy" and "unified" + or nil, + description = os.getenv("PORTABLE_SCRIPT_ALL_FORMATS") == "1" and "all-formats" or "", + }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + if mod.id ~= "legacy" then + assert(mod.scriptFormat == "unified") + if mod.manifest.description == "all-formats" then + assert(mod.contentFormat == "unified") + assert(mod.callbackFormat == "unified") + end + local ok, why = mod.world:queueScript({ { "wait", 1 } }) + assert(ok == nil and type(why) == "string") + else assert(mod.scriptFormat == "native") end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other", "legacy" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" or p == "mods/legacy" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + T.eq(package.loaded["src.core.Game"], nil, "early portable request does not bootstrap Game") + Font.load(data) + local game = { + data = data, + save = SaveData.newGame(), + stack = setmetatable({ states = {} }, { __index = StateStack }), + } + local ow = { + isOverworld = true, + map = { id = "FIX_TOWN", def = data.maps.FIX_TOWN }, + player = { cellX = 4, cellY = 5, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + parallelRunners = {}, + } + game.overworld, ow.game = ow, game + ow.runner = Runner.new(game, ow) + function ow:update() + self.runner:update() + end + game.stack.states = { ow } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + loader.game = game + return loader.apis.probe.world, game, ow, loader, files +end +local function tick(game, key) + game.input.wasPressed = function(_, k) + return k == key + end + Runtime.call("input.step", function() end, game, 1 / 60) + game.stack:update(1 / 60) + game.input.wasPressed = function() + return false + end +end +local function untilState(game, predicate) + for _ = 1, 2000 do + if predicate() then + return + end + tick(game) + end + error("native UI did not reach requested state") +end +local function finishText(game) + local box = game.stack:top() + untilState(game, function() + return box.done or box.waiting and (box.preWait or 0) <= 0 + end) + tick(game, "a") +end +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local before, calls, result = ow.runner, 0, nil + T.check( + world:queueScript({ { "wait", 2 }, { "text", "First" }, { "end" }, { "text", "Unreachable" } }, { + onDone = function(ok, why) + calls = calls + 1 + result = { ok, why } + end, + }), + version .. " program admitted" + ) + T.neq(ow.runner, before, "isolated native runner installed") + T.eq(ow.runner:isRunning(), true, "runner locks foreground input") + tick(game) + T.eq(game.stack:top(), ow, "wait does not end early") + tick(game) + T.neq(game.stack:top(), ow, "native text appears after wait") + T.eq(calls, 0, "completion awaits text") + finishText(game) + T.same(result, { true }, "success completion") + T.eq(calls, 1, "completion once") + T.eq(ow.runner, before, "idle native runner restored") + T.eq(game.stack:top(), ow, "script text closed") + + local Choice = require("src.ui.ChoiceBox") + for _, yes in ipairs({ true, false }) do + local lines = {} + T.check( + world:queueScript({ + { "ask", "Question?" }, + { "jump_if_false", "no" }, + { "text", "YES" }, + { "jump", "end" }, + { "label", "no" }, + { "text", "NO" }, + }, { + onDone = function(ok) + lines[#lines + 1] = ok + end, + }), + "question script admitted" + ) + untilState(game, function() + return getmetatable(game.stack:top()) == Choice + end) + local choice = game.stack:top() + tick(game, yes and "a" or "b") + T.eq(choice.pending, yes, "native answer pending") + T.eq(#lines, 0, "answer hold preserved") + untilState(game, function() + return game.stack:top() ~= choice + end) + T.same( + game.stack:top().pages, + require("src.render.TextBox").paginate( + yes and "YES" or "NO", + game.stack:top().maxCols, + game.stack:top().letterSpacing, + game.stack:top().maxPixels, + true + ), + "correct branch text" + ) + finishText(game) + T.same(lines, { true }, "question branch completes") + end + local trace = {} + T.check( + world:queueScript({ + { "choice", { "ONE", "TWO", "THREE" }, { default = 2, cancel = 3 } }, + { "jump_if_false", 5 }, + { "text", "WRONG" }, + { "end" }, + { "text", "RIGHT" }, + }, { + onDone = function(ok) + trace[#trace + 1] = ok + end, + }), + "multiple choice admitted" + ) + T.eq(game.stack:top().index, 2, "native default selection") + tick(game, "b") + finishText(game) + T.same(trace, { true }, "native B choice follows false branch") + T.eq(loader.apis.legacy.world.queueScript, WorldAPI.queueScript, "legacy method unchanged") +end + +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(world, game, rows, options, label) + local unchanged = freeze(game) + local ok, why = world:queueScript(rows, options) + T.eq(ok, nil, label) + T.eq(type(why), "string", label .. " has reason") + unchanged(label .. " is observational") +end +local badPrograms = { + false, + 1, + "text", + function() end, + { [1] = { "wait", 1 }, [3] = { "end" } }, + { [1] = { "wait", 1 }, extra = true }, + setmetatable({ { "end" } }, {}), + { false }, + { {} }, + { { "wait" } }, + { { "wait", 1, 2 } }, + { { "wait", -1 } }, + { { "wait", 1.5 } }, + { { "wait", 0 / 0 } }, + { { "wait", math.huge } }, + { { "wait", 36001 } }, + { { "end", false } }, + { { "native_mutate", {} } }, + { { "show_text", {} } }, + { { "show_text", string.rep("x", 4097) } }, + { { "text", "Valid" }, { "give_item", "POTION" } }, + { { "text", "Valid" }, { "jump", "missing" } }, + { { "label", "same" }, { "label", "same" } }, + { { "label", "end" } }, + { { "label", "bad\nname" } }, + { { "jump", 0 } }, + { { "jump", 99 } }, + { { "jump", false } }, + { { "choice", {} } }, + { { "choice", { "A", "B" }, { cancel = 3 } } }, + { { "choice", { "A" }, { default = 0 } } }, + { { "choice", { "A" }, { custom = true } } }, + { { "choice", { "A", function() end } } }, + { { "choice", { "" } } }, + { { "choice", { "A" }, setmetatable({}, {}) } }, + { setmetatable({ "wait", 1 }, {}) }, +} +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local callbacks = 0 + for _, rows in ipairs(badPrograms) do + refusal(world, game, rows, { + onDone = function() + callbacks = callbacks + 1 + end, + }, "invalid whole program") + end + for _, options in ipairs({ + false, + 1, + "options", + { onDone = true }, + { onDone = function() end, runner = {} }, + setmetatable({}, {}), + }) do + refusal(world, game, { { "wait", 1 } }, options, "invalid options") + end + T.eq(callbacks, 0, "rejected lists never complete or execute a prefix") + for _, key in ipairs({ + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + }) do + ow[key] = true + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns world") + ow[key] = nil + end + for _, key in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[key] = true + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns player") + ow.player[key] = nil + end + for _, key in ipairs({ "pendingScripts", "scriptMoves", "parallelRunners", "parallelQueue" }) do + ow[key] = { {} } + refusal(world, game, { { "wait", 1 } }, nil, key .. " owns script scheduling") + ow[key] = {} + end + game.linkSession = {} + refusal(world, game, { { "wait", 1 } }, nil, "link session busy") + game.linkSession = nil + game.linkNet = { closed = false } + refusal(world, game, { { "wait", 1 } }, nil, "link transport busy") + game.linkNet = nil + game.stack:push({}) + refusal(world, game, { { "wait", 1 } }, nil, "covered world busy") + game.stack:pop() + ow.runner:run({ { "wait", 9 } }) + refusal(world, game, { { "wait", 1 } }, nil, "native script busy") + for _ = 1, 9 do + tick(game) + end + T.check(world:queueScript({ { "wait", 1 } }), "new script after native completion") + tick(game) + T.eq(loader.apis.other.world:cancelScript(), false, "another mod cannot cancel a program") +end +-- Limits, data copying and compiler immutability, including a cyclic payload. +do + local world, game, ow = boot("crystal") + local over = {} + for i = 1, 1025 do + over[i] = { "end" } + end + refusal(world, game, over, nil, "row bound") + over = {} + for i = 1, 33 do + over[i] = { "text", string.rep("x", 4096) } + end + refusal(world, game, over, nil, "total text bound") + local cycle = {} + cycle[1] = cycle + refusal(world, game, cycle, nil, "cyclic command denied") + local rows = { { "wait", 2 }, { "choice", { "ONE", "TWO" }, { default = 2 } } } + local unchanged = freeze(rows) + local compiled = Program.compile(rows) + unchanged("compiler leaves caller rows intact") + T.neq(compiled[2][2], rows[2][2], "compiler copies nested labels") + T.check(world:queueScript(rows), "copied program accepted") + rows[2][1], rows[2][2][2], rows[2][3].default = "invalid", "MUTATED", 1 + tick(game) + tick(game) + T.eq(game.stack:top().index, 2, "choice options captured before caller edit") + T.eq(game.stack:top().items[2].label, "TWO", "choice labels copied before execution") + world:cancelScript() +end +-- Session changes cancel even while an old Game is no longer being stepped. +for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "heartgold", "black" }) do + for _, change in ipairs({ + "save", + "data", + "stack", + "states", + "map", + "mapId", + "player", + "position", + "runner", + "game", + "version", + "removed", + "transition", + }) do + local world, game, ow, loader = boot(version) + local originalRunner, calls, result = ow.runner, 0 + T.check( + world:queueScript({ { "wait", 30 }, { "text", "Must not run" } }, { + onDone = function(ok, why) + calls = calls + 1 + result = { ok, why } + end, + }), + "session test admitted" + ) + local stale = ow.runner + local replacement + if change == "save" then + game.save = SaveData.newGame() + elseif change == "data" then + game.data = T.fixtures.fresh() + elseif change == "stack" then + game.stack = setmetatable({ states = { ow } }, { __index = StateStack }) + elseif change == "states" then + game.stack.states = { ow } + elseif change == "map" then + ow.map = { id = "FIX_ROUTE", def = game.data.maps.FIX_ROUTE } + elseif change == "mapId" then + ow.map.id = "FIX_ROUTE" + elseif change == "player" then + ow.player = { cellX = 4, cellY = 5 } + elseif change == "position" then + ow.player.cellX = 7 + elseif change == "runner" then + replacement = Runner.new(game, ow) + ow.runner = replacement + elseif change == "game" then + replacement = { stack = setmetatable({ states = {} }, { __index = StateStack }), input = game.input } + loader.game = replacement + elseif change == "version" then + GameVersion.set(version == "red" and "crystal" or "red") + elseif change == "removed" then + game.stack.states = {} + elseif change == "transition" then + ow.transitioning = true + end + Runtime.call("input.step", function() end, change == "game" and replacement or game, 1 / 60) + T.eq(calls, 1, change .. " cancellation delivered") + T.eq(result[1], false, "session cancellation is not success") + T.eq(type(result[2]), "string", "cancellation gives reason") + T.eq(ow.runner, change == "runner" and replacement or originalRunner, "restore only owned runner") + stale:resume() + stale:update() + T.eq(calls, 1, "old runner callbacks cannot replay cancellation") + T.eq(stale:isRunning(), false, "abandoned coroutine released") + GameVersion.set(version) + end +end +-- Cancel removes only owned windows, including the choice above its text. +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local originalRunner, calls = ow.runner, 0 + T.check( + world:queueScript({ { "ask", "Question?" }, { "text", "Later" } }, { + onDone = function(ok) + T.eq(ok, false, "explicit cancel completion") + calls = calls + 1 + end, + }), + "cancellable question" + ) + local box = game.stack:top() + untilState(game, function() + return game.stack:top() ~= box + end) + local choice = game.stack:top() + local foreign = { update = function() end } + game.stack:push(foreign) + T.eq(loader.apis.other.world:cancelScript(), false, "foreign mod has no cancellation authority") + T.check(world:cancelScript(), "owner cancels") + T.same(game.stack.states, { ow, foreign }, "foreign overlay survives cancellation") + T.eq(ow.runner, originalRunner, "cancel restores idle runner") + T.eq(calls, 1, "cancel callback once") + choice.onChoose(true) + box.onDone() + choice:update(1 / 60) + box:update(1 / 60) + T.same(game.stack.states, { ow, foreign }, "stale native UI cannot pop foreign screen") + T.eq(calls, 1, "stale native callbacks do not complete twice") +end +-- Replaying an old callback during a later row must not skip that row's wait. +do + local world, game, ow = boot("red") + T.check( + world:queueScript({ { "text", "First" }, { "wait", 10 }, { "text", "Last" } }), + "callback replay test admitted" + ) + local old = game.stack:top() + finishText(game) + T.eq(ow.runner.waitingFrames, 10, "later wait begins") + old.onDone() + old:update(1 / 60) + T.eq(ow.runner.waitingFrames, 10, "old text callback cannot skip current wait") + for _ = 1, 10 do + tick(game) + end + T.neq(game.stack:top(), ow, "new text after full wait") + world:cancelScript() +end + +-- Native error cleanup, completion exceptions and event reentry are bounded. +for _, version in ipairs({ "red", "crystal", "emerald", "platinum", "black" }) do + local world, game, ow, loader = boot(version) + local TextBox = require("src.render.TextBox") + local original, make = ow.runner, TextBox.new + local completions, result = 0 + TextBox.new = function() + error("injected native constructor failure") + end + Runtime.currentMod = "probe" + T.check( + world:queueScript({ { "text", "Fail" } }, { + onDone = function(ok, why) + completions = completions + 1 + result = { ok, why } + end, + }), + "entered native failure is admitted" + ) + T.eq(Runtime.currentMod, "probe", "engine ownership restored on failure") + Runtime.currentMod = nil + TextBox.new = make + T.eq(result[1], false, "native error completes as failure") + T.check(result[2]:find("constructor failure", 1, true) ~= nil, "native error reason retained") + T.eq(completions, 1, "native error completion once") + T.eq(ow.runner, original, "native error releases runner") + T.check( + world:queueScript({}, { + onDone = function() + error("completion failure") + end, + }), + "callback error isolated" + ) + T.eq(ow.runner, original, "callback error cannot strand runner") + local events = {} + local stop1 = loader.events:on("script.started", function(ev) + if ev.ctx.source and ev.ctx.source.modId == "probe" then + events[#events + 1] = "started" + refusal( + loader.apis.other.world, + game, + { { "wait", 1 } }, + nil, + "event callback cannot reenter owned run" + ) + end + end) + local stop2 = loader.events:on("script.ended", function(ev) + if ev.ctx.source and ev.ctx.source.modId == "probe" then + events[#events + 1] = ev.completed and "success" or "failure" + end + end) + T.check(world:queueScript({ { "wait", 1 } }), "eventful wait admitted") + tick(game) + T.same(events, { "started", "success" }, "one lifecycle event pair") + T.check(world:queueScript({ { "wait", 10 } }), "eventful cancel admitted") + world:cancelScript() + T.same(events, { "started", "success", "started", "failure" }, "cancel emits failure once") + stop1() + stop2() +end +-- An infinite authored jump yields slices to the engine and eventually +-- reports failure rather than blocking a frame or permanently owning input. +do + local world, game, ow = boot("red") + local original, result, ticks = ow.runner, nil, 0 + T.check( + world:queueScript({ { "label", "loop" }, { "jump", "loop" } }, { + onDone = function(ok, why) + result = { ok, why } + end, + }), + "loop admitted" + ) + T.eq(result, nil, "loop yields back from initial call") + untilState(game, function() + ticks = ticks + 1 + return result ~= nil + end) + T.check(ticks > 1, "loop spans engine updates") + T.eq(result[1], false, "instruction limit fails explicitly") + T.check(result[2]:find("budget", 1, true) ~= nil, "budget reason") + T.eq(ow.runner, original, "budget exhaustion releases world") +end +-- Teardown/reload and failed entry chunks retire captured facades. Neither +-- completion nor a script-ended listener may start fresh work during teardown. +for _, mode in ipairs({ "teardown", "reload", "rollback" }) do + local world, game, ow, loader, files = boot("emerald") + local original, calls, retry = ow.runner, 0 + T.check( + world:queueScript({ { "text", "Pending" } }, { + onDone = function(ok) + T.eq(ok, false, "lifecycle cancellation") + calls = calls + 1 + retry = world:queueScript({ { "wait", 1 } }) + end, + }), + "lifecycle program accepted" + ) + if mode == "teardown" then + Loader.teardown() + elseif mode == "reload" then + boot("emerald") + else + loader:_rollback("probe") + end + T.eq(calls, 1, "lifecycle completion once") + T.eq(retry, nil, "no restart while retiring") + T.eq(ow.runner, original, "lifecycle restores runner") + T.same(game.stack.states, { ow }, "lifecycle removes owned text") + refusal(world, game, { { "wait", 1 } }, nil, "retained API retired") +end +do + local _, game, ow, loader, files = boot("crystal") + local original = ow.runner + files["mods/probe/main.lua"] = [[return function(mod) + assert(mod.world:queueScript({ { "wait", 30 } })) + error("entry failure after queue") + end]] + files["mods/other/main.lua"] = "return function() end" + local failed = Loader.new({ fs = loader.fs, dev = false }) + failed.game = game + failed:load(T.fixtures.fresh()) + T.eq(ow.runner, original, "failed entry cancels its accepted run") + T.eq(original:isRunning(), false, "failed entry cannot strand foreground") +end +-- Omitted/native formats preserve the old API 1/2 contract. Explicit malformed +-- or API-1 unified declarations must fail instead of silently changing mode. +do + local function manifest(format, level) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = level or 2, + script_format = format, + } + end + for _, bad in ipairs({ false, true, 1, {}, "portable", "" }) do + T.raises(function() + Manifest.validate(manifest(bad), "mods/probe") + end, "script_format", "invalid format") + end + T.raises(function() + Manifest.validate(manifest("unified", 1), "mods/probe") + end, "API 2", "unified requires API 2") + T.eq( + Manifest.validate(manifest(nil, 1), "mods/probe").script_format, + "native", + "API 1 default remains native" + ) + T.eq( + Manifest.validate(manifest("native", 1), "mods/probe").script_format, + "native", + "explicit API 1 native accepted" + ) +end +-- Per-frame traces against the real native runner/commands, rather than +-- against another implementation of the portable adapter. +local function stateRecord(game, ow) + local top = game.stack:top() + local record = + { depth = #game.stack.states, running = ow.runner:isRunning(), wait = ow.runner.waitingFrames } + if top ~= ow then + record.index, record.pending, record.hold = top.index, top.pending, top.holdFrames + record.page, record.line, record.char = top.pageIndex, top.lineIndex, top.charIndex + record.done, record.waiting, record.preWait = top.done, top.waiting, top.preWait + if top.shown then + record.shown = Json.decode(Json.encode(top.shown)) + end + if top.items then + record.labels = {} + for _, item in ipairs(top.items) do + record.labels[#record.labels + 1] = item.label + end + end + end + return record +end +for _, version in ipairs(GameVersion.ORDER) do + for _, yes in ipairs({ true, false }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local world, game, ow = boot(version) + local rows = { + { "wait", 2 }, + { "show_text", "First\fSecond" }, + { "ask", "Question?" }, + { "jump_if_false", "no" }, + { "choice", { "ONE", "TWO" }, { default = 2, cancel = 2 } }, + { "jump", "end" }, + { "label", "no" }, + { "show_text", "NO" }, + } + local completed, trace = false, {} + local saveUnchanged = freeze(game.save) + if native then + ow.runner:run(rows, { + onDone = function() + completed = true + end, + }) + else + T.check( + world:queueScript(rows, { + onDone = function(ok) + T.eq(ok, true, "parity program succeeds") + completed = true + end, + }), + "parity program admitted" + ) + end + for frame = 1, 2000 do + trace[#trace + 1] = stateRecord(game, ow) + if completed then + break + end + local top = game.stack:top() + local key + if top.pending == nil and getmetatable(top) == require("src.ui.ChoiceBox") then + key = yes and "a" or "b" + elseif top.items then + key = "b" + elseif top.done or top.waiting and (top.preWait or 0) <= 0 then + key = "a" + end + tick(game, key) + end + T.eq(completed, true, "native parity trace completed") + saveUnchanged("dialogue program leaves save unchanged") + traces[#traces + 1] = trace + end + T.same(traces[1], traces[2], version .. " frame-by-frame native dialogue/wait/branch parity") + end +end + +do + local world, game, ow, loader = boot("black") + for _, invalid in ipairs({ + "bad\0text", + "\128", + "\192\128", + "\224\128\128", + "\237\160\128", + "\244\144\128\128", + "\240\159", + }) do + refusal( + world, + game, + { { "text", "Valid first" }, { "ask", invalid } }, + nil, + "invalid UTF-8 refused before prefix" + ) + end + T.check(world:queueScript({ { "text", "Grüße Pokémon ♀" } }), "valid UTF-8 accepted") + world:cancelScript() + local calls = 0 + local stop = loader.hooks:wrap("input.step", function(nextFn, ...) + calls = calls + 1 + return nextFn(...) + end, -1, "other") + for _ = 1, 3 do + T.check(world:queueScript({ { "wait", 2 } }), "reused observer admission") + world:cancelScript() + end + local before = calls + tick(game) + T.eq(calls, before + 1, "observer does not skip or duplicate downstream hooks") + stop() + local random = math.random + local rolls = 0 + math.random = function() + rolls = rolls + 1 + error("portable scripts must not roll RNG") + end + T.check(world:queueScript({ { "text", "FIRST" } }), "no-RNG dialogue") + finishText(game) + math.random = random + T.eq(rolls, 0, "no RNG consumed") + local events = {} + local done = loader.events:on("script.ended", function(ev) + events[#events + 1] = ev.completed + end) + T.check(world:queueScript({ { "text", "Native failure" } }), "native update failure admission") + local box = game.stack:top() + -- The guarded method captures the native update when the box is created. + -- Fail native work reached on a later frame instead of changing the guard. + box.beginLine = function() + error("native text update failure") + end + box.done, box.waiting, box.preWait, box.contAdvance = false, true, 0, true + tick(game, "a") + T.same(events, { false }, "native UI update error completes once") + T.eq(game.stack:top(), ow, "native update error releases owned UI") + done() +end +for _, version in ipairs(GameVersion.ORDER) do + local world, game, ow, loader = boot(version) + local api = loader.apis.probe + if api.supports then + T.eq(api:supports("world", "queueScript"), true, "portable queue capability") + T.eq(api:supports("world", "cancelScript"), true, "portable cancel capability") + for _, verb in ipairs({ + "text", + "show_text", + "ask", + "choice", + "wait", + "label", + "jump", + "jump_if_true", + "jump_if_false", + "end", + }) do + T.eq(api:supports("scripts", verb), true, "bounded verb capability") + end + T.eq(api:supports("scripts", "engine_private_command"), false, "unsupported verb declined") + T.eq(api:supports("scripts", "warp"), GameVersion.generation(version) <= 3, "travel capability matches generation") + T.eq(loader.apis.legacy:supports("world", "cancelScript"), false, "legacy has no portable cancellation") + T.eq(loader.apis.legacy:supports("scripts", "wait"), false, "native format does not claim portable verbs") + end + T.eq(world:current().mapId, "FIX_TOWN", "unrelated world methods still delegate") + T.eq(world.game, game, "existing native fields still delegate") +end + +-- First admission from an input.step callback must not sort a new observer +-- into the chain while it is being traversed (which would replay the caller). +do + local world, game = boot("crystal") + local wrappers, vanilla = 0, 0 + local stop = Runtime.hooks:wrap("input.step", function(nextFn, ...) + wrappers = wrappers + 1 + if wrappers == 1 then + T.check(world:queueScript({ { "wait", 2 } }), "first queue inside input hook") + end + return nextFn(...) + end, 0, "probe") + Runtime.call("input.step", function() + vanilla = vanilla + 1 + end, game, 1 / 60) + T.eq(wrappers, 1, "first queue does not replay input callback") + T.eq(vanilla, 1, "native input boundary called once") + world:cancelScript() + stop() +end +GameVersion.set(originalVersion) +T.finish("portable world scripts") diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index a0dff35be..19cfd28ac 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -192,18 +192,77 @@ check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= n -- so the quit event exits the process outright. It has to sit after the -- love.quit() veto test, or the editor's abort-quit path would die on a quit -- it refused. -local quitBranch = mainSrc:match('if name == "quit" then(.-)\n%s*end\n') -check(quitBranch ~= nil, "love.run still handles the quit event") -quitBranch = quitBranch or "" -local vetoAt = quitBranch:find("not love%.quit()") -local osAt = quitBranch:find("os%.exit") -local androidAt = quitBranch:find('getOS() == "Android"', 1, true) -check(vetoAt ~= nil and osAt ~= nil and vetoAt < osAt, - "the process exit runs only after love.quit declined to veto") -check(androidAt ~= nil and androidAt < osAt, - "the process exit is gated on Android") -local exits = 0 -for _ in mainSrc:gmatch("os%.exit") do exits = exits + 1 end -eq(exits, 1, "os.exit appears once, at the quit event") +-- Execute the actual run-loop function in an isolated environment. Scanning +-- nested `end`s or counting os.exit in comments cannot prove veto behavior. +local runAt = assert(mainSrc:find("\nfunction love.run()", 1, true)) +local runSource = mainSrc:sub(runAt + 1) +local function quitFrame(platform, answer, code, noCallback) + local calls, exits = {}, {} + local function record(name) calls[#calls + 1] = name end + local env = setmetatable({ + BootTrace = { mark = function() end, frame = function() record("frame") end }, + Frames = { tick = function() record("tick") end }, + FrameProfile = { section = function() return function() end end }, + pacingEnabled = function() return false end, + os = { exit = function(value) record("exit"); exits[#exits + 1] = value end }, + love = { + handlers = { quit = function() record("dispatch") end }, + update = function() record("update") end, + event = { + pump = function() end, + poll = function() + local sent = false + return function() + if not sent then sent = true; return "quit", code end + end + end, + }, + }, + }, { __index = _G }) + if platform then env.love.system = { getOS = function() return platform end } end + if not noCallback then + env.love.quit = function() record("quit"); return answer end + end + local compile = loadstring or load + local chunk + if setfenv then + chunk = assert(compile(runSource, "@main.lua:love.run")) + setfenv(chunk, env) + else + chunk = assert(load(runSource, "@main.lua:love.run", "t", env)) + end + chunk() + local result = env.love.run()() + return result, table.concat(calls, ","), exits +end + +for _, platform in ipairs({ "Android", "Windows", "OS X", "Linux", "NX", false }) do + local label = tostring(platform) + for _, code in ipairs({ 0, 7, false }) do + -- false is a table-friendly sentinel for an omitted exit status. + local expected = code or 0 + for _, noCallback in ipairs({ false, true }) do + for _, answer in ipairs({ false, "nil" }) do + local callbackAnswer = answer + if answer == "nil" then callbackAnswer = nil end + local result, order, exits = quitFrame(platform, callbackAnswer, code or nil, noCallback) + local prefix = noCallback and "" or "quit" + eq(result, expected, label .. ": accepted quit returns its exit status") + if platform == "Android" then + eq(#exits, 1, "Android exits exactly once after accepting quit") + eq(exits[1], expected, "Android forwards the requested exit status") + eq(order, prefix == "" and "exit" or "quit,exit", "shutdown precedes process exit") + else + eq(#exits, 0, label .. ": accepted quit does not force a process exit") + eq(order, prefix, label .. ": no further frame work after accepted quit") + end + end + end + end + local result, order, exits = quitFrame(platform, true, 7) + eq(result, nil, label .. ": veto keeps the frame running") + eq(#exits, 0, label .. ": veto never exits the process") + eq(order, "quit,dispatch,update,frame,tick", label .. ": veto resumes event and frame processing") +end T.finish("quit thread shutdown") diff --git a/tests/engine/rebind_capture_bug510.lua b/tests/engine/rebind_capture_bug510.lua index e5d13d311..f36306d15 100644 --- a/tests/engine/rebind_capture_bug510.lua +++ b/tests/engine/rebind_capture_bug510.lua @@ -48,8 +48,13 @@ local function openMenu(game) return bm end --- rows are BindingsMenu's BUTTONS order; 5 = A, 6 = B -local ROW_A, ROW_B = 5, 6 +-- Find actions by their public menu row metadata, not by a generation's order. +local function row(menu, action) + for i, item in ipairs(menu.items) do + if item.button and item.button.id == action then return i end + end + error("missing controls row: " .. action) +end -- The mechanism, pinned so a future "just apply it immediately" revert -- fails here: a rebind overwrites whatever the key used to do, so B = Z @@ -73,14 +78,14 @@ check(game.save.options.bindings == nil, "escaping a capture writes no binding") eq(game.wroteOptions, 0, "and does not touch options on disk") -bm.index = ROW_B +bm.index = row(bm, "b") press(bm, "a") bm:onKeyPressed("z") check(game.save.options.bindings == nil, "a capture holds its press; nothing stores before the release (#589)") bm:onKeyReleased("z") eq(game.save.options.bindings.b.key, "z", "releasing the press stores B = Z") -eq(bm.items[ROW_B].right, "Z/B", "the row shows the new key straight away") +eq(bm.items[row(bm, "b")].right, "Z/B", "the row shows the new key straight away") eq(game.wroteOptions, 1, "the choice persists immediately") eq(Input.keyBindings["z"], "a", "but the live map still reads Z as A while the screen is open (#510)") @@ -90,12 +95,12 @@ eq(#game.stack.states, 1, "capturing Z does not close the screen") -- inherits B's previous key and no key serves two rows (#589) eq(game.save.options.bindings.a.key, "x", "capturing A's key for B hands A the old B key") -eq(bm.items[ROW_A].right, "X/A", "and the A row redraws with it") +eq(bm.items[row(bm, "a")].right, "X/A", "and the A row redraws with it") -- the next Z the player presses is still confirm, so the A row can be armed -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") -eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing") +eq(bm.capture, bm.items[row(bm, "a")], "the A row arms instead of the screen closing") bm:onKeyPressed("x") bm:onKeyReleased("x") eq(game.save.options.bindings.a.key, "x", "re-capturing A's own key keeps it") @@ -117,16 +122,18 @@ eq(Input.keyBindings["z"], "b", "a reload reaches the same map as the close") Input:init() local padGame = newGame() local padBm = openMenu(padGame) -padBm.index = ROW_B +padBm.index = row(padBm, "b") press(padBm, "a") padBm:onGamepadPressed("y") padBm:onGamepadReleased("y") eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores") -eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close") +eq(Input.padBindings["y"], "x", "pad Y keeps its default DS X action until close") +eq(padGame.save.options.bindings.x.pad, "b", "DS X inherits the captured row's pad B") +eq(Input.padBindings["b"], "b", "the donor swap is also deferred until close") -- a second input going down while the first is held backs the capture out -- with no keyboard in reach, the pad's Escape (#589) -padBm.index = ROW_A +padBm.index = row(padBm, "a") press(padBm, "a") padBm:onGamepadPressed("x") padBm:onGamepadPressed("b") @@ -139,6 +146,20 @@ eq(padGame.save.options.bindings.a, nil, press(padBm, "b") eq(Input.padBindings["y"], "b", "closing commits the pad half too") +eq(Input.padBindings["b"], "x", "closing commits the DS X donor half") + +-- A genuinely unbound input must also remain absent until the screen closes. +Input:init() +local spareGame = newGame() +local spareBm = openMenu(spareGame) +spareBm.index = row(spareBm, "a") +press(spareBm, "a") +spareBm:onGamepadPressed("leftstick") +spareBm:onGamepadReleased("leftstick") +eq(spareGame.save.options.bindings.a.pad, "leftstick", "unbound pad capture stores") +eq(Input.padBindings.leftstick, nil, "unbound pad remains absent while the menu is open") +press(spareBm, "b") +eq(Input.padBindings.leftstick, "a", "closing activates the newly bound pad") Input:init() T.finish("rebind_capture_bug510") diff --git a/tests/engine/rebind_swap_clear_bug589.lua b/tests/engine/rebind_swap_clear_bug589.lua index 0e49d03d0..7ccca5fe0 100644 --- a/tests/engine/rebind_swap_clear_bug589.lua +++ b/tests/engine/rebind_swap_clear_bug589.lua @@ -54,8 +54,12 @@ local function settleChoice(state) for _ = 1, Timing.YES_NO_ANSWER do state:update(1 / 60) end end --- rows are BindingsMenu's BUTTONS order -local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8 +local function row(menu, action) + for i, item in ipairs(menu.items) do + if item.button and item.button.id == action then return i end + end + error("missing controls row: " .. action) +end -- ---- (a) pad capture swaps with the row that owns the button -------------- @@ -64,9 +68,9 @@ local game = newGame() local bm = BindingsMenu.new(game) game.stack:push(bm) -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") -eq(bm.capture, bm.items[ROW_A], "A arms the A row") +eq(bm.capture, bm.items[row(bm, "a")], "A arms the A row") bm:onGamepadPressed("b") check(game.save.options.bindings == nil, "a pad capture holds its press; nothing stores before the release") @@ -75,8 +79,8 @@ local bindings = game.save.options.bindings eq(bindings.a.pad, "b", "releasing pad B stores it on the A row") eq(bindings.b.pad, "a", "and the B row, which owned pad B, inherits the A row's old pad (#589)") -eq(bm.items[ROW_A].right, "Z/B", "the A row redraws with the new pad") -eq(bm.items[ROW_B].right, "X/A", "so does the B row") +eq(bm.items[row(bm, "a")].right, "Z/B", "the A row redraws with the new pad") +eq(bm.items[row(bm, "b")].right, "X/A", "so does the B row") -- after applying, every row's effective pad is unique and none is lost Input:applyBindings(bindings) @@ -89,13 +93,21 @@ for button, action in pairs(Input.padBindings) do seen[action] = button actions[#actions + 1] = action end -eq(#actions, 8, "all eight actions still have exactly one pad button") +eq(#actions, 12, "all twelve actions still have exactly one pad button") +local unchanged = { + up = "dpup", down = "dpdown", left = "dpleft", right = "dpright", + x = "y", y = "x", start = "start", select = "back", + l = "leftshoulder", r = "rightshoulder", +} +for action, button in pairs(unchanged) do + eq(seen[action], button, "A/B swap preserves the binding for " .. action) +end Input:init() -- ---- (b) a second input while the first is held cancels ------------------- -- key then key: the straggling release of the first press writes nothing -bm.index = ROW_SELECT +bm.index = row(bm, "select") press(bm, "a") bm:onKeyPressed("q") bm:onKeyPressed("w") @@ -121,17 +133,17 @@ check(bindings.select == nil, "press alone commits nothing (#589)") eq(game.wroteOptions, writesAfterSwap, "and touches nothing on disk") bm:onKeyReleased("q") eq(bindings.select.key, "q", "the release is the commit") -eq(bm.items[ROW_SELECT].right, "Q/BACK", "the row shows the new key") +eq(bm.items[row(bm, "select")].right, "Q/BACK", "the row shows the new key") -- ---- (d) SELECT on a row forgets its rebind ------------------------------- press(bm, "select") check(bindings.select == nil, "SELECT drops the row's overlay entry (#589)") -eq(bm.items[ROW_SELECT].right, "TAB/BACK", "the row falls back to the default") +eq(bm.items[row(bm, "select")].right, "TAB/BACK", "the row falls back to the default") -- the swapped B row clears the same way; a second SELECT on the now -- default row is a no-op, not a write local writes = game.wroteOptions -bm.index = ROW_B +bm.index = row(bm, "b") press(bm, "select") eq(game.wroteOptions, writes + 1, "clearing the swapped B row writes once") check(game.save.options.bindings.b == nil, "and drops its overlay entry") @@ -141,7 +153,7 @@ eq(game.wroteOptions, writes + 1, "clearing an already-default row writes nothin -- ---- (e) START confirms, then drops the whole overlay --------------------- -- rebuild a dirty overlay to reset -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") bm:onKeyPressed("p") bm:onKeyReleased("p") @@ -158,7 +170,7 @@ press(box, "a") settleChoice(box) eq(game.stack:top(), bm, "answering pops the box") check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)") -eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them") +eq(bm.items[row(bm, "a")].right, "P/B", "and the rows keep showing them") -- again, flip to YES: the overlay goes away and every row reads default press(bm, "start") @@ -167,8 +179,8 @@ press(box, "up") press(box, "a") settleChoice(box) check(game.save.options.bindings == nil, "YES clears options.bindings (#589)") -eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again") -eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched") +eq(bm.items[row(bm, "a")].right, "Z/A", "the A row reads its default again") +eq(bm.items[row(bm, "b")].right, "X/B", "so does the B row the swap had touched") -- closing after the reset leaves the live map at the defaults press(bm, "b") diff --git a/tests/engine/soft_reset_bug563.lua b/tests/engine/soft_reset_bug563.lua index 65fb22445..fe8fdcf7d 100644 --- a/tests/engine/soft_reset_bug563.lua +++ b/tests/engine/soft_reset_bug563.lua @@ -106,8 +106,9 @@ check(stepChord(), "reaching 16 again from the restart") -- before stack:update, so it fires from a battle, a menu or a cutscene and -- not just from the overworld (#563). local function fakeGame() - local game = { input = Input, save = {}, returned = 0 } + local game = setmetatable({ input = Input, save = {}, returned = 0 }, { __index = Game }) game.stack = { + states = { {} }, -- a live state, so the real empty-stack guard does not recover updates = 0, update = function(self) self.updates = self.updates + 1 end, } @@ -117,7 +118,23 @@ end local game = fakeGame() holdKeys("z", "x", "escape", "tab") +-- Observe the real profiling wrapper as well as its real _step body. A reset +-- returns early from _step, but must still close the outer profiling scope. +local FrameProfile = require("src.core.FrameProfile") +local savedSection = FrameProfile.section +local opened, closed = 0, 0 +FrameProfile.section = function(name) + local close = savedSection(name) + if name == " step: whole" then opened = opened + 1 end + return function() + if name == " step: whole" then closed = closed + 1 end + close() + end +end for _ = 1, HOLD_STEPS do Game.step(game, 1 / 60) end +FrameProfile.section = savedSection +eq(opened, HOLD_STEPS, "every logic step enters the real profiling wrapper") +eq(closed, HOLD_STEPS, "the reset early return closes its profiling scope") eq(game.returned, 1, "Game:step falls back to the title on the 16th step") eq(game.stack.updates, HOLD_STEPS - 1, "and the state on top never gets that step, whatever it was") @@ -131,16 +148,17 @@ local bare = fakeGame() bare.input = { step = function() end, isDown = function() return false end } for _ = 1, HOLD_STEPS * 2 do Game.step(bare, 1 / 60) end eq(bare.returned, 0, "an input with no chord bookkeeping never resets") +eq(bare.stack.updates, HOLD_STEPS * 2, "an input stand-in still updates the real state stack") -- ---- the on-screen overlay --------------------------------------------- -- Force the overlay live the way a phone would have it. img is only ever --- tested for truthiness on the input path (drawing is what reads it), so a --- placeholder keeps this suite off love.graphics.newImage. +-- checked per button on the input path. Provide each available control +-- without asking love.graphics to load actual image assets. Input:init() TouchControls:init() TouchControls.active, TouchControls.enabled = true, true -TouchControls.img = { stub = true } +TouchControls.img = { a = {}, b = {}, start = {}, select = {}, dpad = {} } local L = TouchControls:layout() -- One finger, one control: touchpressed returns on its first hit, so no diff --git a/tests/engine/tool_mod_hooks.lua b/tests/engine/tool_mod_hooks.lua index 36f1f2a01..d92ac0aad 100644 --- a/tests/engine/tool_mod_hooks.lua +++ b/tests/engine/tool_mod_hooks.lua @@ -8,6 +8,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Hooks = require("src.mods.Hooks") local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors @@ -19,14 +20,14 @@ Runtime.hooks = hooks -- ordering rather than merely checking that a hook was registered. do local order = {} - local fake = { + local fake = setmetatable({ input = { step = function() order[#order + 1] = "input" end }, - stack = { update = function(_, dt) + stack = { states = { {} }, update = function(_, dt) order[#order + 1] = "world" T.eq(dt, 1 / 60, "the world receives the fixed-step dt") end }, save = {}, - } + }, { __index = Game }) hooks:wrap("input.step", function(nextFn, game, dt) T.check(game == fake, "input.step receives the live Game object") T.eq(dt, 1 / 60, "input.step receives the fixed-step dt") @@ -71,10 +72,10 @@ end -- the live snapshot in preparation for disk IO. do local captured = false - local fake = { + local fake = setmetatable({ save = {}, overworld = { captureSave = function() captured = true end }, - } + }, { __index = Game }) hooks:wrap("save.write", function(nextFn, game) T.check(game == fake, "save.write receives the live Game object") return false @@ -112,14 +113,14 @@ do -- Game:draw iterates stack.states and calls each state's draw (it no -- longer goes through stack:draw), so the fixture must put a drawable -- state on the stack to observe composition order. - local fake = { + local fake = setmetatable({ overworld = {}, stack = { states = { { draw = function() order[#order + 1] = "states" end }, }, }, - } + }, { __index = Game }) function fake.stack:visibleBase() return 1 end function fake.stack:top() return self.states[#self.states] end diff --git a/tests/engine/ui_actions.lua b/tests/engine/ui_actions.lua new file mode 100644 index 000000000..8103d644f --- /dev/null +++ b/tests/engine/ui_actions.lua @@ -0,0 +1,792 @@ +-- Real native screens, Loader facades and ROM-free generation fixtures. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Intents = require("src.mods.BattleIntents") +local original = GameVersion.get() +local versions = GameVersion.ORDER +local function boot(version, apiLevel) + GameVersion.set(version) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = apiLevel or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(mod.ui:snapshot()==nil) + assert(type(mod.ui.insertBefore)=="function") + if mod.supports then + assert(mod:supports("ui", "snapshot")) + assert(mod:supports("ui", "submit")) + assert(not mod:supports("ui", "unknown")) + end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + function game.stack:top() + return self.states[#self.states] + end + function game.stack:push(state) + self.states[#self.states + 1] = state + end + function game.stack:pop() + return table.remove(self.states) + end + return loader.apis.probe.ui, game, loader.apis.other.ui, loader +end +local function button(game, screen, name) + local old = game.input + game.input = { + wasPressed = function(_, key) + return key == name + end, + isDown = function() + return false + end, + } + screen:update(1 / 60) + game.input = old +end +local function submit(ui, id, kind, fields) + local snap, err = ui:snapshot() + assert(snap, err) + local intent = { id = id, revision = snap.revision, kind = kind } + for k, v in pairs(fields or {}) do + intent[k] = v + end + return ui:submit(intent) +end +local partyClass = { + red = "PartyMenu", + crystal = "PartyMenu", + emerald = "Gen3PartyMenu", + platinum = "Gen4PartyMenu", + heartgold = "Gen4PartyMenu", + black = "Gen4PartyMenu", +} +local bagClass = { + red = "BagMenu", + crystal = "BagMenu", + emerald = "Gen3BagMenu", + platinum = "Gen4BagMenu", + heartgold = "HgssBagMenu", + black = "Gen5BagMenu", +} +for _, version in ipairs(versions) do + local gen = GameVersion.VERSIONS[version].generation + partyClass[version] = partyClass[version] + or (gen <= 2 and "PartyMenu" or gen == 3 and "Gen3PartyMenu" or "Gen4PartyMenu") + bagClass[version] = bagClass[version] + or ( + gen <= 2 and "BagMenu" + or gen == 3 and "Gen3BagMenu" + or version == "soulsilver" and "HgssBagMenu" + or gen == 5 and "Gen5BagMenu" + or "Gen4BagMenu" + ) + local ui, game, other = boot(version) + local chosen, cancelled = 0, 0 + local screen = require("src.ui." .. partyClass[version]).new(game, { + pickOnly = true, + onSwitch = function(mon) + T.eq(mon, game.save.party[2], "native picker target") + chosen = chosen + 1 + end, + onCancel = function() + cancelled = cancelled + 1 + end, + }) + game.stack:push(screen) + local snap = assert(ui:snapshot()) + T.eq(snap.kind, "party", version .. " party recognized") + T.eq(snap.rows[2].species, "FIXMON_B", "portable party ID") + T.eq(ui:snapshot().revision, snap.revision, "unchanged read retains revision") + snap.rows[1].hp = -999 + T.neq(game.save.party[1].hp, -999, "snapshot detached") + local stale = other:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "native party selection accepted") + T.eq(chosen, 1, "picker callback once") + T.eq(#game.stack.states, 0, "picker popped") + T.eq( + other:submit({ id = 1, revision = stale.revision, kind = "back" }), + nil, + "other facade cannot use departed picker" + ) + game.stack:push(require("src.ui." .. partyClass[version]).new(game, { + onCancel = function() + cancelled = cancelled + 1 + end, + })) + T.check(submit(ui, 2, "back"), "native party back") + T.eq(cancelled, 1, "onCancel delivered") + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local bagSnap = assert(ui:snapshot()) + T.eq(bagSnap.kind, "bag", version .. " bag recognized") + if bagSnap.canPocket then + T.check(submit(ui, 3, "pocket", { direction = 1 }), "native pocket switch") + end + T.check(submit(ui, 4, "back"), "native bag back") + T.eq(#game.stack.states, 0, "bag popped") +end + +-- Walk raw entries, preserving identity and cycles, to detect any gameplay +-- writes from observations and rejected requests (T.same is not cycle-safe). +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(ui, game, intent, message) + local unchanged = freeze(game) + local ok, err = ui:submit(intent) + T.eq(ok, nil, message) + T.check(type(err) == "string", message .. " explains refusal") + unchanged(message .. " leaves game untouched") + return err +end +for _, version in ipairs(versions) do + -- Compare native A/B to the facade for each party submenu and its back path. + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("party snapshot is observational") + T.check(snap ~= nil, "native screen available") + if native then + button(game, party, "a") + else + T.check(submit(ui, 1, "select", { index = 1 }), "open party submenu") + end + local stableSubmenu = freeze(game) + snap = ui:snapshot() + stableSubmenu("submenu snapshot remains observational") + T.eq(snap.kind, "party_actions", "party action rows offered") + local swap + for i, r in ipairs(snap.rows) do + if r.action == "switch" or r.action == "switchOrder" then + swap = i + end + end + T.check(swap ~= nil, "native swap row exposed") + if native then + if GameVersion.VERSIONS[version].generation <= 3 then + party.subIndex = swap + else + party.submenu = swap + end + button(game, party, "a") + party.index = 2 + button(game, party, "a") + else + T.check(submit(ui, 2, "select", { index = swap }), "start swap") + T.check(submit(ui, 3, "select", { index = 2 }), "finish swap") + end + traces[#traces + 1] = { + first = game.save.party[1].species, + second = game.save.party[2].species, + depth = #game.stack.states, + index = party.index, + swapFrom = party.swapFrom, + switchFrom = party.switchFrom, + } + if native then + button(game, party, "b") + else + T.check(submit(ui, 4, "back"), "back after swap") + end + T.eq(#game.stack.states, 0, "native party cancellation") + end + T.same(traces[1], traces[2], version .. " swap matches native A/B") + + local ui, game, other, loader = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local snap = ui:snapshot() + for _, bad in ipairs({ + false, + 1, + "select", + {}, + { id = 1, revision = snap.revision, kind = "unknown" }, + { id = 1, revision = snap.revision, kind = "select", index = 0 }, + { id = 1, revision = snap.revision, kind = "select", index = 1.5 }, + { id = math.huge, revision = snap.revision, kind = "back" }, + { id = 1, revision = snap.revision, kind = "back", extra = true }, + setmetatable({ id = 1, revision = snap.revision, kind = "back" }, {}), + }) do + refusal(ui, game, bad, "invalid intent") + end + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 999 }, "missing row") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "party cannot advance text") + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "pocket", direction = 1 }, + "party has no pockets" + ) + game.save.party[1].hp = game.save.party[1].hp - 1 + T.eq( + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "HP change stales snapshot"), + "stale UI context", + "stale reason" + ) + snap = ui:snapshot() + local replacement = {} + for k, v in pairs(game.save.party[1]) do + replacement[k] = v + end + game.save.party[1] = replacement + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "back" }, + "identical replacement mon stales snapshot" + ) + -- Different screen identity, even with identical values. + snap = ui:snapshot() + local replacementScreen = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack.states[1] = replacementScreen + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "replacement screen stales snapshot") + party = replacementScreen + -- Whitelist native ownership; never infer an unrelated menu from its rows. + game.stack:push({ game = game, kind = "party", items = { { label = "fake" } } }) + T.eq(ui:snapshot(), nil, "unrelated overlay unsupported") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "covered native menu refused") + game.stack:pop() + for _, flag in ipairs({ "heal", "noInput", "script", "closed" }) do + party[flag] = true + snap = ui:snapshot() + T.eq(snap.locked, true, flag .. " locks public UI") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, flag .. " blocks action") + party[flag] = nil + end + -- Shared battle/UI guard, cross-mod epoch, consumed IDs and partial failures. + snap = ui:snapshot() + Intents.state(game).busy = true + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "in-progress battle/UI work blocked") + T.eq(ui:snapshot().locked, true, "busy snapshot locked") + Intents.state(game).busy = false + snap = ui:snapshot() + Intents.state(game).epoch = Intents.state(game).epoch + 1 + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle work expires UI snapshot") + local calls, during, observed = 0 + party.onCancel = function() + calls = calls + 1 + game.stack:push(party) -- keep an identical screen for the epoch test + during = other:snapshot() + observed = other:submit({ id = 9, revision = during.revision, kind = "back" }) + error("native callback failure") + end + local otherSnap = other:snapshot() + snap = ui:snapshot() + Runtime.currentMod = "probe" + local ok, err = ui:submit({ id = 10, revision = snap.revision, kind = "back" }) + T.eq(ok, nil, "partial native failure reported") + T.check(err:find("intent consumed", 1, true) ~= nil, "partial failure consumes id") + T.eq(Runtime.currentMod, "probe", "engine attribution restored after error") + Runtime.currentMod = nil + T.eq(calls, 1, "callback invoked once") + T.eq(observed, nil, "callback cannot submit reentrantly") + T.eq(during.locked, true, "snapshot during callback is locked") + refusal(other, game, { id = 9, revision = otherSnap.revision, kind = "back" }, "other mod snapshot expired") + refusal(other, game, { id = 9, revision = during.revision, kind = "back" }, "mid-callback snapshot expired") + refusal( + ui, + game, + { id = 10, revision = ui:snapshot().revision, kind = "back" }, + "failed native action cannot replay" + ) + game.stack.states = { require("src.ui." .. partyClass[version]).new(game, {}) } + T.check(submit(ui, 11, "back"), "guard released after failure") + T.eq(loader.apis.probe.ui.insertBefore, require("src.ui.ModUI").insertBefore, "builder helpers retained") + T.neq(ui, other, "per-mod facade identity") +end + +local TextBox = require("src.render.TextBox") +local ChoiceBox = require("src.ui.ChoiceBox") +local function tickUntil(game, box, predicate) + for _ = 1, 1000 do + if predicate() then + return + end + box:update(1 / 60) + end + error("native UI did not reach expected state") +end +for _, version in ipairs(versions) do + local ui, game = boot(version) + local done = 0 + local box = TextBox.new(game, "First\fSecond", function() + done = done + 1 + end) + game.stack:push(box) + local snap = ui:snapshot() + T.eq(snap.kind, "text", "text recognized") + T.eq(snap.locked, true, "typing text locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "typing cannot be skipped") + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.check(submit(ui, 1, "advance"), "manual page advances") + T.check((box.holdFrames or 0) > 0, "page clear hold preserved") + snap = ui:snapshot() + refusal(ui, game, { id = 2, revision = snap.revision, kind = "advance" }, "page hold cannot be skipped") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 2, "advance"), "final text closes") + T.eq(done, 1, "text callback once") + T.eq(game.stack:top(), nil, "text popped") + for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + box = TextBox.new(game, "Ready", nil, opts) + game.stack:push(box) + box.done = true + snap = ui:snapshot() + T.eq(snap.canAdvance, false, "automatic/stay/choice text not manually dismissed") + refusal(ui, game, { id = 3, revision = snap.revision, kind = "advance" }, "script-owned text protected") + game.stack:pop() + end + -- Native text -> choice -> delayed answer -> both boxes popped. + local answers = {} + box = TextBox.new(game, "Question?", nil, { + defaultNo = true, + choice = function(yes) + answers[#answers + 1] = yes + end, + }) + game.stack:push(box) + tickUntil(game, box, function() + return box.done + end) + box:update(1 / 60) + local choice = game.stack:top() + T.eq(getmetatable(choice), ChoiceBox, "native choice appears") + T.eq(ui:snapshot().index, 2, "default NO retained") + T.check(submit(ui, 4, "select", { index = 1 }), "YES accepted") + T.eq(#answers, 0, "answer callback waits for native hold") + T.eq(#game.stack.states, 2, "both boxes retained during answer hold") + snap = ui:snapshot() + refusal(ui, game, { id = 5, revision = snap.revision, kind = "back" }, "pending answer locked") + tickUntil(game, choice, function() + return #answers == 1 + end) + T.eq(answers[1], true, "YES delivered") + T.eq(#game.stack.states, 0, "choice closes original text too") + choice = ChoiceBox.new(game, function(yes) + answers[#answers + 1] = yes + end) + game.stack:push(choice) + T.check(submit(ui, 5, "back"), "B selects NO") + T.eq(choice.index, 2, "NO cursor retained while pending") + tickUntil(game, choice, function() + return #answers == 2 + end) + T.eq(answers[2], false, "NO delivered") +end + +-- Pick an actual item through each native bag. API and physical A/B must +-- deliver the same callback and row, including HGSS pages and BW cursors. +for _, version in ipairs(versions) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = { + id = "POTION", + name = "POTION", + pocket = "ITEM", + fieldPocket = 0, + bwPocket = 0, + fieldUse = 1, + fieldUseFunc = 1, + battlePocket = 4, + } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local picked = {} + local bag = require("src.ui." .. bagClass[version]).new(game, { + pick = true, + onPick = function(id) + picked[#picked + 1] = id + end, + }) + game.stack:push(bag) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("bag snapshot does not rebuild, sort or load graphics") + local itemRow + for i, row in ipairs(snap.rows) do + if row.item == "POTION" then + itemRow = i + end + end + T.check(itemRow ~= nil, version .. " item exposed") + if native then + if bagClass[version] == "HgssBagMenu" then + bag.page, bag.slot = math.floor((itemRow - 1) / 6), (itemRow - 1) % 6 + 1 + elseif bagClass[version] == "Gen5BagMenu" then + bag.cursor = itemRow + else + bag.index = itemRow + if bag.clampScroll then + bag:clampScroll() + end + end + button(game, bag, "a") + else + T.check(submit(ui, 1, "select", { index = itemRow }), "native bag selection accepted") + end + -- GB's bag has no generic pick option: it opens USE/GIVE/TOSS instead. + local top = game.stack:top() + if bagClass[version] == "BagMenu" then + T.eq(ui:snapshot().kind, "bag_actions", "only factory-owned bag action menus offered") + if native then + button(game, top, "b") + else + T.check(submit(ui, 2, "back"), "back from native item submenu") + end + else + T.same(picked, { "POTION" }, "native pick callback exactly once") + end + traces[#traces + 1] = { picked = picked, count = game.save.inventory.POTION, depth = #game.stack.states } + end + T.same(traces[1], traces[2], version .. " bag confirm matches native input") +end +-- Real medicine effect, target picker and HP animation through the public +-- boundary. The native flow owns consumption and its delayed completion. +for _, version in ipairs({ "red", "crystal", "emerald" }) do + local ui, game = boot(version) + game.data.items.POTION = { id = "POTION", name = "POTION", pocket = "ITEM" } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local mon = game.save.party[1] + mon.hp = mon.stats.hp - 25 + local before = mon.hp + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local snap = ui:snapshot() + local slot + for i, r in ipairs(snap.rows) do + if r.item == "POTION" then + slot = i + end + end + T.check(submit(ui, 1, "select", { index = slot }), "select medicine") + snap = ui:snapshot() + if snap.kind == "bag_actions" then + local use + for i, r in ipairs(snap.rows) do + if r.action == "use" or r.label == "USE" then + use = i + end + end + T.check(submit(ui, 2, "select", { index = use }), "native USE action") + end + local picker = game.stack:top() + T.eq(ui:snapshot().kind, "party", "medicine opens native target picker") + T.check(submit(ui, 3, "select", { index = 1 }), "select medicine target") + T.eq(mon.hp, before + 20, "real native potion restores 20 HP") + T.eq(game.save.inventory.POTION, 1, "real native potion consumes once") + T.eq(picker.heal ~= nil, true, "native heal animation retained") + snap = ui:snapshot() + refusal( + ui, + game, + { id = 4, revision = snap.revision, kind = "select", index = 1 }, + "cannot use again during HP fill" + ) + tickUntil(game, picker, function() + return picker.heal == nil + end) + local box = game.stack:top() + T.eq(getmetatable(box), TextBox, "native result text appears after fill") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 4, "advance"), "native medicine result dismissed") + T.eq(game.save.inventory.POTION, 1, "result dismissal cannot consume again") +end +-- The GBA action grid keeps its blank cells and script input lock. +do + local ui, game = boot("emerald") + local chosen + local menu = require("src.ui.Gen3ItemMenu").new(game, { + entries = { + { label = "USE", kind = "use" }, + { label = "", kind = "blank" }, + { label = "CANCEL", kind = "cancel" }, + }, + onPick = function(kind) + chosen = kind + end, + }) + game.stack:push(menu) + local snap = ui:snapshot() + T.eq(snap.rows[2].enabled, false, "blank grid cell disabled") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 2 }, "blank cell refused") + T.check(submit(ui, 1, "select", { index = 1 }), "native item grid confirm") + T.eq(chosen, "use", "native item action callback") +end +-- DS battle bags admit their native delayed press, never call its callback +-- early. Page slots, absent items and return buttons preserve native indices. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = + { id = "POTION", name = "POTION", fieldPocket = 0, battlePocket = 4, battleMask = 4 } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local ds = GameVersion.VERSIONS[version].generation == 5 and "Gen5BattleBag" or "Gen4BattleBag" + local bag = require("src.ui." .. ds).new(game, {}) + game.gen4CursorActive = true + game.stack:push(bag) + local snap = ui:snapshot() + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "select", index = 5 }, + "unavailable last-used row" + ) + local function select(id, index) + if native then + bag.cursor.index = index + button(game, bag, "a") + else + T.check(submit(ui, id, "select", { index = index }), "battle bag native press admitted") + end + T.check(bag.press ~= nil, "native press pending") + T.eq(ui:snapshot().locked, true, "pending press locks UI") + tickUntil(game, bag, function() + return bag.press == nil + end) + end + select(1, 1) + T.eq(bag.screen, "pocket", "native pocket opens after animation") + snap = ui:snapshot() + T.eq(snap.rows[1].item, "POTION", "native battle item row") + refusal( + ui, + game, + { id = 2, revision = snap.revision, kind = "select", index = 6 }, + "empty item slot refused" + ) + select(2, 1) + T.eq(bag.screen, "use", "native use question") + select(3, 2) + T.eq(bag.screen, "pocket", "use back returns to pocket") + select(4, 9) + T.eq(bag.screen, "menu", "pocket back returns to categories") + if native then + button(game, bag, "b") + else + T.check(submit(ui, 5, "back"), "battle bag cancel") + end + T.check(bag.press ~= nil, "cancel animation pending") + tickUntil(game, bag, function() + return bag.press == nil + end) + traces[#traces + 1] = { + depth = #game.stack.states, + count = game.save.inventory.POTION, + page = bag.screen, + closed = bag.closed, + } + end + T.same(traces[1], traces[2], version .. " battle bag matches native A/B") +end + +-- opts.auto/stay/choice govern the final page, not the manual paragraph +-- breaks before it. Do not strand a question that spans several pages. +for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + local ui, game = boot("crystal") + local box = TextBox.new(game, "First\fLast", nil, opts) + game.stack:push(box) + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.eq(ui:snapshot().canAdvance, true, "script tail still allows manual intermediate page") + T.check(submit(ui, 1, "advance"), "intermediate question/automatic/stay page advances") + tickUntil(game, box, function() + return box.done + end) + T.eq(ui:snapshot().canAdvance, false, "final script-owned page stays protected") +end +-- Wrong Game, unknown overlays, script-owned bags and pending controllers. +do + local ui, game = boot("platinum") + local bag = require("src.ui.Gen4BagMenu").new(game, {}) + game.stack:push(bag) + for _, key in ipairs({ "moving", "trash", "script", "noInput" }) do + bag[key] = true + local snap = ui:snapshot() + T.eq(snap.locked, true, "busy bag locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "busy bag refusal") + bag[key] = nil + end + bag.battle = { demo = true } + local snap = ui:snapshot() + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle tutorial owns bag") + bag.battle = nil + bag.game = {} + T.eq(ui:snapshot(), nil, "wrong game screen unavailable") +end +-- Existing helpers remain usable with their original dot-call contract. +do + local ui, _, other = boot("red") + local rows = { { label = "a" }, { label = "c" } } + ui.insertBefore(rows, "c", { label = "b" }) + T.eq(rows[2].label, "b", "legacy builder still works") + T.eq(rawget(other, "snapshot") ~= nil, true, "each mod owns its facade methods") +end + +-- API 1 gets the same additive surface without changing its content format. +for _, version in ipairs({ "red", "emerald", "black" }) do + local ui, game, _, loader = boot(version, 1) + game.stack:push(ChoiceBox.new(game, function() end)) + T.eq(ui:snapshot().kind, "choice", "API 1 UI reader") + T.check(submit(ui, 1, "back"), "API 1 UI action") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("ui", "snapshot"), true, "optional reader capability") + T.eq(loader.apis.probe:supports("ui", "submit"), true, "optional action capability") + T.eq(loader.apis.probe:supports("ui", "unknown"), false, "unknown capability declined") + end +end +-- A real battle opens its picker via mod.battle:submit. Targeting the picker +-- consumes the current UI context, then native callbacks resume the battle. +do + local ui, game, _, loader = boot("crystal") + local BattleState = require("src.battle.BattleState") + local b = BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.queue, b.menuIndex = "menu", {}, 1 + game.stack:push(b) + local api = loader.apis.probe.battle + local snapshot = api:snapshot() + T.check( + api:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "party" }), + "battle opens native party UI" + ) + for _ = 1, 100 do + if game.stack:top() ~= b then + break + end + b:updateQueue() + end + T.eq(ui:snapshot().kind, "party", "battle picker exposed by UI facade") + local oldBattle = api:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "choose switching party slot") + T.eq(ui:snapshot().kind, "party_actions", "native battle switch submenu") + local switch + for i, row in ipairs(ui:snapshot().rows) do + if row.action == "battle_switch" then + switch = i + end + end + T.check(submit(ui, 2, "select", { index = switch }), "native switch callback dispatched") + T.eq(game.stack:top(), b, "native battle resumes") + T.check(#b.queue > 0, "native switch queues battle work") + T.eq( + api:submit({ id = 2, revision = oldBattle.revision, kind = "menu", choice = "fight" }), + nil, + "UI work expires battle context" + ) +end + +-- Link/temporary party arrays can differ from save.party. Track the actual +-- native target, including changes made while its action submenu is open. +do + local ui, game = boot("crystal") + local copy = { Pokemon.new(game.data, "FIXMON_C", 15), Pokemon.new(game.data, "FIXMON_B", 12) } + local party = require("src.ui.PartyMenu").new(game, { party = copy }) + game.stack:push(party) + T.check(submit(ui, 1, "select", { index = 1 }), "open temporary party submenu") + local snapshot = ui:snapshot() + copy[1].hp = copy[1].hp - 1 + T.eq( + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party HP invalidates submenu" + ), + "stale UI context", + "actual native party tracked" + ) + snapshot = ui:snapshot() + local replacement = {} + for k, v in pairs(copy[1]) do + replacement[k] = v + end + copy[1] = replacement + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party object identity tracked" + ) +end +GameVersion.set(original) +T.finish("UI actions") diff --git a/tests/engine/unified_content.lua b/tests/engine/unified_content.lua new file mode 100644 index 000000000..b7695caa9 --- /dev/null +++ b/tests/engine/unified_content.lua @@ -0,0 +1,470 @@ +-- Synthetic extractor-shaped datasets; all mod changes go through Loader. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local Merge = require("src.mods.Merge") +local DELETE = Merge.DELETE +local Registry = require("src.mods.Registry") +local Runtime = require("src.mods.Runtime") +local original = Version.get() +local function raises(fn, fragment) + return T.raises(fn, fragment, "rejects " .. fragment) +end +local versions = { "red", "crystal", "emerald", "platinum", "black" } +local function fixture(gen) + local ds = gen >= 4 + local keys = { mon = ds and 25 or gen == 2 and "SPECIES_025" or "PIKACHU", + into = ds and 26 or gen == 2 and "SPECIES_026" or "RAICHU", + move = ds and 33 or gen == 2 and "MOVE_033" or "TACKLE", + item = ds and 17 or gen == 2 and "ITEM_017" or "POTION", + trainer = ds and 7 or "OPP_PROBE" } + local stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 } + if gen > 1 then stats.spatk, stats.spdef = 50, 60 end + if ds then stats.spAttack, stats.spDefense = 50, 60 end + local mon = { id = keys.mon, name = "Pikachu", dex = 25, types = { "NORMAL" }, + baseStats = stats, catchRate = 190, baseExp = 82, growthRate = "MEDIUM_FAST", + level1Moves = { keys.move }, learnset = { { level = 9, move = keys.move } }, tmhm = { keys.move }, + evolutions = { ds and { method = "level", target = keys.into, param = 20, methodId = 4, paramKind = "level" } + or { method = "LEVEL", species = keys.into, level = 20 } }, + spriteFront = "synthetic-front.png", spriteBack = "synthetic-back.png", frontSize = 7, + customNative = { sentinel = "retained" } } + if gen >= 3 then mon.evYield = { speed = 2 }; mon.abilities = { "STATIC" } end + local into = Merge.deepCopy(mon); into.id, into.name, into.dex, into.evolutions = keys.into, "Raichu", 26, {} + local move = { id = keys.move, name = "Tackle", type = "NORMAL", power = 35, accuracy = 95, pp = 35, + effect = "NO_ADDITIONAL_EFFECT", priority = 0, untouchedTarget = 6 } + if ds then move.class, move.classId, move.typeId, move.gen4Effect = "physical", 0, 0, 0 end + local item = { id = keys.item, name = "Potion", price = 300, tossable = true, heldEffect = 27 } + local party = { { species = keys.mon, level = 12, item = gen > 1 and keys.item or nil, + moves = { keys.move }, customIV = 31 } } + local trainer = { id = keys.trainer, name = "Probe", parties = { party }, baseMoney = 10, aiMods = { 9 } } + if ds then trainer.party = party; trainer.nativeFlag = 17 end + local grass = { rate = 20, slots = { { species = keys.mon, level = 5 }, { species = keys.into, level = 7 } }, + buckets = { 128, 256 } } + if gen == 2 then + grass.byTime = { morn = Merge.deepCopy(grass), nite = Merge.deepCopy(grass) } + grass.byTime.nite.rate = 10 + elseif gen == 3 then + grass.rateMax, grass.bucketSpan, grass.buckets = 180, 100, { 50, 100 } + grass.slots[1].level, grass.slots[1].min, grass.slots[1].max = nil, 4, 6 + end + local data = { pokemon = { [keys.mon] = mon, [keys.into] = into }, moves = { [keys.move] = move }, + items = { [keys.item] = item }, trainers = { [keys.trainer] = trainer }, + encounters = { ROUTE_PROBE = { grass = grass } }, constants = { generation = gen }, audio = {} } + if ds then + data.items.ITEM_017 = item + data.encounters = { [1] = { grassRate = 20, grass = { { species = keys.mon, level = 5, chance = 100 } } } } + end + return data, keys +end +local function run(gen, body, format, extra, supplied, selected) + Version.set(selected or versions[gen]) + local data, keys = fixture(gen) + data = supplied or data + local mods = { probe = { entry = body, format = format or "unified" } } + for id, spec in pairs(extra or {}) do mods[id] = spec end + local loader + local fs = { + read = function(path) + local id = path:match("^mods/([^/]+)/manifest.json$") + local mod = id and mods[id] + if mod then return ('{"id":"%s","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua","content_format":"%s","priority":%d}') + :format(id, mod.format or "unified", mod.priority or 0) end + end, + getDirectoryItems = function(path) + if path ~= "mods" then return {} end + local out = {}; for id in pairs(mods) do out[#out + 1] = id end; return out + end, + getInfo = function(path) + if path:match("main.lua$") or path:match("manifest.json$") then return { type = "file" } end + if path == "mods" or path:match("^mods/[^/]+$") then return { type = "directory" } end + end, + load = function(path) + local id = path:match("^mods/([^/]+)/main.lua$") + if mods[id] then return function(api) return mods[id].entry(api, loader) end end + end, + } + loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error("content must not load Game") end + loader:load(data) + return loader.apis.probe, loader, data, keys +end +local function ok(value, reason) assert(value, reason) end +local function ops(registry) local n = 0; for _, rows in pairs(registry.ops) do n = n + #rows end; return n end +local function dataOnly(v) + if type(v) == "function" then return "" end + if type(v) ~= "table" then return v end + local out = {}; for k, sub in pairs(v) do out[k] = dataOnly(sub) end; return out +end + +-- Native defaults, manifest rejection and registry numeric-ID confinement. +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 1, + content_format = "unified" }, "mods/bad") end, "unified content requires mod API 2") +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 2, + content_format = "unifed" }, "mods/bad") end, "content_format must be native or unified") +raises(function() Registry.new("legacy"):patch(25, {}) end, "id is required") + +local savedLove = love; love = nil +for gen = 1, 5 do + local api, loader, data, keys = run(gen, function(mod) + T.eq(mod.contentFormat, "unified", "manifest selects unified content in entry") + if mod.supports then + T.eq(mod:supports("content", "encounters"), gen <= 3, "host query follows selected content format") + T.eq(mod:supports("hooks", "battle.damage"), true, "other capability surfaces retain host query") + end + local pokemon = assert(mod.content.pokemon:get("PIKACHU")) + T.eq(pokemon.id, "PIKACHU", "canonical species identity") + T.eq(pokemon.level1Moves[1], "TACKLE", "canonical starting move") + T.eq(pokemon.evolutions[1].species, "RAICHU", "canonical evolution target") + T.eq(pokemon.baseStats.special, 50, "canonical Special") + T.eq(pokemon.baseStats.specialDefense, gen > 1 and 60 or nil, "split Special retained") + pokemon.baseStats.hp = 99 + pokemon.native.customNative.sentinel = "local" + T.eq(mod.content.pokemon:get("PIKACHU").baseStats.hp, 35, "read is detached") + local _, why = mod.content.pokemon:patch("PIKACHU", pokemon) + T.check(why and why:find("read%-only"), "native diagnostics cannot be written") + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 70 } })) + ok(mod.content.moves:patch("TACKLE", { power = 45 })) + ok(mod.content.items:patch("POTION", { price = 100 })) + local trainerId = gen >= 4 and "7" or "OPP_PROBE" + local trainer = assert(mod.content.trainers:get(trainerId)) + T.eq(trainer.parties[1][1].species, "PIKACHU", "canonical trainer species") + T.eq(trainer.parties[1][1].moves[1], "TACKLE", "canonical trainer moves") + trainer.parties[1][1].level = 18 + ok(mod.content.trainers:patch(trainerId, { parties = trainer.parties })) + local _, whyMissing = mod.content.moves:patch("ABSENT", { power = 5 }) + T.check(whyMissing ~= nil, "absent patch declines with reason") + local encounter = mod.content.encounters + if gen <= 3 then + T.check(encounter:supports(), "GB/GBA encounter adapter available") + local row = assert(encounter:get("ROUTE_PROBE")) + row.grass.slots[1].species = "RAICHU" + ok(encounter:patch("ROUTE_PROBE", { grass = { slots = row.grass.slots } })) + else + local available, why = encounter:supports() + T.check(available == false and type(why) == "string", "DS encounters explicitly unsupported") + local result, err = encounter:patch("1", { grass = { rate = 10 } }) + T.check(result == nil and type(err) == "string", "DS encounter writes decline safely") + end + end) + T.eq(loader.mods.probe.state, "loaded", versions[gen] .. ": real entry/merge succeeds") + T.eq(#loader.errors, 0, versions[gen] .. ": no cross-reference errors") + T.eq(data.pokemon[keys.mon].baseStats.special, 70, "native Special alias updated") + T.eq(data.pokemon[keys.mon].baseStats.spdef, gen > 1 and 70 or nil, "native Special Defense updated") + T.eq(data.pokemon[keys.mon].customNative.sentinel, "retained", "native extensions survive") + T.eq(data.moves[keys.move].power, 45, "native move power updated") + T.eq(data.moves[keys.move].untouchedTarget, 6, "native move targeting preserved") + T.eq(data.items[keys.item].price, 100, "native item price updated") + T.eq(data.items[keys.item].heldEffect, 27, "native held effect preserved") + T.eq(data.trainers[keys.trainer].parties[1][1].level, 18, "native trainer party updated") + T.eq(data.trainers[keys.trainer].parties[1][1].customIV, 31, "native trainer metadata preserved") + T.eq(api.content.pokemon:get("PIKACHU").baseStats.special, 70, "read works after merge/freeze") + if gen >= 4 then + T.eq(data.items.ITEM_017.price, 100, "DS item string alias updated together with numeric ID") + T.eq(api.content.items:get("POTION").price, 100, "DS alias identity survives merge") + T.same(data.trainers[keys.trainer].party, data.trainers[keys.trainer].parties[1], "DS party aliases stay synchronized") + end + raises(function() api.content.moves:patch("TACKLE", { power = 99 }) end, "frozen") + local count = 0 + for id, row in api.content.items:each() do count = count + 1; T.eq(id, row.id, "iterator canonical ID") end + T.eq(count, 1, "iterator coalesces native aliases") +end +love = savedLove or require("tests.love_stub") + +-- No-op canonical round trips preserve every native field and native format. +for gen = 1, 5 do + local _, baseline = run(gen, function() end, "native") + local _, canonical, data = run(gen, function(mod) + local kinds = { "pokemon", "moves", "items", "trainers" } + if gen <= 3 then kinds[#kinds + 1] = "encounters" end + for _, kind in ipairs(kinds) do + for id, record in mod.content[kind]:each() do ok(mod.content[kind]:override(id, record)) end + end + end) + T.eq(canonical.mods.probe.state, "loaded", "round-trip load succeeds") + T.same(dataOnly(data), dataOnly(baseline.baseData), versions[gen] .. ": full native round-trip parity") +end + +-- Unsupported/malformed operations must append nothing and preserve input. +for gen = 1, 5 do + local _, loader = run(gen, function(mod) + local pokemon = mod.content.pokemon + local before = mod.content.pokemon:get("PIKACHU") + local invalid = { baseStats = { hp = 0 } } + raises(function() pokemon:patch("PIKACHU", invalid) end, "invalid") + T.eq(invalid.baseStats.hp, 0, "invalid caller payload untouched") + raises(function() pokemon:patch("PIKACHU", { base_stats = {} }) end, "unknown") + raises(function() pokemon:patch("PIKACHU", { baseStats = DELETE }) end, "required") + raises(function() pokemon:patch("PIKACHU", { learnset = { [2] = { level = 4, move = "TACKLE" } } }) end, "contiguous") + local cyclic = {}; cyclic.foo = cyclic + raises(function() pokemon:patch("PIKACHU", cyclic) end, "cyclic") + raises(function() mod.content.moves:patch("TACKLE", { power = 0 / 0 }) end, "non-finite") + local result, reason = pokemon:patch("PIKACHU", { level1Moves = { "MISSING" } }) + T.check(result == nil and reason:find("unknown moves ID", 1, true), "unknown cross-generation reference refuses") + T.same(pokemon:get("PIKACHU"), before, "failed operations leave effective record intact") + end) + T.eq(loader.mods.probe.state, "loaded", "caught refusals do not disable mod") + T.eq(ops(loader.content.pokemon), 0, "refused operations leave no Pokemon op log") +end + +-- Empty lists, append/prepend, DELETE and stacking with native mods. +local _, lists, data = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = {} })) + T.eq(#mod.content.pokemon:get("PIKACHU").learnset, 0, "empty list clears immediately") + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __append = { { level = 10, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __prepend = { { level = 5, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { tmhm = mod.DELETE })) +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, + later = { format = "native", priority = 1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { defense = 75 } }) + end }, +}) +T.eq(lists.mods.probe.state, "loaded", "list and native stacking load") +T.eq(data.pokemon.SPECIES_025.learnset[1].level, 5, "prepend before append") +T.eq(data.pokemon.SPECIES_025.learnset[2].level, 10, "append retains order") +T.eq(#data.pokemon.SPECIES_025.tmhm, 0, "DELETE materializes empty native list") +T.eq(data.pokemon.SPECIES_025.baseStats.attack, 80, "earlier native patch preserved") +T.eq(data.pokemon.SPECIES_025.baseStats.defense, 75, "later native patch preserved") + +-- An entry failure rolls every canonical/native alias operation back. +for gen = 1, 5 do + local _, loader, data, keys = run(gen, function(mod) + ok(mod.content.items:patch("POTION", { price = 1 })) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 100 } })) + error("intentional entry failure") + end) + T.eq(loader.mods.probe.state, "failed", "entry failure attributed") + T.eq(data.items[keys.item].price, 300, "failed item edit rolled back") + T.eq(data.pokemon[keys.mon].baseStats.hp, 35, "failed species edit rolled back") + if gen >= 4 then T.eq(data.items.ITEM_017.price, 300, "failed DS alias edit rolled back") end +end + +-- Native consumers see the result, rather than merely passing schema checks. +local _, nativeLoader, data, keys = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 90, specialDefense = 95 } })) + ok(mod.content.trainers:patch("OPP_PROBE", { parties = { { { species = "RAICHU", level = 18, moves = { "TACKLE" } } } } })) + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 32, + slots = { { species = "RAICHU", level = 8 }, { species = "PIKACHU", level = 9 } } } })) +end) +T.eq(nativeLoader.mods.probe.state, "loaded", "consumer probe loads") +local Pokemon = require("src.pokemon.Pokemon") +local mon = Pokemon.new(data, keys.mon, 10, function(a) return a end) +T.check(mon.stats.spatk ~= nil and mon.stats.spdef ~= nil, "native Pokemon constructor keeps split stat model") +local Encounter = require("src.world.Encounter") +for _, time in ipairs({ "MORNING", "DAY", "NIGHT" }) do + local tableAtTime = Encounter.atTime(data.encounters.ROUTE_PROBE.grass, time) + local encountered = Encounter.rollTable(tableAtTime, function(a) return a end) + T.eq(encountered.species, keys.into, time .. ": native encounter chooses translated species") + T.eq(encountered.level, 8, time .. ": native encounter uses translated level") +end +local Battle = require("src.battle.BattleState") +local game = { data = data, save = { options = {}, player = {}, party = {} } } +local party = Battle.buildTrainerParty(game, keys.trainer, 1, data.trainers[keys.trainer].parties[1]) +T.eq(party[1].species, keys.into, "native trainer builder receives translated species") +T.eq(party[1].level, 18, "native trainer builder receives translated level") + +-- Every registered version selects the same format using its own host +-- generation. The facade captures that generation before entry execution. +for _, version in ipairs(Version.ORDER) do + local gen = Version.generation(version) + local _, loader, data, key = run(gen, function(mod) + T.eq(mod.gameVersion, version, "selected host identity: " .. version) + mod.generation, mod.host.generation = 99, 99 + Version.set("red") + ok(mod.content.moves:patch("TACKLE", { power = 42 })) + end, "unified", nil, nil, version) + T.eq(loader.mods.probe.state, "loaded", version .. ": captured host format works") + T.eq(data.moves[key.move].power, 42, version .. ": same portable patch reaches native data") +end + +-- Canonical IDs are based on this ROM's names, not official numeric IDs. +local hacked = fixture(2) +hacked.pokemon.SPECIES_777 = hacked.pokemon.SPECIES_025 +hacked.pokemon.SPECIES_025 = nil +local _, hackLoader, hackData = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 65 } })) +end, "unified", nil, hacked) +T.eq(hackLoader.mods.probe.state, "loaded", "relocated ROM-hack species ID resolves") +T.eq(hackData.pokemon.SPECIES_777.baseStats.hp, 65, "no hard-coded National Dex lookup") + +local ambiguous = fixture(2) +ambiguous.pokemon.SPECIES_999 = Merge.deepCopy(ambiguous.pokemon.SPECIES_025) +local _, ambiguity = run(2, function(mod) + local value, why = mod.content.pokemon:get("PIKACHU") + T.check(value == nil and why:find("ambiguous", 1, true), "ambiguous name has a diagnostic") + local changed, reason = mod.content.pokemon:patch("PIKACHU", { catchRate = 1 }) + T.check(changed == nil and reason:find("ambiguous", 1, true), "ambiguous writes cannot choose a record") + raises(function() mod.content.pokemon:each() end, "ambiguous") +end, "unified", nil, ambiguous) +T.eq(ops(ambiguity.content.pokemon), 0, "ambiguity leaves no ops") + +-- New string-key records can be registered without changing legacy schema +-- behavior. Existing numeric records can be patched, not allocated blindly. +for gen = 1, 5 do + local _, loader, data = run(gen, function(mod) + local new = { id = "CUSTOM_ITEM", name = "Custom item", price = 42 } + local saved = Merge.deepCopy(new) + local changed, why = mod.content.items:register("CUSTOM_ITEM", new) + if gen < 4 then + T.check(changed == true, "string-key item registration succeeds") + T.eq(mod.content.items:get("CUSTOM_ITEM").price, 42, "registration immediately readable") + raises(function() mod.content.items:register("CUSTOM_ITEM", new) end, "already registered") + else T.check(changed == nil and why:find("storage", 1, true), "DS allocation declines without a native slot") end + T.same(new, saved, "registration input remains detached") + local payload = { price = 123 } + ok(mod.content.items:patch("POTION", payload)) + payload.price = 999 + T.eq(mod.content.items:get("POTION").price, 123, "later caller mutation cannot edit op log") + local record = assert(mod.content.pokemon:get("PIKACHU")) + local changed, reason = mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 99 } }) + if gen == 1 then + T.check(changed == nil and reason:find("split", 1, true), "Gen 1 split stat rejected atomically") + T.same(mod.content.pokemon:get("PIKACHU"), record, "rejected split retains native record") + else T.eq(changed, true, "later generation accepts split stat") end + raises(function() mod.content.pokemon:patch("PIKACHU", { types = mod.DELETE }) end, "cannot be deleted") + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { specialDefense = mod.DELETE } }) end, "cannot delete") + end) + T.eq(loader.mods.probe.state, "loaded", "registration/refusal cases survive load") + T.eq(data.items.CUSTOM_ITEM and data.items.CUSTOM_ITEM.price, gen < 4 and 42 or nil, "only supported registrations reach Data") +end + +-- Probability units are uniform (a fraction out of 256) and lossless. +local _, rates, rateData = run(3, function(mod) + local before = mod.content.encounters:get("ROUTE_PROBE") + T.eq(before.grass.rate, 20 * 256 / 180, "GBA rate is projected into common units") + local changed, why = mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 31 } }) + T.check(changed == nil and why:find("representable", 1, true), "unrepresentable GBA rate rejected") + T.same(mod.content.encounters:get("ROUTE_PROBE"), before, "rejected rate leaves slots/weights intact") + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 30 * 256 / 180 } })) +end) +T.eq(rateData.encounters.ROUTE_PROBE.grass.rate, 30, "representable rate maps exactly to native span") + +-- Native DS parser output is edited through the same facade. Its class +-- selector and numeric evolution target remain consistent for real consumers. +local parsed = fixture(4) +local bytes = { 0, 0, 0, 35, 0, 95, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0 } +local chars = {}; for i, byte in ipairs(bytes) do chars[i] = string.char(byte) end +parsed.moves[33] = assert(require("src.import.Gen4Moves").parse(table.concat(chars))) +parsed.moves[33].id, parsed.moves[33].name = 33, "Tackle" +local _, parsedLoader, parsedData = run(4, function(mod) + ok(mod.content.moves:patch("TACKLE", { category = "special", power = 50 })) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = { { method = "LEVEL", level = 15, species = "RAICHU" } } })) +end, "unified", nil, parsed) +T.eq(parsedLoader.mods.probe.state, "loaded", "native parser-produced data accepts canonical edits") +T.eq(parsedData.moves[33].class, "special", "native DS class updated") +T.eq(parsedData.moves[33].classId, 1, "native DS class selector updated") +local Evolution = require("src.pokemon.Evolution") +local target = Evolution.pendingFor({ data = parsedData, save = { options = {} } }, + { species = 25, level = 15 }, { kind = "levelup" }) +T.eq(target, 26, "native evolution consumer follows translated numeric target") + +-- Removing a target retains post-merge reference checks, including numeric +-- trainer references and the otherwise hidden Gen 2 night slot table. +for _, gen in ipairs({ 2, 4 }) do + local _, loader = run(gen, function(mod) ok(mod.content.pokemon:remove("RAICHU")) end) + T.check(#loader.errors > 0, "removed native evolution/encounter target is diagnosed") + T.check(table.concat(loader.errors, "\n"):find("probe", 1, true) ~= nil, "reference failure attributed to removing mod") +end +local _, removal, removedData = run(4, function(mod) + local trainer = mod.content.trainers:get("7") + trainer.parties[1][1].item = nil + ok(mod.content.trainers:patch("7", { parties = trainer.parties })) + ok(mod.content.items:remove("POTION")) + T.eq(mod.content.items:has("POTION"), false, "tombstone immediately hidden in canonical view") +end) +T.eq(#removal.errors, 0, "valid alias removal has no dangling reference") +T.check(removedData.items[17] == nil and removedData.items.ITEM_017 == nil, "numeric/string aliases tombstoned together") + +-- Preferences must still choose original patch operations, not snapshots of +-- earlier mods' changes captured into an adapter's replacement record. +local _, arbitration, arbitrationData = run(2, function(mod, loader) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 75 } })) + loader.content.pokemon:preferOwner("SPECIES_025", "probe") +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, +}) +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.hp, 75, "preferred canonical patch remains") +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.attack, 55, "set-aside native patch does not leak through a snapshot") + +-- The extractor keeps abbreviated GBA type keys, not the Gen 1 keys. +local types = fixture(3) +types.type_chart = { types = { NORMAL = { category = "physical" }, PSYCHC = { category = "special" } }, matchups = {} } +types.moves.TACKLE.type = "PSYCHC" +local _, typeLoader, typeData = run(3, function(mod) + T.eq(mod.content.moves:get("TACKLE").type, "PSYCHIC_TYPE", "abbreviated GBA type is canonical on read") + ok(mod.content.moves:patch("TACKLE", { type = "PSYCHIC_TYPE" })) + ok(mod.content.pokemon:patch("PIKACHU", { types = { "PSYCHIC_TYPE" } })) +end, "unified", nil, types) +T.eq(#typeLoader.errors, 0, "canonical type resolves to actual native registry") +T.eq(typeData.moves.TACKLE.type, "PSYCHC", "native abbreviated move type retained") +T.eq(typeData.pokemon.PIKACHU.types[1], "PSYCHC", "species type references translated") + +-- A pre-existing unmapped native move selector must not become a dangling +-- registry ID merely because a balance mod changes its power. +local opaque = fixture(4) +opaque.moves[33].effect, opaque.moves[33].gen4Effect = 999, 999 +local _, opaqueLoader, opaqueData = run(4, function(mod) + local record = mod.content.moves:get("TACKLE") + T.check(record.effect == nil and record.native.effect == 999, "opaque selector exposed only in native diagnostics") + ok(mod.content.moves:patch("TACKLE", { power = 65 })) +end, "unified", nil, opaque) +T.eq(#opaqueLoader.errors, 0, "untouched numeric effect selector has no false reference error") +T.eq(opaqueData.moves[33].gen4Effect, 999, "opaque native selector survives patch") + +-- Default/explicit native mode continues to expose live native records. +local default = Manifest.validate({ id = "native", name = "Native", version = "1", entry = "main.lua", api = 2 }, "mods/native") +T.eq(default.content_format, "native", "omitted format remains native") +local _, native = run(2, function(mod) + T.eq(mod.contentFormat, "native", "native manifest uses original facade") + T.check(mod.content.pokemon:get("PIKACHU") == nil, "native facade does not invent aliases") + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 99 } }) +end, "native") +T.eq(native.baseData.pokemon.SPECIES_025.baseStats.attack, 99, "native patch still has original behavior") + +-- Full read/modify/write of one time must not flatten the others to DAY. +local timeData = fixture(2) +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.rate = 5 +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level = 2 +local _, timeLoader, editedTimes = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.times.NITE.slots[1].level = 11 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end, "unified", nil, timeData) +T.eq(timeLoader.mods.probe.state, "loaded", "time-specific detached edit succeeds") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.rate, 20, "time edit preserves day rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.rate, 5, "time edit preserves distinct morning rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level, 2, "time edit preserves distinct morning slots") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 11, "time edit changes only requested night slot") + +local _, mixedTimeLoader, mixedTimeData = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.rate = 30 + record.grass.times.NITE.slots[1].level = 12 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end) +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.rate, 30, "inherited night rate cannot undo shared edit") +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 12, "explicit night slot edit survives shared rate") + +local _, conflictingStats = run(2, function(mod) + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 50, specialAttack = 80 } }) end, "conflicting") +end) +T.eq(ops(conflictingStats.content.pokemon), 0, "conflicting stat aliases leave no ops") + +-- The unsupported DS writer must not disable native reference auditing. +local seasonal = fixture(5) +seasonal.encounters[1].seasons = { winter = { grass = { { species = 26, level = 8, chance = 100 } } } } +local _, seasonalLoader = run(5, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = {} })) + ok(mod.content.pokemon:remove("RAICHU")) +end, "unified", nil, seasonal) +T.check(table.concat(seasonalLoader.errors, "\n"):find("seasons.winter.grass", 1, true) ~= nil, + "DS seasonal encounter target is included in removal diagnostics") + +Version.set(original) +T.finish("unified content") diff --git a/tests/engine/unified_mod_callbacks.lua b/tests/engine/unified_mod_callbacks.lua new file mode 100644 index 000000000..a4059d663 --- /dev/null +++ b/tests/engine/unified_mod_callbacks.lua @@ -0,0 +1,634 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Catalog = require("tests.modkit.catalog") +local C = require("src.mods.UnifiedCallbacks") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(format, api) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = api or 2, + callback_format = format, + } +end +T.eq(Manifest.validate(manifest()).callback_format, "native", "existing manifests keep native callbacks") +T.eq(Manifest.validate(manifest("unified")).callback_format, "unified", "opt-in accepted") +T.raises(function() + Manifest.validate(manifest("unified", 1)) +end, "requires mod API 2", "unified requires API 2") +T.raises(function() + Manifest.validate(manifest("other")) +end, "callback_format", "unknown format rejected") +for _, invalid in ipairs({ false, 42, {} }) do + T.raises(function() + Manifest.validate(manifest(invalid)) + end, "callback_format", "malformed format rejected") +end +local function set(names) + local out = {} + for _, n in ipairs(names) do + out[n] = true + end + return out +end +T.same(C.hooks, set(Catalog.hooks()), "every native hook has an explicit boundary decision") +T.same(C.events, set(Catalog.events()), "every native event has an explicit boundary decision") +local function boot(version, format, entry) + Version.set(version) + local numeric = Version.generation() >= 4 + local a, b, m, n, item = + numeric and 501 or "ALPHA", + numeric and 502 or "BETA", + numeric and 601 or "TACKLE", + numeric and 602 or "EMBER", + numeric and 701 or "POTION" + local data = { + pokemon = { + [a] = { + id = a, + name = "Alpha", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 30, special = 35 }, + types = { + "NORMAL", + }, + }, + [b] = { id = b, name = "Beta", baseStats = { hp = 50 }, types = { "NORMAL" } }, + }, + moves = { + [m] = { id = m, name = "Tackle", pp = 35, power = 40 }, + [n] = { + id = n, + name = "Ember", + pp = 25, + power = 40, + }, + }, + items = { [item] = { id = item, name = "Potion" } }, + trainers = {}, + encounters = {}, + } + if numeric then + data.pokemon.SPECIES_501 = data.pokemon[a] + data.moves.MOVE_601 = data.moves[m] + end + local json = require("src.link.Json") + local files = { + ["mods/probe/manifest.json"] = json.encode(manifest(format)), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() + error("callback opt-in must not construct Game") + end + local loaded = loader:load(data) + if not entry then + assert(loaded) + assert(loader.apis.probe, tostring(loader.errors.probe)) + end + return loader.apis.probe, loader, data, { a, b, m, n, item } +end +local function mon(ids) + return { + species = ids[1], + item = ids[5], + level = 10, + hp = 25, + status = "PSN", + stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24, spatk = 24, spdef = 25 }, + moves = { { id = ids[3], pp = 12 } }, + ivs = { hp = 17 }, + personality = 123, + custom = { keep = true }, + } +end +for _, version in ipairs(Version.ORDER) do + local api, loader, data, ids = boot(version, "unified") + local label = version .. ": " + T.eq(api.callbackFormat, "unified", label .. "format exposed before Game construction") + local raw = mon(ids) + if Version.generation() == 1 then + raw.stats.spatk = nil + raw.stats.spdef = nil + end + local unchanged = require("src.mods.Merge").deepCopy(raw) + local noop = api.hooks:wrap("battle.damage", function(next) + return next() + end) + loader.hooks:call("battle.damage", function() + return 5 + end, { user = { mon = raw } }) + T.same(raw, unchanged, label .. "no-op unified callback leaves native record exactly unchanged") + T.eq(raw.isEgg, nil, label .. "false view flag does not materialize native field") + noop() + local originalStats, originalMoves, originalSlot = raw.stats, raw.moves, raw.moves[1] + local battler = + { mon = raw, isPlayer = true, curStats = raw.stats, curMoves = raw.moves, stages = { attack = 0 } } + local battle = { tag = "native handle" } + local count = 0 + local off = api.hooks:wrap("battle.damage", function(next, ctx) + T.eq(ctx.battle, battle, label .. "battle stays native") + T.eq(ctx.user.mon, ctx.target.mon, label .. "shared mon preserves public alias") + T.eq(ctx.user.mon.species, "ALPHA", label .. "canonical species") + T.eq(ctx.user.mon.item, "POTION", label .. "canonical held item") + T.eq(ctx.user.mon.moves[1].id, "TACKLE", label .. "canonical moves") + T.eq(ctx.user.mon.status, "poison", label .. "canonical status") + T.eq(ctx.user.mon.maxHp, 40, label .. "maxHp reads stats.hp") + T.eq(ctx.user.mon.stats.specialAttack, 24, label .. "canonical special attack") + ctx.user.mon.hp = 24 + ctx.user.mon.status = "burn" + ctx.user.mon.nickname = "Test" + ctx.user.mon.stats.attack = 26 + ctx.user.mon.moves[1].id = "EMBER" + ctx.user.mon.moves[1].pp = 20 + local n, hole, last = next() + T.eq(ctx.user.mon.hp, 19, label .. "native downstream changes refreshed") + T.eq(select("#", next()), 3, label .. "cached next retains nil holes") + ctx.user.mon.hp = 18 + return n + 1, hole, last + end) + local n, hole, last = loader.hooks:call("battle.damage", function(ctx) + count = count + 1 + T.eq(ctx.user, battler, label .. "native battler identity restored") + T.eq(ctx.user.mon, raw, label .. "native mon identity restored") + T.eq(raw.hp, 24, label .. "pre-next mutation committed") + T.eq(raw.status, "BRN", label .. "status written in native vocabulary") + T.eq(raw.moves[1].id, ids[4], label .. "move ID restored for host") + raw.hp = 19 + return 5, nil, "tail" + end, { battle = battle, user = battler, target = battler, move = data.moves[ids[3]] }) + T.eq(n, 6, label .. "scalar result postprocessing") + T.eq(last, "tail", label .. "result tail preserved") + T.eq(hole, nil, label .. "nil preserved") + T.eq(count, 1, label .. "vanilla runs once") + T.eq(raw.hp, 18, label .. "post-next edit committed") + T.eq(raw.stats, originalStats, label .. "stats table identity preserved") + T.eq(raw.moves, originalMoves, label .. "moves list identity preserved") + T.eq(raw.moves[1], originalSlot, label .. "move slot identity preserved") + T.eq(raw.personality, 123, label .. "hidden personality retained") + T.eq(raw.ivs.hp, 17, label .. "hidden IVs retained") + off() + + -- Mutations across separate bound records preflight as one transaction. + local other = mon(ids) + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.hp = 4 + ctx.target.mon.level = 101 + return 99 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + return 3 + end, { user = battler, target = { mon = other } }), + 3, + label .. "invalid edit skips wrapper" + ) + T.eq(raw.hp, 18, label .. "earlier record edit rolled back") + T.eq(other.level, 10, label .. "invalid record unchanged") + T.eq(count, 1, label .. "failure before next falls through once") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + local n = next() + ctx.user.mon.hp = -1 + return n + 20 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + raw.hp = 16 + return 7 + end, { user = battler }), + 7, + label .. "bad post-next edit keeps downstream result" + ) + T.eq(raw.hp, 16, label .. "downstream mutation retained") + T.eq(count, 1, label .. "bad post-next never retries") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + next() + ctx.user.mon.hp = 2 + error("after next") + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 8 + end, { user = battler }), + 8, + label .. "throw after next preserves result" + ) + T.eq(raw.hp, 16, label .. "throw after next discards late edits") + off() + + local sentinel = { error = "vanilla" } + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + pcall(next) + return 999 + end) + count = 0 + local ok, err = pcall(function() + loader.hooks:call("battle.damage", function() + count = count + 1 + error(sentinel) + end, { user = battler }) + end) + T.check(not ok and err == sentinel, label .. "vanilla error identity survives swallowed next failure") + T.eq(count, 1, label .. "throwing vanilla never replayed") + off() + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + return 999 + end) + local savedMoves = raw.moves + local okBroken, errBroken = pcall(function() + loader.hooks:call("battle.damage", function() + raw.moves = 42 -- a failed native operation can leave an unreadable record + error(sentinel) + end, { user = battler }) + end) + T.check(not okBroken and errBroken == sentinel, label .. "refresh cannot mask a native error") + raw.moves = savedMoves + off() + + -- Event records are detached. The native handles deliberately remain handles. + local seen = 0 + off = api.events:once("battle.exp_gained", function(payload) + seen = seen + 1 + T.eq(payload.mon.species, "ALPHA", label .. "event mon normalized") + payload.mon.hp = 1 + payload.mon.native.ivs.hp = 1 + end) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + T.eq(seen, 1, label .. "once retained") + T.eq(raw.hp, 16, label .. "event mutation detached") + T.eq(raw.ivs.hp, 17, label .. "native diagnostic snapshot detached") + off() + local gift = { species = ids[1], level = 5, ctx = { opaque = true } } + off = api.events:on("pokemon.before_give", function(p) + p.species = "BETA" + p.level = 15 + p.nickname = "Gift" + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "gift ID restored") + T.eq(gift.level, 15, label .. "gift level committed") + T.eq(gift.nickname, "Gift", label .. "gift nickname committed") + off() + off = api.events:on("pokemon.before_give", function(p) + p.species = "ALPHA" + p.level = 101 + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "invalid gift atomic species") + T.eq(gift.level, 15, label .. "invalid gift atomic level") + off() + + off = api.hooks:wrap("battle.turn_order", function(next, p, pm, e, em, ctx) + T.eq(pm.id, "TACKLE", label .. "move definition canonical ID") + T.eq(next(), p, label .. "native result maps to original public battler") + return e + end) + local enemy = { mon = other, isPlayer = false } + T.eq( + loader.hooks:call("battle.turn_order", function(p) + return p + end, battler, data.moves[ids[3]], enemy, data.moves[ids[4]], {}), + enemy, + label .. "selected battler restores identity" + ) + off() + off = api.hooks:wrap("encounter.species", function(next, enc) + enc.species = "BETA" + enc.level = 20 + return next() + end) + local encounter = { species = ids[1], level = 10, form = 2 } + T.eq( + loader.hooks:call("encounter.species", function(enc) + return enc + end, encounter, {}), + encounter, + label .. "encounter identity retained" + ) + T.eq(encounter.species, ids[2], label .. "encounter species converted") + T.eq(encounter.form, 2, label .. "encounter hidden field retained") + off() + off = api.hooks:wrap("trainer.party", function(next, cl, index, party) + T.eq(party[1].species, "ALPHA", label .. "trainer slot canonical species") + T.eq(party[1].moves[1], "TACKLE", label .. "trainer slot moves are IDs") + party[1].species = "BETA" + party[1].moves = { "EMBER" } + party[1].level = 20 + return next() + end) + local party = { { species = ids[1], level = 10, moves = { ids[3] }, dvs = { attack = 15 } } } + T.eq( + loader.hooks:call("trainer.party", function(_, _, p) + return p + end, "CLASS", 1, party), + party, + label .. "trainer party native identity" + ) + T.eq(party[1].species, ids[2], label .. "trainer species restored") + T.eq(party[1].moves[1], ids[4], label .. "trainer move restored") + T.eq(party[1].dvs.attack, 15, label .. "trainer hidden DVs retained") + off() + + count = 0 + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + local p = ctx.alive[1] + p.hp = 15 + ctx.applyShare(p, 2, false) + T.eq(p.level, 11, label .. "applyShare refreshes public mon") + p.hp = 14 + end) + loader.hooks:call("battle.exp_award", function() + error("wrapper replaces vanilla") + end, { + battle = battle, + alive = { raw }, + applyShare = function(p, divisor, announce) + count = count + 1 + T.eq(p, raw, label .. "helper gets native identity") + T.eq(p.hp, 15, label .. "helper sees pending edits") + T.eq(divisor, 2, label .. "helper scalar args") + T.eq(announce, false, label .. "helper false arg") + p.level = 11 + end, + }) + T.eq(count, 1, label .. "helper called once") + T.eq(raw.hp, 14, label .. "helper postprocessing committed") + off() + + off = api.hooks:wrap("fieldmove.eligibility", function(next, id) + T.eq(id, "TACKLE", label .. "field move argument normalized") + local p = next() + T.eq(p.species, "ALPHA", label .. "field move result normalized") + return p + end) + T.eq( + loader.hooks:call("fieldmove.eligibility", function(id) + T.eq(id, ids[3], label .. "field move argument restored") + return raw + end, ids[3], {}), + raw, + label .. "field move mon result identity restored" + ) + off() + off = api.hooks:wrap("battle.enemy_action", function(next) + local action = next() + T.eq(action.id, "TACKLE", label .. "enemy action normalized") + action.id = "EMBER" + return action + end) + local action = { id = ids[3], index = 1 } + T.eq( + loader.hooks:call("battle.enemy_action", function() + return action + end, battle, battler), + action, + label .. "enemy action identity restored" + ) + T.eq(action.id, ids[4], label .. "enemy action ID restored") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "BETA", level = 30 } + end) + local enc = loader.hooks:call("encounter.roll", function() + error("suppressed") + end, nil, {}) + T.eq(enc.species, ids[2], label .. "new encounter result native species") + T.eq(enc.level, 30, label .. "new encounter level") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "MISSING", level = 30 } + end) + T.eq( + loader.hooks:call("encounter.roll", function() + return "fallback" + end, nil, {}), + "fallback", + label .. "unknown species rejected" + ) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.move.power = 999 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 4 + end, { move = data.moves[ids[3]] }), + 4, + label .. "definition mutation rejected" + ) + T.eq(data.moves[ids[3]].power, 40, label .. "definition unchanged") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + ctx.user.mon.maxHp = 50 + ctx.user.mon.stats.specialAttack = 28 + next() + return 0 + end) + loader.hooks:call("battle.damage", function() + T.eq(raw.stats.hp, 50, label .. "maxHp writes native stats.hp") + T.eq(raw.stats.special, 28, label .. "special alias kept") + T.eq( + Version.generation() == 1 and raw.stats.special or raw.stats.spatk, + 28, + label .. "split special stat writes correctly" + ) + end, { user = battler }) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.native.ivs.hp = 9 + ctx.user.mon.hp = 2 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 3 + end, { user = battler }), + 3, + label .. "native snapshot mutation rejected" + ) + T.eq(raw.hp, 14, label .. "snapshot rejection atomic") + off() + local nativeCalls = 0 + local undoNative = loader.hooks:wrap("battle.damage", function(next, ctx) + nativeCalls = nativeCalls + 1 + T.eq(ctx.user, battler, label .. "native mod sees native battler") + return next() + end, -5, "native") + off = api.hooks:wrap("battle.damage", function(next, ctx) + -- Explicit argument packs accept bound records inside a bound context. + ctx.extra = 123 + return next(ctx) + end, 5) + loader.hooks:call("battle.damage", function(ctx) + T.eq(ctx.extra, 123, label .. "explicit next args committed") + end, { user = battler }) + T.eq(nativeCalls, 1, label .. "mixed native/unified chain") + off() + undoNative() + local order = {} + local a = api.events:on("battle.started", function() + order[#order + 1] = "low" + end, -1) + local b = api.events:on("battle.started", function() + order[#order + 1] = "high" + end, 1) + loader.events:emit("battle.started", {}) + T.same(order, { "high", "low" }, label .. "event priority retained") + a() + b() + local actionView + off = api.events:on("battle.turn_started", function(p) + actionView = p.playerAction + end) + loader.events:emit("battle.turn_started", { playerAction = { id = ids[3], index = 1 } }) + T.eq(actionView.id, "TACKLE", label .. "turn event action ID normalized") + off() + + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.moves = { [2] = { id = "TACKLE", pp = 1, ppUps = 0 } } + ctx.user.mon.hp = 1 + return 99 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 2 + end, { user = battler }), + 2, + label .. "sparse move list rejected" + ) + T.eq(raw.hp, 14, label .. "sparse move list fails atomically") + off() + local retainedNext, retainedShare + off = api.hooks:wrap("battle.exp_award", function(next, ctx) + retainedNext = next + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() + error("must not run") + end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedNext() + end, "only valid during", label .. "retained next cannot run after callback") + T.raises(function() + retainedShare(raw) + end, "bound Pokemon", label .. "helper rejects native mon") + off() + local heldMon + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + heldMon = ctx.alive[1] + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedShare(heldMon) + end, "closed", label .. "retained helper cannot write after callback") + off() + + local custom = { mon = raw } + local got + off = api.events:on("mod.other.ready", function(p) + got = p + end) + loader.events:emit("mod.other.ready", custom) + T.eq(got, custom, label .. "custom payload identity untouched") + off() + T.raises(function() + api.events:emit("mod.other.ready", {}) + end, "only emit", label .. "cannot spoof other mod") + T.raises(function() + api.events:emit("mod.probe.", {}) + end, "only emit", label .. "empty custom suffix rejected") + T.raises(function() + api.hooks:wrap("invented.hook", function() end) + end, "unknown unified", label .. "unknown hook rejected") + T.raises(function() + api.events:on("invented.event", function() end) + end, "unknown unified", label .. "unknown event rejected") +end +-- The native path must not install any wrapper or copy its payloads. +local api, loader = boot("crystal") +T.eq(api.callbackFormat, "native", "default API native") +local raw = { mon = { species = 42 } } +api.events:on("custom.engine", function(p) + T.eq(p, raw, "native event identity unchanged") +end) +loader.events:emit("custom.engine", raw) +api.hooks:wrap("custom.hook", function(next, ctx) + T.eq(ctx, raw, "native hook identity unchanged") + return next() +end) +T.eq( + loader.hooks:call("custom.hook", function(ctx) + return ctx + end, raw), + raw, + "native result identity unchanged" +) +-- Entry rollback removes unified subscriptions using the same native owner. +local _, failed = boot( + "crystal", + "unified", + [[return function(mod) + mod.events:on('battle.started',function() end) + mod.hooks:wrap('battle.damage',function() end) + error('rollback probe') +end]] +) +T.check(#failed.errors > 0, "failed entry attributed") +T.check( + not failed.events.listeners["battle.started"] or #failed.events.listeners["battle.started"] == 0, + "failed entry event removed" +) +T.check( + not failed.hooks.chains["battle.damage"] or #failed.hooks.chains["battle.damage"] == 0, + "failed entry hook removed" +) +Version.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +T.finish() diff --git a/tests/engine/unified_move_effects.lua b/tests/engine/unified_move_effects.lua new file mode 100644 index 000000000..8d2c0c6af --- /dev/null +++ b/tests/engine/unified_move_effects.lua @@ -0,0 +1,208 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +local E = require("src.mods.UnifiedEffects") +local C = require("src.mods.MoveEffectContract") +local Native = require("src.battle.MoveEffects").RECORDS +local function registered(id) + return Native[id] ~= nil +end +local accepted = 0 +for id, bindings in pairs(C.bindings) do + for g, row in pairs(bindings) do + local gen = tonumber(g) + local old = { id = row.semantic == 37 and "REST" or row.semantic == 130 and "SONICBOOM" or "POUND" } + local plan, why = E.write(id, gen, old, registered) + if plan then + accepted = accepted + 1 + T.check(registered(plan.effect), "accepted effect has actual handler " .. g .. ":" .. id) + if gen > 1 then + plan.effectChance = plan.probability and math.floor(plan.probability * 256 / 100 + 0.5) + end + T.eq( + E.read(plan.effect, gen, plan), + ({ HIT = "NO_ADDITIONAL_EFFECT", SLEEP = "SLEEP_EFFECT" })[id] or id, + "authored canonical effect read " .. g .. ":" .. id + ) + else + T.check(type(why) == "string", "unrepresentable mapping has reason") + end + end +end +T.check(accepted > 300, "reference matrix exercises hundreds of native bindings") +local Contract = require("tests.api3_contract.effects") +for _, v in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", v) + T.check(ok, v .. " shared effect contract: " .. tostring(why)) +end +for gen = 1, 3 do + local custom = assert(E.write("mod:custom", gen, nil, function(id) + return id == "mod:custom" + end)) + T.eq(custom.effect, "mod:custom", "registered custom native effect selected") + T.eq(E.read(custom.effect, gen, custom), "mod:custom", "custom canonical identity") + T.eq(E.write("unknown", gen, nil, registered), nil, "unknown handler refused") + T.eq(E.write("native:" .. gen .. ":made_up", gen, nil, registered), nil, "forged opaque effect refused") + T.eq( + E.write("native:" .. gen .. ":unhandled", gen, { effect = "unhandled" }, registered).effect, + "unhandled", + "original opaque identity retained" + ) +end +for _, id in ipairs({ + "FOLLOW_ME", + "HELPING_HAND", + "MAGIC_COAT", + "SNATCH", + "IMPRISON", + "ASSIST", + "NATURE_POWER", + "SECRET_POWER", +}) do + T.eq(E.write(id, 3, nil, registered), nil, "missing native Gen3 handler refused " .. id) +end +for _, v in ipairs({ "gold", "firered" }) do + Version.set(v) + local gen = Version.generation() + local plan = assert(E.write("SPECIAL_UP1_EFFECT", gen, { id = "POUND" }, registered)) + local user = { name = "TEST", isPlayer = true, mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + Native[plan.effect].run({ battle = { data = {} }, user = user, target = target, move = plan }) + T.eq(user.stages.spatk, 1, "actual handler raises Special Attack") + T.eq(user.stages.special, nil, "actual handler avoids Gen1 Special stage") + T.eq(user.stages.spdef, nil, "actual handler does not raise Special Defense") + plan = assert(E.write("SPECIAL_DOWN_SIDE_EFFECT", gen, { id = "POUND" }, registered)) + plan.effectChance = 85 + Native[plan.effect].run({ battle = { + data = {}, + rng = function() + return 0 + end, + }, user = user, target = target, move = plan }) + T.eq(target.stages.spdef, -1, "actual handler lowers Special Defense") + plan = assert(E.write("BURN_SIDE_EFFECT1", gen, { id = "POUND" }, registered)) + plan.effectChance = math.floor(plan.probability * 256 / 100 + 0.5) + plan.id, plan.type = "POUND", "FIRE" + local battle = { + data = {}, + rng = function() + return 25 + end, + } + Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan }) + T.eq(target.mon.status, "BRN", "native burn lands below converted threshold") + target.mon.status = nil + battle.rng = function() + return 26 + end + Native[plan.effect].run({ battle = battle, user = user, target = target, move = plan }) + T.eq(target.mon.status, nil, "native burn misses at converted threshold") +end +for gen = 2, 3 do + local old = { id = "POUND", effect = "BURN_SIDE_EFFECT1", effectChance = 27 } + local public = E.read(old.effect, gen, old) + local plan, changed = E.write(public, gen, old, registered) + T.eq(plan.effect, old.effect, "original raw effect roundtrip") + T.eq(changed, false, "roundtrip does not rewrite original odds") + T.eq(E.chance(old, gen), 11, "native fraction projected to whole percent") + T.eq( + E.write("NO_ADDITIONAL_EFFECT", 4, old, registered), + nil, + "later generation changing effect still unavailable" + ) + T.eq(E.read(999, 4, {}), nil, "DS opaque selector retains diagnostic-only behavior") +end +-- Registry operations use the adapter before journaling; refusals are atomic. +local function load(gen, entry, odds) + Version.set(gen == 1 and "red" or gen == 2 and "gold" or "firered") + local data = require("tests.api3_contract.run").dataFor("gen2", gen) + local loader + local fs = { + read = function(path) + if path:match("manifest.json$") then + return '{"id":"probe","name":"Probe","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"}' + end + end, + getInfo = function(path) + if path:match("^mods/probe/.+%.%w+$") then + return { type = "file" } + end + if path == "mods" or path == "mods/probe" then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "probe" } or {} + end, + load = function() + return function(api) + entry(api, loader, data) + end + end, + } + data.moves.POUND.effect = "BURN_SIDE_EFFECT1" + data.moves.POUND.effectChance = odds or 27 + if gen == 3 then + data.moves.POUND.gen3Effect = 4 + data.moves.POUND.secondaryChance = 11 + end + loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = Version.get() }) + loader._game = function() + error("content must not bootstrap Game") + end + loader:load(data) + T.eq(#loader.errors, 0, "public effect mod loads") + return data +end +for gen = 2, 3 do + local data = load(gen, function(mod, loader) + local moves = mod.content.moves + local row = assert(moves:get("POUND")) + T.eq(row.secondaryChance, 11, "public native odds projection") + assert(moves:override("POUND", row)) + T.eq(moves:get("POUND").native.effectChance, 27, "detached override keeps exact native odds") + local operations = #loader.content.moves.ops.POUND + T.eq(moves:patch("POUND", { effect = "SNATCH" }), nil, "unimplemented effect operation refused") + T.eq(#loader.content.moves.ops.POUND, operations, "failed mapping adds no operation") + T.check(not pcall(function() + moves:patch("POUND", { secondaryChance = 101 }) + end), "invalid percent rejected") + T.eq(#loader.content.moves.ops.POUND, operations, "invalid chance adds no operation") + assert(moves:patch("POUND", { effect = "FLINCH_SIDE_EFFECT2", secondaryChance = 5 })) + T.eq(moves:get("POUND").effect, "FLINCH_SIDE_EFFECT2", "authored alias persists after odds edit") + assert(moves:patch("POUND", { effect = "BURN_SIDE_EFFECT1", secondaryChance = 0 })) + T.eq(moves:get("POUND").secondaryChance, 0, "zero probability retained") + end) + local target = { name = "TARGET", mon = { hp = 20, stats = { hp = 20 } }, stages = {} } + Native[data.moves.POUND.effect].run({ + battle = { + data = {}, + rng = function() + return 0 + end, + }, + user = {}, + target = target, + move = data.moves.POUND, + }) + T.eq(target.mon.status, nil, "actual handler respects explicit zero instead of native default") +end +for gen = 2, 3 do + local data = load(gen, function(mod) + local moves = mod.content.moves + assert(moves:override("POUND", assert(moves:get("POUND")))) + T.eq( + moves:get("POUND").native._api3EffectChance, + nil, + "untouched zero-byte roundtrip preserves native fallback" + ) + assert(moves:patch("POUND", { secondaryChance = 0 })) + T.eq(moves:get("POUND").native._api3EffectChance, true, "explicit zero patch arms native zero semantics") + end, 0) +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod = saved[2], saved[3], saved[4], saved[5] +love = saved[6] +T.finish() diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index 698c5c9f7..4df8fec72 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -13,22 +13,36 @@ local Json = require("src.link.Json") -- releaseUrl is the fixed public landing page the UI links on needs_full eq(Check.releaseUrl(), - "https://github.com/bryanthaboi/gen1recomp/releases/latest", + "https://github.com/UNDERdecoded/Gen2Recomped/releases/latest", "releaseUrl points at the repo's latest release") -- parseRelease: a well-formed release with the .love payload and its sums local body = Json.encode({ tag_name = "v1.4.2", assets = { - { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, - { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "Gen2Recomped-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "Gen2Recomped-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, }, }) +-- A same-version Gen1 payload is not a fallback for a missing Gen2 asset. +local foreign = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2-macos.zip",browser_download_url="http://x/zip",size=99}, +}})) +check(foreign ~= nil, "a release without a matching game payload still parses") +eq(foreign.payload,nil,"Gen1 and platform archives never substitute for the Gen2 payload") +local competing = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2.love",browser_download_url="http://x/correct",size=123}, +}})) +check(competing.payload ~= nil,"exact Gen2 payload wins among same-version assets") +eq(competing.payload and competing.payload.url,"http://x/correct","foreign game asset is ignored even when listed first") + local rel = Check.parseRelease(body) check(rel ~= nil, "parseRelease accepts a valid release") eq(rel.version, "1.4.2", "leading v stripped from tag_name") -eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version") +eq(rel.payloadName, "Gen2Recomped-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") @@ -50,13 +64,13 @@ eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected -- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './' -- prefix and CRLF line endings; unrelated lines are skipped local sums = - "aaaa1111 gen1recomp-1.4.2.love\n" .. + "aaaa1111 Gen2Recomped-1.4.2.love\n" .. "BBBB2222 *./sha256sums.txt\r\n" .. "not a checksum line\n" local map = Check.parseSums(sums) -eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "bare-name sum parsed") +eq(map["Gen2Recomped-1.4.2.love"], "aaaa1111", "bare-name sum parsed") eq(map["sha256sums.txt"], "bbbb2222", "* marker and ./ prefix stripped, lowered") -eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") +eq(Check.parseSums(sums, "Gen2Recomped-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") eq(Check.parseSums(sums, "missing.love"), nil, "targeted lookup misses cleanly") -- pickAsset guards a non-table assets field diff --git a/tests/engine/update_tests.lua b/tests/engine/update_tests.lua index 9bf67171e..d150e43f6 100644 --- a/tests/engine/update_tests.lua +++ b/tests/engine/update_tests.lua @@ -193,11 +193,11 @@ end -- --------------------------------------------------------------------------- local assets = { - { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, - { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "Gen2Recomped-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "Gen2Recomped-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, } -local picked = Check.pickAsset(assets, "gen1recomp-1.4.2.love") +local picked = Check.pickAsset(assets, "Gen2Recomped-1.4.2.love") check(picked ~= nil, "pickAsset finds an asset by exact name") eq(picked.url, "http://x/love", "pickAsset returns the download url") eq(picked.size, 12345, "pickAsset returns the numeric size") @@ -223,10 +223,24 @@ local body = Json.encode({ tag_name = "v1.4.2", assets = assets, }) +-- A same-version Gen1 payload is not a fallback for a missing Gen2 asset. +local foreign = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2-macos.zip",browser_download_url="http://x/zip",size=99}, +}})) +check(foreign ~= nil, "a release without a matching game payload still parses") +eq(foreign.payload,nil,"Gen1 and platform archives never substitute for the Gen2 payload") +local competing = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2.love",browser_download_url="http://x/correct",size=123}, +}})) +check(competing.payload ~= nil,"exact Gen2 payload wins among same-version assets") +eq(competing.payload and competing.payload.url,"http://x/correct","foreign game asset is ignored even when listed first") + local rel = Check.parseRelease(body) check(rel ~= nil, "parseRelease accepts a valid release") eq(rel.version, "1.4.2", "parseRelease strips the leading v from tag_name") -eq(rel.payloadName, "gen1recomp-1.4.2.love", "parseRelease derives the payload name from the version") +eq(rel.payloadName, "Gen2Recomped-1.4.2.love", "parseRelease derives the payload name from the version") 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") @@ -268,18 +282,18 @@ eq(literal, nil, "parseRelease returns nil on a non-object JSON literal") -- standard " " lines, the '*' binary marker, a './' prefix, CRLF -- endings and mixed hash case; junk lines are skipped local sums = - "aaaa1111 gen1recomp-1.4.2.love\n" .. + "aaaa1111 Gen2Recomped-1.4.2.love\n" .. "BBBB2222 *./sha256sums.txt\r\n" .. "deadBEEF ./nested.love\n" .. "not a checksum line at all\n" local map = Check.parseSums(sums) -eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") +eq(map["Gen2Recomped-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") eq(map["sha256sums.txt"], "bbbb2222", "parseSums strips the * marker and ./ prefix and lowercases") eq(map["nested.love"], "deadbeef", "parseSums lowercases a mixed-case hash and strips ./") eq(map["not a checksum line at all"], nil, "parseSums skips lines that are not checksums") -- the target form returns just that file's hash (hit / miss) -eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") +eq(Check.parseSums(sums, "Gen2Recomped-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") eq(Check.parseSums(sums, "missing.love"), nil, "parseSums(target) misses cleanly on an unknown file") -- degenerate inputs: empty text yields an empty map, a targeted miss is nil, @@ -294,7 +308,7 @@ eq(Check.parseSums(nil, "anything"), nil, "parseSums(nil, target) returns nil wi -- --------------------------------------------------------------------------- eq(Check.releaseUrl(), - "https://github.com/bryanthaboi/gen1recomp/releases/latest", + "https://github.com/UNDERdecoded/Gen2Recomped/releases/latest", "releaseUrl points at the repo's latest release") T.finish("update") diff --git a/tests/engine/version_dataset_files.lua b/tests/engine/version_dataset_files.lua new file mode 100644 index 000000000..3df810685 --- /dev/null +++ b/tests/engine/version_dataset_files.lua @@ -0,0 +1,127 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Cache = require("src.import.CacheFs") +local Files = require("src.import.VersionFiles") +local originalLove, originalVersion = love, Version.get() +local oldRoot, oldDatasetRoot = Cache.root, Cache.datasetRoot +local root = os.tmpname() +os.remove(root) +local written, dirs = {}, {} +local function mkdir(path) + local windows = package.config:sub(1, 1) == "\\" + local quoted = windows and ('"' .. path .. '"') or ("'" .. path:gsub("'", "'\\''") .. "'") + os.execute((windows and "mkdir " or "mkdir -p ") .. quoted) + dirs[#dirs + 1] = path +end +local function write(path, body) + local f = assert(io.open(path, "wb")) + f:write(body) + f:close() + written[#written + 1] = path +end +mkdir(root) +for _, base in ipairs({ "cache", "save", "source" }) do + mkdir(root .. "/" .. base) + mkdir(root .. "/" .. base .. "/data") + mkdir(root .. "/" .. base .. "/data/generated") + mkdir(root .. "/" .. base .. "/gold") + mkdir(root .. "/" .. base .. "/gold/data") + mkdir(root .. "/" .. base .. "/gold/data/generated") +end +write(root .. "/cache/data/generated/probe.lua", "RED") +write(root .. "/cache/gold/data/generated/probe.lua", "GOLD") +write(root .. "/save/data/generated/probe.lua", "SAVE_RED") +write(root .. "/source/data/generated/probe.lua", "SOURCE_RED") +local chosen, ready = root .. "/cache", true +local virtualReads = 0 +local fs = { + getSaveDirectory = function() + return root .. "/save" + end, + getSource = function() + return root .. "/source" + end, + getRealDirectory = function() + return root .. "/source" + end, + read = function() + virtualReads = virtualReads + 1 + return "ACTIVE_OVERLAY" + end, + getInfo = function() + return { type = "file", size = 14 } + end, +} +love = { filesystem = fs } +Cache.root = function() + error("observational reads must not resolve or mount roots") +end +Cache.datasetRoot = function() + return chosen, ready +end +Version.set("gold") +local files = Files.new(fs) +T.eq(files.read("red", "data/generated/probe.lua", 100), "RED", "inactive Red bypasses active Gold overlay") +T.eq(files.read("gold", "data/generated/probe.lua", 100), "GOLD", "version-qualified native cache") +T.eq(files.info("red", "data/generated/probe.lua").size, 3, "native info uses exact file") +T.eq( + files.read("gold", "data/generated/probe.lua", 3), + nil, + "native reader refuses oversized file before loading bytes" +) +T.eq(virtualReads, 0, "resolved cache never consults virtual overlay") +T.eq(files.path("red", "assets/generated/x.png"), nil, "ambiguous unprefixed asset path refused") +T.eq( + files.path("gold", "assets/generated/x.png"), + "gold/assets/generated/x.png", + "prefixed asset path retained" +) +local identity = files.identity() +chosen = nil +T.check(files.identity() ~= identity, "cache-root changes have distinct identity") +T.eq( + files.read("red", "data/generated/probe.lua", 100), + "SAVE_RED", + "resolved default save root takes precedence" +) +ready = false +T.eq( + files.read("red", "data/generated/probe.lua", 100), + "SOURCE_RED", + "unresolved roots read verified source instead of guessing a save cache" +) +T.eq(virtualReads, 0, "physical source read bypasses overlay") +T.eq(Version.get(), "gold", "native dataset reads leave active version untouched") +-- A source archive is readable only when its exact provenance is verified. +fs.getRealDirectory = function() + return "/another/overlay" +end +T.eq( + Cache.datasetSourceFile("data/generated/probe.lua", 100), + nil, + "foreign overlay cannot impersonate developer source" +) +fs.getRealDirectory = function() + return fs.getSource() +end +T.eq( + Cache.datasetSourceFile("data/generated/probe.lua", 100), + "ACTIVE_OVERLAY", + "verified virtual source can be read" +) +T.eq( + Cache.datasetSourceFile("data/generated/probe.lua", 3), + nil, + "source reader respects advertised size limit" +) +Cache.root, Cache.datasetRoot = oldRoot, oldDatasetRoot +love = originalLove +Version.set(originalVersion) +for i = #written, 1, -1 do + os.remove(written[i]) +end +for i = #dirs, 1, -1 do + os.remove(dirs[i]) +end +T.finish() diff --git a/tests/engine/wide_battle_layout.lua b/tests/engine/wide_battle_layout.lua index 5571af399..dadf125cf 100644 --- a/tests/engine/wide_battle_layout.lua +++ b/tests/engine/wide_battle_layout.lua @@ -38,13 +38,38 @@ T.eq(WideBattle.navigate(1, 4, pressing("right")), 2, T.eq(WideBattle.navigate(1, 4, pressing("a")), nil, "navigate leaves A / B / SELECT to the battle engine") --- animation frames shift as one rigid group toward the side they play on -local px, py = WideBattle.animationOffset({ { x = 24 }, { x = 64 } }) -T.eq(px, 20, "a player-side frame lands on the player anchor") -T.eq(py, 8, "a player-side frame lands on the player baseline") -local ex, ey = WideBattle.animationOffset({ { x = 104 }, { x = 144 } }) -T.eq(ex, 136, "an enemy-side frame lands on the enemy anchor") -T.eq(ey, 0, "an enemy-side frame lands on the enemy baseline") +-- OAM x is the right edge of an 8px sprite. These groups are centered on +-- the authored player (26) and enemy (124) anchors, rather than merely +-- falling somewhere on their half of the 160px screen. +local px, py = WideBattle.animationOffset({ { x = 14 }, { x = 46 } }) +T.eq(px, 20, "the player anchor maps to its wide-layout offset") +T.eq(py, 8, "the player anchor maps to its wide-layout baseline") +local ex, ey = WideBattle.animationOffset({ { x = 112 }, { x = 144 } }) +T.eq(ex, 136, "the enemy anchor maps to its wide-layout offset") +T.eq(ey, 0, "the enemy anchor maps to its wide-layout baseline") + +local function offset(x, wantX, wantY, label) + local dx, dy = WideBattle.animationOffset({ { x = x } }) + T.eq(dx, wantX, label .. " horizontal offset") + T.eq(dy, wantY, label .. " vertical offset") +end +offset(30, 20, 8, "single sprite at the player anchor") +offset(128, 136, 0, "single sprite at the enemy anchor") +offset(79, 78, 4, "midpoint between anchors") +-- Wobble must continue across the anchor instead of sticking at it. +offset(29, 19, 8, "one pixel before the player anchor") +offset(31, 21, 8, "one pixel after the player anchor") +offset(127, 135, 0, "one pixel before the enemy anchor") +offset(129, 137, 0, "one pixel after the enemy anchor") +-- Bounded overshoot lets balls leave the picture without unbounded offsets. +offset(-1000, -3, 10, "far left overshoot") +offset(1000, 159, -2, "far right overshoot") +local rx, ry = WideBattle.animationOffset({ { x = 46 }, { x = 14 } }) +T.eq(rx, px, "sprite ordering does not change the rigid frame offset") +T.eq(ry, py, "sprite ordering does not change the baseline") +local emptyX, emptyY = WideBattle.animationOffset({}) +T.eq(emptyX, 0, "an empty frame has no horizontal offset") +T.eq(emptyY, 0, "an empty frame has no vertical offset") -- the surface: a request outside the bounds falls back to the GB screen, -- and the canvas is only reallocated when the size actually changes diff --git a/tests/engine/world_actions_api.lua b/tests/engine/world_actions_api.lua new file mode 100644 index 000000000..bd029b204 --- /dev/null +++ b/tests/engine/world_actions_api.lua @@ -0,0 +1,582 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Actions = require("src.world.WorldActions") +local GameVersion = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local G4Fly = require("src.world.Gen4Fly") +local Gen3Show = require("src.world.Gen3FieldMove") +local saved = { GameVersion.get(), Runtime.events, Runtime.hooks, Runtime.errors, love, Gen3Show.show } +love = nil +-- Deterministic no-picture native fallback for the cross-version cases. +Gen3Show.show = function() + return false +end +local function boot(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local data = { + pokemon = {}, + moves = {}, + items = {}, + constants = { badges = {} }, + tilesets = { TEST = { blockCells = gen < 3 and 2 or 1 } }, + maps = { + HOME = { + id = "HOME", + width = 20, + height = 20, + tileset = "TEST", + outdoor = true, + mapType = "ROUTE", + allowFly = true, + gen5Flags = 0x2000, + }, + DEST = { id = "DEST", width = 20, height = 20, tileset = "TEST", outdoor = true, mapType = "CITY" }, + }, + field = { flyOrder = { "DEST" }, flyWarps = { DEST = { x = 3, y = 4 } } }, + } + for i = 1, 16 do + data.constants.badges[i] = { id = "BADGE_" .. i } + end + local save = { + party = { + { species = "A", hp = 1, moves = { { id = gen >= 4 and 19 or "FLY" } } }, + { + species = "B", + hp = 0, + moves = {}, + }, + }, + visited = { DEST = true }, + lastHeal = { map = "DEST", x = 3, y = 4 }, + flags = {}, + badges = {}, + } + for i = 1, 16 do + save.badges["BADGE_" .. i] = true + end + if gen == 4 then + data.gen4_version = + { family = (version == "heartgold" or version == "soulsilver") and "hgss" or "platinum" } + local row = { index = 0, firstArrival = 1, fly = { map = "DEST", x = 3, y = 4 } } + data.field.healLocations = { row } + save.flags[G4Fly.rowFlagKey(data, row)] = true + elseif gen == 5 then + data.maps.Z001 = data.maps.DEST + data.gen5_town_map = + { locations = { { zone = 1, flyPoint = true, spawn = { main = true, x = 3, z = 4 } } } } + save.gen5FlyZones = { ["1"] = true } + end + local calls = {} + local ow = { + isOverworld = true, + map = { id = "HOME", def = data.maps.HOME }, + player = { cellX = 2, cellY = 3, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + runner = { + isRunning = function() + return false + end, + }, + } + ow.player.facingCell = function(p) + return p.cellX, p.cellY - 1 + end + local game + ow.partyKnows = function(_, move) + if move == "FLY" then + return game.save.party[1] + end + end + ow.flyTo = function(_, id, mon) + calls[#calls + 1] = { kind = "fly", id = id, mon = mon } + ow.flyAnim = {} + end + ow.gen4FlyTo = function(_, dest, mon) + calls[#calls + 1] = { kind = "fly", id = dest.map, x = dest.x, y = dest.y, mon = mon } + ow.flyAnim = {} + end + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + function stack:push(state) + self.states[#self.states + 1] = state + end + function stack:pop() + return table.remove(self.states) + end + game = { data = data, save = save, overworld = ow, stack = stack } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolved = 0 + loader._game = function() + resolved = resolved + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, game, ow, calls, function() + return resolved + end +end +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow, calls, resolved = boot(version) + local label = version .. ": " + T.eq(resolved(), 0, label .. "building API does not construct Game") + if api.supports then + for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", + }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolved(), 0, label .. "capability probes do not construct Game") + end + local world = api.world + local before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), true, label .. "free-roam party can reorder") + T.eq(world:canFly(), true, label .. "native flight checks pass") + T.same(game, before, label .. "availability does not alter save/world") + local p = game.save.party + local a, b = p[1], p[2] + T.eq(world:reorderParty(1, 2), true, label .. "swap accepted") + T.eq(game.save.party, p, label .. "party list identity retained") + T.eq(p[1], b, label .. "fainted party member may move") + T.eq(p[2], a, label .. "Pokemon identity retained") + T.eq(world:reorderParty(1, 2), true, label .. "swap back accepted") + T.eq(world:reorderParty(1, 1), true, label .. "same-slot no-op accepted") + for _, slot in ipairs({ 0, -1, 3, 7, 1.5, "1", math.huge, 0 / 0 }) do + before = Merge.deepCopy(game) + local ok, reason = world:reorderParty(slot, 1) + T.check(ok == nil and reason == "invalid party slot", label .. "invalid slot refused") + T.same(game, before, label .. "bad swap atomic") + end + local locks = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", + } + for _, lock in ipairs(locks) do + ow[lock] = true + before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), false, label .. lock .. " blocks reorder probe") + T.eq(world:canFly(), false, label .. lock .. " blocks fly probe") + T.eq(world:reorderParty(1, 2), nil, label .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. lock .. " blocks flight") + T.eq(world:useFieldAction("surf"), nil, label .. lock .. " blocks field use") + T.same(world:availableFieldActions(), {}, label .. lock .. " suppresses actions") + T.same(game, before, label .. lock .. " refusal leaves game unchanged") + ow[lock] = nil + end + for _, lock in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[lock] = true + before = Merge.deepCopy(game) + T.eq(world:reorderParty(1, 2), nil, label .. "player " .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "player " .. lock .. " blocks fly") + T.same(game, before, label .. "player " .. lock .. " no mutation") + ow.player[lock] = nil + end + game.stack:push({ isBattle = true }) + T.eq(world:reorderParty(1, 2), nil, label .. "battle/screen above world blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "battle/screen blocks fly") + game.stack:pop() + ow.runner.isRunning = function() + return true + end + T.eq(world:reorderParty(1, 2), nil, label .. "running VM blocks swap") + ow.runner.isRunning = function() + return false + end + ow.pendingScripts[1] = {} + T.eq(world:flyTo("DEST"), nil, label .. "pending scene blocks flight") + ow.pendingScripts = {} + ow.scriptMoves[1] = {} + T.eq(world:reorderParty(1, 2), nil, label .. "scripted movement blocks swap") + ow.scriptMoves = {} + game.linkSession = {} + T.eq(world:flyTo("DEST"), nil, label .. "link lock blocks flight") + game.linkSession = nil + game.linkNet = { closed = false } + T.eq(world:reorderParty(1, 2), nil, label .. "live link net blocks swap") + game.linkNet = nil + local destination = GameVersion.generation() == 5 and "Z001" or "DEST" + before = Merge.deepCopy(game) + T.eq(world:flyTo("MISSING"), nil, label .. "unknown destination refused") + T.eq(world:flyTo({}), nil, label .. "non-string destination refused") + T.same(game, before, label .. "invalid destinations leave save/world unchanged") + T.eq(#calls, 0, label .. "no gameplay handlers ran on refusal") + T.eq(world:flyTo(destination), true, label .. "flight accepted") + T.eq(#calls, 1, label .. "one native departure") + T.eq(calls[1].id, destination, label .. "native destination selected") + T.eq(calls[1].mon, a, label .. "native carrier identity retained") + T.eq(world:flyTo(destination), nil, label .. "in-flight repeat refused") + T.eq(#calls, 1, label .. "repeat does not replay departure") + ow.flyAnim = nil + a.isEgg = true + T.eq(world:canFly(), false, label .. "egg cannot carry flight") + a.isEgg = nil + if GameVersion.generation() == 4 then + game.save.flags.FLAG_G4_0961 = true + T.eq(world:canFly(), false, label .. "native partner restriction retained") + game.save.flags.FLAG_G4_0961 = nil + if version == "heartgold" or version == "soulsilver" then + game.save.hgssRocketCostume = true + T.eq(world:canFly(), false, label .. "Rocket costume restriction retained") + game.save.hgssRocketCostume = nil + end + end + game.save = nil + T.eq(world:canReorderParty(), false, label .. "no save probe safe") + T.eq(world:reorderParty(1, 2), nil, label .. "no save swap safe") + T.eq(world:canFly(), false, label .. "no save fly probe safe") + T.eq(world:flyTo(destination), nil, label .. "no save fly safe") +end + +-- Exercise the actual native carrier/badge lookup and departure methods, +-- including their input lock and animation/landing state, without graphics. +local OW = require("src.world.OverworldController") +love = {} -- native sound entry points expect a host table, but need no audio device +local gameUpvalue, oldGame +for i = 1, 30 do + local name, value = debug.getupvalue(OW.flyTo, i) + if name == "Game" then + gameUpvalue = i + oldGame = value + break + end +end +assert(gameUpvalue, "native Game binding") +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + ow.partyKnows = OW.partyKnows + ow.flyTo = OW.flyTo + ow.gen4FlyTo = OW.gen4FlyTo + ow.closeToMap = OW.closeToMap + ow.clearBikeFlags = function() end + ow.hasFlyBird = function() + return false + end + ow.syncSurfingPikachu = function() end + local gen = GameVersion.generation() + if gen <= 2 then + game.data.constants.hmBadges = { FLY = { badge = "FLY_ALLOWED" } } + game.save.flags.FLY_ALLOWED = true + elseif gen == 3 then + game.data.constants.gen3FieldMoves = { FLY = { badgeFlag = 0x820 } } + game.save.flags.FLAG_G3_0820 = true + end + local destination = gen == 5 and "Z001" or "DEST" + T.eq(api.world:canFly(), true, version .. " actual native eligibility") + local before = Merge.deepCopy(game) + if gen <= 2 then + game.save.flags.FLY_ALLOWED = nil + elseif gen == 3 then + game.save.flags.FLAG_G3_0820 = nil + elseif gen == 4 then + for k in pairs(game.save.badges) do + game.save.badges[k] = nil + end + end + if gen < 5 then + T.eq(api.world:canFly(), false, version .. " native missing badge refuses flight") + T.eq(api.world:flyTo(destination), nil, version .. " missing badge has no departure") + T.eq(ow.flyAnim, nil, version .. " denied departure has no animation") + end + game.save = Merge.deepCopy(before.save) + if gen <= 3 then + game.save.visited.DEST = nil + elseif gen == 4 then + game.save.flags = {} + else + game.save.gen5FlyZones = {} + end + T.eq(api.world:canFly(), false, version .. " unvisited/locked destinations unavailable") + T.eq(api.world:flyTo(destination), nil, version .. " locked destination refuses native handler") + game.save = Merge.deepCopy(before.save) + -- Validate the landing before any departure-side state is changed. + if gen <= 3 then + game.data.field.flyWarps.DEST.x = -1 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = -1 + else + game.data.gen5_town_map.locations[1].spawn.x = -1 + end + T.eq(api.world:flyTo(destination), nil, version .. " invalid landing refused") + T.eq(ow.flyAnim, nil, version .. " invalid landing leaves animation absent") + if gen <= 3 then + game.data.field.flyWarps.DEST.x = 3 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = 3 + else + game.data.gen5_town_map.locations[1].spawn.x = 3 + end + game.save.onBike = true + ow.player.surfing = true + T.eq(api.world:flyTo(destination), true, version .. " native departure accepted") + T.eq(ow.flyAnim.frames, 48, version .. " native flight timing preserved") + T.eq(ow.flyDest.map, destination, version .. " native landing map preserved") + T.eq(ow.flyDest.x, 3, version .. " native landing x preserved") + T.eq(ow.flyDest.y, 4, version .. " native landing y preserved") + T.eq(ow.player.inputLocked, true, version .. " native input lock applied") + T.eq(ow.player.surfing, false, version .. " native surfing cleared") + T.eq(game.save.onBike, false, version .. " native bicycle state cleared") +end +for _, version in ipairs({ "red", "gold", "emerald" }) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + local gen = GameVersion.generation() + ow.partyKnows = OW.partyKnows + ow.beginTeleportOut = OW.beginTeleportOut + ow.syncSurfingPikachu = function() end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + game.save.party[1].moves = { { id = "DIG" }, { id = "TELEPORT" } } + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(api.world:useFieldAction("dig"), true, version .. " actual native Dig starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Dig animation timing") + T.eq(ow.teleportOut.escape, gen >= 2 and true or nil, version .. " native Dig escape destination semantics") + T.eq(ow.player.inputLocked, true, version .. " native Dig input lock") + ow.teleportOut = nil + ow.player.inputLocked = false + ow.player.spinning = false + ow.map.def.tileset = "OVERWORLD" + ow.map.def.environment = 1 + T.eq(api.world:useFieldAction("teleport"), true, version .. " actual native Teleport starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Teleport animation timing") + T.eq(ow.teleportOut.escape, nil, version .. " native Teleport uses last heal point") +end +debug.setupvalue(OW.flyTo, gameUpvalue, oldGame) +love = nil + +-- Field dispatch is tested separately from presentation, using native IDs +-- and the exact overworld methods called by the existing party menus. +for _, version in ipairs({ "red", "gold", "emerald", "platinum", "black" }) do + local api, game, ow, calls = boot(version) + local world = api.world + local gen = GameVersion.generation() + local mon = game.save.party[1] + ow.partyKnows = function() + return mon + end + ow.useSurfFieldMove = function() + return "ok" + end + ow.useCutFieldMove = function() + return "ok" + end + ow.trySurf = function(_, x, y, close, carrier) + calls[#calls + 1] = { kind = "surf", carrier = carrier, x = x, y = y } + game.stack:push({ isText = true }) + end + ow.tryCut = function(_, x, y) + calls[#calls + 1] = { kind = "cut", x = x, y = y } + game.stack:push({ isText = true }) + return true + end + ow.beginTeleportOut = function(_, done, opts) + calls[#calls + 1] = { kind = "escape", escape = opts.escape } + ow.teleportOut = {} + end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + local before = Merge.deepCopy(game) + local list = world:availableFieldActions() + T.same(game, before, version .. " field availability is data-only with native pure gates") + local names = {} + for _, row in ipairs(list) do + names[row.id] = true + end + T.eq(names.surf, gen <= 3 and true or nil, version .. " verified surf coverage") + T.eq(names.cut, gen <= 2 and true or nil, version .. " verified cut coverage") + T.eq(names.teleport, gen <= 3 and true or nil, version .. " verified teleport coverage") + if list[1] then + list[1].id = "MUTATED" + T.check(world:availableFieldActions()[1].id ~= "MUTATED", version .. " action rows detached") + end + T.eq(world:useFieldAction("unknown"), nil, version .. " unknown action refused") + T.eq(world:useFieldAction("surf", { sourceSlot = 1 }), nil, version .. " unsupported options refused") + if gen <= 3 then + T.eq(world:useFieldAction("surf"), true, version .. " native surf started") + T.eq(calls[#calls].carrier, mon, version .. " surf uses validated carrier") + T.eq(world:useFieldAction("surf"), nil, version .. " active dialogue prevents replay") + game.stack:pop() + ow.player.surfing = true + T.eq(world:useFieldAction("surf"), nil, version .. " no invented dismount action") + ow.player.surfing = false + T.eq(world:useFieldAction("teleport"), true, version .. " native Teleport started") + T.eq(calls[#calls].escape, nil, version .. " Teleport keeps heal-point semantics") + ow.teleportOut = nil + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(world:useFieldAction("dig"), true, version .. " native Dig started") + T.eq( + calls[#calls].escape, + gen >= 2 and true or nil, + version .. " Dig keeps generation-specific destination" + ) + ow.teleportOut = nil + if gen <= 2 then + T.eq(world:useFieldAction("cut"), true, version .. " native Cut started") + game.stack:pop() + end + else + T.eq(world:useFieldAction("surf"), nil, version .. " unsupported script adapter is explicit") + T.eq(#calls, 0, version .. " unsupported field dispatch has no effects") + end +end + +-- Deferred Gen 3 cut-ins own the request until completion; callbacks may be +-- invoked twice or outlive a replaced map/save, but effects must not replay. +local api, game, ow, calls = boot("emerald") +local world = api.world +local finish +Gen3Show.show = function(g, mon, done) + finish = done + g.stack:push({ isCutin = true }) + return true +end +T.eq(world:flyTo("DEST"), true, "cut-in queued") +T.eq(#calls, 0, "no departure before cut-in finishes") +T.eq(world:reorderParty(1, 2), nil, "cut-in blocks competing action") +game.stack:pop() +finish() +finish() +T.eq(#calls, 1, "cut-in completion departs once") +ow.flyAnim = nil +T.eq(world:flyTo("DEST"), true, "second independent request queued") +game.stack:pop() +game.save = Merge.deepCopy(game.save) +finish() +T.eq(#calls, 1, "save replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "cancelled request releases its lock") +T.eq(world:flyTo("DEST"), true, "request can resume after cancellation") +game.stack:pop() +ow.map = { id = "OTHER", def = game.data.maps.HOME } +finish() +T.eq(#calls, 1, "map replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "map cancellation releases lock") +T.eq(world:flyTo("DEST"), true, "abandoned presentation queued") +game.stack:pop() -- simulate native UI being dismissed without completion +T.eq(world:canReorderParty(), true, "abandoned cut-in does not leave a permanent API lock") +finish() +T.eq(#calls, 1, "abandoned callback cannot later run") +Gen3Show.show = function(g, mon, done) + done() + error("after completion") +end +local ok, err = pcall(function() + world:flyTo("DEST") +end) +T.check( + not ok and tostring(err):find("after completion", 1, true) ~= nil, + "native presentation error is propagated" +) +T.eq(#calls, 2, "presentation failure never retries a completed departure") +Gen3Show.show = function() + return false +end +ow.flyAnim = nil +-- Hooks cannot recursively start a second action or move the source unnoticed. +ow.partyKnows = function() + T.eq(world:reorderParty(1, 2), nil, "nested mutation during eligibility is refused") + return game.save.party[1] +end +T.eq(world:canFly(), true, "recursive capability probe terminates") +ow.partyKnows = function() + ow.player.cellX = ow.player.cellX + 1 + return game.save.party[1] +end +T.eq(world:flyTo("DEST"), nil, "eligibility hook changing source position cancels action") +T.eq(#calls, 2, "changed source never reaches native handler") + +local nestedApi, nestedGame, nestedWorld = boot("red") +nestedWorld.flyTo = function() + T.eq(nestedApi.world:reorderParty(1, 2), nil, "native dispatch callback cannot reorder concurrently") + T.eq(nestedApi.world:flyTo("DEST"), nil, "native dispatch callback cannot start a second flight") + nestedWorld.flyAnim = {} +end +T.eq(nestedApi.world:flyTo("DEST"), true, "guarded native dispatch still starts once") +nestedWorld.flyAnim = nil +local nativeError = { native = true } +nestedWorld.flyTo = function() + error(nativeError) +end +local okNative, errNative = pcall(function() + nestedApi.world:flyTo("DEST") +end) +T.check(not okNative and errNative == nativeError, "native error identity retained") +T.eq(nestedApi.world:canReorderParty(), true, "throwing handler releases only the API dispatch lock") + +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:reorderParty(1, 2), nil, "missing game mutation safe") +T.eq(empty:canFly(), false, "missing game probe safe") +T.same(empty:availableFieldActions(), {}, "missing game has no field actions") +for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", +}) do + T.check(Actions.supports(name), "action capability " .. name) +end +T.eq(Actions.supports("party"), false, "reader capability remains separate") +GameVersion.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +Gen3Show.show = saved[6] +T.finish() diff --git a/tests/engine/world_overview_api.lua b/tests/engine/world_overview_api.lua new file mode 100644 index 000000000..dab0d78bd --- /dev/null +++ b/tests/engine/world_overview_api.lua @@ -0,0 +1,660 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Map = require("src.world.Map") +local WorldAPI = require("src.world.WorldAPI") +local Encounter = require("src.world.Encounter") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local GameVersion = require("src.core.GameVersion") +local Assets = require("src.render.Assets") +local Loader = require("src.mods.Loader") +local Json = require("src.link.Json") +local savedVersion, savedHooks = GameVersion.get(), Runtime.hooks +local function copy(t, seen) + if type(t) ~= "table" then + return t + end + seen = seen or {} + if seen[t] then + return seen[t] + end + local out = {} + seen[t] = out + for k, v in pairs(t) do + out[copy(k, seen)] = copy(v, seen) + end + return out +end +-- Exact reference/key/metatable freeze; cycles (Game -> world -> Game) are real. +local function freeze(root) + local graph = {} + local function walk(t) + if type(t) ~= "table" or graph[t] then + return + end + local keys = {} + graph[t] = { keys = keys, mt = getmetatable(t) } + for k, v in pairs(t) do + keys[k] = v + walk(k) + walk(v) + end + end + walk(root) + return function(label) + for t, before in pairs(graph) do + T.eq(getmetatable(t), before.mt, label .. " metatable") + for k, v in pairs(before.keys) do + T.eq(rawget(t, k), v, label .. " value") + end + for k, v in pairs(t) do + T.eq(before.keys[k], v, label .. " no new key") + end + end + end +end +local function pack(...) + local out = {} + for _, n in ipairs({ ... }) do + out[#out + 1] = string.char(n % 256, math.floor(n / 256)) + end + return table.concat(out) +end +local function uniform(n) + local out = {} + for i = 1, 16 do + out[i] = n + end + return out +end +local function scene(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local def = { + id = "ACTIVE", + width = 2, + height = 2, + borderBlock = 0, + blocks = { 1, 2, 3, 4 }, + warps = { { x = 1, y = 1 }, { x = -1, y = 1 } }, + signs = { + { x = 0, y = 1, item = "POTION", eventFlag = "FOUND" }, + { + x = 1, + y = 0, + item = "POTION", + eventFlag = "HIDDEN", + }, + }, + } + local ts = { + blockCells = 2, + blockTiles = 4, + blocks = { uniform(0), uniform(0), uniform(1), uniform(2), uniform(3) }, + walkable = { 0, 2 }, + waterTiles = { 1 }, + shoreTiles = {}, + warpTiles = { 3 }, + image = "overview-test.png", + tilesPerRow = 4, + } + if gen == 2 then + ts.collision = { 255, 255, 255, 255, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3 } + elseif gen >= 3 then + def.blocks = pack(1 + 1024, 2 + 4096, 3, 4) + def.collisionCells = { 0, 0, 1, 0 } + def.elevationCells = gen == 3 and { 3, 1, 3, 3 } or nil + ts.blockCells, ts.blockTiles, ts.blocks, ts.collision = 1, 2, nil, { 0, 0, 1, 2, 3 } + ts.behaviourBytes = true + ts.warpsAreEvents = true + if gen >= 4 then + def.generation = 4 + def.gen5 = gen == 5 or nil + def.behaviorCells = string.char(0, gen == 5 and 0x3D or 0x10, 0, 0) + end + end + local map = Map.new(def, ts) + local ow = { + isOverworld = true, + map = map, + npcs = { + { def = { item = "POTION" }, cellX = 0, cellY = 0 }, + { def = { item = "POTION" }, cellX = 1, cellY = 0, hidden = true }, + { def = { item = "POTION", fruitTree = true }, cellX = 0, cellY = 1 }, + { def = { item = 0 }, cellX = 0, cellY = 1 }, + { def = { item = "POTION" }, cellX = 99, cellY = 99 }, + }, + tod = "NITE", + player = { cellX = 0, cellY = 0 }, + } + local game = { + data = { + maps = { ACTIVE = def }, + field = { hiddenItems = { ACTIVE = { { x = 0, y = 0 }, { x = 1, y = 1 } } } }, + }, + save = { flags = { HIDDEN = true }, hiddenTaken = { ACTIVE_1_1 = true } }, + overworld = ow, + stack = { states = { ow, { isMenu = true } } }, + } + ow.game = game + return WorldAPI.new(game, "probe"), game, ow +end +local savedLoveRandom, savedMathRandom = love.math.random, math.random +local draws = 0 +love.math.random = function() + draws = draws + 1 + error("query consumed love RNG") +end +math.random = function() + draws = draws + 1 + error("query consumed math RNG") +end +Runtime.hooks = Hooks.new() +local callbacks = 0 +for _, name in ipairs({ "encounter.roll", "encounter.species", "encounter.table" }) do + Runtime.hooks:wrap(name, function() + callbacks = callbacks + 1 + error("query executed encounter hook") + end) +end +for _, version in ipairs({ + "red", + "gold", + "silver", + "crystal", + "emerald", + "firered", + "platinum", + "heartgold", + "black", + "white", +}) do + local api, game, ow = scene(version) + for _, warm in ipairs({ false, true }) do + if warm then + ow.map:blockAt(0, 0) + end + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable(version .. " overview") + T.eq(out.mapId, "ACTIVE", version .. " map ID") + T.eq(out.width, ow.map.widthCells, version .. " width") + T.eq(#out.rows, out.height, version .. " rows") + -- Native classifiers on an independent map give the exact terrain contract. + local native = Map.new(copy(ow.map.def), copy(ow.map.tileset)) + for y = 0, out.height - 1 do + T.eq(#out.rows[y + 1], out.width, version .. " row width") + for x = 0, out.width - 1 do + local expected = native:isWarpTileCell(x, y) and "+" + or native:isWaterCell(x, y) and "~" + or native:isWalkableCell(x, y) and "." + or " " + T.eq(out.rows[y + 1]:sub(x + 1, x + 1), expected, version .. " native terrain") + end + end + T.same(out.markers, { + { kind = "warp", x = 1, y = 1 }, + { kind = "item", x = 0, y = 0 }, + { kind = "hidden", x = 0, y = 0 }, + { + kind = "hidden", + x = 0, + y = 1, + }, + }, version .. " only active markers") + out.markers[1].x = 999 + out.rows[1] = "corrupted" + T.eq(api:mapOverview().markers[1].x, 1, version .. " detached markers") + end + local old = assert(api:mapOverview()) + ow.npcs = {} + game.save.flags.FOUND = true + game.save.hiddenTaken.ACTIVE_0_0 = true + T.eq(#api:mapOverview().markers, 1, version .. " live removals reflected") + T.eq(#old.markers, 4, version .. " old snapshot remains detached") + ow.map:setBlock(0, 0, 0, true) + local stable = freeze(game) + local patched = assert(api:mapOverview()) + stable(version .. " patched map") + T.eq(patched.rows[1]:sub(1, 1), " ", version .. " live shut cell") + if GameVersion.generation() >= 4 then + ow.map.def.behaviorCells = nil + local result, why = api:mapOverview() + T.eq(result, nil, version .. " streamed raster refused") + T.check(why:find("streamed", 1, true), version .. " streamed reason") + end +end +-- Literal terrain reference: whitespace is an impassable cell, not a missing row. +do + local api = scene("red") + T.same(api:mapOverview().rows, { "..~~", "..~~", "..++", "..++" }, "GB literal terrain grid") +end +-- GB visuals use actual 8x8 pixel means/quadrants; no placeholder art on failure. +do + local api, game, ow = scene("gold") + local saved = love.image + love.image = {} + local reads = 0 + love.image.newImageData = function(path) + reads = reads + 1 + T.eq(path, "overview-test.png", "resolved image path") + return { + getDimensions = function() + return 32, 8 + end, + getPixel = function(_, x, y) + local shade = math.floor(x / 8) + if shade == 2 then + shade = (x % 8 < 4 and 0 or 3) + end + local v = 1 - shade / 3 + return v, v, v, 1 + end, + } + end + Assets.invalidate() + local stable = freeze(game) + local out = assert(api:mapOverview()) + stable("visual pure") + T.eq(out.tileRows[1], "00001111", "literal GB tile shades") + T.eq(out.tileRows[5], "22223333", "literal averaged split tile") + T.eq(out.tileDetailRows[9], "0303030333333333", "literal GB quadrants") + T.eq(out.tileWidth, 8, "2x tile width") + T.eq(out.tileDetailWidth, 16, "4x detail width") + out.tileRows[1] = "broken" + out.tileDetailRows[1] = "broken" + T.eq(api:mapOverview().tileRows[1], "00001111", "detached visual rows") + T.eq(reads, 1, "visual cache reused") + Assets.invalidate() + api:mapOverview() + T.eq(reads, 2, "asset reload invalidates visual cache") + love.image.newImageData = function() + error("missing image") + end + Assets.invalidate() + T.eq(api:mapOverview().tileRows, nil, "unavailable art is omitted") + love.image = saved + Assets.invalidate() +end +local function tableDef(rate, buckets, species) + local slots = {} + for i, id in ipairs(species or { "RATTATA", "PIDGEY", "RATTATA" }) do + slots[i] = { species = id, min = 2, max = 7 } + end + return { rate = rate, buckets = buckets or { 51, 102, 256 }, slots = slots } +end +local function encounters(version) + GameVersion.set(version) + local a = tableDef(60) + a.byTime = { + morn = tableDef(80, nil, { "PIDGEY", "PIDGEY", "RATTATA" }), + nite = tableDef(20, nil, { "HOOTHOOT", "RATTATA", "HOOTHOOT" }), + } + local maps = { + A = { id = "A", label = "RouteA", encounters = GameVersion.generation() >= 4 and 7 or nil }, + B = { + id = "B", + }, + } + local data = { + maps = maps, + encounters = { + [maps.A.encounters or "A"] = { + grass = a, + water = tableDef(10, { 128, 256 }, { "MAGIKARP", "POLIWAG" }), + }, + }, + field = {}, + pokemon = { + RATTATA = { name = "Rattata" }, + PIDGEY = { name = "Pidgey" }, + HOOTHOOT = { name = "Hoothoot" }, + MAGIKARP = { name = "Magikarp" }, + POLIWAG = { name = "Poliwag" }, + }, + } + local game = { data = data, save = { party = {}, flags = {} }, stack = { states = {} } } + return WorldAPI.new(game, "probe"), game, a +end +local function nativeParity(api, game, opts) + local hour = opts.daytime == "NITE" and 22 or opts.daytime == "MORNING" and 6 or 12 + local enc = Encounter.forMap(game.data, game.data.maps.A, "A", hour, game.save) + local slots = Encounter.atTime(enc.grass, opts.daytime) + local counts = {} + Encounter.load(game.data) + for pick = 0, (slots.bucketSpan or 256) - 1 do + local n = 0 + local result = Encounter.chooseTable(slots, function(lo) + n = n + 1 + if n == 1 then + return pick + end + return lo + end) + if result then + counts[tostring(result.species)] = (counts[tostring(result.species)] or 0) + 1 + end + end + local stable = freeze(game) + local out = assert(api:effectiveEncounters("A", "grass", opts)) + stable("encounter graph") + T.same(out.dist, counts, "native exhaustive bucket distribution") + local passed = 0 + for gate = 0, (slots.rateMax or 256) - 1 do + local n = 0 + local result = Encounter.rollTable(slots, function(lo) + n = n + 1 + if n == 1 then + return gate + end + return lo + end) + if result then + passed = passed + 1 + end + end + T.eq(out.chance, passed / (slots.rateMax or 256), "native exhaustive rate gate") + return out +end +for _, version in ipairs({ "red", "gold", "silver", "crystal", "emerald", "firered" }) do + local api, game = encounters(version) + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + local out = api:effectiveEncounters("A", "grass", { daytime = "DAY" }) + T.same(out.dist, { RATTATA = 205, PIDGEY = 51 }, version .. " duplicate species aggregated") + out.dist.RATTATA = 0 + T.eq(api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, 205, "distribution detached") + T.same( + api:effectiveEncounters("A", "indoor", { daytime = "NITE" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "indoor grass alias" + ) + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water table" + ) + T.same(api:effectiveEncounters("B", "grass"), { chance = 0, dist = {} }, "known map with no encounters") + T.eq(api:effectiveEncounters("missing", "grass"), nil, "unknown map refused") + for _, bad in ipairs({ "fishing", "darkGrass", false }) do + T.eq(api:effectiveEncounters("A", bad), nil, "terrain refused") + end + T.eq(api:effectiveEncounters("A", "grass", "NITE"), nil, "options must be table") + T.eq(api:effectiveEncounters("A", "grass", { daytime = "dusk" }), nil, "invalid time refused") + T.same( + api:effectiveEncounters("A", "grass", { daytime = "night" }), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "time aliases" + ) + game.data.encounters = nil + T.eq(api:effectiveEncounters("A", "grass"), nil, "unloaded data refused") +end +-- Gen2 outbreaks use the same native map selection for all three cartridges. +for _, version in ipairs({ "gold", "silver", "crystal" }) do + local api, game = encounters(version) + game.data.field.gen2Swarms = { + maps = { RouteA = { grass = tableDef(200, nil, { "HOOTHOOT", "HOOTHOOT", "HOOTHOOT" }) } }, + flags = { [0] = 17 }, + } + game.save.g2Swarm = { kind = 0, map = "A" } + if version == "crystal" then + game.save.flags[require("src.script.Gen2Flags").scriptFlag(17)] = true + end + nativeParity(api, game, { daytime = "DAY" }) + T.same( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist, + { HOOTHOOT = 256 }, + version .. " outbreak table" + ) + game.save.g2Swarm = nil + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.RATTATA, + 205, + "outbreak ends immediately" + ) +end +-- DS rates/bucket spans, time substitutions, daily replacements and seasons. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = i % 2 == 0 and "PIDGEY" or "RATTATA", level = 5, chance = chance } + end + local raw = { + grassRate = 30, + grass = slots, + surf = { + rate = 20, + slots = { { species = "MAGIKARP", level = 5, chance = 100 } }, + }, + } + game.data.encounters[7] = raw + if version == "black" or version == "white" then + raw.family = "bw" + raw.seasons = + { winter = { grassRate = 50, grass = { { species = "HOOTHOOT", level = 5, chance = 100 } } } } + game.save.gen5Season = 3 + end + for _, period in ipairs({ "MORNING", "DAY", "NITE" }) do + nativeParity(api, game, { daytime = period }) + end + if raw.family == "bw" then + T.same(api:effectiveEncounters("A", "grass").dist, { HOOTHOOT = 100 }, "BW current season") + game.save.gen5Season = 0 + T.eq(api:effectiveEncounters("A", "grass").chance, 0.3, "BW season changes immediately") + end +end +-- Cold DS resolver views: timed slots and daily replacements are reflected +-- without initializing save state or exposing mutable source slot records. +for _, version in ipairs({ "platinum", "heartgold" }) do + local api, game = encounters(version) + local slots = {} + for i, chance in ipairs({ 20, 20, 10, 10, 10, 10, 5, 5, 4, 4, 1, 1 }) do + slots[i] = { species = "RATTATA", level = 5, chance = chance } + end + local raw = { grassRate = 30, grass = slots, day = { 25, 25 }, night = { 163, 163 }, swarm = { 129, 129 } } + if version == "heartgold" then + raw.timed = { day = {}, night = {} } + for i = 1, 12 do + raw.timed.day[i] = 25 + raw.timed.night[i] = 163 + end + end + game.data.pokemon[25] = { name = "Pikachu" } + game.data.pokemon[163] = { name = "Hoothoot" } + game.data.pokemon[129] = { name = "Magikarp" } + game.data.encounters[7] = raw + local stable = freeze(game) + local day = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + local night = assert(api:effectiveEncounters("A", "grass", { daytime = "NITE" })) + T.eq(day.dist.PIKACHU, version == "heartgold" and 100 or 20, "DS time species day") + T.eq(night.dist.HOOTHOOT, version == "heartgold" and 100 or 20, "DS time species night") + stable("cold DS timed query") + local Swarms = require("src.world.Gen4Swarms") + game.data.maps.A.header = Swarms.HEADERS[1] + game.save.gen4Swarm = { enabled = true, daily = 0, rand = 123 } + stable = freeze(game) + local outbreak = assert(api:effectiveEncounters("A", "grass", { daytime = "DAY" })) + T.eq(outbreak.dist.MAGIKARP, 40, "DS daily swarm slots") + stable("DS daily swarm read") + outbreak.dist.MAGIKARP = 0 + T.eq( + api:effectiveEncounters("A", "grass", { daytime = "DAY" }).dist.MAGIKARP, + 40, + "DS distribution detached" + ) + game.save.gen4Swarm.enabled = false + if version == "platinum" then + game.data.maps.A.header = 287 + game.save.pokedex = { national = true } + game.save.gen4TrophyGarden = { slot1 = 0, slot2 = 1 } + game.data.gen4_special_encounters = { trophyGarden = { 25, 163 } } + stable = freeze(game) + local garden = assert(api:effectiveEncounters("A", "grass", { daytime = "MORNING" })) + T.eq(garden.dist.PIKACHU, 5, "garden first daily slot") + T.eq(garden.dist.HOOTHOOT, 5, "garden second daily slot") + stable("DS garden read") + end +end +-- A water table is not time-swapped by the native step path. +do + local api, game = encounters("gold") + game.data.encounters.A.water.byTime = { nite = tableDef(255, { 256 }, { "HOOTHOOT" }) } + T.same( + api:effectiveEncounters("A", "water", { daytime = "NITE" }).dist, + { MAGIKARP = 128, POLIWAG = 128 }, + "water ignores land time selector" + ) +end +-- Numeric IDs follow the shared public name contract, including loader aliases. +do + local api, game = encounters("platinum") + game.data.pokemon[25] = { id = 25, name = "Pikachu" } + game.data.encounters[7] = { grass = tableDef(50, { 256 }, { 25 }) } + T.same(api:effectiveEncounters("A", "grass").dist, { PIKACHU = 256 }, "numeric species -> portable ID") +end +-- Default Gen2 period follows the clock, and queries never run world.tod hooks. +do + local api, game = encounters("gold") + local date = os.date + os.date = function(format) + if format == "%H" then + return "23" + end + return date(format) + end + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "NITE" }), + "live Gen2 clock" + ) + Runtime.hooks:wrap("world.tod", function() + callbacks = callbacks + 1 + end) + game.overworld = { isOverworld = true, map = { id = "A" }, tod = "MORNING" } + game.stack.states = { game.overworld } + T.same( + api:effectiveEncounters("A", "grass"), + api:effectiveEncounters("A", "grass", { daytime = "MORNING" }), + "cached effective mod period" + ) + os.date = date +end +-- Malformed tables and exceptional positional tables fail explicitly. +do + local api, game, a = encounters("red") + for _, bad in ipairs({ 0, -1, math.huge, 0 / 0 }) do + a.rateMax = bad + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid denominator") + end + a.rateMax = nil + a.buckets = { 51, 50, 256 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "descending buckets refused") + a.buckets = { 51, 102, 257 } + T.eq(api:effectiveEncounters("A", "grass"), nil, "unreachable bucket refused") + a.buckets = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid bucket type refused") + a.buckets = { 51, 102, 256 } + local originalSlots = a.slots + a.slots = "bad" + T.eq(api:effectiveEncounters("A", "grass"), nil, "invalid slot type refused") + a.slots = originalSlots + a.buckets = { 51, 102, 256 } + a.rate = 0 + T.same(api:effectiveEncounters("A", "grass"), { chance = 0, dist = {} }, "zero rate") + a.rate = 999 + T.eq(api:effectiveEncounters("A", "grass").chance, 1, "saturated rate") + GameVersion.set("heartgold") + local S = require("src.world.HgssSafariZone") + game.data.maps.A.layout = S.LAYOUT + game.data.maps.A.cameraFamily = "hgss" + local stable = freeze(game) + T.eq(api:effectiveEncounters("A", "grass"), nil, "Safari query refused") + stable("Safari does not seed save") + GameVersion.set("emerald") + for _, id in ipairs({ + require("src.script.Gen3BattlePike").WILD_ROOM, + require("src.script.Gen3BattlePyramid").FLOOR, + }) do + game.data.maps[id] = { id = id } + T.eq(api:effectiveEncounters(id, "grass"), nil, "facility query refused") + end +end +-- API1/2 native mod loading, lazy capability queries and live table patches. +for _, apiVersion in ipairs({ 1, 2 }) do + GameVersion.set("gold") + local data = T.fixtures.fresh() + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = apiVersion, + entry = "main.lua", + content_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + callback_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" + or nil, + script_format = os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 and "unified" or nil, + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + elseif files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function(self) + resolves = resolves + 1 + return self.game + end + T.check(loader:load(data), "real loader succeeds") + local mod = loader.apis.probe + if os.getenv("WORLD_OVERVIEW_ALL_FORMATS") == "1" and apiVersion == 2 then + T.eq(mod.contentFormat, "unified", "unified content facade") + T.eq(mod.callbackFormat, "unified", "unified callback facade") + T.eq(mod.scriptFormat, "unified", "portable script facade") + end + T.eq(resolves, 0, "entry and ID context stay lazy") + if mod.supports then + T.eq(mod:supports("world", "mapOverview"), true, "overview capability") + T.eq(mod:supports("world", "effectiveEncounters"), true, "encounter capability") + T.eq(resolves, 0, "capability query stays lazy") + end + loader.game = { data = data, save = {}, stack = { states = {} } } + local world = mod.world + T.eq(world:mapOverview(), nil, "title screen overview is quiet") + local out = assert(world:effectiveEncounters("FIX_ROUTE", "grass")) + T.check(next(out.dist) ~= nil, "query available at title screen") + local before = out.chance + local id = next(out.dist) + data.pokemon[id].name = "Renamed presentation" + T.check( + world:effectiveEncounters("FIX_ROUTE", "grass").dist[id] ~= nil, + "public identity survives name patch" + ) + data.encounters.FIX_ROUTE.grass.rate = 100 + T.check(world:effectiveEncounters("FIX_ROUTE", "grass").chance ~= before, "live data patch reflected") +end +T.eq(draws, 0, "all queries consume zero RNG draws") +T.eq(callbacks, 0, "all queries execute zero hook callbacks") +love.math.random, math.random = savedLoveRandom, savedMathRandom +Runtime.hooks = savedHooks +GameVersion.set(savedVersion) +T.finish("world overview and encounter API") diff --git a/tests/engine/world_read_api.lua b/tests/engine/world_read_api.lua new file mode 100644 index 000000000..b7beda07f --- /dev/null +++ b/tests/engine/world_read_api.lua @@ -0,0 +1,415 @@ +-- ROM-free tests through the real loader and real runtime Map objects. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Map = require("src.world.Map") +local MapLoader = require("src.world.MapLoader") +local Merge = require("src.mods.Merge") +local Runtime = require("src.mods.Runtime") +local GameVersion = require("src.core.GameVersion") +local Json = require("src.link.Json") +local Reads = require("src.mods.WorldReads") +local savedLove, savedVersion = love, GameVersion.get() +local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors +love = nil +local function pack16(...) + local out = {} + for _, v in ipairs({ ... }) do + out[#out + 1] = string.char(v % 256, math.floor(v / 256)) + end + return table.concat(out) +end +local function map(id, cells, packed, ds) + return Map.new({ + id = id, + width = 3, + height = 2, + blocks = packed and pack16(0, 1 + 3 * 1024, 2 + 9 * 4096, 3, 4, 1023) or { 0, 1, 2, 3, 4, 1023 }, + borderBlock = 999, + generation = ds and 4 or nil, + }, { blockCells = cells, blockTiles = cells * 2, walkable = {}, blocks = {} }) +end +local function boot(version, entry) + GameVersion.set(version) + local gen = GameVersion.generation() + local s, m, item = gen >= 4 and 25 or "PIKACHU", gen >= 4 and 33 or "TACKLE", gen >= 4 and 13 or "POTION" + local species = { id = s, name = "Pikachu" } + local move = { id = m, name = "Tackle", pp = 35 } + local data = { + pokemon = { [s] = species }, + moves = { [m] = move }, + items = { + [item] = { id = item, name = "Potion" }, + }, + } + if gen >= 4 then + data.pokemon.SPECIES_25 = species + data.moves.MOVE_33 = move + end + local mon = { + species = s, + item = item, + nickname = "Buddy", + name = "native name", + level = 7, + hp = 19, + status = "PSN", + stats = { hp = 23, attack = 10, defense = 11, speed = 12, special = 13 }, + moves = { { id = m, pp = 29, ppUps = 1, hidden = false } }, + ivs = { hp = 27 }, + evs = { speed = 3 }, + personality = 12345, + extension = { flag = false }, + isEgg = false, + } + if gen > 1 then + mon.stats.spatk = 13 + mon.stats.spdef = 14 + end + local active = map("ACTIVE", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local neighbor = map("NORTH", gen <= 2 and 2 or 1, gen >= 3, gen >= 4) + local ow = { isOverworld = true, map = active, neighbors = { { map = neighbor, cx = -2, cy = -4 } } } + local game = { + data = data, + save = { party = { mon }, money = 500 }, + stack = { states = { ow, { isMenu = true } } }, + overworld = ow, + } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolves = 0 + loader._game = function() + resolves = resolves + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + local api = assert(loader.apis.probe) + return api, loader, game, ow, { s, m, item }, function() + return resolves + end +end + +-- Fail if any reader tries to load a map, generate a Pokemon or use RNG. +local oldMapLoad = MapLoader.load +local Pokemon = require("src.pokemon.Pokemon") +local oldNew = Pokemon.new +local oldRandom = math.random +local function forbidden() + error("read crossed into gameplay") +end +MapLoader.load, Pokemon.new, math.random = forbidden, forbidden, forbidden + +for _, version in ipairs(GameVersion.ORDER) do + local api, loader, game, ow, ids, resolves = boot(version) + local label = version .. ": " + T.eq(resolves(), 0, label .. "entry and read metadata do not instantiate Game") + if api.supports then + for _, name in ipairs({ "party", "liveMaps", "activeBlockAt" }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolves(), 0, label .. "capability probes do not instantiate Game") + end + local before = Merge.deepCopy(game) + local world = api.world + T.eq(api.world, world, label .. "same cached facade") + local party = world:party() + local mon = party[1] + T.eq(mon.species, "PIKACHU", label .. "symbolic species") + T.eq(mon.item, "POTION", label .. "symbolic item") + T.eq(mon.moves[1].id, "TACKLE", label .. "symbolic move") + T.eq(mon.moves[1].pp, 29, label .. "current PP") + T.eq(mon.moves[1].maxPp, 42, label .. "PP Ups use native rounding") + T.eq(mon.moves[1].ppUps, 1, label .. "PP Ups exposed") + T.eq(mon.maxHp, 23, label .. "max HP from stats.hp") + T.eq(mon.stats.specialAttack, 13, label .. "special attack") + T.eq(mon.stats.specialDefense, GameVersion.generation() == 1 and 13 or 14, label .. "special defense") + T.eq(mon.status, "poison", label .. "status normalized") + T.eq(mon.isEgg, false, label .. "false flags preserved") + T.eq(mon.native.extension.flag, false, label .. "native false extension preserved") + T.eq(mon.native.ivs.hp, 27, label .. "native IV diagnostics") + T.eq(getmetatable(mon), nil, label .. "plain detached mon") + mon.species = "OTHER" + mon.stats.hp = 1 + mon.moves[1].pp = 0 + mon.native.ivs.hp = 0 + party[2] = { species = "INJECTED" } + T.same(game, before, label .. "party mutations do not reach game") + local nextParty = world:party() + T.eq(nextParty[1].stats.hp, 23, label .. "fresh nested stat snapshot") + T.eq(nextParty[1].moves[1].pp, 29, label .. "fresh move snapshot") + T.eq(#nextParty, 1, label .. "party membership detached") + game.save.party[1].hp = 18 + T.eq(world:party()[1].hp, 18, label .. "reads current native HP") + game.save = { party = { { species = ids[1], hp = 1, stats = { hp = 2 }, moves = {} } } } + T.eq(world:party()[1].hp, 1, label .. "held facade follows a replaced save") + game.save = before.save + + local cells = ow.map.blockCells + before = Merge.deepCopy(game) + local id, metadata = world:activeBlockAt("ACTIVE", 0, 0) + T.eq(id, 0, label .. "block zero is present") + T.eq(metadata.tileSize, cells * 16, label .. "block pixel size from runtime map") + T.eq(metadata.cellSize, 16, label .. "walk-cell pixel size") + T.eq(metadata.coordinateBase, 0, label .. "zero-based block coordinates") + T.eq(metadata.width, 3, label .. "block width") + T.eq(metadata.height, 2, label .. "block height") + local gen = GameVersion.generation() + T.eq( + metadata.kind, + gen >= 4 and "runtime_cell" or gen >= 3 and "metatile" or "block", + label .. "honest block kind" + ) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 1, label .. "packed collision bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 0), 2, label .. "packed elevation bits stripped") + T.eq(world:activeBlockAt("ACTIVE", 2, 1), 1023, label .. "last block / full 10-bit ID") + T.eq(ow.map.def._blockArray, nil, label .. "cold packed grid remains undecoded") + metadata.width = 999 + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)).width, 3, label .. "metadata detached") + for _, xy in ipairs({ + { -1, 0 }, + { 0, -1 }, + { 3, 0 }, + { 0, 2 }, + { 0.5, 0 }, + { 0, 0.5 }, + { math.huge, 0 }, + { + 0, + 0 / 0, + }, + { + "1", + 0, + }, + }) do + local value, reason = world:activeBlockAt("ACTIVE", xy[1], xy[2]) + T.check(value == nil and type(reason) == "string", label .. "invalid/off-map coordinates rejected") + end + local missing, reason = world:activeBlockAt("NORTH", 0, 0) + T.check(missing == nil and reason == "map is not active", label .. "stale map rejected") + T.same(game, before, label .. "block reads do not alter world or decoder cache") + ow.map:setBlock(1, 0, 0) + local patched = Merge.deepCopy(game) + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 0, label .. "live zero-valued block patch wins") + T.same(game, patched, label .. "patch read remains pure") + ow.map.blockPatch[2] = 777 + T.eq(world:activeBlockAt("ACTIVE", 1, 0), 777, label .. "reads current runtime patch") + local oldMap = ow.map + ow.map = ow.neighbors[1].map + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "held facade rejects map after warp") + T.eq(world:activeBlockAt("NORTH", 0, 0), 0, label .. "held facade reads new active map") + ow.map = oldMap + + before = Merge.deepCopy(game) + local legacy = world:loadedMaps() + local maps = world:liveMaps() + T.eq(#maps, 2, label .. "only loaded maps returned") + T.eq(maps[1].mapId, "ACTIVE", label .. "active first") + T.eq(maps[1].active, true, label .. "active tagged") + T.eq(maps[1].width, 3 * cells, label .. "map dimensions in cells") + T.eq(maps[2].mapId, "NORTH", label .. "loaded neighbor included") + T.eq(maps[2].x, -2, label .. "neighbor x offset in cells") + T.eq(maps[2].y, -4, label .. "neighbor y offset in cells") + T.eq(maps[2].block.tileSize, cells * 16, label .. "per-map block units") + maps[1].mapId = "MUTATED" + maps[2].block.width = 9 + maps[2].x = 123 + T.same(game, before, label .. "live map snapshots detached") + T.same(world:loadedMaps(), legacy, label .. "legacy loadedMaps shape unchanged") + T.eq(world:liveMaps()[2].x, -2, label .. "fresh map snapshots") + + game.stack.states = {} + game.overworld = nil + T.eq(world:activeBlockAt("ACTIVE", 0, 0), nil, label .. "no active world block result") + T.eq(select(2, world:activeBlockAt("ACTIVE", 0, 0)), "no overworld", label .. "no world reason") + T.eq(world:liveMaps(), nil, label .. "no active world maps result") + T.check(world:party() ~= nil, label .. "party available without world") + game.save = nil + T.eq(world:party(), nil, label .. "no save party result") + T.eq(select(2, world:party()), "no save", label .. "no save reason") +end + +MapLoader.load, Pokemon.new, math.random = oldMapLoad, oldNew, oldRandom +-- Trap LÖVE's RNG too when the host provides one; queries remain ordinary +-- data reads in a host with no graphics/filesystem surface. +love = { math = { random = forbidden } } +local rngApi = WorldAPI.new( + { data = {}, save = { party = {} }, overworld = { isOverworld = true, map = map("RNG", 1, true, false) } }, + "rng" +) +T.check(rngApi:party() ~= nil, "party never calls love.math.random") +T.eq(rngApi:activeBlockAt("RNG", 0, 0), 0, "block never calls love.math.random") +T.eq(#rngApi:liveMaps(), 1, "map list never calls love.math.random") +love = nil +-- Direct engine construction also produces symbolic records without a loader. +local data = { + pokemon = { [999] = { name = "Mr. Mime" } }, + moves = { [888] = { name = "Ember", pp = 25 } }, + items = { + [777] = { name = "Potion" }, + }, +} +local raw = { + species = 999, + item = 777, + hp = 3, + stats = { hp = 9 }, + moves = { { id = 888, pp = 5 } }, + extension = { + flag = false, + }, +} +local direct = WorldAPI.new({ data = data, save = { party = { raw } } }, "direct") +local before = Merge.deepCopy(data) +local p = direct:party() +T.eq(p[1].species, "MR_MIME", "standalone numeric species resolves by imported name") +T.eq(p[1].moves[1].id, "EMBER", "standalone numeric move resolves") +T.eq(p[1].native.extension.flag, false, "standalone false diagnostics retained") +T.same(data, before, "standalone resolver does not mutate data") +raw.self = raw +raw.callback = function() end +raw[{}] = { hidden = true } +local cyclic = direct:party()[1] +T.eq(cyclic.native.self, cyclic.native, "cyclic diagnostics detached consistently") +T.eq(cyclic.native.callback, nil, "diagnostics do not expose functions") +local tableKey = false +for k in pairs(cyclic.native) do + if type(k) == "table" then + tableKey = true + end +end +T.eq(tableKey, false, "diagnostics do not retain native table keys") +raw.self = nil +raw.callback = nil + +-- Metadata comes from the actual tileset even when its size is unusual. +local mixed = map("MIXED", 3, false, false) +local mixedApi = WorldAPI.new({ overworld = { isOverworld = true, map = mixed } }, "mixed") +T.eq( + select(2, mixedApi:activeBlockAt("MIXED", 0, 0)).tileSize, + 48, + "custom block size is not inferred from generation" +) +local missingMap = map("SHORT", 1, true, false) +missingMap.def.blocks = "\1" +local shortApi = WorldAPI.new({ overworld = { isOverworld = true, map = missingMap } }, "short") +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "truncated packed block refused") +missingMap.def.blocks = {} +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "missing array cell refused") +missingMap.def.blocks = { -1 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "negative block ID refused") +missingMap.def.blocks = pack16(1) +missingMap.def._blockArray = { 7 } +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), 7, "warm decoder cache matches Map:blockAt") +local cache = missingMap.def._blockArray +T.eq(cache[1], 7, "warm cache unmodified") +missingMap.blockTiles = 3 +T.eq(shortApi:activeBlockAt("SHORT", 0, 0), nil, "inconsistent runtime geometry refused") +local placements = { isOverworld = true, map = map("HOME", 1, false, false) } +local repeated = map("NORTH", 1, false, false) +placements.neighbors = { + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 0, cy = -2 }, + { map = repeated, cx = 3, cy = 0 }, + { map = placements.map, cx = 0, cy = 0 }, + { map = map("OTHER", 2, false, false), cx = -4, cy = 0 }, +} +local list = WorldAPI.new({ overworld = placements }, "placements"):liveMaps() +T.eq(#list, 4, "duplicate placements collapse, different placements of the same map remain") +T.eq(list[2].mapId, list[3].mapId, "two placements share map identity") +T.eq(list[3].x, 3, "second placement offset preserved") +T.eq(list[4].block.tileSize, 32, "mixed-neighborhood block sizes describe each map") +T.eq(list[4].width, 6, "mixed-neighborhood width remains in walk cells") +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:party(), nil, "no Game party read safe") +T.eq(empty:liveMaps(), nil, "no Game map read safe") +T.eq(empty:activeBlockAt("MAP", 0, 0), nil, "no Game block read safe") + +-- IDs stay bound to pre-merge identity despite presentation patches and +-- registrations made after the API/context was constructed. +local api, loader, game = boot( + "platinum", + [[return function(mod) + mod.exports.api=mod + mod.content.pokemon:patch("SPECIES_25",{name="A localized display name"}) + mod.content.pokemon:register("CUSTOMMON",{ + id="CUSTOMMON",dex=999,name="Another display name",types={"NORMAL"}, + catchRate=45,baseExp=64,level1Moves={},growthRate="MEDIUM_FAST", + learnset={},evolutions={},spriteFront="front.png",spriteBack="back.png",frontSize=5, + baseStats={hp=40,attack=40,defense=40,speed=40,special=40}, + }) +end]] +) +game.save.party[1].species = "SPECIES_25" +game.save.party[2] = { species = "CUSTOMMON", level = 1, stats = {}, moves = {} } +T.eq(api.world:party()[1].species, "PIKACHU", "patched alias keeps canonical pre-merge ID") +T.eq(api.world:party()[2].species, "CUSTOMMON", "late mod registration uses its authored ID") +local emptyParty = game.save.party +game.save.party = {} +T.same(api.world:party(), {}, "empty saved party returns an empty snapshot") +game.save.party = emptyParty +game.save.party[1].item = 0 +game.save.party[1].status = nil +game.save.party[1].isEgg = true +T.eq(api.world:party()[1].item, nil, "no-item native sentinel is absent") +T.eq(api.world:party()[1].status, nil, "healthy status is absent") +T.eq(api.world:party()[1].isEgg, true, "egg flag exposed") +-- Optional integration gate against #92's actual event record encoder. +if pcall(require, "src.mods.UnifiedCallbacks") then + local Unified = require("src.mods.UnifiedCallbacks") + Unified.install(loader, api, "probe", 4) + local eventMon + api.events:on("pokemon.received", function(event) + eventMon = event.mon + end) + loader.events:emit("pokemon.received", { mon = game.save.party[1] }) + T.same(api.world:party()[1], eventMon, "party/event live record shapes match exactly") +end + +-- Verify both normal RNG streams independently, without relying only on the +-- tripwire above. Lua's sequence must remain unchanged by the real readers. +math.randomseed(24680) +local expected = { math.random(), math.random(), math.random() } +math.randomseed(24680) +api.world:party() +api.world:activeBlockAt("ACTIVE", 1, 0) +api.world:liveMaps() +T.same({ math.random(), math.random(), math.random() }, expected, "reader calls leave RNG sequence unchanged") +for _, name in ipairs({ "party", "activeBlockAt", "liveMaps" }) do + T.check(Reads.supports(name), "new capability " .. name) +end +T.check(not Reads.supports("flyTo") and not Reads.supports({}), "reader capability list stays narrow") + +GameVersion.set(savedVersion) +Runtime.install(savedEvents, savedHooks, savedErrors) +love = savedLove +T.finish() diff --git a/tests/goldens/fixture_fingerprint.md b/tests/goldens/fixture_fingerprint.md new file mode 100644 index 000000000..55c09281f --- /dev/null +++ b/tests/goldens/fixture_fingerprint.md @@ -0,0 +1,47 @@ +# Fixture link fingerprint audit + +The previous `7de3051a7b3c108e` pin was already stale at Gen2Recomped's +`0b8c9c4` import: that checkout computes `8c94d595cd60ca69` against its own +ROM-free fixtures. It is not a baseline that current Gen2 data can satisfy. + +At upstream `13af468b97dc37015d14ad5c71d7f067c3ee810d`, the same production +serializer and digest compute `41e69caf4afa35ab`. Comparing the canonical +`Fingerprint.surface(data, {})` strings against `0b8c9c4` changes **only** +`move_effects`: commit `3252aec` adds these four full-handler registry records: + +- `POWER_BASED_ON_LOW_SPEED` (Gyro Ball) +- `INCREASE_POWER_WITH_MORE_HP` (Wring Out / Crush Grip) +- `DOUBLE_POWER_WHEN_BELOW_HALF` (Brine) +- `DOUBLE_POWER_HEAL_SLEEP` (Wake-Up Slap) + +No records were removed or altered in the serialized surface comparison. +The current surface has 198 effect records. Species, moves, type chart, +statuses, constants and mod list are byte-identical to the imported checkout. +An independent Python implementation using integer XOR, multiplication by +16777619 and modulo 2**32 in both lanes also produces the current digest. +No fingerprint algorithm, allowlist, cache or production registry is changed +by this test repair. + +`fixture_fingerprint_sections.txt` pins each populated section separately so +future intentional registry additions cannot hide changes in another section. +`gate_fingerprint.lua` retains all prior mutation and determinism assertions, +and additionally proves that removing any of the four Sinnoh registrations +changes compatibility. An empty `link_fields` registry has no serialized +section; a future nonempty one requires a deliberate new section pin. + +To audit a future change, load a fresh `tests.modkit` fixture with +`T.sdk.loadNone({data=data})`, compare `Fingerprint.surface(data,{})` section +by section, and then update only reviewed pins. Run the gate again and keep +the mutation assertions enabled. The ROM-dependent vanilla golden is separate +and was not re-blessed by this update. + +## Current upstream Gen4 effect registrations + +Upstream `8d5b4387` adds 53 native Gen4 effect registrations through +`Gen4MoveEffects` and the existing non-destructive registry merge. Comparing +canonical fixture surfaces against `ed5189c2` changes only `move_effects`: +53 additions, no removals, and no changed existing kind/revision records. +The other six populated sections remain byte-identical. The whole fingerprint +is now `884a4b379d3e2f83`, and the effect section is `1c01a759e13618d5`. +The production serializer, digest and all gate mutation assertions remain +unchanged. These pins acknowledge the new upstream registry surface. diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 3ab0752dc..d43cb64a0 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -7de3051a7b3c108e +884a4b379d3e2f83 diff --git a/tests/goldens/fixture_fingerprint_sections.txt b/tests/goldens/fixture_fingerprint_sections.txt new file mode 100644 index 000000000..4437d24e9 --- /dev/null +++ b/tests/goldens/fixture_fingerprint_sections.txt @@ -0,0 +1,7 @@ +pokemon 0ad4d8a59c0daa59 +moves 54b3cb2c1776ed48 +type_chart cc5bd80d003bd459 +statuses 68247ced2ed19a21 +move_effects 1c01a759e13618d5 +constants e4cf6f91ff122fa5 +mods 6a9ee9de68f78902 diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py new file mode 100644 index 000000000..e16316854 --- /dev/null +++ b/tools/api3-contract-check/check.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Run the identical API-3 mod files on Gen2Recomped and pinned Gen1Recomp.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +GEN1_REF = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +VERSIONS = ("red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald") + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--gen1", required=True, type=Path, help="clean checkout at the pinned Gen1 commit") + parser.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + gen1, gen2 = args.gen1.resolve(), args.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1_REF: + parser.error(f"Gen1 must be pinned to {GEN1_REF}") + if git(gen1, "status", "--porcelain", "--untracked-files=normal"): + parser.error("Gen1 reference has tracked modifications; use an isolated checkout") + fixtures = gen2 / "tests" / "api3_contract" + manifest = {} + for path in sorted((fixtures / "mods").rglob("*")): + if path.is_file(): + manifest[str(path.relative_to(fixtures))] = hashlib.sha256(path.read_bytes()).hexdigest() + results = [] + scaffold_temp = tempfile.TemporaryDirectory(prefix="api3-scaffold-") + scaffold_root = Path(scaffold_temp.name) + for name, options in (("portable", ["--api", "3"]), ("legacy", [])): + subprocess.run([sys.executable, str(gen2 / "tools/modkit.py"), "scaffold", name, + "--dest", str(scaffold_root / "mods"), "--quiet", *options], + cwd=gen2, check=True, capture_output=True, text=True, timeout=60) + generated = json.loads((scaffold_root / "mods/portable/manifest.json").read_text()) + assert generated["api"] == 3 and "game_version" not in generated + assert generated["games"] == ["gen1", "gen2", "gen3"] + legacy = json.loads((scaffold_root / "mods/legacy/manifest.json").read_text()) + assert legacy["api"] == 2 and legacy["game_version"].startswith(">=") + generated_hashes = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in (scaffold_root / "mods/portable").iterdir() + if path.is_file()} + for version in VERSIONS: + for mode in ("normal", "rollback", "scaffold", "warp", "world_edits", "npcs", "wild_battle", "invalidate", "flags", "script_flags", "script_state", "script_gifts", "script_world", "script_motion", "script_travel", "script_battles", "script_trainers", "script_shops", "script_audio", "script_dialogue_audio", "script_fields", "effects", "datasets"): + values = {} + for backend, engine in (("gen1", gen1), ("gen2", gen2)): + source = scaffold_root if mode == "scaffold" else fixtures + command = [args.lua, str(fixtures / "worker.lua"), str(source), backend, version, mode] + run = subprocess.run(command, cwd=engine, text=True, capture_output=True, timeout=60) + reports = [line.removeprefix("API3_CONTRACT ") for line in run.stdout.splitlines() + if line.startswith("API3_CONTRACT ")] + if run.returncode or len(reports) != 1: + raise RuntimeError(f"{backend}/{version}/{mode} failed:\n{run.stdout}{run.stderr}") + values[backend] = json.loads(reports[0]) + if values["gen1"] != values["gen2"]: + raise RuntimeError(f"contract mismatch for {version}/{mode}: {values}") + results.append({"version": version, "mode": mode, "result": values["gen1"]}) + print(f"PASS {version}/{mode}: identical mod and public result") + report = {"gen1_ref": GEN1_REF, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "gen2_tracked_changes": bool(git(gen2, "status", "--porcelain", "--untracked-files=no")), + "fixture_sha256": manifest, "scaffold_sha256": generated_hashes, "comparisons": results} + if args.report: + args.report.write_text(json.dumps(report, indent=2) + "\n") + scaffold_temp.cleanup() + print(f"API 3: {len(results)} cross-engine comparisons, {len(results) * 2} isolated loader runs passed") + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, subprocess.SubprocessError, OSError, ValueError, AssertionError) as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/tools/flag-catalog-check/generate.py b/tools/flag-catalog-check/generate.py new file mode 100644 index 000000000..e385d9931 --- /dev/null +++ b/tools/flag-catalog-check/generate.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Reproduce/check flag identifier catalogs against the clean pinned reference. + +Exports identifiers only. No Gen1 engine code or cartridge ROM is vendored. +""" +import argparse +import json +from pathlib import Path +import subprocess + +PIN = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +EXPORT = r""" +package.path = './?.lua;./?/init.lua;' .. package.path +local out = { reference = '79ea62f45fb2ea8f65d081a109431e4eb1bbdb36', catalogs = {} } +for _, version in ipairs({'red','yellow'}) do + local suffix = version == 'yellow' and '_yellow' or '' + local names=require('src.save_convert.data.event_flags'..suffix).byBit + local encoded={} for k,v in pairs(names) do encoded['n'..k]=v end + out.catalogs[version] = { names=encoded } +end +for _, version in ipairs({'firered','emerald'}) do + require('src.core.GameVersion').set(version) + local t = require('src.core.game3.scripting.flags').forVersion(version) + local ids,names,count={},{},t.FLAGS_COUNT or 0x900 + for k,v in pairs(t.IDS) do + if type(k)=='string' and type(v)=='number' and v>=1 and v=1 and k={{game_version}} <{{next_major}}.0.0", + "api": {{api}}, + "entry": "main.lua",{{games_line}} + "profile": "{{profile}}",{{engine_range_line}} "category": "GAMEPLAY", "priority": 100, "dependencies": [], @@ -378,7 +377,7 @@ def check_github_field(manifest): "MK001", "error", str(err), "manifest.json")) return findings, notes -MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2). +MAIN_CONTENT = """-- {{id}}: a content-profile mod (api {{api}}). -- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat. return function(mod) -- patch, not override: every field you do not name keeps its base value @@ -391,7 +390,16 @@ def check_github_field(manifest): end """ -MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api 2). +MAIN_PORTABLE = """-- {{id}}: portable API {{api}} entry for Gen1Recomp and Gen2Recomped. +return function(mod) + if mod.content.moves:has("POUND") then + local ok, reason = mod.content.moves:patch("POUND", { power = 45 }) + if not ok then mod.log:warn("%s", reason) end + end +end +""" + +MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api {{api}}). return function(mod) mod.options:define({ { key = "difficulty", label = "DIFFICULTY", kind = "choice", @@ -411,7 +419,7 @@ def check_github_field(manifest): end """ -MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api 2). +MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api {{api}}). return function(mod) -- the new game itself: spawn, names, money (field.boot, D11) -- mod.content.field:patch("boot", { @@ -442,7 +450,7 @@ def check_github_field(manifest): README_TEMPLATE = """# {{name}} -A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2). +A `{{profile}}` mod for {{target_engine}} (mod api {{api}}). ## Layout @@ -489,6 +497,10 @@ def cmd_scaffold(args, repo): "{{game_version}}": engine, "{{next_major}}": str(next_major), "{{extra}}": extra, "{{github_line}}": github_line, + "{{api}}": str(args.api), + "{{games_line}}": '\n "games": ["gen1", "gen2", "gen3"],' if args.api == 3 else "", + "{{engine_range_line}}": "" if args.api == 3 else f'\n "game_version": ">={engine} <{next_major}.0.0",', + "{{target_engine}}": "Gen1Recomp and Gen2Recomped" if args.api == 3 else "the LOVE2D Pokemon Red engine", "{{experimental}}": "true" if getattr(args, "experimental", False) else "false", } @@ -504,6 +516,8 @@ def emit(rel, template): main = {"content": MAIN_CONTENT, "overhaul": MAIN_OVERHAUL, "total_conversion": MAIN_TC}[profile] + if args.api == 3: + main = MAIN_PORTABLE layout_extra = "" if profile == "total_conversion": layout_extra = "- `transforms.lua` - asset transforms over the player's cache\n" @@ -519,7 +533,7 @@ def emit(rel, template): emit("transforms.lua", TRANSFORMS_TEMPLATE) if not args.quiet: - print(f"created {dest} ({profile} profile, api 2)") + print(f"created {dest} ({profile} profile, api {args.api})") print(f"next: python3 tools/modkit.py validate {args.id}") return 0 @@ -1964,7 +1978,7 @@ def main(argv): p.add_argument("id") p.add_argument("--profile", default="content", choices=["content", "overhaul", "total_conversion"]) - p.add_argument("--api", type=int, default=2) + p.add_argument("--api", type=int, choices=(1, 2, 3), default=2) p.add_argument("--github", default="", help="optional owner/repo (enables launcher auto-update)") p.add_argument("--experimental", action="store_true", diff --git a/tools/move-effect-contract/export_reference.lua b/tools/move-effect-contract/export_reference.lua new file mode 100644 index 000000000..47f75c4a6 --- /dev/null +++ b/tools/move-effect-contract/export_reference.lua @@ -0,0 +1,44 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local E = require("src.mods.UnifiedEffects") +local M1 = require("src.battle.g3u.EffectMap1") +local M2 = require("src.battle.g3u.EffectMap2") +local N = require("src.core.game3.battle.effect_ids") +local ids = {} +for _, t in ipairs({ M1.EFFECTS, M1.UNSUPPORTED, M2.EFFECTS, M2.UNSUPPORTED, N }) do + for k in pairs(t) do + ids[k] = true + end +end +local out = { bindings = {}, read3 = {}, read2 = {}, variants = {}, reserved = {} } +for id in pairs(ids) do + out.reserved[id] = true +end +for id, row in pairs(M1.EFFECTS) do + out.variants[id] = { semantic = row.effect, chance = row.chance } +end +for id in pairs(ids) do + local row = {} + for gen = 1, 3 do + local native, chance = E.write(id, gen) + if native then + local semantic = gen == 3 and native + or gen == 2 and M2.EFFECTS[native] and M2.EFFECTS[native].effect + or nil + if gen == 2 and native == "EFFECT_BURN" then + semantic = N.WILL_O_WISP + end + row[tostring(gen)] = + { native = native, chance = type(chance) == "number" and chance or nil, semantic = semantic } + end + end + if next(row) then + out.bindings[id] = row + end +end +for i = 0, 255 do + out.read3[tostring(i)] = E.read(i, 3, {}) +end +for id in pairs(M2.EFFECTS) do + out.read2[id] = E.read(id, 2, {}) +end +print(require("src.link.Json").encode(out)) diff --git a/tools/move-effect-contract/generate.py b/tools/move-effect-contract/generate.py new file mode 100644 index 000000000..cb53f7e34 --- /dev/null +++ b/tools/move-effect-contract/generate.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Project the pinned reference effect contract onto host native handler names.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess + +PIN = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +ROOT = Path(__file__).resolve().parents[2] + + +def lua(v): + if v is None: + return "nil" + if v is True: + return "true" + if v is False: + return "false" + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, str): + return json.dumps(v, ensure_ascii=False) + if isinstance(v, list): + return "{" + ",".join(lua(x) for x in v) + "}" + return "{" + ",".join("[" + lua(k) + "]=" + lua(x) for k, x in sorted(v.items())) + "}" + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--lua", required=True) + p.add_argument("--check", action="store_true") + a = p.parse_args() + ref = a.gen1.resolve() + def git(*args): + return subprocess.check_output(["git", "-C", str(ref), *args], text=True).strip() + if git("rev-parse", "HEAD") != PIN or git("status", "--porcelain", "--untracked-files=normal"): + p.error("reference must be clean at " + PIN) + raw = subprocess.check_output([a.lua, str(Path(__file__).with_name("export_reference.lua"))], cwd=ref) + data = json.loads(raw) + src = ROOT / "src/import/RomExtractorGen3.lua" + block = src.read_text().split("local GEN3_MOVE_EFFECTS = {", 1)[1].split("\n}", 1)[0] + data["native3"] = {n: re.findall(r'"([A-Z0-9_]+)"', row) + for n, row in re.findall(r'\[(\d+)\]\s*=\s*\{([^}]+)\}', block)} + data.pop("read2") + files = ["src/mods/UnifiedEffects.lua", "src/battle/g3u/EffectMap1.lua", + "src/battle/g3u/EffectMap2.lua", "src/core/game3/battle/effect_ids.lua"] + data["reference"] = {"commit": PIN, "sha256": { + f: hashlib.sha256((ref / f).read_bytes()).hexdigest() for f in files}} + data["hostMapSha256"] = hashlib.sha256(block.encode()).hexdigest() + lines = ["-- Generated by tools/move-effect-contract/generate.py; do not hand-edit.", "return {"] + for group, rows in sorted(data.items()): + lines.append(" [" + lua(group) + "] = {") + for key, value in sorted(rows.items() if isinstance(rows, dict) else []): + lines.append(" [" + lua(key) + "] = " + lua(value) + ",") + if not isinstance(rows, dict): + lines[-1] = " [" + lua(group) + "] = " + lua(rows) + "," + else: + lines.append(" },") + lines.append("}") + text = "\n".join(lines) + "\n" + target = ROOT / "src/mods/MoveEffectContract.lua" + if a.check: + if target.read_text() != text: + raise SystemExit("effect contract differs; regenerate") + print("PASS pinned effect bindings and host native-name map") + else: + target.write_text(text) + print("Wrote", target) + + +if __name__ == "__main__": + main()