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/.github/workflows/native-file-picker.yml b/.github/workflows/native-file-picker.yml new file mode 100644 index 000000000..107ddf332 --- /dev/null +++ b/.github/workflows/native-file-picker.yml @@ -0,0 +1,55 @@ +name: Native desktop file selection +on: + pull_request: + paths: + - 'src/core/FilePicker.lua' + - 'src/core/NativeFile.lua' + - 'src/core/HostShell.lua' + - 'src/import/RomImporter.lua' + - 'tests/engine/native_file_picker.lua' + - 'tests/engine/native_file_io.lua' + - 'tests/native_file_io/**' + - 'tests/*picker*' + - 'tests/rom_importer_android_mod_pick_test.lua' + - '.github/workflows/native-file-picker.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: native-file-picker-${{ github.ref }} + cancel-in-progress: true +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit + - name: Model desktop commands without opening dialogs + run: | + luajit tests/engine/native_file_picker.lua + luajit tests/engine/native_file_io.lua + - name: Preserve native pointer release and mobile fallback + run: | + luajit tests/parity_picker_pointer_grab.lua + luajit tests/rom_importer_no_picker_test.lua + luajit tests/rom_importer_android_mod_pick_test.lua + windows-unicode-files: + runs-on: windows-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Read real Unicode files through the native wide API + shell: python + run: | + import os, pathlib, subprocess, urllib.request, zipfile + target = pathlib.Path(os.environ['RUNNER_TEMP']) / 'love-native-file-io' + target.mkdir() + archive = target / 'runtime.zip' + urllib.request.urlretrieve('https://github.com/love2d/love/releases/download/11.5/love-11.5-win64.zip', archive) + with zipfile.ZipFile(archive) as z: z.extractall(target) + executable = next(target.rglob('lovec.exe')) + subprocess.run([str(executable), 'tests/native_file_io', str(pathlib.Path.cwd())], check=True, timeout=90) diff --git a/.gitignore b/.gitignore index fe97850f7..7e79b49e3 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,6 @@ mobile/ios/bundle_id.local # secrets as public -- but GitHub's secret scanning flags it, so it is kept out # of the repository and injected into release builds instead. /cloud_clients.lua + +# Owned memory-only custom-skin fixture. +!/tests/skin_fs_fixture.lua diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..fbcffd47c --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, Gen1Recomp / BOIS CLUB GAMES +work, other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 000000000..7277db373 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,31 @@ +# Native touch-skin provenance + +The TouchSkin, DeltaSkin, SkinZip and PdfImage modules, their identified +TouchControls integration and adapted format tests originate in Gen1Recomp +commit 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 and its Section 7 terms are +retained in licenses/GEN1RECOMP-TOUCH-SKINS.md. + +Native host integration and tests: Contributions by Roxas2712 — +https://github.com/Roxas2712 + +Existing upstream and Gen1 rights and licence grants remain effective; this +notice assigns no other author's work to Roxas2712. No proprietary Gen1 +launcher code or third-party bundled skin art is included. + +Roxas2712's independently owned additions are offered under +[LICENSE-ROXAS2712.md](LICENSE-ROXAS2712.md), subject to prior grants and +the preserved Gen1 GPL terms. Existing GPL-covered code is not relicensed. + +The identified haptic controls in TouchControls.lua are also adapted from the +same Gen1Recomp source under the same preserved GPL/Section7 grant. + +The identified hotbar geometry/drawing and adapted hotbar reference test also +originate in that source, under the same preserved GPL/Section7 terms. + +SkinStudio, PadCursor, identified cursor sections in TouchControlsEditor, +widget toolkit (except native vector Icons) and adapted studio tests originate +in the same Gen1Recomp revision under preserved GPLv3/Section7 terms. Native +host integration, FilePicker/command provider and all62 vector icons are +contributions by Roxas2712, subject to LICENSE-ROXAS2712.md and prior grants. +Excluded Gen1 launcher files and artwork are not copied. diff --git a/README.md b/README.md index 4cd31e7c0..be0173327 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # gen2recomp +Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC +(https://github.com/bryanthaboi/gen1recomp). + +Contributions by Roxas2712 — https://github.com/Roxas2712. +Newly adapted touch-skin/studio code retains the original GPLv3/Section7 +terms in `licenses/GEN1RECOMP-TOUCH-SKINS.md`. Existing author rights and +earlier grants remain effective; see `NOTICE.md` and `LICENSE-ROXAS2712.md`. +

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..e5d44665b --- /dev/null +++ b/docs/api3-portability.md @@ -0,0 +1,163 @@ +# 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 200 comparisons +and 400 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. + +Final composed-frame ownership is documented in [render-output.md](render-output.md). + +Viewport reservation, window composition and pointer coordinates are documented in [game-viewport.md](game-viewport.md). 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/game-viewport.md b/docs/game-viewport.md new file mode 100644 index 000000000..83d8fcc8d --- /dev/null +++ b/docs/game-viewport.md @@ -0,0 +1,65 @@ +# Game viewport and final window composition + +API-3 layout mods can reserve an OS-window rectangle for the normally rendered +Gen1–3 game, then draw their surrounding UI through a shared window hook: + +```lua +mod.hooks:wrap("render.viewport", function(next, metrics) + return { x = 0, y = 0, width = metrics.width / 2, height = metrics.height } +end) +mod.hooks:wrap("render.window", function(next, game, ctx) + -- Draw window-space chrome using the mod's graphics service. + return next(game, ctx) +end) +``` + +Viewport metrics are `{ width, height, pixelWidth, pixelHeight, dpiX, dpiY, +generation }`. Rectangle `x`, `y`, `width`, `height` are LÖVE units. Finite +values are floored and clamped to the real window, with at least one unit in +each axis. A plain rectangle table is required; unexpected fields are ignored. +Returning the whole window preserves the direct path. `capture=true` requests +a separate target even at full size. No canvas is allocated without capture or +a changed rectangle. Changing position reuses a same-size target; resizing, +disabling and retiring a view release its old canvas. Failed allocations fall +back to the ordinary full game and retry after a changed request. + +The normal Renderer uses the rectangle's own dimensions, pixel dimensions and +DPI. World/UI composition, HUD and final-output owners draw inside it. +`render.window(game, ctx)` then receives `{ canvas, x, y, width, height, +windowWidth, windowHeight, dpiX, dpiY, generation }`. Its default draws the +borrowed game canvas at `x,y`; the OS window starts black. The hook runs only +when a viewport target is active. It follows ordinary hook/next semantics, +without a separate boolean ownership result. Mobile controls draw afterward +in OS-window coordinates, at full size. + +Both hooks fence GPU state. The default window draw also fences its own state +so a failed outer hook cannot redirect it to another canvas or shader. +Invalid downstream contexts fall back to the original frame. A reset during +composition defers target release until the frame finishes. Mod reload, +launcher teardown and renderer reinitialization clear viewport state. +Gen4/5 and native combined-screen layouts keep their existing display path; +`mod:supports("hooks", "render.viewport")`/`"render.window"` report only Gen1–3. + +The existing touch/mouse-as-touch route now supplies `input.pointer` with +`{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure, button }`. +Phases are `pressed`, `moved`, `released`. Window `x,y` remain unchanged; +`gameX,gameY` subtract the viewport origin, and `insideGame` tests its bounds. +Unspecified movement deltas derive from the last point. Real touch pressure +is forwarded. This is the existing main-window press/drag route, not a new +hover, wheel or multitouch input system. + +Virtual controls keep their complete gesture first. Mods can claim other +pointer gestures by returning exactly `true`. Once a native screen has +captured a gesture, a later mod movement result cannot steal its release. +Native game-screen handlers receive local coordinates; injected secondary +screen events and OS-window controls keep their existing coordinates. An +outside press cannot activate a game screen through the reserved margin. + +`love tools/game-viewport-check` drives actual `Game:draw`, the Renderer and the +same public API-3 layout mod with real screenshot readback on Red, Gold and +FireRed. It verifies default/captured paths, window chrome, compositor failure, +deferred retirement, resize, allocation fallback, state restoration and +unscaled controls. Shared eight-version comparisons additionally check hook +payloads and pointer coordinates against pinned Gen1Recomp. No ROM is required +and no game settings are written. Native combined-screen/mobile hardware and +ROM gameplay are not exercised by this desktop GPU probe. 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/native-touch-skins.md b/docs/native-touch-skins.md new file mode 100644 index 000000000..bb7002172 --- /dev/null +++ b/docs/native-touch-skins.md @@ -0,0 +1,85 @@ +# Native custom touch skins + +Upstream stack: this change is based on the offered viewport/window contract +PR #124 (and its prerequisites). Merge those first. The runtime needs their +actual game rectangle and input mapping; no viewport stub is provided here. + +The shared engine now supplies the real `src.core.TouchSkin` service used by +Gen1 mods. This is engine work; mod files and their optional geometry consumers +do not need changes. The implementation is adapted from Gen1Recomp +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, with its original copyright and +[licence](../licenses/GEN1RECOMP-TOUCH-SKINS.md) retained. No proprietary Gen1 +launcher code or bundled third-party skin artwork is included. + +## Installed skins and options + +Skins live under `skins//skin.lua`, `skins//overlay.cfg`, or in an +installed `skins/.zip` / `.deltaskin` inside the engine's writable storage. +`TouchSkin.installArchive(name, bytes)` installs archive bytes through the same +persistence filesystem used by native options. `TouchSkin.list()` discovers +native, RetroArch and Delta configurations; `load` loads their artwork and +controls. A wrapped Delta archive may contain its metadata in a subdirectory. + +When installed skins exist, native OPTIONS offers **TOUCH SKIN**. LEFT/RIGHT +cycles the library and DEFAULT, applies the choice immediately and saves +`options.touchControls.skin`. DEFAULT restores the existing native pad. The +position/size editor retains this selection in its save snapshot. Turning the +pad off clears the active skin and its cutout. Invalid/missing selections +restore the native presentation and expose `TouchControls.skinError`. + +Mobile OPTIONS also offers **HAPTICS**: OFF, LIGHT, NORMAL and STRONG, with +Gen1's light default and legacy-name normalization. The preference is shared +across cartridges, outside position/size layout snapshots. The first owner of +a button/key/action pulses through the real `love.system.vibrate`; additional +contacts holding that same input, release and focus reset do not retrigger it. +Runtimes without a vibrator retain normal input. Unit pulse checks do not +constitute physical phone vibration acceptance. + +The custom-skin studio/library editor and its launcher integration remain a +separate, open Gen1 parity requirement. This runtime PR does not claim that +the existing native position editor implements that studio. + +## Input and presentation owners + +* Controls support GB/GBA buttons, combinations, directional areas, sectors, + arbitrary key bindings, held speed actions and overlay page switching. + Multiple contacts share per-button and per-key ownership. Release, a reused + pointer id, cancellation polling and focus reset release the correct holds. +* The real `Game.touchSkinHotkey` routes held/toggled speed, reset and OPTIONS + through native engine owners. Existing fixed-speed state locks still apply. +* Native `GameViewport` fits the game into the active skin's screen cutout and + maps gameplay coordinates into it. Explicit `render.viewport` mods retain + final layout authority; the skin is their default rectangle. Unsupported + native combined-screen layouts retain their existing owner. +* Bezel/control artwork draws in OS-window space after final composition. + A desktop bezel survives controller use without capturing gameplay input. + With no selected skin, the viewport stays on its existing direct path. +* The native default pad, Gen4 X/Y layout, sticks, free-camera drag and touch + cancellation recovery are retained; the Gen1 controller file is not used + as a replacement for those implementations. + +The library retains native/RetroArch/Delta export, native editing data helpers, +image import and viewport detection. Raster JPEG data embedded in a PDF may +be recovered; vector-only PDF artwork reports that conversion is required. + +## Verification scope + +ROM-free suites: + +```sh +luajit tests/engine/touch_skin_test.lua +luajit tests/engine/touch_skin_dpad_area.lua +luajit tests/engine/skin_format_import_test.lua +luajit tests/engine/touch_skin_native.lua +luajit tests/engine/touch_haptics.lua +luajit tests/engine/touch_orientation_bug633.lua +luajit tests/engine/game_viewport.lua +``` + +The first three preserve Gen1 format/input contracts, with third-party bundled +art assertions replaced by owned memory-only assets in the native suite. The +native suite also exercises the actual mod loader, normal OPTIONS input, +save-codec round trips, contact ownership, rendering calls and viewport hooks. +Archive-member fixtures model PhysFS mounts explicitly; they are not evidence +of real ZIP decoding or GPU/window acceptance. Real runtime import, both GPU +backends, rotation and packaged desktop/mobile builds still require acceptance. 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/qa/native-skin-studio.md b/docs/qa/native-skin-studio.md new file mode 100644 index 000000000..0bc11ff28 --- /dev/null +++ b/docs/qa/native-skin-studio.md @@ -0,0 +1,66 @@ +# Offered native skin studio + +Depends on the offered viewport/runtime/hotbar stacks (#124, #383, #386). +This branch adapts the actual older native launcher and its flat settings +owners; it does not import a replacement Gen1 launcher. The library/editor, +widget toolkit and shared cursor are complete implementations. All62 icons +are independently drawn native vectors, without excluded Gen1 artwork. + +The native host routes pointer/text/keyboard/wheel/focus/device events, +suspends and resumes its own launcher, and defers Play until update. The +embedded Test device scopes the same rectangle as drawing and the selected +cartridge context, restoring both afterwards. Preview reset/menu/speed actions +report their parsed action and cannot call a suspended game's handlers. +Native Gen4 X/Y declarations work through real Input; DS-only contacts stay +inert/click-through on Crystal, and mixed bindings retain supported GB input. + +The shared native FilePicker provider now has real capability probes, +validated/quoted commands, one-backend cancellation, pointer-release ownership +and protected binary reads. Older dialog pipe reads could raise instead of +returning failure; they now close the pipe and return no selected file. +Windows non-ASCII staging preserves the selected extension rather than .bin. +This is a command contract, not complete interactive Windows/Unicode IO proof. +Required original-project and contribution credits remain in README and the +native launcher's measured/wrapped footer alongside existing authors. + +Evidence: + +- Actual offered native main/host callback suite50, studio model137, image + import35, UX211, library reach118, default cursor/editor16, transitions36, + pagination4472, spinner5 and updated format243 contracts pass. Native + hotbar271 and skin65 regressions pass. Picker91 and import-error28 pass. +- Real isolated LOVE12 on Apple M3, exact636fa7a0: Metal and OpenGL each + pass18 studio GUI/Test framebuffers, nine owned on-disk skins, actual PNG + save/reload, undo/redo, native ZIP export/drop/import and persisted selection, + plus all62 native vector icons. Later changes add explicit generation gates, + scoped preview actions and command/format contracts, verified separately. +- Native host and picker91/skin65 focused tests pass on c23c6627. The complete + available official c23c6627 run is terminal:117/127 engine suites and3/3 SDK + pass. The ten failing engine suites need trace/baseline attribution; this + is not a green upstream-stack acceptance. + There is no current upstream-main mergeability, full Gen1 GUI pixel identity, + OS-window/mobile/FPS/full-mod or release qualification claim. + +Provenance: identified studio/cursor/widget code and adapted tests originate +in Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC, under the +preserved GPLv3/Section7 terms in licenses/GEN1RECOMP-TOUCH-SKINS.md. +Native integration, FilePicker provider and vector artwork contributions by +Roxas2712 are expressly offered under LICENSE-ROXAS2712.md, subject to +existing grants and other authors' retained rights. Mods/player profiles +are not modified. Full parity and release acceptance remain open. + +## Windows original-path file reads + +SkinStudio's `FilePicker.open/read` now keeps the original UTF-8 filename and +uses `NativeFile`'s readonly Win32 wide-character reader. It does not copy the +image into `%TEMP%`, whose user-profile path can itself contain non-ASCII +characters. Existing `chooseFileByExt` callers retain their staging contract +unless they explicitly select the native UTF-8 reader. + +The Windows workflow runs LÖVE/LuaJIT against real binary files under a directory +containing accented, CJK and non-BMP characters, checks exact multi-block bytes +and basename preservation, then replaces/removes them to prove handle release. +It also checks missing/empty files and invalid UTF-8. The portable engine suite covers exception +and close handling. These are file-IO checks, not a Windows GPU or full-mod +qualification; inspect the exact-head workflow result before claiming Windows +acceptance. diff --git a/docs/qa/native-stack-test-baseline.md b/docs/qa/native-stack-test-baseline.md new file mode 100644 index 000000000..684c3c2fb --- /dev/null +++ b/docs/qa/native-stack-test-baseline.md @@ -0,0 +1,38 @@ +# Native stack test baseline + +The exact viewport prerequisite `ce4a825a961d35df2e6748b22632843f77d5c671` +passes 100/110 engine suites and 3/3 SDK suites under the official +`scripts/test.sh` command. The offered studio source `c23c6627` passes +117/127 engine suites and 3/3 SDK suites. Both runs fail the same ten named +suites, before and after the runtime/hotbar/studio additions. Both runs omit +the ROM-dependent content/link tier when no generated Red dataset is present. + +The repairs retain the actual production owners: + +- `rebind_capture_bug510` and `rebind_swap_clear_bug589` locate rows by their + public action metadata, retaining release-to-commit, swap/clear/cancel and + uniqueness assertions across the native twelve-action map. +- `soft_reset_bug563` and `tool_mod_hooks` use actual `Game` method lookup, + including the profiling `_step` body, instead of incomplete plain tables. +- `quit_thread_shutdown` executes the real `love.run` function to prove veto, + Android-only exit and return ordering. A pattern stopping at the first + nested `end` could not establish any of those behaviors. Existing native + shutdown assertions remain appropriate to this older host. +- `update_check_tests` and `update_tests` use this engine's actual + `Gen2Recomped` payload names and upstream release URL. A negative case + explicitly rejects a foreign Gen1 payload; the updater is not changed. +- `wide_battle_layout` tests the authored OAM anchor positions, interpolation, + wobble, empty frames and bounded overshoot, rather than expecting two + unrelated frames to coincide with anchors they do not occupy. +- `gate_meta_coverage` gains real public-API/owner tests for Birch enter/finish, + bulk block replacement, world ticks, egg hatch completion and native HUD + ownership. No coverage debt is added to silence the gate. +- `gate_fingerprint` retains mutation/determinism checks and uses the + independently reviewed `41e69caf4afa35ab` fixture pin from the prior native + registry audit, with per-section pins and four Sinnoh-registration removal + checks. No serializer, registry, hash algorithm or ROM golden is altered. + See `tests/goldens/fixture_fingerprint.md` for the audited input change. + +The changed stack still needs its own exact-source full test result. A passing +test-fixture repair is not complete Gen1 behavioral parity, unchanged-mod +gameplay, current upstream-main integration or packaged-release acceptance. diff --git a/docs/render-output.md b/docs/render-output.md new file mode 100644 index 000000000..a5995b5cb --- /dev/null +++ b/docs/render-output.md @@ -0,0 +1,51 @@ +# Final render output for mods + +API-3 mods can take ownership of the normally composed frame: + +```lua +mod.hooks:wrap("render.output_enabled", function(next) + return enabled or next() +end) +mod.hooks:wrap("render.output", function(next, ctx) + if not enabled then return next(ctx) end + -- Draw ctx.canvas through the mod's graphics/presentation service. + return true +end) +``` + +`render.output_enabled` has no arguments and defaults to `false`. Capture is +allocated only when an output subscriber exists and the predicate returns +exactly `true`. With neither an output owner nor another present-stage effect, +the renderer retains its direct path and allocates no present canvas. + +`render.output(ctx)` receives the finished composition after native +post-process pipelines and before optional display effects. Its context is +`{ canvas, width, height, gameX, gameY, gameWidth, gameHeight, scale, dpiX, +dpiY, generation }`. Sizes and positions are LÖVE units; `scale` is native +framebuffer pixels per game pixel. The canvas is borrowed for that frame and +must not be released or retained across resize/context loss. + +Returning exactly `true` takes ownership of presentation. Other results fall +back to the ordinary display path. An earlier `render.compose` owner bypasses +normal composition and this output seam, as in the reference. The output +seam does not take over HUD/mobile control drawing. A disabled/unloaded owner +cannot force capture through a predicate alone. + +Callbacks begin with an identity transform, no shader/scissor, ordinary alpha +blend and white color. GPU state and the render target are restored afterward, +including when a callback throws or leaks graphics pushes. A callback cannot +pop the caller's graphics state through the scoped stack API. The same fence +protects the predicate. Existing hook-chain error/next semantics still apply. +A callback that returns `true` is responsible for drawing a complete output. + +The public hook contract is available on the host's common renderer across +its generations. Shared fixtures compare the same API-3 mod on eight Gen1–3 +versions against pinned Gen1Recomp using explicit canvas/dispatch seams. +`love tools/render-output-check` additionally drives the actual Renderer and +public mod with real GPU draws and screenshot readback: disabled direct path, +false fallback, identity ownership, inversion with clean HUD state, callback +failure, nonboolean predicate and owner removal. No ROM is required and the +probe writes no game settings. + +With a [game viewport](game-viewport.md), output metrics refer to that game rectangle. +Window composition and physical controls follow the output stage. 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/licenses/GEN1RECOMP-TOUCH-SKINS.md b/licenses/GEN1RECOMP-TOUCH-SKINS.md new file mode 100644 index 000000000..806424f26 --- /dev/null +++ b/licenses/GEN1RECOMP-TOUCH-SKINS.md @@ -0,0 +1,759 @@ +# License + +Copyright (C) 2026 BOIS CLUB GAMES, LLC + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, version 3 of the License, with the Additional Terms below, which +are added under Section 7 of that License. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +## Additional Terms (GPLv3 Section 7) + +### 1. Attribution (Section 7(b)) + +Any copy, fork, modified version, or work based on this program, in source or +object form, and for any purpose, must: + +- keep this license file and every copyright and author notice in the source; +- give clear, visible credit to the original project in its README (or + equivalent project page) and in any in-app credits, "about", or title + screen, reading at minimum: + "Based on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC + (https://github.com/bryanthaboi/gen1recomp)"; +- show that same credit, visibly, inside any launcher, front end, menu shell, + or other program used to start or select the game, including any launcher + written to replace the one excluded in term 2. + +Using this program, in whole or in part, for other projects is permitted, as +long as this credit is always given. + +### 2. The Launcher is not licensed under the GPL + +The project launcher (the "Launcher") is NOT part of the program licensed +above. It is proprietary: Copyright (C) 2026 BOIS CLUB GAMES, LLC, all rights +reserved. The Launcher may not be copied, modified, redistributed, or used in +any fork, modified version, or other project, in whole or in part. A fork or +derivative must provide its own launcher, which must carry the credit +required by term 1. + +The Launcher consists of the following files, and any later version of them: + +- `src/import/LauncherView.lua` +- `src/import/LauncherSettings.lua` +- `src/import/OnlinePanel.lua` +- `src/import/CartLabelArt.lua` +- `src/import/CartShape.lua` +- `src/mods/LauncherMods.lua` +- the launcher user interface code in `src/import/RomImporter.lua` (the + ROM import, verification, and cache extraction logic in that file remains + under the GPL) +- `assets/launcher/` and `assets/labels/` + +Distributing the Launcher together with this program is permitted only in +unmodified official releases from BOIS CLUB GAMES, LLC. + +### PortMaster distribution permission + +As an exception to the Launcher distribution restriction above, BOIS CLUB +GAMES, LLC grants permission to distribute the Launcher with this program +through PortMaster, including the PortMaster-MV repository and its release +packages. This permission covers PortMaster packaging and launch scripts; +it does not grant a general right to reuse or modify the proprietary Launcher +in other projects. Retain this license and the attribution required above. + +This exception records the explicit PortMaster permission granted by project +owner bryanthaboi on 6 October 2026. All other terms remain unchanged. + +### 3. Names and marks (Section 7(e)) + +This license grants no rights to use the names, logos, or trademarks of BOIS +CLUB GAMES, LLC or of the project, except as needed to give the credit +required by term 1. + +### 4. Third-party rights + +This project contains no ROM data and no extracted game content. Pokemon and +all related names and content are the property of Nintendo, Creatures Inc., +and GAME FREAK inc. This license covers only the original code and assets of +this project. + +--- + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/main.lua b/main.lua index 18325ffdf..947c1a23e 100644 --- a/main.lua +++ b/main.lua @@ -209,15 +209,40 @@ local closeTouchControlsEditor -- forward declaration local function openTouchControlsEditor() touchEditorHost = Importer + if touchEditorHost then touchEditorHost:prepareOverlayHandoff() end Importer = nil TouchEditor = require("src.ui.TouchControlsEditor") TouchEditor.load({ onClose = function() closeTouchControlsEditor() end }) end +local function openTouchSkinStudio() + touchEditorHost = Importer + if touchEditorHost then touchEditorHost:prepareOverlayHandoff() end + Importer = nil + TouchEditor = require('src.ui.SkinStudio') + local version = touchEditorHost and touchEditorHost.tab + local Version = require('src.core.GameVersion') + if not Version.VERSIONS[version] then version = Version.get() end + TouchEditor.load({version=version, + onClose=function() closeTouchControlsEditor() end, + onPlay=function(chosen) + closeTouchControlsEditor() + if Importer then Importer:play(chosen) end + end}) +end + +-- The default position editor and full skin studio share one suspended host. +-- Optional events stay owned by the active editor even when it has no handler. +local function touchEditorEvent(name, ...) + local fn = TouchEditor and TouchEditor[name] + if fn then return fn(...) end +end + function closeTouchControlsEditor() if TouchEditor and TouchEditor.unload then TouchEditor.unload() end TouchEditor = nil Importer = touchEditorHost + if Importer then Importer:resumeAfterOverlay() end touchEditorHost = nil end @@ -395,6 +420,7 @@ local function openLauncher(forceImport) onEditSave = openEditor, onEditMaps = openMapEditor, onEditTouchControls = openTouchControlsEditor, + onEditTouchSkins = openTouchSkinStudio, }) end @@ -764,7 +790,7 @@ end function love.gamepadpressed(joystick, button) -- and the dismissal that matters on the Switch. if bootReport then return dismissBootReport() end - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('gamepadpressed', joystick, button) end if editorMode then if EditorApp.gamepadpressed then return EditorApp.gamepadpressed(button) end return @@ -775,7 +801,7 @@ function love.gamepadpressed(joystick, button) end function love.gamepadreleased(joystick, button) - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('gamepadreleased', joystick, button) end if editorMode then if EditorApp.gamepadreleased then return EditorApp.gamepadreleased(button) end return @@ -786,7 +812,7 @@ function love.gamepadreleased(joystick, button) end function love.gamepadaxis(joystick, axis, value) - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('gamepadaxis', joystick, axis, value) end if editorMode then if EditorApp.gamepadaxis then return EditorApp.gamepadaxis(axis, value) end return @@ -798,7 +824,7 @@ end function love.joystickpressed(joystick, button) if bootReport then return dismissBootReport() end - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('joystickpressed', joystick, button) end if editorMode then if EditorApp.joystickpressed then return EditorApp.joystickpressed(joystick, button) @@ -811,14 +837,15 @@ function love.joystickpressed(joystick, button) end function love.joystickreleased(joystick, button) - if editorMode or TouchEditor then return end + if TouchEditor then return touchEditorEvent('joystickreleased', joystick, button) end + if editorMode then return end if Importer then return Importer:joystickreleased(joystick, button) end if not Game then return end Game:joystickreleased(joystick, button) end function love.joystickaxis(joystick, axis, value) - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('joystickaxis', joystick, axis, value) end if editorMode then if EditorApp.joystickaxis then return EditorApp.joystickaxis(joystick, axis, value) @@ -831,7 +858,7 @@ function love.joystickaxis(joystick, axis, value) end function love.joystickhat(joystick, hat, direction) - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('joystickhat', joystick, hat, direction) end if editorMode then if EditorApp.joystickhat then return EditorApp.joystickhat(joystick, hat, direction) @@ -844,7 +871,8 @@ function love.joystickhat(joystick, hat, direction) end function love.joystickremoved(joystick) - if editorMode or TouchEditor then return end + if TouchEditor then return touchEditorEvent('joystickremoved', joystick) end + if editorMode then return end if Importer then return end if not Game then return end Game:joystickremoved(joystick) @@ -857,7 +885,8 @@ function love.focus(f) if bootReport then return end -- a phone stops the frames in the background: the walker credits the gap walkerTick("focus", f) - if editorMode or TouchEditor then return end + if TouchEditor then return touchEditorEvent('focus', f) end + if editorMode then return end if Importer then if Importer.focus then Importer:focus(f) end return @@ -869,7 +898,8 @@ end -- v is true when the window becomes visible again, false on minimize. function love.visible(v) if bootReport then return end - if editorMode or TouchEditor then return end + if TouchEditor then return touchEditorEvent('visible', v) end + if editorMode then return end if Importer then return end if not Game then return end Game:visible(v) @@ -887,12 +917,12 @@ function love.touchpressed(id, x, y, dx, dy, pressure) if TouchEditor then -- iOS synthesizes mousepressed for the primary touch (same as the -- launcher); Android drives the editor through love.touch directly. - if love.system.getOS() == "iOS" then return end + if love.system.getOS() == "iOS" and not TouchEditor.touchFirst then return end return TouchEditor.touchpressed(id, x, y) end if Importer then return Importer:touchpressed(id,x,y,dx,dy,pressure) end if not Game then return end - Game:touchpressed(id, x, y) + Game:touchpressed(id, x, y, dx, dy, pressure) end function love.touchmoved(id, x, y, dx, dy, pressure) @@ -901,12 +931,12 @@ function love.touchmoved(id, x, y, dx, dy, pressure) return end if TouchEditor then - if love.system.getOS() == "iOS" then return end + if love.system.getOS() == "iOS" and not TouchEditor.touchFirst then return end return TouchEditor.touchmoved(id, x, y) end if Importer then return Importer:touchmoved(id,x,y,dx,dy,pressure) end if not Game then return end - Game:touchmoved(id, x, y) + Game:touchmoved(id, x, y, dx, dy, pressure) end function love.touchreleased(id, x, y, dx, dy, pressure) @@ -915,12 +945,12 @@ function love.touchreleased(id, x, y, dx, dy, pressure) return end if TouchEditor then - if love.system.getOS() == "iOS" then return end + if love.system.getOS() == "iOS" and not TouchEditor.touchFirst then return end return TouchEditor.touchreleased(id, x, y) end if Importer then return Importer:touchreleased(id,x,y,dx,dy,pressure) end if not Game then return end - Game:touchreleased(id, x, y) + Game:touchreleased(id, x, y, dx, dy, pressure) end function love.wheelmoved(x, y) @@ -928,7 +958,7 @@ function love.wheelmoved(x, y) if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end return end - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('wheelmoved', x, y) end if Importer then return end if not Game then return end Game:wheelmoved(x, y) @@ -939,7 +969,7 @@ function love.mousepressed(x, y, button, istouch) if TouchEditor then -- Android primary touch already arrived via love.touchpressed; a second -- mouse path would double-fire Done / begin a second drag. - if love.system.getOS() == "Android" then return end + if istouch and (TouchEditor.touchFirst or love.system.getOS() == "Android") then return end return TouchEditor.mousepressed(x, y, button) end if Importer then @@ -960,7 +990,7 @@ end function love.mousereleased(x, y, button, istouch) if TouchEditor then - if love.system.getOS() == "Android" then return end + if istouch and (TouchEditor.touchFirst or love.system.getOS() == "Android") then return end return TouchEditor.mousereleased(x, y, button) end if Importer then return end @@ -1003,7 +1033,7 @@ function love.mousemoved(x, y, dx, dy, istouch) return end if TouchEditor then - if love.system.getOS() == "Android" then return end + if istouch and (TouchEditor.touchFirst or love.system.getOS() == "Android") then return end return TouchEditor.mousemoved(x, y) end if editorMode then @@ -1017,7 +1047,7 @@ function love.mousemoved(x, y, dx, dy, istouch) end function love.textinput(text) - if TouchEditor then return end + if TouchEditor then return touchEditorEvent('textinput', text) end if Importer then return Importer:textinput(text) end if editorMode and EditorApp.textinput then return EditorApp.textinput(text) @@ -1193,6 +1223,7 @@ function love.quit() end function love.filedropped(file) + if TouchEditor then return touchEditorEvent('filedropped', file) end if editorMode and EditorApp and EditorApp.filedropped then return EditorApp.filedropped(file) end diff --git a/scripts/pack_love.sh b/scripts/pack_love.sh index 7d4ce2ede..e9bac5a32 100755 --- a/scripts/pack_love.sh +++ b/scripts/pack_love.sh @@ -155,6 +155,18 @@ for licence in LICENSE LICENSE.MD LICENSE.md; do [ -f "$ROOT/$licence" ] && cp "$ROOT/$licence" "$STAGE/" && break done +for notice in NOTICE.md LICENSE-ROXAS2712.md; do + if [ -f "$ROOT/$notice" ]; then cp "$ROOT/$notice" "$STAGE/"; fi +done + +# Preserve the original licence alongside the actual derived skin runtime. +if [ -f "$ROOT/src/core/TouchSkin.lua" ]; then + skin_licence="licenses/GEN1RECOMP-TOUCH-SKINS.md" + [ -f "$ROOT/$skin_licence" ] || fail "missing Gen1 touch-skin licence" + mkdir -p "$STAGE/licenses" + cp "$ROOT/$skin_licence" "$STAGE/$skin_licence" +fi + # ---------------------------------------------------------------- excludes # Pruned from the STAGE rather than passed to zip as -x patterns: the two # forms disagree on how they anchor a leading directory, and a silent diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua new file mode 100644 index 000000000..9c0778a86 --- /dev/null +++ b/src/battle/BattleCommands.lua @@ -0,0 +1,355 @@ +-- 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 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: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 9688aa08f..c2f51594a 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 @@ -4406,7 +4411,7 @@ function BattleState:update(dt) Gen4Battle.actionMove(self.menuIndex, dir, self.gen4MenuColumn) 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 @@ -4432,7 +4437,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 @@ -4512,60 +4517,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 @@ -4591,58 +4546,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 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 @@ -4660,16 +4571,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 @@ -4690,12 +4594,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/MoveEffects.lua b/src/battle/MoveEffects.lua index ed4249843..fa959fcef 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -144,7 +144,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/DeltaSkin.lua b/src/core/DeltaSkin.lua new file mode 100644 index 000000000..ac5b62e6a --- /dev/null +++ b/src/core/DeltaSkin.lua @@ -0,0 +1,544 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms; +-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712. +local Json = require("src.link.Json") +local TouchSkin = require("src.core.TouchSkin") + +local DeltaSkin = {} + +DeltaSkin.INFO_NAME = "info.json" +DeltaSkin.MAX_INFO_BYTES = 4 * 1024 * 1024 + +DeltaSkin.GAME_TYPE_PREFIXES = { + "com.rileytestut.delta.game.", + "public.aoshuang.game.", +} + +DeltaSkin.SYSTEMS = { gb = true, gbc = true, gba = true } + +DeltaSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true } + +DeltaSkin.DEVICE_ORDER = { "iphone", "ipad", "tv" } +DeltaSkin.DISPLAY_ORDER = { "edgeToEdge", "standard", "splitView" } +DeltaSkin.ORIENTATIONS = { "portrait", "landscape" } +DeltaSkin.SIDES = { "up", "down", "left", "right" } + +DeltaSkin.ASSET_LADDER = { "small", "medium", "large" } +DeltaSkin.ASSET_WIDTHS = { small = 640, medium = 750, large = 1080 } +DeltaSkin.DEFAULT_TARGET_WIDTH = 1080 + +DeltaSkin.INPUTS = { + a = "a", b = "b", start = "start", select = "select", + up = "up", down = "down", left = "left", right = "right", + l = "l", r = "r", + triggerl = "l", triggerr = "r", + shoulderl = "l", shoulderr = "r", + menu = "menu_toggle", + fastforward = "hold_fast_forward", + togglefastforward = "toggle_fast_forward", +} + +DeltaSkin.OUTPUT_HOTKEYS = { + menu = "menu", + fast_forward_hold = "fastForward", + fast_forward_toggle = "toggleFastForward", +} + +DeltaSkin.MAPPING = { + portrait = { width = 1080, height = 1920 }, + landscape = { width = 1920, height = 1080 }, +} + +DeltaSkin.SCREEN_WIDTH = 160 +DeltaSkin.SCREEN_HEIGHT = 144 + +local function pick(t, key) + if type(t) ~= "table" then return nil end + local direct = t[key] + if direct ~= nil then return direct end + local want = tostring(key):lower() + for k, v in pairs(t) do + if tostring(k):lower() == want then return v end + end + return nil +end + +local function numOr(v, fallback) + local n = tonumber(v) + if not n or n ~= n then return fallback end + return n +end + +local function round(n) + return math.floor(numOr(n, 0) + 0.5) +end + +local function isArray(t) + return type(t) == "table" and t[1] ~= nil +end + +local function addWarning(list, text) + if type(list) ~= "table" then return end + for _, existing in ipairs(list) do + if existing == text then return end + end + list[#list + 1] = text +end + +function DeltaSkin.findInfo(root) + local direct = root .. "/" .. DeltaSkin.INFO_NAME + if TouchSkin.readFile(direct) then return direct, "" end + local items = TouchSkin.listDir(root) + for _, name in ipairs(items) do + if tostring(name):lower() == DeltaSkin.INFO_NAME then + return root .. "/" .. name, "" + end + end + table.sort(items) + for _, name in ipairs(items) do + local nested = root .. "/" .. name .. "/" .. DeltaSkin.INFO_NAME + if TouchSkin.readFile(nested) then return nested, name .. "/" end + end + return nil +end + +function DeltaSkin.resolveName(name, opts) + name = tostring(name or ""):gsub("\\", "/"):gsub("^%./", "") + if name == "" then return nil end + local names = opts and opts.names + if type(names) == "table" then + local want = name:lower() + for _, entry in ipairs(names) do + if tostring(entry):lower() == want then + name = tostring(entry) + break + end + end + end + return ((opts and opts.prefix) or "") .. name +end + +function DeltaSkin.pickAsset(assets, opts, pdfFiles) + if type(assets) ~= "table" then return nil end + pdfFiles = pdfFiles or {} + local raster = {} + local pdfName + for _, key in ipairs(DeltaSkin.ASSET_LADDER) do + local name = pick(assets, key) + if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end + if type(name) == "string" and name ~= "" then + if name:lower():match("%.pdf$") then + pdfName = name + pdfFiles[#pdfFiles + 1] = name + else + raster[#raster + 1] = { key = key, name = name } + end + end + end + local resizable = pick(assets, "resizable") + if type(resizable) == "string" and resizable ~= "" then + if resizable:lower():match("%.pdf$") then + pdfName = resizable + pdfFiles[#pdfFiles + 1] = resizable + else + raster[#raster + 1] = { key = "large", name = resizable } + end + end + local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil + if #raster == 0 then return nil, pdfPath end + + local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH) + local chosen + for _, cand in ipairs(raster) do + if not chosen and (DeltaSkin.ASSET_WIDTHS[cand.key] or 0) >= target then + chosen = cand.name + end + end + if not chosen then chosen = raster[#raster].name end + return DeltaSkin.resolveName(chosen, opts), nil +end + +function DeltaSkin.mergeEdges(base, item) + local out = { top = 0, bottom = 0, left = 0, right = 0 } + for _, side in ipairs({ "top", "bottom", "left", "right" }) do + local v = pick(item, side) + if v == nil then v = pick(base, side) end + out[side] = numOr(v, 0) + end + return out +end + +function DeltaSkin.representation(reps, orient) + for _, device in ipairs(DeltaSkin.DEVICE_ORDER) do + local dev = pick(reps, device) + if type(dev) == "table" then + for _, display in ipairs(DeltaSkin.DISPLAY_ORDER) do + local shown = pick(dev, display) + if type(shown) == "table" then + local obj = pick(shown, orient) + if type(obj) == "table" then return obj, device, display end + end + end + local flat = pick(dev, orient) + if type(flat) == "table" and (pick(flat, "items") or pick(flat, "mappingSize")) then + return flat, device, nil + end + end + end + return nil +end + +function DeltaSkin.directionalInputs(inputs) + if type(inputs) ~= "table" or isArray(inputs) then return nil end + local out, found = {}, 0 + for _, side in ipairs(DeltaSkin.SIDES) do + local v = pick(inputs, side) + if type(v) == "string" then + local lower = v:lower() + local mapped = DeltaSkin.INPUTS[lower] + if not mapped and lower:find(side, 1, true) then mapped = side end + if mapped then + out[side] = mapped + found = found + 1 + end + end + end + if found >= 2 then return out end + return nil +end + +function DeltaSkin.specFor(inputs) + local parts = {} + local function add(v) + if type(v) ~= "string" then return end + local mapped = DeltaSkin.INPUTS[v:lower()] + if mapped then parts[#parts + 1] = mapped end + end + if type(inputs) == "string" then + add(inputs) + elseif type(inputs) == "table" then + if isArray(inputs) then + for _, v in ipairs(inputs) do add(v) end + else + local keys = {} + for k in pairs(inputs) do keys[#keys + 1] = tostring(k) end + table.sort(keys) + for _, k in ipairs(keys) do add(inputs[k]) end + end + end + if #parts == 0 then return "nul" end + return table.concat(parts, "|") +end + +function DeltaSkin.screenRect(obj, mapW, mapH) + local frame + local screens = pick(obj, "screens") + if type(screens) == "table" and type(screens[1]) == "table" then + frame = pick(screens[1], "outputFrame") + end + if type(frame) ~= "table" then frame = pick(obj, "gameScreenFrame") end + if type(frame) ~= "table" then return nil end + local w = numOr(pick(frame, "width"), 0) + local h = numOr(pick(frame, "height"), 0) + if w <= 0 or h <= 0 then return nil end + return { + x = numOr(pick(frame, "x"), 0) / mapW, + y = numOr(pick(frame, "y"), 0) / mapH, + w = w / mapW, h = h / mapH, + } +end + +function DeltaSkin.addItem(page, item, baseEdges, mapW, mapH) + if type(item) ~= "table" then return end + local frame = pick(item, "frame") + if type(frame) ~= "table" then return end + local fw = numOr(pick(frame, "width"), 0) + local fh = numOr(pick(frame, "height"), 0) + if fw <= 0 or fh <= 0 then return end + local fx = numOr(pick(frame, "x"), 0) + local fy = numOr(pick(frame, "y"), 0) + + local edges = DeltaSkin.mergeEdges(baseEdges, pick(item, "extendedEdges")) + local cx, cy = (fx + fw * 0.5) / mapW, (fy + fh * 0.5) / mapH + local w, h = fw / mapW, fh / mapH + local reachLeft = 1 + edges.left / (fw * 0.5) + local reachRight = 1 + edges.right / (fw * 0.5) + local reachUp = 1 + edges.top / (fh * 0.5) + local reachDown = 1 + edges.bottom / (fh * 0.5) + + local inputs = pick(item, "inputs") + local dirs = DeltaSkin.directionalInputs(inputs) + if dirs then + local base = { + x = cx, y = cy, rangeX = w * 0.5, rangeY = h * 0.5, + rangeMod = 1, alphaMod = page.alphaMod, shape = "rect", + reachLeft = reachLeft, reachRight = reachRight, + reachUp = reachUp, reachDown = reachDown, + } + for _, ctl in ipairs(TouchSkin.expandDirectional(base, dirs)) do + page.controls[#page.controls + 1] = ctl + end + return + end + + local shape = tostring(pick(item, "mask") or ""):lower() == "circle" and "radial" or "rect" + local ctl = TouchSkin.newControl(DeltaSkin.specFor(inputs), cx, cy, w, h, shape) + ctl.alphaMod = page.alphaMod + ctl.reachLeft, ctl.reachRight = reachLeft, reachRight + ctl.reachUp, ctl.reachDown = reachUp, reachDown + page.controls[#page.controls + 1] = ctl +end + +function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles) + local mapping = pick(obj, "mappingSize") + local mapW = numOr(pick(mapping, "width"), 0) + local mapH = numOr(pick(mapping, "height"), 0) + if mapW <= 0 or mapH <= 0 then + mapW, mapH = 320, 240 + addWarning(warnings, orient .. " has no mappingSize; assuming 320x240") + end + + local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles) + local page = { + name = orient, + orient = orient, + imagePath = imagePath, + pdfPath = pdfPath, + fullScreen = true, + normalized = true, + pixelCoords = false, + rangeMod = 1, + alphaMod = pick(obj, "translucent") == true and 0.7 or 1, + aspect = mapW / mapH, + aspectFromCfg = false, + rect = { x = 0, y = 0, w = 1, h = 1 }, + mappingWidth = mapW, + mappingHeight = mapH, + controls = {}, + } + + local screen = DeltaSkin.screenRect(obj, mapW, mapH) + if screen then + page.viewport = screen + page.viewportFill = false + else + -- mappingSize is the overlay, not the device. Portrait controller + -- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep + -- that aspect, sit at the bottom, and leave the leftover for the + -- Game Boy picture. A screens/gameScreenFrame rect still fills. + page.aspectFromCfg = true + page.screenFit = "remainder" + if orient == "portrait" then page.anchor = "bottom" end + end + + local baseEdges = pick(obj, "extendedEdges") + local items = pick(obj, "items") + if type(items) == "table" then + for _, item in ipairs(items) do + DeltaSkin.addItem(page, item, baseEdges, mapW, mapH) + end + end + return page +end + +function DeltaSkin.systemOf(gameType) + if type(gameType) ~= "string" or gameType == "" then return nil end + for _, prefix in ipairs(DeltaSkin.GAME_TYPE_PREFIXES) do + if gameType:sub(1, #prefix) == prefix then + return gameType:sub(#prefix + 1):lower() + end + end + return nil +end + +function DeltaSkin.parse(text, opts) + opts = opts or {} + local info, err = Json.decode(tostring(text or ""), DeltaSkin.MAX_INFO_BYTES) + if type(info) ~= "table" then + return nil, "info.json does not parse: " .. tostring(err) + end + + local gameType = info.gameTypeIdentifier + if type(gameType) ~= "string" or gameType == "" then + return nil, "old GBA4iOS skin, not supported: info.json has no gameTypeIdentifier" + end + if gameType:lower():find("gba4ios", 1, true) then + return nil, "old GBA4iOS skin, not supported" + end + local system = DeltaSkin.systemOf(gameType) + if not system then + return nil, "not a Delta skin: unknown gameTypeIdentifier " .. gameType + end + + local warnings = {} + if not DeltaSkin.SYSTEMS[system] then + addWarning(warnings, "this skin is for " .. system .. ", not Game Boy") + end + + local reps = info.representations + if type(reps) ~= "table" then return nil, "info.json has no representations" end + + local pdfFiles, pages = {}, {} + for _, orient in ipairs(DeltaSkin.ORIENTATIONS) do + local obj = DeltaSkin.representation(reps, orient) + if obj then + local page = DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles) + page.index = #pages + 1 + pages[#pages + 1] = page + end + end + if #pages == 0 then return nil, "info.json has no usable representation" end + + return { + pages = pages, + name = info.name, + author = info.author, + notes = info.notes, + format = "delta", + system = system, + identifier = info.identifier, + warnings = warnings, + pdfFiles = pdfFiles, + } +end + +function DeltaSkin.needsConversion(skin) + if type(skin) ~= "table" then return nil end + local files = skin.pdfFiles + if type(files) ~= "table" or #files == 0 then return nil end + for _, page in ipairs(skin.pages or {}) do + -- A raster asset, or a JPEG recovered from the PDF at load, means the + -- skin can draw. Parse-only callers still see pdfOnly because they + -- have not run extract yet. + if page.rasterData then return nil end + if page.imagePath then return nil end + end + return { pdfOnly = true, files = files } +end + +function DeltaSkin.outputInputs(ctl) + local out = {} + for _, b in ipairs(ctl.buttons or {}) do out[#out + 1] = b end + for _, h in ipairs(ctl.hotkeys or {}) do + local mapped = DeltaSkin.OUTPUT_HOTKEYS[h] + if mapped then out[#out + 1] = mapped end + end + return out +end + +function DeltaSkin.buildRepresentation(page, orient, warnings) + local map = DeltaSkin.MAPPING[orient] or DeltaSkin.MAPPING.portrait + local mapW, mapH = map.width, map.height + local items, files = {}, {} + + for _, ctl in ipairs(page.controls or {}) do + local names = DeltaSkin.outputInputs(ctl) + if ctl.sector and ctl.sector ~= 1 then + names = {} + elseif ctl.sector and ctl.areaNames then + local TouchSkin = require("src.core.TouchSkin") + local dirs = {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + local mapped = TouchSkin.GB_BUTTONS[tostring(ctl.areaNames[side]):lower()] + if mapped then dirs[side] = mapped end + end + names = next(dirs) and dirs or {} + end + if names.up or names.down or names.left or names.right or #names > 0 then + local item = { + inputs = names, + frame = { + x = round((ctl.x - ctl.rangeX) * mapW), + y = round((ctl.y - ctl.rangeY) * mapH), + width = round(ctl.rangeX * 2 * mapW), + height = round(ctl.rangeY * 2 * mapH), + }, + } + if ctl.shape == "radial" then item.mask = "circle" end + local edges, any = {}, false + local pairsList = { + { key = "left", reach = ctl.reachLeft, half = ctl.rangeX * mapW }, + { key = "right", reach = ctl.reachRight, half = ctl.rangeX * mapW }, + { key = "top", reach = ctl.reachUp, half = ctl.rangeY * mapH }, + { key = "bottom", reach = ctl.reachDown, half = ctl.rangeY * mapH }, + } + for _, side in ipairs(pairsList) do + local reach = numOr(side.reach, 1) + if reach ~= 1 then + edges[side.key] = round((reach - 1) * side.half) + any = true + end + end + if any then item.extendedEdges = edges end + items[#items + 1] = item + elseif ctl.imagePath then + addWarning(warnings, "per-button art is dropped: Delta keeps all art in one image") + end + end + + local obj = { + items = items, + mappingSize = { width = mapW, height = mapH }, + extendedEdges = { top = 0, bottom = 0, left = 0, right = 0 }, + translucent = false, + } + if page.imagePath then + obj.assets = { + small = page.imagePath, medium = page.imagePath, large = page.imagePath, + } + files[#files + 1] = page.imagePath + end + if page.viewport then + obj.screens = { { + inputFrame = { x = 0, y = 0, + width = DeltaSkin.SCREEN_WIDTH, height = DeltaSkin.SCREEN_HEIGHT }, + outputFrame = { + x = round(page.viewport.x * mapW), y = round(page.viewport.y * mapH), + width = round(page.viewport.w * mapW), height = round(page.viewport.h * mapH), + }, + } } + end + return obj, files +end + +function DeltaSkin.build(skin, opts) + if type(skin) ~= "table" or not skin.pages or not skin.pages[1] then + return nil, "skin has no pages" + end + opts = opts or {} + local standard, edgeToEdge = {}, {} + local assets, warnings, used = {}, {}, {} + + for _, page in ipairs(skin.pages) do + local orient = TouchSkin.pageOrient(page) + if orient ~= "portrait" and orient ~= "landscape" then + orient = (numOr(page.aspect, 1) < 1) and "portrait" or "landscape" + end + if not used[orient] then + used[orient] = true + local obj, files = DeltaSkin.buildRepresentation(page, orient, warnings) + standard[orient] = obj + edgeToEdge[orient] = obj + for _, rel in ipairs(files) do assets[#assets + 1] = rel end + end + end + + local system = tostring(opts.system or "gbc") + local info = { + name = skin.name or skin.id or "skin", + identifier = opts.identifier + or ("com.gen1recomp.skin." .. tostring(skin.id or "skin")), + gameTypeIdentifier = DeltaSkin.GAME_TYPE_PREFIXES[1] .. system, + debug = false, + representations = { iphone = { standard = standard, edgeToEdge = edgeToEdge } }, + } + return info, assets, warnings +end + +function DeltaSkin.encodeInfo(skin, opts) + local info, assets, warnings = DeltaSkin.build(skin, opts) + if not info then return nil, assets end + return Json.encode(info), assets, warnings +end + +return DeltaSkin diff --git a/src/core/FilePicker.lua b/src/core/FilePicker.lua new file mode 100644 index 000000000..6f634e77e --- /dev/null +++ b/src/core/FilePicker.lua @@ -0,0 +1,89 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared desktop picker contract. RomImporter retains the one dialog launch +-- path, including pointer-grab release; importing this module starts nothing. +local Host=require('src.core.HostShell') +local M={IMAGE={label='Image',exts={'png','jpg','jpeg'}}} +local function promptOk(value)return type(value)=='string'and #value<=512 and not value:find('%z')end +local function kindOf(kind) + kind=kind or M.IMAGE + if type(kind)~='table'or type(kind.exts)~='table'or #kind.exts<1 or #kind.exts>16 then return end + local label=kind.label or 'File' + if type(label)~='string'or #label>128 or label:find('[%z\r\n|]')then return end + local exts,seen={},{} + for _,ext in ipairs(kind.exts)do + if type(ext)~='string'or #ext>24 or not ext:match('^[%w][%w_.-]*$')then return end + ext=ext:lower();if not seen[ext]then seen[ext]=true;exts[#exts+1]=ext end + end + return{label=label,exts=exts} +end +local function backend() + if not Host.canSpawnProcess()then return end + local os=love.system.getOS() + if os=='OS X'or os=='Windows'then return os end + if os=='Linux'then + if Host.hasCommand('zenity')then return 'zenity'end + if Host.hasCommand('kdialog')then return 'kdialog'end + end +end +function M.available()return backend()~=nil end +function M.matches(name,kind) + local k=kindOf(kind);if not k or type(name)~='string'then return false end + name=name:lower() + for _,ext in ipairs(k.exts)do if name:sub(-#ext-1)=='.'..ext then return true end end + return false +end +local function shell(value)return "'"..value:gsub("'","'\\''").."'"end +local function apple(value)return '"'..value:gsub('\\','\\\\'):gsub('"','\\"'):gsub('\n','\\n'):gsub('\r','\\r')..'"'end +-- Untrusted UTF-8 travels as decimal bytes, never as PowerShell/cmd syntax. +local function windows(value) + local bytes={};for i=1,#value do bytes[i]=tostring(value:byte(i))end + return '([Text.Encoding]::UTF8.GetString([byte[]]@('..table.concat(bytes,',')..')))' +end +function M.commands(prompt,kind,nativeUTF8) + local k=kindOf(kind) + if not k or not promptOk(prompt)then return end + local os=backend();if not os then return end + local globs,types={},{} + for i,ext in ipairs(k.exts)do globs[i]='*.'..ext;types[i]=apple(ext)end + if os=='OS X'then + local script='POSIX path of (choose file with prompt '..apple(prompt)..' of type {'..table.concat(types,', ')..'})' + return{'osascript -e '..shell(script)..' 2>/dev/null'} + elseif os=='Windows'then + local patterns=table.concat(globs,';') + local filter=k.label..' ('..patterns..')|'..patterns..'|All files (*.*)|*.*' + -- The skin editor reads through NativeFile's wide API. Other existing + -- chooseFileByExt consumers still expect an ANSI-readable staging path. + if nativeUTF8 then + local command='powershell -NoProfile -STA -Command "'..table.concat({ + 'Add-Type -AssemblyName System.Windows.Forms;', + '$d=New-Object System.Windows.Forms.OpenFileDialog;','$d.Title='..windows(prompt)..';', + '$d.Filter='..windows(filter)..';', + "if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=[Text.Encoding]::UTF8;[Console]::Write($d.FileName)}"})..'"' + if #command>7000 then return end + return{command} + end + local script=table.concat({'Add-Type -AssemblyName System.Windows.Forms;', + '$d=New-Object System.Windows.Forms.OpenFileDialog;','$d.Title='..windows(prompt)..';', + '$d.Filter='..windows(filter)..';',"if($d.ShowDialog() -eq 'OK'){$p=$d.FileName;$ascii=$true;", + 'foreach($c in $p.ToCharArray()){if([int]$c -lt 32 -or [int]$c -gt 126){$ascii=$false}};', + '[Console]::OutputEncoding=[Text.Encoding]::UTF8;if($ascii){[Console]::Write($p)}else{', + "$t=Join-Path $env:TEMP ('pokeport_base_'+[Guid]::NewGuid().ToString('N')+[IO.Path]::GetExtension($p));", + 'try{Copy-Item -LiteralPath $p -Destination $t -ErrorAction Stop;[Console]::Write($t)}catch{}}}'}) + local command='powershell -NoProfile -STA -Command "'..script..'"' + if #command>7000 then return end + return{command} + elseif os=='zenity'then + return{'zenity --file-selection --title='..shell(prompt)..' --file-filter='..shell(k.label..' | '..table.concat(globs,' '))..' 2>/dev/null'} + else + return{'kdialog --getopenfilename "$HOME" '..shell(table.concat(globs,' ')..'|'..k.label)..' --title '..shell(prompt)..' 2>/dev/null'} + end +end +function M.open(prompt,kind) + local k=kindOf(kind);if not k or not promptOk(prompt)then return end + return require('src.import.RomImporter').chooseFileByExt(k.exts,nil,{title=prompt,label=k.label,nativeUTF8=true}) +end +function M.basename(path)return tostring(path or''):match('([^/\\]+)$')or tostring(path or'')end +function M.read(path) + return require('src.core.NativeFile').read(path) +end +return M diff --git a/src/core/Game.lua b/src/core/Game.lua index 9dc07ce9d..3cc4ed74e 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -7,6 +7,7 @@ local Input = require("src.core.Input") local Logger = require("src.core.Logger") local Platform = require("src.core.Platform") local Renderer = require("src.render.Renderer") +local GameViewport = require("src.render.GameViewport") local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") local FrameProfile = require("src.core.FrameProfile") @@ -30,6 +31,7 @@ end function Game:load() self.pointerOwners={} + self.modPointers={} self.secondScreenPointerOwner=nil self.secondScreenOverworldPoketch=nil self.data = Data @@ -84,6 +86,9 @@ function Game:load() self.touchControls = TouchControls TouchControls:init() + TouchControls:setHotkeyHandler(function(action, pressed) + self:touchSkinHotkey(action, pressed) + end) self.renderer = Renderer Renderer:init() @@ -308,6 +313,47 @@ function Game:returnToTitle() self.stack:push(self:makeTitleState()) end +-- Skin actions use the same native speed/title/options owners as keyboard input. +Game.SKIN_FAST_FORWARD = 4 +function Game:_cycleSpeed(dir) + if not (self.save and self.save.options) or self:speedLocked() then return false end + local ow = self.overworld + local top = self.stack and (self.stack.active and self.stack:active() or self.stack:top()) + if ow and (ow.transitioning or (top == ow and ( + (ow.runner and ow.runner.isRunning and ow.runner:isRunning()) + or (ow.scriptMoves and #ow.scriptMoves > 0) or ow.engaging or ow.emote))) then + return false + end + self.save.options.speed = require('src.core.GameSpeed').cycle(self.save.options.speed, dir or 1) + self.speedOverride = nil + self:writeOptions() + return true +end + +function Game:touchSkinHotkey(action, pressed) + if action == "fast_forward_hold" then + if pressed then + self.skinSpeedSaved = self.speedOverride + self.speedOverride = Game.SKIN_FAST_FORWARD + else + self.speedOverride = self.skinSpeedSaved + self.skinSpeedSaved = nil + end + elseif action == "fast_forward_toggle" and pressed then + return self:_cycleSpeed(1) + elseif action == "soft_reset" and pressed then + Input:reset() + TouchControls:reset() + self:returnToTitle() + elseif action == "menu" and pressed then + local top = self.stack and self.stack:top() + if top and not top.isTouchSkinMenu then + local state = require("src.ui.Screens").build(self, "OptionsMenu") + if state then state.isTouchSkinMenu = true; self.stack:push(state) end + end + end +end + function Game:step(dt) -- THE WHOLE LOGIC STEP, because `stack:update` alone accounted for under -- 2 ms of a 43 ms update and everything else in here was invisible. Run 4 @@ -333,6 +379,7 @@ function Game:_step(dt) close() end if TouchControls.pollTouches then TouchControls:pollTouches() end + Game._pollPadPointers(self) self.input:step() -- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the -- audio, whites the palettes out and falls through into Init, i.e. the @@ -381,6 +428,20 @@ end -- Options row takes effect immediately; speedOverride is the --speed / -- POKEPORT_SPEED run argument, which wins over the saved option so a bot -- or screenshot run does not depend on whatever the player last chose. +function Game:speedLocked() + if self.linkSession or (self.linkNet and not self.linkNet.closed) then + return true, 'link' + end + local states = self.stack and self.stack.states or {} + for i = #states, 1, -1 do + local state = states[i] + if state and (state.isFixedSpeed or state.isMinigame) then + return true, 'minigame' + end + end + return false +end + function Game:logicSpeed() local GameSpeed = require("src.core.GameSpeed") -- Link play is always 1X on both machines, and this wins over every other @@ -389,7 +450,7 @@ function Game:logicSpeed() -- than the opponent it is racing, and drove its own animation/message -- queue at a different rate than the peer it is locked to. Nothing about -- a match should depend on what either player set this to. - if self.linkSession or (self.linkNet and not self.linkNet.closed) then + if self:speedLocked() then return 1 end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end @@ -917,6 +978,8 @@ Game.centerClassicZones = centerClassicZones function Game:draw() local closeDraw = FrameProfile.section("draw: whole frame") local screenLayout=require('src.ui.SecondScreen') + local generation = require('src.core.GameVersion').generation() + GameViewport.begin(generation, generation <= 3 and not screenLayout.combined(self)) Renderer.splitViewport=nil if screenLayout.combined(self) then local w,h=love.graphics.getDimensions() @@ -979,8 +1042,9 @@ function Game:draw() if screenLayout.combined(self) then screenLayout.composeWindow(self) - TouchControls:draw() end + GameViewport.finish(self) + TouchControls:draw() closeDraw() -- THE SECOND PANEL'S FRAME LEAVES LAST. -- @@ -1176,7 +1240,7 @@ function Game:_draw() ModRuntime.call("render.hud", function() end, self, viewport) end -- on-screen mobile controls: pure screen-space, over the finished frame - if not Renderer.splitViewport then TouchControls:draw() end + -- OS-window controls draw after GameViewport.finish in Game:draw. end -- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out @@ -1373,6 +1437,9 @@ function Game:keypressed(key) local loaded, recovered = SaveData.load() if loaded then self:restoreSave(loaded, recovered) end return + elseif key == "1" then + self:_cycleSpeed(1) + return elseif key == "-" then self:zoomStep(-1) return @@ -1623,12 +1690,14 @@ end function Game:focus(f) Input:reset() TouchControls:reset() + self._padPointerIds = nil self:audioSession(f, "focus") end function Game:visible(v) Input:reset() TouchControls:reset() + self._padPointerIds = nil self:audioSession(v, "visibility") end @@ -1710,9 +1779,13 @@ end local function offerPointer(self, method, id, x, y) self.pointerOwners=self.pointerOwners or {} local captured=self.pointerOwners[id] + local gameX, gameY, inside = GameViewport.toLocal(x, y) + local panel = self.secondScreenPointerOwner local function offer(screen) if not (screen and type(screen[method])=='function') then return false end - local ok,claimed=pcall(screen[method],screen,id,x,y) + local ok,claimed=pcall(screen[method],screen,id, + (self.secondScreenInjecting or screen == panel) and x or gameX, + (self.secondScreenInjecting or screen == panel) and y or gameY) if not ok then require('src.core.Logger').warn('%s raised in a touch handler: %s', tostring(screen.name or 'a screen'),tostring(claimed)) @@ -1727,21 +1800,72 @@ local function offerPointer(self, method, id, x, y) return true end local top = self.stack and self.stack.top and self.stack:top() - if offer(top) then return true end + if (inside or self.secondScreenInjecting) and offer(top) then return true end -- The always-present Android lower-panel watch is drawn outside the stack. -- Its coordinate mapper rejects main-window input in display mode. - local panel=self.secondScreenPointerOwner return panel~=top and offer(panel) or false end -function Game:touchpressed(id, x, y) +-- Window-space pointer metadata plus coordinates local to the game rectangle. +local function pointerUnclaimed() return false end +function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button) + local gameX, gameY, inside = GameViewport.toLocal(x, y) + return ModRuntime.call("input.pointer", pointerUnclaimed, self, { + phase = phase, source = source, id = id, x = x, y = y, + gameX = gameX, gameY = gameY, insideGame = inside, + dx = dx or 0, dy = dy or 0, pressure = pressure, button = button, + }) == true +end +local function modPointer(self, phase, id, x, y, dx, dy, pressure) + if self.secondScreenInjecting then return false end + local p = self.modPointers and self.modPointers[id] + if phase == "pressed" then + if not ModRuntime.wantsHook("input.pointer") then return false end + self.modPointers = self.modPointers or {} + p = { source = id == "mouse" and "mouse" or "touch", x = x, y = y } + self.modPointers[id] = p + elseif not p then return false end + dx, dy = dx or (x - p.x), dy or (y - p.y) + p.x, p.y = x, y + if pressure ~= nil then p.pressure = pressure end + local nativeOwner = self.pointerOwners and self.pointerOwners[id] + local claimed = self:pointerEvent(phase, p.source, id, x, y, dx, dy, p.pressure, id == "mouse" and 1 or nil) + if not nativeOwner and claimed then p.claimed = true end + if phase == "released" and self.modPointers then self.modPointers[id] = nil end + return not nativeOwner and p.claimed == true +end + +-- Options can reset the pad synchronously while a hotbar key is dispatched. +-- Keep gesture ownership separately until release; a changed screen/mod must +-- not inherit the remaining movement/release of that virtual-control press. +function Game:_pollPadPointers() + if not self._padPointerIds or not (love.touch and love.touch.getTouches) then return end + local ok, ids = pcall(love.touch.getTouches) + if not ok or type(ids) ~= 'table' then return end + local live = {} + for _, id in ipairs(ids) do live[id] = true end + for id in pairs(self._padPointerIds) do + if id ~= 'mouse' and not live[id] then self._padPointerIds[id] = nil end + end +end + +function Game:touchpressed(id, x, y, dx, dy, pressure) + if not self.secondScreenInjecting and self._padPointerIds then + if self._padPointerIds[id] then TouchControls:touchreleased(id,x,y) end + self._padPointerIds[id] = nil + end -- Visible virtual controls own their complete gesture, even when pressing -- one opens a different screen before the finger is lifted. if not self.secondScreenInjecting and TouchControls.visible and TouchControls:visible() and TouchControls:hitTest(x,y) then - TouchControls:touchpressed(id,x,y) - return + if TouchControls:touchpressed(id,x,y) + or (TouchControls.touches and TouchControls.touches[id]) then + self._padPointerIds = self._padPointerIds or {} + self._padPointerIds[id] = true + return + end end + if modPointer(self, "pressed", id, x, y, dx, dy, pressure) then return end if offerPointer(self, "touchpressed", id, x, y) then return end if self.secondScreenInjecting then return end TouchControls:touchpressed(id, x, y) @@ -1756,21 +1880,26 @@ function Game:hasPointerScreen() return top and type(top.touchpressed)=='function' or false end -function Game:touchmoved(id, x, y) - if not self.secondScreenInjecting and TouchControls.touches and TouchControls.touches[id] then +function Game:touchmoved(id, x, y, dx, dy, pressure) + if not self.secondScreenInjecting and ((self._padPointerIds and self._padPointerIds[id]) + or (TouchControls.touches and TouchControls.touches[id])) then TouchControls:touchmoved(id,x,y) return end + if modPointer(self, "moved", id, x, y, dx, dy, pressure) then return end if offerPointer(self, "touchmoved", id, x, y) then return end if self.secondScreenInjecting then return end TouchControls:touchmoved(id, x, y) end -function Game:touchreleased(id, x, y) - if not self.secondScreenInjecting and TouchControls.touches and TouchControls.touches[id] then +function Game:touchreleased(id, x, y, dx, dy, pressure) + if not self.secondScreenInjecting and ((self._padPointerIds and self._padPointerIds[id]) + or (TouchControls.touches and TouchControls.touches[id])) then TouchControls:touchreleased(id,x,y) + if self._padPointerIds then self._padPointerIds[id] = nil end return end + if modPointer(self, "released", id, x, y, dx, dy, pressure) then return end if offerPointer(self, "touchreleased", id, x, y) then return end if self.secondScreenInjecting then return end TouchControls:touchreleased(id, x, y) diff --git a/src/core/GenOptions.lua b/src/core/GenOptions.lua index 001e56185..ef737cd02 100644 --- a/src/core/GenOptions.lua +++ b/src/core/GenOptions.lua @@ -51,6 +51,8 @@ GenOptions.SHARED_ONLY = { modUpdateCache = true, saveSlots = true, touchControls = true, + haptics = true, + hotbar = true, tutorials = true, perGeneration = true, dataDir = true, diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 72e75e2ba..bafd3f67a 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -381,4 +381,15 @@ function HostShell.httpGet(url, userAgent, accept) return body end +function HostShell.hasCommand(name) + if not HostShell.canSpawnProcess() or type(name)~='string' or #name>80 + or not name:match('^[%w][%w_.+-]*$') then return false end + local windows=love.system.getOS()=='Windows' + local pipe=HostShell.popen(windows and('where '..name..' 2>NUL')or("command -v '"..name.."' 2>/dev/null")) + if not pipe then return false end + local ok,value=pcall(pipe.read,pipe,'*a') + pcall(pipe.close,pipe) + return ok and type(value)=='string'and value:find('%S')~=nil +end + return HostShell diff --git a/src/core/NativeFile.lua b/src/core/NativeFile.lua new file mode 100644 index 000000000..90da72509 --- /dev/null +++ b/src/core/NativeFile.lua @@ -0,0 +1,84 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Read a user-selected host file. Windows LuaJIT's io.open uses the ANSI +-- codepage; the native wide API keeps UTF-8 paths, including the user's TEMP +-- directory, independent of that codepage. This never stages or writes data. +local NativeFile = {} +local windowsAPI + +local function windowsRead(path) + local ffi = require('ffi') + if ffi.os ~= 'Windows' then return nil, 'native Windows reader unavailable' end + if not windowsAPI then + ffi.cdef[[ + int __stdcall MultiByteToWideChar(unsigned int, unsigned long, const char *, int, uint16_t *, int); + void * __stdcall CreateFileW(const uint16_t *, unsigned long, unsigned long, void *, unsigned long, unsigned long, void *); + int __stdcall GetFileSizeEx(void *, int64_t *); + int __stdcall ReadFile(void *, void *, unsigned long, unsigned long *, void *); + int __stdcall CloseHandle(void *); + unsigned long __stdcall GetLastError(void); + ]] + windowsAPI = ffi.load('kernel32') + end + local api = windowsAPI + local n = api.MultiByteToWideChar(65001, 8, path, #path, nil, 0) + if n == 0 then return nil, 'invalid UTF-8 file path' end + local wide = ffi.new('uint16_t[?]', n + 1) + if api.MultiByteToWideChar(65001, 8, path, #path, wide, n) == 0 then + return nil, 'cannot convert UTF-8 file path' + end + -- OPEN_EXISTING, GENERIC_READ, FILE_SHARE_READ: never create/truncate the + -- chosen file and do not accept a concurrent writer changing its contents. + local handle = api.CreateFileW(wide, 0x80000000, 1, nil, 3, 0x08000000, nil) + if handle == ffi.cast('void *', -1) then + return nil, 'cannot open file (Windows error '..tonumber(api.GetLastError())..')' + end + handle = ffi.gc(handle, api.CloseHandle) + local function read() + local size = ffi.new('int64_t[1]') + if api.GetFileSizeEx(handle, size) == 0 then + return nil, 'cannot measure file (Windows error '..tonumber(api.GetLastError())..')' + end + local length = tonumber(size[0]) + if length == 0 then return nil, 'empty file' end + if length < 0 or length > 2147483647 then return nil, 'file too large for a Lua string' end + local buffer, count = ffi.new('uint8_t[65536]'), ffi.new('unsigned long[1]') + local chunks, remaining = {}, length + while remaining > 0 do + local wanted = math.min(remaining, 65536) + if api.ReadFile(handle, buffer, wanted, count, nil) == 0 then + return nil, 'cannot read file (Windows error '..tonumber(api.GetLastError())..')' + end + local got = tonumber(count[0]) + if got == 0 then return nil, 'file ended before its reported size' end + chunks[#chunks + 1] = ffi.string(buffer, got) + remaining = remaining - got + end + return table.concat(chunks) + end + local ok, bytes, why = pcall(read) + ffi.gc(handle, nil) + api.CloseHandle(handle) + if not ok then return nil, bytes end + return bytes, why +end + +function NativeFile.read(path) + if type(path) ~= 'string' or path == '' or path:find('%z') then + return nil, 'invalid native file path' + end + local osName = love and love.system and love.system.getOS and love.system.getOS() + if osName == 'Windows' then + local ok, bytes, why = pcall(windowsRead, path) + if not ok then return nil, bytes end + return bytes, why + end + local opened, file, why = pcall(io.open, path, 'rb') + if not opened or not file then return nil, opened and why or file end + local ok, bytes, err = pcall(file.read, file, '*a') + pcall(file.close, file) + if not ok then return nil, bytes end + if not bytes or bytes == '' then return nil, err or 'empty file' end + return bytes +end + +return NativeFile diff --git a/src/core/PdfImage.lua b/src/core/PdfImage.lua new file mode 100644 index 000000000..671e94bcc --- /dev/null +++ b/src/core/PdfImage.lua @@ -0,0 +1,118 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms; +-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712. +-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins +-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so +-- import pulls the embedded image out instead of refusing the skin. True +-- vector PDFs (no Image XObject, no JPEG) still fail. + +local PdfImage = {} + +local function isPdf(bytes) + return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-" +end + +-- After the `stream` keyword the spec allows \n or \r\n before the bytes. +-- `endstream` also contains the letters "stream", so skip that match. +local function streamDataStart(bytes, from) + local s, e = bytes:find("stream", from, true) + while s do + if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then + local p = e + 1 + if bytes:sub(p, p) == "\r" then p = p + 1 end + if bytes:sub(p, p) == "\n" then p = p + 1 end + return p, s + end + s, e = bytes:find("stream", e + 1, true) + end + return nil +end + +local function dictWindow(bytes, imageAt) + local from = imageAt > 400 and (imageAt - 400) or 1 + local to = math.min(#bytes, imageAt + 800) + return bytes:sub(from, to) +end + +local function dictNumber(window, key) + -- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5. + if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end + return tonumber(window:match("/" .. key .. "%s+(%d+)")) +end + +local function dictFilter(window) + local named = window:match("/Filter%s*/(%w+)") + if named then return named end + return window:match("/Filter%s*%[%s*/(%w+)") +end + +local function jpegIn(bytes, from, to) + if from < 1 then from = 1 end + if not to or to > #bytes then to = #bytes end + if to < from then return nil end + local region = bytes:sub(from, to) + local soi = region:find("\255\216\255", 1, true) + if not soi then return nil end + local eoi = region:find("\255\217", soi + 3, true) + if not eoi then return nil end + return region:sub(soi, eoi + 1) +end + +local function candidate(data, width, height, ext) + if not data or data == "" then return nil end + return { + data = data, + ext = ext or "jpg", + width = width or 0, + height = height or 0, + } +end + +local function bigger(a, b) + if not a then return b end + if not b then return a end + local as = (a.width or 0) * (a.height or 0) + local bs = (b.width or 0) * (b.height or 0) + if bs ~= as then return bs > as and b or a end + return #b.data > #a.data and b or a +end + +-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream. +local function fromImageXObjects(bytes) + local best + local i = 1 + while true do + local s, e = bytes:find("/Subtype%s*/Image", i) + if not s then break end + local window = dictWindow(bytes, s) + local filter = dictFilter(window) + local width = dictNumber(window, "Width") + local height = dictNumber(window, "Height") + local dataStart = streamDataStart(bytes, e) + i = e + 1 + if dataStart and filter == "DCTDecode" then + local es = bytes:find("endstream", dataStart, true) + local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil) + best = bigger(best, candidate(jpeg, width, height, "jpg")) + end + end + return best +end + +-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG +-- body even when /Length is an indirect object we do not resolve. +local function fromBareJpeg(bytes) + local jpeg = jpegIn(bytes, 1, #bytes) + if not jpeg then return nil end + return candidate(jpeg, 0, 0, "jpg") +end + +function PdfImage.extract(bytes) + if not isPdf(bytes) then return nil, "not a pdf" end + local best = fromImageXObjects(bytes) + if not best then best = fromBareJpeg(bytes) end + if not best then return nil, "no extractable image" end + return best +end + +return PdfImage diff --git a/src/core/SafeArea.lua b/src/core/SafeArea.lua index 86c404fbc..bf57c3c6d 100644 --- a/src/core/SafeArea.lua +++ b/src/core/SafeArea.lua @@ -8,23 +8,33 @@ -- canvas may still letterbox into the full framebuffer for immersion. local SafeArea = {} +local Viewport = require("src.render.GameViewport") -function SafeArea.rect() +function SafeArea.windowRect() local ww, wh = 0, 0 if love and love.graphics and love.graphics.getDimensions then ww, wh = love.graphics.getDimensions() end - if ww <= 0 then ww = 1 end - if wh <= 0 then wh = 1 end + if ww <= 0 then + ww = 1 + end + if wh <= 0 then + wh = 1 + end if not (love and love.window and love.window.getSafeArea) then return 0, 0, ww, wh end local x, y, w, h = love.window.getSafeArea() - if type(x) ~= "number" or type(y) ~= "number" - or type(w) ~= "number" or type(h) ~= "number" - or w <= 0 or h <= 0 then + if + type(x) ~= "number" + or type(y) ~= "number" + or type(w) ~= "number" + or type(h) ~= "number" + or w <= 0 + or h <= 0 + then return 0, 0, ww, wh end @@ -37,4 +47,8 @@ function SafeArea.rect() return x, y, w, h end +function SafeArea.rect() + return Viewport.localSafeRect(SafeArea.windowRect()) +end + return SafeArea diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..58215e1b7 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -520,6 +520,8 @@ function SaveData.defaultOptions() -- layout (#633). Pre-#633 files stored one top-level positions table; -- TouchControls.normalizeConfig folds it into both orientations on load. touchControls = { enabled = true }, + haptics = "light", + hotbar = true, -- Where imported game data lives. false = LOVE's save directory (the -- appdata folder); a string is an absolute folder the player chose in -- LAUNCHER SETTINGS. See the game-data folder block above. diff --git a/src/core/SkinZip.lua b/src/core/SkinZip.lua new file mode 100644 index 000000000..acc6cf287 --- /dev/null +++ b/src/core/SkinZip.lua @@ -0,0 +1,95 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms; +-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712. +local SkinZip = {} + +local bxor +do + local ok, bit = pcall(require, "bit") + if ok and bit and bit.bxor then + bxor = function(a, b) return bit.bxor(a, b) % 0x100000000 end + else + local byteXor = {} + local function xor8(a, b) + local key = a * 256 + b + local memo = byteXor[key] + if memo then return memo end + local r, bitv = 0, 1 + local x, y = a, b + for _ = 1, 8 do + if (x % 2) ~= (y % 2) then r = r + bitv end + x, y, bitv = math.floor(x / 2), math.floor(y / 2), bitv * 2 + end + byteXor[key] = r + return r + end + bxor = function(a, b) + local r, mul = 0, 1 + for _ = 1, 4 do + r = r + xor8(a % 256, b % 256) * mul + a, b, mul = math.floor(a / 256), math.floor(b / 256), mul * 256 + end + return r + end + end +end + +local crcTable +local function crc32(s) + if not crcTable then + crcTable = {} + for i = 0, 255 do + local c = i + for _ = 1, 8 do + if c % 2 == 1 then + c = bxor(math.floor(c / 2), 0xEDB88320) + else + c = math.floor(c / 2) + end + end + crcTable[i] = c + end + end + local crc = 0xFFFFFFFF + for i = 1, #s do + crc = bxor(crcTable[bxor(crc, s:byte(i)) % 256], math.floor(crc / 256)) + end + return bxor(crc, 0xFFFFFFFF) % 0x100000000 +end + +local function le16(n) + n = math.floor(n) % 0x10000 + return string.char(n % 256, math.floor(n / 256) % 256) +end + +local function le32(n) + n = math.floor(n) % 0x100000000 + return string.char(n % 256, math.floor(n / 256) % 256, + math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256) +end + +function SkinZip.encode(entries) + local out, central, offset = {}, {}, 0 + for _, entry in ipairs(entries) do + local name, data = entry.name, entry.data or "" + local crc, size = crc32(data), #data + local local_ = "PK\3\4" .. le16(20) .. le16(0) .. le16(0) + .. le16(0) .. le16(0) + .. le32(crc) .. le32(size) .. le32(size) + .. le16(#name) .. le16(0) .. name + out[#out + 1] = local_ + out[#out + 1] = data + central[#central + 1] = "PK\1\2" .. le16(20) .. le16(20) .. le16(0) .. le16(0) + .. le16(0) .. le16(0) + .. le32(crc) .. le32(size) .. le32(size) + .. le16(#name) .. le16(0) .. le16(0) .. le16(0) .. le16(0) + .. le32(0) .. le32(offset) .. name + offset = offset + #local_ + #data + end + local dir = table.concat(central) + return table.concat(out) .. dir + .. "PK\5\6" .. le16(0) .. le16(0) .. le16(#entries) .. le16(#entries) + .. le32(#dir) .. le32(offset) .. le16(0) +end + +return SkinZip 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/TouchControls.lua b/src/core/TouchControls.lua index f9917f278..d463e3cb7 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -30,6 +30,8 @@ local Input = require("src.core.Input") local SafeArea = require("src.core.SafeArea") local GameVersion = require("src.core.GameVersion") +local TouchSkin = require("src.core.TouchSkin") + local TouchControls = {} -- idle vs pressed overlay opacity @@ -61,13 +63,35 @@ local SLOP = { a = 1.3, b = 1.3, x = 1.15, y = 1.15, start = 1.4, select = 1.4, -- and that switches the whole overlay off, so an install whose assets predate -- these two would lose its d-pad. They are skipped instead -- see OPTIONAL. local BUTTONS = { "a", "b", "x", "y", "start", "select", "l", "r" } -local CONTROLS = { "dpad", "a", "b", "x", "y", "start", "select", "l", "r" } +local CONTROLS = { "dpad", "a", "b", "x", "y", "start", "select", "l", "r", "hotbar" } + +local HOTBAR = { + { spec = "key:f1", label = "SAVE" }, + { spec = "key:f2", label = "LOAD" }, + { spec = "key:1", label = "SPEED" }, + { spec = "key:2", label = "COLOR" }, + { spec = "key:3", label = "TILT" }, + { spec = "key:4", label = "ZOOM" }, +} +local HOTBAR_SLOP = 1.4 + +local function skinButtonAvailable(btn) + return (btn ~= 'x' and btn ~= 'y') or GameVersion.isGen4() +end + +local function skinControlAvailable(ctl) + if #ctl.keys > 0 or #ctl.hotkeys > 0 then return true end + for _,btn in ipairs(ctl.buttons)do + if skinButtonAvailable(btn) then return true end + end + return false +end local function buttonAvailable(self, btn) -- X and Y are Platinum / HGSS buttons in play, but the launcher's layout -- editor (preview) shows them always: the layout it saves is shared by -- every game, and the Gen 4 ones are where a player wants to place them - if btn == 'x' or btn == 'y' then return self.preview or GameVersion.isGen4() end + if btn == 'x' or btn == 'y' then return self.preview or skinButtonAvailable(btn) end return self.img and self.img[btn] ~= nil end @@ -112,6 +136,52 @@ local function clampScale(v) return v end +-- Haptic controls adapted from Gen1Recomp 535a5ec0. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC; licenses/GEN1RECOMP-TOUCH-SKINS.md. +TouchControls.HAPTICS = { "off", "light", "normal", "strong" } +TouchControls.HAPTIC_DEFAULT = "light" + +local HAPTIC_SECONDS = { + off = 0, light = 0.012, normal = 0.025, strong = 0.045, +} +local HAPTIC_LABELS = { + off = "OFF", light = "LIGHT", normal = "NORMAL", strong = "STRONG", +} + +function TouchControls.normalizeHaptics(level) + if level == "physical" then return "light" end + if level == "medium" then return "normal" end + if level == "heavy" then return "strong" end + if HAPTIC_SECONDS[level] then return level end + return TouchControls.HAPTIC_DEFAULT +end + +function TouchControls.hapticLabel(level) + return HAPTIC_LABELS[TouchControls.normalizeHaptics(level)] +end + +function TouchControls.cycleHaptics(level, dir) + local cur, idx = TouchControls.normalizeHaptics(level), 1 + for i, m in ipairs(TouchControls.HAPTICS) do + if m == cur then idx = i break end + end + local n = #TouchControls.HAPTICS + return TouchControls.HAPTICS[(idx - 1 + (dir or 1)) % n + 1] +end + +-- One pulse at the given level. Feature-guarded rather than platform-gated: +-- love.system.vibrate is a no-op on desktop and absent from the headless love +-- stubs, so the press path below stays identical everywhere and the tests +-- never reach a vibrator. +function TouchControls.buzz(level) + local secs = HAPTIC_SECONDS[TouchControls.normalizeHaptics(level)] + if not secs or secs <= 0 then return false end + if not (love and love.system and love.system.vibrate) then return false end + pcall(love.system.vibrate, secs) + return true +end + + -- Copy a persisted positions table, dropping unknown / non-numeric entries. -- Always a fresh table: two orientations seeded from the same pre-#633 -- layout must not alias, or dragging one would still move the other. @@ -152,6 +222,7 @@ function TouchControls.normalizeConfig(tc) -- no caller ever has to nil-check a bucket's scale if type(tc) ~= "table" then tc = {} end if tc.enabled == false then out.enabled = false end + if type(tc.skin) == "string" and tc.skin ~= "" then out.skin = tc.skin end local saved = type(tc.layouts) == "table" and tc.layouts or nil for _, o in ipairs(ORIENTATIONS) do local b = saved and saved[o] @@ -185,6 +256,8 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale) b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW }, start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW }, select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW }, + -- Between the native shoulders, above the thumb clusters. + hotbar = {cx=ox + ww * 0.50, cy=oy + margin + ssW * 0.70, w=ssW}, -- THE SHOULDERS GO AT THE TOP CORNERS, where the hardware puts them and -- where a hand already holding the phone can reach without covering the -- screen. Sized off `dpadW` like everything else here, so the size slider @@ -226,6 +299,12 @@ end function TouchControls:init() self.active = wantsOverlay() + TouchSkin.setOverlayLive(self.active) + TouchSkin.setActive(nil) + self.skinId, self.skinError = nil, nil + self.hotkeysHeld, self.keysHeld = {}, {} + self.haptics = TouchControls.HAPTIC_DEFAULT + self.hotbarEnabled, self.hotbarOpen, self.hotbarControls = true, false, nil self.enabled = true -- per-orientation buckets (#633); self.positions / self.scale mirror the -- one currently on screen so layout(), the editor and the tests keep a @@ -267,6 +346,11 @@ end function TouchControls:applyOptions(opts) local cfg = TouchControls.normalizeConfig(opts and opts.touchControls) self.enabled = cfg.enabled + self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) + self.hotbarEnabled = not (opts and opts.hotbar == false) + if not self.hotbarEnabled then self.hotbarOpen = false end + TouchSkin.setOverlayLive(self.active) + self:selectSkin(cfg.enabled and cfg.skin or nil) self.layouts = cfg.layouts self.layoutW, self.layoutH = nil, nil self.layoutOx, self.layoutOy = nil, nil @@ -282,7 +366,7 @@ end -- Snapshot for the editor's save path: enabled plus both orientation -- buckets, matching what options.lua stores (#633). function TouchControls:config() - local out = { enabled = self.enabled ~= false, layouts = {} } + local out = { enabled = self.enabled ~= false, skin = self.skinId, layouts = {} } for _, o in ipairs(ORIENTATIONS) do local b = self.layouts and self.layouts[o] or nil out.layouts[o] = { @@ -305,9 +389,70 @@ function TouchControls:setPreview(on) end function TouchControls:visible() - if self.preview then return self.img ~= nil end - return self.active and self.enabled ~= false and self.img ~= nil - and not self.controllerHidden + local art = TouchSkin.active ~= nil or self.img ~= nil + if self.preview then return art end + if self.enabled == false or not art then return false end + -- A selected skin is also a desktop/TV bezel. Input remains gated in + -- touchpressed, but the artwork must not disappear when a controller is + -- connected or the platform is not touch-first. + if TouchSkin.active then return true end + return self.active and not self.controllerHidden +end + +local function surfaceRect() + local r = TouchSkin.surfaceRect + if r then return r.w, r.h, r.x, r.y end + if love and love.graphics and love.graphics.getDimensions then + local w, h = love.graphics.getDimensions() + return w, h, 0, 0 + end + return 0, 0, 0, 0 +end + +TouchControls.surfaceRect = surfaceRect + +function TouchControls:selectSkin(id) + if id == self.skinId and TouchSkin.active then return TouchSkin.active end + self:reset() + self.skinError = nil + if not id or id == "" then + self.skinId = nil + TouchSkin.setActive(nil) + return nil + end + local skin, err = TouchSkin.select(id) + if not skin then + self.skinId = nil + self.skinError = err + TouchSkin.setActive(nil) + return nil, err + end + self.skinId = id + return skin +end + +function TouchControls:skin() + if not self:visible() then return nil end + return TouchSkin.active +end + +function TouchControls:setHotkeyHandler(fn) + self.hotkeyHandler = type(fn) == "function" and fn or nil +end + +local skinHitSet, applySkinSet + +function TouchControls:buttons() + local out = {} + for _, name in ipairs(BUTTONS) do if buttonAvailable(self, name) then out[#out+1] = name end end + return out +end + +function TouchControls:controls() + local out = {"dpad"} + for _, name in ipairs(self:buttons()) do out[#out+1] = name end + if self:hotbarShown() then out[#out+1] = "hotbar" end + return out end -- Keep a control fully inside the usable rect [x0,y0]..[x1,y1]. @@ -321,7 +466,7 @@ end -- demand. Mirrors it into self.orientation / self.positions / self.scale, -- which layout(), the editor chrome and the tests read. function TouchControls:currentBucket() - local _, _, sw, sh = SafeArea.rect() + local _, _, sw, sh = (SafeArea.windowRect or SafeArea.rect)() local o = orientationFor(sw, sh) self.layouts = self.layouts or { portrait = {}, landscape = {} } local b = self.layouts[o] @@ -347,7 +492,7 @@ end -- while sizes stay derived from the short edge, times the orientation's -- size setting (#633). function TouchControls:layout() - local ox, oy, sw, sh = SafeArea.rect() + local ox, oy, sw, sh = (SafeArea.windowRect or SafeArea.rect)() if self.layoutW == sw and self.layoutH == sh and self.layoutOx == ox and self.layoutOy == oy and self.layoutVersion == GameVersion.get() and self.L then @@ -383,7 +528,7 @@ end -- Move one control to a screen-space point and persist its normalized -- position within the safe rect. Used by the layout editor while dragging. function TouchControls:setControlCenter(name, cx, cy) - local ox, oy, sw, sh = SafeArea.rect() + local ox, oy, sw, sh = (SafeArea.windowRect or SafeArea.rect)() local L = self:layout() local zone = L[name] if not zone then return end @@ -434,10 +579,86 @@ local function inCircle(zone, x, y, slop) return dx * dx + dy * dy <= r * r end +-- Hotbar geometry/drawing adapted from Gen1Recomp535a5ec0. +-- Copyright(C)2026 BOIS CLUB GAMES, LLC; licenses/GEN1RECOMP-TOUCH-SKINS.md. +function TouchControls:hotbarItems() + if self.hotbarControls then return self.hotbarControls end + local out = {} + for i, entry in ipairs(HOTBAR) do + local ctl = TouchSkin.newControl(entry.spec, 0, 0, 0, 0, "rect") + ctl.label = entry.label + out[i] = ctl + end + self.hotbarControls = out + return out +end + +function TouchControls:hotbarShown() + return self.hotbarEnabled ~= false and not TouchSkin.active +end + +function TouchControls:hotbarStrip() + local L = self:layout() + local zone = L and L.hotbar + local items = self:hotbarItems() + if not zone or #items == 0 then return nil end + local ox, oy, sw, sh = (SafeArea.windowRect or SafeArea.rect)() + local h = zone.w * 0.95 + local pad = h * 0.14 + local cw = h * 1.9 + if self.labelFont then + for _, ctl in ipairs(items) do + cw = math.max(cw, self.labelFont:getWidth(ctl.label) + pad * 3) + end + end + local total = math.min(sw - pad * 2, cw * #items) + cw = total / #items + local x0 = math.max(ox + pad, + math.min(zone.cx - total, ox + sw - pad - total)) + local y0 = zone.cy + zone.w * 0.80 + if y0 + h + pad > oy + sh then y0 = zone.cy - zone.w * 0.80 - h end + y0 = math.max(oy + pad, math.min(y0, oy + sh - h - pad)) + local cells = {} + for i, ctl in ipairs(items) do + cells[i] = { ctl = ctl, label = ctl.label, h = h, + x = x0 + (i - 1) * cw, y = y0, w = cw - pad } + end + return cells +end + +function TouchControls:hotbarCellAt(x, y) + if self.preview or not (self.hotbarOpen and self:hotbarShown()) then return nil end + for _, cell in ipairs(self:hotbarStrip() or {}) do + if x >= cell.x and x <= cell.x + cell.w + and y >= cell.y and y <= cell.y + cell.h then + return cell + end + end + return nil +end + + -- Which control (if any) contains (x, y). Prefer face buttons over the -- d-pad when they overlap, matching touchpressed's order. function TouchControls:hitTest(x, y) + local page = TouchSkin.page() + if page then + local ww, wh, ox, oy = surfaceRect() + for i = #page.controls, 1, -1 do + local ctl = page.controls[i] + if not ctl.decorative and skinControlAvailable(ctl) + and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then + return ctl.spec, ctl + end + end + return nil + end local L = self:layout() + if self:hotbarShown() then + local cell = self:hotbarCellAt(x, y) + if cell then return "hotkey", cell.ctl end + if inCircle(L.hotbar, x, y, HOTBAR_SLOP) then return "hotbar" end + end for _, btn in ipairs(BUTTONS) do if buttonAvailable(self,btn) and L[btn] and inCircle(L[btn], x, y, SLOP[btn]) then return btn end end @@ -462,7 +683,10 @@ end local function pressBtn(self, btn) local n = (self.held[btn] or 0) + 1 self.held[btn] = n - if n == 1 then Input:overlayPressed(btn) end + if n == 1 then + Input:overlayPressed(btn) + TouchControls.buzz(self.haptics) + end end local function releaseBtn(self, btn) @@ -484,17 +708,131 @@ local function setDpad(self, touch, dir) if dir then pressBtn(self, dir) end end +-- Custom-skin control ownership adapted from Gen1Recomp 535a5ec0. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC; licenses/GEN1RECOMP-TOUCH-SKINS.md. +local function pressKey(self, key, down) + self.keysHeld = self.keysHeld or {} + local before = self.keysHeld[key] or 0 + local count = math.max(0, before + (down and 1 or -1)) + self.keysHeld[key] = count > 0 and count or nil + if down and before > 0 or not down and (before == 0 or count > 0) then return end + local fn = down and love.keypressed or love.keyreleased + if type(fn) == "function" then pcall(fn, key, key, false) end +end + +local function fireHotkey(self, action, pressed, ctl) + if action == "overlay_next" then + if pressed then TouchSkin.nextPage(ctl and ctl.nextTarget) end + return pressed + end + if action == "overlay_prev" then + if pressed then TouchSkin.setPage(TouchSkin.pageIndex - 1) end + return pressed + end + self.hotkeysHeld = self.hotkeysHeld or {} + local n = (self.hotkeysHeld[action] or 0) + (pressed and 1 or -1) + if n < 0 then n = 0 end + self.hotkeysHeld[action] = n > 0 and n or nil + local edge = (pressed and n == 1) or (not pressed and n == 0) + if edge and self.hotkeyHandler then + pcall(self.hotkeyHandler, action, pressed) + end + return false +end + +local function enterControl(self, ctl) + local freshButton, freshAction = false, false + for _, btn in ipairs(ctl.buttons) do + if skinButtonAvailable(btn) and not self.held[btn] then freshButton = true end + end + for _, key in ipairs(ctl.keys) do if not (self.keysHeld and self.keysHeld[key]) then freshAction = true end end + for _, action in ipairs(ctl.hotkeys) do if not (self.hotkeysHeld and self.hotkeysHeld[action]) then freshAction = true end end + for _, btn in ipairs(ctl.buttons) do + if skinButtonAvailable(btn) then pressBtn(self, btn) end + end + for _, key in ipairs(ctl.keys) do pressKey(self, key, true) end + local switched = false + for _, action in ipairs(ctl.hotkeys) do + if fireHotkey(self, action, true, ctl) then switched = true end + end + if not freshButton and freshAction then TouchControls.buzz(self.haptics) end + return switched +end + +local function exitControl(self, ctl) + for _, btn in ipairs(ctl.buttons) do releaseBtn(self, btn) end + for _, key in ipairs(ctl.keys) do pressKey(self, key, false) end + for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end +end + +function skinHitSet(self, x, y, prev) + local page = TouchSkin.page() + if not page then return nil end + local ww, wh, ox, oy = surfaceRect() + local set = nil + for _, ctl in ipairs(page.controls) do + local held = (prev and prev[ctl]) == true + if not ctl.decorative and skinControlAvailable(ctl) + and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy, held) then + set = set or {} + set[ctl] = true + end + end + return set +end + +function applySkinSet(self, touch, set) + local prev = touch.set or {} + local switched = false + for ctl in pairs(prev) do + if not (set and set[ctl]) then exitControl(self, ctl) end + end + for ctl in pairs(set or {}) do + if not prev[ctl] then + if enterControl(self, ctl) then switched = true end + end + end + touch.set = set + if switched then + for ctl in pairs(touch.set or {}) do exitControl(self, ctl) end + touch.set = nil + end +end + + function TouchControls:touchpressed(id, x, y) -- preview mode is layout-edit only: never press GB buttons if self.preview then return end - if not (self.active and self.enabled ~= false and self.img) then return end + if not (self.active and self.enabled ~= false and (TouchSkin.active or self.img)) then return end -- A reused native pointer id must release its previous hold first. if self.touches[id] then self:touchreleased(id,x,y) end -- A controller hid the overlay; restore it and accept this same press. if self.controllerHidden then self.controllerHidden = false end + if TouchSkin.active then + local set = skinHitSet(self, x, y, nil) + if not set then return end + local touch = { control = "skin" } + self.touches[id] = touch + applySkinSet(self, touch, set) + return true + end local L = self:layout() + if self:hotbarShown() then + local cell = self:hotbarCellAt(x, y) + if cell then + self.touches[id] = {control="hotkey", ctl=cell.ctl} + enterControl(self, cell.ctl) + return true + end + if inCircle(L.hotbar, x, y, HOTBAR_SLOP) then + self.hotbarOpen = not self.hotbarOpen + self.touches[id] = {control="hotbarToggle"} + TouchControls.buzz(self.haptics) + return true + end + end for _, btn in ipairs(BUTTONS) do if buttonAvailable(self,btn) and L[btn] and inCircle(L[btn], x, y, SLOP[btn]) then self.touches[id] = { control = btn } @@ -528,6 +866,10 @@ function TouchControls:touchmoved(id, x, y) if self.preview then return end local touch = self.touches[id] if not touch then return end + if touch.control == "skin" then + applySkinSet(self, touch, skinHitSet(self, x, y, touch.set)) + return + end -- Gen4 free camera look if touch.control == "cameralook" and self.cameraLookStart then @@ -560,7 +902,12 @@ function TouchControls:touchreleased(id, x, y) if not touch then return end self.touches[id] = nil - if touch.control == "cameralook" then + if touch.control == "skin" then + applySkinSet(self, touch, nil) + elseif touch.control == "hotkey" then + exitControl(self, touch.ctl) + elseif touch.control == "hotbarToggle" then + elseif touch.control == "cameralook" then self.cameraLookTouch = nil self.cameraLookStart = nil elseif touch.control == "dpad" then @@ -576,6 +923,18 @@ end -- touchreleased and would strand its button held forever. Called from -- Game alongside Input:reset() on focus/visibility loss. function TouchControls:reset() + for _, touch in pairs(self.touches or {}) do + if touch.control == "skin" then applySkinSet(self, touch, nil) + elseif touch.control == "hotkey" and touch.ctl then exitControl(self, touch.ctl) end + end + for key in pairs(self.keysHeld or {}) do + self.keysHeld[key] = 1 + pressKey(self, key, false) + end + for action in pairs(self.hotkeysHeld or {}) do + if self.hotkeyHandler then pcall(self.hotkeyHandler, action, false) end + end + self.keysHeld, self.hotkeysHeld = {}, {} for btn in pairs(self.held or {}) do Input:overlayReleased(btn) end @@ -586,6 +945,7 @@ function TouchControls:reset() self.rightStickTouch = nil self.cameraLookTouch = nil self.cameraLookStart = nil + self.hotbarOpen = false end -- Recover if Android cancels a contact without delivering touchreleased. @@ -637,8 +997,53 @@ end -- Screen-space, called by Game:draw after Renderer:endFrame so the -- overlay rides on top of everything (world, UI, CRT/GBC FX included). -- Also used by the launcher layout editor under preview mode. +local function drawOverlayImage(img, x, y, w, h, alpha) + if not img or alpha <= 0 then return end + local sx, sy = TouchSkin.imageFit(img:getWidth(), img:getHeight(), w, h) + if not sx then return end + love.graphics.setColor(1, 1, 1, math.min(1, alpha)) + love.graphics.draw(img, x, y, 0, sx, sy) +end + +function TouchControls:drawSkin(alphaMul) + local page = TouchSkin.page() + if not page then return false end + local ww, wh, sox, soy = surfaceRect() + local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh, sox, soy) + local opacity = (self.skinOpacity or 1) * alphaMul + + love.graphics.push("all") + love.graphics.origin() + drawOverlayImage(page.image, bx, by, bw, bh, opacity) + + local pressed = {} + for _, touch in pairs(self.touches or {}) do + for ctl in pairs(touch.set or {}) do pressed[ctl] = true end + end + + for _, ctl in ipairs(page.controls) do + local down = pressed[ctl] == true + local img = (down and ctl.pressedImage) or ctl.image + if img then + local cx, cy, halfW, halfH = + TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy) + local alpha = opacity + if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end + drawOverlayImage(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) + end + end + + love.graphics.pop() + return true +end + + function TouchControls:draw() if not self:visible() then return end + if TouchSkin.active then + local mul = (self.preview and self.enabled == false) and 0.45 or 1 + if self:drawSkin(mul) then return end + end local L = self:layout() -- when the player disabled the overlay but the editor is previewing, -- draw dimmed so the layout is still editable @@ -684,6 +1089,49 @@ function TouchControls:draw() label("START", L.start) label("SELECT", L.select) + if self:hotbarShown() then + local z = L.hotbar + local r = z.w * 0.5 + love.graphics.setColor(0.15, 0.15, 0.15, 0.55 * alphaMul) + love.graphics.circle("fill", z.cx, z.cy, r * 1.16) + love.graphics.setColor(1, 1, 1, (ALPHA + 0.2) * alphaMul) + local dot = r * 0.16 + for i = -1, 1 do + love.graphics.circle("fill", z.cx + i * r * 0.5, z.cy, dot) + end + if self.hotbarOpen and not self.preview then + local pressed = {} + for _, touch in pairs(self.touches or {}) do + if touch.control == "hotkey" and touch.ctl then pressed[touch.ctl] = true end + end + local cells = self:hotbarStrip() or {} + local first, last = cells[1], cells[#cells] + if first and last then + local pad = first.h * 0.14 + love.graphics.setColor(0.15, 0.15, 0.15, 0.55 * alphaMul) + love.graphics.rectangle("fill", first.x - pad, first.y - pad, + last.x + last.w - first.x + pad * 2, + first.h + pad * 2, first.h * 0.3) + end + for _, cell in ipairs(cells) do + local down = pressed[cell.ctl] == true + love.graphics.setColor(0.3, 0.3, 0.3, + (down and 0.85 or 0.6) * alphaMul) + love.graphics.rectangle("fill", cell.x, cell.y, cell.w, cell.h, + cell.h * 0.25) + local tw = self.labelFont:getWidth(cell.label) + local ty = cell.y + (cell.h - self.labelFont:getHeight()) * 0.5 + love.graphics.setColor(0, 0, 0, 0.6 * alphaMul) + love.graphics.print(cell.label, + cell.x + (cell.w - tw) * 0.5 + 1, ty + 1) + love.graphics.setColor(1, 1, 1, + (down and ALPHA_PRESSED or (ALPHA + 0.2)) * alphaMul) + love.graphics.print(cell.label, cell.x + (cell.w - tw) * 0.5, ty) + end + end + end + + love.graphics.pop() end diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua new file mode 100644 index 000000000..d37b9acde --- /dev/null +++ b/src/core/TouchSkin.lua @@ -0,0 +1,1530 @@ +-- Adapted from Gen1Recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Copyright (C) 2026 BOIS CLUB GAMES, LLC. GPLv3 with Section 7 terms; +-- see licenses/GEN1RECOMP-TOUCH-SKINS.md. Native integration by Roxas2712. +local TouchSkin = {} + +TouchSkin.BUNDLED_ROOT = "assets/skins" +TouchSkin.USER_ROOT = "skins" +TouchSkin.EXPORT_ROOT = "skins/_export" + +TouchSkin.GB_BUTTONS = { + a = "a", b = "b", x = "x", y = "y", start = "start", select = "select", + up = "up", down = "down", left = "left", right = "right", + l = "l", r = "r", + l1 = "l", r1 = "r", + l2 = "l", r2 = "r", + trigger_l = "l", trigger_r = "r", + shoulder_l = "l", shoulder_r = "r", +} + +TouchSkin.HOTKEYS = { + overlay_next = "overlay_next", + overlay_previous = "overlay_prev", + menu_toggle = "menu", + reset = "soft_reset", + hold_fast_forward = "fast_forward_hold", + fast_forward = "fast_forward_hold", + toggle_fast_forward = "fast_forward_toggle", + screenshot = "screenshot", + pause_toggle = "pause", + exit_emulator = "quit", +} + +local DEFAULT_ASPECT = 16 / 9 +local PORTRAIT_ASPECT = 9 / 16 + +local function trim(s) + return (tostring(s or ""):match("^%s*(.-)%s*$")) +end + +local function unquote(s) + s = trim(s) + local quoted = s:match('^"([^"]*)"') + if quoted then return quoted end + s = s:gsub("%s*//.*$", ""):gsub("%s*#.*$", "") + s = trim(s) + local inner = s:match('^"(.*)"$') + return inner or s +end + +local function toBool(v) + v = trim(v):lower() + return v == "true" or v == "1" +end + +local function tokens(s) + local out = {} + for tok in tostring(s or ""):gmatch("[^,%s]+") do out[#out + 1] = tok end + return out +end + +local function num(v, fallback) + local n = tonumber(trim(v)) + if not n or n ~= n then return fallback end + return n +end + +function TouchSkin.parseConfig(text) + local kv = {} + for line in tostring(text or ""):gmatch("[^\r\n]+") do + if not line:match("^%s*#") then + local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$") + if key then kv[key] = unquote(value) end + end + end + return kv +end + +local function parseBinds(spec) + local buttons, hotkeys, keys, decorative = {}, {}, {}, true + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = trim(raw):lower() + local key = name:match("^key:(.+)$") or name:match("^retrok_(.+)$") + if name == "nul" or name == "" then + -- decoration + elseif key then + keys[#keys + 1] = key + decorative = false + elseif TouchSkin.GB_BUTTONS[name] then + buttons[#buttons + 1] = TouchSkin.GB_BUTTONS[name] + decorative = false + elseif TouchSkin.HOTKEYS[name] then + hotkeys[#hotkeys + 1] = TouchSkin.HOTKEYS[name] + decorative = false + end + end + return buttons, hotkeys, keys, decorative +end + +TouchSkin.AREA_DEFAULTS = { + dpad_area = { up = "up", down = "down", left = "left", right = "right" }, + abxy_area = { up = "x", down = "b", left = "y", right = "a" }, + analog_left = { up = "up", down = "down", left = "left", right = "right" }, + analog_right = { up = "up", down = "down", left = "left", right = "right" }, +} + +local DIRECTIONAL_CELLS = { + { col = 1, row = 1, h = "left", v = "up" }, + { col = 2, row = 1, v = "up" }, + { col = 3, row = 1, h = "right", v = "up" }, + { col = 1, row = 2, h = "left" }, + { col = 3, row = 2, h = "right" }, + { col = 1, row = 3, h = "left", v = "down" }, + { col = 2, row = 3, v = "down" }, + { col = 3, row = 3, h = "right", v = "down" }, +} + +local function outwardReach(reach) + return 1 + 3 * ((num(reach, 1)) - 1) +end + +function TouchSkin.expandDirectional(base, names) + names = names or {} + local cellX = math.abs(num(base.rangeX, 0.05)) / 3 + local cellY = math.abs(num(base.rangeY, 0.05)) / 3 + local out = {} + for _, cell in ipairs(DIRECTIONAL_CELLS) do + local parts = {} + if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end + if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end + local spec = #parts > 0 and table.concat(parts, "|") or "nul" + local ctl = TouchSkin.newControl(spec, + num(base.x, 0.5) + (cell.col - 2) * cellX * 2, + num(base.y, 0.5) + (cell.row - 2) * cellY * 2, + cellX * 2, cellY * 2, "rect") + ctl.rangeMod = num(base.rangeMod, 1) + ctl.alphaMod = num(base.alphaMod, 1) + ctl.reachLeft = cell.col == 1 and outwardReach(base.reachLeft) or 1 + ctl.reachRight = cell.col == 3 and outwardReach(base.reachRight) or 1 + ctl.reachUp = cell.row == 1 and outwardReach(base.reachUp) or 1 + ctl.reachDown = cell.row == 3 and outwardReach(base.reachDown) or 1 + ctl.pixelCoords = base.pixelCoords + ctl.movable = base.movable + ctl.exclusive = base.exclusive + out[#out + 1] = ctl + end + return out +end + +local SECTOR_CELLS = { + { h = "right" }, + { h = "right", v = "down" }, + { v = "down" }, + { h = "left", v = "down" }, + { h = "left" }, + { h = "left", v = "up" }, + { v = "up" }, + { h = "right", v = "up" }, +} + +TouchSkin.SECTOR_SPAN = math.pi / 4 + +function TouchSkin.sectorHit(sector, dx, dy) + local span = TouchSkin.SECTOR_SPAN + local start = (sector - 1) * span - span * 0.5 + local a = (math.atan2(dy, dx) - start) % (math.pi * 2) + return a < span +end + +function TouchSkin.expandSectors(base, names) + names = names or {} + local out = {} + for i, cell in ipairs(SECTOR_CELLS) do + local parts = {} + if cell.h and names[cell.h] then parts[#parts + 1] = names[cell.h] end + if cell.v and names[cell.v] then parts[#parts + 1] = names[cell.v] end + local spec = #parts > 0 and table.concat(parts, "|") or "nul" + local ctl = TouchSkin.newControl(spec, num(base.x, 0.5), num(base.y, 0.5), + math.abs(num(base.rangeX, 0.05)) * 2, math.abs(num(base.rangeY, 0.05)) * 2, + base.shape) + ctl.sector = i + ctl.areaKind = base.areaKind + ctl.areaNames = base.areaNames + ctl.rangeMod = num(base.rangeMod, 1) + ctl.alphaMod = num(base.alphaMod, 1) + ctl.reachLeft = num(base.reachLeft, 1) + ctl.reachRight = num(base.reachRight, 1) + ctl.reachUp = num(base.reachUp, 1) + ctl.reachDown = num(base.reachDown, 1) + ctl.pixelCoords = base.pixelCoords + ctl.movable = base.movable + ctl.exclusive = base.exclusive + out[#out + 1] = ctl + end + return out +end + +local function areaSide(kv, prefix, side, fallback) + local v = kv[prefix .. "_" .. side] + if v == nil or trim(v) == "" then return fallback end + return trim(v) +end + +local function parseDesc(kv, prefix, page) + local spec = kv[prefix] + if not spec then return nil end + local t = tokens(spec) + if #t < 6 then return nil end + + local shape = trim(t[4]):lower() + if shape ~= "radial" and shape ~= "rect" then shape = "rect" end + + local buttons, hotkeys, keys, decorative = parseBinds(t[1]) + local reachX = num(kv[prefix .. "_reach_x"], 1) + local reachY = num(kv[prefix .. "_reach_y"], 1) + + local ctl = { + spec = t[1], + buttons = buttons, + hotkeys = hotkeys, + keys = keys, + decorative = decorative, + x = num(t[2], 0.5), + y = num(t[3], 0.5), + shape = shape, + rangeX = math.abs(num(t[5], 0.05)), + rangeY = math.abs(num(t[6], 0.05)), + rangeMod = num(kv[prefix .. "_range_mod"], page.rangeMod), + alphaMod = num(kv[prefix .. "_alpha_mod"], page.alphaMod), + reachUp = num(kv[prefix .. "_reach_up"], reachY), + reachDown = num(kv[prefix .. "_reach_down"], reachY), + reachLeft = num(kv[prefix .. "_reach_left"], reachX), + reachRight = num(kv[prefix .. "_reach_right"], reachX), + imagePath = kv[prefix .. "_overlay"], + pressedImagePath = kv[prefix .. "_overlay_pressed"], + nextTarget = kv[prefix .. "_next_target"], + movable = toBool(kv[prefix .. "_movable"]) or nil, + exclusive = (toBool(kv[prefix .. "_exclusive"]) + or toBool(kv[prefix .. "_range_mod_exclusive"])) or nil, + saturatePct = num(kv[prefix .. "_saturate_pct"], nil), + } + if ctl.imagePath == "" then ctl.imagePath = nil end + if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end + + local normalized = kv[prefix .. "_normalized"] + if normalized ~= nil then ctl.pixelCoords = not toBool(normalized) end + + local areaKind = trim(t[1]):lower() + local defaults = TouchSkin.AREA_DEFAULTS[areaKind] + if defaults then + ctl.areaKind = areaKind + ctl.areaNames = { + up = areaSide(kv, prefix, "up", defaults.up), + down = areaSide(kv, prefix, "down", defaults.down), + left = areaSide(kv, prefix, "left", defaults.left), + right = areaSide(kv, prefix, "right", defaults.right), + } + end + return ctl +end + +function TouchSkin.parse(text) + local kv = TouchSkin.parseConfig(text) + local count = math.floor(num(kv.overlays, 0)) + if count <= 0 then return nil, "no overlays" end + + local pages, warnings = {}, {} + local function warn(text) + for _, existing in ipairs(warnings) do + if existing == text then return end + end + warnings[#warnings + 1] = text + end + + for i = 0, count - 1 do + local p = "overlay" .. i + local page = { + index = i + 1, + name = kv[p .. "_name"] or ("overlay" .. i), + imagePath = kv[p .. "_overlay"], + fullScreen = toBool(kv[p .. "_full_screen"]), + normalized = toBool(kv[p .. "_normalized"]), + rangeMod = num(kv[p .. "_range_mod"], 1), + alphaMod = num(kv[p .. "_alpha_mod"], 1), + aspect = num(kv[p .. "_aspect_ratio"], nil), + controls = {}, + } + if page.imagePath == "" then page.imagePath = nil end + -- An explicit aspect_ratio is the overlay's design aspect. RetroArch + -- letterboxes to it even when full_screen is set, so range_x/range_y + -- that were authored as a circle stay a circle. #1503 + page.aspectFromCfg = page.aspect ~= nil and page.aspect > 0 + if not page.aspectFromCfg then + page.aspect = page.name:lower():find("portrait", 1, true) + and PORTRAIT_ASPECT or DEFAULT_ASPECT + end + + local rect = tokens(kv[p .. "_rect"]) + page.rect = { x = 0, y = 0, w = 1, h = 1 } + if #rect >= 4 then + page.rect = { x = num(rect[1], 0), y = num(rect[2], 0), + w = num(rect[3], 1), h = num(rect[4], 1) } + end + + local vp = tokens(kv[p .. "_viewport"]) + if #vp >= 4 then + page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0), + w = num(vp[3], 1), h = num(vp[4], 1) } + page.viewportFill = toBool(kv[p .. "_viewport_fill"]) + end + + page.pixelCoords = not page.normalized + if page.pixelCoords and not page.imagePath then + page.pixelCoords = false + warn(page.name .. " has no base image: desc coordinates read as normalized") + end + + local descs = math.floor(num(kv[p .. "_descs"], 0)) + for d = 0, descs - 1 do + local ctl = parseDesc(kv, p .. "_desc" .. d, page) + if not ctl then + warn(page.name .. " is missing desc " .. d) + elseif ctl.areaKind then + if ctl.imagePath or ctl.pressedImagePath then + local art = TouchSkin.newControl("nul", ctl.x, ctl.y, + ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape) + art.imagePath = ctl.imagePath + art.pressedImagePath = ctl.pressedImagePath + art.rangeMod, art.alphaMod = ctl.rangeMod, ctl.alphaMod + art.pixelCoords = ctl.pixelCoords + art.movable, art.exclusive = ctl.movable, ctl.exclusive + page.controls[#page.controls + 1] = art + end + for _, cell in ipairs(TouchSkin.expandSectors(ctl, ctl.areaNames)) do + page.controls[#page.controls + 1] = cell + end + else + page.controls[#page.controls + 1] = ctl + end + end + pages[#pages + 1] = page + end + + -- RetroArch auto-rotate: overlay names containing portrait / landscape + -- are the lock. Stamp it so the studio does not need a second click. #1503 + for _, page in ipairs(pages) do + if not page.orient then page.orient = TouchSkin.pageOrient(page) end + end + + return { pages = pages, warnings = warnings } +end + +local function readFile(path) + if love and love.filesystem and love.filesystem.read then + local ok, data = pcall(love.filesystem.read, path) + if ok and data then return data end + end + local ok, handle = pcall(io.open, path, "rb") + if not ok or not handle then return nil end + local read, bytes = pcall(handle.read, handle, "*a") + pcall(handle.close, handle) + return read and bytes or nil +end + +local function listDir(path) + if love and love.filesystem and love.filesystem.getDirectoryItems then + local ok, items = pcall(love.filesystem.getDirectoryItems, path) + if ok and items then return items end + end + return {} +end + +local function wfs() + return require("src.core.SaveData").persistenceFs(love.filesystem) +end + +local function isDir(path) + if love and love.filesystem and love.filesystem.getInfo then + local ok, info = pcall(love.filesystem.getInfo, path) + if ok and info then return info.type == "directory" end + end + return false +end + +local function joinPath(root, rel) + rel = tostring(rel or ""):gsub("^%./", ""):gsub("\\", "/") + if rel:sub(1, 1) == "/" then return rel:sub(2) end + return root .. "/" .. rel +end + +TouchSkin.NATIVE_NAME = "skin.lua" + +TouchSkin.readFile = readFile +TouchSkin.listDir = listDir +TouchSkin.isDir = isDir + +local function findConfig(root) + if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then + return root .. "/" .. TouchSkin.NATIVE_NAME, "native", "" + end + local named = { "overlay.cfg", "skin.cfg", "layout.cfg" } + for _, name in ipairs(named) do + if readFile(root .. "/" .. name) then + return root .. "/" .. name, "retroarch", "" + end + end + local infoPath, prefix = require("src.core.DeltaSkin").findInfo(root) + if infoPath then return infoPath, "delta", prefix end + local items = listDir(root) + table.sort(items) + for _, name in ipairs(items) do + if name:match("%.cfg$") then return root .. "/" .. name, "retroarch", "" end + end + return nil +end + +local function loadDataChunk(text, name) + if loadstring then + local chunk, err = loadstring(text, name) + if not chunk then return nil, err end + return setfenv(chunk, {}) + end + return load(text, name, "t", {}) +end + +function TouchSkin.parseNative(text) + local chunk, err = loadDataChunk(tostring(text or ""), "skin") + if not chunk then return nil, "skin.lua does not parse: " .. tostring(err) end + local ok, data = pcall(chunk) + if not ok or type(data) ~= "table" then return nil, "skin.lua returned no table" end + if type(data.pages) ~= "table" or not data.pages[1] then + return nil, "skin.lua has no pages" + end + + local pages = {} + for i, raw in ipairs(data.pages) do + if type(raw) ~= "table" then return nil, "page " .. i .. " is not a table" end + local page = { + index = i, + name = tostring(raw.name or ("page" .. i)), + imagePath = raw.image, + fullScreen = raw.fullScreen ~= false, + normalized = true, + pixelCoords = false, + rangeMod = num(raw.rangeMod, 1), + alphaMod = num(raw.alphaMod, 1), + aspect = num(raw.aspect, DEFAULT_ASPECT), + aspectFromCfg = raw.fitAspect == true, + orient = (raw.orient == "portrait" or raw.orient == "landscape" + or raw.orient == "any") and raw.orient or nil, + screenFit = raw.screenFit == "remainder" and "remainder" or nil, + anchor = (raw.anchor == "top" or raw.anchor == "bottom" + or raw.anchor == "left" or raw.anchor == "right") + and raw.anchor or nil, + rect = { x = 0, y = 0, w = 1, h = 1 }, + controls = {}, + } + if type(raw.rect) == "table" then + page.rect = { x = num(raw.rect.x, 0), y = num(raw.rect.y, 0), + w = num(raw.rect.w, 1), h = num(raw.rect.h, 1) } + end + if type(raw.viewport) == "table" then + page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0), + w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) } + page.viewportFill = raw.viewport.fill == true + end + for _, c in ipairs(raw.controls or {}) do + local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul") + local sector = tonumber(c.sector) + if sector then + sector = math.floor(sector) + if sector < 1 or sector > #SECTOR_CELLS then sector = nil end + end + local areaNames + if type(c.areaNames) == "table" then + areaNames = {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + if type(c.areaNames[side]) == "string" then + areaNames[side] = c.areaNames[side] + end + end + end + page.controls[#page.controls + 1] = { + sector = sector, + areaKind = type(c.areaKind) == "string" and c.areaKind or nil, + areaNames = areaNames, + spec = tostring(c.bind or "nul"), + buttons = buttons, hotkeys = hotkeys, keys = keys, + decorative = decorative, + x = num(c.x, 0.5), y = num(c.y, 0.5), + shape = c.shape == "radial" and "radial" or "rect", + rangeX = math.abs(num(c.w, 0.1)) * 0.5, + rangeY = math.abs(num(c.h, 0.1)) * 0.5, + rangeMod = num(c.rangeMod, page.rangeMod), + alphaMod = num(c.alphaMod, page.alphaMod), + reachUp = num(c.reachUp, 1), reachDown = num(c.reachDown, 1), + reachLeft = num(c.reachLeft, 1), reachRight = num(c.reachRight, 1), + imagePath = c.image, + pressedImagePath = c.imagePressed, + nextTarget = c.nextTarget, + movable = c.movable == true or nil, + exclusive = c.exclusive == true or nil, + } + end + if not page.orient then page.orient = TouchSkin.pageOrient(page) end + pages[#pages + 1] = page + end + return { pages = pages, name = data.name, author = data.author, + notes = data.notes, format = "native" } +end + +function TouchSkin.toNative(skin) + local out = { + name = skin.name or skin.id, + author = skin.author, + notes = skin.notes, + format = 1, + pages = {}, + } + for _, page in ipairs(skin.pages or {}) do + local p = { + name = page.name, + image = page.imagePath, + fullScreen = page.fullScreen ~= false, + rangeMod = page.rangeMod, + alphaMod = page.alphaMod, + aspect = page.aspect, + fitAspect = page.aspectFromCfg or nil, + screenFit = page.screenFit == "remainder" and "remainder" or nil, + anchor = page.anchor, + orient = (page.orient == "portrait" or page.orient == "landscape" + or page.orient == "any") and page.orient or nil, + controls = {}, + } + if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0 + or page.rect.w ~= 1 or page.rect.h ~= 1) then + p.rect = { x = page.rect.x, y = page.rect.y, w = page.rect.w, h = page.rect.h } + end + if page.viewport then + p.viewport = { + x = page.viewport.x, y = page.viewport.y, + w = page.viewport.w, h = page.viewport.h, + fill = page.viewportFill or nil, + } + end + for _, ctl in ipairs(page.controls or {}) do + p.controls[#p.controls + 1] = { + bind = ctl.spec, + x = ctl.x, y = ctl.y, + w = ctl.rangeX * 2, h = ctl.rangeY * 2, + shape = ctl.shape, + rangeMod = ctl.rangeMod ~= p.rangeMod and ctl.rangeMod or nil, + alphaMod = ctl.alphaMod ~= p.alphaMod and ctl.alphaMod or nil, + reachUp = ctl.reachUp ~= 1 and ctl.reachUp or nil, + reachDown = ctl.reachDown ~= 1 and ctl.reachDown or nil, + reachLeft = ctl.reachLeft ~= 1 and ctl.reachLeft or nil, + reachRight = ctl.reachRight ~= 1 and ctl.reachRight or nil, + image = ctl.imagePath, + imagePressed = ctl.pressedImagePath, + nextTarget = ctl.nextTarget, + movable = ctl.movable or nil, + exclusive = ctl.exclusive or nil, + sector = ctl.sector, + areaKind = ctl.areaKind, + areaNames = ctl.areaNames and { + up = ctl.areaNames.up, down = ctl.areaNames.down, + left = ctl.areaNames.left, right = ctl.areaNames.right, + } or nil, + } + end + out.pages[#out.pages + 1] = p + end + return out +end + +function TouchSkin.serialize(skin) + return require("src.import.LuaWriter").encode(TouchSkin.toNative(skin)) +end + +local imageCache = setmetatable({}, { __mode = "v" }) + +local function loadImage(path) + if not (love and love.graphics and love.graphics.newImage) then return nil end + local cached = imageCache[path] + if cached then return cached end + local ok, img = pcall(love.graphics.newImage, path) + if not ok or not img then return nil end + if img.setFilter then img:setFilter("linear", "linear") end + imageCache[path] = img + return img +end + +-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip. +local function loadImageFromBytes(bytes, name) + if not bytes or bytes == "" then return nil end + if not (love and love.graphics and love.graphics.newImage) then return nil end + if not (love.filesystem and love.filesystem.newFileData) then return nil end + local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes) + local cached = imageCache[key] + if cached then return cached end + local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg") + if not okFd or not fd then return nil end + local ok, img = pcall(love.graphics.newImage, fd) + if not ok or not img then + if love.image and love.image.newImageData then + local okData, data = pcall(love.image.newImageData, fd) + if okData and data then ok, img = pcall(love.graphics.newImage, data) end + end + end + if not ok or not img then return nil end + if img.setFilter then img:setFilter("linear", "linear") end + imageCache[key] = img + return img +end + +local function rasterizePdfPage(page, root) + if not page or page.image or not page.pdfPath then return end + local pdf = readFile(joinPath(root, page.pdfPath)) + local raster = require("src.core.PdfImage").extract(pdf) + if not raster then return end + local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext + page.rasterData = raster.data + page.rasterName = name:match("([^/]+)$") or name + page.image = loadImageFromBytes(raster.data, page.rasterName) +end + +local function pixelScalePending(page) + if page.pixelCoords then return true end + for _, ctl in ipairs(page.controls or {}) do + if ctl.pixelCoords then return true end + end + return false +end + +local function applyPixelScale(page) + if not pixelScalePending(page) then return true end + if not page.image or not page.image.getDimensions then return false end + local iw, ih = page.image:getDimensions() + if not iw or not ih or iw <= 0 or ih <= 0 then return false end + for _, ctl in ipairs(page.controls or {}) do + local pixel = ctl.pixelCoords + if pixel == nil then pixel = page.pixelCoords end + if pixel then + ctl.x, ctl.y = ctl.x / iw, ctl.y / ih + ctl.rangeX, ctl.rangeY = ctl.rangeX / iw, ctl.rangeY / ih + ctl.pixelCoords = false + end + end + page.pixelCoords = false + return true +end + +-- An overlay image is its own design canvas. Older RetroArch cfg files often +-- omit `aspect_ratio`; reading the dimensions here keeps that legacy art and +-- all of its normalized controls on the same uniform scale. +function TouchSkin.applyImageAspect(page) + if not page or page.aspectFromCfg or not page.image + or not page.image.getDimensions then return false end + local iw, ih = page.image:getDimensions() + if not iw or not ih or iw <= 0 or ih <= 0 then return false end + page.aspect = iw / ih + page.aspectFromImage = true + return true +end + +function TouchSkin.load(root, id) + local cfgPath, format, prefix = findConfig(root) + if not cfgPath then return nil, "no skin.lua, .cfg or info.json in " .. root end + local text = readFile(cfgPath) + if not text then return nil, "unreadable " .. cfgPath end + local skin, err + if format == "native" then + skin, err = TouchSkin.parseNative(text) + elseif format == "delta" then + local dir = cfgPath:match("^(.*)/[^/]+$") or root + skin, err = require("src.core.DeltaSkin").parse(text, + { prefix = prefix or "", names = listDir(dir) }) + else + skin, err = TouchSkin.parse(text) + end + if not skin then return nil, err end + + skin.id = id or root:match("([^/]+)$") or root + skin.root = root + skin.configPath = cfgPath + skin.format = format + skin.name = skin.name or skin.pages[1] and skin.pages[1].name or skin.id + + for _, page in ipairs(skin.pages) do + if page.imagePath then + page.image = loadImage(joinPath(root, page.imagePath)) + elseif page.pdfPath then + rasterizePdfPage(page, root) + end + TouchSkin.applyImageAspect(page) + if not applyPixelScale(page) then + return nil, "could not read " .. tostring(page.imagePath) + .. ", which " .. page.name .. " measures its coordinates against" + end + for _, ctl in ipairs(page.controls) do + if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end + if ctl.pressedImagePath then + ctl.pressedImage = loadImage(joinPath(root, ctl.pressedImagePath)) + end + end + end + return skin +end + +local mountedArchives = {} +local function unmountZip(archive) + local point = mountedArchives[archive] + if not point then return true end + if not (love and love.filesystem and love.filesystem.unmount) then + return nil, "cannot release mounted skin archive" + end + local ok, removed = pcall(love.filesystem.unmount, archive) + if not ok or removed ~= true then return nil, "skin archive is still in use" end + mountedArchives[archive] = nil + if TouchSkin.active and TouchSkin.active.root == point then TouchSkin.setActive(nil) end + for key in pairs(imageCache) do + if key:sub(1, #point + 1) == point .. "/" or key:sub(1, 6) == "bytes:" then + imageCache[key] = nil + end + end + return true +end + +local function mountZip(archive, point) + if not (love and love.filesystem and love.filesystem.mount) then return false end + -- PhysFS rejects a second mount of an already-mounted archive. A library + -- refresh must keep its existing entry instead of making it disappear. + if mountedArchives[archive] == point and findConfig(point) then return true end + local ok, mounted = pcall(love.filesystem.mount, archive, point) + if ok and mounted == true then mountedArchives[archive] = point end + return ok and mounted == true +end + +TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true } +TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true } +TouchSkin.PDF_ONLY_MESSAGE = + "This skin uses PDF artwork with no extractable image. " + .. "Ask the author for a PNG version." + +function TouchSkin.archiveId(name) + name = tostring(name or "") + local ext = name:match("%.([%w]+)$") + if not ext or not TouchSkin.ARCHIVE_EXTS[ext:lower()] then return nil end + local id = name:sub(1, #name - #ext - 1) + if id == "" then return nil end + return id, ext:lower() +end + +function TouchSkin.list() + local out, seen = {}, {} + local function scan(root, source) + for _, name in ipairs(listDir(root)) do + local archiveId = TouchSkin.archiveId(name) + local id = archiveId or name + if not seen[id] and name:sub(1, 1) ~= "_" then + local path = root .. "/" .. name + if archiveId then + local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id + if mountZip(path, point) and findConfig(point) then + seen[id] = true + out[#out + 1] = { id = id, root = point, source = source, archive = path } + end + elseif isDir(path) and findConfig(path) then + seen[id] = true + out[#out + 1] = { id = id, root = path, source = source } + end + end + end + end + if love and love.filesystem and love.filesystem.createDirectory then + pcall(wfs().createDirectory, TouchSkin.USER_ROOT) + end + scan(TouchSkin.USER_ROOT, "user") + scan(TouchSkin.BUNDLED_ROOT, "bundled") + table.sort(out, function(a, b) return a.id < b.id end) + return out +end + +-- Drop a .zip or .deltaskin into /skins and report the id it lists under. +function TouchSkin.installArchive(name, data) + if not data or data == "" then return nil, "empty archive" end + if not (love and love.filesystem and love.filesystem.write) then + return nil, "no writable filesystem" + end + name = tostring(name or ""):match("([^/\\]+)$") or "" + name = name:gsub("[^%w%._%-]", "_"):gsub("^_+", "") + local legacy = name:match("%.([%w]+)$") + if legacy and TouchSkin.LEGACY_EXTS[legacy:lower()] then + return nil, "old GBA4iOS skin, not supported" + end + local id = TouchSkin.archiveId(name) + if not id then return nil, "not a .zip or .deltaskin" end + + pcall(wfs().createDirectory, TouchSkin.USER_ROOT) + local dest = TouchSkin.USER_ROOT .. "/" .. name + local released, releaseError = unmountZip(dest) + if not released then return nil, releaseError end + local ok, err = wfs().write(dest, data) + if not ok then return nil, tostring(err) end + + local entry = TouchSkin.find(id) + if not entry then + wfs().remove(dest) + return nil, "no skin.lua, .cfg or info.json inside " .. name + end + local skin = TouchSkin.load(entry.root, entry.id) + if skin and require("src.core.DeltaSkin").needsConversion(skin) then + unmountZip(dest) + wfs().remove(dest) + return nil, TouchSkin.PDF_ONLY_MESSAGE + end + return id, skin and skin.warnings or nil +end + +function TouchSkin.find(id) + if not id or id == "" then return nil end + for _, entry in ipairs(TouchSkin.list()) do + if entry.id == id then return entry end + end + return nil +end + +-- Remove only a user-installed skin. Bundled skins are shipped with the +-- game and intentionally have no delete affordance. +function TouchSkin.remove(id) + local entry = TouchSkin.find(id) + if not entry then return nil, "no skin " .. tostring(id) end + if entry.source ~= "user" then return nil, "bundled skins cannot be deleted" end + if not (love and love.filesystem and love.filesystem.remove) then + return nil, "no writable filesystem" + end + if entry.archive then + local released, err = unmountZip(entry.archive) + if not released then return nil, err end + end + local function removeTree(path) + if isDir(path) and love.filesystem.getDirectoryItems then + for _, name in ipairs(love.filesystem.getDirectoryItems(path)) do + local ok, err = removeTree(path .. "/" .. name) + if not ok then return nil, err end + end + end + local ok, err = wfs().remove(path) + return ok and true or nil, err + end + return removeTree(entry.archive or (TouchSkin.USER_ROOT .. "/" .. entry.id)) +end + +function TouchSkin.assetPaths(skin) + local out, seen = {}, {} + local function add(rel) + if rel and rel ~= "" and not seen[rel] then + seen[rel] = true + out[#out + 1] = rel + end + end + for _, page in ipairs(skin.pages or {}) do + add(page.imagePath) + for _, ctl in ipairs(page.controls or {}) do + add(ctl.imagePath) + add(ctl.pressedImagePath) + end + end + return out +end + +local function writeArchive(entries, destPath) + local blob = require("src.core.SkinZip").encode(entries) + local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil + if not absolute and love and love.filesystem and love.filesystem.createDirectory then + local dir = destPath:match("^(.*)/[^/]+$") + if dir then pcall(wfs().createDirectory, dir) end + end + if not absolute and love and love.filesystem and love.filesystem.write then + local ok, err = wfs().write(destPath, blob) + if not ok then return nil, tostring(err) end + else + local handle = io.open(destPath, "wb") + if not handle then return nil, "cannot write " .. destPath end + handle:write(blob) + handle:close() + end + return destPath +end + +local function collectAssets(skin, rels) + local entries, missing = {}, {} + for _, rel in ipairs(rels) do + local data = readFile(joinPath(skin.root, rel)) + if data then + entries[#entries + 1] = { name = rel, data = data } + else + missing[#missing + 1] = rel + end + end + return entries, missing +end + +function TouchSkin.export(skin, destPath) + if not skin then return nil, "no skin" end + local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } } + local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin)) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + if skin.configPath and skin.format == "retroarch" then + local cfg = readFile(skin.configPath) + if cfg then + entries[#entries + 1] = + { name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg } + end + end + destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-export.zip") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end + return destPath, missing +end + +local function fmtNum(n) + n = tonumber(n) or 0 + if n == math.floor(n) then return string.format("%d", n) end + local s = string.format("%.6f", n):gsub("0+$", ""):gsub("%.$", "") + return s +end + +local function fmtRect(r) + return ('"%s,%s,%s,%s"'):format(fmtNum(r.x), fmtNum(r.y), fmtNum(r.w), fmtNum(r.h)) +end + +local function cfgSpec(spec) + local parts = {} + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = trim(raw) + local key = name:lower():match("^key:(.+)$") + parts[#parts + 1] = key and ("retrok_" .. key) or name + end + return table.concat(parts, "|") +end + +function TouchSkin.toRetroArchConfig(skin) + local pages = (skin and skin.pages) or {} + local out = { "overlays = " .. #pages } + for i, page in ipairs(pages) do + local p = "overlay" .. (i - 1) + out[#out + 1] = "" + out[#out + 1] = p .. '_name = "' .. tostring(page.name or ("overlay" .. (i - 1))) .. '"' + if page.imagePath then out[#out + 1] = p .. "_overlay = " .. page.imagePath end + out[#out + 1] = p .. "_full_screen = " .. (page.fullScreen ~= false and "true" or "false") + out[#out + 1] = p .. "_normalized = true" + if num(page.rangeMod, 1) ~= 1 then + out[#out + 1] = p .. "_range_mod = " .. fmtNum(page.rangeMod) + end + if num(page.alphaMod, 1) ~= 1 then + out[#out + 1] = p .. "_alpha_mod = " .. fmtNum(page.alphaMod) + end + if page.aspectFromCfg and page.aspect and page.aspect > 0 then + out[#out + 1] = p .. "_aspect_ratio = " .. fmtNum(page.aspect) + end + local r = page.rect + if r and (r.x ~= 0 or r.y ~= 0 or r.w ~= 1 or r.h ~= 1) then + out[#out + 1] = p .. "_rect = " .. fmtRect(r) + end + if page.viewport then + out[#out + 1] = p .. "_viewport = " .. fmtRect(page.viewport) + if page.viewportFill then out[#out + 1] = p .. "_viewport_fill = true" end + end + local controls = {} + for _, ctl in ipairs(page.controls or {}) do + if not ctl.sector or ctl.sector == 1 then controls[#controls + 1] = ctl end + end + out[#out + 1] = p .. "_descs = " .. #controls + for j, ctl in ipairs(controls) do + local d = p .. "_desc" .. (j - 1) + local spec = ctl.areaKind and ctl.sector and ctl.areaKind + or cfgSpec(ctl.spec) + if spec == "" then spec = "nul" end + out[#out + 1] = ('%s = "%s,%s,%s,%s,%s,%s"'):format(d, spec, + fmtNum(ctl.x), fmtNum(ctl.y), + ctl.shape == "radial" and "radial" or "rect", + fmtNum(ctl.rangeX), fmtNum(ctl.rangeY)) + if ctl.imagePath then out[#out + 1] = d .. "_overlay = " .. ctl.imagePath end + if ctl.pressedImagePath then + out[#out + 1] = d .. "_overlay_pressed = " .. ctl.pressedImagePath + end + if num(ctl.rangeMod, 1) ~= num(page.rangeMod, 1) then + out[#out + 1] = d .. "_range_mod = " .. fmtNum(ctl.rangeMod) + end + if num(ctl.alphaMod, 1) ~= num(page.alphaMod, 1) then + out[#out + 1] = d .. "_alpha_mod = " .. fmtNum(ctl.alphaMod) + end + for key, value in pairs({ up = ctl.reachUp, down = ctl.reachDown, + left = ctl.reachLeft, right = ctl.reachRight }) do + if num(value, 1) ~= 1 then + out[#out + 1] = d .. "_reach_" .. key .. " = " .. fmtNum(value) + end + end + if ctl.movable then out[#out + 1] = d .. "_movable = true" end + if ctl.exclusive then out[#out + 1] = d .. "_exclusive = true" end + if ctl.nextTarget then + out[#out + 1] = d .. '_next_target = "' .. tostring(ctl.nextTarget) .. '"' + end + if ctl.areaKind and ctl.sector and ctl.areaNames then + local defaults = TouchSkin.AREA_DEFAULTS[ctl.areaKind] or {} + for _, side in ipairs({ "up", "down", "left", "right" }) do + local name = ctl.areaNames[side] + if name and name ~= defaults[side] then + out[#out + 1] = d .. "_" .. side .. ' = "' .. name .. '"' + end + end + end + end + end + return table.concat(out, "\n") .. "\n" +end + +function TouchSkin.exportRetroArch(skin, destPath) + if not skin then return nil, "no skin" end + local entries = { { name = "overlay.cfg", data = TouchSkin.toRetroArchConfig(skin) } } + local assets, missing = collectAssets(skin, TouchSkin.assetPaths(skin)) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + destPath = destPath or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. "-retroarch.zip") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end + return destPath, missing +end + +function TouchSkin.exportDelta(skin, opts) + if not skin then return nil, "no skin" end + opts = opts or {} + local DeltaSkin = require("src.core.DeltaSkin") + local info, assetRels, warnings = DeltaSkin.build(skin, opts) + if not info then return nil, assetRels end + local entries = { + { name = DeltaSkin.INFO_NAME, data = require("src.link.Json").encode(info) }, + } + local assets, missing = collectAssets(skin, assetRels) + for _, entry in ipairs(assets) do entries[#entries + 1] = entry end + local destPath = opts.path + or (TouchSkin.EXPORT_ROOT .. "/" .. skin.id .. ".deltaskin") + local written, err = writeArchive(entries, destPath) + if not written then return nil, err end + return destPath, missing, warnings +end + +TouchSkin.BINDS = { + "nul", + "up", "down", "left", "right", + "a", "b", "l", "r", "start", "select", + "left|up", "right|up", "left|down", "right|down", + "hold_fast_forward", "toggle_fast_forward", "reset", "menu_toggle", + "overlay_next", +} + +function TouchSkin.describeBind(spec) + local buttons, hotkeys, keys, decorative = parseBinds(spec) + if decorative then return "decoration" end + local parts = {} + for _, b in ipairs(buttons) do parts[#parts + 1] = "GB " .. b:upper() end + for _, h in ipairs(hotkeys) do parts[#parts + 1] = h end + for _, k in ipairs(keys) do parts[#parts + 1] = "key " .. k end + return table.concat(parts, " + ") +end + +function TouchSkin.newControl(spec, x, y, w, h, shape) + local buttons, hotkeys, keys, decorative = parseBinds(spec) + return { + spec = spec, buttons = buttons, hotkeys = hotkeys, keys = keys, + decorative = decorative, + x = x, y = y, rangeX = w * 0.5, rangeY = h * 0.5, + shape = shape == "radial" and "radial" or "rect", + rangeMod = 1, alphaMod = 1, + reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1, + } +end + +function TouchSkin.setBind(ctl, spec) + ctl.spec = spec + ctl.buttons, ctl.hotkeys, ctl.keys, ctl.decorative = parseBinds(spec) + return ctl +end + +function TouchSkin.newSkin(id) + local page = { + index = 1, name = "main", fullScreen = true, normalized = true, + rangeMod = 1, alphaMod = 1, aspect = PORTRAIT_ASPECT, + rect = { x = 0, y = 0, w = 1, h = 1 }, + viewport = { x = 0, y = 0, w = 1, h = 0.5 }, + viewportFill = false, + controls = {}, + } + return { id = id or "new_skin", name = id or "new_skin", + root = TouchSkin.USER_ROOT .. "/" .. (id or "new_skin"), + format = "native", pages = { page } } +end + +local function copyTable(src) + if type(src) ~= "table" then return src end + local out = {} + for k, v in pairs(src) do out[k] = copyTable(v) end + return out +end + +function TouchSkin.clone(skin) + if not skin then return nil end + local out = { + id = skin.id, name = skin.name, root = skin.root, format = skin.format, + author = skin.author, notes = skin.notes, configPath = skin.configPath, + source = skin.source, pages = {}, + warnings = skin.warnings and copyTable(skin.warnings) or nil, + } + for i, page in ipairs(skin.pages or {}) do + local p = copyTable(page) + p.image = page.image + p.controls = {} + for j, ctl in ipairs(page.controls or {}) do + local c = copyTable(ctl) + c.image = ctl.image + c.pressedImage = ctl.pressedImage + p.controls[j] = c + end + out.pages[i] = p + end + return out +end + +function TouchSkin.resolveImage(root, rel) + if not rel or rel == "" then return nil end + return loadImage(joinPath(root, rel)) +end + +function TouchSkin.detectViewport(root, imagePath) + if not (love and love.image and love.image.newImageData) then return nil end + if not imagePath or imagePath == "" then return nil end + local ok, data = pcall(love.image.newImageData, joinPath(root, imagePath)) + if not ok or not data then return nil end + local w, h = data:getWidth(), data:getHeight() + if w < 2 or h < 2 then return nil end + + local function clear(x, y) + if x < 0 or y < 0 or x >= w or y >= h then return false end + local okp, _, _, _, a = pcall(data.getPixel, data, x, y) + return okp and a ~= nil and a < 0.05 + end + + local cx, cy = math.floor(w / 2), math.floor(h / 2) + if not clear(cx, cy) then return nil end + + local left, right = cx, cx + while left > 0 and clear(left - 1, cy) do left = left - 1 end + while right < w - 1 and clear(right + 1, cy) do right = right + 1 end + local top, bottom = cy, cy + while top > 0 and clear(cx, top - 1) do top = top - 1 end + while bottom < h - 1 and clear(cx, bottom + 1) do bottom = bottom + 1 end + + local rw, rh = right - left + 1, bottom - top + 1 + if rw < 8 or rh < 8 then return nil end + return { x = left / w, y = top / h, w = rw / w, h = rh / h }, rw, rh +end + +-- Bring outside art into the skin's own folder. A skin still living in +-- assets/ or a mounted zip is saved out to /skins/ first, because +-- that is the only root the engine can write to. +function TouchSkin.importImage(skin, name, data) + if not skin then return nil, "no skin" end + if not data or data == "" then return nil, "no image data" end + if not (love and love.filesystem and love.filesystem.write) then + return nil, "no writable filesystem" + end + name = tostring(name):gsub("[^%w%._%-]", "_") + if name == "" then return nil, "bad file name" end + + local dest = TouchSkin.USER_ROOT .. "/" .. skin.id + if skin.root ~= dest then + local saved, err = TouchSkin.saveTo(skin, skin.id) + if not saved then return nil, tostring(err) end + end + pcall(wfs().createDirectory, dest .. "/img") + local rel = "img/" .. name + local ok, err = wfs().write(dest .. "/" .. rel, data) + if not ok then return nil, tostring(err) end + return rel +end + +function TouchSkin.listImages(root) + local out = {} + local function scan(dir, prefix) + for _, name in ipairs(listDir(dir)) do + local path = dir .. "/" .. name + local lower = name:lower() + if lower:match("%.png$") or lower:match("%.jpg$") or lower:match("%.jpeg$") then + out[#out + 1] = prefix .. name + elseif isDir(path) and prefix == "" then + scan(path, name .. "/") + end + end + end + scan(root, "") + table.sort(out) + return out +end + +function TouchSkin.saveTo(skin, id) + id = id or skin.id + if not id or id == "" then return nil, "no skin id" end + if not (love and love.filesystem and love.filesystem.write) then + return nil, "no writable filesystem" + end + local dest = TouchSkin.USER_ROOT .. "/" .. id + pcall(wfs().createDirectory, dest) + + local copied, failed = 0, {} + for _, rel in ipairs(TouchSkin.assetPaths(skin)) do + local target = dest .. "/" .. rel + local dir = target:match("^(.*)/[^/]+$") + if dir then pcall(wfs().createDirectory, dir) end + if skin.root ~= dest then + local data = readFile(joinPath(skin.root, rel)) + if data then + if wfs().write(target, data) then copied = copied + 1 end + else + failed[#failed + 1] = rel + end + end + end + + local ok, err = wfs().write(dest .. "/" .. TouchSkin.NATIVE_NAME, + TouchSkin.serialize(skin)) + if not ok then return nil, tostring(err) end + skin.id, skin.root, skin.format = id, dest, "native" + return dest, failed, copied +end + +TouchSkin.active = nil +TouchSkin.pageIndex = 1 +-- RetroArch Auto-Rotate Overlay (1.7.9, default on mobile): a cfg whose +-- pages are named portrait / landscape is swapped to match the display. +-- The Skin Studio turns this off so PAGE and the canvas preset stay independent. +TouchSkin.autoOrient = true + +TouchSkin.surfaceRect = nil + +function TouchSkin.setSurface(x, y, w, h) + if not w or w <= 0 or not h or h <= 0 then + TouchSkin.surfaceRect = nil + else + TouchSkin.surfaceRect = { x = x, y = y, w = w, h = h } + end + return TouchSkin.surfaceRect +end + +function TouchSkin.setActive(skin) + TouchSkin.active = skin or nil + TouchSkin.pageIndex = 1 + return TouchSkin.active +end + +function TouchSkin.select(id) + if not id or id == "" then return TouchSkin.setActive(nil) end + local entry = TouchSkin.find(id) + if not entry then return nil, "no skin " .. tostring(id) end + local skin, err = TouchSkin.load(entry.root, entry.id) + if not skin then return nil, err end + skin.source = entry.source + return TouchSkin.setActive(skin) +end + +local function displaySize() + local r = TouchSkin.surfaceRect + if r and r.w and r.h and r.w > 0 and r.h > 0 then return r.w, r.h end + if love and love.graphics and love.graphics.getDimensions then + return love.graphics.getDimensions() + end + return 0, 0 +end + +-- Explicit lock (studio) wins; otherwise the page name, the RetroArch +-- auto-rotate convention. "any" means unlocked even if the name says +-- portrait or landscape. +function TouchSkin.pageOrient(page) + if not page then return nil end + if page.orient == "any" then return nil end + if page.orient == "portrait" or page.orient == "landscape" then + return page.orient + end + local n = tostring(page.name or ""):lower() + if n:find("landscape", 1, true) then return "landscape" end + if n:find("portrait", 1, true) then return "portrait" end + return nil +end + +function TouchSkin.hasOrientPair(skin) + local saw = {} + for _, page in ipairs(skin and skin.pages or {}) do + local o = TouchSkin.pageOrient(page) + if o then saw[o] = true end + end + return saw.portrait == true and saw.landscape == true +end + +local function findOrientPage(skin, keyword) + for i, page in ipairs(skin.pages or {}) do + if TouchSkin.pageOrient(page) == keyword then return i end + end + return nil +end + +-- If the current page is the wrong orientation of a portrait/landscape pair, +-- jump to the matching one. Pages locked to neither (gb_anim's GameBoy / +-- GameBoyColor) are left alone. #1503 +function TouchSkin.syncOrientation(w, h) + if not TouchSkin.autoOrient then return end + local skin = TouchSkin.active + if not skin or not w or not h or w <= 0 or h <= 0 then return end + local want = w > h and "landscape" or "portrait" + local unwant = w > h and "portrait" or "landscape" + local page = skin.pages[TouchSkin.pageIndex] or skin.pages[1] + local current = TouchSkin.pageOrient(page) + if current == want then return end + if current ~= unwant then return end + local idx = findOrientPage(skin, want) + if idx then TouchSkin.pageIndex = idx end +end + +function TouchSkin.page() + local skin = TouchSkin.active + if not skin then return nil end + local w, h = displaySize() + TouchSkin.syncOrientation(w, h) + return skin.pages[TouchSkin.pageIndex] or skin.pages[1] +end + +function TouchSkin.setPage(target) + local skin = TouchSkin.active + if not skin then return nil end + if type(target) == "number" then + local n = #skin.pages + TouchSkin.pageIndex = ((math.floor(target) - 1) % n) + 1 + return TouchSkin.page() + end + for i, page in ipairs(skin.pages) do + if page.name == target then + TouchSkin.pageIndex = i + return page + end + end + return TouchSkin.page() +end + +function TouchSkin.nextPage(target) + local skin = TouchSkin.active + if not skin then return nil end + if target and target ~= "" then return TouchSkin.setPage(target) end + return TouchSkin.setPage(TouchSkin.pageIndex + 1) +end + +function TouchSkin.pageBox(page, w, h, ox, oy) + ox, oy = ox or 0, oy or 0 + if not page then return ox, oy, w, h end + local bx, by, bw, bh = ox, oy, w, h + -- full_screen means "relative to the window, not the game viewport". + -- When the cfg also names an aspect_ratio, that window is then fitted + -- to the overlay's design aspect so buttons do not stretch. #1503 + local fit = ((not page.fullScreen) or page.aspectFromCfg or page.aspectFromImage) + and page.aspect and page.aspect > 0 and h > 0 + if fit then + local displayAspect = w / h + local anchor = page.anchor + if displayAspect > page.aspect then + bw = h * page.aspect + local extra = w - bw + if anchor == "right" then + bx = ox + extra + elseif anchor == "left" then + bx = ox + else + bx = ox + extra * 0.5 + end + else + bh = w / page.aspect + local extra = h - bh + if anchor == "bottom" then + by = oy + extra + elseif anchor == "top" then + by = oy + elseif page.aspect < 1 then + -- A portrait bezel with controls is a controller deck. On an + -- unusually tall display, pin the deck to the lower edge and leave + -- the additional room for the game above it. + by = oy + extra + else + by = oy + extra * 0.5 + end + end + end + local r = page.rect + return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh +end + +function TouchSkin.imageFit(iw, ih, w, h) + if not iw or not ih or iw <= 0 or ih <= 0 then return nil end + return w / iw, h / ih +end + +function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) + local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy) + local cx, cy = bx + ctl.x * bw, by + ctl.y * bh + local halfW, halfH = ctl.rangeX * bw, ctl.rangeY * bh + return cx, cy, halfW, halfH +end + +function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy, held) + local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) + local mod = held == false and 1 or ctl.rangeMod + local left = halfW * ctl.reachLeft * mod + local right = halfW * ctl.reachRight * mod + local up = halfH * ctl.reachUp * mod + local down = halfH * ctl.reachDown * mod + local dx = px - cx + local dy = py - cy + local rx = dx < 0 and left or right + local ry = dy < 0 and up or down + if rx <= 0 or ry <= 0 then return false end + if ctl.sector and not TouchSkin.sectorHit(ctl.sector, dx, dy) then + return false + end + if ctl.shape == "radial" then + return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1 + end + return math.abs(dx) <= rx and math.abs(dy) <= ry +end + +TouchSkin.overlayLive = false + +function TouchSkin.setOverlayLive(on) + TouchSkin.overlayLive = on and true or false +end + +function TouchSkin.decorativeOnly() + local page = TouchSkin.page() + if not page then return false end + for _, ctl in ipairs(page.controls) do + if not ctl.decorative then return false end + end + return true +end + +function TouchSkin.drawable() + -- A selected skin is a presentation choice, not a mobile-only input mode. + -- Its artwork and screen placement therefore belong on every platform; + -- `overlayLive` still controls whether touch input is available. + return TouchSkin.active ~= nil +end + +function TouchSkin.hasViewport() + local page = TouchSkin.page() + if not page or not TouchSkin.drawable() then return false end + return page.viewport ~= nil or page.screenFit == "remainder" +end + +-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box. +local function remainderBox(ox, oy, w, h, bx, by, bw, bh) + local right, bottom = ox + w, oy + h + local cand = { + { ox, oy, w, by - oy }, + { ox, by + bh, w, bottom - (by + bh) }, + { ox, oy, bx - ox, h }, + { bx + bw, oy, right - (bx + bw), h }, + } + local best, bestArea + for _, r in ipairs(cand) do + if r[3] > 1 and r[4] > 1 then + local area = r[3] * r[4] + if not best or area > bestArea then + best, bestArea = r, area + end + end + end + if not best then return nil end + return best[1], best[2], best[3], best[4] +end + +-- The deck box pinned to the lower edge (see pageBox) leaves room above it +-- that belongs to the game, so a screen rect flush with the top of the deck +-- grows into it instead of showing a black band. +local function deckHeadroom(y, vh, by, bh, oy, h) + if by <= oy + 0.5 then return y, vh end + if by + bh < oy + h - 0.5 then return y, vh end + if y - by > math.max(2, bh * 0.01) then return y, vh end + return oy, vh + (y - oy) +end + +function TouchSkin.pageViewport(page, w, h, ox, oy) + if not page then return nil end + ox, oy = ox or 0, oy or 0 + local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy) + if page.viewport then + local v = page.viewport + local x, y = bx + v.x * bw, by + v.y * bh + local vw, vh = v.w * bw, v.h * bh + if vw <= 0 or vh <= 0 then return nil end + y, vh = deckHeadroom(y, vh, by, bh, oy, h) + return x, y, vw, vh, page.viewportFill == true + end + if page.screenFit == "remainder" then + local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh) + if not x then return nil end + return x, y, vw, vh, false + end + return nil +end + +-- Centre of the page's screen cutout. The renderer fits the 160x144 +-- picture into that rect; this helper is for the studio preview. +function TouchSkin.screenCenter(w, h, ox, oy, page) + page = page or TouchSkin.page() + if not page then return nil end + local x, y, vw, vh = TouchSkin.pageViewport(page, w, h, ox, oy) + if not x then return nil end + return x + vw * 0.5, y + vh * 0.5 +end + +function TouchSkin.viewport(w, h, ox, oy) + local page = TouchSkin.page() + if not page or not TouchSkin.drawable() then return nil end + return TouchSkin.pageViewport(page, w, h, ox, oy) +end + +return TouchSkin 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 858974555..a39b422bb 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1680,6 +1680,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") @@ -2125,8 +2143,9 @@ local function commandOutput(command) releasePointerGrab() local pipe = HostShell.popen(command) if not pipe then return nil end - local result = pipe:read("*a") - pipe:close() + local ok, result = pcall(pipe.read, pipe, "*a") + pcall(pipe.close, pipe) + if not ok or type(result) ~= 'string' then return nil end result = trim(result) return result ~= "" and result or nil end @@ -2699,73 +2718,14 @@ end -- The extension list comes from the manifest rather than being fixed, because -- what a mod needs is its own business: STADIUM2_IMPORTER wants a Nintendo 64 -- cartridge in any of its three byte orders (.z64/.n64/.v64). -local function chooseFileByExt(exts, promptName) - if type(exts) ~= "table" or #exts == 0 then return nil end - local prompt = Strings("Choose your %s", promptName or "file") - local platform = love.system.getOS() - local quoted, globs, semis = {}, {}, {} - for index, ext in ipairs(exts) do - quoted[index] = '"' .. ext .. '"' - globs[index] = "*." .. ext - semis[index] = "*." .. ext - end - if platform == "OS X" then - return commandOutput( - ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]]) - :format(prompt, table.concat(quoted, ", "))) - elseif platform == "Windows" then - local script = table.concat({ - "Add-Type -AssemblyName System.Windows.Forms;", - "$d=New-Object System.Windows.Forms.OpenFileDialog;", - "$d.Title='" .. prompt .. "';", - "$d.Filter='Base file (" .. table.concat(semis, ";") .. ")|" - .. table.concat(semis, ";") .. "|All files (*.*)|*.*';", - -- THE TEMP COPY IS THE FALLBACK NOW, NOT THE DEFAULT. - -- - -- io.open on Windows needs ANSI bytes, so a path with characters outside - -- them cannot be opened at all (#325) and the pick has to be copied to - -- an ASCII temp name first. That was done for EVERY pick, on the - -- reasoning that "a cartridge is tens of megabytes, so this copy is the - -- one slow step" -- which stopped being true the moment a mod could - -- declare a GameCube disc. A 1.4 GB pick was copied into %TEMP% on the - -- system drive before anything looked at it: minutes of apparent freeze, - -- 1.4 GB of C: needed to import a file already sitting on G:, and -- the - -- reported failure -- Copy-Item leaves $ErrorActionPreference at - -- Continue, so a copy that ran out of space still fell through to - -- writing the temp path, and the launcher opened a file that was never - -- created. "that file could not be opened", for a file that was right - -- there. - -- - -- So: an ASCII path is handed back AS IS, which is every ordinary path - -- and costs nothing. Only a path io.open genuinely cannot take is - -- copied, and that copy now has to prove it landed before its - -- destination is reported -- otherwise nothing is written and the - -- caller falls through to the "copy it into imports/" instructions, - -- which at least tell the player what to do next. - "if($d.ShowDialog() -eq 'OK'){", - "$p=$d.FileName;", - "$ascii=$true;", - "foreach($c in $p.ToCharArray()){", - "if([int]$c -lt 32 -or [int]$c -gt 126){$ascii=$false}};", - "[Console]::OutputEncoding=[Text.Encoding]::UTF8;", - "if($ascii){[Console]::Write($p)}else{", - "$t=Join-Path $env:TEMP 'pokeport_base_pick.bin';", - "Copy-Item -LiteralPath $p -Destination $t -Force " - .. "-ErrorAction SilentlyContinue;", - "if(Test-Path -LiteralPath $t){[Console]::Write($t)}}}", - }) - return commandOutput( - 'powershell -NoProfile -STA -Command "' .. script .. '"') - elseif platform == "Linux" then - local path = commandOutput( - ([[zenity --file-selection --title="%s" --file-filter="Base file | %s" 2>/dev/null]]) - :format(prompt, table.concat(globs, " "))) - if path then return path end - return commandOutput( - ([[kdialog --getopenfilename "$HOME" "%s|Base file" 2>/dev/null]]) - :format(table.concat(globs, " "))) +local function chooseFileByExt(exts, promptName, opts) + opts = opts or {} + local prompt = opts.title or Strings("Choose your %s", promptName or "file") + local commands = require('src.core.FilePicker').commands(prompt, {label=opts.label or 'Base file',exts=exts}, opts.nativeUTF8) + for _,command in ipairs(commands or {}) do + -- One selected backend: cancelling its dialog must not open another one. + return commandOutput(command) end - return nil end -- PUBLISHED, because the map editor needs a picker too. @@ -2861,6 +2821,7 @@ function RomImporter.new(onComplete, opts) forceImport = opts.forceImport or false, onEditSave = opts.onEditSave, onEditTouchControls = opts.onEditTouchControls, + onEditTouchSkins = opts.onEditTouchSkins, onEditMaps = opts.onEditMaps, android = android, -- Read by the three console-inbox branches below. It was never assigned, @@ -5007,6 +4968,27 @@ function RomImporter:_activatePadCursor() self._padCursorActive = true end +-- Native overlay handoff: a release may be consumed by the editor rather +-- than this suspended launcher. Drop gesture/axis ownership at both edges, +-- retaining only cursor position and whether the player was using a pad. +function RomImporter:prepareOverlayHandoff() + self._overlayPadActive = self._padCursorActive == true + self._slotPress, self._modPress, self._slotDrag = nil, nil, nil + self._touchGesture = nil + self._padAxis, self._padDir, self._rawHatDirs = {}, {}, {} + self._padCursorActive = false + self._lastMouseX, self._lastMouseY = nil, nil +end + +function RomImporter:resumeAfterOverlay() + self._slotPress, self._modPress, self._slotDrag = nil, nil, nil + self._touchGesture = nil + self._padAxis, self._padDir, self._rawHatDirs = {}, {}, {} + self._padCursorActive = self._overlayPadActive == true + self._overlayPadActive = nil + self._lastMouseX, self._lastMouseY = nil, nil +end + -- THE ROW AS IT IS ON SCREEN, not a list written when there were three games. -- -- This named red / blue / yellow / mods / find and nothing else, so a gamepad @@ -5690,7 +5672,13 @@ function RomImporter:draw() if link.url==DONATE_URL then paypalLink=link else footerLinks[#footerLinks+1]=link end end - local creditH = self.hintFont:getHeight() + local creditText = 'Created by '..credit.by + ..'\nBased on the Pokemon Gen 1 Recompilation Project by BOIS CLUB GAMES, LLC' + ..'\n(https://github.com/bryanthaboi/gen1recomp)' + ..'\nContributions by Roxas2712 — https://github.com/Roxas2712' + local creditWidth = math.max(1, appW - 2 * padH) + local _, creditLines = self.hintFont:getWrap(creditText, creditWidth) + local creditH = #creditLines * self.hintFont:getHeight() local donateH = self.hintFont:getHeight() + 10 * s local chipsH = (#footerLinks > 0) and (8 * s + donateH) or 0 local footerH = 10 * s + bcgDH + 6 * s + warningH + 8 * s + creditH + chipsH + 12 * s @@ -6225,7 +6213,7 @@ function RomImporter:draw() love.graphics.setFont(self.hintFont) local creditY = warningY + warningH + 8 * s col(PAL.warning) - love.graphics.printf("Created by " .. credit.by, appX, creditY, appW, "center") + love.graphics.printf(creditText, appX + padH, creditY, creditWidth, "center") self.donateButtons = {} self.donateButton = nil if #footerLinks > 0 then @@ -8675,6 +8663,9 @@ function RomImporter:_settingsRows() add({ kind = "action", label = "EDIT LAYOUT", value = "OPEN", action = "touchLayout" }) end + if self.onEditTouchSkins then + add({kind='action',label='SKIN LIBRARY / EDITOR',value='OPEN',action='touchSkins'}) + end -- MOD OPTIONS, one section per installed mod that declares any. Read through -- LauncherMods, which parses the mod's declared schema FILE rather than @@ -9192,6 +9183,8 @@ function RomImporter:_settingsAction(entry) self._settingsRowCache=nil elseif action == "touchLayout" then if self.onEditTouchControls then self.onEditTouchControls() end + elseif action == 'touchSkins' then + if self.onEditTouchSkins then self.onEditTouchSkins() end elseif action == "launcherPage" then self.settingsPage = "launcher" self.settingsScroll = 0 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..0fe2cd6ad --- /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.pointer 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.output render.output_enabled 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", "render.viewport render.window 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..13ab6e048 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,41 @@ 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 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 +1199,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 +1276,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 +1394,12 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + local viewport = package.loaded["src.render.GameViewport"] + if viewport then viewport.reset() end + 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 +1743,12 @@ 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() + local viewport = package.loaded["src.render.GameViewport"] + if viewport then viewport.reset() end + 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..f28caeadd --- /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.pointer 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.output render.output_enabled render.viewport render.window 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/Pokemon.lua b/src/pokemon/Pokemon.lua index 79c91e7fe..2ed5e2137 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -467,10 +467,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/GameViewport.lua b/src/render/GameViewport.lua new file mode 100644 index 000000000..14c0bdada --- /dev/null +++ b/src/render/GameViewport.lua @@ -0,0 +1,253 @@ +-- Optional game rectangle inside the OS window. No canvas on the default path. +local Runtime = require("src.mods.Runtime") +local Scope = require("src.render.GraphicsScope") +local Logger = require("src.core.Logger") +local V = {} +local function finite(v) + return type(v) == "number" and v == v and math.abs(v) < math.huge +end +local function release() + if V.canvas then + if love and love.graphics and love.graphics.getCanvas and love.graphics.getCanvas() == V.canvas then + love.graphics.setCanvas() + end + if V.canvas.release then + pcall(V.canvas.release, V.canvas) + end + end + V.canvas = nil +end +function V.reset() + if V.inFrame then + V.retiring = true + return + end + release() + V.skinFill = false + V.rect = nil + V.full = nil + V.generation = nil + V.frameActive = false + V.retiring = nil + V.failedSize = nil +end +local function rect(value, w, h) + if type(value) ~= "table" or getmetatable(value) ~= nil then + value = {} + end + local function field(k, default) + local v = rawget(value, k) + return finite(v) and math.floor(v) or default + end + local x, y = field("x", 0), field("y", 0) + x = math.max(0, math.min(x, w - 1)) + y = math.max(0, math.min(y, h - 1)) + return { + x = x, + y = y, + width = math.max(1, math.min(field("width", w), w - x)), + height = math.max(1, math.min(field("height", h), h - y)), + }, + rawget(value, "capture") == true +end +function V.begin(generation, allowed) + -- A previous frame can have been interrupted by a state error or launcher exit. + V.inFrame = false + if V.retiring then + V.reset() + end + local G = love.graphics + local w, h = G.getDimensions() + w = finite(w) and math.max(1, w) or 1 + h = finite(h) and math.max(1, h) or 1 + local pw, ph = w, h + if G.getPixelDimensions then + pw, ph = G.getPixelDimensions() + end + pw = finite(pw) and math.max(1, pw) or w + ph = finite(ph) and math.max(1, ph) or h + local context = + { width = w, height = h, pixelWidth = pw, pixelHeight = ph, dpiX = pw / w, dpiY = ph / h, generation = generation } + local requested, skinFill + if allowed ~= false then + local Skin = require("src.core.TouchSkin") + local ok, x, y, sw, sh, fill = pcall(Skin.viewport, w, h) + if ok and finite(x) and finite(y) and finite(sw) and finite(sh) and sw > 0 and sh > 0 then + requested = { x = x, y = y, width = sw, height = sh } + skinFill = fill == true + end + end + local skinRequest = requested + if allowed ~= false and Runtime.wantsHook("render.viewport") then + local ok, value = Scope.call(function() + return Runtime.call("render.viewport", function() + return skinRequest or { x = 0, y = 0, width = w, height = h } + end, context) + end) + if ok then + requested = value + else + Logger.warn("render viewport failed: %s", tostring(value)) + end + end + -- Retain real metrics independently of mutations to the hook argument. + V.full = + { width = w, height = h, pixelWidth = pw, pixelHeight = ph, dpiX = pw / w, dpiY = ph / h, generation = generation } + local r, capture = rect(requested, w, h) + local active = capture or r.x ~= 0 or r.y ~= 0 or r.width ~= w or r.height ~= h + V.rect, V.generation, V.frameActive = r, generation, active + V.skinFill = skinFill == true and requested == skinRequest + if active then + local key = table.concat({ r.width, r.height, pw / w, ph / h }, ":") + if V.failedSize ~= key then + V.failedSize = nil + end + if V.failedSize == key then + active = false + elseif + not V.canvas + or V.canvas:getWidth() ~= r.width + or V.canvas:getHeight() ~= r.height + or V.canvasDpiX ~= pw / w + or V.canvasDpiY ~= ph / h + then + release() + local ok, canvas = pcall(G.newCanvas, r.width, r.height) + if ok and canvas then + V.canvas = canvas + canvas:setFilter("nearest", "nearest") + V.canvasDpiX, V.canvasDpiY = pw / w, ph / h + else + V.failedSize = key + active = false + Logger.warn("render viewport unavailable: %s", tostring(canvas)) + end + end + else + release() + V.failedSize = nil + end + if not active then + V.frameActive = false + V.skinFill = false + V.rect = { x = 0, y = 0, width = w, height = h } + end + V.inFrame = true + return V.rect +end +function V.active() + return V.frameActive == true and V.canvas ~= nil +end +function V.dimensions() + if V.active() then + return V.rect.width, V.rect.height + end + return love.graphics.getDimensions() +end +function V.pixelDimensions() + if V.active() then + if V.canvas.getPixelDimensions then + return V.canvas:getPixelDimensions() + end + return math.max(1, math.floor(V.rect.width * V.full.dpiX)), + math.max(1, math.floor(V.rect.height * V.full.dpiY)) + end + if love.graphics.getPixelDimensions then + return love.graphics.getPixelDimensions() + end + return love.graphics.getDimensions() +end +function V.fullDimensions() + if V.full then + return V.full.width, V.full.height + end + return love.graphics.getDimensions() +end +function V.target() + return V.active() and V.canvas or nil +end +function V.setTarget() + love.graphics.setCanvas(V.target()) +end +function V.toLocal(x, y) + local r = V.rect + if not r then + return x, y, true + end + local lx, ly = x - r.x, y - r.y + return lx, ly, lx >= 0 and ly >= 0 and lx < r.width and ly < r.height +end +function V.localSafeRect(x, y, w, h) + if not V.active() then + return x, y, w, h + end + local r = V.rect + local x1, y1 = math.max(x, r.x), math.max(y, r.y) + local x2, y2 = math.min(x + w, r.x + r.width), math.min(y + h, r.y + r.height) + if x2 <= x1 or y2 <= y1 then + return 0, 0, r.width, r.height + end + return x1 - r.x, y1 - r.y, x2 - x1, y2 - y1 +end +local function prepare() + local G = love.graphics + G.setCanvas() + G.origin() + G.setScissor() + G.setShader() + G.setBlendMode("alpha") + G.setColor(1, 1, 1, 1) +end +function V.finish(game) + if V.active() then + local G = love.graphics + local r, full = V.rect, V.full + local context = { + canvas = V.canvas, + x = r.x, + y = r.y, + width = r.width, + height = r.height, + windowWidth = full.width, + windowHeight = full.height, + dpiX = full.dpiX, + dpiY = full.dpiY, + generation = V.generation, + } + G.setCanvas() + local function vanilla(_, ctx) + -- A failing outer link must not taint the default draw reached by next. + local canvas = type(ctx) == "table" and rawget(ctx, "canvas") + local x = type(ctx) == "table" and rawget(ctx, "x") + local y = type(ctx) == "table" and rawget(ctx, "y") + if not canvas or not finite(x) or not finite(y) then + error("invalid render.window context", 0) + end + local ok, why = Scope.call(function() + prepare() + G.draw(canvas, x, y) + end) + if not ok then + error(why, 0) + end + end + local ok, why = Scope.call(function() + prepare() + G.clear(0, 0, 0, 1) + return Runtime.call("render.window", vanilla, game, context) + end) + if not ok then + Logger.warn("render window failed: %s", tostring(why)) + Scope.call(function() + prepare() + G.clear(0, 0, 0, 1) + G.draw(V.canvas, r.x, r.y) + end) + end + end + V.inFrame = false + if V.retiring then + V.reset() + end +end +return V diff --git a/src/render/GraphicsScope.lua b/src/render/GraphicsScope.lua new file mode 100644 index 000000000..931fe14c6 --- /dev/null +++ b/src/render/GraphicsScope.lua @@ -0,0 +1,50 @@ +-- Fence a native render seam, including leaked pushes and canvas changes. +local S = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +function S.call(fn, ...) + local G = love.graphics + local canvas = pack(G.getCanvas()) + local realPush, realPop = G.push, G.pop + local before = G.getStackDepth and G.getStackDepth() + local pushed, why = pcall(realPush, "all") + if not pushed then + return false, why + end + local depth = 0 + G.push = function(...) + local result = pack(realPush(...)) + depth = depth + 1 + return unpack(result, 1, result.n) + end + G.pop = function(...) + if depth <= 0 then + error("render callback cannot pop caller graphics state", 0) + end + local result = pack(realPop(...)) + depth = depth - 1 + return unpack(result, 1, result.n) + end + local out = pack(pcall(fn, ...)) + G.push, G.pop = realPush, realPop + if before and G.getStackDepth then + local current = G.getStackDepth() + if current < before + 1 then + G.setCanvas(unpack(canvas, 1, canvas.n)) + return false, "render callback removed its graphics guard" + end + depth = current - before - 1 + end + for _ = 1, depth do + pcall(realPop) + end + local restored, err = pcall(realPop) + G.setCanvas(unpack(canvas, 1, canvas.n)) + if not restored then + return false, err + end + return unpack(out, 1, out.n) +end +return S diff --git a/src/render/Output.lua b/src/render/Output.lua new file mode 100644 index 000000000..16a9237a0 --- /dev/null +++ b/src/render/Output.lua @@ -0,0 +1,37 @@ +-- Final-output ownership comes after composition and before display effects. +local Runtime = require("src.mods.Runtime") +local Scope = require("src.render.GraphicsScope") +local Logger = require("src.core.Logger") +local O = {} +function O.enabled() + if not Runtime.wantsHook("render.output") then + return false + end + local ok, value = Scope.call(function() + return Runtime.call("render.output_enabled", function() + return false + end) + end) + if not ok then + Logger.warn("render output gate failed: %s", tostring(value)) + end + return ok and value == true +end +function O.draw(ctx) + local ok, value = Scope.call(function() + local G = love.graphics + G.origin() + G.setShader() + G.setScissor() + G.setBlendMode("alpha") + G.setColor(1, 1, 1, 1) + return Runtime.call("render.output", function() + return false + end, ctx) + end) + if not ok then + Logger.warn("render output failed: %s", tostring(value)) + end + return ok and value == true +end +return O diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7cff87285..e251985f6 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,8 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +local Output = require("src.render.Output") +local GameViewport = require("src.render.GameViewport") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -85,10 +87,10 @@ Renderer.UPRIGHT_MARGIN = 160 -- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels -- on BOTH axes (square). local function displayMetrics() - local ww, wh = love.graphics.getDimensions() + local ww, wh = GameViewport.dimensions() local pw, ph = ww, wh if love.graphics.getPixelDimensions then - pw, ph = love.graphics.getPixelDimensions() + pw, ph = GameViewport.pixelDimensions() end local dpiX, dpiY = 1, 1 if ww > 0 and pw > 0 then dpiX = pw / ww end @@ -109,6 +111,7 @@ local function displayMetrics() end function Renderer:init() + GameViewport.reset() -- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua -- (#208). Every canvas below is sized in framebuffer pixels for the same -- reason -- worldViewSize() already works in drawable pixels. @@ -827,7 +830,7 @@ end -- When GBC FX is active the composite is drawn into presentCanvas and -- presented through the GBC FX shader as a final pass. function Renderer:endFrame(zones, worldZones) - love.graphics.setCanvas() + GameViewport.setTarget() local ww, wh, pw, ph, dpiX, dpiY = displayMetrics() -- Sp = integer framebuffer pixels per GB pixel; -- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY). @@ -848,7 +851,7 @@ function Renderer:endFrame(zones, worldZones) -- GB pixel stops being a whole number of screen pixels, which is the trade -- the setting exists to offer. Clamped on the horizontal too, so a narrow -- window scales to fit instead of overflowing off both sides. - if self.uiFill or self.splitViewport then + if self.uiFill or self.splitViewport or GameViewport.skinFill then Up = math.min(ph / uih, pw / uiw) end local Ux, Uy = Up / dpiX, Up / dpiY @@ -929,8 +932,9 @@ function Renderer:endFrame(zones, worldZones) and (self.battleWipe.style == "frlg_swirl" or self.battleWipe.needsSource) and self.battleWipe.screenDraw + local hasOutputHook = Output.enabled() local needPresent = GBCFX.active() or Pipelines.wantsPresent() - or battleNeedsSource + or battleNeedsSource or hasOutputHook local present = nil if needPresent then if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww @@ -943,6 +947,7 @@ function Renderer:endFrame(zones, worldZones) -- what remains here is the dpiX vs dpiY truncation gap (well under 1%, -- one seam across the window) that a single scalar dpiscale cannot -- express. + if self.presentCanvas and self.presentCanvas.release then self.presentCanvas:release() end self.presentCanvas = love.graphics.newCanvas(ww, wh) -- NEAREST, like every other surface in this pipeline. -- @@ -1393,7 +1398,7 @@ function Renderer:endFrame(zones, worldZones) end if present then - love.graphics.setCanvas() + GameViewport.setTarget() -- Post-process pipelines run over the finished composite -- world, UI -- and all -- and before GBC FX, so a blur or colour grade is what the -- LCD grid is then drawn over rather than something that smears the @@ -1401,7 +1406,15 @@ function Renderer:endFrame(zones, worldZones) -- this returns `present` unchanged and the frame is byte-identical. local composed = Pipelines.present(present, { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present - if GBCFX.active() then + local outputHandled = hasOutputHook and Output.draw({ + canvas = composed, width = ww, height = wh, + gameX = ox, gameY = oy, gameWidth = vpw, gameHeight = vph, + scale = Sp, dpiX = dpiX, dpiY = dpiY, + generation = require("src.core.GameVersion").generation(), + }) + if outputHandled then + -- The enabled owner presented the composed frame. + elseif GBCFX.active() then -- shader grid/shadow math is in framebuffer pixels GBCFX.present(composed, Sp) else 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 568ad5dfc..983bc3fc1 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 @@ -554,7 +554,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 @@ -1221,21 +1221,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/ @@ -1265,6 +1268,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") @@ -1314,6 +1318,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 4d229471d..4020375b6 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -1234,11 +1234,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 ce9163880..851b8ec29 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -876,6 +876,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 @@ -908,23 +930,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/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/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..7a1ec4ae7 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -522,6 +522,48 @@ local function buildRows(game) return true end }, } + do + local osName = love.system and love.system.getOS and love.system.getOS() + if osName == 'Android' or osName == 'iOS' or os.getenv('POKEPORT_TOUCH') == '1' then + rows[#rows + 1] = { id = 'hotbar', label = Strings('TOUCH HOTBAR'), + value = function(g) return g.save.options.hotbar ~= false and Strings('ON') or Strings('OFF') end, + step = function(g) + g.save.options.hotbar = g.save.options.hotbar == false + require('src.core.TouchControls'):applyOptions(g.save.options) + return true + end } + rows[#rows + 1] = { id = 'haptics', label = Strings('HAPTICS'), + value = function(g) return require('src.core.TouchControls').hapticLabel(g.save.options.haptics) end, + step = function(g, dir) + local Controls = require('src.core.TouchControls') + g.save.options.haptics = Controls.cycleHaptics(g.save.options.haptics, dir or 1) + Controls:applyOptions(g.save.options) + Controls.buzz(g.save.options.haptics) + return true + end } + end + end + -- The same installed library is usable as mobile controls or a desktop bezel. + local skins = require('src.core.TouchSkin').list() + if #skins > 0 then + rows[#rows + 1] = { id = 'touchSkin', label = Strings('TOUCH SKIN'), + value = function(g) + local tc = g.save.options.touchControls + return type(tc) == 'table' and tc.skin or Strings('DEFAULT') + end, + step = function(g, dir) + local tc = g.save.options.touchControls + tc = type(tc) == 'table' and tc or {} + local index = 0 + for i, skin in ipairs(skins) do if skin.id == tc.skin then index = i; break end end + index = (index + (dir or 1)) % (#skins + 1) + tc.skin = index > 0 and skins[index].id or nil + if tc.skin then tc.enabled = true end + g.save.options.touchControls = tc + require('src.core.TouchControls'):applyOptions(g.save.options) + return true + end } + end -- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks if not GBCFX.isSupported() then local filtered = {} diff --git a/src/ui/PadCursor.lua b/src/ui/PadCursor.lua new file mode 100644 index 000000000..4348df823 --- /dev/null +++ b/src/ui/PadCursor.lua @@ -0,0 +1,283 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Virtual pointer for overlay hosts (save editor, touch-controls editor) on +-- Switch / handhelds / any gamepad. Mirrors the launcher's RomImporter pad +-- cursor (speeds, deadzone, dual-path raw gate) without sharing that module. +-- +-- Stick / D-pad move; real mouse motion yields so desktop stays normal. +-- Callers map A → click, B → close, shoulders → host-specific actions, right +-- stick → wheel notches (save editor lists). + +local SafeArea = require("src.core.SafeArea") +local GamepadMap = require("src.core.GamepadMap") + +local PAD_DEAD = 0.28 +local PAD_SPEED = 560 +local PAD_DPAD_SPEED = 420 +-- Right stick → Kit wheel notches: ~2 notches/sec at full deflection so lists +-- scroll at a usable pace without flooding one frame. +local PAD_WHEEL_RATE = 2.0 +local TRIGGER_NOTCHES = 3 +local TRIGGER_REPEAT_DELAY = 0.35 +local TRIGGER_REPEAT_EVERY = 0.08 +local WHEEL_CAP = 12 + +local PadCursor = {} + +local cursor = { x = 0, y = 0 } +local active = false +local inited = false +local axis = { leftx = 0, lefty = 0, righty = 0 } +local dir = {} +local rawHatDirs = {} +local lastMouseX, lastMouseY +local wheelAcc = 0 +local triggers = {} + +local function addWheel(n) + wheelAcc = math.max(-WHEEL_CAP, math.min(WHEEL_CAP, wheelAcc + n)) +end + +local function activate() + if active then return end + local ox, oy, w, h = SafeArea.rect() + if not inited then + cursor.x = ox + w * 0.5 + cursor.y = oy + h * 0.45 + inited = true + end + active = true +end + +function PadCursor.reset() + cursor.x, cursor.y = 0, 0 + active = false + inited = false + axis.leftx, axis.lefty, axis.righty = 0, 0, 0 + for k in pairs(dir) do dir[k] = nil end + for k in pairs(rawHatDirs) do rawHatDirs[k] = nil end + lastMouseX, lastMouseY = nil, nil + wheelAcc = 0 + for k in pairs(triggers) do triggers[k] = nil end +end + +local function triggerDown(side, src) + local t = triggers[side] + if t then + t.src[src] = true + return + end + activate() + triggers[side] = { held = 0, rep = 0, src = { [src] = true } } + addWheel(side == "l" and TRIGGER_NOTCHES or -TRIGGER_NOTCHES) +end + +local function triggerUp(side, src) + local t = triggers[side] + if not t then return end + t.src[src] = nil + if next(t.src) == nil then triggers[side] = nil end +end + +local function triggerAxis(side, src, value) + if value > GamepadMap.TRIGGER_ON then + triggerDown(side, src) + elseif value < GamepadMap.TRIGGER_OFF then + triggerUp(side, src) + end +end + +local TRIGGER_SIDE = { + triggerleft = "l", lefttrigger = "l", l2 = "l", + triggerright = "r", righttrigger = "r", r2 = "r", +} + +-- Touch / mouse press: drop the virtual cursor for this interaction so a tap +-- is not swallowed by the Joy-Con pointer sitting elsewhere on screen. +function PadCursor.yieldToPointer() + active = false +end + +-- Returns mx, my, isActive. When inactive the caller should use the system +-- mouse; when active these coords feed hit-tests / draws. +function PadCursor.pointer() + return cursor.x, cursor.y, active +end + +function PadCursor.isActive() + return active +end + +-- Consume accumulated right-stick scroll as integer wheel notches (same +-- units App.wheelmoved feeds Kit). Fractional remainder stays for next frame. +function PadCursor.takeWheel() + local notches = 0 + if wheelAcc >= 1 or wheelAcc <= -1 then + notches = wheelAcc > 0 and math.floor(wheelAcc) or math.ceil(wheelAcc) + wheelAcc = wheelAcc - notches + end + return notches +end + +function PadCursor.update(dt) + if not (love and love.mouse and love.mouse.getPosition) then return end + local mx, my = love.mouse.getPosition() + if lastMouseX and active then + if math.abs(mx - lastMouseX) > 3 or math.abs(my - lastMouseY) > 3 then + active = false + end + end + lastMouseX, lastMouseY = mx, my + + local ax = axis.leftx or 0 + local ay = axis.lefty or 0 + local dx, dy = 0, 0 + if math.abs(ax) > PAD_DEAD then dx = dx + ax end + if math.abs(ay) > PAD_DEAD then dy = dy + ay end + if dir.dpleft then dx = dx - 1 end + if dir.dpright then dx = dx + 1 end + if dir.dpup then dy = dy - 1 end + if dir.dpdown then dy = dy + 1 end + + if dx ~= 0 or dy ~= 0 then + activate() + local mag = math.sqrt(dx * dx + dy * dy) + if mag > 1 then dx, dy = dx / mag, dy / mag end + local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD) + and PAD_SPEED or PAD_DPAD_SPEED + local ox, oy, w, h = SafeArea.rect() + local nx = cursor.x + dx * speed * dt + local ny = cursor.y + dy * speed * dt + cursor.x = math.max(ox, math.min(ox + w, nx)) + cursor.y = math.max(oy, math.min(oy + h, ny)) + end + + local ry = axis.righty or 0 + if math.abs(ry) > PAD_DEAD then + activate() + -- Negative righty (stick up) scrolls lists up = positive wheel notches. + addWheel((-ry) * PAD_WHEEL_RATE * dt) + end + + for side, t in pairs(triggers) do + t.held = t.held + dt + if t.held >= TRIGGER_REPEAT_DELAY then + t.rep = t.rep + dt + while t.rep >= TRIGGER_REPEAT_EVERY do + t.rep = t.rep - TRIGGER_REPEAT_EVERY + addWheel(side == "l" and 1 or -1) + end + end + end +end + +-- Returns a string action the host handles: +-- "a" | "b" | "tab_prev" | "tab_next" | nil +function PadCursor.gamepadpressed(_, button) + activate() + local action = GamepadMap.mapGamepadButton(button) + if action == "a" or action == "b" then + return action + elseif button == "leftshoulder" then + return "tab_prev" + elseif button == "rightshoulder" then + return "tab_next" + elseif TRIGGER_SIDE[button] then + triggerDown(TRIGGER_SIDE[button], "button") + elseif button == "dpup" or button == "dpdown" + or button == "dpleft" or button == "dpright" then + dir[button] = true + end + return nil +end + +function PadCursor.gamepadreleased(_, button) + if TRIGGER_SIDE[button] then + triggerUp(TRIGGER_SIDE[button], "button") + elseif button == "dpup" or button == "dpdown" + or button == "dpleft" or button == "dpright" then + dir[button] = nil + end +end + +function PadCursor.gamepadaxis(_, axisName, value) + if axisName == "leftx" or axisName == "lefty" or axisName == "righty" then + axis[axisName] = value + if math.abs(value) > PAD_DEAD then activate() end + elseif TRIGGER_SIDE[axisName] then + triggerAxis(TRIGGER_SIDE[axisName], "axis", value) + end +end + +function PadCursor.joystickpressed(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return nil end + if GamepadMap.isAccelerometer(joystick) then return nil end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton then return PadCursor.gamepadpressed(joystick, padButton) end + return nil +end + +function PadCursor.joystickreleased(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton then PadCursor.gamepadreleased(joystick, padButton) end +end + +function PadCursor.joystickaxis(joystick, axisIndex, value) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end + if axisIndex == 1 then + PadCursor.gamepadaxis(joystick, "leftx", value) + elseif axisIndex == 2 then + PadCursor.gamepadaxis(joystick, "lefty", value) + elseif axisIndex == 3 or axisIndex == 5 then + triggerAxis("l", "raw", value) + elseif axisIndex == 4 or axisIndex == 6 then + triggerAxis("r", "raw", value) + end +end + +function PadCursor.joystickhat(joystick, hat, direction) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + if GamepadMap.isAccelerometer(joystick) then return end + for _, d in ipairs(rawHatDirs[hat] or {}) do + dir[d] = nil + end + local dirs = ({ + u = { "dpup" }, d = { "dpdown" }, l = { "dpleft" }, r = { "dpright" }, + lu = { "dpleft", "dpup" }, ru = { "dpright", "dpup" }, + ld = { "dpleft", "dpdown" }, rd = { "dpright", "dpdown" }, + })[direction] or {} + for _, d in ipairs(dirs) do dir[d] = true end + rawHatDirs[hat] = dirs + if #dirs > 0 then activate() end +end + +function PadCursor.draw() + if not active then return end + if not (love and love.graphics) then return end + local x, y = cursor.x, cursor.y + love.graphics.push("all") + if love.graphics.origin then love.graphics.origin() end + if love.graphics.setLineWidth then love.graphics.setLineWidth(1) end + love.graphics.setColor(0, 0, 0, 0.45) + if love.graphics.polygon then + love.graphics.polygon("fill", + x + 2, y + 2, x + 2, y + 22, x + 8, y + 16, x + 14, y + 26, + x + 18, y + 24, x + 11, y + 14, x + 20, y + 14) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.polygon("fill", + x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, + x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) + love.graphics.setColor(0.05, 0.07, 0.12, 1) + love.graphics.polygon("line", + x, y, x, y + 20, x + 6, y + 14, x + 12, y + 24, + x + 16, y + 22, x + 9, y + 12, x + 18, y + 12) + else + love.graphics.rectangle("fill", x, y, 12, 18) + end + love.graphics.pop() +end + +return PadCursor 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/SkinStudio.lua b/src/ui/SkinStudio.lua new file mode 100644 index 000000000..09a45c739 --- /dev/null +++ b/src/ui/SkinStudio.lua @@ -0,0 +1,3080 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local TouchSkin = require("src.core.TouchSkin") +local TouchControls = require("src.core.TouchControls") +local SaveData = require("src.core.SaveData") +local FilePicker = require("src.core.FilePicker") +local SafeArea = require("src.core.SafeArea") +local GamepadMap = require("src.core.GamepadMap") +local PadCursor = require("src.ui.PadCursor") + +local Studio = {} +-- Own native touch callbacks on both mobile platforms; synthesized mouse +-- events from that same finger must not activate editor controls twice. +Studio.touchFirst = true + +Studio.CANVASES = { + { id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 }, + { id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 }, + { id = "this_device", label = "This screen", live = true }, + { id = "tablet_portrait", label = "Tablet portrait", w = 1536, h = 2048 }, + { id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 }, + { id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 }, + { id = "desktop_1080", label = "Desktop 1080p", w = 1920, h = 1080 }, + { id = "ultrawide", label = "Ultrawide 21:9", w = 2560, h = 1080 }, + { id = "sgb_border", label = "Super Game Boy border", w = 256, h = 224, + lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } }, +} + +-- Editor view zoom: 1 is contain-fit. Smaller values shrink the mock +-- device inside the workspace so the screen hole can be dragged past the +-- bezel and you can still grab the handles. +Studio.ZOOM_LEVELS = { 1, 0.75, 0.5, 0.35 } +Studio.viewZoom = 1 + +-- Hydrated live canvas; canvas() is called many times a frame so this is +-- mutated in place instead of allocating a new table. +local liveCanvas = { id = "this_device", label = "This screen", live = true, + w = 1080, h = 1920 } + +-- Per-page lock, and whether the canvas preset follows it. Match is on +-- by default so a portrait/landscape overlay pair does not need two +-- separate clicks to preview the right way up. #1503 +Studio.matchOrient = true +Studio.ORIENT_CYCLE = { "any", "portrait", "landscape" } +Studio.ORIENT_LABEL = { + any = "Lock: Off", + portrait = "Lock: Portrait", + landscape = "Lock: Landscape", +} + +local HANDLE = 7 +local TAP_SLOP2 = 16 * 16 +local HANDLES = { + { "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 }, + { "w", 0, 0.5 }, { "e", 1, 0.5 }, + { "sw", 0, 1 }, { "s", 0.5, 1 }, { "se", 1, 1 }, +} + +local GB_ASPECT = 160 / 144 + +Studio.UNDO_CAP = 50 +Studio.SNAP_PX = 4 +Studio.STATUS_HOLD = 4 + +Studio.FORMAT_LABEL = { + native = "RetroRecompiled", + retroarch = "RetroArch", + delta = "Delta", +} + +Studio.EXPORTS = { + { id = "native", label = "RetroRecompiled .zip", + hint = "Reopens here and in the launcher." }, + { id = "retroarch", label = "RetroArch .zip", + hint = "An overlay .cfg RetroArch can load." }, + { id = "delta", label = "Delta .deltaskin", + hint = "info.json plus art, for Delta and Ignited." }, +} + +Studio.BIND_GROUPS = { + { title = "GAME BOY", + specs = { "a", "b", "start", "select", "up", "down", "left", "right" } }, + { title = "GBA / SHOULDERS", + specs = { "l", "r", "l|r" } }, + { title = "NATIVE DS BUTTONS", specs = { "x", "y" } }, + { title = "DIAGONALS", + specs = { "left|up", "right|up", "left|down", "right|down" } }, + { title = "HOTKEYS", + specs = { "menu_toggle", "reset", "hold_fast_forward", + "toggle_fast_forward", "overlay_next", "overlay_previous", + "pause_toggle", "screenshot", "exit_emulator" } }, + { title = "KEYBOARD", + specs = { "key:escape", "key:return", "key:space", "key:tab", "key:f1" } }, + -- These are deliberately keyboard binds rather than new skin-only actions. + -- They therefore take the exact same path as their desktop counterparts in + -- Game:keypressed, including any future changes to those shortcuts. + { title = "DESKTOP HOTKEYS", + specs = { "key:-", "key:=", "key:1", "key:2", "key:3", "key:4", + "key:5", "key:f1", "key:f2", "key:f10" } }, + { title = "NO INPUT", specs = { "nul" } }, +} + +Studio.BIND_PARTS = { "left", "right", "up", "down", "a", "b", "l", "r", "start", "select", "x", "y" } + +local PART_RANK = { + left = 1, right = 2, up = 3, down = 4, + a = 5, b = 6, l = 7, r = 8, start = 9, select = 10, + x = 11, y = 12, +} + +local function now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return 0 +end + +local function clamp01(v) + if v ~= v then return 0 end + if v < 0 then return 0 end + if v > 1 then return 1 end + return v +end + +local function round(v) return math.floor(v + 0.5) end + +function Studio.deviceSize() + if love and love.graphics and love.graphics.getDimensions then + local w, h = love.graphics.getDimensions() + w, h = tonumber(w), tonumber(h) + if w and h and w > 1 and h > 1 then return w, h end + end + return 1080, 1920 +end + +function Studio.canvasIndexById(id) + for i, c in ipairs(Studio.CANVASES) do + if c.id == id then return i end + end + return nil +end + +function Studio.canvas() + local spec = Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1] + if spec.live then + local w, h = Studio.deviceSize() + liveCanvas.id = spec.id + liveCanvas.label = spec.label + liveCanvas.w, liveCanvas.h = w, h + liveCanvas.live = true + liveCanvas.lockViewport = spec.lockViewport + return liveCanvas + end + return spec +end + +function Studio.zoomIndex() + local z = Studio.viewZoom or 1 + local best, dist = 1, math.huge + for i, v in ipairs(Studio.ZOOM_LEVELS) do + local d = math.abs(v - z) + if d < dist then best, dist = i, d end + end + return best +end + +function Studio.zoomOut() + local i = Studio.zoomIndex() + if i < #Studio.ZOOM_LEVELS then + Studio.viewZoom = Studio.ZOOM_LEVELS[i + 1] + end + return Studio.viewZoom +end + +function Studio.zoomIn() + local i = Studio.zoomIndex() + if i > 1 then + Studio.viewZoom = Studio.ZOOM_LEVELS[i - 1] + end + return Studio.viewZoom +end + +function Studio.zoomFit() + Studio.viewZoom = Studio.ZOOM_LEVELS[1] + return Studio.viewZoom +end + +function Studio.detectDeviceCanvas() + local idx = Studio.canvasIndexById("this_device") + if not idx then return nil end + Studio.setCanvas(idx) + local canvas = Studio.canvas() + Studio.setStatus(("This screen: %dx%d"):format(canvas.w, canvas.h)) + return canvas +end + +-- Usable chrome rect. Background still fills the window so the notch +-- band is the same colour as the rest of the studio; every button and +-- the mock device live inside the safe area, matching the launcher. +function Studio.safeFrame() + local W, H = 0, 0 + if love and love.graphics and love.graphics.getDimensions then + W, H = love.graphics.getDimensions() + end + W, H = math.max(1, tonumber(W) or 1), math.max(1, tonumber(H) or 1) + local ox, oy, sw, sh = SafeArea.rect() + ox = math.max(0, tonumber(ox) or 0) + oy = math.max(0, tonumber(oy) or 0) + sw = math.max(1, tonumber(sw) or W) + sh = math.max(1, tonumber(sh) or H) + Studio._frame = { W = W, H = H, x = ox, y = oy, w = sw, h = sh } + return W, H, ox, oy, sw, sh +end + +function Studio.page() + local skin = Studio.skin + if not skin then return nil end + return skin.pages[Studio.pageIndex] or skin.pages[1] +end + +function Studio.selectedControl() + local page = Studio.page() + if not page then return nil end + return page.controls[Studio.selected] +end + +local function setStatus(text, isError) + Studio.status = text + Studio.statusErr = isError == true + Studio.statusAt = now() +end +Studio.setStatus = setStatus + +function Studio.expireStatus() + if not Studio.statusErr or not Studio.status then return end + if now() - (Studio.statusAt or 0) > Studio.STATUS_HOLD then + Studio.status, Studio.statusErr = nil, false + end +end + +local function markDirty() + Studio.dirty = true + if not Studio.statusErr then Studio.status = nil end +end + +local function snapshot() + return { + skin = TouchSkin.clone(Studio.skin), + pageIndex = Studio.pageIndex, + selected = Studio.selected, + idField = Studio.skinIdField, + } +end + +local function restore(snap) + Studio.skin = snap.skin + Studio.pageIndex = snap.pageIndex or 1 + Studio.selected = snap.selected + Studio.skinIdField = snap.idField or Studio.skinIdField + TouchSkin.setActive(Studio.skin) + TouchSkin.pageIndex = Studio.pageIndex + Studio.dirty = true +end + +function Studio.pushUndo(tag) + if not Studio.skin then return false end + Studio.undoStack = Studio.undoStack or {} + Studio.redoStack = {} + if tag and Studio.undoTag == tag and now() - (Studio.undoAt or 0) < 1 then + return false + end + Studio.undoTag, Studio.undoAt = tag, now() + local stack = Studio.undoStack + stack[#stack + 1] = snapshot() + while #stack > Studio.UNDO_CAP do table.remove(stack, 1) end + return true +end + +function Studio.undo() + local stack = Studio.undoStack or {} + local snap = stack[#stack] + if not snap then + setStatus("Nothing to undo.") + return false + end + table.remove(stack) + Studio.redoStack = Studio.redoStack or {} + Studio.redoStack[#Studio.redoStack + 1] = snapshot() + Studio.undoTag = nil + restore(snap) + setStatus("Undo") + return true +end + +function Studio.redo() + local stack = Studio.redoStack or {} + local snap = stack[#stack] + if not snap then + setStatus("Nothing to redo.") + return false + end + table.remove(stack) + Studio.undoStack = Studio.undoStack or {} + Studio.undoStack[#Studio.undoStack + 1] = snapshot() + Studio.undoTag = nil + restore(snap) + setStatus("Redo") + return true +end + +function Studio.canUndo() return #(Studio.undoStack or {}) > 0 end +function Studio.canRedo() return #(Studio.redoStack or {}) > 0 end + +function Studio.openModal(kind, data) + data = data or {} + data.kind = kind + data.scroll = 0 + Studio.modal = data + return data +end + +function Studio.closeModal() + Studio.modal = nil + Kit.blur() +end + +function Studio.modalUp() + return Studio.modal ~= nil or Studio.confirm ~= nil +end + +function Studio.ask(text, onYes, yesLabel) + Studio.confirm = { text = text, onYes = onYes, + yesLabel = yesLabel or "Discard" } + return Studio.confirm +end + +function Studio.confirmYes() + local c = Studio.confirm + Studio.confirm = nil + if c and c.onYes then c.onYes() end + return c ~= nil +end + +function Studio.confirmNo() + local had = Studio.confirm ~= nil + Studio.confirm = nil + return had +end + +function Studio.guard(text, fn) + if not Studio.dirty or not Studio.skin then + fn() + return true + end + Studio.ask(text, fn) + return false +end + +local function syncActive() + TouchSkin.setActive(Studio.skin) + TouchSkin.pageIndex = Studio.pageIndex +end + +local function canvasOrientation(canvas) + canvas = canvas or Studio.canvas() + if not canvas then return nil end + local w, h = canvas.w, canvas.h + if canvas.live and (not w or not h) then + w, h = Studio.deviceSize() + end + if not w or not h then return nil end + return w > h and "landscape" or "portrait" +end + +local function pickCanvasIndex(want) + local cur = Studio.canvas() + -- A live "this screen" canvas already is the window; keep it so Match + -- canvas cannot yank a phone author back onto a generic 16:9 preset. + if cur and cur.live then return Studio.canvasIndex end + if canvasOrientation(cur) == want then return Studio.canvasIndex end + if cur and cur.id then + local hint = cur.id:gsub("portrait", want):gsub("landscape", want) + for i, c in ipairs(Studio.CANVASES) do + if c.id == hint then return i end + end + end + for i, c in ipairs(Studio.CANVASES) do + if not c.live and not c.lockViewport + and canvasOrientation(c) == want then + return i + end + end + return nil +end + +function Studio.applyImportedOrient() + if not Studio.skin then return false end + if not TouchSkin.hasOrientPair(Studio.skin) then + Studio.syncCanvasToPage() + return false + end + -- A RetroArch overlay that already auto-rotates should do the same in + -- the studio: lock is on, canvas follows, and the visible page matches + -- the mock device. #1503 + Studio.matchOrient = true + Studio.syncPageToCanvas() + Studio.syncCanvasToPage() + return true +end + +function Studio.syncCanvasToPage() + if not Studio.matchOrient then return end + local want = TouchSkin.pageOrient(Studio.page()) + if not want then return end + local idx = pickCanvasIndex(want) + if idx and idx ~= Studio.canvasIndex then Studio.setCanvas(idx, true) end +end + +function Studio.syncPageToCanvas() + if not Studio.matchOrient or not Studio.skin then return end + local want = canvasOrientation() + if TouchSkin.pageOrient(Studio.page()) == want then return end + for i, page in ipairs(Studio.skin.pages or {}) do + if TouchSkin.pageOrient(page) == want then + Studio.pageIndex = i + Studio.selected = nil + syncActive() + return + end + end +end + +function Studio.cyclePageOrient(dir) + local page = Studio.page() + if not page then return end + local cur = TouchSkin.pageOrient(page) or "any" + local idx = 1 + for i, o in ipairs(Studio.ORIENT_CYCLE) do + if o == cur then idx = i break end + end + local n = #Studio.ORIENT_CYCLE + local nxt = Studio.ORIENT_CYCLE[((idx - 1 + (dir or 1)) % n) + 1] + page.orient = nxt + local name = tostring(page.name or "") + if (nxt == "portrait" or nxt == "landscape") + and (name == "" or name == "main" or name:match("^page%d+$")) then + page.name = nxt + end + markDirty() + Studio.syncCanvasToPage() + return nxt +end + +function Studio.setCanvas(index, fromSync) + local n = #Studio.CANVASES + Studio.canvasIndex = ((index - 1) % n) + 1 + -- Pick the matching page before writing canvas-owned fields onto it, + -- so a landscape preset does not stamp a portrait page. #1503 + if not fromSync then Studio.syncPageToCanvas() end + local canvas = Studio.canvas() + local page = Studio.page() + if page and canvas.lockViewport then + page.viewport = { + x = canvas.lockViewport.x, y = canvas.lockViewport.y, + w = canvas.lockViewport.w, h = canvas.lockViewport.h, + } + page.viewportFill = false + markDirty() + end + -- A cfg-authored aspect_ratio is the overlay's design aspect, and bezel art + -- is its own design canvas (TouchSkin.applyImageAspect); keep either so the + -- preview lays the page out exactly the way the game does (#1503). + if page and not (page.aspectFromCfg or page.aspectFromImage) then + page.aspect = canvas.w / canvas.h + end +end + +function Studio.load(opts) + opts = opts or {} + Studio._activeSkinId = nil + Studio.onClose = opts.onClose + Studio.onPlay = opts.onPlay + Studio.version = opts.version + Studio.pageIndex = 1 + Studio.selected = nil + Studio.testing = false + Studio.dirty = false + Studio.status = nil + Studio.drag = nil + Studio.pendingPlay = false + Studio.canvasIndex = 1 + Studio.viewZoom = 1 + -- On a phone the mock device should be this screen's form factor, not a + -- generic 1080x1920 16:9, so the hole and the pad land where they will + -- at play time. + if Studio.isMobile() then + local live = Studio.canvasIndexById("this_device") + if live then Studio.canvasIndex = live end + end + -- A free-form screen is the default. 10:9 is an optional convenience, + -- never a restriction on imported or custom skins. + Studio.aspectLock = false + Studio.skinIdField = "" + Studio.available = TouchSkin.list() + Studio.availableMeta = {} + Studio.imageTarget = "idle" + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag, Studio.undoAt = nil, nil + Studio.modal, Studio.confirm = nil, nil + Studio.guides = nil + Studio.showLabels = true + Studio.statusErr = false + Studio.thumbs = {} + Studio.pointerX, Studio.pointerY = nil, nil + Studio.pointerDown, Studio.touchId = false, nil + PadCursor.reset() + -- Studio always opens on the library. Creating and choosing a skin are + -- first-class tasks, not controls buried inside the editor workspace. + Studio.mode = "library" + Studio.libraryThumbs = {} + Studio.libraryScroll, Studio.libraryScrollMax = 0, 0 + Studio.libraryPress, Studio.libraryRects = nil, {} + Studio.libraryFocusId = nil + + TouchControls:init() + TouchControls.active = true + TouchControls.enabled = true + -- A mock-device test must never call a suspended game's reset/menu/speed + -- handler. Keep real control ownership/pressed art and report the action. + TouchControls:setHotkeyHandler(function(action,pressed) + if pressed then setStatus('Test action: '..action) end + end) + TouchControls:setPreview(true) + -- Play snaps pages from the window aspect. The studio uses its own + -- Match canvas toggle against the mock device instead. #1503 + TouchSkin.autoOrient = false + Studio.matchOrient = true + + local start = opts.skinId + if not start then + local saved = SaveData.loadOptions() + local tc = type(saved.touchControls) == "table" and saved.touchControls or {} + start = tc.skin + end + if start and TouchSkin.find(start) then + Studio.open(start) + else + Studio.skin = TouchSkin.newSkin("new_skin") + Studio.skinIdField = Studio.skin.id + syncActive() + end +end + +function Studio.open(id) + local entry = TouchSkin.find(id) + if not entry then return false end + local loaded = TouchSkin.load(entry.root, entry.id) + if not loaded then return false end + Studio.skin = TouchSkin.clone(loaded) + Studio.skinIdField = Studio.skin.id + Studio.pageIndex = 1 + Studio.selected = nil + Studio.dirty = false + Studio.images = TouchSkin.listImages(Studio.skin.root) + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag = nil + Studio.thumbs = {} + Studio.libraryThumbs = {} + syncActive() + Studio.applyImportedOrient() + return true +end + +function Studio.newSkin() + Studio.pushUndo() + Studio.skin = TouchSkin.newSkin("new_skin") + Studio.skinIdField = "new_skin" + Studio.pageIndex, Studio.selected = 1, nil + Studio.images = {} + Studio.thumbs = {} + Studio.libraryThumbs = {} + syncActive() + markDirty() +end + +function Studio.skinFormat(skin) + skin = skin or Studio.skin + local fmt = skin and skin.format or "native" + return Studio.FORMAT_LABEL[fmt] or fmt +end + +function Studio.describeSkin(entry) + Studio.availableMeta = Studio.availableMeta or {} + local meta = Studio.availableMeta[entry.id] + if meta then return meta end + local skin = TouchSkin.load(entry.root, entry.id) + local page = skin and skin.pages[1] + local controls = 0 + for _, ctl in ipairs(page and page.controls or {}) do + if not ctl.decorative then controls = controls + 1 end + end + meta = { + id = entry.id, + source = entry.source, + format = Studio.skinFormat(skin), + pages = skin and #skin.pages or 0, + controls = controls, + ok = skin ~= nil, + } + Studio.availableMeta[entry.id] = meta + return meta +end + +function Studio.skinSummary(entry) + local meta = Studio.describeSkin(entry) + local bits = { meta.source == "user" and "installed" or "bundled", meta.format } + bits[#bits + 1] = meta.controls .. " buttons" + if meta.pages > 1 then bits[#bits + 1] = meta.pages .. " pages" end + return table.concat(bits, " \194\183 ") +end + +function Studio.forgetActiveSkin() + Studio._activeSkinId = nil +end + +function Studio.activeSkinId() + if Studio._activeSkinId == nil then + local saved = SaveData.loadOptions() + local tc = type(saved.touchControls) == "table" and saved.touchControls or {} + Studio._activeSkinId = (tc.enabled ~= false and tc.skin) or false + end + return Studio._activeSkinId or nil +end + +function Studio.refreshAvailable() + Studio.forgetActiveSkin() + Studio.available = TouchSkin.list() + Studio.availableMeta = {} + Studio.libraryThumbs = {} + Studio.libraryScroll = Kit.scrollClamp(Studio.libraryScroll or 0, + Studio.libraryScrollMax or 0) + Studio.libraryPress = nil + return Studio.available +end + +function Studio.enterEditor() + Studio.mode = "editor" + Studio.selected = nil + Studio.modal, Studio.confirm = nil, nil + Studio.drag, Studio.guides = nil, nil +end + +function Studio.backToLibrary() + Studio.guard("Return to My Skins and lose the unsaved changes?", function() + Studio.refreshAvailable() + Studio.mode = "library" + Studio.selected = nil + Studio.modal, Studio.confirm = nil, nil + Studio.drag, Studio.guides = nil, nil + end) +end + +function Studio.libraryThumb(entry) + if not entry then return nil end + Studio.libraryThumbs = Studio.libraryThumbs or {} + local cached = Studio.libraryThumbs[entry.id] + if cached ~= nil then return cached or nil end + local skin = TouchSkin.load(entry.root, entry.id) + local image = skin and skin.pages and skin.pages[1] and skin.pages[1].image + Studio.libraryThumbs[entry.id] = image or false + return image +end + +function Studio.exportEntry(id) + local entry = TouchSkin.find(id) + local skin = entry and TouchSkin.load(entry.root, entry.id) + if not skin then + setStatus("Could not read " .. tostring(id), true) + return nil + end + local path, missing = TouchSkin.export(skin) + if not path then + setStatus("Export failed: " .. tostring(missing), true) + return nil + end + Studio.lastExport = path + setStatus("Exported " .. path) + return path +end + +function Studio.selectEntry(id) + local entry = TouchSkin.find(id) + if not entry then + setStatus("Could not find " .. tostring(id), true) + return false + end + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled, tc.skin = true, id + opts.touchControls = tc + SaveData.saveOptions(opts) + Studio.forgetActiveSkin() + TouchControls:applyOptions(opts) + setStatus("Using " .. id) + return true +end + +function Studio.deleteEntry(id) + local entry = TouchSkin.find(id) + if not entry then return false end + if entry.source ~= "user" then + setStatus("Bundled skins cannot be deleted.", true) + return false + end + Studio.ask("Delete " .. tostring(id) .. "? This removes its artwork and cannot be undone.", + function() + local ok, err = TouchSkin.remove(id) + if not ok then + setStatus("Delete failed: " .. tostring(err), true) + return + end + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + if tc.skin == id then + tc.enabled, tc.skin = true, nil + opts.touchControls = tc + SaveData.saveOptions(opts) + Studio.forgetActiveSkin() + TouchControls:applyOptions(opts) + end + Studio.refreshAvailable() + setStatus("Deleted " .. id) + end, "Delete") + return true +end + +function Studio.importSkinBytes(name, data) + local id, note = TouchSkin.installArchive(name, data) + if not id then + setStatus("Import failed: " .. tostring(note), true) + return false + end + Studio.refreshAvailable() + Studio.open(id) + Studio.enterEditor() + setStatus("Imported " .. id) + return true +end + +function Studio.importSkinFile() + if not FilePicker.available() then + setStatus("Use a desktop file picker, or drop a skin ZIP, Delta skin or CFG onto this window.", true) + return false + end + local kind = { label = "Skin", exts = { "zip", "deltaskin", "cfg" } } + local path = FilePicker.open("Choose a skin", kind) + if not path then return false end + local name, data = FilePicker.basename(path), FilePicker.read(path) + if not data then + setStatus("Could not read " .. name, true) + return false + end + return Studio.importSkinBytes(name, data) +end + +function Studio.openLoadPicker() + return Studio.guard("Open another skin and lose the unsaved changes?", + function() + Studio.refreshAvailable() + Studio.openModal("open") + end) +end + +function Studio.loadEntry(id) + Studio.closeModal() + if not Studio.open(id) then + setStatus("Could not open " .. tostring(id), true) + return false + end + local warning = Studio.skin and Studio.skin.warnings + and Studio.skin.warnings[1] + if warning then + setStatus("Opened " .. id .. ": " .. tostring(warning), true) + else + setStatus("Opened " .. id) + end + return true +end + +function Studio.unload() + Studio._activeSkinId = nil + Studio.pendingPlay = false + TouchSkin.setSurface(nil) + TouchSkin.setActive(nil) + TouchSkin.autoOrient = true + TouchControls:setPreview(false) + TouchControls:reset() + TouchControls:setHotkeyHandler(nil) + Studio.skin = nil + Studio.onClose = nil + Studio.onPlay = nil + Studio.drag = nil + Studio.modal, Studio.confirm = nil, nil + Studio.guides = nil + Studio.undoStack, Studio.redoStack = {}, {} + Studio.pointerX, Studio.pointerY = nil, nil + Studio.pointerDown, Studio.touchId = false, nil + PadCursor.reset() +end + +-- The Studio is a real touch editor on Android and iOS. Keeping this here, +-- instead of asking the host to synthesize a mouse, makes the drag state and +-- the immediate-mode hit tests agree on the same finger position. +function Studio.isMobile() + local osName = love.system and love.system.getOS and love.system.getOS() + return osName == "Android" or osName == "iOS" +end + +function Studio.disableTouchControls() + local opts = SaveData.loadOptions() + local tc = type(opts.touchControls) == "table" and opts.touchControls or {} + tc.enabled, tc.skin = false, nil + opts.touchControls = tc + SaveData.saveOptions(opts) + Studio.forgetActiveSkin() + TouchControls:applyOptions(opts) + TouchSkin.setActive(nil) + Studio.closeModal() + setStatus("On-screen controls are off.") + return true +end + +-- --------------------------------------------------------------- editing + +function Studio.addControl() + local page = Studio.page() + if not page then return end + Studio.pushUndo() + page.controls[#page.controls + 1] = + TouchSkin.newControl("a", 0.5, 0.5, 0.16, 0.09, "radial") + Studio.selected = #page.controls + markDirty() +end + +function Studio.deleteControl() + local page = Studio.page() + if not page or not Studio.selected then return end + Studio.pushUndo() + table.remove(page.controls, Studio.selected) + Studio.selected = page.controls[Studio.selected] and Studio.selected + or (#page.controls > 0 and #page.controls or nil) + markDirty() +end + +function Studio.duplicateControl() + local page, ctl = Studio.page(), Studio.selectedControl() + if not page or not ctl then return end + Studio.pushUndo() + local copy = TouchSkin.newControl(ctl.spec, clamp01(ctl.x + 0.04), + clamp01(ctl.y + 0.04), ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape) + copy.imagePath, copy.image = ctl.imagePath, ctl.image + copy.pressedImagePath, copy.pressedImage = ctl.pressedImagePath, ctl.pressedImage + copy.rangeMod, copy.alphaMod = ctl.rangeMod, ctl.alphaMod + table.insert(page.controls, Studio.selected + 1, copy) + Studio.selected = Studio.selected + 1 + markDirty() +end + +function Studio.cycleBind(dir) + local ctl = Studio.selectedControl() + if not ctl then return end + local list, at = TouchSkin.BINDS, 1 + for i, spec in ipairs(list) do + if spec == ctl.spec then at = i break end + end + Studio.pushUndo("bind") + TouchSkin.setBind(ctl, list[((at - 1 + dir) % #list) + 1]) + markDirty() +end + +function Studio.bindParts(spec) + local out = {} + for raw in tostring(spec or ""):gmatch("[^|]+") do + local name = raw:gsub("^%s+", ""):gsub("%s+$", ""):lower() + if name ~= "" and name ~= "nul" then out[#out + 1] = name end + end + return out +end + +function Studio.hasBindPart(spec, part) + for _, name in ipairs(Studio.bindParts(spec)) do + if name == part then return true end + end + return false +end + +function Studio.toggleBindPart(spec, part) + local kept, found = {}, false + for _, name in ipairs(Studio.bindParts(spec)) do + if name == part then + found = true + else + kept[#kept + 1] = name + end + end + if not found then kept[#kept + 1] = part end + if #kept == 0 then return "nul" end + table.sort(kept, function(a, b) + local ra, rb = PART_RANK[a] or 9, PART_RANK[b] or 9 + if ra ~= rb then return ra < rb end + return a < b + end) + return table.concat(kept, "|") +end + +function Studio.setBindSpec(spec) + local ctl = Studio.selectedControl() + if not ctl or not spec then return false end + Studio.pushUndo() + TouchSkin.setBind(ctl, spec) + markDirty() + return true +end + +function Studio.toggleSelectedBindPart(part) + local ctl = Studio.selectedControl() + if not ctl then return false end + return Studio.setBindSpec(Studio.toggleBindPart(ctl.spec, part)) +end + +function Studio.openBindPicker() + if not Studio.selectedControl() then + setStatus("Select a control first.", true) + return false + end + Studio.openModal("bind") + return true +end + +function Studio.moveControlOrder(delta) + local page, i = Studio.page(), Studio.selected + if not page or not i then return false end + local j = i + delta + if j < 1 or j > #page.controls then return false end + Studio.pushUndo() + page.controls[i], page.controls[j] = page.controls[j], page.controls[i] + Studio.selected = j + markDirty() + return true +end + +function Studio.nudge(dx, dy, big) + local ctl = Studio.selectedControl() + if not ctl then return false end + local canvas = Studio.canvas() + local step = big and 10 or 1 + Studio.pushUndo("nudge") + ctl.x = clamp01(ctl.x + dx * step / canvas.w) + ctl.y = clamp01(ctl.y + dy * step / canvas.h) + markDirty() + return true +end + +function Studio.snapOffset(edges, lines, tol) + local best, at = nil, nil + for _, e in ipairs(edges) do + for _, l in ipairs(lines) do + local d = l - e + if math.abs(d) <= tol and (best == nil or math.abs(d) < math.abs(best)) then + best, at = d, l + end + end + end + return best or 0, at +end + + +function Studio.cycleImage(dir) + local ctl = Studio.selectedControl() + local page = Studio.page() + if not page then return end + Studio.images = Studio.images or TouchSkin.listImages(Studio.skin.root) + local list = Studio.images + local field = Studio.imageTarget + local owner = (field == "bezel") and page or ctl + if not owner then return end + local key = (field == "pressed") and "pressedImagePath" or "imagePath" + if field == "bezel" then key = "imagePath" end + + local at = 0 + for i, rel in ipairs(list) do + if rel == owner[key] then at = i break end + end + local next_ = at + dir + if next_ < 0 then next_ = #list end + if next_ > #list then next_ = 0 end + local rel = (next_ >= 1) and list[next_] or nil + owner[key] = rel + local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil + if field == "pressed" then + owner.pressedImage = img + else + owner.image = img + if field == "bezel" then TouchSkin.applyImageAspect(owner) end + end + markDirty() +end + +function Studio.refreshImages() + Studio.images = Studio.skin and TouchSkin.listImages(Studio.skin.root) or {} + Studio.thumbs = {} + return Studio.images +end + +function Studio.openImagePicker(target) + if not Studio.skin then return false end + target = target or Studio.imageTarget + if target ~= "bezel" and not Studio.selectedControl() then + setStatus("Select a control first, or pick a bezel image.", true) + return false + end + Studio.imageTarget = target + Studio.refreshImages() + Studio.openModal("image") + return true +end + +function Studio.currentImagePath() + local target = Studio.imageTarget + if target == "bezel" then + local page = Studio.page() + return page and page.imagePath + end + local ctl = Studio.selectedControl() + if not ctl then return nil end + return (target == "pressed") and ctl.pressedImagePath or ctl.imagePath +end + +function Studio.thumb(rel) + if not rel or not Studio.skin then return nil end + Studio.thumbs = Studio.thumbs or {} + local cached = Studio.thumbs[rel] + if cached ~= nil then return cached or nil end + local img = TouchSkin.resolveImage(Studio.skin.root, rel) + Studio.thumbs[rel] = img or false + return img +end + +function Studio.chooseImage(rel) + if not Studio.skin then return false end + Studio.pushUndo() + Studio.assignImage(rel) + Studio.closeModal() + setStatus(rel and ("Using " .. rel .. " as " .. Studio.imageTargetLabel()) + or ("Cleared the " .. Studio.imageTargetLabel())) + return true +end + +function Studio.imageTargetLabel() + local target = Studio.imageTarget + if target == "bezel" or not Studio.selectedControl() then return "bezel" end + return target == "pressed" and "pressed art" or "idle art" +end + +function Studio.assignImage(rel) + local page, ctl = Studio.page(), Studio.selectedControl() + local img = TouchSkin.resolveImage(Studio.skin.root, rel) + local target = Studio.imageTarget + if ctl and target == "pressed" then + ctl.pressedImagePath, ctl.pressedImage = rel, img + elseif ctl and target == "idle" then + ctl.imagePath, ctl.image = rel, img + elseif page then + page.imagePath, page.image = rel, img + TouchSkin.applyImageAspect(page) + end + Studio.images = TouchSkin.listImages(Studio.skin.root) + Studio.dirty = true +end + +local function commitSkinId() + local skin = Studio.skin + if not skin then return end + local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "") + if id == "" or id == skin.id then return end + TouchSkin.saveTo(skin, id) + Studio.available = TouchSkin.list() +end + +function Studio.adoptImage(name, data, target) + if not Studio.skin then return false end + if target then Studio.imageTarget = target end + if not FilePicker.matches(name, FilePicker.IMAGE) then + setStatus("Pick a PNG or JPG.", true) + return false + end + commitSkinId() + local rel, err = TouchSkin.importImage(Studio.skin, name, data) + if not rel then + setStatus("Import failed: " .. tostring(err), true) + return false + end + local where = Studio.imageTargetLabel() + Studio.pushUndo() + Studio.assignImage(rel) + Studio.skinIdField = Studio.skin.id + local text = "Imported " .. rel .. " as " .. where + if where == "bezel" and not Studio.canvas().lockViewport then + text = text .. " -- use Detect screen from bezel to place the screen" + end + setStatus(text) + return true +end + +function Studio.importImageFile(target) + if not Studio.skin then return end + target = target or Studio.imageTarget + Studio.imageTarget = target + if target ~= "bezel" and not Studio.selectedControl() then + setStatus("Select a control first, or import a bezel image.", true) + return + end + if not FilePicker.available() then + setStatus("No file picker here -- drag a PNG onto the window instead.", true) + return + end + local prompt = (target == "bezel") and "Choose a bezel image" + or "Choose a button image" + local path = FilePicker.open(prompt, FilePicker.IMAGE) + if not path then return end + local base = FilePicker.basename(path) + local data, err = FilePicker.read(path) + if not data then + setStatus("Could not read " .. base .. ": " .. tostring(err), true) + return + end + Studio.adoptImage(base, data, target) +end + +function Studio.filedropped(file) + if not Studio.skin then return end + local path = (file.getFilename and file:getFilename()) or "" + local base = FilePicker.basename(path) + local archive=FilePicker.matches(base,{exts={'zip','deltaskin','cfg'}}) + if not archive and not FilePicker.matches(base, FilePicker.IMAGE) then + setStatus("Drop a PNG/JPG for art, or a ZIP/Delta/CFG skin.", true) + return + end + local ok, data = pcall(function() + file:open("r") + local bytes = file:read() + file:close() + return bytes + end) + if not ok or not data then + setStatus("Could not read " .. base, true) + return + end + if archive then + return Studio.guard('Import another skin and lose the unsaved changes?', + function() Studio.importSkinBytes(base,data) end) + end + return Studio.adoptImage(base, data) +end + +function Studio.detectViewport() + local page = Studio.page() + if not page or not Studio.skin then return end + if Studio.canvas().lockViewport then + setStatus("This preset locks the screen position.", true) + return + end + if not page.imagePath then + setStatus("Pick a bezel image first.", true) + return + end + local rect, pw, ph = TouchSkin.detectViewport(Studio.skin.root, page.imagePath) + if not rect then + setStatus("No transparent screen hole found in " .. page.imagePath, true) + return + end + Studio.pushUndo() + page.viewport = rect + page.screenFit = nil + setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph)) + Studio.dirty = true +end + +function Studio.toggleViewport() + local page = Studio.page() + if not page then return end + if Studio.canvas().lockViewport then return end + Studio.pushUndo() + if page.viewport or page.screenFit == "remainder" then + page.viewport = nil + page.screenFit = nil + else + page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 } + end + markDirty() +end + +function Studio.setPage(index) + local skin = Studio.skin + if not skin or not skin.pages[index] then return false end + Studio.pageIndex = index + Studio.selected = nil + syncActive() + Studio.syncCanvasToPage() + return true +end + +function Studio.nextPage() + local skin = Studio.skin + if not skin or #skin.pages == 0 then return false end + return Studio.setPage((Studio.pageIndex % #skin.pages) + 1) +end + +function Studio.pageLabel(index) + local skin = Studio.skin + local page = skin and skin.pages[index] + if not page then return "" end + local name = tostring(page.name or ("page" .. index)) + local orient = TouchSkin.pageOrient(page) + local bits = { #(page.controls or {}) .. " controls" } + if orient then bits[#bits + 1] = orient end + if page.viewport or page.screenFit == "remainder" then bits[#bits + 1] = "screen" end + return name, table.concat(bits, " \194\183 ") +end + +function Studio.openPageMenu() + local skin = Studio.skin + if not skin then return false end + Studio.pageNameField = tostring(Studio.page() and Studio.page().name or "") + Studio.openModal("page") + return true +end + +function Studio.openScreenMenu() + Studio.openModal("screen") + return true +end + +function Studio.renamePage(name) + local page = Studio.page() + if not page then return false end + name = tostring(name or ""):gsub("^%s+", ""):gsub("%s+$", "") + if name == "" then + setStatus("Give the page a name first.", true) + return false + end + Studio.pushUndo() + page.name = name + markDirty() + setStatus("Renamed page " .. Studio.pageIndex .. " to " .. name) + return true +end + +function Studio.deletePage(index) + local skin = Studio.skin + index = index or Studio.pageIndex + if not skin or not skin.pages[index] then return false end + if #skin.pages <= 1 then + setStatus("A skin needs at least one page.", true) + return false + end + Studio.pushUndo() + table.remove(skin.pages, index) + for i, page in ipairs(skin.pages) do page.index = i end + Studio.pageIndex = math.min(Studio.pageIndex, #skin.pages) + Studio.selected = nil + syncActive() + markDirty() + setStatus("Deleted a page") + return true +end + +function Studio.addPage() + local skin = Studio.skin + if not skin then return end + Studio.pushUndo() + local page = TouchSkin.newSkin(skin.id).pages[1] + page.name = "page" .. (#skin.pages + 1) + page.index = #skin.pages + 1 + skin.pages[#skin.pages + 1] = page + Studio.pageIndex = #skin.pages + Studio.selected = nil + syncActive() + Studio.syncCanvasToPage() + markDirty() +end + +function Studio.save() + local skin = Studio.skin + if not skin then return end + local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "") + if id == "" then + setStatus("Give the skin a name first.", true) + return + end + local dest, failed = TouchSkin.saveTo(skin, id) + if not dest then + setStatus("Save failed: " .. tostring(failed), true) + return + end + Studio.dirty = false + Studio.refreshAvailable() + local text = "Saved to " .. dest + if type(failed) == "table" and failed[1] then + text = text .. " (" .. #failed .. " image(s) not found)" + end + setStatus(text) +end + +function Studio.exportAs(kind) + local skin = Studio.skin + if not skin then return nil end + if Studio.dirty then Studio.save() end + if Studio.dirty then return nil end + local path, missing, warnings + if kind == "retroarch" then + path, missing = TouchSkin.exportRetroArch(skin) + elseif kind == "delta" then + path, missing, warnings = TouchSkin.exportDelta(skin) + else + path, missing = TouchSkin.export(skin) + end + if not path then + setStatus("Export failed: " .. tostring(missing), true) + return nil + end + local text = "Exported " .. path + if type(missing) == "table" and missing[1] then + text = text .. " (" .. #missing .. " image(s) not found)" + end + if type(warnings) == "table" and warnings[1] then + text = text .. " " .. tostring(warnings[1]) + end + setStatus(text) + Studio.lastExport = path + Studio.refreshAvailable() + return path +end + +function Studio.export() + return Studio.exportAs("native") +end + +function Studio.openExportMenu() + Studio.openModal("export") + return true +end + +function Studio.fileUrl(path) + path = tostring(path):gsub("\\", "/") + if path:sub(1, 1) ~= "/" then path = "/" .. path end + local encoded = path:gsub("[^%w%-%._~/:]", function(c) + return string.format("%%%02X", string.byte(c)) + end) + return "file://" .. encoded +end + +function Studio.revealExport() + local path = Studio.lastExport + if not path then return false end + if not (love and love.filesystem and love.filesystem.getSaveDirectory) then + return false + end + local dir = love.filesystem.getSaveDirectory() + if not dir then return false end + if love.system and love.system.openURL then + pcall(love.system.openURL, Studio.fileUrl(dir)) + end + setStatus("Saved in " .. dir .. "/" .. path) + return true +end + +function Studio.play() + local skin = Studio.skin + if not skin then return end + Studio.save() + if Studio.dirty then return end + local opts = SaveData.loadOptions() + local block = type(opts.touchControls) == "table" and opts.touchControls or {} + block.enabled = true + block.skin = skin.id + opts.touchControls = block + SaveData.saveOptions(opts) + Studio.forgetActiveSkin() + -- Handing off here would unload the studio inside its own draw pass and + -- leave the rest of this frame drawing against a dead skin; Studio.update + -- runs it on the next tick instead. + Studio.pendingPlay = true + setStatus("Starting the game with " .. skin.id .. "...") +end + +-- ---------------------------------------------------------------- canvas + +function Studio.canvasRect(x, y, w, h) + local canvas = Studio.canvas() + local aspect = canvas.w / canvas.h + if not aspect or aspect <= 0 then aspect = 1 end + local cw, ch = w, w / aspect + if ch > h then ch, cw = h, h * aspect end + local z = Studio.viewZoom or 1 + if z < 0.999 then + cw, ch = cw * z, ch * z + end + return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch +end + +local function controlRect(page, ctl, r) + local cx, cy, halfW, halfH = + TouchSkin.controlGeometry(page, ctl, r.w, r.h, r.x, r.y) + return cx - halfW, cy - halfH, halfW * 2, halfH * 2 +end + +function Studio.snapLines(page, r, skipIndex) + local xs, ys = {}, {} + local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y) + xs[1], xs[2], xs[3] = px, px + pw * 0.5, px + pw + ys[1], ys[2], ys[3] = py, py + ph * 0.5, py + ph + for i, ctl in ipairs(page.controls or {}) do + if i ~= skipIndex then + local bx, by, bw, bh = controlRect(page, ctl, r) + xs[#xs + 1] = bx + xs[#xs + 1] = bx + bw * 0.5 + xs[#xs + 1] = bx + bw + ys[#ys + 1] = by + ys[#ys + 1] = by + bh * 0.5 + ys[#ys + 1] = by + bh + end + end + return xs, ys +end + +local function viewportRect(page, r) + local x, y, w, h = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y) + if not x then return nil end + return x, y, w, h +end + +local function handleRects(bx, by, bw, bh) + local out = {} + for _, h in ipairs(HANDLES) do + out[#out + 1] = { + id = h[1], + x = bx + bw * h[2] - HANDLE * Kit.scale, + y = by + bh * h[3] - HANDLE * Kit.scale, + w = HANDLE * 2 * Kit.scale, h = HANDLE * 2 * Kit.scale, + } + end + return out +end + +local function applyResize(id, bx, by, bw, bh, dx, dy) + if id:find("w") then bx = bx + dx bw = bw - dx end + if id:find("e") then bw = bw + dx end + if id:find("n") then by = by + dy bh = bh - dy end + if id:find("s") then bh = bh + dy end + return bx, by, math.max(4, bw), math.max(4, bh) +end + +function Studio.beginCanvasDrag(mx, my, r) + local page = Studio.page() + if not page then return end + Studio.guides = nil + + local ctl = Studio.selectedControl() + if ctl then + local bx, by, bw, bh = controlRect(page, ctl, r) + for _, h in ipairs(handleRects(bx, by, bw, bh)) do + if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then + Studio.pushUndo() + Studio.drag = { kind = "control-resize", handle = h.id, mx = mx, my = my, + bx = bx, by = by, bw = bw, bh = bh } + return + end + end + end + + local vx, vy, vw, vh = viewportRect(page, r) + if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then + for _, h in ipairs(handleRects(vx, vy, vw, vh)) do + if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then + Studio.pushUndo() + Studio.drag = { kind = "viewport-resize", handle = h.id, mx = mx, my = my, + bx = vx, by = vy, bw = vw, bh = vh } + Studio.selected = nil + return + end + end + end + + for i = #page.controls, 1, -1 do + local c = page.controls[i] + local bx, by, bw, bh = controlRect(page, c, r) + if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then + Studio.selected = i + Studio.pushUndo() + Studio.drag = { kind = "control-move", mx = mx, my = my, + bx = bx, by = by, bw = bw, bh = bh } + return + end + end + + if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" + and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then + Studio.selected = nil + Studio.pushUndo() + Studio.drag = { kind = "viewport-move", mx = mx, my = my, + bx = vx, by = vy, bw = vw, bh = vh } + return + end + + Studio.selected = nil +end + +function Studio.updateDrag(mx, my, r) + local d = Studio.drag + local page = Studio.page() + if not d or not page then return end + local dx, dy = mx - d.mx, my - d.my + local bx, by, bw, bh = d.bx, d.by, d.bw, d.bh + + if d.kind == "control-move" or d.kind == "viewport-move" then + bx, by = bx + dx, by + dy + else + bx, by, bw, bh = applyResize(d.handle, bx, by, bw, bh, dx, dy) + end + + if d.kind:find("viewport") and Studio.aspectLock and d.handle then + -- Derive the axis the handle does not drive, or an edge handle fights the + -- lock and appears dead. A corner drives whichever way the pointer moved + -- furthest, so dragging it up/down resizes as readily as left/right. + local byHeight = (d.handle == "n" or d.handle == "s") + if not byHeight and #d.handle > 1 then + byHeight = math.abs(dy) > math.abs(dx) + end + if byHeight then + local newW = bh * GB_ASPECT + if d.handle:find("w") then bx = bx + (bw - newW) + elseif not d.handle:find("e") then bx = bx + (bw - newW) * 0.5 end + bw = newW + else + local newH = bw / GB_ASPECT + if d.handle:find("n") then by = by + (bh - newH) + elseif not d.handle:find("s") then by = by + (bh - newH) * 0.5 end + bh = newH + end + end + + if d.kind == "control-move" then + local tol = Studio.SNAP_PX * Kit.scale + local xs, ys = Studio.snapLines(page, r, Studio.selected) + local offX, lineX = Studio.snapOffset({ bx, bx + bw * 0.5, bx + bw }, xs, tol) + local offY, lineY = Studio.snapOffset({ by, by + bh * 0.5, by + bh }, ys, tol) + bx, by = bx + offX, by + offY + Studio.guides = { x = lineX, y = lineY } + end + + local px, py, pw, ph = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y) + if pw <= 0 or ph <= 0 then return end + if d.kind:find("control") then + local ctl = Studio.selectedControl() + if not ctl then return end + ctl.x = clamp01(((bx + bw * 0.5) - px) / pw) + ctl.y = clamp01(((by + bh * 0.5) - py) / ph) + ctl.rangeX = math.max(0.002, (bw * 0.5) / pw) + ctl.rangeY = math.max(0.002, (bh * 0.5) / ph) + else + -- Permit an anchor to live beyond every canvas edge. That is useful for + -- intentional off-centre compositions and mirrors the normal Screen Pos + -- behaviour at runtime; only a non-zero size is required. + page.viewport = { + x = (bx - px) / pw, y = (by - py) / ph, + w = math.max(0.02, bw / pw), h = math.max(0.02, bh / ph), + } + end + markDirty() +end + +-- ----------------------------------------------------------------- draw + +local function drawGameTest(r, page) + -- Test letterboxes a 160x144 picture inside the screen cutout, the same + -- way gameplay fits the Game Boy surface into the hole. + local vx, vy, vw, vh = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y) + if not vx then vx, vy, vw, vh = r.x, r.y, r.w, r.h end + local s = math.min(vw / 160, vh / 144) + local gw, gh = 160 * s, 144 * s + local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5 + Theme.fill(r.x, r.y, r.w, r.h, { 8, 12, 8 }, 1) + Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1) + local tile = math.max(2, math.floor(16 * s)) + for row = 0, 8 do + for col = 0, 9 do + if (row + col) % 2 == 0 then + Theme.fill(gx + col * tile, gy + row * tile, tile, tile, + { 139, 172, 15 }, 0.45) + end + end + end + Theme.fill(gx + gw * 0.10, gy + gh * 0.15, gw * 0.22, gh * 0.18, + { 48, 98, 48 }, 0.9) + Theme.fill(gx + gw * 0.58, gy + gh * 0.49, gw * 0.12, gh * 0.18, + { 48, 98, 48 }, 0.9) + Theme.fill(gx + gw * 0.14, gy + gh * 0.70, gw * 0.72, gh * 0.16, + { 224, 248, 208 }, 1) + Kit.text("small", "TEST BATTLE", gx + gw * 0.18, gy + gh * 0.74, + { 24, 56, 24 }) +end + +local function drawCanvas(x, y, w, h) + Studio.canvasWorkspace = { x = x, y = y, w = w, h = h } + local page = Studio.page() + local r = { } + r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h) + Studio.lastCanvas = r + Theme.fill(x, y, w, h, { 10, 12, 18 }, 1) + if not page then return r end + + Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1) + + TouchSkin.setSurface(r.x, r.y, r.w, r.h) + syncActive() + + local vx, vy, vw, vh = viewportRect(page, r) + if Studio.testing then + drawGameTest(r, page) + elseif vx then + local scale = math.min(vw / 160, vh / 144) + local gw, gh = 160 * scale, 144 * scale + local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5 + Theme.fill(vx, vy, vw, vh, { 12, 12, 12 }, 1) + Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1) + Kit.textCenter("small", "160 x 144", gx, gy + gh * 0.5 - Kit.textHeight("small") * 0.5, + gw, { 20, 40, 20 }) + end + + TouchControls:draw() + TouchSkin.setSurface(nil) + + Theme.strokeRounded(r.x, r.y, r.w, r.h, PAL.line, Theme.A.hairline, 1, 2) + + if Studio.testing then return r end + + if vx then + Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2) + Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue) + if not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then + local a = Studio.selectedControl() and 0.45 or 1 + for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do + Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a) + end + end + end + + for i, ctl in ipairs(page.controls) do + local bx, by, bw, bh = controlRect(page, ctl, r) + local selected = (i == Studio.selected) + local c = ctl.decorative and PAL.muted or (selected and PAL.green or PAL.line) + Theme.strokeRounded(bx, by, bw, bh, c, selected and 1 or 0.45, + selected and 2 or 1, ctl.shape == "radial" and bh * 0.5 or 2) + if Studio.showLabels and bw > 8 * Kit.scale then + Kit.textCenter("micro", Kit.ellipsize("micro", ctl.spec, bw), bx, + by + bh * 0.5 - Kit.textHeight("micro") * 0.5, bw, + selected and PAL.green or PAL.muted) + end + if selected then + for _, hd in ipairs(handleRects(bx, by, bw, bh)) do + Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.green, 1) + end + Kit.text("small", ctl.spec, bx, by - Kit.textHeight("small") - 2 * Kit.scale, + PAL.green) + end + end + + local guides = Studio.drag and Studio.guides + if guides then + if guides.x then + Theme.fill(guides.x, r.y, math.max(1, Kit.scale), r.h, PAL.yellow, 0.6) + end + if guides.y then + Theme.fill(r.x, guides.y, r.w, math.max(1, Kit.scale), PAL.yellow, 0.6) + end + end + return r +end + +local function inspectorBody(x, y, w) + if not Studio.skin then return y end + local page = Studio.page() + local ctl = Studio.selectedControl() + local rowH = math.max(Kit.tapMin(), 30 * Kit.scale) + local gap = 6 * Kit.scale + local cy = y + + Kit.caption(x, cy, "SKIN") + cy = cy + Kit.textHeight("small") + gap + Studio.skinIdField = Kit.textfield("skinid", x, cy, w, rowH, + Studio.skinIdField, "skin name") + cy = cy + rowH + gap + + local third = (w - gap * 2) / 3 + if Kit.button(x, cy, third, rowH, "Save", { id = "save" }) then Studio.save() end + if Kit.button(x + third + gap, cy, third, rowH, "Export >", + { id = "export" }) then + Studio.openExportMenu() + end + if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Play", { id = "play" }) then + Studio.play() + end + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", "Format: " .. Studio.skinFormat(), x, cy, PAL.faint) + cy = cy + Kit.textHeight("small") + gap * 2 + + Kit.caption(x, cy, "OPEN") + cy = cy + Kit.textHeight("small") + gap + local half = (w - gap) / 2 + if Kit.button(x, cy, half, rowH, "New", { id = "new" }) then + Studio.guard("Start a new skin and lose the unsaved changes?", + Studio.newSkin) + end + if Kit.button(x + half + gap, cy, half, rowH, + "Load " .. (#Studio.available > 0 and "\226\150\184" or "-"), + { id = "load", enabled = #Studio.available > 0 }) then + Studio.openLoadPicker() + end + cy = cy + rowH + gap * 2 + + Kit.caption(x, cy, "PAGE " .. Studio.pageIndex .. " / " .. #(Studio.skin.pages or {})) + cy = cy + Kit.textHeight("small") + gap + if Kit.button(x, cy, half, rowH, + "Pages \226\150\184 " .. tostring(page and page.name or "-"), + { id = "pagelist" }) then + Studio.openPageMenu() + end + if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then + Studio.addPage() + end + cy = cy + rowH + gap + local lock = TouchSkin.pageOrient(page) or "any" + if Kit.button(x, cy, half, rowH, Studio.ORIENT_LABEL[lock] or "Lock: Off", + { id = "orient" }) then + Studio.cyclePageOrient(1) + end + local matchOn = Studio.matchOrient + Studio.matchOrient = Kit.checkbox(x + half + gap, cy, half, rowH, + Studio.matchOrient, "Match canvas", "matchorient") + if Studio.matchOrient and not matchOn then Studio.syncCanvasToPage() end + cy = cy + rowH + gap + + if page then + local bezel = page.imagePath or page.rasterName or "(none)" + local pickW = 82 * Kit.scale + local cycleW = w - pickW - gap + if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then + Studio.openImagePicker("bezel") + end + if Kit.button(x + cycleW + gap, cy, pickW, rowH, "Import", + { id = "bezelpick" }) then + Studio.importImageFile("bezel") + end + cy = cy + rowH + gap + local vpLabel = (page.viewport or page.screenFit == "remainder") + and "Screen cutout: ON" or "Screen cutout: OFF" + if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp", + enabled = not Studio.canvas().lockViewport }) then + Studio.toggleViewport() + end + Studio.aspectLock = Kit.checkbox(x + half + gap, cy, half, rowH, + Studio.aspectLock, "10:9 lock", "aspect") + cy = cy + rowH + gap + if Kit.button(x, cy, w, rowH, "Detect screen from bezel", { id = "detect", + enabled = page.imagePath ~= nil and not Studio.canvas().lockViewport }) then + Studio.detectViewport() + end + cy = cy + rowH + gap * 2 + end + + Kit.caption(x, cy, "CONTROLS") + cy = cy + Kit.textHeight("small") + gap + if Kit.button(x, cy, third, rowH, "Add", { id = "add" }) then Studio.addControl() end + if Kit.button(x + third + gap, cy, third, rowH, "Dup", + { id = "dup", enabled = ctl ~= nil }) then + Studio.duplicateControl() + end + if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Del", + { id = "del", enabled = ctl ~= nil }) then + Studio.deleteControl() + end + cy = cy + rowH + gap + + if not ctl then + Kit.textWrapped("small", page and #page.controls == 0 + and "No controls yet. Add one, then drag it on the canvas." + or "Click a control on the canvas to edit it.", x, cy, w, PAL.muted, 3) + return cy + Kit.textHeight("small") * 3 + end + + local canvas = Studio.canvas() + local zW = 68 * Kit.scale + if Kit.button(x, cy, zW, rowH, "Back", { id = "zback", + enabled = (Studio.selected or 1) > 1 }) then + Studio.moveControlOrder(-1) + end + if Kit.button(x + zW + gap, cy, zW, rowH, "Front", { id = "zfront", + enabled = Studio.selected ~= nil and page ~= nil + and Studio.selected < #page.controls }) then + Studio.moveControlOrder(1) + end + Kit.text("small", ("%d / %d"):format(Studio.selected or 0, + page and #page.controls or 0), x + (zW + gap) * 2, + cy + (rowH - Kit.textHeight("small")) * 0.5, PAL.faint) + cy = cy + rowH + gap + + if Kit.button(x, cy, w, rowH, "Bind: " .. ctl.spec, { id = "bind" }) then + Studio.openBindPicker() + end + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", TouchSkin.describeBind(ctl.spec), x, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + gap + + if Kit.button(x, cy, half, rowH, "Shape: " .. ctl.shape, { id = "shape" }) then + Studio.pushUndo() + ctl.shape = ctl.shape == "radial" and "rect" or "radial" + markDirty() + end + if Kit.button(x + half + gap, cy, half, rowH, + string.format("Reach x%.2f", ctl.rangeMod), { id = "rangemod" }) then + Studio.pushUndo() + ctl.rangeMod = ctl.rangeMod >= 2 and 0.5 or (ctl.rangeMod + 0.25) + markDirty() + end + cy = cy + rowH + gap + + local quarter = (w - gap * 3) / 4 + local fields = { + { "X", round((ctl.x - ctl.rangeX) * canvas.w) }, + { "Y", round((ctl.y - ctl.rangeY) * canvas.h) }, + { "W", round(ctl.rangeX * 2 * canvas.w) }, + { "H", round(ctl.rangeY * 2 * canvas.h) }, + } + for i, f in ipairs(fields) do + local fx = x + (quarter + gap) * (i - 1) + Kit.text("small", f[1], fx, cy, PAL.faint) + local id = "num" .. f[1] + local shown = Studio.editing == id and Studio.editBuf or tostring(f[2]) + local typed = Kit.textfield(id, fx, cy + Kit.textHeight("small"), + quarter, rowH, shown, "0") + if Kit.focus == id then + Studio.editing, Studio.editBuf = id, typed + elseif Studio.editing == id then + Studio.commitField(id, Studio.editBuf) + Studio.editing, Studio.editBuf = nil, nil + end + end + cy = cy + Kit.textHeight("small") + rowH + gap + Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint) + cy = cy + Kit.textHeight("small") + gap + + local pickW = 82 * Kit.scale + local artW = w - pickW - gap + local idle = ctl.imagePath or "(none)" + if Kit.button(x, cy, artW, rowH, "Idle art: " .. idle, { id = "img" }) then + Studio.openImagePicker("idle") + end + if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgpick" }) then + Studio.importImageFile("idle") + end + cy = cy + rowH + gap + local pressed = ctl.pressedImagePath or "(none)" + if Kit.button(x, cy, artW, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then + Studio.openImagePicker("pressed") + end + if Kit.button(x + artW + gap, cy, pickW, rowH, "Import", { id = "imgppick" }) then + Studio.importImageFile("pressed") + end + return cy + rowH +end + +local function drawInspector(x, y, w, h) + local pad = 10 * Kit.scale + Kit.card(x, y, w, h) + + local scroll = Studio.inspectorScroll or 0 + if Kit.hit(x, y, w, h) and (Kit.wheelY or 0) ~= 0 then + scroll = scroll - Kit.wheelY * 48 * Kit.scale + end + local maxScroll = math.max(0, (Studio.inspectorH or 0) - (h - pad * 2)) + scroll = math.max(0, math.min(scroll, maxScroll)) + Studio.inspectorScroll = scroll + + Kit.pushClip(x, y, w, h) + local top = y + pad - scroll + local endY = inspectorBody(x + pad, top, w - pad * 2) or top + Studio.inspectorH = endY - top + Kit.popClip() + + if maxScroll > 0 then + local frac = h / (h + maxScroll) + local barH = math.max(24 * Kit.scale, h * frac) + local barY = y + (h - barH) * (scroll / maxScroll) + Theme.fill(x + w - 4 * Kit.scale, barY, 3 * Kit.scale, barH, PAL.line, 0.4) + end +end + +function Studio.commitField(id, text) + local ctl = Studio.selectedControl() + if not ctl then return end + local n = tonumber(text) + if not n then return end + Studio.pushUndo("field") + local canvas = Studio.canvas() + local left = (ctl.x - ctl.rangeX) * canvas.w + local top = (ctl.y - ctl.rangeY) * canvas.h + local wpx = ctl.rangeX * 2 * canvas.w + local hpx = ctl.rangeY * 2 * canvas.h + if id == "numX" then left = n + elseif id == "numY" then top = n + elseif id == "numW" then wpx = math.max(1, n) + elseif id == "numH" then hpx = math.max(1, n) end + ctl.rangeX = (wpx * 0.5) / canvas.w + ctl.rangeY = (hpx * 0.5) / canvas.h + ctl.x = clamp01((left + wpx * 0.5) / canvas.w) + ctl.y = clamp01((top + hpx * 0.5) / canvas.h) + markDirty() +end + +local function modalFrame(W, H, title, wFrac, hFrac) + local ox, oy, sw, sh, winW, winH = 0, 0, W, H, W, H + local f = Studio._frame + if f then + ox, oy, sw, sh = f.x, f.y, f.w, f.h + winW, winH = f.W, f.H + end + Theme.fill(0, 0, winW, winH, PAL.bg, 0.85) + local pad = 14 * Kit.scale + local mw = math.min(sw - pad * 2, math.max(320 * Kit.scale, (wFrac or 0.6) * sw)) + local mh = math.min(sh - pad * 2, math.max(220 * Kit.scale, (hFrac or 0.72) * sh)) + local mx = math.floor(ox + (sw - mw) * 0.5) + local my = math.floor(oy + (sh - mh) * 0.5) + Kit.card(mx, my, mw, mh) + Kit.textBold("title", title, mx + pad, my + pad * 0.7, PAL.heading) + return mx, my, mw, mh, pad +end + +local function modalFooter(mx, my, mw, mh, pad) + local bh = math.max(Kit.tapMin(), 32 * Kit.scale) + local by = my + mh - pad - bh + if Kit.button(mx + mw - pad - 110 * Kit.scale, by, 110 * Kit.scale, bh, + "Close", { id = "modal-close" }) then + Studio.closeModal() + end + return by, bh +end + +local function modalScroll(modal, x, y, w, h, contentH) + local maxScroll = Kit.scrollExtent(contentH, h) + modal.scroll = select(1, Kit.scrollWheel(modal.scroll or 0, maxScroll, + x, y, w, h)) + modal.scroll = Kit.scrollClamp(modal.scroll, maxScroll) + return modal.scroll, maxScroll +end + +local function drawBindModal(W, H) + local modal = Studio.modal + local ctl = Studio.selectedControl() + local mx, my, mw, mh, pad = modalFrame(W, H, "Choose a bind", 0.66, 0.8) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by = modalFooter(mx, my, mw, mh, pad) + local viewH = by - top - pad + local rowH = math.max(Kit.tapMin(), 30 * Kit.scale) + local gap = 6 * Kit.scale + local cols = math.max(2, math.floor((mw - pad * 2) / (150 * Kit.scale))) + local colW = (mw - pad * 2 - gap * (cols - 1)) / cols + + local contentH = modal.contentH or viewH + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local cy = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, + Kit.scrollExtent(contentH, viewH)) + local startY = cy + + Kit.caption(mx + pad, cy, "COMBINE") + cy = cy + Kit.textHeight("small") + gap + for i, part in ipairs(Studio.BIND_PARTS) do + local col = (i - 1) % cols + local px = mx + pad + col * (colW + gap) + local py = cy + math.floor((i - 1) / cols) * (rowH + gap) + local on = ctl and Studio.hasBindPart(ctl.spec, part) + if Kit.chip(px, py, colW, rowH, part, on, on and PAL.green or nil, + "bindpart-" .. part) then + Studio.toggleSelectedBindPart(part) + end + end + cy = cy + math.ceil(#Studio.BIND_PARTS / cols) * (rowH + gap) + gap + + for _, group in ipairs(Studio.BIND_GROUPS) do + Kit.caption(mx + pad, cy, group.title) + cy = cy + Kit.textHeight("small") + gap + for i, spec in ipairs(group.specs) do + local col = (i - 1) % cols + local px = mx + pad + col * (colW + gap) + local py = cy + math.floor((i - 1) / cols) * (rowH + gap) + local active = ctl and ctl.spec == spec + if Kit.button(px, py, colW, rowH, spec, + { id = "bind-" .. spec, kind = active and "accent" or nil, + font = "small" }) then + Studio.setBindSpec(spec) + Studio.closeModal() + end + end + cy = cy + math.ceil(#group.specs / cols) * (rowH + gap) + gap + end + modal.contentH = cy - startY + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, + Kit.scrollExtent(contentH, viewH)) + + if ctl then + Kit.text("small", Kit.ellipsize("small", + "Bind: " .. ctl.spec .. " -> " .. TouchSkin.describeBind(ctl.spec), + mw - pad * 2 - 120 * Kit.scale), mx + pad, by + Kit.scale * 8, PAL.muted) + end +end + +local function drawImageModal(W, H) + local modal = Studio.modal + local mx, my, mw, mh, pad = modalFrame(W, H, + "Choose art for the " .. Studio.imageTargetLabel(), 0.7, 0.8) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by, bh = modalFooter(mx, my, mw, mh, pad) + if Kit.button(mx + pad, by, 150 * Kit.scale, bh, "Import a file...", + { id = "modal-import", kind = "accent", + enabled = FilePicker.available() }) then + local target = Studio.imageTarget + Studio.closeModal() + Studio.importImageFile(target) + end + + local viewH = by - top - pad + local gap = 8 * Kit.scale + local tile = math.max(72 * Kit.scale, 96 * Kit.scale) + local cols = math.max(2, math.floor((mw - pad * 2) / (tile + gap))) + local tileW = (mw - pad * 2 - gap * (cols - 1)) / cols + local tileH = tileW * 0.75 + Kit.textHeight("micro") + 6 * Kit.scale + + local list = { false } + for _, rel in ipairs(Studio.images or {}) do list[#list + 1] = rel end + local rows = math.ceil(#list / cols) + local contentH = rows * (tileH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + local current = Studio.currentImagePath() + + for i, rel in ipairs(list) do + local col = (i - 1) % cols + local row = math.floor((i - 1) / cols) + local px = mx + pad + col * (tileW + gap) + local py = baseY + row * (tileH + gap) + local selected = (rel or nil) == current + local clicked = Kit.row(px, py, tileW, tileH, selected, "imgtile-" .. i) + local art = rel and Studio.thumb(rel) or nil + local artH = tileH - Kit.textHeight("micro") - 6 * Kit.scale + if art and art.getWidth then + local iw, ih = art:getWidth(), art:getHeight() + if iw > 0 and ih > 0 then + local scale = math.min((tileW - 8 * Kit.scale) / iw, + (artH - 8 * Kit.scale) / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(art, px + (tileW - iw * scale) * 0.5, + py + (artH - ih * scale) * 0.5, 0, scale, scale) + end + elseif not rel then + Kit.textCenter("small", "(none)", px, + py + artH * 0.5 - Kit.textHeight("small") * 0.5, tileW, PAL.muted) + end + Kit.textCenter("micro", Kit.ellipsize("micro", rel or "no art", tileW), + px, py + tileH - Kit.textHeight("micro") - 2 * Kit.scale, tileW, + selected and PAL.green or PAL.detail) + if clicked then Studio.chooseImage(rel or nil) end + end + if #list == 1 then + Kit.text("small", "No images in this skin yet. Import one.", mx + pad, + baseY, PAL.muted) + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawOpenModal(W, H) + local modal = Studio.modal + local mx, my, mw, mh, pad = modalFrame(W, H, "Open a skin", 0.62, 0.76) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by = modalFooter(mx, my, mw, mh, pad) + local viewH = by - top - pad + local rowH = math.max(Kit.tapMin(), 46 * Kit.scale) + local gap = 4 * Kit.scale + local list = Studio.available or {} + -- A visible Off choice is more intentional than making people infer that + -- an empty selection disables the overlay. It also works from the skin + -- grid on phones, where the normal pad editor is not the destination. + local offH = rowH + gap + local contentH = offH + #list * (rowH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + + local offClicked = Kit.row(mx + pad, baseY, mw - pad * 2, rowH, false, + "open-off") + Kit.text("mono", "Off", mx + pad * 2, baseY + 6 * Kit.scale, PAL.red) + Kit.text("small", "Hide all on-screen controls.", mx + pad * 2, + baseY + 6 * Kit.scale + Kit.textHeight("mono"), PAL.muted) + if offClicked then Studio.disableTouchControls() end + + for i, entry in ipairs(list) do + local py = baseY + offH + (i - 1) * (rowH + gap) + local selected = Studio.skin and Studio.skin.id == entry.id + local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, + "open-" .. entry.id) + Kit.text("mono", Kit.ellipsize("mono", entry.id, mw - pad * 4), + mx + pad * 2, py + 6 * Kit.scale, selected and PAL.green or PAL.heading) + Kit.text("small", Kit.ellipsize("small", Studio.skinSummary(entry), + mw - pad * 4), mx + pad * 2, py + 6 * Kit.scale + Kit.textHeight("mono"), + PAL.muted) + if clicked then Studio.loadEntry(entry.id) end + end + if #list == 0 then + Kit.text("small", "No skins installed yet.", mx + pad, baseY, PAL.muted) + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawPageModal(W, H) + local modal = Studio.modal + local skin = Studio.skin + local mx, my, mw, mh, pad = modalFrame(W, H, "Pages", 0.58, 0.72) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by, bh = modalFooter(mx, my, mw, mh, pad) + local gap = 6 * Kit.scale + -- Keep the page name easy to edit: one full-width field, then its actions + -- below it, with Close retaining its own footer row. + local fieldY = by - bh * 2 - gap * 2 + local actionY = by - bh - gap + local innerW = mw - pad * 2 + local actionW = (innerW - gap) * 0.5 + Studio.pageNameField = Kit.textfield("pagename", mx + pad, fieldY, innerW, bh, + Studio.pageNameField or "", "page name") + if Kit.button(mx + pad, actionY, actionW, bh, "Rename", + { id = "page-rename" }) then + Studio.renamePage(Studio.pageNameField) + end + if Kit.button(mx + pad + actionW + gap, actionY, actionW, + bh, "Delete", { id = "page-del", kind = "danger", + enabled = skin and #skin.pages > 1 }) then + local index = Studio.pageIndex + Studio.closeModal() + Studio.ask("Delete this page and everything on it?", + function() Studio.deletePage(index) end, "Delete") + end + + local viewH = fieldY - top - pad + local rowH = math.max(Kit.tapMin(), 44 * Kit.scale) + local pages = (skin and skin.pages) or {} + local contentH = #pages * (rowH + gap) + local at = modalScroll(modal, mx + pad, top, mw - pad * 2, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) + for i = 1, #pages do + local py = baseY + (i - 1) * (rowH + gap) + local selected = i == Studio.pageIndex + local clicked = Kit.row(mx + pad, py, mw - pad * 2, rowH, selected, + "page-" .. i) + local name, detail = Studio.pageLabel(i) + Kit.text("mono", Kit.ellipsize("mono", i .. ". " .. name, mw - pad * 4), + mx + pad * 2, py + 5 * Kit.scale, selected and PAL.green or PAL.heading) + Kit.text("small", Kit.ellipsize("small", detail, mw - pad * 4), + mx + pad * 2, py + 5 * Kit.scale + Kit.textHeight("mono"), PAL.muted) + if clicked then + Studio.setPage(i) + Studio.pageNameField = name + end + end + Kit.scrollEnd(mx + pad, top, mw - pad * 2, viewH, at, maxScroll) +end + +local function drawExportModal(W, H) + local mx, my, mw, mh, pad = modalFrame(W, H, "Export this skin", 0.56, 0.6) + local cy = my + pad + Kit.textHeight("title") + pad + local rowH = math.max(Kit.tapMin(), 34 * Kit.scale) + local gap = 6 * Kit.scale + local by, bh = modalFooter(mx, my, mw, mh, pad) + for _, spec in ipairs(Studio.EXPORTS) do + if Kit.button(mx + pad, cy, mw - pad * 2, rowH, spec.label, + { id = "export-" .. spec.id, kind = "accent" }) then + Studio.exportAs(spec.id) + Studio.closeModal() + end + cy = cy + rowH + 2 * Kit.scale + Kit.text("small", Kit.ellipsize("small", spec.hint, mw - pad * 2), + mx + pad, cy, PAL.muted) + cy = cy + Kit.textHeight("small") + gap + end + if Studio.lastExport then + if Kit.button(mx + pad, by, 150 * Kit.scale, bh, "Show the file", + { id = "export-reveal" }) then + Studio.revealExport() + end + end + Kit.textWrapped("small", + "Exports land in the skins folder of your save directory.", + mx + pad, cy, mw - pad * 2, PAL.faint, 2) +end + +local function drawScreenModal(W, H) + local modal = Studio.modal + local mx, my, mw, mh, pad = modalFrame(W, H, "Screen & canvas", 0.62, 0.82) + local top = my + pad + Kit.textHeight("title") + pad * 0.5 + local by, bh = modalFooter(mx, my, mw, mh, pad) + local gap = 6 * Kit.scale + local rowH = math.max(Kit.tapMin(), 34 * Kit.scale) + local innerW = mw - pad * 2 + local half = (innerW - gap) * 0.5 + local page = Studio.page() + local locked = Studio.canvas().lockViewport + + local vpOn = page and (page.viewport or page.screenFit == "remainder") + if Kit.button(mx + pad, top, half, rowH, + vpOn and "Cutout: ON" or "Cutout: OFF", + { id = "screen-cutout", active = vpOn == true, + enabled = not locked }) then + Studio.toggleViewport() + end + if Kit.button(mx + pad + half + gap, top, half, rowH, + Studio.aspectLock and "Shape: 10:9" or "Shape: Free", + { id = "screen-shape", active = Studio.aspectLock }) then + Studio.aspectLock = not Studio.aspectLock + end + top = top + rowH + gap + if Kit.button(mx + pad, top, innerW, rowH, "Detect screen from bezel", + { id = "screen-bezel", + enabled = page ~= nil and page.imagePath ~= nil and not locked }) then + Studio.detectViewport() + end + top = top + rowH + gap + local live = Studio.canvas() + local detectLabel = ("Detect this screen (%dx%d)"):format(live.w, live.h) + if live.id ~= "this_device" then + local w, h = Studio.deviceSize() + detectLabel = ("Detect this screen (%dx%d)"):format(w, h) + end + if Kit.button(mx + pad, top, innerW, rowH, detectLabel, + { id = "screen-device", kind = "accent" }) then + Studio.detectDeviceCanvas() + end + top = top + rowH + gap * 1.5 + Kit.caption(mx + pad, top, "CANVAS") + top = top + Kit.textHeight("small") + gap + + local viewH = by - top - pad + local canvases = Studio.CANVASES + local contentH = #canvases * (rowH + gap) + local at = modalScroll(modal, mx + pad, top, innerW, viewH, contentH) + local maxScroll = Kit.scrollExtent(contentH, viewH) + local baseY = Kit.scrollBegin(mx + pad, top, innerW, viewH, at, maxScroll) + for i, spec in ipairs(canvases) do + local py = baseY + (i - 1) * (rowH + gap) + local selected = i == Studio.canvasIndex + local clicked = Kit.row(mx + pad, py, innerW, rowH, selected, + "canvas-" .. spec.id) + local label = spec.label + local detail + if spec.live then + local w, h = Studio.deviceSize() + detail = ("%dx%d · this window"):format(w, h) + else + detail = ("%dx%d"):format(spec.w, spec.h) + end + Kit.text("mono", Kit.ellipsize("mono", label, innerW - pad), + mx + pad * 2, py + 4 * Kit.scale, selected and PAL.green or PAL.heading) + Kit.text("small", Kit.ellipsize("small", detail, innerW - pad), + mx + pad * 2, py + 4 * Kit.scale + Kit.textHeight("mono"), PAL.muted) + if clicked then Studio.setCanvas(i) end + end + Kit.scrollEnd(mx + pad, top, innerW, viewH, at, maxScroll) +end + +local function drawConfirm(W, H) + local c = Studio.confirm + local mx, my, mw, mh, pad = modalFrame(W, H, "Unsaved changes", 0.42, 0.3) + Kit.textWrapped("button", c.text, mx + pad, + my + pad + Kit.textHeight("title") + pad, mw - pad * 2, PAL.text, 3) + local bh = math.max(Kit.tapMin(), 32 * Kit.scale) + local by = my + mh - pad - bh + local bw = (mw - pad * 2 - 12 * Kit.scale) / 3 + if Kit.button(mx + pad, by, bw, bh, "Save first", + { id = "confirm-save", kind = "accent" }) then + Studio.save() + if not Studio.dirty then Studio.confirmYes() end + end + if Kit.button(mx + pad + bw + 6 * Kit.scale, by, bw, bh, c.yesLabel, + { id = "confirm-yes", kind = "danger" }) then + Studio.confirmYes() + end + if Kit.button(mx + pad + (bw + 6 * Kit.scale) * 2, by, bw, bh, "Cancel", + { id = "confirm-no" }) then + Studio.confirmNo() + end +end + +function Studio.drawOverlay(W, H) + if Studio.confirm then + drawConfirm(W, H) + return true + end + local modal = Studio.modal + if not modal then return false end + if modal.kind == "bind" then drawBindModal(W, H) + elseif modal.kind == "image" then drawImageModal(W, H) + elseif modal.kind == "open" then drawOpenModal(W, H) + elseif modal.kind == "page" then drawPageModal(W, H) + elseif modal.kind == "export" then drawExportModal(W, H) + elseif modal.kind == "screen" then drawScreenModal(W, H) + else Studio.closeModal() end + return true +end + +local function drawLegacyStudio() + local winW, winH, ox, oy, W, H = Studio.safeFrame() + Kit.layout(W, H) + local mx, my = love.mouse.getPosition() + if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end + Kit.beginFrame(mx - ox, my - oy, Studio.clicked, Studio.wheel) + Studio.clicked, Studio.wheel = false, 0 + + Theme.fill(0, 0, winW, winH, PAL.bg, 1) + Studio.expireStatus() + Kit.blockClicks = Studio.modalUp() + love.graphics.push() + love.graphics.translate(ox, oy) + + local pad = 14 * Kit.scale + local mobile = Studio.isMobile() + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + -- A phone gets an intentional two-row toolbar. The desktop's single row + -- is excellent at a wide monitor, but it would run its canvas/test/history + -- controls beneath Close on a portrait display. + local barH = mobile and (btnH * 2 + pad * 1.5) or (btnH + pad) + + Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading) + local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2 + + local toolY = mobile and (pad * 0.5 + btnH + pad * 0.25) or (pad * 0.5) + local bx = mobile and pad or titleW + local canvas = Studio.canvas() + local canvasW = mobile and math.min(200 * Kit.scale, W * 0.4) or 200 * Kit.scale + if Kit.button(bx, toolY, canvasW, btnH, + canvas.label, { id = "canvas" }) then + Studio.setCanvas(Studio.canvasIndex + 1) + end + bx = bx + canvasW + 8 * Kit.scale + local testW = mobile and math.min(110 * Kit.scale, W * 0.21) or 110 * Kit.scale + if Kit.button(bx, toolY, testW, btnH, + Studio.testing and "Test: ON" or "Test: OFF", + { id = "test", active = Studio.testing }) then + Studio.testing = not Studio.testing + TouchControls:setPreview(not Studio.testing) + TouchControls:reset() + end + bx = bx + testW + 8 * Kit.scale + local smallW = 74 * Kit.scale + if Kit.button(bx, toolY, smallW, btnH, "Undo", + { id = "undo", font = "small", enabled = Studio.canUndo() }) then + Studio.undo() + end + bx = bx + smallW + 6 * Kit.scale + if Kit.button(bx, toolY, smallW, btnH, "Redo", + { id = "redo", font = "small", enabled = Studio.canRedo() }) then + Studio.redo() + end + bx = bx + smallW + 6 * Kit.scale + if not mobile then + if Kit.button(bx, toolY, smallW + 20 * Kit.scale, btnH, + Studio.showLabels and "Labels: ON" or "Labels: OFF", + { id = "labels", font = "small", active = Studio.showLabels }) then + Studio.showLabels = not Studio.showLabels + end + bx = bx + smallW + 26 * Kit.scale + end + + if Studio.dirty then + Kit.text("small", "unsaved", bx, toolY + btnH * 0.3, PAL.yellow) + end + + local closeW = 100 * Kit.scale + local offW = 74 * Kit.scale + if Kit.button(W - pad - closeW - offW - 6 * Kit.scale, pad * 0.5, offW, btnH, + "Off", { id = "off", kind = "danger" }) then + Studio.disableTouchControls() + end + local closed = false + if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close", + { id = "close" }) then + Studio.guard("Close the studio and lose the unsaved changes?", function() + closed = true + if Studio.onClose then Studio.onClose() end + end) + end + if closed then + Kit.blockClicks = false + love.graphics.pop() + Kit.endFrame() + return + end + + local bodyY = barH + pad * 0.5 + local bodyH = H - bodyY - pad + local r, cx, cw, footY + if Studio.isMobile() then + -- On a phone the canvas stays wide and the inspector becomes the lower + -- sheet. Both remain on screen, so a tapped control can be adjusted + -- without swapping modes or hiding the preview. + cx, cw = pad, W - pad * 2 + local canvasH = math.max(180 * Kit.scale, (bodyH - 46 * Kit.scale) * 0.46) + r = drawCanvas(cx, bodyY, cw, canvasH) + local inspectorY = bodyY + canvasH + 8 * Kit.scale + local inspectorH = math.max(0, H - inspectorY - pad - 30 * Kit.scale) + drawInspector(pad, inspectorY, W - pad * 2, inspectorH) + footY = H - pad - 24 * Kit.scale + else + local panelW = math.min(360 * Kit.scale, W * 0.34) + drawInspector(pad, bodyY, panelW, bodyH) + cx = pad * 2 + panelW + cw = W - cx - pad + r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale) + footY = bodyY + bodyH - 30 * Kit.scale + end + local msg = Studio.status + if not msg and Studio.testing then + local held = {} + for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end + table.sort(held) + msg = "TEST: click the pad. Held: " + .. (held[1] and table.concat(held, ", ") or "(none)") + end + if not msg then + msg = "Drag to move, corner handles to resize, blue box is the game screen." + end + Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, + Studio.statusErr and PAL.red or PAL.detail) + + Kit.blockClicks = false + love.graphics.pop() + if r then r.x, r.y = r.x + ox, r.y + oy end + if Studio.lastCanvas then + Studio.lastCanvas.x = Studio.lastCanvas.x + ox + Studio.lastCanvas.y = Studio.lastCanvas.y + oy + end + if Studio.canvasWorkspace then + Studio.canvasWorkspace.x = Studio.canvasWorkspace.x + ox + Studio.canvasWorkspace.y = Studio.canvasWorkspace.y + oy + end + Studio.drawOverlay(W, H) + + Kit.endFrame() + Studio.canvasArea = r +end + +-- ------------------------------------------------------ mobile-first studio + +-- Kept as an opt-in diagnostic renderer while downstream tools migrate; the +-- active Studio path below is the library/editor flow. +Studio.drawLegacy = drawLegacyStudio + +local function studioCard(x, y, w, h, id, active) + local focused = Kit.focusable(id, x, y, w, h) + Kit.card(x, y, w, h, active and "selected" + or (focused or Kit.hover(x, y, w, h))) + return Kit.press(x, y, w, h) or Kit._activateId == id +end + +local function drawSkinArtwork(image, x, y, w, h) + Theme.fillRounded(x, y, w, h, PAL.bg, 1, Theme.cardRadius() * 0.65) + if image and image.getDimensions then + local iw, ih = image:getDimensions() + if iw > 0 and ih > 0 then + local s = math.min((w - 14 * Kit.scale) / iw, (h - 14 * Kit.scale) / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, x + (w - iw * s) * 0.5, + y + (h - ih * s) * 0.5, 0, s, s) + return + end + end + -- A neutral mock reads as a touch skin even before it has artwork. + Theme.strokeRounded(x + w * 0.19, y + h * 0.10, w * 0.62, h * 0.44, + PAL.line, Theme.A.hairline, 1, 3) + Theme.fillRounded(x + w * 0.13, y + h * 0.64, w * 0.26, h * 0.18, + PAL.steel, 0.8, 3) + Theme.fillRounded(x + w * 0.60, y + h * 0.59, w * 0.13, h * 0.24, + PAL.steel, 0.8, h * 0.12) + Theme.fillRounded(x + w * 0.77, y + h * 0.54, w * 0.13, h * 0.24, + PAL.steel, 0.8, h * 0.12) +end + +local function drawLibrary() + local f = Studio._frame + local ox, oy, W, H = f.x, f.y, f.w, f.h + local pad = 14 * Kit.scale + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + local titleY = oy + pad * 0.55 + Kit.textBold("title", "My Skins", ox + pad, titleY, PAL.heading) + local closeW = math.min(112 * Kit.scale, W * 0.24) + if Kit.button(ox + W - pad - closeW, oy + pad * 0.5, closeW, btnH, "Close", + { id = "library-close" }) then + if Studio.onClose then Studio.onClose() end + return + end + + local y = oy + pad * 0.5 + btnH + pad + local gap = 8 * Kit.scale + local half = (W - pad * 2 - gap) * 0.5 + if Kit.button(ox + pad, y, half, btnH * 1.12, "+ New skin", + { id = "library-new", kind = "accent" }) then + Studio.newSkin() + Studio.enterEditor() + return + end + if Kit.button(ox + pad + half + gap, y, half, btnH * 1.12, "Import skin", + { id = "library-import" }) then + Studio.importSkinFile() + return + end + y = y + btnH * 1.12 + pad + + Kit.caption(ox + pad, y, "YOUR SKINS") + y = y + Kit.textHeight("small") + gap + + local entries = Studio.available or {} + local cardGap = 10 * Kit.scale + local listX, listTop = ox + pad, y + local listW = W - pad * 2 + local listH = math.max(0, (oy + H) - listTop - pad) + local gridW = listW - Kit.scrollGutter() + local cols = math.max(2, math.min(5, + math.floor((gridW + cardGap) / (250 * Kit.scale + cardGap)))) + local cardW = (gridW - cardGap * (cols - 1)) / cols + local textH = Kit.textHeight("mono") + Kit.textHeight("small") + local artH = math.min(cardW * 0.52, math.max(52 * Kit.scale, listH * 0.34)) + local cardH = artH + textH + btnH * 2 + 34 * Kit.scale + local activeId = Studio.activeSkinId() + + local rows = math.ceil(#entries / cols) + local contentH = rows * cardH + math.max(0, rows - 1) * cardGap + local maxScroll = Kit.scrollExtent(contentH, listH) + Studio.libraryScroll = Kit.scrollWheel(Studio.libraryScroll or 0, maxScroll, + listX, listTop, listW, listH) + Studio.libraryScroll = Kit.scrollClamp(Studio.libraryScroll, maxScroll) + local focusId = (Kit._ringShown and Kit.focusId) or nil + if focusId ~= Studio.libraryFocusId then + Studio.libraryFocusId = focusId + local focusSkin = focusId and tostring(focusId):match("^skin%-%a+%-(.+)$") + if focusSkin and maxScroll > 0 then + for i = 1, #entries do + if entries[i].id == focusSkin then + local bandTop = math.floor((i - 1) / cols) * (cardH + cardGap) + if bandTop < Studio.libraryScroll then + Studio.libraryScroll = Kit.scrollClamp(bandTop, maxScroll) + elseif bandTop + cardH > Studio.libraryScroll + listH then + Studio.libraryScroll = Kit.scrollClamp(bandTop + cardH - listH, + maxScroll) + end + break + end + end + end + end + Studio.libraryScrollMax = maxScroll + Studio.libraryView = { x = listX, y = listTop, w = listW, h = listH } + Studio.libraryRects = {} + + local jumped = false + local top = Kit.scrollBegin(listX, listTop, listW, listH, + Studio.libraryScroll, maxScroll) + for slot = 1, #entries do + local index = slot - 1 + local cx = listX + (index % cols) * (cardW + cardGap) + local cy = top + math.floor(index / cols) * (cardH + cardGap) + if cy + cardH >= listTop - listH and cy <= listTop + listH * 2 then + local entry = entries[slot] + local selected = entry.id == activeId + Kit.card(cx, cy, cardW, cardH, selected and "selected" or nil) + drawSkinArtwork(Studio.libraryThumb(entry), cx + 7 * Kit.scale, + cy + 7 * Kit.scale, cardW - 14 * Kit.scale, artH) + Kit.text("mono", Kit.ellipsize("mono", entry.id, cardW - 20 * Kit.scale), + cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale, + selected and PAL.green or PAL.heading) + local meta = Studio.skinSummary(entry) + Kit.text("small", Kit.ellipsize("small", meta, cardW - 20 * Kit.scale), + cx + 10 * Kit.scale, cy + artH + 15 * Kit.scale + Kit.textHeight("mono"), + PAL.muted) + local actionGap = 4 * Kit.scale + local actionW = (cardW - 20 * Kit.scale - actionGap) * 0.5 + local actionY = cy + cardH - btnH * 2 - actionGap - 8 * Kit.scale + Studio.libraryRects[entry.id] = + { x = cx + 8 * Kit.scale, y = actionY, w = actionW, h = btnH } + if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH, + selected and "Selected" or "Select", + { id = "skin-select-" .. entry.id, + kind = selected and "good" or "accent" }) then + Studio.selectEntry(entry.id) + end + if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH, + "Edit", { id = "skin-edit-" .. entry.id, kind = "accent" }) then + Studio.loadEntry(entry.id) + Studio.enterEditor() + jumped = true + break + end + actionY = actionY + btnH + actionGap + if Kit.button(cx + 8 * Kit.scale, actionY, actionW, btnH, + "Export", { id = "skin-export-" .. entry.id }) then + Studio.exportEntry(entry.id) + end + if Kit.button(cx + 8 * Kit.scale + actionW + actionGap, actionY, actionW, btnH, + "Delete", { id = "skin-delete-" .. entry.id, + kind = "danger", enabled = entry.source == "user" }) then + Studio.deleteEntry(entry.id) + end + end + end + Kit.scrollEnd(listX, listTop, listW, listH, Studio.libraryScroll, maxScroll) + if jumped then return end + + if #entries == 0 then + Kit.text("small", "Create a blank skin or import one to get started.", + listX, listTop + gap, PAL.muted) + end +end + +local function drawEditor() + local f = Studio._frame + local ox, oy, W, H = f.x, f.y, f.w, f.h + local pad = 14 * Kit.scale + local btnH = math.max(Kit.tapMin(), 32 * Kit.scale) + local gap = 7 * Kit.scale + local backW = math.min(120 * Kit.scale, W * 0.23) + if Kit.button(ox + pad, oy + pad * 0.5, backW, btnH, "My Skins", + { id = "editor-back" }) then + Studio.backToLibrary() + end + local closeW = math.min(90 * Kit.scale, W * 0.17) + local saveW = math.min(90 * Kit.scale, W * 0.17) + local testW = math.min(100 * Kit.scale, W * 0.18) + local right = ox + W - pad + if Kit.button(right - closeW, oy + pad * 0.5, closeW, btnH, "Close", + { id = "editor-close" }) then + Studio.guard("Close the studio and lose the unsaved changes?", function() + if Studio.onClose then Studio.onClose() end + end) + end + right = right - closeW - gap + if Kit.button(right - saveW, oy + pad * 0.5, saveW, btnH, "Save", + { id = "editor-save", kind = "accent" }) then Studio.save() end + right = right - saveW - gap + if Kit.button(right - testW, oy + pad * 0.5, testW, btnH, + Studio.testing and "Test: ON" or "Test", + { id = "editor-test", active = Studio.testing }) then + Studio.testing = not Studio.testing + TouchControls:setPreview(not Studio.testing) + TouchControls:reset() + end + local titleX = ox + pad + backW + gap + local titleW = math.max(0, right - testW - gap - titleX) + Kit.textBold("button", Kit.ellipsize("button", + (Studio.skin and Studio.skin.name) or "Untitled skin", titleW), + titleX, oy + pad * 0.5 + (btnH - Kit.textHeight("button")) * 0.5, PAL.heading) + + local zH = math.max(Kit.tapMin(), 28 * Kit.scale) + local trayH = btnH * 2 + zH + gap * 4 + Kit.textHeight("small") + gap + local bodyY = oy + pad * 0.5 + btnH + pad + local trayY = oy + H - pad - trayH + local canvasH = math.max(120 * Kit.scale, trayY - bodyY - pad) + drawCanvas(ox + pad, bodyY, W - pad * 2, canvasH) + + Kit.card(ox + pad, trayY, W - pad * 2, trayH) + local innerX, innerW = ox + pad + gap, W - pad * 2 - gap * 2 + local actionW = (innerW - gap * 3) / 4 + local ctl = Studio.selectedControl() + if Kit.button(innerX, trayY + gap, actionW, btnH, "+ Control", + { id = "tray-add", kind = "accent" }) then + Studio.addControl() + Studio.openBindPicker() + end + if Kit.button(innerX + (actionW + gap), trayY + gap, actionW, btnH, + ctl and ("Bind: " .. ctl.spec) or "Bind", + { id = "tray-bind", enabled = ctl ~= nil }) then + Studio.openBindPicker() + end + if Kit.button(innerX + (actionW + gap) * 2, trayY + gap, actionW, btnH, + "Button art", { id = "tray-art", enabled = ctl ~= nil }) then + Studio.openImagePicker("idle") + end + if Kit.button(innerX + (actionW + gap) * 3, trayY + gap, actionW, btnH, + "Bezel art", { id = "tray-bezel" }) then + Studio.openImagePicker("bezel") + end + + local row2 = trayY + gap * 2 + btnH + if Kit.button(innerX, row2, actionW, btnH, "Pages", + { id = "tray-pages" }) then Studio.openPageMenu() end + if Kit.button(innerX + (actionW + gap), row2, actionW, btnH, + "Screen", { id = "tray-screen" }) then Studio.openScreenMenu() end + if Kit.button(innerX + (actionW + gap) * 2, row2, actionW, btnH, + Studio.aspectLock and "Shape: 10:9" or "Shape: Free", + { id = "tray-shape", active = Studio.aspectLock }) then + Studio.aspectLock = not Studio.aspectLock + end + if Kit.button(innerX + (actionW + gap) * 3, row2, actionW, btnH, + "Delete", { id = "tray-delete", kind = "danger", enabled = ctl ~= nil }) then + Studio.deleteControl() + end + + local row3 = row2 + btnH + gap + local zoomAt = Studio.zoomIndex() + if Kit.button(innerX, row3, actionW, zH, "Detect screen", + { id = "tray-detect" }) then + Studio.detectDeviceCanvas() + end + if Kit.button(innerX + (actionW + gap), row3, actionW, zH, "Zoom -", + { id = "zoom-out", enabled = zoomAt < #Studio.ZOOM_LEVELS }) then + Studio.zoomOut() + end + local pct = math.floor((Studio.viewZoom or 1) * 100 + 0.5) .. "%" + if Kit.button(innerX + (actionW + gap) * 2, row3, actionW, zH, "Fit " .. pct, + { id = "zoom-fit", active = zoomAt == 1 }) then + Studio.zoomFit() + end + if Kit.button(innerX + (actionW + gap) * 3, row3, actionW, zH, "Zoom +", + { id = "zoom-in", enabled = zoomAt > 1 }) then + Studio.zoomIn() + end + + local hint = ctl and ("Selected: " .. TouchSkin.describeBind(ctl.spec) + .. " — drag to move; use blue handles to resize.") + or "Tap a control to select it. Drag to move; use blue handles to resize." + Kit.text("small", Kit.ellipsize("small", Studio.status or hint, innerW), + innerX, trayY + trayH - Kit.textHeight("small") - gap, + Studio.statusErr and PAL.red or PAL.muted) +end + +function Studio.draw() + local W, H = Studio.safeFrame() + local f = Studio._frame + Kit.layout(f.w, f.h) + local mx, my = love.mouse.getPosition() + local px, py, padActive = PadCursor.pointer() + if padActive then + mx, my = px, py + elseif Studio.pointerX ~= nil then + mx, my = Studio.pointerX, Studio.pointerY + end + Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel) + Studio.clicked, Studio.wheel = false, 0 + Theme.fill(0, 0, W, H, PAL.bg, 1) + Studio.expireStatus() + Kit.blockClicks = Studio.modalUp() + + if Studio.mode == "library" then drawLibrary() + else drawEditor() end + + Kit.blockClicks = false + Studio.drawOverlay(f.w, f.h) + Kit.endFrame() + PadCursor.draw() +end + +-- ---------------------------------------------------------------- input + +-- Test uses the mock device drawn inside the workspace. The draw pass has +-- already relinquished that surface, so input must scope the same rectangle +-- explicitly and restore its caller's surface afterwards. +local function testPad(method,x,y) + local Version=require('src.core.GameVersion') + local previousVersion=Version.get() + if Studio.version and Version.VERSIONS[Studio.version] then Version.set(Studio.version) end + local previous=TouchSkin.surfaceRect + local r=Studio.lastCanvas + if r then TouchSkin.setSurface(r.x,r.y,r.w,r.h) end + local ok,result=pcall(TouchControls[method],TouchControls,'studio',x,y) + if previous then + TouchSkin.setSurface(previous.x,previous.y,previous.w,previous.h) + else TouchSkin.setSurface(nil) end + Version.set(previousVersion) + if not ok then error(result,0) end + return result +end + +function Studio.update(dt) + PadCursor.update(dt or 0) + local x, y, active = PadCursor.pointer() + if active and Studio.pointerDown then Studio.mousemoved(x, y) end + local wheel = PadCursor.takeWheel() + if wheel ~= 0 then Studio.wheelmoved(0, wheel) end + if not Studio.pendingPlay then return end + Studio.pendingPlay = false + local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas() + if onPlay then onPlay(version, canvas) end +end + +function Studio.mousepressed(x, y, button, fromPad) + if button ~= 1 then return end + if not fromPad then PadCursor.yieldToPointer() end + Studio.pointerX, Studio.pointerY = x, y + Studio.pointerDown = true + if Studio.modalUp() then Studio.clicked = true return end + if Studio.mode == "library" then + if fromPad then Studio.clicked = true return end + Studio.libraryPress = { x = x, y = y, at = Studio.libraryScroll or 0 } + return + end + Studio.clicked = true + local r = Studio.lastCanvas + if not r then return end + if Studio.testing then + if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then + testPad('touchpressed',x,y) + end + return + end + local slop = HANDLE * 2 * Kit.scale + local work = Studio.canvasWorkspace + local insideDevice = x >= r.x - slop and x <= r.x + r.w + slop + and y >= r.y - slop and y <= r.y + r.h + slop + local insideWork = work and x >= work.x and x <= work.x + work.w + and y >= work.y and y <= work.y + work.h + if not insideDevice and not insideWork then + return + end + Studio.beginCanvasDrag(x, y, r) +end + +function Studio.mousemoved(x, y) + Studio.pointerX, Studio.pointerY = x, y + if Studio.modalUp() then return end + if Studio.mode == "library" then + local p = Studio.libraryPress + if not p then return end + local ddx, ddy = x - p.x, y - p.y + if ddx * ddx + ddy * ddy > TAP_SLOP2 then p.dragged = true end + if p.dragged then + Studio.libraryScroll = Kit.scrollClamp(p.at - (y - p.y), + Studio.libraryScrollMax or 0) + end + return + end + if Studio.testing then + testPad('touchmoved',x,y) + return + end + if Studio.drag and (Studio.pointerDown or love.mouse.isDown(1)) and Studio.lastCanvas then + Studio.updateDrag(x, y, Studio.lastCanvas) + end +end + +function Studio.mousereleased(x, y, button) + if button ~= 1 then return end + Studio.pointerX, Studio.pointerY = x, y + Studio.pointerDown = false + local press = Studio.libraryPress + Studio.libraryPress = nil + if press and not press.dragged then Studio.clicked = true end + if Studio.testing then + testPad('touchreleased',x,y) + return + end + Studio.drag = nil + Studio.guides = nil +end + +function Studio.touchpressed(id, x, y) + if Studio.touchId and Studio.touchId ~= id then return end + Studio.touchId = id + return Studio.mousepressed(x, y, 1) +end + +local function closeFromPad() + if Studio.confirm then + Studio.confirmNo() + elseif Studio.modal then + Studio.closeModal() + elseif Studio.mode == "editor" then + Studio.backToLibrary() + elseif Studio.onClose then + Studio.onClose() + end +end + +local function handlePadAction(action) + if action == "a" then + local x, y = PadCursor.pointer() + Studio.mousepressed(x, y, 1, true) + elseif action == "b" then + closeFromPad() + end +end + +function Studio.gamepadpressed(joystick, button) + handlePadAction(PadCursor.gamepadpressed(joystick, button)) +end + +function Studio.gamepadreleased(joystick, button) + PadCursor.gamepadreleased(joystick, button) + if GamepadMap.mapGamepadButton(button) == "a" then + local x, y = PadCursor.pointer() + Studio.mousereleased(x, y, 1) + end +end + +function Studio.gamepadaxis(joystick, axis, value) + PadCursor.gamepadaxis(joystick, axis, value) +end + +function Studio.joystickpressed(joystick, button) + handlePadAction(PadCursor.joystickpressed(joystick, button)) +end + +function Studio.joystickreleased(joystick, button) + PadCursor.joystickreleased(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton and GamepadMap.mapGamepadButton(padButton) == "a" then + local x, y = PadCursor.pointer() + Studio.mousereleased(x, y, 1) + end +end + +function Studio.joystickaxis(joystick, axis, value) + PadCursor.joystickaxis(joystick, axis, value) +end + +function Studio.joystickhat(joystick, hat, direction) + PadCursor.joystickhat(joystick, hat, direction) +end + +function Studio.touchmoved(id, x, y) + if Studio.touchId ~= id then return end + return Studio.mousemoved(x, y) +end + +function Studio.touchreleased(id, x, y) + if Studio.touchId ~= id then return end + Studio.touchId = nil + return Studio.mousereleased(x, y, 1) +end + +function Studio.wheelmoved(_, dy) + if Studio.mode == "editor" and not Studio.modalUp() and dy and dy ~= 0 then + local work = Studio.canvasWorkspace + local mx, my, active = PadCursor.pointer() + if not active then mx, my = Studio.pointerX, Studio.pointerY end + if (not mx or not my) and love and love.mouse and love.mouse.getPosition then + mx, my = love.mouse.getPosition() + end + if work and mx and my + and mx >= work.x and mx <= work.x + work.w + and my >= work.y and my <= work.y + work.h then + if dy > 0 then Studio.zoomIn() else Studio.zoomOut() end + return + end + end + Studio.wheel = dy +end + +function Studio.focus() + PadCursor.reset() + Studio.drag = nil + Studio.clicked = false + Studio.libraryPress = nil + Studio.pointerDown, Studio.touchId = false, nil + if Studio.testing then TouchControls:reset() end +end + +function Studio.visible() + Studio.focus() +end + +function Studio.joystickremoved() + Studio.focus() + PadCursor.reset() +end + +function Studio.textinput(text) + Kit.textinput(text) +end + +local function heldCtrl() + if not (love and love.keyboard and love.keyboard.isDown) then return false end + local ok, down = pcall(love.keyboard.isDown, "lctrl", "rctrl", "lgui", "rgui") + return ok and down == true +end + +local function heldShift() + if not (love and love.keyboard and love.keyboard.isDown) then return false end + local ok, down = pcall(love.keyboard.isDown, "lshift", "rshift") + return ok and down == true +end + +Studio.NUDGES = { + up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 }, +} + +function Studio.keypressed(key) + if Kit.focus then + Kit.keypressed(key) + return + end + if Studio.confirm then + if key == "escape" then Studio.confirmNo() + elseif key == "return" or key == "kpenter" then Studio.confirmYes() + else Kit.keypressed(key) end + return + end + if Studio.modal then + if key == "escape" then Studio.closeModal() else Kit.keypressed(key) end + return + end + local nudge = Studio.NUDGES[key] + if nudge then + Studio.nudge(nudge[1], nudge[2], heldShift()) + return + end + if (key == "y" and heldCtrl()) + or (key == "z" and heldShift() and heldCtrl()) then + Studio.redo() + elseif key == "z" and heldCtrl() then + Studio.undo() + elseif key == "u" then + if heldShift() then Studio.redo() else Studio.undo() end + elseif key == "l" then + Studio.showLabels = not Studio.showLabels + elseif key == "escape" then + Studio.guard("Close the studio and lose the unsaved changes?", function() + if Studio.onClose then Studio.onClose() end + end) + elseif key == "delete" or key == "backspace" then + Studio.deleteControl() + elseif key == "n" then + Studio.addControl() + elseif key == "d" then + Studio.duplicateControl() + elseif key == "t" then + Studio.testing = not Studio.testing + TouchControls:setPreview(not Studio.testing) + TouchControls:reset() + elseif key == "-" or key == "kp-" then + Studio.zoomOut() + elseif key == "=" or key == "+" or key == "kp+" then + Studio.zoomIn() + elseif key == "0" or key == "kp0" then + Studio.zoomFit() + elseif key == "s" then + Studio.save() + elseif key == "tab" then + local page = Studio.page() + if page and #page.controls > 0 then + Studio.selected = ((Studio.selected or 0) % #page.controls) + 1 + end + else + Kit.keypressed(key) + end +end + +function Studio.available_desktop() + -- Kept as the compatibility name for launcher integrations. The Studio + -- now has a touch layout and is available on every supported platform. + return true +end + +return Studio diff --git a/src/ui/TouchControlsEditor.lua b/src/ui/TouchControlsEditor.lua index e1ab10a62..d1c440476 100644 --- a/src/ui/TouchControlsEditor.lua +++ b/src/ui/TouchControlsEditor.lua @@ -14,6 +14,9 @@ local SaveData = require("src.core.SaveData") local TouchControls = require("src.core.TouchControls") +-- Shared Gen1 cursor mechanics; GPLv3/Section7 notice in NOTICE.md. +local PadCursor = require("src.ui.PadCursor") +local GamepadMap = require("src.core.GamepadMap") local Editor = {} @@ -44,6 +47,7 @@ function Editor.load(opts) opts = opts or {} Editor.onClose = opts.onClose Editor.drag = nil + PadCursor.reset() Editor.rects = {} Editor.fonts = { title = love.graphics.newFont(28), @@ -63,6 +67,7 @@ function Editor.unload() TouchControls:reset() Editor.drag = nil Editor.onClose = nil + PadCursor.reset() end local function persist() @@ -72,6 +77,7 @@ local function persist() -- dropped once the player saves under the new shape opts.touchControls = { enabled = cfg.enabled, + skin = cfg.skin, layouts = cfg.layouts, } SaveData.saveOptions(opts) @@ -94,13 +100,16 @@ local function toggleEnabled() if not Editor.enabled then TouchControls:reset() end end -function Editor.update(_dt) +function Editor.update(dt) + PadCursor.update(dt or 0) -- drag follows the live pointer when love.touch / mouse is available; -- touchmoved / mousemoved also update, so this is a belt-and-suspenders -- path for Android where move events can be thin if not Editor.drag then return end local x, y - if love.touch and love.touch.getPosition and Editor.drag.touchId then + if Editor.drag.touchId == 'pad' then + x, y = PadCursor.pointer() + elseif love.touch and love.touch.getPosition and Editor.drag.touchId then local ok, tx, ty = pcall(love.touch.getPosition, Editor.drag.touchId) if ok and tx then x, y = tx, ty end end @@ -163,7 +172,17 @@ function Editor.draw() local titleW = Editor.fonts.title:getWidth("Touch Controls") local narrow = colW < titleW + 2 * btnW + 4 * pad if narrow then btnW = math.min(btnW, (colW - 2 * pad - 10 * s) / 2) end - local rowY = narrow and (oy + pad + barH) or (oy + pad + (barH - btnH) / 2) + local inset = 0 + local toggle = Lay.hotbar + if toggle and TouchControls:hotbarShown() + and toggle.cx + toggle.w * 0.7 > colX + and toggle.cx - toggle.w * 0.7 < colX + colW + and toggle.cy - toggle.w * 0.7 < oy + 260 * s then + inset = math.max(0, toggle.cy + toggle.w * 0.7 - oy + pad) + end + local rowY = narrow and (oy + inset + pad + barH) + or (oy + inset + pad + (barH - btnH) / 2) + barH = barH + inset local done = { x = colX + colW - pad - btnW, y = rowY, w = btnW, h = btnH } local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH } if narrow then barH = barH + btnH + 8 * s end @@ -179,7 +198,7 @@ function Editor.draw() col(PAL.white) -- shrunk to the column when it is narrower than the heading (portrait) local tScale = math.min(1, (colW - 2 * pad) / math.max(1, titleW)) - love.graphics.print("Touch Controls", colX + pad, oy + pad + 4 * s, 0, tScale, tScale) + love.graphics.print("Touch Controls", colX + pad, oy + inset + pad + 4 * s, 0, tScale, tScale) Editor.rects.done, Editor.rects.reset = done, reset @@ -276,6 +295,7 @@ function Editor.draw() love.graphics.circle("line", zone.cx, zone.cy, zone.w * 0.62) end end + PadCursor.draw() end local function beginDrag(id, x, y) @@ -290,6 +310,9 @@ local function beginDrag(id, x, y) TouchControls:nudgeScale(TouchControls.SCALE_STEP); return end + -- A custom skin's controls have arbitrary shapes/specs rather than native + -- pad layout keys. Its dedicated studio owns those edits. + if require('src.core.TouchSkin').active then return end local name = TouchControls:hitTest(x, y) if not name then return end local zone = TouchControls:layout()[name] @@ -317,6 +340,7 @@ end function Editor.mousepressed(x, y, button) if button ~= 1 then return end + PadCursor.yieldToPointer() beginDrag("mouse", x, y) end @@ -330,6 +354,7 @@ function Editor.mousereleased(x, y, button) end function Editor.touchpressed(id, x, y) + PadCursor.yieldToPointer() beginDrag(id, x, y) end @@ -341,6 +366,65 @@ function Editor.touchreleased(id, x, y) endDrag(id) end +local function handlePadAction(action) + if not action then return end + if action == "a" then + local mx, my = PadCursor.pointer() + beginDrag("pad", mx, my) + elseif action == "b" then + close() + elseif action == "tab_prev" then + TouchControls:nudgeScale(-TouchControls.SCALE_STEP) + elseif action == "tab_next" then + TouchControls:nudgeScale(TouchControls.SCALE_STEP) + end +end + +function Editor.gamepadpressed(joystick, button) + handlePadAction(PadCursor.gamepadpressed(joystick, button)) +end + +function Editor.gamepadreleased(joystick, button) + PadCursor.gamepadreleased(joystick, button) + -- A release ends a pad drag (hold A + stick to reposition a control). + local action = GamepadMap.mapGamepadButton(button) + if action == "a" then endDrag("pad") end +end + +function Editor.gamepadaxis(joystick, axis, value) + PadCursor.gamepadaxis(joystick, axis, value) +end + +function Editor.joystickpressed(joystick, button) + handlePadAction(PadCursor.joystickpressed(joystick, button)) +end + +function Editor.joystickreleased(joystick, button) + PadCursor.joystickreleased(joystick, button) + if GamepadMap.ignoreRawForJoystick(joystick) then return end + local padButton = GamepadMap.mapRawToGamepadButton(button) + if padButton then + local action = GamepadMap.mapGamepadButton(padButton) + if action == "a" then endDrag("pad") end + end +end + +function Editor.joystickaxis(joystick, axis, value) + PadCursor.joystickaxis(joystick, axis, value) +end + +function Editor.joystickhat(joystick, hat, direction) + PadCursor.joystickhat(joystick, hat, direction) +end + +function Editor.focus() + Editor.drag=nil + PadCursor.reset() + TouchControls:reset() +end +Editor.visible=Editor.focus +Editor.joystickremoved=Editor.focus + function Editor.keypressed(key) if key == "escape" or key == "return" or key == "space" then close() 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/ui/kit/Button.lua b/src/ui/kit/Button.lua new file mode 100644 index 000000000..3a817e7fd --- /dev/null +++ b/src/ui/kit/Button.lua @@ -0,0 +1,350 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Shared button faces for launcher and save-editor input adapters. +-- The owner supplies fonts, pointer state and text drawing; this module only paints. +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local Icons = require("src.ui.kit.Icons") +local Button = {} +local KINDS = { + primary = { fill = PAL.green, ink = PAL.inverse }, + good = { fill = PAL.green, ink = PAL.inverse }, + accent = { fill = PAL.buttonBlue, ink = PAL.heading }, + warn = { fill = PAL.yellow, ink = PAL.inverse }, + danger = { fill = PAL.surface, ink = PAL.red, stroke = PAL.red }, + ghost = { fill = PAL.surface, ink = PAL.text, stroke = PAL.line }, + disabled = { fill = PAL.surface, ink = PAL.muted, stroke = PAL.line }, +} +Button.KINDS = KINDS + +-- Complete labels wrap between words. The input adapter sizes the control +-- from these same metrics; the painter never substitutes an ellipsis. +function Button.labelLayout(Kit, w, h, label, opts) + local name = opts.font or "button" + local lineH = Kit.textHeight(name) + local pad = Theme.BUTTON.labelInset * Kit.scale / 2 + local trailing = opts.trailingIcon and h * 0.65 or 0 + local available = w - 2 * pad - trailing + local function wrap(width) + local lines, line, widest = {}, "", 0 + for word in tostring(label):gmatch("%S+") do + local nextLine = line == "" and word or (line .. " " .. word) + if line ~= "" and Kit.textWidth(name, nextLine) > width then + lines[#lines + 1] = line + widest = math.max(widest, Kit.textWidth(name, line)) + line = word + else + line = nextLine + end + end + if line ~= "" then + lines[#lines + 1] = line + widest = math.max(widest, Kit.textWidth(name, line)) + end + return lines, widest + end + local lines, widest = wrap(available) + local mode, size, gap + if opts.icon then + size = math.floor(math.min(w, h) * 0.42) + gap = math.floor(7 * Kit.scale) + local sideW = available - size - gap + local sideLines, sideWidth = wrap(sideW) + if not opts.iconStack and sideWidth <= sideW and #sideLines * lineH <= h - 4 * Kit.scale then + lines, widest, available, mode = sideLines, sideWidth, sideW, "side" + elseif size + math.max(2, 3 * Kit.scale) + #lines * lineH <= h - 4 * Kit.scale then + mode, gap = "stack", math.max(2, 3 * Kit.scale) + end + end + return { + font = name, + lines = lines, + width = widest, + available = available, + pad = pad, + trailing = trailing, + lineH = lineH, + height = #lines * lineH + (mode == "stack" and size + gap or 0), + mode = mode, + iconSize = size, + gap = gap, + } +end + +function Button.draw(Kit, x, y, w, h, label, opts, hot, focused) + local G = love and love.graphics + local enabled = opts.enabled ~= false + local face = opts.face or "fill" + local B = Theme.BUTTON + local radius = opts.radius or B.radius + local segments = opts.segments + local active = opts.active or opts.on + local invert = false + local fill, ink, stroke, strokeA, strokeWidth, doEmboss, doRing, glowA + if face == "bare" then + ink = opts.ink or PAL.heading + doRing = focused + elseif face == "selection" then + -- A selected control keeps its geometry, dark face and label layout. + -- Selection is an inset accent outline, never a filled keycap. + fill = PAL.surface + ink = PAL.text + stroke = (active or focused) and PAL.blue or PAL.line + strokeA = (active or focused) and Theme.A.focus or (hot and Theme.A.hover or Theme.A.hairline) + strokeWidth = (active or focused) and 2 or 1 + doRing = focused or hot + elseif face == "invert" then + invert = false + fill = (hot or focused) and PAL.raised or (opts.fill or PAL.surface) + ink = opts.ink or PAL.heading + stroke = opts.stroke or PAL.line + strokeA = (hot or focused) and Theme.A.focus or Theme.A.hairline + doRing = focused or hot + elseif face == "tab" then + invert = active and true or false + local tint = opts.color or opts.fill or PAL.ink + fill = invert and tint or PAL.surface + ink = invert and PAL.inverse or (opts.color or PAL.text) + if not invert then + stroke = tint + strokeA = (focused or hot) and Theme.A.focus + or (opts.color and Theme.A.hover or Theme.A.hairline) + end + doRing = focused or hot + elseif face == "chip" then + local c = opts.color or PAL.line + invert = active and true or false + if active then + fill = c + ink = PAL.inverse + doEmboss = true + else + fill = PAL.bg + ink = c + stroke = c + strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover + end + doRing = focused or hot + else + local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"] + fill = (enabled and opts.fill) or kind.fill + ink = (enabled and opts.ink) or kind.ink + doEmboss = (opts.kind == "primary" or opts.kind == "good" or opts.kind == "accent") + stroke = kind.stroke + strokeA = stroke and Theme.A.hover or nil + doRing = hot or focused + if opts.glow and enabled and not doRing then + glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz)) + end + end + if opts.emboss ~= nil then + doEmboss = opts.emboss + end + if opts.ring ~= nil then + doRing = opts.ring + end + if G then + if face ~= "bare" then + Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius, segments) + end + if doEmboss then + local es = enabled and ((hot or focused) and B.embossHot or B.embossRest) or B.embossDisabled + Theme.emboss(x, y, w, h, es) + end + if strokeA then + Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeWidth or 1, radius, segments) + end + if doRing then + Theme.fillRounded( + x, + y, + w, + h, + PAL.ink, + Kit.mouseDown and hot and 0.04 or 0.09, + radius, + segments + ) + if face ~= "selection" then + Theme.strokeRounded( + x, + y, + w, + h, + PAL.ink, + focused and 1 or 0.7, + focused and 2 or 1, + radius, + segments + ) + end + elseif glowA then + Theme.strokeRounded( + x - B.ringPad, + y - B.ringPad, + w + 2 * B.ringPad, + h + 2 * B.ringPad, + PAL.lineStrong, + glowA, + B.ringWidth, + radius + B.ringPad, + segments + ) + end + local fname = opts.font or ((face == "chip") and "micro" or "button") + local ty = y + (h - Kit.textHeight(fname)) / 2 + local image = opts.image + local drawFn = opts.drawFn + local letter = opts.letter + local hasLabel = label and label ~= "" + local bold = opts.bold + if bold == nil then + bold = face ~= "tab" and face ~= "selection" + end + if opts.fullLabel and hasLabel then + local layout = opts.labelLayout or Button.labelLayout(Kit, w, h, label, opts) + local lx, ly = x + layout.pad, y + (h - #layout.lines * layout.lineH) / 2 + if layout.mode == "side" then + local groupW = layout.iconSize + layout.gap + layout.width + local ix = opts.align == "left" and lx or x + (w - layout.trailing - groupW) / 2 + Icons.draw( + opts.icon, + ix, + y + (h - layout.iconSize) / 2, + layout.iconSize, + opts.iconInk or ink, + enabled and 1 or B.disabledA + ) + lx = ix + layout.iconSize + layout.gap + elseif layout.mode == "stack" then + local top = y + (h - layout.height) / 2 + Icons.draw( + opts.icon, + x + (w - layout.trailing - layout.iconSize) / 2, + top, + layout.iconSize, + opts.iconInk or ink, + enabled and 1 or B.disabledA + ) + ly = top + layout.iconSize + layout.gap + end + for i, line in ipairs(layout.lines) do + local lineY = ly + (i - 1) * layout.lineH + if layout.mode == "side" or opts.align == "left" then + if bold then + Kit.textBold(layout.font, line, lx, lineY, ink) + else + Kit.text(layout.font, line, lx, lineY, ink) + end + elseif bold then + Kit.textCenterBold(layout.font, line, lx, lineY, layout.available, ink) + else + Kit.textCenter(layout.font, line, lx, lineY, layout.available, ink) + end + end + elseif opts.icon and opts.iconStack then + local size = math.floor(math.min(w * 0.5, h * 0.4)) + local labelH = Kit.textHeight(fname) + local gap = math.max(2, math.floor(3 * Kit.scale)) + local top = y + (h - size - gap - labelH) / 2 + Icons.draw(opts.icon, x + (w - size) / 2, top, size, ink, enabled and 1 or B.disabledA) + local shown = Kit.ellipsize(fname, label or "", w - B.labelInset * Kit.scale) + Kit.textCenter(fname, shown, x, top + size + gap, w, ink) + elseif opts.icon then + local box = math.min(w, h) + local size = math.floor(box * (hasLabel and 0.42 or 0.5) * (opts.iconScale or 1)) + local gap = math.floor(7 * Kit.scale) + local shown = hasLabel + and Kit.ellipsize( + fname, + label, + w - size - gap - 2 * B.labelPad * Kit.scale - (opts.trailingIcon and h * 0.65 or 0) + ) + or "" + local groupW = size + (hasLabel and gap + Kit.textWidth(fname, shown) or 0) + local ix = opts.align == "left" and x + B.labelPad * Kit.scale or x + (w - groupW) / 2 + Icons.draw(opts.icon, ix, y + (h - size) / 2, size, opts.iconInk or ink, enabled and 1 or B.disabledA) + if hasLabel then + if bold then + Kit.textBold(fname, shown, ix + size + gap, ty, ink) + else + Kit.text(fname, shown, ix + size + gap, ty, ink) + end + end + elseif image then + local box = h + local boxX, boxY = x, y + if not hasLabel then + box = math.min(w, h) + boxX = x + (w - box) / 2 + boxY = y + (h - box) / 2 + end + local iw, ih = image:getDimensions() + local pad = math.floor(box * (opts.iconPad or B.iconPad)) + local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih) + Theme.col(ink, enabled and 1 or B.disabledA) + G.draw( + image, + Theme.snap(boxX + (box - iw * s) / 2), + Theme.snap(boxY + (box - ih * s) / 2), + 0, + s, + s + ) + if hasLabel then + local lx = x + h + B.letterGap * Kit.scale + if bold then + Kit.textBold(fname, label, lx, ty, ink) + else + Kit.text(fname, label, lx, ty, ink) + end + end + elseif drawFn then + drawFn(x, y, w, h, invert) + elseif letter then + if opts.letterBold then + Kit.textCenterBold(fname, letter, x, ty, h, ink) + else + Kit.textCenter(fname, letter, x, ty, h, ink) + end + if hasLabel then + local lx = x + h + B.letterGap * Kit.scale + if bold then + Kit.textBold(fname, label, lx, ty, ink) + else + Kit.text(fname, label, lx, ty, ink) + end + end + elseif hasLabel then + local shown = Kit.ellipsize( + fname, + label, + w - B.labelInset * Kit.scale - (opts.trailingIcon and h * 0.65 or 0) + ) + if opts.align == "left" then + local lx = x + B.labelPad * Kit.scale + if bold then + Kit.textBold(fname, shown, lx, ty, ink) + else + Kit.text(fname, shown, lx, ty, ink) + end + elseif bold then + Kit.textCenterBold(fname, shown, x, ty, w, ink) + else + Kit.textCenter(fname, shown, x, ty, w, ink) + end + end + end + if opts.trailingIcon and G then + local size = math.floor(h * 0.4) + Icons.draw( + opts.trailingIcon, + x + w - size - B.labelPad * Kit.scale, + y + (h - size) / 2, + size, + ink, + enabled and 1 or B.disabledA + ) + end +end +return Button diff --git a/src/ui/kit/FileBrowser.lua b/src/ui/kit/FileBrowser.lua new file mode 100644 index 000000000..56550c166 --- /dev/null +++ b/src/ui/kit/FileBrowser.lua @@ -0,0 +1,391 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Immediate-mode in-launcher File Browser for handhelds and gamepads. +-- Allows navigating directories to pick ROM files (.gb/.gbc), import save files (.sav), +-- and select mod/skin archives (.zip) without requiring host desktop GUI pickers (zenity/kdialog). + +local Theme = require("src.ui.kit.Theme") +local HostShell = require("src.core.HostShell") + +-- POSIX shell quoting for the ls/test commands below. HostShell.quote is the +-- shared helper, but the launcher may run where HostShell is a minimal shim +-- (the NX gate), so fall back to the same escaping locally. +local function shQuote(path) + if HostShell and type(HostShell.quote) == "function" then + return HostShell.quote(path) + end + return "'" .. tostring(path):gsub("'", "'\\''") .. "'" +end +local PAL = Theme.PAL +local Kit = nil +local function getKit() + if not Kit then Kit = require("src.ui.kit.Kit") end + return Kit +end + +local FileBrowser = { + active = false, + title = "Select File", + mode = "rom", -- "rom", "save", "mod", "cart", "all" + currentDir = "/", + entries = {}, + selectedIdx = 1, + page = 1, + perPage = 8, + onSelect = nil, + onCancel = nil, + shortcutIdx = 1, +} + +local SHORTCUTS = { + { label = "SD Card", path = "/mnt/SDCARD" }, + { label = "GB ROMs", path = "/roms/gb" }, + { label = "GBC ROMs", path = "/roms/gbc" }, + { label = "Ports", path = "/roms/ports" }, + { label = "Game Dir", path = "." }, + { label = "Storage", path = "/storage" }, + { label = "Userdata", path = "/userdata/roms" }, + { label = "Root /", path = "/" }, +} + +local function findSdCardRoot() + local candidates = { + "/mnt/SDCARD", + "/mnt/sdcard", + "/mnt/mmc", + "/sdcard", + "/roms", + "/storage/roms", + "/userdata/roms", + "/storage", + "/userdata", + } + for _, path in ipairs(candidates) do + local ok, h = pcall(io.popen, string.format("test -d %s && echo \"yes\"", shQuote(path))) + if ok and h then + local res = h:read("*a") + h:close() + if res and res:match("yes") then + return path + end + end + end + return "/" +end + +local function normalizePath(p) + if not p or p == "" then return "/" end + p = p:gsub("\\", "/") + if p:sub(1, 1) ~= "/" and p:sub(1, 2) ~= "./" and p:sub(1, 3) ~= "../" then + p = "/" .. p + end + p = p:gsub("/+", "/") + return p +end + +local function parentDir(p) + p = normalizePath(p) + if p == "/" or p == "." then return "/" end + local parent = p:match("^(.*)/[^/]+/?$") + if not parent or parent == "" then return "/" end + return parent +end + +local function isMatchingFilter(name, isDir, mode) + if isDir then return true end + local ext = name:match("%.([^.]+)$") + if not ext then return (mode == "all") end + ext = ext:lower() + if mode == "rom" then + return ext == "gb" or ext == "gbc" or ext == "gba" or ext == "agb" + or ext == "bin" or ext == "zip" or ext == "7z" + elseif mode == "save" then + return (ext == "sav" or ext == "srm" or ext == "lua") + elseif mode == "box" then + return (ext == "gci" or ext == "sav") + elseif mode == "mod" then + return (ext == "zip") + elseif mode == "cart" then + return (ext == "g1rcart") + end + return true +end + +local function scanDirectory(dir, mode) + dir = normalizePath(dir) + local entries = {} + + local ok, handle = pcall(io.popen, string.format("ls -1ap %s 2>/dev/null", shQuote(dir))) + if ok and handle then + local output = handle:read("*a") + handle:close() + if output and #output > 0 then + for line in output:gmatch("[^\r\n]+") do + if line ~= "./" and line ~= "../" and line ~= "." and line ~= ".." then + local isDir = (line:sub(-1) == "/") + local cleanName = isDir and line:sub(1, -2) or line + if isMatchingFilter(cleanName, isDir, mode) then + table.insert(entries, { + name = cleanName, + isDir = isDir, + path = (dir == "/" and "/" .. cleanName) or (dir .. "/" .. cleanName), + }) + end + end + end + end + end + + if #entries == 0 and love and love.filesystem and love.filesystem.getDirectoryItems then + local okFs, items = pcall(love.filesystem.getDirectoryItems, dir) + if okFs and items then + for _, name in ipairs(items) do + local full = (dir == "/" and "/" .. name) or (dir .. "/" .. name) + local info = love.filesystem.getInfo and love.filesystem.getInfo(full) + local isDir = info and info.type == "directory" + if isMatchingFilter(name, isDir, mode) then + table.insert(entries, { + name = name, + isDir = isDir, + path = full, + }) + end + end + end + end + + table.sort(entries, function(a, b) + if a.isDir ~= b.isDir then + return a.isDir + end + return a.name:lower() < b.name:lower() + end) + + return entries +end + +function FileBrowser.open(opts) + opts = opts or {} + FileBrowser.active = true + FileBrowser.title = opts.title or "Select File" + FileBrowser.mode = opts.mode or "rom" + FileBrowser.onSelect = opts.onSelect + FileBrowser.onCancel = opts.onCancel + FileBrowser.hint = opts.hint + FileBrowser.selectedIdx = 1 + FileBrowser.page = 1 + + local initPath = opts.initialPath or findSdCardRoot() + FileBrowser.currentDir = initPath + FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode) + if #FileBrowser.entries == 0 and initPath ~= "/" then + FileBrowser.currentDir = "/" + FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode) + end +end + +function FileBrowser.close(path) + if not FileBrowser.active then return end + local onSelect = FileBrowser.onSelect + local onCancel = FileBrowser.onCancel + FileBrowser.active = false + FileBrowser.onSelect = nil + FileBrowser.onCancel = nil + FileBrowser.hint = nil + if path and onSelect then + onSelect(path) + elseif not path and onCancel then + onCancel() + end +end + +function FileBrowser.setDirectory(dir) + FileBrowser.currentDir = normalizePath(dir) + FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode) + FileBrowser.selectedIdx = 1 + FileBrowser.page = 1 +end + +function FileBrowser.cycleShortcut() + FileBrowser.shortcutIdx = (FileBrowser.shortcutIdx % #SHORTCUTS) + 1 + local target = SHORTCUTS[FileBrowser.shortcutIdx] + FileBrowser.setDirectory(target.path) +end + +function FileBrowser.gamepadpressed(button) + if not FileBrowser.active then return false end + + local total = #FileBrowser.entries + local perPage = FileBrowser.perPage + local maxPage = math.max(1, math.ceil(total / perPage)) + + if button == "dpup" then + if FileBrowser.selectedIdx > 1 then + FileBrowser.selectedIdx = FileBrowser.selectedIdx - 1 + FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1 + end + return true + elseif button == "dpdown" then + if FileBrowser.selectedIdx < total then + FileBrowser.selectedIdx = FileBrowser.selectedIdx + 1 + FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1 + end + return true + elseif button == "dpleft" or button == "leftshoulder" or button == "triggerleft" or button == "lefttrigger" or button == "l2" then + if FileBrowser.page > 1 then + FileBrowser.page = FileBrowser.page - 1 + FileBrowser.selectedIdx = (FileBrowser.page - 1) * perPage + 1 + end + return true + elseif button == "dpright" or button == "rightshoulder" or button == "triggerright" or button == "righttrigger" or button == "r2" then + if FileBrowser.page < maxPage then + FileBrowser.page = FileBrowser.page + 1 + FileBrowser.selectedIdx = math.min(total, (FileBrowser.page - 1) * perPage + 1) + end + return true + elseif button == "a" or button == "start" then + local entry = FileBrowser.entries[FileBrowser.selectedIdx] + if entry then + if entry.isDir then + FileBrowser.setDirectory(entry.path) + else + FileBrowser.close(entry.path) + end + end + return true + elseif button == "b" then + if FileBrowser.currentDir ~= "/" and FileBrowser.currentDir ~= "." then + FileBrowser.setDirectory(parentDir(FileBrowser.currentDir)) + else + FileBrowser.close(nil) + end + return true + elseif button == "x" then + FileBrowser.cycleShortcut() + return true + elseif button == "back" or button == "select" then + FileBrowser.close(nil) + return true + end + + return true +end + +function FileBrowser.keypressed(key) + if not FileBrowser.active then return false end + if key == "escape" then + FileBrowser.close(nil) + return true + elseif key == "up" then + return FileBrowser.gamepadpressed("dpup") + elseif key == "down" then + return FileBrowser.gamepadpressed("dpdown") + elseif key == "left" then + return FileBrowser.gamepadpressed("dpleft") + elseif key == "right" then + return FileBrowser.gamepadpressed("dpright") + elseif key == "return" then + return FileBrowser.gamepadpressed("a") + elseif key == "backspace" then + return FileBrowser.gamepadpressed("b") + end + return false +end + +function FileBrowser.draw(m) + if not FileBrowser.active then return end + + local Kit = getKit() + local s = m and m.s or Kit.scale or 1 + local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640 + local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480 + + Theme.fill(0, 0, W, H, PAL.bg, 0.94) + Kit.blockClicks = true + + local pad = math.floor(14 * s) + local modalW = math.floor(math.min(560 * s, W - 20 * s)) + local modalH = math.floor(math.min(420 * s, H - 20 * s)) + local px = math.floor((W - modalW) / 2) + local py = math.floor((H - modalH) / 2) + + Kit.card(px, py, modalW, modalH, true) + + local cy = py + pad + -- Header + Kit.text("button", FileBrowser.title, px + pad, cy, PAL.heading) + Kit.tag(px + modalW - pad - math.floor(60 * s), cy, math.floor(60 * s), math.floor(18 * s), + FileBrowser.mode:upper(), PAL.blue) + + cy = cy + Kit.textHeight("button") + math.floor(6 * s) + + -- Breadcrumb / Current directory bar + local pathBarH = math.floor(24 * s) + Theme.fillRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.bg, 1) + Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.line, Theme.A.hairline, 1) + local pathStr = Kit.ellipsizeLeft("small", FileBrowser.currentDir, modalW - 4 * pad) + Kit.text("small", pathStr, px + pad + math.floor(6 * s), cy + math.floor(4 * s), PAL.heading) + + cy = cy + pathBarH + math.floor(8 * s) + + if FileBrowser.hint then + cy = cy + Kit.textWrapped("micro", FileBrowser.hint, px + pad, cy, modalW - 2 * pad, + PAL.red, 2) + math.floor(6 * s) + end + + -- File / Directory List + local total = #FileBrowser.entries + local perPage = FileBrowser.perPage + local startIdx = (FileBrowser.page - 1) * perPage + 1 + local endIdx = math.min(total, startIdx + perPage - 1) + local rowH = math.floor(26 * s) + local rowGap = math.floor(3 * s) + + if total == 0 then + Theme.fillRounded(px + pad, cy, modalW - 2 * pad, rowH * 4, PAL.bg, 1) + Kit.textCenter("small", "No files found in this folder.", px + pad, cy + rowH * 1.5, modalW - 2 * pad, PAL.muted) + cy = cy + rowH * 4 + math.floor(8 * s) + else + for i = startIdx, endIdx do + local entry = FileBrowser.entries[i] + local isSel = (FileBrowser.selectedIdx == i) + local rx = px + pad + local rw = modalW - 2 * pad + + if Kit.press(rx, cy, rw, rowH) then + FileBrowser.selectedIdx = i + if entry.isDir then + FileBrowser.setDirectory(entry.path) + else + FileBrowser.close(entry.path) + end + end + + if isSel then + Theme.fillRounded(rx, cy, rw, rowH, PAL.ink, 1) + local icon = entry.isDir and "[DIR] " or "[FILE] " + Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), PAL.inverse) + else + Theme.fillRounded(rx, cy, rw, rowH, PAL.surface, 1) + Theme.strokeRounded(rx, cy, rw, rowH, PAL.line, Theme.A.hairline, 1) + local icon = entry.isDir and "[DIR] " or "[FILE] " + local col = entry.isDir and PAL.blue or PAL.text + Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), col) + end + + cy = cy + rowH + rowGap + end + end + + -- Pager info & Controls guide + local bottomY = py + modalH - pad - math.floor(18 * s) + local pageInfo = string.format("Page %d of %d (%d items)", FileBrowser.page, math.max(1, math.ceil(total / perPage)), total) + Kit.text("micro", pageInfo, px + pad, bottomY, PAL.muted) + + local guide = "A: Open/Select B: Parent X: Shortcut Start: Confirm Select: Cancel" + Kit.textRight("micro", guide, px + modalW - pad, bottomY, PAL.muted) + + Kit.blockClicks = false +end + +return FileBrowser diff --git a/src/ui/kit/Icons.lua b/src/ui/kit/Icons.lua new file mode 100644 index 000000000..d90fae3d7 --- /dev/null +++ b/src/ui/kit/Icons.lua @@ -0,0 +1,80 @@ +-- Original native vector artwork by Roxas2712. No Gen1 launcher atlas. +-- Geometry is expressed on a 24-unit canvas and drawn at the caller's scale. +local Icons={NAMES={},ATLASES={}} +local shapes={ + ['x']={{'line',5,5,19,19},{'line',19,5,5,19}}, + ['check']={{'line',4,12,9,17,20,6}}, + ['plus']={{'line',4,12,20,12},{'line',12,4,12,20}}, + ['minus']={{'line',4,12,20,12}}, + ['chevron-left']={{'line',15,5,8,12,15,19}}, + ['chevron-right']={{'line',9,5,16,12,9,19}}, + ['chevron-up']={{'line',5,15,12,8,19,15}}, + ['chevron-down']={{'line',5,9,12,16,19,9}}, + ['arrow-left']={{'line',20,12,4,12,10,6},{'line',4,12,10,18}}, + ['arrow-right']={{'line',4,12,20,12,14,6},{'line',20,12,14,18}}, + ['arrow-up']={{'line',12,20,12,4,6,10},{'line',12,4,18,10}}, + ['arrow-down']={{'line',12,4,12,20,6,14},{'line',12,20,18,14}}, + ['arrow-up-down']={{'line',8,20,8,4,4,8},{'line',8,4,12,8},{'line',16,4,16,20,12,16},{'line',16,20,20,16}}, + ['arrow-left-right']={{'line',21,8,3,8,7,4},{'line',3,8,7,12},{'line',3,16,21,16,17,12},{'line',21,16,17,20}}, + ['chevrons-up']={{'line',5,12,12,5,19,12},{'line',5,19,12,12,19,19}}, + ['folder']={{'line',3,19,3,5,10,5,12,8,21,8,21,19,3,19}}, + ['folder-open']={{'line',3,18,3,5,10,5,12,8,20,8,20,11},{'line',3,18,7,11,22,11,18,19,3,19}}, + ['save']={{'rectangle','line',4,3,16,18},{'rectangle','line',8,3,8,6},{'rectangle','line',8,14,8,7}}, + ['download']={{'line',12,3,12,15,7,10},{'line',12,15,17,10},{'line',4,16,4,21,20,21,20,16}}, + ['upload']={{'line',12,16,12,4,7,9},{'line',12,4,17,9},{'line',4,16,4,21,20,21,20,16}}, + ['trash']={{'line',4,6,20,6},{'line',8,6,8,3,16,3,16,6},{'line',6,6,7,21,17,21,18,6},{'line',10,9,10,17},{'line',14,9,14,17}}, + ['pencil']={{'line',4,20,5,15,17,3,21,7,9,19,4,20},{'line',14,6,18,10}}, + ['paintbrush']={{'line',10,14,19,3,22,6,13,17,10,14},{'line',10,14,6,14,4,18,2,20,8,21,12,18,13,17}}, + ['file-pen-line']={{'line',16,8,16,3,4,3,4,21,12,21},{'line',12,3,16,7,12,7,12,3},{'line',12,20,13,16,20,9,23,12,16,19,12,20}}, + ['copy']={{'rectangle','line',3,3,12,12},{'rectangle','line',8,8,13,13}}, + ['undo-2']={{'line',3,11,3,5,9,5},{'line',3,10,8,6,15,6,20,10,20,15,16,19,10,19}}, + ['redo-2']={{'line',21,11,21,5,15,5},{'line',21,10,16,6,9,6,4,10,4,15,8,19,14,19}}, + ['rotate-ccw']={{'line',3,10,3,4,9,4},{'line',3,9,8,4,15,4,20,9,20,15,15,20,8,20,4,16}}, + ['ellipsis']={{'circle','fill',5,12,1.5},{'circle','fill',12,12,1.5},{'circle','fill',19,12,1.5}}, + ['lock']={{'rectangle','line',5,10,14,11},{'line',8,10,8,5,10,3,14,3,16,5,16,10},{'line',12,14,12,17}}, + ['lock-open']={{'rectangle','line',5,10,14,11},{'line',8,10,8,5,10,3,14,3,16,5},{'line',12,14,12,17}}, + ['mail']={{'rectangle','line',3,5,18,14},{'line',3,5,12,13,21,5}}, + ['search']={{'circle','line',10,10,6},{'line',15,15,21,21}}, + ['globe']={{'circle','line',12,12,9},{'line',3,12,21,12},{'line',12,3,8,8,8,16,12,21,16,16,16,8,12,3}}, + ['settings']={{'circle','line',12,12,4},{'line',9,3,15,3,15,6,18,8,21,8,21,15,18,15,16,18,16,21,9,21,9,18,6,16,3,16,3,9,6,9,9,6,9,3}}, + ['puzzle']={{'line',4,4,10,4,10,2,14,2,14,4,20,4,20,10,22,10,22,14,20,14,20,20,14,20,14,17,10,17,10,20,4,20,4,14,7,14,7,10,4,10,4,4}}, + ['heart']={{'line',12,21,3,12,3,6,7,3,12,7,17,3,21,6,21,12,12,21}}, + ['package']={{'line',3,7,12,3,21,7,21,18,12,22,3,18,3,7,12,11,21,7},{'line',12,11,12,22},{'line',7,5,16,9}}, + ['backpack']={{'rectangle','line',5,6,14,16},{'rectangle','line',8,13,8,6},{'line',9,6,9,3,15,3,15,6}}, + ['list-filter']={{'line',3,5,21,5},{'line',6,11,18,11},{'line',10,17,14,17}}, + ['eye']={{'line',2,12,7,6,17,6,22,12,17,18,7,18,2,12},{'circle','line',12,12,3}}, + ['award']={{'circle','line',12,9,6},{'line',7,14,6,22,12,19,18,22,17,14}}, + ['book-open']={{'line',12,21,3,18,3,3,12,6,21,3,21,18,12,21,12,6}}, + ['shield-check']={{'line',12,2,21,6,19,17,12,22,5,17,3,6,12,2},{'line',8,12,11,15,16,9}}, + ['map-pin']={{'line',12,22,5,12,5,6,9,2,15,2,19,6,19,12,12,22},{'circle','line',12,8,3}}, + ['grid-2x2']={{'rectangle','line',3,3,7,7},{'rectangle','line',14,3,7,7},{'rectangle','line',3,14,7,7},{'rectangle','line',14,14,7,7}}, + ['sliders-horizontal']={{'line',3,6,21,6},{'line',3,12,21,12},{'line',3,18,21,18},{'circle','fill',8,6,2},{'circle','fill',16,12,2},{'circle','fill',10,18,2}}, + ['expand']={{'line',3,9,3,3,9,3},{'line',15,3,21,3,21,9},{'line',21,15,21,21,15,21},{'line',9,21,3,21,3,15}}, + ['users']={{'circle','line',9,7,4},{'circle','line',18,8,3},{'line',2,21,2,16,5,13,13,13,16,16,16,21},{'line',17,13,22,16,22,21}}, + ['user-round']={{'circle','line',12,7,4},{'line',4,21,4,16,8,12,16,12,20,16,20,21}}, + ['wallet']={{'rectangle','line',3,5,18,15},{'rectangle','line',15,10,7,6},{'circle','fill',18,13,1}}, + ['flag']={{'line',5,22,5,3,13,3,13,5,21,5,20,14,12,14,12,12,5,12}}, + ['sparkles']={{'line',12,2,14,9,21,11,14,13,12,20,10,13,3,11,10,9,12,2},{'line',20,1,20,5},{'line',18,3,22,3}}, + ['shuffle']={{'line',3,5,7,5,17,19,21,19,18,16},{'line',21,19,18,22},{'line',3,19,7,19,17,5,21,5,18,2},{'line',21,5,18,8}}, + ['triangle-alert']={{'line',12,2,23,21,1,21,12,2},{'line',12,8,12,14},{'circle','fill',12,18,1}}, + ['circle-help']={{'circle','line',12,12,10},{'line',8,8,9,6,14,6,16,8,16,10,12,13,12,15},{'circle','fill',12,18,1}}, + ['music']={{'line',8,18,8,5,20,3,20,16},{'line',8,8,20,6},{'circle','fill',5,18,3},{'circle','fill',17,16,3}}, + ['play']={{'polygon','fill',6,3,21,12,6,21}}, + ['square']={{'rectangle','fill',4,4,16,16}}, + ['monitor']={{'rectangle','line',2,3,20,14},{'line',12,17,12,22},{'line',7,22,17,22}}, + ['gamepad-2']={{'line',5,6,19,6,23,18,20,21,15,15,9,15,4,21,1,18,5,6},{'line',5,10,11,10},{'line',8,7,8,13},{'circle','fill',16,10,1},{'circle','fill',19,12,1}}, + ['chart-no-axes-column']={{'rectangle','line',3,12,4,9},{'rectangle','line',10,3,4,18},{'rectangle','line',17,8,4,13}}, +} +for name in pairs(shapes)do Icons.NAMES[#Icons.NAMES+1]=name end +table.sort(Icons.NAMES) +function Icons.has(name)return shapes[name]~=nil end +function Icons.draw(name,x,y,size,color,alpha) + local commands=shapes[name] + if not commands then return false end + local g=love.graphics + g.push('all');g.translate(x,y);g.scale(size/24,size/24) + g.setLineWidth(1.6);g.setColor(color[1]/255,color[2]/255,color[3]/255,alpha or 1) + for _,command in ipairs(commands)do g[command[1]](unpack(command,2)) end + g.pop();return true +end +return Icons diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua new file mode 100644 index 000000000..dae04314c --- /dev/null +++ b/src/ui/kit/Kit.lua @@ -0,0 +1,1282 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Historical performance figures below describe the original Gen1 kit; +-- this native integration makes no corresponding FPS/latency claim. +-- Immediate-mode widget kit shared by the launcher and the save editor. +-- +-- This is the replacement for the vendored FlexLove tree that the launcher +-- used to rebuild every frame. The contract that mattered there is kept -- +-- the UI is rebuilt from owner state each frame, so it can never drift -- +-- but without a retained element tree, per-element id hashing, or a +-- property snapshot pass. Measured effect on the launcher's build+draw: +-- ~9.2ms/frame down to well under 1ms (see POKEPORT_LAUNCHER_PROF). +-- +-- Usage, once per love.draw(): +-- Kit.layout(w, h) -- fonts + scale, only on resize +-- Kit.beginFrame(mx, my, clicked, wheel) +-- ... widgets ... +-- Kit.endFrame() +-- +-- WHY IT IS FAST (the rules any new widget must follow): +-- 1. No allocation in the steady state. Widgets take and return scalars; +-- the per-frame tables that do exist (nav list, audit) are reused and +-- truncated, never rebuilt. LuaJIT's GC is the difference between a +-- 6ms frame and a 0.6ms one when a list has 200 rows. +-- 2. Text is cached as love.graphics.Text objects keyed by font+string +-- (Kit.text). G.print re-shapes the string every call; a Text object +-- shapes once and then costs one batched draw. Colour is applied at +-- draw time, which does NOT break the batch -- switching FONTS does, +-- which is the other reason the cache pays. +-- 3. Measurement (font:getWidth, ellipsize) is memoised per font+string. +-- Ellipsising is O(glyphs) with a getWidth per step and list rows do it +-- for every visible cell, every frame, on strings that never change. +-- 4. Lists PAGINATE. Row count is bounded by the page size, so a 500-mod +-- index costs exactly what a 10-mod one does. There is no virtualised +-- scroller and no momentum integrator to run. +-- 5. Draw flat. No stencil, no mesh, no canvas, no shader, no blend-mode +-- change -- every one is a pipeline flush. Rounded corners, the emboss +-- and a card's drop shadow are allowed because they only add VERTICES at +-- the same pipeline state (see Theme.lua's header for the full rule). +-- +-- ACCESSIBILITY / INPUT: every control is reachable four ways -- mouse, +-- touch (>= 30px targets), keyboard (spatial focus ring, arrows + Enter), +-- and gamepad (the same ring, driven by the d-pad, plus a virtual cursor). +-- Hit testing is a plain rect with no z-order, so overlapping layers must be +-- drawn in dispatch order and a modal raises Kit.blockClicks over what it +-- covers. + +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local VirtualKeyboard = require("src.ui.kit.VirtualKeyboard") +local FileBrowser = require("src.ui.kit.FileBrowser") +local Transition = require("src.ui.kit.Transition") + +local Kit = { + scale = 1, + time = 0, + focus = nil, -- active text-input id (nil = no focused field) + focusId = nil, -- spatial-nav ring id (nil = nothing selected by pad/arrows) + VirtualKeyboard = VirtualKeyboard, + FileBrowser = FileBrowser, + Transition = Transition, +} + +Kit.mouseX, Kit.mouseY = 0, 0 +Kit.mouseClicked = false -- left button pressed this frame +Kit.mouseDown = false -- held, polled (drag / press-and-hold) +Kit.wheelY = 0 -- wheel notches queued since the last frame +Kit.scale = 1 +Kit.blockClicks = false +Kit.audit = nil + +local G = love and love.graphics or nil +local edits = {} -- queued textinput / backspace since the last frame +local kbField = nil -- id of the field the soft keyboard is raised for + +local function has(name) + return Theme.probe(name) +end + +-- ------------------------------------------------------------ soft keyboard +-- Mobile LOVE only delivers love.textinput while setTextInput(true) is +-- active, and that call is what raises the Android/iOS soft keyboard; the +-- rect keeps the focused field visible above it. setTextInput is global SDL +-- state, not per-widget, so desktop text input is never turned off (#529). +local function mobile() + local osName = love and love.system and love.system.getOS + and love.system.getOS() + return osName == "Android" or osName == "iOS" +end +Kit.isMobile = mobile + +local function syncSoftKeyboard(id, x, y, w, h) + if not (love and love.keyboard and love.keyboard.setTextInput) then return end + if id then + if kbField ~= id then + kbField = id + love.keyboard.setTextInput(true, math.floor(x), math.floor(y), + math.ceil(w), math.ceil(h)) + end + elseif kbField then + kbField = nil + if mobile() then love.keyboard.setTextInput(false) end + end +end + +-- ------------------------------------------------------------- text caching +-- Two caches, both keyed by font name + string, both cleared wholesale when +-- the font set is rebuilt (a resize). A wholesale clear is correct and +-- cheap: an LRU would cost more bookkeeping per lookup than it saves, and +-- the working set of a UI is small and stable between resizes. +local textCache, textCacheN = {}, 0 +local widthCache = {} +local ellipsisCache = {} +local CACHE_MAX = 1024 + +local wrapCacheRef -- forward declaration; the table is defined below +local function clearCaches() + textCache, textCacheN = {}, 0 + widthCache = {} + ellipsisCache = {} + if wrapCacheRef then + for k in pairs(wrapCacheRef) do wrapCacheRef[k] = nil end + end +end +Kit.clearCaches = clearCaches + +local function font(name) + return Kit.fonts[name] or Kit.fonts.small +end +Kit.font = font + +-- Rebuild the font set when the window size changes. The scale never dips +-- below 0.9 so text and the 30px tap targets stay readable on a phone; a +-- narrow window is answered by REFLOW (see Layout.lua), never by shrinking. +-- Global size multiplier. Everything in the UI derives from Kit.scale, so +-- one factor here moves text, tap targets, padding and row heights together +-- and nothing drifts out of proportion. 1.3 because the launcher is read at +-- couch distance as often as at desk distance, and the old sizing was tuned +-- for the latter only. +local UI_SCALE = 1.3 + +function Kit.layout(width, height) + local s = Theme.clamp(math.min(width / 640, height / 768), 0.9, 1.6) * UI_SCALE + -- Two numbers, not a formatted key: this runs once per frame and the + -- string:format allocated on every one of them. + local kw, kh = math.floor(width), math.floor(height) + if Kit._fontW ~= kw or Kit._fontH ~= kh then + Kit._fontW, Kit._fontH = kw, kh + Kit.fonts = Theme.fonts(s) + clearCaches() -- every cached Text/width belongs to the old font set + end + Kit.scale = s + Kit.width, Kit.height = width, height + return s +end + +function Kit.textWidth(name, str) + str = tostring(str) + local key = name .. "\0" .. str + local w = widthCache[key] + if w then return w end + local f = font(name) + -- Never let a malformed string (a mod name from a third-party index) throw + -- out of a measurement: an unmeasurable string is treated as zero-width and + -- the ellipsis logic clips it away. + if f then + local ok, got = pcall(f.getWidth, f, str) + w = ok and got or 0 + else + w = 0 + end + widthCache[key] = w + return w +end + +function Kit.textHeight(name) + local f = font(name) + return f and f:getHeight() or 12 +end + +function Kit.ellipsize(name, str, maxW) + str = tostring(str or "") + local key = name .. "\0" .. math.floor(maxW) .. "\0" .. str + local c = ellipsisCache[key] + if c then return c end + c = Theme.ellipsize(font(name), str, maxW) + ellipsisCache[key] = c + return c +end + +function Kit.ellipsizeLeft(name, str, maxW) + str = tostring(str or "") + local key = name .. "\1" .. math.floor(maxW) .. "\0" .. str + local c = ellipsisCache[key] + if c then return c end + c = Theme.ellipsizeLeft(font(name), str, maxW) + ellipsisCache[key] = c + return c +end + +-- A cached, pre-shaped Text object. Falls back to G.print under a stub or +-- when the cache is saturated. +local function textObject(name, str) + if not (G and has("newText")) then return nil end + local key = name .. "\0" .. str + local t = textCache[key] + if t then return t end + if textCacheN >= CACHE_MAX then clearCaches() end + local f = font(name) + if not f then return nil end + local ok, obj = pcall(G.newText, f, str) + if not ok then return nil end + textCache[key] = obj + textCacheN = textCacheN + 1 + return obj +end + +-- Bold text: the same cached run drawn twice, one pixel apart. The UI face +-- has a single weight, so this is the only way to get emphasis without +-- shipping a second font -- and it keeps the measurement identical, which +-- matters because every layout here is measured, not flowed. +function Kit.textBold(name, str, x, y, c, a) + local w = Kit.text(name, str, x, y, c, a) + Kit.text(name, str, x + Theme.BOLD_OFFSET, y, c, a) + return w +end + +function Kit.textCenterBold(name, str, x, y, w, c, a) + local tw = Kit.textWidth(name, tostring(str)) + return Kit.textBold(name, str, x + (w - tw) / 2, y, c, a) +end + +-- Draw a string. Returns its width, so callers can lay out inline runs +-- without a second measurement. +function Kit.text(name, str, x, y, c, a) + if not G then return 0 end + str = tostring(str) + Theme.col(c or PAL.text, a or 1) + local obj = textObject(name, str) + if obj then + G.draw(obj, Theme.snap(x), Theme.snap(y)) + else + local f = font(name) + if not f then return 0 end + G.setFont(f) + -- Same guard as the measurement path: a string LOVE cannot shape must + -- not take the whole frame down with it. + pcall(G.print, str, Theme.snap(x), Theme.snap(y)) + end + return Kit.textWidth(name, str) +end + +function Kit.textRight(name, str, x2, y, c, a) + return Kit.text(name, str, x2 - Kit.textWidth(name, tostring(str)), y, c, a) +end + +function Kit.textCenter(name, str, x, y, w, c, a) + return Kit.text(name, str, x + (w - Kit.textWidth(name, tostring(str))) / 2, + y, c, a) +end + +-- Word-wrapped text. Font:getWrap re-shapes the whole string every call and +-- list rows ask for the same (font, width, string) every frame, so the line +-- split is memoised alongside the other measurement caches. `maxLines` +-- truncates with an ellipsis rather than overflowing the box the caller +-- reserved -- an immediate-mode layout has no way to grow after the fact. +local wrapCache = {} +wrapCacheRef = wrapCache + +function Kit.wrapLines(name, str, w) + str = tostring(str or "") + if str == "" or w <= 0 then return nil end + local key = name .. "\0" .. math.floor(w) .. "\0" .. str + local lines = wrapCache[key] + if lines then return lines end + local f = font(name) + if not f then return nil end + local ok, _, wrapped = pcall(f.getWrap, f, str, w) + lines = (ok and wrapped) or { str } + wrapCache[key] = lines + return lines +end + +-- Returns the height consumed. +function Kit.textWrapped(name, str, x, y, w, c, maxLines, a) + local lines = Kit.wrapLines(name, str, w) + if not lines then return 0 end + local lh = Kit.textHeight(name) + local n = #lines + if maxLines and n > maxLines then n = maxLines end + for i = 1, n do + local line = lines[i] + if maxLines and i == maxLines and #lines > maxLines then + line = Kit.ellipsize(name, line .. "...", w) + end + Kit.text(name, line, x, y + (i - 1) * lh, c, a) + end + return n * lh +end + +-- Height a wrapped run will need, without drawing it. Panels call this to +-- reserve space before laying the block out. +function Kit.wrapHeight(name, str, w, maxLines) + local lines = Kit.wrapLines(name, str, w) + if not lines then return 0 end + local n = #lines + if maxLines and n > maxLines then n = maxLines end + return n * Kit.textHeight(name) +end + +-- 12px / 2px-tracked uppercase section caption -- the design's one and only +-- section header. Returns its height so callers can stack below. +function Kit.caption(x, y, str, c) + if not G then return Kit.textHeight("caption") end + local f = font("caption") + if not f then return 12 end + G.setFont(f) + Theme.col(c or PAL.caption, 1) + Theme.spaced(f, str, Theme.snap(x), Theme.snap(y), 2 * Kit.scale) + return f:getHeight() +end + +function Kit.captionWidth(str) + return Theme.spacedWidth(font("caption"), str, 2 * Kit.scale) +end + +-- ------------------------------------------------------------- frame cycle +function Kit.beginFrame(mx, my, clicked, wheel) + Kit.mouseX, Kit.mouseY = mx or 0, my or 0 + Kit.mouseClicked = clicked and true or false + Kit.wheelY = wheel or 0 + Kit._fieldHit, Kit._focusDrawn = false, false + local down = false + if love and love.mouse and love.mouse.isDown then + down = love.mouse.isDown(1) and true or false + end + Kit.mouseDown = down + if not down then Kit._drag = nil end + Kit.resetClip() + Kit.blockClicks = false + Kit.occlude(nil) + if love and love.timer and love.timer.getTime then + Kit.time = love.timer.getTime() + end + Transition.update(Kit.time) + if Transition.active() then Kit.blockClicks = true end + -- Resolve any queued focus-ring movement against LAST frame's geometry. + -- Immediate mode has no geometry until the frame is built, and the ring + -- must move before widgets test themselves against it. + Kit._resolveNav() + -- Start collecting this frame's focusables. + Kit._navN = 0 +end + +local function getNavLayer(slot) + if not slot then return 3 end + local id = tostring(slot.id or "") + local y = slot.y or 0 + + -- Layer 1: Top Bar (Settings / Gear, Close / Quit, Save Sync). tab-sync + -- rides the gear's cluster; by its tab- prefix alone it fell into layer 2, + -- so Right from it found nothing and Up jumped sideways to the gear. + if id == "gear" or id == "settings" or id == "close" or id == "quit" + or id == "tab-sync" + or (y < 45 * Kit.scale and not id:match("^tab%-")) then + return 1 + end + + -- Layer 2: ROM Select & Feature Tabs (Red, Blue, Yellow, Gold, Silver, Crystal, Mods, Find, Skins, Bug) + if id:match("^tab%-") or (y >= 45 * Kit.scale and y < 105 * Kit.scale and (slot.h and slot.h < 50 * Kit.scale)) then + return 2 + end + + -- Layer 4: Footer (Patch notes, Updater, BCG, Export, etc.) + local screenH = (love and love.graphics and love.graphics.getHeight and love.graphics.getHeight()) or 480 + if id == "patch-notes" or id == "updater" or id == "bcg" + or id:match("^footer") or (y > screenH - 55 * Kit.scale) then + return 4 + end + + -- Layer 3: Main content panel (Import ROM / Play, Manage, Save slots, Mods list, Find mods, etc.) + return 3 +end + +-- Retire this frame's keystrokes, wheel notches and one-shot activations. +-- Anything typed while no field had focus is dropped here rather than +-- replayed into the next field that gets clicked. +function Kit.endFrame() + if Kit.focus and ((Kit.mouseClicked and not Kit._fieldHit) or not Kit._focusDrawn) then Kit.blur() end + for i = #edits, 1, -1 do edits[i] = nil end + Kit.wheelY = 0 + Kit._activateId = nil + -- This frame's focusables become next frame's navigation graph. + local n = Kit._navN or 0 + Kit._navPrevN = n + -- If the focused id vanished or not set yet, park the ring on Layer 3 (Import ROM / Play) + if (not Kit.focusId or not Kit._navSeen[Kit.focusId]) and n > 0 then + -- 1. Prefer ROM Action (Layer 3) + local chosen = nil + for i = 1, n do + local slot = Kit._nav[i] + if slot and slot.id and tostring(slot.id):match("^rom%-") then + chosen = slot.id; break + end + end + -- 2. Prefer any Layer 3 control + if not chosen then + for i = 1, n do + local slot = Kit._nav[i] + if slot and getNavLayer(slot) == 3 then + chosen = slot.id; break + end + end + end + Kit.focusId = chosen or (Kit._nav[1] and Kit._nav[1].id) or nil + end + for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end +end + +-- ------------------------------------------------------------ focus ring +-- Spatial navigation. Every focusable control registers its rect as it +-- draws; a queued direction picks the nearest candidate in that direction +-- from the previous frame's set. +Kit._nav = {} +Kit._navN = 0 +Kit._navPrevN = 0 +Kit._navSeen = {} +Kit._navQueue = nil +Kit._activateId = nil +Kit._ringShown = true + +-- Register a focusable. Returns true when it currently holds the ring. +function Kit.focusable(id, x, y, w, h) + if Kit.blockClicks then return false end + local n = (Kit._navN or 0) + 1 + Kit._navN = n + local slot = Kit._nav[n] + if not slot then slot = {}; Kit._nav[n] = slot end + slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h + Kit._navSeen[id] = true + if Kit.focusId == nil and tostring(id):match("^rom%-") then + Kit.focusId = id + end + return Kit._ringShown and Kit.focusId == id +end + +function Kit.navigate(dir) + Kit._navQueue = dir + Kit._ringShown = true +end + +function Kit.activateFocused() + if Kit.focusId then Kit._activateId = Kit.focusId end +end + +function Kit.setFocus(id) + Kit.focusId = id + Kit._ringShown = id ~= nil +end + +function Kit.pointerUsed() + Kit._ringShown = false +end + +function Kit._resolveNav() + local dir = Kit._navQueue + Kit._navQueue = nil + local n = Kit._navPrevN or 0 + if not dir or n == 0 then return end + local cur + for i = 1, n do + if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i]; break end + end + if not cur then + Kit.focusId = Kit._nav[1].id + return + end + + local curLayer = getNavLayer(cur) + local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2 + + if tostring(cur.id):match("^gamepop%-") then + local best, bestScore + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id and tostring(c.id):match("^gamepop%-") then + local dx, dy = c.x + c.w / 2 - cx, c.y + c.h / 2 - cy + local horizontal = dir == "left" or dir == "right" + local forward = (dir == "left" and -dx) or (dir == "right" and dx) + or (dir == "up" and -dy) or dy + local cross = math.abs(horizontal and dy or dx) + if forward > 1 and (not horizontal or cross < 1) then + local score = forward + cross * 2 + if not bestScore or score < bestScore then + best, bestScore = c, score + end + end + end + end + if best then Kit.focusId = best.id end + return + end + + if dir == "left" or dir == "right" then + -- STRICT SAME-LAYER HORIZONTAL NAVIGATION (Left/Right NEVER jumps between layers) + local best, bestDx + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id and getNavLayer(c) == curLayer then + local cMidX = c.x + c.w / 2 + local dx = cMidX - cx + if dir == "left" and dx < -1 then + local dist = -dx + if not bestDx or dist < bestDx then + best, bestDx = c, dist + end + elseif dir == "right" and dx > 1 then + local dist = dx + if not bestDx or dist < bestDx then + best, bestDx = c, dist + end + end + end + end + if best then + Kit.focusId = best.id + return + end + return + elseif dir == "up" or dir == "down" then + -- Within the layer first: the nearest control above/below in the same + -- layer wins, so Down from the cart reaches the Scan / Import button under + -- it instead of jumping over it to the footer. The horizontal gap weighs + -- double so a control straight below beats one off to the side. + do + local best, bestScore + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id and getNavLayer(c) == curLayer then + local dy = (c.y + c.h / 2) - cy + local forward = dir == "down" and dy or -dy + if forward > 1 then + local gap = math.max(0, c.x - (cur.x + cur.w), cur.x - (c.x + c.w)) + local score = forward + gap * 2 + if not bestScore or score < bestScore then + best, bestScore = c, score + end + end + end + end + if best then + Kit.focusId = best.id + return + end + end + -- VERTICAL LAYER NAVIGATION (Up/Down steps between layers: 1 <-> 2 <-> 3 <-> 4) + local targetLayer = dir == "up" and (curLayer - 1) or (curLayer + 1) + targetLayer = math.max(1, math.min(4, targetLayer)) + + local best, bestScore + for i = 1, n do + local c = Kit._nav[i] + if c.id ~= cur.id then + local l = getNavLayer(c) + if (dir == "up" and l < curLayer) or (dir == "down" and l > curLayer) then + local layerDiff = math.abs(l - targetLayer) + local cMidX = c.x + c.w / 2 + local xDist = math.abs(cMidX - cx) + local score = layerDiff * 10000 + xDist + if not bestScore or score < bestScore then + best, bestScore = c, score + end + end + end + end + if best then + Kit.focusId = best.id + return + end + end +end + +-- ------------------------------------------------------------ input plumbing +function Kit.textinput(text) + if VirtualKeyboard.active then + return VirtualKeyboard.textinput(text) + end + if not Kit.focus then return false end + edits[#edits + 1] = text + return true +end + +-- Returns true when the key was consumed, so the host can leave its own +-- shortcuts alone while the user is typing or driving the ring. +function Kit.keypressed(key) + if VirtualKeyboard.active then + return VirtualKeyboard.keypressed(key) + end + if FileBrowser.active then + return FileBrowser.keypressed(key) + end + if Kit.focus then + if key == "backspace" then edits[#edits + 1] = "\b" return true + elseif key == "return" or key == "kpenter" or key == "escape" then + edits[#edits + 1] = "\r" return true + elseif key == "tab" or key == "up" or key == "down" then + Kit.blur() + Kit.navigate(key == "up" and "up" or "down") + return true + end + -- printable keys arrive through textinput; everything else falls through + return false + end + if key == "up" or key == "down" or key == "left" or key == "right" then + Kit.navigate(key) + return true + elseif key == "return" or key == "kpenter" or key == "space" then + Kit.activateFocused() + return true + end + return false +end + +-- Gamepad d-pad / stick, routed by the host's pad handling. +function Kit.gamepadpressed(button) + local GamepadMap = require("src.core.GamepadMap") + local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button + if VirtualKeyboard.active then + return VirtualKeyboard.gamepadpressed(action) + end + if FileBrowser.active then + return FileBrowser.gamepadpressed(action) + end + if Kit.focus and action == "b" then Kit.blur() return true end + if action == "dpup" then Kit.navigate("up") return true + elseif action == "dpdown" then Kit.navigate("down") return true + elseif action == "dpleft" then Kit.navigate("left") return true + elseif action == "dpright" then Kit.navigate("right") return true + elseif action == "a" then Kit.activateFocused() return true end + return false +end + +function Kit.blur() + Kit.focus = nil + syncSoftKeyboard(nil) +end + +-- -------------------------------------------------------------- hit testing +local occluderBoxes = {} +Kit._occluders = 0 +Kit._occluder = nil +Kit._overlay = false + +function Kit.occlude(x, y, w, h) + if x == nil then + Kit._occluders = 0 + Kit._occluder = nil + return + end + local n = Kit._occluders + 1 + local o = occluderBoxes[n] + if not o then + o = {} + occluderBoxes[n] = o + end + o.x, o.y, o.w, o.h = x, y, w, h + Kit._occluders = n + Kit._occluder = occluderBoxes[1] +end + +function Kit.occluded() + if Kit._occluders == 0 or Kit._overlay then return false end + local mx, my = Kit.mouseX, Kit.mouseY + for i = 1, Kit._occluders do + local o = occluderBoxes[i] + if mx >= o.x and mx <= o.x + o.w and my >= o.y and my <= o.y + o.h then + return true + end + end + return false +end + +-- A widget inside a clip region can sit at coordinates outside the visible +-- rect, so the active clip bounds the hit: what the user cannot see cannot +-- take the tap. +function Kit.hit(x, y, w, h) + local c = Kit._clipRect + if c and not (Kit.mouseX >= c.x and Kit.mouseX <= c.x + c.w + and Kit.mouseY >= c.y and Kit.mouseY <= c.y + c.h) then + return false + end + if Kit._occluders > 0 and Kit.occluded() then return false end + return Kit.mouseX >= x and Kit.mouseX <= x + w + and Kit.mouseY >= y and Kit.mouseY <= y + h +end + +function Kit.hover(x, y, w, h) + -- Shielded widgets (drawn while a modal owns the frame) must not glow + -- either: a hover highlight under the scrim reads as "still clickable". + if Kit.blockClicks then return false end + return Kit.hit(x, y, w, h) +end + +function Kit.press(x, y, w, h) + if Kit.blockClicks then return false end + return Kit.mouseClicked and Kit.hit(x, y, w, h) +end + +-- Layout audit: when a test sets Kit.audit to a table, every control that +-- could take a click this frame appends its rect (plus the clip that bounds +-- it), so a window-size sweep can assert no two controls overlap and none +-- escapes the window. Shielded widgets are skipped: under a modal they +-- cannot take the tap, and the modal legitimately covers them. +local function audit(class, x, y, w, h, label) + local a = Kit.audit + if not a or Kit.blockClicks then return end + local c = Kit._clipRect + a[#a + 1] = { class = class, x = x, y = y, w = w, h = h, + label = tostring(label or ""), + clip = c and { x = c.x, y = c.y, w = c.w, h = c.h } or nil } +end +Kit._audit = audit + +-- ------------------------------------------------------------------ metrics +-- Minimum tap target. 30px at scale 1 (up from the editor's 26) because the +-- launcher is the first thing a phone user touches and these are the only +-- controls that matter. +function Kit.tapMin() return math.floor(30 * Kit.scale) end + +-- ---------------------------------------------------------------- surfaces +function Kit.card(x, y, w, h, variant) + Theme.card(x, y, w, h, variant) +end + +function Kit.row(x, y, w, h, selected, id) + audit("row", x, y, w, h, id or "row") + local focused = id and Kit.focusable(id, x, y, w, h) or false + local hot = Kit.hover(x, y, w, h) + local state = selected and "selected" or (hot and "hover" or nil) + local ink = Theme.row(x, y, w, h, state) + -- The focus ring is a second inset outline, so it reads on both a black + -- row and a white selected one. + if focused then + Theme.strokeRounded(x, y, w, h, PAL.ink, 1, 2, Theme.radius()) + end + local clicked = Kit.press(x, y, w, h) + or (id ~= nil and Kit._activateId == id) + return clicked, ink +end + +-- Empty-state box: hairline outline and a centred hint. (The old dashed +-- border sampled a rounded path into a polyline every frame; a solid +-- hairline says the same thing for one rect.) +function Kit.emptyBox(x, y, w, h, message) + if not G then return end + Theme.card(x, y, w, h, "empty") + Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale), + x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted) +end + +-- ----------------------------------------------------------------- buttons +-- Primary actions carry color; secondary and destructive actions use a quiet +-- surface and an outline. All faces share an inset hover/press highlight and +-- a solid keyboard focus border, so controls never grow into their neighbors. +local Button = require("src.ui.kit.Button") +local Icons = require("src.ui.kit.Icons") +Kit.KINDS = Button.KINDS +local NO_OPTS = {} + +function Kit.button(x, y, w, h, label, opts) + opts = opts or NO_OPTS + local enabled = opts.enabled ~= false + audit("control", x, y, w, h, label) + local focused = enabled and opts.id + and Kit.focusable(opts.id, x, y, w, h) or false + local hot = enabled and Kit.hover(x, y, w, h) + Button.draw(Kit, x, y, w, h, label, opts, hot, focused) + if not enabled then return false end + return Kit.press(x, y, w, h) + or (not Kit.blockClicks and opts.id ~= nil + and Kit._activateId == opts.id) +end + +function Kit.stepper(x, y, w, h, glyph, opts) + opts = opts or {} + opts.kind = opts.kind or "ghost" + opts.font = opts.font or "small" + return Kit.button(x, y, w, h, glyph, opts) +end + +local CHIP_OPTS = { face = "chip", font = "micro" } + +function Kit.chip(x, y, w, h, label, on, color, id) + CHIP_OPTS.active = on + CHIP_OPTS.color = color + CHIP_OPTS.id = id + return Kit.button(x, y, w, h, label, CHIP_OPTS) +end + +-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE" +-- markers on mod rows. Pass opts.fill for a solid chip (BETA badges); ink +-- then defaults to PAL.inverse so the label stays readable on the fill. +function Kit.tag(x, y, w, h, label, color, opts) + if not G then return end + opts = opts or {} + if opts.fill then + Theme.fillRounded(x, y, w, h, color or PAL.yellow, 1, h / 2) + else + Theme.strokeRounded(x, y, w, h, color or PAL.line, 0.7, 1) + end + local ink = opts.ink or (opts.fill and PAL.inverse) or color or PAL.muted + local ty = y + (h - Kit.textHeight("micro")) / 2 + if opts.bold then + Kit.textCenterBold("micro", label, x, ty, w, ink) + else + Kit.textCenter("micro", label, x, ty, w, ink) + end +end + +local TOAST_ACCEPT = { kind = "primary", font = "small" } +local TOAST_DECLINE = { kind = "ghost", font = "small" } + +function Kit.toastRect(W, text, top, slide) + local s = Kit.scale + local margin = math.floor(16 * s) + local w = math.floor(math.min(360 * s, (W or 0) - 2 * margin)) + local pad = math.floor(12 * s) + local gap = math.floor(8 * s) + local icon = math.floor(22 * s) + local btnH = math.max(Kit.tapMin(), math.floor(30 * s)) + local barH = math.max(2, math.floor(3 * s)) + local textW = w - 2 * pad - icon - gap + local textH = Kit.wrapHeight("small", text or "", textW, 2) + local h = pad + math.max(icon, textH) + gap + btnH + gap + barH + pad + local x = (W or 0) - w - margin + x = x + math.floor((1 - (slide or 1)) * (w + margin)) + return x, top or margin, w, h +end + +function Kit.toast(opts) + opts = opts or NO_OPTS + local s = Kit.scale + local x, y, w, h = Kit.toastRect(opts.W, opts.text, opts.top, opts.slide) + local pad = math.floor(12 * s) + local gap = math.floor(8 * s) + local icon = math.floor(22 * s) + local btnH = math.max(Kit.tapMin(), math.floor(30 * s)) + local barH = math.max(2, math.floor(3 * s)) + local wasOverlay, wasBlock = Kit._overlay, Kit.blockClicks + Kit._overlay = true + Kit.blockClicks = opts.blocked == true + local action = nil + if G then + Kit.card(x, y, w, h, true) + Theme.strokeRounded(x, y, w, h, PAL.lineStrong, Theme.A.focus, 1) + Icons.draw(opts.icon or "mail", x + pad, y + pad, icon, PAL.heading, 1) + end + local tx = x + pad + icon + gap + Kit.textWrapped("small", opts.text or "", tx, y + pad, w - 2 * pad - icon - gap, + PAL.heading, 2) + local by = y + h - pad - barH - gap - btnH + local half = math.floor((w - 2 * pad - gap) / 2) + local id = opts.id or "toast" + TOAST_DECLINE.id = id .. "-decline" + TOAST_ACCEPT.id = id .. "-accept" + if Kit.button(x + pad, by, half, btnH, opts.decline or "Decline", + TOAST_DECLINE) then + action = "decline" + end + if Kit.button(x + pad + half + gap, by, w - 2 * pad - half - gap, btnH, + opts.accept or "Accept", TOAST_ACCEPT) then + action = "accept" + end + if G then + local bw = w - 2 * pad + local left = math.max(0, math.min(1, opts.progress or 1)) + Theme.fillRounded(x + pad, y + h - pad - barH, bw, barH, PAL.line, 0.5, barH / 2) + if left > 0 then + Theme.fillRounded(x + pad, y + h - pad - barH, math.max(barH, bw * left), barH, + left < 0.25 and PAL.red or PAL.green, 1, barH / 2) + end + end + Kit._overlay, Kit.blockClicks = wasOverlay, wasBlock + return action, x, y, w, h +end + +-- Checkbox row. Returns (newChecked, changed). +function Kit.checkbox(x, y, w, h, checked, label, id, labelColor) + local clicked, ink = Kit.row(x, y, w, h, false, id) + local box = 20 * Kit.scale + local bx, by = x + 12 * Kit.scale, y + (h - box) / 2 + if G then + local br = math.min(Theme.radius(), box / 3) + if checked then + Theme.fillRounded(bx, by, box, box, PAL.ink, 1, br) + Kit.textCenter("small", "X", bx, + by + (box - Kit.textHeight("small")) / 2, box, PAL.inverse) + else + Theme.strokeRounded(bx, by, box, box, PAL.line, Theme.A.hover, 1, br) + end + local lx = bx + box + 12 * Kit.scale + Kit.text("mono", Kit.ellipsize("mono", label, x + w - lx - 10 * Kit.scale), + lx, y + (h - Kit.textHeight("mono")) / 2, + labelColor or ink or PAL.text) + end + if clicked then return not checked, true end + return checked, false +end + +-- A two-state switch, for the settings ladders. +function Kit.toggle(x, y, w, h, on, id) + audit("control", x, y, w, h, "toggle") + local focused = id and Kit.focusable(id, x, y, w, h) or false + if G then + -- Track, then a knob inset inside it, so the control reads as a switch + -- rather than as a white square with a word next to it. The label sits + -- in the empty half, which is the half that says what pressing does. + local r = math.min(Theme.radius(), h / 2) + Theme.fillRounded(x, y, w, h, PAL.rowBg, 1, r) + Theme.strokeRounded(x, y, w, h, PAL.line, + (focused or Kit.hover(x, y, w, h)) and Theme.A.focus or Theme.A.hover, 1, r) + local inset = 3 + local knob = w / 2 - inset + Theme.fillRounded(on and (x + w / 2) or (x + inset), y + inset, knob, + h - 2 * inset, PAL.ink, 1, math.min(r, (h - 2 * inset) / 2)) + Kit.textCenter("micro", on and "ON" or "OFF", + on and x or (x + w / 2), y + (h - Kit.textHeight("micro")) / 2, w / 2, + PAL.text) + end + local hitTaken = Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id) + if hitTaken then return not on, true end + return on, false +end + +-- A determinate progress bar with an optional caption. +function Kit.progress(x, y, w, h, frac, label) + Theme.meter(x, y, w, h, (frac or 0) * 100, PAL.ink) + if label then + Kit.text("micro", label, x, y + h + 4 * Kit.scale, PAL.muted) + end +end + +-- --------------------------------------------------------------- text field +function Kit.textfield(id, x, y, w, h, value, placeholder) + audit("control", x, y, w, h, id) + local focusRing = Kit.focusable(id, x, y, w, h) + value = tostring(value or "") + + local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" + or os.getenv("POKEPORT_SBC") == "1" or os.getenv("ANBERNIC") == "1" + + local clickedOrActivated = (Kit._activateId == id) or (isHandheld and Kit.press(x, y, w, h)) + if isHandheld and clickedOrActivated and not mobile() then + VirtualKeyboard.open({ + text = value, + targetId = id, + title = placeholder or "Enter Text", + onDone = function(newText, confirmed) + if confirmed then + edits[#edits + 1] = "\r" + end + end + }) + end + + if VirtualKeyboard.active and VirtualKeyboard.targetId == id then + value = VirtualKeyboard.text + end + + if Kit.press(x, y, w, h) then Kit._fieldHit = true end + if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end + local focused = (Kit.focus == id) + if focused then + syncSoftKeyboard(id, x, y, w, h) + for _, e in ipairs(edits) do + if e == "\b" then + value = value:sub(1, -2) + elseif e == "\r" then + Kit.blur() + focused = false + else + value = value .. e + end + end + if focused then Kit._focusDrawn = true end + end + if G then + Theme.fillRounded(x, y, w, h, PAL.bg, 1) + Theme.strokeRounded(x, y, w, h, PAL.line, + (focused or focusRing) and Theme.A.focus or Theme.A.hairline, + focused and 2 or 1) + local pad = 10 * Kit.scale + local ty = y + (h - Kit.textHeight("mono")) / 2 + if value == "" and not focused then + Kit.text("mono", placeholder or "", x + pad, ty, PAL.faint) + else + local shown = Kit.ellipsizeLeft("mono", value, w - 2 * pad) + local tw = Kit.text("mono", shown, x + pad, ty, PAL.heading) + if focused and (Kit.time % 1) < 0.55 then + Theme.fill(x + pad + tw + 2, ty, math.max(1, Kit.scale), + Kit.textHeight("mono"), PAL.ink, 1) + end + end + end + return value +end + +-- -------------------------------------------------------------------- pager +-- Prev / Next / "1-12 of 151". Drawn even for a single page, so a list is +-- never silently truncated. A long list still PAGES rather than scrolling: +-- no momentum, bounded row count per frame. +-- Returns the new page (1-based) and the row height consumed. +local pagerLabels = {} + +function Kit.pager(x, y, w, page, total, perPage, idPrefix, compact) + local h = math.max(Kit.tapMin(), 30 * Kit.scale) + local bw = 74 * Kit.scale + local pages = math.max(1, math.ceil(total / math.max(1, perPage))) + page = math.floor(Theme.clamp(page or 1, 1, pages)) + local gap = 8 * Kit.scale + idPrefix = idPrefix or "pager" + + if Kit.button(x, y, bw, h, "Prev", { kind = "ghost", font = "small", icon = "chevron-left", + enabled = page > 1, id = idPrefix .. ":prev" }) then + page = math.max(1, page - 1) + end + if Kit.button(x + bw + gap, y, bw, h, "Next", { kind = "ghost", icon = "chevron-right", + font = "small", enabled = page < pages, id = idPrefix .. ":next" }) then + page = math.min(pages, page + 1) + end + + local first = total > 0 and ((page - 1) * perPage + 1) or 0 + local last = math.min(total, page * perPage) + -- One memo per pager id. The counts only change when the user pages or the + -- list does; formatting them every frame minted a new string that then + -- missed the width / ellipsis / Text caches by content. + local memo = pagerLabels[idPrefix] + if not memo then memo = {}; pagerLabels[idPrefix] = memo end + if memo.first ~= first or memo.last ~= last or memo.total ~= total + or memo.page ~= page or memo.pages ~= pages then + memo.first, memo.last, memo.total = first, last, total + memo.page, memo.pages = page, pages + memo.label = ("%d-%d of %d (page %d/%d)") + :format(first, last, total, page, pages) + end + local label = compact and (tostring(page) .. " / " .. tostring(pages)) or memo.label + local labelX = x + 2 * bw + 2 * gap + gap + Kit.text("mono", Kit.ellipsize("mono", label, math.max(0, x + w - labelX)), + labelX, y + (h - Kit.textHeight("mono")) / 2, PAL.caption) + return page, h +end + +-- Slice helper so callers never hand-roll page arithmetic (and never draw a +-- row that is off the page -- the entire performance claim rests on this). +function Kit.pageBounds(page, total, perPage) + local pages = math.max(1, math.ceil(total / math.max(1, perPage))) + page = math.floor(Theme.clamp(page or 1, 1, pages)) + local first = (page - 1) * perPage + 1 + local last = math.min(total, page * perPage) + return first, last, page, pages +end + +-- How many rows of `rowH` (plus `gap`) fit in `h` pixels. Panels call this +-- to derive perPage from the real viewport instead of a magic number, so a +-- tall window shows more rows and a phone shows fewer -- with no scrolling +-- either way. +function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows) + local per = math.floor((h + (gap or 0)) / math.max(1, rowH + (gap or 0))) + return math.max(minRows or 1, math.min(maxRows or 99, per)) +end + +Kit.dragX = nil +Kit.dragY = nil +Kit.dragAccum = 0 + +function Kit.dragBegin(x, y) + Kit.dragX, Kit.dragY, Kit.dragAccum = x, y, 0 +end + +function Kit.dragAdd(dy) + if Kit.dragX then Kit.dragAccum = Kit.dragAccum + (dy or 0) end +end + +function Kit.dragEnd() + Kit.dragX, Kit.dragY, Kit.dragAccum = nil, nil, 0 +end + +local function dragOriginIn(x, y, w, h) + if not Kit.dragX then return false end + local x1, y1, x2, y2 = x, y, x + w, y + h + local c = Kit._clipRect + if c then + x1, y1 = math.max(x1, c.x), math.max(y1, c.y) + x2, y2 = math.min(x2, c.x + c.w), math.min(y2, c.y + c.h) + end + return Kit.dragX >= x1 and Kit.dragX <= x2 + and Kit.dragY >= y1 and Kit.dragY <= y2 +end + +-- Mouse wheel over a paginated list turns PAGES. The wheel still has to do +-- something (users expect it), but it moves a bounded page index rather than +-- driving a pixel offset, so there is no scroll state and no interpolation. +function Kit.wheelPage(x, y, w, h, page, total, perPage) + if Kit.blockClicks then return page end + local pages = math.max(1, math.ceil(total / math.max(1, perPage))) + local out = page + if (Kit.wheelY or 0) ~= 0 and Kit.hit(x, y, w, h) then + out = math.floor(Theme.clamp((out or 1) + (Kit.wheelY > 0 and -1 or 1), + 1, pages)) + Kit.wheelY = 0 + end + local acc = Kit.dragAccum or 0 + if acc ~= 0 and dragOriginIn(x, y, w, h) then + local stepPx = math.max(1, math.floor((h or 0) / 2)) + local flips = acc >= 0 and math.floor(acc / stepPx) + or -math.floor(-acc / stepPx) + if flips ~= 0 then + local want = (out or 1) + flips + out = math.floor(Theme.clamp(want, 1, pages)) + Kit.dragAccum = out ~= want and 0 or acc - flips * stepPx + end + end + return out +end + +function Kit.scrollExtent(contentH, viewH) + return math.max(0, (contentH or 0) - math.max(0, viewH or 0)) +end + +function Kit.scrollClamp(offset, maxScroll) + return math.max(0, math.min(offset or 0, math.max(0, maxScroll or 0))) +end + +function Kit.scrollStep(scale) + return math.floor(48 * (scale or Kit.scale)) +end + +function Kit.scrollBarW(scale) + return math.max(2, math.floor(4 * (scale or Kit.scale))) +end + +function Kit.scrollGutter(scale) + return Kit.scrollBarW(scale) + math.max(2, math.floor(4 * (scale or Kit.scale))) +end + +function Kit.scrollHandoff(offset, maxScroll, delta) + local want = (offset or 0) + (delta or 0) + local at = Kit.scrollClamp(want, maxScroll) + return at, want - at +end + +function Kit.scrollWheel(offset, maxScroll, x, y, w, h, step) + local at = Kit.scrollClamp(offset, maxScroll) + local wheel = Kit.wheelY or 0 + if Kit.blockClicks or wheel == 0 or (maxScroll or 0) <= 0 then + return at, false + end + if not Kit.hit(x, y, w, h) then return at, false end + local moved = Kit.scrollClamp(at - wheel * (step or Kit.scrollStep()), + maxScroll) + if moved == at then return at, false end + Kit.wheelY = 0 + return moved, true +end + +function Kit.scrollInput(offset, maxScroll, x, y, w, h) + local at = Kit.scrollWheel(offset, maxScroll, x, y, w, h) + if not Kit.blockClicks and (Kit.dragAccum or 0) ~= 0 + and dragOriginIn(x, y, w, h) then + at = Kit.scrollClamp(at + Kit.dragAccum, maxScroll) + Kit.dragAccum = 0 + end + return at +end + +function Kit.scrollBegin(x, y, w, h, offset, maxScroll) + Kit.pushClip(x, y, math.max(0, w or 0), math.max(0, h or 0)) + return y - Kit.scrollClamp(offset, maxScroll) +end + +function Kit.scrollEnd(x, y, w, h, offset, maxScroll, background) + if (maxScroll or 0) > (offset or 0) + 1 and h > 0 then + local fadeH = math.min(math.floor(24 * Kit.scale), math.floor(h / 4)) + local strength = math.min(1, (maxScroll - (offset or 0)) / math.max(1, fadeH)) + for i = 0, fadeH - 1 do + Theme.fill(x, y + h - fadeH + i, w - Kit.scrollGutter(), 1, + background or PAL.field, strength * ((i + 1) / fadeH) ^ 2) + end + end + Kit.popClip() + if (maxScroll or 0) <= 0 or (h or 0) <= 0 or (w or 0) <= 0 then return end + local barW = Kit.scrollBarW() + local barX = x + w - barW + local at = Kit.scrollClamp(offset, maxScroll) + local thumbH = math.max(math.floor(20 * Kit.scale), + math.floor(h * (h / (h + maxScroll)))) + local thumbY = y + (h - thumbH) * (at / maxScroll) + Theme.fill(barX, y, barW, h, PAL.bg, 0.35) + Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7) +end + +-- ------------------------------------------------------------------ spinner +-- The one animated element in the UI: a rotating arc of ticks. Drawn as N +-- short lines at descending alpha, which needs no shader, no canvas and no +-- blend-mode change. `t` defaults to the frame clock so every spinner on +-- screen stays in phase. +function Kit.spinner(cx, cy, r, t) + if not G or not has("line") then return end + t = t or Kit.time + local ticks = 12 + local step = (math.pi * 2) / ticks + local head = math.floor((t * 10) % ticks) + if has("setLineWidth") then G.setLineWidth(math.max(2, 2 * Kit.scale)) end + for i = 0, ticks - 1 do + local a = ((ticks - ((head - i) % ticks)) / ticks) + local ang = i * step - math.pi / 2 + local c, s = math.cos(ang), math.sin(ang) + Theme.col(PAL.ink, a * a) + G.line(cx + c * r * 0.55, cy + s * r * 0.55, cx + c * r, cy + s * r) + end + if has("setLineWidth") then G.setLineWidth(1) end +end + +-- ------------------------------------------------------------------- clip +-- Clip drawing to a rect. A stack: pushes intersect with the rect above and +-- a pop restores that rect rather than clearing the scissor, so a nested +-- region can never unclip its parent. The tracked rect also bounds Kit.hit, +-- so a widget clipped out of view is inert instead of taking taps aimed at +-- whatever is drawn where it left. +-- The stack rects are pooled by depth and fully overwritten on every push, +-- so a frame that clips a dozen lists allocates nothing. +local clipStack = {} +local clipPool = {} + +local function applyClip(rect) + Kit._clipRect = rect + if not (G and G.setScissor) then return end + if not rect then + G.setScissor() + elseif rect.w <= 0 or rect.h <= 0 then + -- LOVE rejects negative scissor dimensions; an exhausted clip region is + -- empty, not invalid. + G.setScissor(0, 0, 0, 0) + else + G.setScissor(math.floor(rect.x), math.floor(rect.y), + math.ceil(rect.w), math.ceil(rect.h)) + end +end + +function Kit.pushClip(x, y, w, h) + local prev = clipStack[#clipStack] + local x2, y2 = x + math.max(0, w), y + math.max(0, h) + if prev then + x, y = math.max(x, prev.x), math.max(y, prev.y) + x2 = math.min(x2, prev.x + prev.w) + y2 = math.min(y2, prev.y + prev.h) + end + local n = #clipStack + 1 + local rect = clipPool[n] + if not rect then rect = {}; clipPool[n] = rect end + rect.x, rect.y = x, y + rect.w, rect.h = math.max(0, x2 - x), math.max(0, y2 - y) + clipStack[n] = rect + applyClip(rect) +end + +function Kit.popClip() + clipStack[#clipStack] = nil + applyClip(clipStack[#clipStack]) +end + +-- A pcall-ed draw that raised mid-clip must not leak the stack into later +-- frames (every hit test would stay fenced to the dead rect), so the frame +-- boundary clears it. +function Kit.resetClip() + for i = #clipStack, 1, -1 do clipStack[i] = nil end + applyClip(nil) +end + +return Kit diff --git a/src/ui/kit/Layout.lua b/src/ui/kit/Layout.lua new file mode 100644 index 000000000..2a5e29162 --- /dev/null +++ b/src/ui/kit/Layout.lua @@ -0,0 +1,132 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Shared layout metrics for the launcher and the save editor. +-- +-- Both windows derive one `m` table per frame from the real window size and +-- the platform safe area, and every panel lays itself out in explicit pixels +-- off that table. Explicit pixels are the point: the old view expressed +-- widths as "100%" and leaned on a layout engine to resolve them, which is +-- where the launcher's layout bugs lived (percentages resolving against a +-- border box instead of a content box, auto-sized children measuring zero +-- height inside an auto-sized parent, flex-shrink compressing text until it +-- overlapped). None of those failure modes exist when a column is simply +-- `math.floor((contentW - gap) / 2)`. +-- +-- REFLOW, not shrink: a narrow window drops to fewer columns rather than +-- scaling the desktop layout down. Scale has a floor (Kit.layout clamps to +-- 0.9) so tap targets and text stay legible on a phone. + +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local SafeArea = require("src.core.SafeArea") +local GameViewport = require("src.render.GameViewport") + +local Layout = {} + +-- Breakpoints, in safe-area pixels. Named so panels read intent rather than +-- magic numbers. +Layout.BP = { + twoCol = 640, -- side-by-side columns become possible + threeCol = 1100, -- wide desktop: mod list + detail + chrome +} + +-- Build the frame's metrics. `maxAppW` caps the content column on an +-- ultrawide monitor so the UI stays a readable measure instead of stretching. +-- One metrics table, reused. Every field is a pure function of the window +-- size, the safe area and maxAppW, so the table only has to be rebuilt when +-- one of those changes; the launcher asked for a fresh one 60 times a second +-- and threw all of them away. Callers must treat `m` as read-only (nothing +-- writes to it today) -- a caller that needs a shifted field should save, +-- assign and restore it around the call, not wrap `m` in a proxy. +local M = {} +local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax + +function Layout.metrics(maxAppW) + local W, H = 0, 0 + if love and love.graphics and love.graphics.getDimensions then + W, H = GameViewport.dimensions() + end + local ox, oy, sw, sh = SafeArea.rect() + local s = Kit.layout(sw, sh) + if W == lastW and H == lastH and ox == lastOx and oy == lastOy + and sw == lastSw and sh == lastSh and maxAppW == lastMax then + return M + end + lastW, lastH, lastOx, lastOy = W, H, ox, oy + lastSw, lastSh, lastMax = sw, sh, maxAppW + + local appW = math.min(sw, (maxAppW or 1200) * s) + local m = M + m.W, m.H, m.s = W, H, s + m.x = math.floor(ox + (sw - appW) / 2) + m.top = math.floor(oy) + m.w = math.floor(appW) + m.h = math.floor(sh) + m.pad = math.floor(Theme.clamp(appW * 0.03, 10, 24)) + m.gap = math.floor(12 * s) + m.colGap = math.floor(16 * s) + m.rowH = math.max(Kit.tapMin(), math.floor(44 * s)) + m.btnH = math.max(Kit.tapMin(), math.floor(38 * s)) + m.chip = math.max(Kit.tapMin(), math.floor(40 * s)) + m.railH = math.max(3, math.floor(4 * s)) + m.logoH = math.floor(Theme.clamp(sh * 0.10, 36, 84)) + m.cols = (appW >= Layout.BP.threeCol * s and 3) + or (appW >= Layout.BP.twoCol * s and 2) + or 1 + m.twoCol = m.cols >= 2 + m.contentW = m.w - 2 * m.pad + m.colW = m.twoCol + and math.floor((m.contentW - m.colGap) / 2) + or m.contentW + m.contentX = m.x + m.pad + return m +end + +-- A vertical cursor for stacking blocks down a column. Immediate mode has +-- no layout pass, so panels advance a y by hand; this makes that explicit +-- and keeps the arithmetic in one place. +local Cursor = {} +Cursor.__index = Cursor + +function Layout.cursor(x, y, w) + return setmetatable({ x = x, y = y, w = w, y0 = y }, Cursor) +end + +-- Reserve `h` pixels and return the rect that was reserved. +function Cursor:take(h, gapAfter) + local x, y = self.x, self.y + self.y = self.y + h + (gapAfter or 0) + return x, y, self.w, h +end + +function Cursor:skip(h) + self.y = self.y + h +end + +function Cursor:height() + return self.y - self.y0 +end + +-- Split the cursor's width into `n` equal columns with `gap` between them, +-- returning a function that yields the i-th column's x and width. +function Layout.columns(x, w, n, gap) + n = math.max(1, n) + local cw = math.floor((w - gap * (n - 1)) / n) + return function(i) + return x + (i - 1) * (cw + gap), cw + end +end + +-- Lay a row of buttons out right-aligned within [x, x+w], returning a +-- function that yields each button's x as it is consumed right to left. +function Layout.rightCluster(x, w, gap) + local cursor = x + w + return function(bw) + cursor = cursor - bw + local bx = cursor + cursor = cursor - gap + return bx + end +end + +return Layout diff --git a/src/ui/kit/Loader.lua b/src/ui/kit/Loader.lua new file mode 100644 index 000000000..1b0ed3539 --- /dev/null +++ b/src/ui/kit/Loader.lua @@ -0,0 +1,135 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Non-dismissable loading overlays. +-- +-- The rule this module enforces: ANY operation that can make the UI wait -- +-- a network fetch, a ROM extraction, a mod install, an update check -- puts +-- something obvious on screen for its whole duration. The old launcher +-- failed this twice over: slow work ran synchronously on the main thread, so +-- the window simply stopped responding (the Find Mods tab could hang for +-- minutes with no indication it was doing anything at all), and the few +-- operations that did report progress did so as a small line of text. +-- +-- Two presentations: +-- Loader.overlay(...) a modal scrim + panel, for work the user must wait +-- on before doing anything else. It BLOCKS input +-- (Kit.blockClicks) and offers no dismiss control -- +-- that is deliberate, so a half-finished install can +-- never be clicked around. Cancellable work passes +-- an onCancel and gets exactly one Cancel button. +-- Loader.inline(...) a spinner + label sized to a control, for work that +-- only blocks part of the UI (a row's update check). +-- +-- Callers drive both from a state table; nothing here owns state or time, so +-- the same overlay renders identically in a screenshot test. + +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL + +local Loader = {} + +-- Scrim alpha. Not opaque: the user keeps the context of what they were +-- doing, which is most of why a modal beats a blank screen. +local SCRIM_A = 0.82 + +-- spec = { +-- title = "Fetching mod index", -- required, the verb in progress +-- detail = "index.json from ...", -- optional second line +-- progress = 0..1 or nil, -- nil = indeterminate (spinner) +-- count = "3 of 12", -- optional right-aligned counter +-- onCancel = function() end, -- optional; adds a Cancel button +-- cancelLabel = "Cancel", +-- } +-- Returns true when the cancel button was activated this frame. +function Loader.overlay(m, spec) + if not spec then return false end + local G = love and love.graphics + local W, H = m.W, m.H + + -- The scrim covers the whole window, not just the app column: a modal that + -- leaves the letterboxed margins live is a modal you can click around. + if G then + Theme.fill(0, 0, W, H, PAL.bg, SCRIM_A) + end + -- Everything drawn BEFORE this call is now shielded; the panel below + -- lowers the shield for its own controls. + Kit.blockClicks = true + + local pw = math.floor(math.min(m.w - 2 * m.pad, 460 * m.s)) + local ph = math.floor((spec.onCancel and 210 or 160) * m.s) + local px = math.floor((W - pw) / 2) + local py = math.floor((H - ph) / 2) + + Kit.card(px, py, pw, ph, true) + + local pad = math.floor(18 * m.s) + local cx = px + pw / 2 + + -- Spinner (indeterminate) or a progress bar (determinate). Never both. + local y = py + pad + if spec.progress then + Kit.textCenter("button", spec.title, px + pad, y, pw - 2 * pad, PAL.heading) + y = y + Kit.textHeight("button") + math.floor(14 * m.s) + Kit.progress(px + pad, y, pw - 2 * pad, math.floor(10 * m.s), spec.progress) + y = y + math.floor(10 * m.s) + math.floor(10 * m.s) + local pct = ("%d%%"):format(math.floor(spec.progress * 100 + 0.5)) + Kit.textCenter("small", pct, px + pad, y, pw - 2 * pad, PAL.detail) + y = y + Kit.textHeight("small") + math.floor(6 * m.s) + else + local r = math.floor(16 * m.s) + Kit.spinner(cx, y + r, r) + y = y + 2 * r + math.floor(14 * m.s) + Kit.textCenter("button", spec.title, px + pad, y, pw - 2 * pad, PAL.heading) + y = y + Kit.textHeight("button") + math.floor(6 * m.s) + end + + if spec.detail and spec.detail ~= "" then + Kit.textCenter("small", + Kit.ellipsize("small", spec.detail, pw - 2 * pad), + px + pad, y, pw - 2 * pad, PAL.muted) + y = y + Kit.textHeight("small") + math.floor(4 * m.s) + end + if spec.count and spec.count ~= "" then + Kit.textCenter("micro", spec.count, px + pad, y, pw - 2 * pad, PAL.faint) + end + + local cancelled = false + if spec.onCancel then + -- The one control a blocking overlay may have. It lives inside the + -- panel, so it is the only thing on screen that can take a click. + Kit.blockClicks = false + local bw = math.floor(math.min(pw - 2 * pad, 160 * m.s)) + local bh = m.btnH + if Kit.button(px + (pw - bw) / 2, py + ph - pad - bh, bw, bh, + spec.cancelLabel or "Cancel", + { kind = "ghost", id = "loader:cancel" }) then + cancelled = true + end + Kit.blockClicks = true + end + return cancelled +end + +-- A spinner plus label occupying a control-sized rect. Used in place of the +-- button that started the work, so the row does not reflow while it runs. +function Loader.inline(x, y, w, h, label) + local r = math.floor(math.min(h, 20 * Kit.scale) / 2) + local cx = x + r + 4 + Kit.spinner(cx, y + h / 2, r) + if label then + local lx = cx + r + 8 + Kit.text("small", Kit.ellipsize("small", label, math.max(0, x + w - lx)), + lx, y + (h - Kit.textHeight("small")) / 2, PAL.muted) + end +end + +-- A tiny spinner sized to sit inside a text run (a mod row checking for +-- updates). Returns the width it consumed. +function Loader.dot(x, y, size) + local r = size / 2 + Kit.spinner(x + r, y + r, r) + return size +end + +return Loader diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua new file mode 100644 index 000000000..d70dd1f93 --- /dev/null +++ b/src/ui/kit/Theme.lua @@ -0,0 +1,526 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Cartridge Studio: flat graphite surfaces, quiet borders and bright actions. +-- The version rail is the only animated color treatment. Other depth comes +-- from solid value steps; no canvases, blur passes or per-card meshes. +-- Colors are 0-255 RGB. These primitives also work with the headless stub. + +local Theme = {} + +local PAL = { + -- Solid graphite surfaces; raised controls use a lighter value. + field = { 20, 24, 29 }, -- the page BEHIND the cards + bg = { 20, 24, 29 }, -- light black + surface = { 29, 34, 41 }, -- card interiors + rowBg = { 24, 29, 35 }, -- rows inside a card, one step below it + raised = { 42, 49, 59 }, -- hover feedback + ink = { 240, 244, 249 }, -- the selected/focused fill + -- outlines. Two weights only: a hairline for structure, solid for focus. + line = { 139, 157, 180 }, -- hairline, drawn at alpha 0.35 + lineStrong = { 255, 255, 255 }, -- focus / selection, drawn at alpha 1 + -- text + heading = { 245, 247, 250 }, + text = { 234, 239, 245 }, + detail = { 200, 200, 200 }, + muted = { 161, 172, 187 }, + caption = { 170, 170, 170 }, -- letterspaced section captions + faint = { 110, 110, 110 }, -- slot indices, hints + inverse = { 0, 0, 0 }, -- ink on a white (selected/focused) fill + -- Action colors and status accents. + green = { 85, 232, 135 }, -- safe / confirmed / installed + yellow = { 255, 214, 0 }, -- attention / update available + red = { 255, 80, 90 }, -- destructive + blue = { 76, 163, 240 }, -- links, in-panel navigation + buttonBlue = { 36, 106, 181 }, -- darker fill for white action labels + buttonPurple = { 88, 44, 140 }, + steel = { 120, 120, 120 }, -- disabled + -- the version rail is the one piece of brand colour that stays + railRed = { 255, 60, 72 }, + railBlue = { 70, 150, 255 }, + railGold = { 255, 203, 5 }, -- Yellow cartridge (bright) + railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal) + railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal) + railCrystal = { 132, 196, 228 }, -- Crystal cartridge (translucent ice blue) + railLeafGreen = { 38, 162, 78 }, -- LeafGreen cartridge (vibrant deep forest green) + railFireRed = { 220, 48, 48 }, -- FireRed cartridge (deeper red than Red) + railEmerald = { 31, 158, 110 }, + railRuby = { 185, 46, 50 }, + railSapphire = { 53, 94, 196 }, +} +-- Semantic aliases kept so ported call sites read the same as before. +PAL.cardBorder = PAL.line +PAL.greenInk = PAL.inverse +PAL.blueInk = PAL.blue +PAL.redSoft = PAL.red +PAL.greenDark = PAL.green +Theme.PAL = PAL + +-- Standard alphas, so "hairline" means one thing everywhere. +Theme.A = { + hairline = 0.35, + hover = 0.65, + focus = 1.0, + fillHover= 1.0, + disabled = 0.30, +} + +Theme.BUTTON = { + radius = 8, + ringPad = 2, + ringWidth = 2, + labelInset = 16, + labelPad = 10, + iconPad = 0.24, + letterGap = 4, + disabledA = 0.45, + embossHot = 0.35, + embossRest = 0.25, + embossDisabled = 0.4, + glowHz = 3, + glowBase = 0.25, + glowAmp = 0.75, + iconRestA = 0.85, +} + +Theme.CARD = { + radius = 9, + shadow = false, + fill = PAL.surface, + fillA = 1, + stroke = PAL.line, + strokeA = Theme.A.hairline, +} + +Theme.CARD_VARIANT = { + emphasis = { strokeA = Theme.A.focus }, + muted = { + fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false, + }, + mutedHot = { + fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false, + }, + empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" }, + warn = { + fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover, + shadow = false, radius = "ctl", + }, + row = { + fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline, + shadow = false, radius = "ctl", + }, + rowHover = { + fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover, + shadow = false, radius = "ctl", + }, + rowSelected = { + fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl", + }, + hairline = { + fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline, + shadow = false, radius = "ctl", + }, + badge = { + fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover, + shadow = false, radius = 0, + }, +} + +local G = love and love.graphics or nil + +local has = {} +local function probe(name) + if has[name] == nil then has[name] = (G and type(G[name]) == "function") or false end + return has[name] +end +Theme.probe = probe + +function Theme.col(c, a) + if not G then return end + G.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1) +end +local col = Theme.col + +function Theme.clamp(n, lo, hi) + if n < lo then return lo end + if n > hi then return hi end + return n +end +local clamp = Theme.clamp + +-- --------------------------------------------------------------- primitives +-- Square, flat, snapped to whole pixels. Snapping matters at 1px line width: +-- a rect on a half pixel renders as a 2px grey smear instead of a crisp white +-- hairline, which is the whole look. +local function snap(v) return math.floor(v + 0.5) end +Theme.snap = snap + +function Theme.fill(x, y, w, h, c, a) + if not G or w <= 0 or h <= 0 then return end + col(c or PAL.bg, a or 1) + G.rectangle("fill", snap(x), snap(y), snap(w), snap(h)) +end + +-- Corner radius for controls. Fixed rather than scaled: LOVE tessellates a +-- rounded rect by radius, so a scale-driven radius would change the vertex +-- count with the window size, and these are the two tiers the design needs. +-- Controls get the smaller one, containers the larger, so a button never +-- looks like a card and a card never looks like a button. +function Theme.radius() + return Theme.BUTTON.radius +end + +function Theme.cardRadius() + return Theme.CARD.radius +end + +-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider +-- and one step lower than the last -- a cheap falloff that needs no blur, no +-- canvas and no blend-mode change, so it stays inside the pipeline budget the +-- rest of this file is written to. Drawn BEFORE the surface it belongs to, +-- and never for a control (only containers cast one, or the whole screen +-- reads as floating debris). +function Theme.shadow(x, y, w, h, r) + if not G or w <= 0 or h <= 0 then return end + r = r or Theme.cardRadius() + for i = 1, 3 do + local spread = i * 2 + col(PAL.bg, 0.13) + G.rectangle("fill", snap(x - spread), snap(y - spread + i * 3), + snap(w + 2 * spread), snap(h + 2 * spread), r + spread, r + spread) + end +end + +function Theme.fillRounded(x, y, w, h, c, a, r, segments) + if not G or w <= 0 or h <= 0 then return end + r = r or Theme.radius() + col(c or PAL.bg, a or 1) + x, y, w, h = snap(x), snap(y), snap(w), snap(h) + if segments and r > 1 and w > 2 and h > 2 and (a or 1) == 1 and probe("setLineWidth") then + -- Filled polygons have no edge antialiasing on a non-MSAA canvas. + -- Inset the opaque body, then finish its boundary with a smooth 1px + -- line in the same colour. Translucent overlays keep a single fill so + -- their opacity does not accumulate at the edge. + local innerRadius = math.min(r, w / 2, h / 2) - 0.5 + local oldWidth = probe("getLineWidth") and G.getLineWidth() or 1 + local oldStyle = probe("getLineStyle") and G.getLineStyle() or nil + G.rectangle("fill", x + 0.5, y + 0.5, w - 1, h - 1, innerRadius, innerRadius, segments) + G.setLineWidth(1) + if oldStyle and probe("setLineStyle") then G.setLineStyle("smooth") end + G.rectangle("line", x + 0.5, y + 0.5, w - 1, h - 1, innerRadius, innerRadius, segments) + G.setLineWidth(oldWidth) + if oldStyle and probe("setLineStyle") then G.setLineStyle(oldStyle) end + else + G.rectangle("fill", x, y, w, h, r, r, segments) + end +end + +function Theme.strokeRounded(x, y, w, h, c, a, lw, r, segments) + if not G or w <= 0 or h <= 0 then return end + lw = lw or 1 + r = r or Theme.radius() + if probe("setLineWidth") then G.setLineWidth(lw) end + col(c or PAL.line, a or Theme.A.hairline) + -- Explicitly tessellated controls keep the outline's outer arc aligned + -- with the fill, including thicker selection rings. + local insetRadius = segments and math.max(0, r - lw / 2) or r + G.rectangle("line", snap(x) + lw / 2, snap(y) + lw / 2, + snap(w) - lw, snap(h) - lw, insetRadius, insetRadius, segments) + if probe("setLineWidth") then G.setLineWidth(1) end +end + +-- EMBOSS. A lit top edge and a shaded bottom edge inside the control, which +-- is what makes a flat fill read as a raised key. Two thin rects on top of +-- the fill -- no gradient mesh, no stencil, no blend-mode change, so it costs +-- the same pipeline state as everything around it. +function Theme.emboss(x, y, w, h, strength) + if not G or w <= 2 or h <= 2 then return end + strength = strength or 1 + local t = math.max(1, math.floor(h * 0.10)) + -- The inset must clear the corner arc, but a narrow control (a stepper, a + -- row chip) is thinner than two radii -- clamp or the highlight rect goes + -- negative-width and vanishes. + local r = math.min(Theme.radius(), math.floor(w / 3)) + -- highlight along the top + col(PAL.ink, 0.28 * strength) + G.rectangle("fill", snap(x) + r, snap(y) + 1, snap(w) - 2 * r, t) + -- shadow along the bottom + col(PAL.bg, 0.30 * strength) + G.rectangle("fill", snap(x) + r, snap(y + h) - t - 1, snap(w) - 2 * r, t) +end + +-- Faux bold: the UI face ships in one weight, so a bold run is the same text +-- drawn a second time one pixel across. Callers do this only for button +-- labels, where the extra draw is bounded by the number of controls on +-- screen and the text is already a cached Text object. +Theme.BOLD_OFFSET = 1 + +-- A 1px outline drawn INSIDE the rect, so a bordered control never bleeds +-- into its neighbour's pixel and adjacent outlines never double up to 2px. +function Theme.stroke(x, y, w, h, c, a, lw) + if not G or w <= 0 or h <= 0 then return end + lw = lw or 1 + if probe("setLineWidth") then G.setLineWidth(lw) end + col(c or PAL.line, a or Theme.A.hairline) + G.rectangle("line", snap(x) + lw / 2, snap(y) + lw / 2, + snap(w) - lw, snap(h) - lw) + if probe("setLineWidth") then G.setLineWidth(1) end +end + +-- The design's only container: a rounded surface a few values above the +-- field, its own drop shadow, and a white hairline. `emphasis` raises the +-- outline to full white (used for the focused/active card). +function Theme.card(x, y, w, h, variant) + local spec = Theme.CARD + local v + if variant == true then + v = Theme.CARD_VARIANT.emphasis + elseif type(variant) == "string" then + v = Theme.CARD_VARIANT[variant] + elseif type(variant) == "table" then + v = variant + end + local radius = (v and v.radius) or spec.radius + if radius == "ctl" then radius = Theme.BUTTON.radius end + local fill = (v and v.fill) or spec.fill + local fillA = spec.fillA + if v and v.fillA ~= nil then fillA = v.fillA end + local stroke = (v and v.stroke) or spec.stroke + local strokeA = spec.strokeA + if v and v.strokeA ~= nil then strokeA = v.strokeA end + local shadow = spec.shadow + if v and v.shadow ~= nil then shadow = v.shadow end + local strokeW = (v and v.strokeW) or 1 + if fillA > 0 and fill then + if shadow then Theme.shadow(x, y, w, h, radius) end + Theme.fillRounded(x, y, w, h, fill, fillA, radius) + end + if strokeA > 0 and stroke then + Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius) + end +end + +function Theme.row(x, y, w, h, state) + if state == "selected" then + Theme.card(x, y, w, h, "rowSelected") + return PAL.inverse + end + Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row") + return PAL.text +end + +-- A percentage meter (HP, box fill, dex completion, import progress). +-- pct is 0-100. Outline + solid fill, rounded to the track's own half-height +-- so a thin bar reads as a capsule instead of a clipped rectangle. +function Theme.meter(x, y, w, h, pct, c) + if not G then return end + local r = math.min(Theme.radius(), h / 2) + Theme.strokeRounded(x, y, w, h, PAL.line, Theme.A.hairline, 1, r) + local fill = (w - 2) * clamp((pct or 0) / 100, 0, 1) + if fill > 0 then + Theme.fillRounded(x + 1, y + 1, fill, h - 2, c or PAL.ink, 1, + math.min(r, fill / 2)) + end +end + +local railColors = { + PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber, PAL.railSilver, + PAL.railCrystal, PAL.railFireRed, PAL.railLeafGreen, PAL.railEmerald, + PAL.railRuby, PAL.railSapphire, +} + +-- One seamless sweep every 24 seconds. Pixel strips keep this in the same +-- batched rectangle pipeline as the rest of the theme, without a shader. +function Theme.versionRail(x, y, w, h, colors) + if not G then return end + local railColors = (colors and #colors > 0) and colors or railColors + x, y, w, h = snap(x), snap(y), snap(w), snap(h) + if w <= 0 or h <= 0 then return end + local now = love.timer and love.timer.getTime and love.timer.getTime() or 0 + local phase = (now % 24) / 24 + for px = 0, w - 1 do + local pos = ((px / w - phase) % 1) * #railColors + local index = math.floor(pos) + local a = railColors[index + 1] + local b = railColors[(index + 1) % #railColors + 1] + local t = pos - index + G.setColor((a[1] + (b[1] - a[1]) * t) / 255, + (a[2] + (b[2] - a[2]) * t) / 255, + (a[3] + (b[3] - a[3]) * t) / 255, 1) + G.rectangle("fill", x + px, y, 1, h) + end +end + +-- ------------------------------------------------------------------- text +-- Letterspaced caption text. The UI font has no tracking control, so this +-- advances glyph by glyph; captions are short by construction. +-- Measuring never throws. Third-party strings (mod names from an index, +-- translated captions) reach these primitives unvalidated. +local function safeWidthOrZero(font, s) + local ok, w = pcall(font.getWidth, font, s) + return ok and w or 0 +end + +-- Steps CODEPOINTS, not bytes: a translated caption (the JP strings) is +-- multi-byte, and printing half a sequence is a "UTF-8 decoding error" that +-- takes the frame down. +local function eachChar(text, fn) + local i = 1 + local n = #text + while i <= n do + local j = i + 1 + while j <= n do + local b = text:byte(j) + if b < 0x80 or b >= 0xC0 then break end + j = j + 1 + end + fn(text:sub(i, j - 1)) + i = j + end +end + +function Theme.spaced(font, text, x, y, spacing) + if not G or not font then return 0 end + local cx = x + eachChar(tostring(text), function(ch) + pcall(G.print, ch, cx, y) + cx = cx + safeWidthOrZero(font, ch) + spacing + end) + return math.max(0, cx - x - spacing) +end + +function Theme.spacedWidth(font, text, spacing) + if not font then return 0 end + local w = 0 + eachChar(tostring(text), function(ch) + w = w + safeWidthOrZero(font, ch) + spacing + end) + return math.max(0, w - spacing) +end + +-- UTF-8 stepping. Truncation MUST move whole codepoints: LOVE's Font:getWidth +-- raises "UTF-8 decoding error" on a string cut through a multi-byte sequence, +-- and a launcher listing mods with non-ASCII names (the JP index) hits that on +-- the first frame. A continuation byte is 10xxxxxx (0x80..0xBF). +local function prevCharStart(s, i) + -- largest j < i where s:byte(j) starts a codepoint + local j = i - 1 + while j > 1 do + local b = s:byte(j) + if b < 0x80 or b >= 0xC0 then break end + j = j - 1 + end + return j +end + +local function nextCharStart(s, i) + local j = i + 1 + while j <= #s do + local b = s:byte(j) + if b < 0x80 or b >= 0xC0 then break end + j = j + 1 + end + return j +end + +-- Width that never throws on malformed input: a mod name can carry anything. +local function safeWidth(font, s) + local ok, w = pcall(font.getWidth, font, s) + return ok and w or math.huge +end +Theme.safeWidth = safeWidth + +-- Clip text to a pixel width with a trailing ellipsis. Results are memoised +-- per (font, text, width) in Kit's measurement cache -- this function is the +-- single hottest string operation in a list-heavy frame, and it is O(n) in +-- glyphs with a getWidth call per step. +function Theme.ellipsize(font, text, maxW) + text = tostring(text or "") + if not font then return text end + -- A non-positive budget means "nothing fits", not "everything fits". + if maxW <= 0 then return "" end + if safeWidth(font, text) <= maxW then return text end + local ell = "..." + local ew = safeWidth(font, ell) + local last = #text + 1 -- one past the end of the kept prefix + while last > 1 do + last = prevCharStart(text, last) + local head = text:sub(1, last - 1) + if safeWidth(font, head) + ew <= maxW then return head .. ell end + end + return ell +end + +-- Save paths truncate from the LEFT so the filename survives. +function Theme.ellipsizeLeft(font, text, maxW) + text = tostring(text or "") + if not font then return text end + if maxW <= 0 then return "" end + if safeWidth(font, text) <= maxW then return text end + local ell = "..." + local ew = safeWidth(font, ell) + local i = 1 + while i <= #text do + i = nextCharStart(text, i) + local tail = text:sub(i) + if safeWidth(font, tail) + ew <= maxW then return ell .. tail end + end + return ell +end + +-- The background: one flat clear to the faintly red-cast field colour. One +-- call, no mesh, no fan, no allocation -- the old radial field built a +-- 66-vertex mesh EVERY frame. The tint is deliberately small (a handful of +-- points of red at near-black): enough that the cards read as sitting ON +-- something, not enough to compete with the tri-colour rail for brand duty. +function Theme.field() + if not G then return end + G.clear(PAL.field[1] / 255, PAL.field[2] / 255, PAL.field[3] / 255, 1) +end + +-- ------------------------------------------------------------------- fonts +-- Font set, rebuilt only when the scale changes. Sizes are integers by +-- construction: fractional sizes measure and render at different widths, +-- which is what made ported launcher text overrun its measured box. +function Theme.fonts(s) + if not probe("newFont") then return {} end + -- Every face goes through UiFont.attach, which hangs a kana/CJK fallback + -- off it. Without that a translated build renders the entire launcher as + -- tofu boxes -- LOVE's default face is Latin-only. + local UiFont + local okUi, mod = pcall(require, "src.render.UiFont") + if okUi then UiFont = mod end + local cache = {} + local function f(px) + local n = math.max(8, math.floor(px + 0.5)) + if not cache[n] then + local face = G.newFont(n) + if UiFont and UiFont.attach then + local ok, attached = pcall(UiFont.attach, face, n) + if ok and attached then face = attached end + end + cache[n] = face + end + return cache[n] + end + return { + scale = s, + wordmark = f(14 * s), + brand = f(11 * s), + chip = f(11 * s), + tile = f(13 * s), + tab = f(13 * s), + button = f(14 * s), + small = f(12 * s), + tiny = f(11 * s), + micro = f(10 * s), + caption = f(12 * s), + mono = f(12 * s), + monoRow = f(13 * s), + monoBig = f(18 * s), + title = f(24 * s), + headline = f(26 * s), + stat = f(19 * s), + } +end + +return Theme diff --git a/src/ui/kit/Toast.lua b/src/ui/kit/Toast.lua new file mode 100644 index 000000000..49b45f882 --- /dev/null +++ b/src/ui/kit/Toast.lua @@ -0,0 +1,153 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +local Toast = {} + +Toast.KINDS = { + ok = { color = "green", icon = "check", hold = 3.5 }, + error = { color = "red", icon = "triangle-alert", hold = 7 }, + warn = { color = "yellow", icon = "triangle-alert", hold = 6 }, + info = { color = "blue", icon = "circle-help", hold = 5 }, +} +Toast.SLIDE = .18 +Toast.FADE = .25 + +local function now() + if love and love.timer and love.timer.getTime then return love.timer.getTime() end + return nil +end + +local function holdOf(t) + if t.sticky then return math.huge end + return t.hold or Toast.KINDS[t.kind].hold +end + +local function expired(t, at) + return t.at and at and (at - t.at) > holdOf(t) + Toast.FADE +end + +function Toast.show(store, text, kind, opts) + if text == nil or text == "" then store.toast = nil; return end + text = tostring(text) + kind = Toast.KINDS[kind] and kind or "info" + opts = opts or {} + local t, at = store.toast, now() + if t and t.text == text and t.kind == kind and not expired(t, at) then + if t.at and at then t.at = at - math.min(Toast.SLIDE, at - t.at) end + t.sticky, t.hold = opts.sticky, opts.hold + return t + end + store.toast = { text = text, kind = kind, at = at, sticky = opts.sticky, hold = opts.hold } + return store.toast +end + +function Toast.clear(store) + store.toast = nil +end + +function Toast.current(store) + local t = store and store.toast + if not t then return nil end + if expired(t, now()) then store.toast = nil; return nil end + return t +end + +function Toast.hit(store, x, y) + local t = Toast.current(store) + local r = t and t.rect + if r and t.at then + local at = now() + local elapsed = at and (at - t.at) + if elapsed and (elapsed < Toast.SLIDE or elapsed > holdOf(t)) then return false end + end + return r ~= nil and x ~= nil and y ~= nil + and x >= r[1] and x <= r[1] + r[3] and y >= r[2] and y <= r[2] + r[4] +end + +local function wrapLines(font, text, w) + if not font then return { text } end + local ok, _, lines = pcall(font.getWrap, font, text, w) + return (ok and lines and #lines > 0) and lines or { text } +end + +local function lineHeight(font) + return font and font:getHeight() or 12 +end + +local function cachedLines(t, font, w) + local c = t._wrap + if c and c.font == font and c.w == w then return c.lines end + local lines = wrapLines(font, t.text, w) + t._wrap = { font = font, w = w, lines = lines } + return lines +end + +local function defaultText(font, lines, x, y, w, color, maxLines) + local G = love and love.graphics + if not G or not font then return end + local Theme = require("src.ui.kit.Theme") + local lh = lineHeight(font) + G.setFont(font) + Theme.col(color, 1) + for i = 1, math.min(#lines, maxLines) do + local line = lines[i] + if i == maxLines and #lines > maxLines then line = Theme.ellipsize(font, line .. "...", w) end + pcall(G.print, line, Theme.snap(x), Theme.snap(y + (i - 1) * lh)) + end +end + +function Toast.draw(store, area) + local t = store and store.toast + if not t then return nil end + local at = now() + if not t.at then t.at = at end + local elapsed = (at and t.at) and (at - t.at) or 0 + local hold = holdOf(t) + if elapsed > hold + Toast.FADE then store.toast = nil; return nil end + local Theme = require("src.ui.kit.Theme") + local Icons = require("src.ui.kit.Icons") + local PAL = Theme.PAL + local style = Toast.KINDS[t.kind] + local slide = math.min(1, elapsed / Toast.SLIDE, (hold + Toast.FADE - elapsed) / Toast.FADE) + slide = 1 - (1 - math.max(0, slide)) ^ 3 + local s, color = area.s or 1, PAL[style.color] + local pad, gap, icon = 12 * s, 10 * s, 20 * s + local maxLines = area.maxLines or 4 + local w = area.width or math.min(420 * s, area.w - 32 * s) + local textW = w - 2 * pad - icon - gap + local lines = (not area.wrapHeight or not area.drawText) and cachedLines(t, area.font, textW) + local textH = area.wrapHeight and area.wrapHeight(t.text, textW, maxLines) + or math.min(#lines, maxLines) * lineHeight(area.font) + local h = 2 * pad + math.max(icon, textH) + local x = (area.x or 0) + (area.w - w) / 2 + local y, fromAbove + if area.place then y, fromAbove = area.place(x, w, h) end + if y == nil then + if area.centerY then + y, fromAbove = area.centerY - h / 2, true + elseif area.top and not (area.bottom and area.maxY and area.top + 12 * s + h > area.maxY) then + y, fromAbove = area.top + 12 * s, true + else + y, fromAbove = area.bottom - h - 12 * s, false + end + end + if fromAbove then + y = y - (1 - slide) * (h + 24 * s) + else + y = y + (1 - slide) * (h + 24 * s) + end + t.rect = { x, y, w, h } + local r = area.radius or Theme.cardRadius() + Theme.fillRounded(x + 2 * s, y + 4 * s, w, h, PAL.inverse, .35, r) + Theme.fillRounded(x, y, w, h, PAL.surface, .98, r) + Theme.fillRounded(x, y, w, h, color, .16, r) + Theme.strokeRounded(x, y, w, h, color, .75, 1.5, r) + Icons.draw(style.icon, x + pad, y + (h - icon) / 2, icon, color, 1) + if area.drawText then + area.drawText(t.text, x + pad + icon + gap, y + pad, textW, PAL.heading, maxLines) + else + defaultText(area.font, lines, x + pad + icon + gap, y + pad, textW, PAL.heading, maxLines) + end + return t.rect +end + +return Toast diff --git a/src/ui/kit/Transition.lua b/src/ui/kit/Transition.lua new file mode 100644 index 000000000..c88c490c4 --- /dev/null +++ b/src/ui/kit/Transition.lua @@ -0,0 +1,134 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +local Transition = {} + +Transition.reduceMotion = false +Transition.armed = false + +Transition.DURATIONS = { + ["in"] = 0.12, + out = 0.09, + tab = 0.18, + push = 0.16, + pop = 0.16, +} + +Transition.LAYERS = { "tabs", "online", "modal" } +Transition.LOCAL = { "box" } +Transition.PASSIVE = { "toast" } + +local ALL = {} +for i = 1, #Transition.LAYERS do ALL[#ALL + 1] = Transition.LAYERS[i] end +for i = 1, #Transition.LOCAL do ALL[#ALL + 1] = Transition.LOCAL[i] end +for i = 1, #Transition.PASSIVE do ALL[#ALL + 1] = Transition.PASSIVE[i] end + +local layers = {} +for i = 1, #ALL do + layers[ALL[i]] = { + active = false, kind = nil, dir = 1, + t0 = 0, dur = 0, p = 1, + from = nil, fromAt = nil, to = nil, + } +end + +local function now() + if love and love.timer and love.timer.getTime then + return love.timer.getTime() + end + return 0 +end +Transition.now = now + +local function ease(t) + if t <= 0 then return 0 end + if t >= 1 then return 1 end + local u = 1 - t + return 1 - u * u * u +end +Transition.ease = ease + +function Transition.update(t) + t = t or now() + for i = 1, #ALL do + local L = layers[ALL[i]] + if L.active then + local raw = 1 + if L.dur > 0 then raw = (t - L.t0) / L.dur end + if raw >= 1 then + L.active, L.p = false, 1 + elseif raw <= 0 then + L.p = 0 + else + L.p = ease(raw) + end + end + end +end + +function Transition.start(layer, kind, opts) + local L = layers[layer] + if not L then return false end + L.kind = kind + L.dir = (opts and opts.dir) or 1 + L.from = opts and opts.from or nil + L.fromAt = opts and opts.fromAt or nil + L.to = opts and opts.to or nil + local dur = (opts and opts.duration) or Transition.DURATIONS[kind] or 0.15 + if Transition.reduceMotion or not Transition.armed or dur <= 0 then + L.active, L.p, L.dur, L.t0 = false, 1, 0, 0 + return false + end + L.active, L.p, L.dur, L.t0 = true, 0, dur, now() + return true +end + +function Transition.get(layer) + local L = layers[layer] + if L and L.active then return L end + return nil +end + +function Transition.progress(layer) + local L = layers[layer] + if not L or not L.active then return 1 end + return L.p +end + +function Transition.kind(layer) + local L = layers[layer] + if L and L.active then return L.kind end + return nil +end + +function Transition.dir(layer) + local L = layers[layer] + if L and L.active then return L.dir end + return 0 +end + +function Transition.active(layer) + if layer then + local L = layers[layer] + return (L and L.active) == true + end + for i = 1, #Transition.LAYERS do + if layers[Transition.LAYERS[i]].active then return true end + end + return false +end + +function Transition.clear(layer) + local L = layers[layer] + if not L then return false end + L.active, L.p, L.dur, L.t0 = false, 1, 0, 0 + L.kind, L.from, L.fromAt, L.to = nil, nil, nil, nil + return true +end + +function Transition.reset() + for i = 1, #ALL do + Transition.clear(ALL[i]) + end +end + +return Transition diff --git a/src/ui/kit/VirtualKeyboard.lua b/src/ui/kit/VirtualKeyboard.lua new file mode 100644 index 000000000..cdf8dab4d --- /dev/null +++ b/src/ui/kit/VirtualKeyboard.lua @@ -0,0 +1,310 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Controller-driven on-screen keyboard (OSK) for handhelds and gamepads. +-- Allows typing URLs (mod index, repositories), mod search queries, and custom names +-- without needing a physical keyboard. + +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local Kit = nil +local function getKit() + if not Kit then Kit = require("src.ui.kit.Kit") end + return Kit +end + +local VirtualKeyboard = { + active = false, + text = "", + title = "Enter Text", + targetId = nil, + onDone = nil, + row = 1, + col = 1, + mode = 1, -- 1: lower, 2: upper, 3: symbols +} + +local ROWS_LOWER = { + { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=" }, + { "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "/", ":" }, + { "a", "s", "d", "f", "g", "h", "j", "k", "l", ".", "_", "@" }, + { "z", "x", "c", "v", "b", "n", "m", "?", "!", "&", "%", "#" }, + { { id = "shift", label = "Shift", w = 2 }, { id = "sym", label = "Sym", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } }, + { { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } }, +} + +local ROWS_UPPER = { + { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=" }, + { "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "/", ":" }, + { "A", "S", "D", "F", "G", "H", "J", "K", "L", ".", "_", "@" }, + { "Z", "X", "C", "V", "B", "N", "M", "?", "!", "&", "%", "#" }, + { { id = "shift", label = "shift", w = 2 }, { id = "sym", label = "Sym", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } }, + { { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } }, +} + +local ROWS_SYM = { + { "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+" }, + { "~", "`", "{", "}", "[", "]", "|", "\\", ";", ":", "'", "\"" }, + { "<", ">", ",", ".", "/", "?", "=", "-", "*", "/", "\\", "~" }, + { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", ".", "=" }, + { { id = "shift", label = "ABC", w = 2 }, { id = "sym", label = "123", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } }, + { { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } }, +} + +local ROWS_DIGITS = { + { "1", "2", "3" }, + { "4", "5", "6" }, + { "7", "8", "9" }, + { { id = "back", label = "Bksp" }, "0", { id = "done", label = "Done" } }, +} + +VirtualKeyboard.ROWS_DIGITS = ROWS_DIGITS + +local function isLinuxHandheld() + return os.getenv("HANDHELD") == "1" + or os.getenv("PORTMASTER") == "1" + or os.getenv("POKEPORT_HANDHELD") == "1" + or os.getenv("TRIMUI") == "1" + or os.getenv("MUOS") == "1" + or os.getenv("KNULLI") == "1" + or os.getenv("POKEPORT_SBC") == "1" + or os.getenv("ANBERNIC") == "1" +end + +function VirtualKeyboard.isSupported() + return isLinuxHandheld() +end + +function VirtualKeyboard.open(opts) + if not isLinuxHandheld() then return false end + opts = opts or {} + VirtualKeyboard.active = true + VirtualKeyboard.text = tostring(opts.text or "") + VirtualKeyboard.title = opts.title or "Enter Text" + VirtualKeyboard.targetId = opts.targetId + VirtualKeyboard.onDone = opts.onDone + VirtualKeyboard.row = 1 + VirtualKeyboard.col = 1 + VirtualKeyboard.mode = opts.digits and 4 or 1 + VirtualKeyboard.maxLen = tonumber(opts.maxLen) + return true +end + +function VirtualKeyboard.close(confirmed) + if not VirtualKeyboard.active then return end + local txt = VirtualKeyboard.text + local cb = VirtualKeyboard.onDone + VirtualKeyboard.active = false + VirtualKeyboard.targetId = nil + VirtualKeyboard.onDone = nil + if cb then + cb(txt, confirmed == true) + end +end + +local function currentRows() + if VirtualKeyboard.mode == 4 then return ROWS_DIGITS end + if VirtualKeyboard.mode == 2 then return ROWS_UPPER end + if VirtualKeyboard.mode == 3 then return ROWS_SYM end + return ROWS_LOWER +end + +VirtualKeyboard.currentRows = currentRows + +local function triggerKey(key) + if type(key) == "string" then + local cap = VirtualKeyboard.maxLen + if cap and #VirtualKeyboard.text >= cap then return end + VirtualKeyboard.text = VirtualKeyboard.text .. key + elseif type(key) == "table" then + if key.id == "shift" then + VirtualKeyboard.mode = (VirtualKeyboard.mode == 2) and 1 or 2 + elseif key.id == "sym" then + VirtualKeyboard.mode = (VirtualKeyboard.mode == 3) and 1 or 3 + elseif key.id == "space" then + VirtualKeyboard.text = VirtualKeyboard.text .. " " + elseif key.id == "back" then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2) + elseif key.id == "clear" then + VirtualKeyboard.text = "" + elseif key.id == "done" then + VirtualKeyboard.close(true) + elseif key.id == "url_https" then + VirtualKeyboard.text = VirtualKeyboard.text .. "https://" + elseif key.id == "url_http" then + VirtualKeyboard.text = VirtualKeyboard.text .. "http://" + elseif key.id == "url_json" then + VirtualKeyboard.text = VirtualKeyboard.text .. ".json" + elseif key.id == "url_zip" then + VirtualKeyboard.text = VirtualKeyboard.text .. ".zip" + end + end +end + +function VirtualKeyboard.gamepadpressed(button) + if not VirtualKeyboard.active then return false end + + local rows = currentRows() + local r = VirtualKeyboard.row + local c = VirtualKeyboard.col + + if button == "dpup" then + VirtualKeyboard.row = math.max(1, r - 1) + local maxC = #rows[VirtualKeyboard.row] + if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end + return true + elseif button == "dpdown" then + VirtualKeyboard.row = math.min(#rows, r + 1) + local maxC = #rows[VirtualKeyboard.row] + if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end + return true + elseif button == "dpleft" then + VirtualKeyboard.col = math.max(1, c - 1) + return true + elseif button == "dpright" then + VirtualKeyboard.col = math.min(#rows[r], c + 1) + return true + elseif button == "a" then + local key = rows[r] and rows[r][c] + if key then triggerKey(key) end + return true + elseif button == "b" then + VirtualKeyboard.close(false) + return true + elseif button == "x" then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2) + return true + elseif button == "y" then + if VirtualKeyboard.mode == 4 then return true end + VirtualKeyboard.text = VirtualKeyboard.text .. " " + return true + elseif button == "leftshoulder" or button == "rightshoulder" then + if VirtualKeyboard.mode == 4 then return true end + VirtualKeyboard.mode = (VirtualKeyboard.mode % 3) + 1 + local maxC = #currentRows()[VirtualKeyboard.row] + if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end + return true + elseif button == "start" then + VirtualKeyboard.close(true) + return true + end + + return true -- consume all input while keyboard modal is up +end + +function VirtualKeyboard.keypressed(key) + if not VirtualKeyboard.active then return false end + if key == "escape" then + VirtualKeyboard.close(false) + return true + elseif key == "return" then + VirtualKeyboard.close(true) + return true + elseif key == "backspace" then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2) + return true + end + return false +end + +function VirtualKeyboard.textinput(text) + if not VirtualKeyboard.active then return false end + if VirtualKeyboard.mode == 4 then text = tostring(text or ""):gsub("%D", "") end + local cap = VirtualKeyboard.maxLen + VirtualKeyboard.text = VirtualKeyboard.text .. text + if cap and #VirtualKeyboard.text > cap then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, cap) + end + return true +end + +function VirtualKeyboard.draw(m) + if not VirtualKeyboard.active then return end + + local Kit = getKit() + local s = m and m.s or Kit.scale or 1 + local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640 + local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480 + + -- Scrim background + Theme.fill(0, 0, W, H, PAL.bg, 0.92) + local shielded = Kit.blockClicks + Kit.blockClicks = true + + local pad = math.floor(12 * s) + local modalW = math.floor(math.min(540 * s, W - 20 * s)) + local modalH = math.floor(math.min(360 * s, H - 20 * s)) + local px = math.floor((W - modalW) / 2) + local py = math.floor((H - modalH) / 2) + + Kit.card(px, py, modalW, modalH, true) + Kit.blockClicks = shielded + + local cy = py + pad + -- Title & hint + Kit.text("button", VirtualKeyboard.title, px + pad, cy, PAL.heading) + Kit.textRight("micro", VirtualKeyboard.mode == 4 + and "X: Bksp Start: Done B: Cancel" + or "L1/R1: Mode X: Bksp Y: Space Start: Done B: Cancel", + px + modalW - pad, cy + 2 * s, PAL.muted) + + cy = cy + Kit.textHeight("button") + math.floor(8 * s) + + -- Preview textfield box + local fieldH = math.floor(32 * s) + Theme.fillRounded(px + pad, cy, modalW - 2 * pad, fieldH, PAL.bg, 1) + Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, fieldH, PAL.lineStrong, 1, 2) + + local shown = Kit.ellipsizeLeft("mono", VirtualKeyboard.text .. "_", modalW - 4 * pad) + Kit.text("mono", shown, px + pad + math.floor(8 * s), cy + math.floor(7 * s), PAL.heading) + + cy = cy + fieldH + math.floor(10 * s) + + -- Keyboard Rows + local rows = currentRows() + local rowH = math.floor(30 * s) + local gap = math.floor(4 * s) + local availableW = modalW - 2 * pad + + for rIdx, rData in ipairs(rows) do + local totalUnits = 0 + for _, key in ipairs(rData) do + totalUnits = totalUnits + (type(key) == "table" and (key.w or 1) or 1) + end + + local unitW = (availableW - (gap * (#rData - 1))) / totalUnits + local kx = px + pad + + for cIdx, key in ipairs(rData) do + local units = type(key) == "table" and (key.w or 1) or 1 + local kw = math.floor(unitW * units) + local isSelected = (VirtualKeyboard.row == rIdx and VirtualKeyboard.col == cIdx) + + local label = type(key) == "table" and key.label or key + local isSpecial = type(key) == "table" + + -- Click / touch detection + if Kit.press(kx, cy, kw, rowH) then + VirtualKeyboard.row = rIdx + VirtualKeyboard.col = cIdx + triggerKey(key) + end + + if isSelected then + Theme.fillRounded(kx, cy, kw, rowH, PAL.ink, 1) + Kit.textCenterBold("small", label, kx, cy + math.floor(6 * s), kw, PAL.inverse) + else + Theme.fillRounded(kx, cy, kw, rowH, isSpecial and PAL.raised or PAL.surface, 1) + Theme.strokeRounded(kx, cy, kw, rowH, PAL.line, Theme.A.hairline, 1) + Kit.textCenter("small", label, kx, cy + math.floor(6 * s), kw, isSpecial and PAL.heading or PAL.text) + end + + kx = kx + kw + gap + end + + cy = cy + rowH + gap + end + + Kit.blockClicks = shielded +end + +return VirtualKeyboard 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 05f6dc9d0..1df5b2b0c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -14777,7 +14777,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 @@ -14967,7 +14967,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/render_output/main.lua b/tests/api3_contract/mods/render_output/main.lua new file mode 100644 index 000000000..0882775cd --- /dev/null +++ b/tests/api3_contract/mods/render_output/main.lua @@ -0,0 +1,48 @@ +local mod = ... +assert(mod:supports("hooks", "render.output")) +assert(mod:supports("hooks", "render.output_enabled")) +local enabled, handled, callback, gate = false, false, nil, nil +local trace = {} +mod.hooks:wrap("render.output_enabled", function(next) + if gate then + return gate() + end + if enabled then + return true + end + return next() +end) +mod.hooks:wrap("render.output", function(next, ctx) + assert(ctx.canvas and ctx.width == 320 and ctx.height == 288 and ctx.generation == mod.generation) + trace[#trace + 1] = { + width = ctx.width, + height = ctx.height, + gameWidth = ctx.gameWidth, + gameHeight = ctx.gameHeight, + gameX = ctx.gameX, + gameY = ctx.gameY, + scale = ctx.scale, + dpiX = ctx.dpiX, + dpiY = ctx.dpiY, + generation = ctx.generation, + } + if callback then + return callback(ctx) + end + if handled then + return true + end + return next(ctx) +end) +mod.exports.state = function(on, owns) + enabled, handled = on, owns +end +mod.exports.callback = function(fn) + callback = fn +end +mod.exports.gate = function(fn) + gate = fn +end +mod.exports.trace = function() + return trace +end diff --git a/tests/api3_contract/mods/render_output/manifest.json b/tests/api3_contract/mods/render_output/manifest.json new file mode 100644 index 000000000..3a2878ae7 --- /dev/null +++ b/tests/api3_contract/mods/render_output/manifest.json @@ -0,0 +1 @@ +{"id":"render_output","name":"Render output 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/viewport/main.lua b/tests/api3_contract/mods/viewport/main.lua new file mode 100644 index 000000000..08f76276f --- /dev/null +++ b/tests/api3_contract/mods/viewport/main.lua @@ -0,0 +1,68 @@ +local mod = ... +assert(mod:supports("hooks", "render.viewport")) +assert(mod:supports("hooks", "render.window")) +assert(mod:supports("hooks", "input.pointer")) +local request, canvasRef, windowAction = nil, nil, nil +local trace = { viewport = {}, window = {}, pointer = {} } +mod.hooks:wrap("render.viewport", function(next, ctx) + trace.viewport[#trace.viewport + 1] = { + width = ctx.width, + height = ctx.height, + pixelWidth = ctx.pixelWidth, + pixelHeight = ctx.pixelHeight, + dpiX = ctx.dpiX, + dpiY = ctx.dpiY, + generation = ctx.generation, + } + if request then + return request + end + return next(ctx) +end) +mod.hooks:wrap("render.window", function(next, game, ctx) + assert(ctx.canvas == canvasRef) + trace.window[#trace.window + 1] = { + x = ctx.x, + y = ctx.y, + width = ctx.width, + height = ctx.height, + windowWidth = ctx.windowWidth, + windowHeight = ctx.windowHeight, + dpiX = ctx.dpiX, + dpiY = ctx.dpiY, + generation = ctx.generation, + } + if windowAction then + return windowAction(next, game, ctx) + end + return next(game, ctx) +end) +mod.hooks:wrap("input.pointer", function(next, game, ctx) + trace.pointer[#trace.pointer + 1] = { + phase = ctx.phase, + source = ctx.source, + id = ctx.id, + x = ctx.x, + y = ctx.y, + gameX = ctx.gameX, + gameY = ctx.gameY, + insideGame = ctx.insideGame, + dx = ctx.dx, + dy = ctx.dy, + pressure = ctx.pressure, + button = ctx.button, + } + return next(game, ctx) +end) +mod.exports.request = function(value) + request = value +end +mod.exports.canvas = function(value) + canvasRef = value +end +mod.exports.window = function(fn) + windowAction = fn +end +mod.exports.trace = function() + return trace +end diff --git a/tests/api3_contract/mods/viewport/manifest.json b/tests/api3_contract/mods/viewport/manifest.json new file mode 100644 index 000000000..70047d193 --- /dev/null +++ b/tests/api3_contract/mods/viewport/manifest.json @@ -0,0 +1 @@ +{"id":"viewport","name":"Viewport/window contract","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/render_output.lua b/tests/api3_contract/render_output.lua new file mode 100644 index 000000000..8e3dfce54 --- /dev/null +++ b/tests/api3_contract/render_output.lua @@ -0,0 +1,49 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/render_output%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "render_output") + love = love or require("tests.love_stub") + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local Runtime = require("src.mods.Runtime") + local Output = backend == "gen2" and require("src.render.Output") + or { + enabled = function() + return Runtime.wantsHook("render.output") + and Runtime.call("render.output_enabled", function() + return false + end) == true + end, + draw = function(ctx) + return Runtime.call("render.output", function() + return false + end, ctx) == true + end, + } + assert(not Output.enabled()) + local ctx = { + canvas = newproxy(true), + width = 320, + height = 288, + gameX = 0, + gameY = 0, + gameWidth = 320, + gameHeight = 288, + scale = 2, + dpiX = 1, + dpiY = 1, + generation = f.gen, + } + f.exports.state(true, true) + assert(Output.enabled() and Output.draw(ctx)) + f.exports.state(true, false) + assert(Output.enabled() and not Output.draw(ctx)) + f.exports.state(false, true) + assert(not Output.enabled()) + local report = { trace = f.exports.trace(), disabled = true, fallback = true } + 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/viewport.lua b/tests/api3_contract/viewport.lua new file mode 100644 index 000000000..2415fd319 --- /dev/null +++ b/tests/api3_contract/viewport.lua @@ -0,0 +1,48 @@ +local M = {} +function M.fixture(root, backend, version) + local codeRoot = debug.getinfo(1, "S").source:sub(2):match("^(.*)/viewport%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "viewport") + love = love or require("tests.love_stub") + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local V = require("src.render.GameViewport") + local Game = require(backend == "gen1" and f.gen > 1 and ("src.core.Game" .. f.gen) or "src.core.Game") + local G = love.graphics + local dims, pixels = G.getDimensions, G.getPixelDimensions + G.getDimensions = function() + return 640, 480 + end + G.getPixelDimensions = function() + return 1280, 960 + end + f.exports.request({ x = 100, y = 20, width = 320, height = 288 }) + local r = V.begin(f.gen) + assert(V.active() and r.x == 100 and r.y == 20 and r.width == 320 and r.height == 288) + assert(select(1, V.dimensions()) == 320) + assert(select(1, V.pixelDimensions()) == 640) + local x, y, inside = V.toLocal(132, 52) + assert(x == 32 and y == 32 and inside) + x, y, inside = V.toLocal(99, 19) + assert(x == -1 and y == -1 and not inside) + f.exports.canvas(V.target()) + Game.pointerEvent(f.game, "pressed", "mouse", "mouse", 132, 52, 0, 0, nil, 1) + Game.pointerEvent(f.game, "moved", "mouse", "mouse", 99, 19, -33, -33, nil, 1) + V.finish(f.game) + f.exports.request({ capture = true }) + V.begin(f.gen) + assert(V.active() and select(1, V.dimensions()) == 640) + f.exports.canvas(V.target()) + V.finish(f.game) + f.exports.request(nil) + V.begin(f.gen) + assert(not V.active()) + V.finish(f.game) + local report = f.exports.trace() + G.getDimensions, G.getPixelDimensions = dims, pixels + V.reset() + 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..d10e79740 --- /dev/null +++ b/tests/api3_contract/worker.lua @@ -0,0 +1,85 @@ +-- 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" + or mode == "render_output" + or mode == "viewport" + ), + "invalid worker arguments" +) +local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") +if mode == "viewport" then + dofile(codeRoot .. "/viewport.lua").run(root, backend, version) +elseif mode == "render_output" then + dofile(codeRoot .. "/render_output.lua").run(root, backend, version) +elseif 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..e028c128d --- /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"), true, "implemented output hook is 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/game_viewport.lua b/tests/engine/game_viewport.lua new file mode 100644 index 000000000..af029ece4 --- /dev/null +++ b/tests/engine/game_viewport.lua @@ -0,0 +1,258 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.viewport") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local V = require("src.render.GameViewport") +local Game = require("src.core.Game") +local Touch = require("src.core.TouchControls") +local Safe = require("src.core.SafeArea") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +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 viewport/window contract: " .. tostring(why)) +end +local G = love.graphics +local dimensions, pixels = G.getDimensions, G.getPixelDimensions +G.getDimensions = function() + return 640, 480 +end +G.getPixelDimensions = function() + return 1280, 960 +end +local function fixture() + V.finish({}) + V.reset() + local f = Contract.fixture("tests/api3_contract", "gen2", "red") + return f +end +local function begin(f, value) + f.exports.request(value) + local r = V.begin(1) + f.exports.canvas(V.target()) + return r +end +for _, value in ipairs({ + { x = -20, y = -40, width = 10000, height = 10000 }, + { x = 1 / 0, y = 0 / 0, width = -3, height = 0 }, + { x = 639.9, y = 479.9, width = 320, height = 288 }, + setmetatable({}, { + __index = function() + error("metatable must not execute") + end, + }), +}) do + local f = fixture() + local r = begin(f, value) + T.check(r.x >= 0 and r.y >= 0 and r.width >= 1 and r.height >= 1, "clamped viewport bounds") + T.check(r.x + r.width <= 640 and r.y + r.height <= 480, "viewport stays inside window") + V.finish(f.game) +end +do + local f = fixture() + local creates, releases = 0, 0 + local oldNew = G.newCanvas + G.newCanvas = function(...) + creates = creates + 1 + local c = oldNew(...) + c.release = function() + releases = releases + 1 + end + return c + end + begin(f, { x = 10, y = 20, width = 320, height = 288 }) + V.finish(f.game) + begin(f, { x = 11, y = 21, width = 320, height = 288 }) + V.finish(f.game) + T.eq(creates, 1, "moving rectangle reuses same-size canvas") + begin(f, { width = 321, height = 288 }) + V.finish(f.game) + T.eq(creates, 2, "resize allocates matching canvas") + T.eq(releases, 1, "resize releases old target") + begin(f, nil) + V.finish(f.game) + T.eq(releases, 2, "disabling releases viewport target") + G.newCanvas = oldNew +end +do + local f = fixture() + local oldNew = G.newCanvas + local attempts = 0 + G.newCanvas = function() + attempts = attempts + 1 + error("injected allocation failure") + end + local r = begin(f, { x = 10, y = 20, width = 100, height = 80 }) + T.eq(V.active(), false, "allocation failure keeps ordinary rendering") + T.eq(r.width, 640, "failed viewport falls back to full width") + V.finish(f.game) + begin(f, { x = 10, y = 20, width = 100, height = 80 }) + V.finish(f.game) + T.eq(attempts, 1, "failed size does not retry every frame") + G.newCanvas = oldNew + begin(f, { width = 101, height = 80 }) + V.finish(f.game) + T.eq(V.active(), true, "changed request retries successfully") + begin(f, { width = 100, height = 80 }) + V.finish(f.game) + T.eq(V.active(), true, "former failed size can recover after request change") +end +do + local f = fixture() + local n = #f.exports.trace().viewport + V.begin(4, false) + V.finish(f.game) + T.eq(V.active(), false, "unsupported/native combined layout stays direct") + T.eq(#f.exports.trace().viewport, n, "unsupported layout does not dispatch viewport request") + begin(f, { x = 100, y = 20, width = 320, height = 288 }) + local x, y, w, h = Safe.rect() + T.eq(x, 0, "safe area mapped to local x") + T.eq(y, 0, "safe area mapped to local y") + T.eq(w, 320, "safe area clipped to game width") + T.eq(h, 288, "safe area clipped to game height") + local ox, oy, ow, oh = Safe.windowRect() + T.eq(ow, 640, "OS chrome keeps full window width") + T.eq(oh, 480, "OS chrome keeps full window height") + V.finish(f.game) +end +for _, phase in ipairs({ "viewport", "window", "window_context" }) do + local f = fixture() + local shader, foreign = {}, {} + G.setShader(shader) + G.setColor(0.2, 0.3, 0.4, 0.5) + local mod = f.loader.apis.viewport + if phase == "viewport" then + mod.hooks:wrap("render.viewport", function(next, ctx) + G.setShader({}) + G.setCanvas(foreign) + G.push("all") + ctx.width = 0 + error("injected viewport hook error") + end, 100) + elseif phase == "window" then + f.exports.window(function() + G.setShader({}) + G.setCanvas(foreign) + G.push("all") + error("injected window hook error") + end) + else + f.exports.window(function(next, g) + return next(g, { canvas = false, x = 0, y = 0 }) + end) + end + begin(f, { x = 100, y = 20, width = 320, height = 288 }) + V.finish(f.game) + T.eq(G.getCanvas(), nil, "window target restored after " .. phase .. " failure") + T.eq(G.getShader(), shader, "shader restored after " .. phase .. " failure") + T.eq(select(1, G.getColor()), 0.2, "color restored after " .. phase .. " failure") +end +do + local f = fixture() + local resetInside = false + begin(f, { capture = true }) + local canvas = V.target() + canvas.release = function() + resetInside = true + end + f.exports.window(function(next, game, ctx) + V.reset() + T.eq(resetInside, false, "reset inside render callback defers canvas release") + return next(game, ctx) + end) + V.finish(f.game) + T.eq(resetInside, true, "retired target released after composition") + T.eq(V.active(), false, "deferred reset clears viewport state") +end +-- Actual native touch dispatch preserves pad and screen capture ownership. +do + local f = fixture() + begin(f, { x = 100, y = 20, width = 320, height = 288 }) + V.finish(f.game) + local old = { + Touch.visible, + Touch.hitTest, + Touch.touchpressed, + Touch.touchmoved, + Touch.touchreleased, + Touch.draw, + Touch.touches, + } + local pad, screen = {}, {} + Touch.touches = {} + Touch.visible = function() + return true + end + Touch.hitTest = function(_, x, y) + return x == 10 and y == 10 + end + Touch.touchpressed = function(_, id, x, y) + pad[#pad + 1] = { "pressed", id, x, y } + if id == "pad" then + Touch.touches[id] = true + end + end + Touch.touchmoved = function(_, id, x, y) + pad[#pad + 1] = { "moved", id, x, y } + end + Touch.touchreleased = function(_, id, x, y) + pad[#pad + 1] = { "released", id, x, y } + Touch.touches[id] = nil + end + local top = {} + for _, name in ipairs({ "touchpressed", "touchmoved", "touchreleased" }) do + top[name] = function(_, id, x, y) + screen[#screen + 1] = { name, id, x, y } + return true + end + end + local game = setmetatable( + { stack = { + top = function() + return top + end, + }, save = { options = {} } }, + { __index = Game } + ) + local mod = f.loader.apis.viewport + mod.hooks:wrap("render.window", function(next, g, ctx) + return next(g, ctx) + end, -100) + mod.hooks:wrap("input.pointer", function(next, g, ctx) + if ctx.id == "mod" or ctx.id == "native" and ctx.phase ~= "pressed" then + return true + end + return next(g, ctx) + end, 100) + game:touchpressed("pad", 10, 10) + game:touchmoved("pad", 20, 20) + game:touchreleased("pad", 20, 20) + T.eq(#f.exports.trace().pointer, 0, "virtual pad gesture never reaches mod pointer hook") + T.eq(#pad, 3, "virtual pad keeps entire gesture") + game:touchpressed("native", 132, 52, nil, nil, 0.5) + game:touchmoved("native", 149, 63) + game:touchreleased("native", 160, 64) + T.eq(#screen, 3, "native screen retains gesture after mod later claims movement") + T.eq(screen[1][3], 32, "native screen receives game-local x") + T.eq(screen[1][4], 32, "native screen receives game-local y") + T.eq(game.pointerOwners.native, nil, "native capture released") + game:touchpressed("mod", 132, 52) + game:touchmoved("mod", 149, 63) + game:touchreleased("mod", 160, 64) + T.eq(#screen, 3, "claimed mod gesture does not leak to native screen") + T.eq(game.modPointers.mod, nil, "mod pointer released") + game:touchpressed("outside", 99, 19) + T.eq(#screen, 3, "outside viewport does not activate native screen") + local last = pad[#pad] + T.eq(last[3], 99, "pad fallback keeps physical x") + T.eq(last[4], 19, "pad fallback keeps physical y") + Touch.visible, Touch.hitTest, Touch.touchpressed, Touch.touchmoved, Touch.touchreleased, Touch.draw, Touch.touches = + unpack(old) +end +V.finish({}) +V.reset() +G.getDimensions, G.getPixelDimensions = dimensions, pixels +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/kit_spinner_trail_bug2048.lua b/tests/engine/kit_spinner_trail_bug2048.lua new file mode 100644 index 000000000..0fd4d8f62 --- /dev/null +++ b/tests/engine/kit_spinner_trail_bug2048.lua @@ -0,0 +1,50 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check = T.check +local Kit = require("src.ui.kit.Kit") + +local G = love.graphics +local rec, alpha = {}, 1 +local oldSet, oldLine = G.setColor, G.line + +G.setColor = function(r, g, b, a) alpha = a or 1 end +G.line = function(_, _, x2, y2) + rec[#rec + 1] = { a = alpha, ang = math.atan2(y2 - 100, x2 - 100) } +end + +local function sample(t) + rec = {} + Kit.scale = 1 + Kit.spinner(100, 100, 20, t) + local head, best = 1, -1 + for i, v in ipairs(rec) do + if v.a > best then best, head = v.a, i end + end + return rec, head +end + +local r0, h0 = sample(0.0) +check(#r0 == 12, "the ring is twelve ticks") +local _, h1 = sample(0.1) +check(h1 == (h0 % #r0) + 1, "the bright head advances clockwise one tick") + +local ahead = r0[(h0 % #r0) + 1] +local behind = r0[((h0 - 2) % #r0) + 1] +check(behind.a > ahead.a, "the fade trails the head instead of leading it") +check(ahead.a < 0.1, "the tick the head is about to reach is the dimmest") + +local prev, monotonic = r0[h0].a, true +for n = 1, #r0 - 1 do + local v = r0[((h0 - 1 - n) % #r0) + 1] + if v.a >= prev then monotonic = false end + prev = v.a +end +check(monotonic, "and keeps falling off all the way round the trail") + +G.setColor, G.line = oldSet, oldLine + +T.finish("kit spinner trail") diff --git a/tests/engine/kit_transition.lua b/tests/engine/kit_transition.lua new file mode 100644 index 000000000..aadc48d25 --- /dev/null +++ b/tests/engine/kit_transition.lua @@ -0,0 +1,114 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = love or require("tests.love_stub") + +local clock = 0 +love.timer = love.timer or {} +local realGetTime = love.timer.getTime +love.timer.getTime = function() return clock end + +local Transition = require("src.ui.kit.Transition") +local Kit = require("src.ui.kit.Kit") + +local function near(a, b, tol) + return math.abs(a - b) <= (tol or 1e-9) +end + +eq(Transition.ease(0), 0, "the easing starts at zero") +eq(Transition.ease(1), 1, "and ends at one") +check(Transition.ease(0.5) > 0.5, "easeOutCubic runs ahead of linear") +eq(Transition.ease(-1), 0, "a negative input clamps to zero") +eq(Transition.ease(2), 1, "an overshoot clamps to one") + +Transition.reset() +Transition.reduceMotion = false +Transition.armed = false + +clock = 10 +check(not Transition.start("tabs", "tab", { dir = 1 }), + "an unarmed launcher does not animate") +check(not Transition.active(), "so nothing is running") +eq(Transition.progress("tabs"), 1, "and the layer reads as finished") + +Transition.armed = true +clock = 10 +check(Transition.start("tabs", "tab", { dir = -1, from = "red", to = "blue" }), + "an armed launcher starts the tab slide") +check(Transition.active("tabs"), "which is active on its own layer") +check(Transition.active(), "and answers the any-layer question") +eq(Transition.kind("tabs"), "tab", "the kind is kept for the draw") +eq(Transition.dir("tabs"), -1, "and so is the direction") +eq(Transition.progress("tabs"), 0, "progress starts at zero") + +clock = 10 + Transition.DURATIONS.tab / 2 +Transition.update() +check(Transition.progress("tabs") > 0.5, + "halfway through the clock the eased progress is past half") +check(Transition.active("tabs"), "and the layer is still running") + +clock = 10 + Transition.DURATIONS.tab * 1.5 +Transition.update() +eq(Transition.progress("tabs"), 1, "the end of the duration is progress 1") +check(not Transition.active("tabs"), "and the layer retires itself") +eq(Transition.get("tabs"), nil, "a retired layer hands back no record") + +-- ------------------------------------------------- one per layer, replaced + +clock = 20 +Transition.start("online", "push", { dir = 1, from = "home", to = "play" }) +clock = 20 + Transition.DURATIONS.push / 2 +Transition.update() +check(near(Transition.progress("online"), Transition.ease(0.5)), + "the online layer eases off its own start time") +Transition.start("online", "pop", { dir = -1, from = "play", to = "home" }) +eq(Transition.kind("online"), "pop", "a second start replaces the first") +eq(Transition.progress("online"), 0, "and restarts its clock") +eq(Transition.dir("online"), -1, "with the new direction") + +Transition.start("modal", "in") +check(Transition.active("modal") and Transition.active("online"), + "layers run independently") +eq(Transition.DURATIONS["in"], 0.12, "a modal pops in over 120 ms") +eq(Transition.DURATIONS.out, 0.09, "and out over 90 ms") +eq(Transition.DURATIONS.tab, 0.18, "a tab change takes 180 ms") +eq(Transition.DURATIONS.push, 0.16, "an online push takes 160 ms") +eq(Transition.DURATIONS.pop, 0.16, "and so does a pop") + +clock = 30 +Transition.update() +check(not Transition.active(), "every layer retires once its time is up") + +-- ------------------------------------------------------------ input block + +clock = 40 +Transition.start("tabs", "tab", { dir = 1 }) +Kit.beginFrame(0, 0, false, 0) +check(Kit.blockClicks, "a running transition shields the frame from clicks") +Kit.endFrame() + +clock = 40 + Transition.DURATIONS.tab * 1.5 +Kit.beginFrame(0, 0, false, 0) +check(not Kit.blockClicks, "and lets go on the frame it ends") +Kit.endFrame() + +-- ----------------------------------------------------------- reduce motion + +Transition.reduceMotion = true +clock = 50 +check(not Transition.start("modal", "in"), "reduce motion refuses to animate") +check(not Transition.active("modal"), "so no transition is running") +eq(Transition.progress("modal"), 1, "and the layer snaps to done") +eq(Transition.kind("modal"), nil, "with no kind for the draw to honour") + +Transition.reduceMotion = false +Transition.reset() +Transition.armed = false +love.timer.getTime = realGetTime + +T.finish("kit transition") 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_file_io.lua b/tests/engine/native_file_io.lua new file mode 100644 index 000000000..5035af273 --- /dev/null +++ b/tests/engine/native_file_io.lua @@ -0,0 +1,28 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Portable error/handle contract. The Windows CI driver separately exercises +-- the actual wide APIs against files written by LÖVE in a Unicode directory. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Native=require('src.core.NativeFile') +local oldLove,oldOpen=love,io.open +love={system={getOS=function()return 'Linux'end}} +for _,bad in ipairs{false,42,'','a\0b'}do + local bytes,why=Native.read(bad);T.eq(bytes,nil);T.check(type(why)=='string') +end +local closed=0 +io.open=function(path,mode) + T.eq(path,'owned fixture');T.eq(mode,'rb') + return{read=function()return '\0PNG\255'end,close=function()closed=closed+1 end} +end +T.eq(Native.read('owned fixture'),'\0PNG\255');T.eq(closed,1) +io.open=function()return nil,'owned open failure'end +local bytes,why=Native.read('owned fixture');T.eq(bytes,nil);T.eq(why,'owned open failure') +io.open=function()error('owned open exception')end +bytes,why=Native.read('owned fixture');T.eq(bytes,nil);T.check(why:find('owned open exception',1,true)~=nil) +for _,read in ipairs{function()return ''end,function()error('owned read exception')end,function()return nil,'owned read failure'end}do + io.open=function()return{read=read,close=function()closed=closed+1 end}end + bytes,why=Native.read('owned fixture');T.eq(bytes,nil);T.check(type(why)=='string') +end +T.eq(closed,4,'all opened handles are closed, including read failures') +io.open, love=oldOpen,oldLove +T.finish('native readonly file bytes and error cleanup') diff --git a/tests/engine/native_file_picker.lua b/tests/engine/native_file_picker.lua new file mode 100644 index 000000000..46d65c607 --- /dev/null +++ b/tests/engine/native_file_picker.lua @@ -0,0 +1,83 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local Host=require('src.core.HostShell') +local oldSystem=love.system;love.system=love.system or{} +local osName='UWP';local osGet=love.system.getOS;love.system.getOS=function()return osName end +local oldGame=package.loaded['src.core.Game'];local F=require('src.core.FilePicker') +T.eq(package.loaded['src.core.Game'],oldGame,'import cannot bootstrap Game') +local oldPop,oldHas=Host.popen,Host.hasCommand +local probes,closed={},0 +Host.popen=function(cmd)probes[#probes+1]=cmd;return{read=function()return '/usr/bin/zenity\n'end,close=function()closed=closed+1 end}end +for _,os in ipairs{'UWP','NX','Android','iOS','Web','Unknown'}do + osName=os;T.eq(Host.hasCommand('zenity'),false);T.eq(F.available(),false) + T.eq(F.commands('Choose',F.IMAGE),nil);T.eq(F.open('Choose',F.IMAGE),nil) +end +T.eq(#probes,0,'unsupported hosts never probe or spawn') +osName='Linux' +for _,name in ipairs{'zenity;touch nope','a b','../tool','-bad','$(bad)','x\nfoo','x%PATH%'}do T.eq(Host.hasCommand(name),false)end +T.eq(#probes,0);T.eq(Host.hasCommand('zenity'),true);T.eq(closed,1) +Host.popen=function()return{read=function()error('owned probe read failure')end,close=function()closed=closed+1 end}end +T.eq(Host.hasCommand('zenity'),false);T.eq(closed,2,'failed probe closes pipe') +local available={zenity=true,kdialog=true};Host.hasCommand=function(n)return available[n]or false end +local kind={label='QA sprite package',exts={'SPRITEPACK','spritepack'}} +local prompt='QA \' " $(do-not-run) `literal` %PATH% ü' +for _,os in ipairs{'OS X','Windows','Linux'}do + osName=os;T.eq(F.available(),true);local cmds=assert(F.commands(prompt,kind));T.eq(#cmds,1) + if os=='OS X'then + T.check(cmds[1]:find('osascript -e ',1,true)==1);T.check(cmds[1]:find("'\\''",1,true)~=nil) + elseif os=='Windows'then + T.eq(cmds[1]:find('do-not-run',1,true),nil,'untrusted UTF-8 is data') + T.check(cmds[1]:find('[Text.Encoding]::UTF8.GetString([byte[]]@(',1,true)~=nil) + T.check(cmds[1]:find('if($ascii){[Console]::Write($p)}else{',1,true)~=nil,'large ASCII file fast path') + T.check(cmds[1]:find('[Guid]::NewGuid()',1,true)~=nil,'unique non-ASCII staging') + T.check(cmds[1]:find('[IO.Path]::GetExtension($p)',1,true)~=nil, + 'non-ASCII staging preserves image/archive extension for the actual importer') + T.check(cmds[1]:find('-ErrorAction Stop',1,true)~=nil,'failed staging cannot return stale data') + local direct=assert(F.commands(prompt,kind,true))[1] + T.check(direct:find('[Console]::Write($d.FileName)',1,true)~=nil,'wide-reader caller keeps original selected name') + T.eq(direct:find('Copy-Item',1,true),nil,'wide-reader caller never copies into Unicode TEMP') + T.eq(direct:find('do-not-run',1,true),nil,'direct picker also encodes untrusted prompt bytes') + else T.check(cmds[1]:find('zenity ',1,true)==1)end +end +osName='Linux';available.zenity=false;T.check(F.commands('QA',kind)[1]:find('kdialog ',1,true)==1) +available.kdialog=false;T.eq(F.available(),false);T.eq(F.commands('QA',kind),nil);available.zenity=true +T.eq(F.matches('C:\\QA\\FILE.SPRITEPACK',kind),true);T.eq(F.matches('file.spritepack.exe',kind),false) +T.eq(F.matches('image.JPEG'),true);T.eq(F.basename('C:\\QA\\image.png'),'image.png');T.eq(F.basename('/qa/a.spritepack'),'a.spritepack') +for _,bad in ipairs{{exts={}},{exts={'bad;cmd'}},{exts={'bad"x'}},{exts={'bad x'}},{exts={'png'},label='x|*.sav'}}do + T.eq(F.commands('QA',bad),nil);T.eq(F.open('QA',bad),nil) +end +T.eq(F.commands('bad\0title',kind),nil);T.eq(F.open(nil,kind),nil);T.eq(F.open(string.rep('x',513),kind),nil) +local mouse,event,timer=love.mouse,love.event,love.timer;local held,pumps,launches=true,0,0 +love.mouse={isDown=function()return held end};love.event={pump=function()pumps=pumps+1;held=false end} +love.timer={getTime=function()return 0 end,sleep=function()end} +Host.popen=function() + T.eq(held,false,'pointer released before native dialog');launches=launches+1 + return{read=function()return ' /owned-qa/a.spritepack\n'end,close=function()closed=closed+1 end} +end +T.eq(F.open('QA pick',kind),'/owned-qa/a.spritepack');T.eq(launches,1);T.eq(pumps,1) +Host.popen=function()launches=launches+1;return{read=function()return ''end,close=function()closed=closed+1 end}end +T.eq(F.open('QA cancel',kind),nil);T.eq(launches,2,'cancel cannot launch another chooser') +local beforeClose=closed +Host.popen=function()return{read=function()error('owned dialog read failure')end,close=function()closed=closed+1 end}end +T.eq(F.open('QA failure',kind),nil);T.eq(closed,beforeClose+1) +osName='Windows' +Host.popen=function(command) + T.check(command:find('[Console]::Write($d.FileName)',1,true)~=nil,'public FilePicker.open selects wide-readable original path') + T.eq(command:find('Copy-Item',1,true),nil) + return{read=function()return 'C:\\用户\\Pokémon 🎮.png'end,close=function()end} +end +local original=F.open('Pick art',F.IMAGE) +T.eq(original,'C:\\用户\\Pokémon 🎮.png');T.eq(F.basename(original),'Pokémon 🎮.png') +osName='Linux' +love.mouse,love.event,love.timer=mouse,event,timer +local oldOpen=io.open;local reads,closes=0,0 +io.open=function(path,mode) + T.eq(path,'owned-qa-byte-probe');T.eq(mode,'rb') + return{read=function()reads=reads+1;return '\0binary\255'end,close=function()closes=closes+1 end} +end +T.eq(F.read('owned-qa-byte-probe'),'\0binary\255');T.eq(reads,1);T.eq(closes,1) +io.open=function()return{read=function()error('owned read failure')end,close=function()closes=closes+1 end}end +local bytes,err=F.read('owned-qa-byte-probe');T.eq(bytes,nil);T.check(err:find('owned read failure',1,true)~=nil);T.eq(closes,2) +io.open=oldOpen;Host.popen,Host.hasCommand=oldPop,oldHas;love.system.getOS=osGet;love.system=oldSystem +T.eq(package.loaded['src.core.Game'],oldGame,'no session/save owner introduced') +T.finish('native picker commands, one-backend cancellation, shared pointer release and readonly bytes') 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/public_battle_hud_hook.lua b/tests/engine/public_battle_hud_hook.lua new file mode 100644 index 000000000..b4a9f7b69 --- /dev/null +++ b/tests/engine/public_battle_hud_hook.lua @@ -0,0 +1,31 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local run = T.sdk.loadMods({ "mods/hud_probe" }, { fs = T.sdk.memfs({ + ["mods/hud_probe/manifest.json"] = '{"id":"hud_probe","name":"HUD probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ["mods/hud_probe/main.lua"] = [[ + local mod = ... + mod.exports.calls = 0 + mod.hooks:wrap("battle.status_hud_visible", function(nextFn, battle) + mod.exports.calls = mod.exports.calls + 1 + mod.exports.battle = battle + mod.exports.native = nextFn(battle) + return false + end) + ]], +}) }) +T.eq(#run.errors, 0, "HUD owner loads through public API") +local battle = { data = {}, game = { data = {} } } +local Gen4 = require("src.battle.Gen4Battle") +local draws, draw = 0, love.graphics.draw +love.graphics.draw = function() draws = draws + 1 end +T.eq(Gen4.drawHealthboxes(battle), true, "owned HUD prevents fallback native panels") +T.eq(draws, 0, "hidden native HUD performs no draw") +local exports = run.loader.exports.hud_probe +T.eq(exports.calls, 1, "native Gen4 healthbox entry consults the public hook") +T.eq(exports.battle, battle, "hook receives the actual battle") +T.eq(exports.native, true, "unwrapped native visibility defaults to true") +run.loader.hooks:removeOwner("hud_probe") +T.eq(Gen4.drawHealthboxes(battle), false, "without owner, unavailable native art returns to ordinary fallback") +love.graphics.draw = draw +run.release() +T.finish("Public battle HUD ownership hook") diff --git a/tests/engine/public_field_events.lua b/tests/engine/public_field_events.lua new file mode 100644 index 000000000..ba47a15b0 --- /dev/null +++ b/tests/engine/public_field_events.lua @@ -0,0 +1,82 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V = require("src.core.GameVersion") +V.set("red") -- ROM-free engine fixture, no external checkout or game process. +local run = T.sdk.loadMods({ "mods/field_probe" }, { fs = T.sdk.memfs({ + ["mods/field_probe/manifest.json"] = '{"id":"field_probe","name":"Field probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ["mods/field_probe/main.lua"] = [[ + local mod = ... + mod.exports.ticks, mod.exports.blocks, mod.exports.eggs = {}, {}, {} + mod.exports.stopTicks = mod.events:on("world.tick", function(ev) table.insert(mod.exports.ticks, ev) end) + mod.events:on("world.blocks_replaced", function(ev) table.insert(mod.exports.blocks, ev) end) + mod.events:on("pokemon.egg_hatched", function(ev) table.insert(mod.exports.eggs, ev) end) + ]], +}) }) +T.eq(#run.errors, 0, "field observer loads through public API") +local exports = run.loader.exports.field_probe +local Game = require("src.core.Game") +local World = require("src.world.OverworldController") +Game.data, Game.save = run.data, require("src.core.SaveData").newGame() +Game.stack = { states = {} } +function Game.stack:push(state) self.states[#self.states + 1] = state end +function Game.stack:pop() return table.remove(self.states) end +function Game.stack:top() return self.states[#self.states] end +-- Enter the real state without importing or loading a map. The event driver +-- supplies resident geometry and isolates unrelated timed field mechanics. +local world = setmetatable({ setMap = function() end, refreshStandingOnWarp = function() end }, { __index = World }) +world:enter("FIX_TOWN", 0, 0, "down") +local rebuilds = 0 +local map = require("src.world.Map").new({ id = "FIX_TOWN", width = 2, height = 2, + blocks = { 1, 2, 3, 4 } }, { blockTiles = 2, blockCells = 1 }) +map.renderer = { rebuild = function() rebuilds = rebuilds + 1 end } +world.map = map +T.eq(world:replaceBlocks({ 1, 5, 6, 4, 999 }), true, "native bulk change applies within map bounds") +T.eq(#exports.blocks, 1, "one bulk change emits one event") +T.eq(exports.blocks[1].mapId, "FIX_TOWN", "bulk event identifies map") +T.eq(exports.blocks[1].count, 2, "bulk event counts actual changed cells") +T.eq(map:blockAt(1, 0), 5, "bulk event follows committed geometry") +T.eq(rebuilds, 1, "geometry redraw still occurs") +T.eq(world:replaceBlocks({ 1, 5, 6, 4 }), false, "identical replacement is a no-op") +T.eq(#exports.blocks, 1, "no event for unchanged blocks") +local noop = function() end +for _, name in ipairs({ "tickCrackedFloor", "tickPacifidlogLogs", "tickPcScreen", "tickGymDoorSlide", + "tickGen3GateSwing", "tickLavaridgeLaunch", "drainPendingScripts", "noteCellChange", + "tickCyclingChallenge", "watchStuckScript", "applyForcedBike", "checkSpecialPhoneCall", "checkBugContestClock" }) do + world[name] = noop +end +world.runner = { isRunning = function() return true end, update = noop } +local boundary = {} +world.updateParallel = function() error(boundary) end -- stop immediately after the tested event boundary +local ok, reason = pcall(world.update, world, 1/60) +T.eq(ok, false, "driver reaches the post-tick boundary") +T.eq(reason, boundary, "the native update reached the event without another failure") +T.eq(#exports.ticks, 1, "one native update emits one fixed tick") +T.eq(exports.ticks[1].dt, 1/60, "tick supplies the fixed dt") +T.eq(exports.ticks[1].mapId, "FIX_TOWN", "tick identifies the resident map") +T.eq(exports.ticks[1].overworld, world, "tick supplies the real world") +T.eq(exports.ticks[1].scripted, true, "tick reports the native runner state") +-- Hot-path parity: without listeners the actual update never calls emit. +exports.stopTicks() +local Runtime = require("src.mods.Runtime") +local originalEmit, tickEmits = Runtime.emit, 0 +Runtime.emit = function(name, ...) + if name == "world.tick" then tickEmits = tickEmits + 1 end + return originalEmit(name, ...) +end +local _, noListenerBoundary = pcall(world.update, world, 1/60) +Runtime.emit = originalEmit +T.eq(noListenerBoundary, boundary, "unsubscribed tick reaches the same native boundary") +T.eq(tickEmits, 0, "unsubscribed hot tick does not emit or build its payload") +local mon = require("src.pokemon.Pokemon").new(run.data, "FIXMON_A", 5) +mon.isEgg, mon.eggSteps, mon.eggCycles, mon.nickname = true, 9, 2, "EGG" +T.eq(world:hatchEgg(mon), true, "native hatch accepts the egg") +T.eq(mon.isEgg, nil, "native hatch clears egg flag") +T.eq(mon.eggSteps, nil, "native hatch clears egg steps") +T.eq(mon.eggCycles, nil, "native hatch clears egg cycles") +T.eq(#exports.eggs, 0, "hatch event waits for the message completion") +local box = Game.stack:top() +assert(box.onDone, "real native hatch message has a completion callback")() +T.eq(#exports.eggs, 1, "message completion emits one hatch event") +T.eq(exports.eggs[1].mon, mon, "hatch observer receives the normalized Pokemon") +run.release() +T.finish("Public field and egg events") diff --git a/tests/engine/public_intro_events.lua b/tests/engine/public_intro_events.lua new file mode 100644 index 000000000..708bbfde5 --- /dev/null +++ b/tests/engine/public_intro_events.lua @@ -0,0 +1,43 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +require("src.core.GameVersion").set("emerald") +local run = T.sdk.loadMods({ "mods/intro_probe" }, { fs = T.sdk.memfs({ + ["mods/intro_probe/manifest.json"] = '{"id":"intro_probe","name":"Intro probe","version":"1.0.0","api":2,"entry":"main.lua","games":["gen3"]}', + ["mods/intro_probe/main.lua"] = [[ + local mod = ... + mod.exports.started, mod.exports.finished = {}, {} + mod.events:on("intro.birch_speech.started", function(ev) + table.insert(mod.exports.started, ev) + end) + mod.events:on("intro.birch_speech.finished", function(ev) + table.insert(mod.exports.finished, ev) + end) + ]], +}) }) +T.eq(#run.errors, 0, "intro observer loads through public API") +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() return table.remove(self.states) end +local game = { data = run.data, save = { player = {} }, stack = stack } +local done = false +local exports = run.loader.exports.intro_probe +local speech = require("src.ui.BirchSpeech").new(game, function() + T.eq(#exports.finished, 1, "finish event precedes completion callback") + done = true +end) +stack:push(speech) +speech:enter() +T.eq(#exports.started, 1, "native intro enter emits start once") +T.eq(exports.started[1].speech, speech, "start identifies the actual speech") +T.eq(#exports.finished, 0, "enter does not finish the intro") +-- Exercise the native finish boundary independently of keyboard/name entry. +speech.answers = { gender = "girl", name = "MAY" } +stack:pop() -- the skipped question is closed before the native finish boundary +speech:finish() +T.eq(#exports.finished, 1, "native finish emits completion once") +T.eq(exports.finished[1].speech, speech, "finish identifies the same speech") +T.same(exports.finished[1].answers, speech.answers, "finish exposes the intro answers") +T.eq(done, true, "completion callback still runs") +T.eq(#stack.states, 0, "native finish removes the intro from the stack") +run.release() +T.finish("Public Birch intro events") diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index a0dff35be..02c0f7aa1 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -164,7 +164,7 @@ local function source(path) check(f ~= nil, path .. " is readable") local text = f and f:read("*a") or "" if f then f:close() end - return text + return (text:gsub("\r\n", "\n")) end local chipSrc = source("src/core/chip_worker.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/render_output.lua b/tests/engine/render_output.lua new file mode 100644 index 000000000..d8afd8ffd --- /dev/null +++ b/tests/engine/render_output.lua @@ -0,0 +1,81 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Contract = require("tests.api3_contract.render_output") +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 Output = require("src.render.Output") +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 .. " output boundary contract: " .. tostring(why)) +end +for _, mode in ipairs({ "dirty", "throw", "leak", "overpop" }) do + local f = Contract.fixture("tests/api3_contract", "gen2", "red") + local G = love.graphics + local previous, foreign, shader = {}, {}, {} + G.setCanvas(previous) + G.setShader(shader) + G.setColor(0.2, 0.3, 0.4, 0.5) + G.setBlendMode("add") + local originalPush, originalPop = G.push, G.pop + f.exports.state(true, true) + f.exports.callback(function() + G.setShader({}) + G.setCanvas(foreign) + G.setColor(1, 0, 0, 1) + G.setBlendMode("replace") + if mode == "throw" then + error("injected output failure") + end + if mode == "leak" then + G.push("all") + G.push("all") + end + if mode == "overpop" then + G.pop() + end + return true + end) + local ctx = { + canvas = {}, + width = 320, + height = 288, + gameX = 0, + gameY = 0, + gameWidth = 320, + gameHeight = 288, + scale = 2, + dpiX = 1, + dpiY = 1, + generation = 1, + } + T.eq(Output.enabled(), true, "enabled output owner") + T.eq(Output.draw(ctx), mode == "dirty" or mode == "leak", "error/overpop falls back") + T.eq(G.getCanvas(), previous, "canvas restored after " .. mode) + T.eq(G.getShader(), shader, "shader restored after " .. mode) + T.eq(G.getBlendMode(), "add", "blend restored after " .. mode) + T.eq(select(1, G.getColor()), 0.2, "color restored after " .. mode) + T.eq(G.push, originalPush, "push wrapper removed after " .. mode) + T.eq(G.pop, originalPop, "pop wrapper removed after " .. mode) + f.exports.gate(function() + G.setCanvas(foreign) + G.setShader({}) + return 1 + end) + T.eq(Output.enabled(), false, "only exact true enables capture") + T.eq(G.getCanvas(), previous, "predicate canvas restored") + T.eq(G.getShader(), shader, "predicate shader restored") + local mod = f.loader.apis.render_output + mod.hooks:wrap("render.output_enabled", function(next) + return next() + end, -10) + mod.hooks:wrap("render.output", function(next, frame) + return next(frame) + end, -10) + f.loader:_rollback("render_output") + T.eq(Output.enabled(), false, "unloaded owner no longer captures output") +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/skin_format_import_test.lua b/tests/engine/skin_format_import_test.lua new file mode 100644 index 000000000..169a7b972 --- /dev/null +++ b/tests/engine/skin_format_import_test.lua @@ -0,0 +1,731 @@ +-- From Gen1Recomp 535a5ec0, Copyright (C) 2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section 7; see licenses/GEN1RECOMP-TOUCH-SKINS.md. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +-- The native general stub does not model mounts/FileData. These contracts use +-- a dedicated memory archive fixture, rather than pretending to open ZIPs. +love.filesystem = require('tests.skin_fs_fixture')() +local newImage = love.graphics.newImage +love.graphics.newImage = function(source) + if type(source)=='table' and source.contents then + return {getWidth=function() return 1 end,getHeight=function() return 1 end, + getDimensions=function() return 1,1 end,setFilter=function() end} + end + return newImage(source) +end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local DeltaSkin = require("src.core.DeltaSkin") +local Json = require("src.link.Json") + +local function near(got, want, msg) + return check(type(got) == "number" and math.abs(got - want) < 1e-6, + ("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want))) +end + +local function hasWarning(skin, fragment) + for _, w in ipairs(skin.warnings or {}) do + if tostring(w):find(fragment, 1, true) then return true end + end + return false +end + +local function unzip(bytes) + local out, i = {}, 1 + while bytes:sub(i, i + 3) == "PK\3\4" do + local function u16(off) + local a, b = bytes:byte(i + off, i + off + 1) + return a + b * 256 + end + local function u32(off) + local a, b, c, d = bytes:byte(i + off, i + off + 3) + return a + b * 256 + c * 65536 + d * 16777216 + end + local size, nameLen, extraLen = u32(18), u16(26), u16(28) + local name = bytes:sub(i + 30, i + 29 + nameLen) + local start = i + 30 + nameLen + extraLen + out[name] = bytes:sub(start, start + size - 1) + out[#out + 1] = name + i = start + size + end + return out +end + +local function readBytes(path) + local f = assert(io.open(path, "rb")) + local data = f:read("*a") + f:close() + return data +end + +local GAMEBOY_CFG = [[ +overlays = 4 + +overlay0_name = "landscape" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_range_mod = 1.5 +overlay0_alpha_mod = 2.0 +overlay0_aspect_ratio = 2.22222222222222 +overlay0_descs = 13 +overlay0_desc0 = "nul,0.0985,0.6825,rect,0.0525,0.0875" +overlay0_desc0_overlay = img/dpad.png +overlay0_desc1 = "up,0.0985,0.5950,rect,0.0175,0.0292" +overlay0_desc2 = "down,0.0985,0.7700,rect,0.0175,0.0292" +overlay0_desc3 = "left,0.0460,0.6825,rect,0.0175,0.0292" +overlay0_desc4 = "right,0.1510,0.6825,rect,0.0175,0.0292" +overlay0_desc5 = "left|up,0.0460,0.5950,rect,0.0175,0.0292" +overlay0_desc6 = "right|up,0.1510,0.5950,rect,0.0175,0.0292" +overlay0_desc7 = "left|down,0.0460,0.7700,rect,0.0175,0.0292" +overlay0_desc8 = "right|down,0.1510,0.7700,rect,0.0175,0.0292" +overlay0_desc9 = "a,0.8975,0.6300,radial,0.0525,0.0875" +overlay0_desc9_overlay = img/a.png +overlay0_desc10 = "b,0.8100,0.7350,radial,0.0525,0.0875" +overlay0_desc10_overlay = img/b.png +overlay0_desc11 = "start,0.5500,0.9000,rect,0.0500,0.0400" +overlay0_desc12 = "select,0.4500,0.9000,rect,0.0500,0.0400" + +overlay1_name = "portrait" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_aspect_ratio = 0.45 +overlay1_descs = 2 +overlay1_desc0 = "a,0.8975,0.6300,radial,0.0875,0.0525" +overlay1_desc1 = "b,0.8100,0.7350,radial,0.0875,0.0525" + +overlay2_name = "menu" +overlay2_full_screen = true +overlay2_normalized = true +overlay2_descs = 1 +overlay2_desc0 = "menu_toggle,0.5,0.5,rect,0.1,0.1" + +overlay3_name = "hide" +overlay3_full_screen = true +overlay3_normalized = true +overlay3_descs = 1 +overlay3_desc0 = "overlay_next,0.95,0.05,radial,0.04,0.04" +overlay3_desc0_next_target = "landscape" +]] + +local gameboy = assert(TouchSkin.parse(GAMEBOY_CFG)) +eq(#gameboy.pages, 4, "the canonical gameboy overlay has four pages") +eq(gameboy.pages[1].orient, "landscape", "page 1 auto-rotates landscape") +eq(gameboy.pages[2].orient, "portrait", "page 2 auto-rotates portrait") +check(TouchSkin.hasOrientPair(gameboy), "so it is an auto-rotate overlay") +eq(gameboy.pages[3].orient, nil, "the menu page is not part of the pair") +eq(#gameboy.pages[1].controls, 13, "every landscape desc parsed") +check(gameboy.pages[1].controls[1].decorative, "the d-pad art desc binds nothing") +eq(gameboy.pages[1].controls[1].imagePath, "img/dpad.png", "and carries the art") +eq(gameboy.pages[1].imagePath, nil, "the overlay ships no page background") +eq(gameboy.pages[4].controls[1].nextTarget, "landscape", "hide jumps back by name") +local named = {} +for _, ctl in ipairs(gameboy.pages[1].controls) do + for _, btn in ipairs(ctl.buttons) do named[btn] = true end +end +for _, btn in ipairs({ "a", "b", "start", "select", "up", "down", "left", "right" }) do + check(named[btn], "landscape binds GB " .. btn) +end +eq(#gameboy.warnings, 0, "a well-formed overlay warns about nothing") + +local SPACED_CFG = [[ +overlays = 1 +overlay0_name = "spaced" +overlay0_normalized = true +overlay0_descs = 2 +overlay0_desc0 = "a 0.5 0.5 rect 0.05 0.05" +overlay0_desc0_saturate_pct = 0.6 +overlay0_desc0_exclusive = true +overlay0_desc0_movable = true +overlay0_desc1 = b,0.25,0.5,radial,0.05,0.05 +]] +local spaced = assert(TouchSkin.parse(SPACED_CFG)) +near(spaced.pages[1].controls[1].saturatePct, 0.6, "_saturate_pct is parsed") +check(spaced.pages[1].controls[1].exclusive, "_exclusive is parsed") +check(spaced.pages[1].controls[1].movable, "_movable is parsed on a plain desc") +eq(spaced.pages[1].controls[2].exclusive, nil, "and is not inherited") +eq(#spaced.pages[1].controls, 2, "a space-separated desc still parses") +eq(spaced.pages[1].controls[1].buttons[1], "a", "space-separated bind") +near(spaced.pages[1].controls[1].x, 0.5, "space-separated position") +eq(spaced.pages[1].controls[2].buttons[1], "b", "an unquoted desc parses too") +eq(spaced.pages[1].controls[2].shape, "radial", "and keeps its hitbox shape") + +local SHORT_CFG = [[ +overlays = 1 +overlay0_name = "short" +overlay0_normalized = true +overlay0_descs = 2 +overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" +]] +local short = assert(TouchSkin.parse(SHORT_CFG)) +eq(#short.pages[1].controls, 1, "a missing desc is skipped, not faked") +check(hasWarning(short, "missing desc 1"), "and the importer says so") + +eq(select(1, TouchSkin.parse("overlay0_descs = 1\n")), nil, + "a cfg without the overlays key is refused") + +local AREA_CFG = [[ +overlays = 1 +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_descs = 3 +overlay0_desc0 = "dpad_area,0.2,0.7,rect,0.15,0.1" +overlay0_desc0_overlay = img/dpad.png +overlay0_desc0_reach_x = 1.5 +overlay0_desc0_movable = true +overlay0_desc1 = "abxy_area,0.8,0.7,radial,0.12,0.08" +overlay0_desc1_up = "start" +overlay0_desc2 = "analog_left,0.2,0.3,radial,0.1,0.1" +overlay0_desc2_saturate_pct = 0.6 +overlay0_desc2_exclusive = true +]] +local area = assert(TouchSkin.parse(AREA_CFG)) +local ap = area.pages[1] +near(ap.aspect, 0.5625, "a portrait-named overlay defaults to 9:16") +check(not ap.aspectFromCfg, "and that default is not a cfg aspect lock") +eq(#ap.controls, 1 + 8 + 8 + 8, "each area desc expands into eight hitboxes") + +local art = ap.controls[1] +check(art.decorative, "the dpad_area art is carried by a decoration") +eq(art.imagePath, "img/dpad.png", "with the desc's own overlay image") +near(art.rangeX, 0.15, "sized like the area it replaces") + +local sectorE = ap.controls[2] +eq(sectorE.spec, "right", "the first sector is the one pointing right") +near(sectorE.x, 0.2, "every sector sits on the area centre") +near(sectorE.y, 0.7, "on both axes") +near(sectorE.rangeX, 0.15, "and covers the whole area, not a ninth of it") +near(sectorE.reachLeft, 1.5, "the desc reach_x rides onto the sectors as it is") +near(sectorE.reachRight, 1.5, "on both sides") +eq(sectorE.sector, 1, "the sector index is kept for the hit test") +eq(ap.controls[3].spec, "right|down", "the next sector is the lower-right corner") +eq(ap.controls[4].spec, "down", "then straight down, y growing downwards") +eq(ap.controls[8].spec, "up", "and straight up seven sectors along") +check(ap.controls[1].movable, "_movable is parsed") + +local abxy = ap.controls[10] +eq(abxy.spec, "a", "abxy right is RetroPad a, which is GB A") +eq(abxy.buttons[1], "a", "and reaches that GB button") +eq(ap.controls[16].spec, "start", "abxy_area honours an _up override") +eq(ap.controls[15].spec, "y|start", "the up-left sector combines both sides") +check(ap.controls[15].exclusive == nil, "and inherits nothing the desc did not set") +-- The native host also owns DS controls. Unlike the GB-only reference, +-- importing a RetroPad Y must preserve that native button binding. +check(not ap.controls[14].decorative, "RetroPad Y remains a native input control") +eq(ap.controls[14].buttons[1], 'y', "RetroPad Y retains its native DS binding") +eq(abxy.shape, "radial", "a radial area keeps its ellipse") + +eq(ap.controls[18].spec, "right", "analog_left degrades to a directional pad") +check(ap.controls[18].exclusive, "_exclusive rides onto the expanded sectors") +eq(ap.controls[23].spec, "left|up", "with all eight sectors") +near(ap.controls[18].rangeX, 0.1, "analog sectors share the whole stick area") + +local sq = { x = 0.5, y = 0.5, rangeX = 0.25, rangeY = 0.25, shape = "rect", + rangeMod = 1, alphaMod = 1, + reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1 } +local sectors = TouchSkin.expandSectors(sq, TouchSkin.AREA_DEFAULTS.dpad_area) +eq(#sectors, 8, "a dpad area expands into eight sector hitboxes") +local page = { rect = { x = 0, y = 0, w = 1, h = 1 }, fullScreen = true, + aspect = 1, controls = sectors } +local function hitSpecs(px, py) + local out = {} + for _, ctl in ipairs(sectors) do + if TouchSkin.hits(page, ctl, 100, 100, px, py, 0, 0) then out[#out + 1] = ctl.spec end + end + return table.concat(out, "+") +end +eq(hitSpecs(50, 50), "right", "the exact centre still fires a direction: no dead zone") +eq(hitSpecs(60, 50), "right", "a touch to the right of centre is right") +eq(hitSpecs(50, 60), "down", "a touch below centre is down, y growing downwards") +eq(hitSpecs(50, 40), "up", "a touch above centre is up") +eq(hitSpecs(40, 40), "left|up", "a diagonal touch fires both directions") +eq(hitSpecs(58, 52), "right", "17 degrees off the axis is still a pure direction") +eq(hitSpecs(55, 53), "right|down", "and 31 degrees is the diagonal, not a grid corner") +eq(hitSpecs(50, 80), "", "outside the area nothing fires") + +local PIXEL_NO_IMAGE = [[ +overlays = 1 +overlay0_name = "pixels" +overlay0_descs = 1 +overlay0_desc0 = "a,120,80,rect,20,10" +]] +local noImage = assert(TouchSkin.parse(PIXEL_NO_IMAGE)) +check(hasWarning(noImage, "no base image"), + "pixel coords without a base image are called out") +check(noImage.pages[1].pixelCoords == false, + "and read as normalized rather than dividing by nothing") + +love.filesystem.write("skins/px/overlay.cfg", [[ +overlays = 1 +overlay0_name = "px" +overlay0_overlay = img/base.png +overlay0_full_screen = true +overlay0_descs = 2 +overlay0_desc0 = "a,4,4,rect,2,1" +overlay0_desc1 = "b,6,2,rect,1,1" +overlay0_desc1_normalized = true +]]) +local px = assert(TouchSkin.load("skins/px", "px")) +local pxPage = px.pages[1] +check(pxPage.image ~= nil, "the base overlay image loads") +local iw, ih = pxPage.image:getDimensions() +near(pxPage.controls[1].x, 4 / iw, "pixel x is divided by the base image width") +near(pxPage.controls[1].y, 4 / ih, "pixel y is divided by the base image height") +near(pxPage.controls[1].rangeX, 2 / iw, "and so are the half extents") +near(pxPage.controls[2].x, 6, "a per-desc normalized flag opts that desc out") +check(pxPage.pixelCoords == false, "the page is normalized once converted") + +love.filesystem.write("skins/pxbad/overlay.cfg", [[ +overlays = 1 +overlay0_name = "pxbad" +overlay0_overlay = img/broken.png +overlay0_descs = 1 +overlay0_desc0 = "a,4,4,rect,2,1" +]]) +local savedNewImage = love.graphics.newImage +love.graphics.newImage = function() error("unreadable image") end +local badPx, badPxErr = TouchSkin.load("skins/pxbad", "pxbad") +love.graphics.newImage = savedNewImage +eq(badPx, nil, "a skin whose pixel coordinates have no base image fails to load") +check(tostring(badPxErr):find("img/broken.png", 1, true) ~= nil, + "and the error names the image it could not read") + +local DELTA_JSON = [[ +{ + "name": "Test GBC", + "identifier": "com.example.gbc.test", + "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "debug": false, + "representations": { + "iphone": { + "edgeToEdge": { + "portrait": { + "assets": { "small": "p_small.png", "medium": "p_medium.png", + "large": "p_large.png" }, + "items": [ + { "inputs": ["a"], "frame": {"x":240,"y":320,"width":64,"height":64}, + "mask": "circle" }, + { "inputs": ["b"], "frame": {"x":160,"y":360,"width":64,"height":64}, + "extendedEdges": {"right":16} }, + { "inputs": {"up":"up","down":"down","left":"left","right":"right"}, + "frame": {"x":16,"y":320,"width":96,"height":96} }, + { "inputs": ["start","select"], + "frame": {"x":128,"y":448,"width":64,"height":32} }, + { "inputs": ["menu"], "frame": {"x":0,"y":0,"width":32,"height":32} }, + { "inputs": ["quickSave"], + "frame": {"x":288,"y":0,"width":32,"height":32} } + ], + "mappingSize": {"width":320,"height":480}, + "extendedEdges": {"top":8,"bottom":8,"left":8,"right":8}, + "translucent": false, + "screens": [{ "inputFrame": {"x":0,"y":0,"width":160,"height":144}, + "outputFrame": {"x":0,"y":32,"width":320,"height":288} }] + } + }, + "standard": { + "portrait": { "items": [], "mappingSize": {"width":320,"height":480} } + } + } + } +} +]] + +local delta = assert(TouchSkin ~= nil and DeltaSkin.parse(DELTA_JSON)) +eq(delta.format, "delta", "a .deltaskin parses into the native model") +eq(delta.name, "Test GBC", "info.json name") +eq(delta.system, "gbc", "gbc covers both GB and GBC") +eq(#delta.pages, 1, "only the orientations present become pages") +local dp = delta.pages[1] +eq(dp.name, "portrait", "the page is named for its orientation") +eq(dp.orient, "portrait", "and locked to it") +eq(dp.imagePath, "p_large.png", "the PNG ladder picks the largest for a phone") +check(dp.fullScreen, "Delta stretches its skin over the whole surface") +check(not dp.aspectFromCfg, "so nothing letterboxes it") +near(dp.aspect, 320 / 480, "the page aspect is the mappingSize aspect") +eq(#dp.controls, 13, "edgeToEdge wins over standard, so all six items parsed") + +local dA = dp.controls[1] +eq(dA.buttons[1], "a", "an inputs array binds its button") +eq(dA.shape, "radial", 'mask "circle" becomes a radial hitbox') +near(dA.x, 0.85, "frame top-left plus half width is the native centre") +near(dA.y, 352 / 480, "and the same for y") +near(dA.rangeX, 0.1, "frame width halves into the native half extent") +near(dA.reachLeft, 1.25, "orientation extendedEdges become reach") + +local dB = dp.controls[2] +near(dB.reachRight, 1.5, "a per-item extendedEdges key overrides that side") +near(dB.reachLeft, 1.25, "and leaves the others inherited") + +eq(dp.controls[3].spec, "left|up", "a dpad input object expands to a 3x3 grid") +near(dp.controls[3].x, 0.1, "dpad top-left cell x") +near(dp.controls[3].rangeX, 0.05, "dpad cells are a third of the frame") +near(dp.controls[3].reachLeft, 1.5, "with the extended edge re-scaled onto them") +eq(dp.controls[4].spec, "up", "dpad top-centre cell") +eq(dp.controls[10].spec, "right|down", "dpad bottom-right cell") + +eq(dp.controls[11].spec, "start|select", "a multi-input item fires both") +eq(dp.controls[12].hotkeys[1], "menu", "the Delta menu button becomes a hotkey") +check(dp.controls[13].decorative, + "quickSave has no engine hotkey, so it is inert rather than a game button") + +check(dp.viewport ~= nil, "screens[] places the emulator picture") +near(dp.viewport.y, 32 / 480, "outputFrame y normalizes by mappingSize") +near(dp.viewport.h, 288 / 480, "outputFrame height normalizes by mappingSize") + +local bx, by, bw, bh = TouchSkin.pageBox(dp, 1000, 500) +eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y") +eq(bw, 1000, "delta page box fills the width") +eq(bh, 500, "delta page box fills the height") + +local DECK_JSON = [[ +{ "name": "Deck", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "large": "deck.png" }, + "mappingSize": {"width":320,"height":240}, + "items": [ { "inputs": ["a"], "frame": {"x":240,"y":60,"width":64,"height":64} } ] + } } } } } +]] +local deck = assert(DeltaSkin.parse(DECK_JSON)) +local deckPage = deck.pages[1] +check(deckPage.aspectFromCfg, "a portrait deck without screens keeps mapping aspect") +eq(deckPage.anchor, "bottom", "and sits at the bottom of the window") +eq(deckPage.screenFit, "remainder", "with the leftover given to the GB picture") +eq(deckPage.viewport, nil, "no screens[] means no baked cutout") +local dbx, dby, dbw, dbh = TouchSkin.pageBox(deckPage, 1080, 1920) +eq(dbx, 0, "deck overlay is full width") +eq(dbw, 1080, "deck overlay width") +near(dbh, 1080 * 240 / 320, "deck overlay height is mapping aspect") +near(dby, 1920 - dbh, "pinned to the bottom, not stretched") +local vx, vy, vw, vh = TouchSkin.pageViewport(deckPage, 1080, 1920) +eq(vx, 0, "screen leftover x") eq(vy, 0, "screen leftover y") +eq(vw, 1080, "screen leftover is full width") +near(vh, dby, "and fills everything above the overlay") +check(vh > dbh, "there is more room for the picture than for the pad") + +local LEGACY_SCREEN = [[ +{ "gameTypeIdentifier": "public.aoshuang.game.gbc", + "representations": { "iphone": { "standard": { "landscape": { + "mappingSize": {"width":640,"height":320}, + "gameScreenFrame": {"x":160,"y":0,"width":320,"height":288}, + "translucent": true, + "items": [ { "inputs": {"up":"analogStickUp","down":"analogStickDown", + "left":"analogStickLeft","right":"analogStickRight"}, + "frame": {"x":0,"y":0,"width":120,"height":120} } ] } } } } } +]] +local legacy = assert(DeltaSkin.parse(LEGACY_SCREEN)) +eq(legacy.system, "gbc", "the Manic public.aoshuang prefix is accepted") +eq(#legacy.pages, 1, "landscape only") +eq(legacy.pages[1].orient, "landscape", "orientation key drives the lock") +near(legacy.pages[1].viewport.x, 0.25, "gameScreenFrame is the legacy screen rect") +near(legacy.pages[1].alphaMod, 0.7, "translucent dims the controls") +eq(#legacy.pages[1].controls, 8, "a thumbstick degrades to a directional pad") +eq(legacy.pages[1].controls[1].spec, "left|up", "with the analog names mapped") + +local snes = assert(DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.snes", + "representations": { "iphone": { "standard": { "portrait": { + "mappingSize": {"width":320,"height":480}, "items": [] } } } } } +]])) +check(hasWarning(snes, "not Game Boy"), "a non Game Boy skin warns") +eq(#snes.pages, 1, "but still imports") + +eq(select(1, DeltaSkin.parse([[ +{ "name": "old", "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gba", + "representations": { "iphone": { "portrait": { "assets": {} } } } } +]])), nil, "a GBA4iOS skin is refused") +local _, gbaErr = DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.GBA4iOS.gbc", "representations": {} } +]]) +check(tostring(gbaErr):find("GBA4iOS", 1, true) ~= nil, + "and the message names the old format") + +local _, noTypeErr = DeltaSkin.parse('{ "representations": {} }') +check(tostring(noTypeErr):find("gameTypeIdentifier", 1, true) ~= nil, + "info.json without a gameTypeIdentifier is refused by name") +eq(select(1, DeltaSkin.parse("not json at all")), nil, "garbage is refused") +eq(select(1, DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", "representations": {} } +]])), nil, "an empty representations tree is refused") + +local PDF_JSON = [[ +{ "name": "Vector", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "resizable": "iphone_portrait.pdf" }, + "mappingSize": {"width":320,"height":480}, + "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":32,"height":32} } ] + } } } } } +]] +local pdf = assert(DeltaSkin.parse(PDF_JSON)) +eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art") +eq(pdf.pages[1].pdfPath, "iphone_portrait.pdf", + "but the PDF path is kept so load can extract a JPEG from it") +local convert = DeltaSkin.needsConversion(pdf) +check(convert ~= nil, "PDF-only skins report that they need conversion") +if convert then + check(convert.pdfOnly, "the report is flagged pdfOnly") + eq(convert.files[1], "iphone_portrait.pdf", "and names the file to convert") +end +eq(DeltaSkin.needsConversion(delta), nil, "a PNG skin needs no conversion") + +local mixed = assert(DeltaSkin.parse([[ +{ "gameTypeIdentifier": "com.rileytestut.delta.game.gb", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "resizable": "art.pdf", "medium": "art.png" }, + "mappingSize": {"width":320,"height":480}, "items": [] } } } } } +]])) +eq(mixed.pages[1].imagePath, "art.png", "a raster asset beats the PDF") +eq(DeltaSkin.needsConversion(mixed), nil, "so no conversion is needed") + +eq(DeltaSkin.pickAsset({ small = "s.png" }, { targetWidth = 1080 }, {}), "s.png", + "the ladder falls back to the largest shipped asset") +eq(DeltaSkin.pickAsset({ small = "s.png", medium = "m.png", large = "l.png" }, + { targetWidth = 640 }, {}), "s.png", + "a small target takes the small asset") +eq(DeltaSkin.pickAsset({ normal = "n.png" }, { targetWidth = 640 }, {}), "n.png", + 'the Manic "normal" alias is accepted') + +love.filesystem.write("skins/wrapped.deltaskin/MySkin/info.json", [[ +{ "name": "Wrapped", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc", + "representations": { "iphone": { "standard": { "portrait": { + "assets": { "large": "Portrait.PNG" }, + "mappingSize": {"width":320,"height":480}, + "items": [ { "inputs": ["a"], "frame": {"x":0,"y":0,"width":64,"height":64} } ] + } } } } } +]]) +love.filesystem.write("skins/wrapped.deltaskin/MySkin/portrait.png", "\137PNG\r\n\26\n") + +local wrappedId, wrappedErr = TouchSkin.installArchive("wrapped.deltaskin", "PK\3\4stub") +eq(wrappedId, "wrapped", "a .deltaskin installs under its bare name: " .. tostring(wrappedErr)) +eq(TouchSkin.find('wrapped').id,'wrapped','refresh retains an already-mounted archive') +eq(TouchSkin.find('wrapped').id,'wrapped','repeated refresh still retains the archive') +local wrapped = assert(TouchSkin.load("skins/_mounted/wrapped", "wrapped")) +eq(wrapped.format, "delta", "the mounted archive is recognised as a Delta skin") +eq(wrapped.name, "Wrapped", "and its name comes from info.json") +eq(wrapped.pages[1].imagePath, "MySkin/portrait.png", + "the wrapping folder is prefixed onto assets and the real file name wins") +eq(#wrapped.pages[1].controls, 1, "the wrapped items parsed") +eq(TouchSkin.installArchive('wrapped.deltaskin','PK\3\4replacement'),'wrapped', + 'replacement releases the previous mount before installing') +check(TouchSkin.find('wrapped') ~= nil,'replacement is visible on a subsequent refresh') + +love.filesystem.write("skins/vector.deltaskin/info.json", PDF_JSON) +local vectorId, vectorErr = TouchSkin.installArchive("vector.deltaskin", "PK\3\4stub") +eq(vectorId, nil, "a PDF-only skin is refused instead of installing invisible") +check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil, + "with the message that asks for a PNG version") +eq(love.filesystem.read("skins/vector.deltaskin"), nil, + "and the refused archive is not left behind") + +local PdfImage = require("src.core.PdfImage") +local function unhex(s) + return (s:gsub("..", function(cc) + return string.char(tonumber(cc, 16)) + end)) +end +-- 1x1 JFIF JPEG, so extract tests do not need a file on disk. +local TINY_JPEG = unhex( + "ffd8ffe000104a46494600010100000100010000ffdb0043000806060706050807070709" .. + "09080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c283729" .. + "2c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc400140001" .. + "0000000000000000000000000000000008ffc40014100100000000000000000000000000" .. + "00000000ffda0008010100003f007f3fffd9") +local function jpegPdf(jpeg, w, h) + return "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width " + .. tostring(w) .. " /Height " .. tostring(h) + .. " /BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter /DCTDecode /Length " + .. tostring(#jpeg) .. " >>\nstream\n" .. jpeg .. "\nendstream\nendobj\n%%EOF\n" +end +local extracted = assert(PdfImage.extract(jpegPdf(TINY_JPEG, 1, 1))) +eq(extracted.ext, "jpg", "a JPEG-in-PDF yields a jpg") +eq(extracted.data, TINY_JPEG, "and the JPEG body is recovered byte for byte") +eq(extracted.width, 1, "width comes from the Image XObject") +eq(extracted.height, 1, "and so does height") + +local indirect = "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width 1" + .. " /Height 1 /Filter /DCTDecode /Length 5 0 R >>\nstream\n" .. TINY_JPEG + .. "\nendstream\nendobj\n5 0 obj\n" .. tostring(#TINY_JPEG) .. "\nendobj\n%%EOF\n" +local fromRef = assert(PdfImage.extract(indirect)) +eq(fromRef.data, TINY_JPEG, + "an indirect /Length (the 3-Heights Image-to-PDF layout) still extracts") + +eq(select(1, PdfImage.extract("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")), + nil, "a vector PDF with no image is not pretended to be art") +eq(select(1, PdfImage.extract("not a pdf")), nil, "and neither is garbage") + +love.filesystem.write("skins/pikapdf.deltaskin/info.json", PDF_JSON) +love.filesystem.write("skins/pikapdf.deltaskin/iphone_portrait.pdf", + jpegPdf(TINY_JPEG, 1, 1)) +local pikaId, pikaErr = TouchSkin.installArchive("pikapdf.deltaskin", "PK\3\4stub") +eq(pikaId, "pikapdf", "a Delta skin whose PDF wraps a JPEG installs: " + .. tostring(pikaErr)) +local pika = assert(TouchSkin.load("skins/_mounted/pikapdf", "pikapdf")) +check(pika.pages[1].rasterData == TINY_JPEG, + "load recovers the JPEG from the PDF") +check(pika.pages[1].image ~= nil, "and LOVE gets an image from those bytes") +eq(DeltaSkin.needsConversion(pika), nil, + "so the skin no longer reports that it needs conversion") + +eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil, + "a GBA4iOS .gbcskin is refused at the door") +local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub") +check(tostring(legacyErr):find("GBA4iOS", 1, true) ~= nil, + "with a message that names the format") +eq(select(1, TouchSkin.installArchive("skin.rar", "PK\3\4stub")), nil, + "an unknown archive extension is refused") +eq(TouchSkin.archiveId("pad.deltaskin"), "pad", "archiveId strips .deltaskin") +eq(TouchSkin.archiveId("pad.zip"), "pad", "archiveId strips .zip") +eq(TouchSkin.archiveId("pad"), nil, "a bare name is not an archive") + +local AUTHORED = [[ +return { name = "Authored", pages = { + { name = "portrait", orient = "portrait", fullScreen = true, + viewport = { x = 0, y = 0, w = 1, h = 0.5 }, + controls = { + { bind = "a", x = 0.8, y = 0.75, w = 0.2, h = 0.1, shape = "radial" }, + { bind = "b", x = 0.6, y = 0.8, w = 0.2, h = 0.1, shape = "radial", + reachRight = 1.5 }, + { bind = "start", x = 0.5, y = 0.95, w = 0.1, h = 0.04 }, + { bind = "menu_toggle", x = 0.05, y = 0.05, w = 0.08, h = 0.04 }, + { bind = "nul", x = 0.2, y = 0.7, w = 0.3, h = 0.2, image = "img/dpad.png" }, + } }, + { name = "landscape", orient = "landscape", fullScreen = true, + controls = { + { bind = "a", x = 0.9, y = 0.8, w = 0.1, h = 0.15, shape = "radial" }, + } }, +} } +]] +local authored = assert(TouchSkin.parseNative(AUTHORED)) +authored.id = "authored" +authored.root = "skins/authored" + +local cfgText = TouchSkin.toRetroArchConfig(authored) +check(cfgText:find("overlays = 2", 1, true) ~= nil, "the cfg declares its overlays") +local reparsed = assert(TouchSkin.parse(cfgText)) +eq(#reparsed.pages, 2, "the generated cfg round-trips both pages") +eq(reparsed.pages[1].name, "portrait", "and their names") +eq(#reparsed.pages[1].controls, 5, "and every desc") +eq(reparsed.pages[1].controls[1].spec, "a", "binds survive the round trip") +near(reparsed.pages[1].controls[1].x, 0.8, "centres survive the round trip") +near(reparsed.pages[1].controls[1].rangeX, 0.1, "half extents survive") +eq(reparsed.pages[1].controls[1].shape, "radial", "hitbox shape survives") +near(reparsed.pages[1].controls[2].reachRight, 1.5, "per-side reach survives") +eq(reparsed.pages[1].controls[4].hotkeys[1], "menu", "hotkeys survive") +check(reparsed.pages[1].controls[5].decorative, "decoration stays decoration") +eq(reparsed.pages[1].controls[5].imagePath, "img/dpad.png", "and keeps its art") +near(reparsed.pages[1].viewport.h, 0.5, "the screen cutout survives") +eq(reparsed.pages[1].orient, "portrait", "the orientation lock survives by name") + +local KEY_SKIN = [[ +return { name = "Keys", pages = { + { name = "portrait", fullScreen = true, controls = { + { bind = "key:escape", x = 0.5, y = 0.5, w = 0.1, h = 0.1 }, + } }, +} } +]] +local keySkin = assert(TouchSkin.parseNative(KEY_SKIN)) +local keyCfg = TouchSkin.toRetroArchConfig(keySkin) +check(keyCfg:find("retrok_escape", 1, true) ~= nil, + "a key bind exports in the grammar RetroArch understands") +check(keyCfg:find("key:escape", 1, true) == nil, "and not in the native spelling") +eq(assert(TouchSkin.parse(keyCfg)).pages[1].controls[1].keys[1], "escape", + "which this importer still reads back as the same key") + +local areaCfg = TouchSkin.toRetroArchConfig({ pages = area.pages }) +local areaBack = assert(TouchSkin.parse(areaCfg)) +eq(#areaBack.pages[1].controls, #ap.controls, + "an area desc exports as one desc, not eight overlapping ones") +check(areaCfg:find("dpad_area", 1, true) ~= nil, "the area kind is written back") +check(areaCfg:find('_up = "start"', 1, true) ~= nil, "with its output override") +eq(areaBack.pages[1].controls[16].spec, "start", "which survives the round trip") + +local areaInfo = assert(DeltaSkin.build({ id = "area", pages = area.pages })) +local areaRep = areaInfo.representations.iphone.edgeToEdge.portrait +local dpadItem +for _, item in ipairs(areaRep.items) do + if not dpadItem and type(item.inputs) == "table" and item.inputs.up then + dpadItem = item + end +end +check(dpadItem ~= nil, "the same area exports to Delta as one d-pad item") +eq(dpadItem.inputs.left, "left", "carrying each direction") +eq(#areaRep.items, 3, "one per area desc, not eight stacked on one another") + +local raPath = os.tmpname() .. "-ra.zip" +local raWritten, raMissing = TouchSkin.exportRetroArch(authored, raPath) +eq(raWritten, raPath, "exportRetroArch writes where it was told") +eq(raMissing[1], "img/dpad.png", "and reports art it could not find") +local raZip = unzip(readBytes(raPath)) +eq(raZip[1], "overlay.cfg", "the RetroArch zip leads with overlay.cfg") +check(raZip["overlay.cfg"] ~= nil, "and the entry has bytes") +check(TouchSkin.parse(raZip["overlay.cfg"]) ~= nil, "which RetroArch grammar accepts") +os.remove(raPath) + +local dsPath = os.tmpname() .. ".deltaskin" +local dsWritten, _, dsWarnings = TouchSkin.exportDelta(authored, { path = dsPath }) +eq(dsWritten, dsPath, "exportDelta writes where it was told") +check(#dsWarnings > 0, "and warns that per-button art has nowhere to go") +local dsZip = unzip(readBytes(dsPath)) +eq(dsZip[1], "info.json", "the .deltaskin leads with info.json") +local info = assert(Json.decode(dsZip["info.json"])) +eq(info.gameTypeIdentifier, "com.rileytestut.delta.game.gbc", + "the export claims the GBC game type") +eq(info.name, "Authored", "and carries the skin name") +check(info.identifier:find("authored", 1, true) ~= nil, "identifier names the skin") +local rep = info.representations.iphone.edgeToEdge.portrait +check(rep ~= nil, "an iPhone edgeToEdge portrait representation is emitted") +eq(info.representations.iphone.standard.portrait.mappingSize.width, 1080, + "standard portrait maps 1080 wide") +eq(rep.mappingSize.height, 1920, "portrait maps 1920 tall") +eq(#rep.items, 4, "only bound controls become Delta items") +eq(rep.items[1].inputs[1], "a", "the first item is A") +eq(rep.items[1].mask, "circle", "a radial hitbox exports as a circle mask") +eq(rep.items[1].frame.x, 756, "frame x is top-left, not centre") +eq(rep.items[1].frame.width, 216, "frame width is the full extent") +eq(rep.items[2].extendedEdges.right, 54, "reach exports as extendedEdges") +eq(rep.items[4].inputs[1], "menu", "the menu hotkey exports as a Delta host input") +eq(rep.screens[1].inputFrame.width, 160, "the screen crop is a full GB frame") +eq(rep.screens[1].outputFrame.height, 960, "and the output frame follows the viewport") +eq(info.representations.iphone.edgeToEdge.landscape.mappingSize.width, 1920, + "the landscape page maps 1920 wide") + +local back = assert(DeltaSkin.parse(dsZip["info.json"])) +eq(#back.pages, 2, "the exported skin re-imports both orientations") +local bp = back.pages[1] +eq(#bp.controls, 4, "with every bound control") +near(bp.controls[1].x, 0.8, "and the same centres it started with") +near(bp.controls[1].rangeX, 0.1, "and the same half extents") +eq(bp.controls[1].shape, "radial", "and the same hitbox shape") +near(bp.controls[2].reachRight, 1.5, "and the same reach") +near(bp.viewport.h, 0.5, "and the same screen cutout") +os.remove(dsPath) + +love.filesystem.write("skins/collide/overlay.cfg", [[ +overlays = 1 +overlay0_name = "collide" +overlay0_descs = 1 +overlay0_desc0 = "a,0.5,0.5,rect,0.05,0.05" +]]) +local collide = assert(TouchSkin.load("skins/collide", "collide")) +local defaultDelta = assert(TouchSkin.exportDelta(collide)) +eq(defaultDelta, "skins/_export/collide.deltaskin", + "a default export lands outside the folder the skin list scans") +local listedRoot, listedExport +for _, entry in ipairs(TouchSkin.list()) do + if entry.id == "collide" then listedRoot = entry.root end + if entry.id == "_export" then listedExport = true end +end +eq(listedRoot, "skins/collide", "so the export cannot shadow the skin it came from") +check(not listedExport, "and the export folder is not a skin of its own") + +local oldUnmount=love.filesystem.unmount +love.filesystem.unmount=function() return false end +eq(TouchSkin.remove('wrapped'),nil,'a busy mounted archive is not deleted') +check(love.filesystem.read('skins/wrapped.deltaskin') ~= nil,'busy archive bytes are retained') +love.filesystem.unmount=oldUnmount +check(TouchSkin.remove('wrapped'),'removal releases its mount before deleting archive bytes') +eq(TouchSkin.find('wrapped'),nil,'removed archive is absent on subsequent refresh') + +T.finish("skin_format_import") diff --git a/tests/engine/skin_studio_image_import.lua b/tests/engine/skin_studio_image_import.lua new file mode 100644 index 000000000..3e75860da --- /dev/null +++ b/tests/engine/skin_studio_image_import.lua @@ -0,0 +1,122 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Skin Studio art import: the native image picker behind the "Import" buttons +-- (bezel / idle / pressed), and the alt-tab crash that took the studio down -- +-- love.focus fell through to Game:focus with no game booted. +-- luajit tests/engine/skin_studio_image_import.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local FilePicker = require("src.core.FilePicker") +local TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") + +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.skinIdField = "t" + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.drag = nil + Studio.dirty = false + Studio.status = nil + Studio.testing = false + Studio.images = {} + Studio.imageTarget = "idle" + return Studio.skin +end + +-- ------------------------------------------------------------ file picker + +check(FilePicker.matches("bezel.png", FilePicker.IMAGE), "png is art") +check(FilePicker.matches("BEZEL.PNG", FilePicker.IMAGE), "case does not matter") +check(FilePicker.matches("shot.jpg", FilePicker.IMAGE), "jpg is art") +check(FilePicker.matches("shot.jpeg", FilePicker.IMAGE), "jpeg is art") +check(not FilePicker.matches("skin.zip", FilePicker.IMAGE), "a zip is not art") +eq(FilePicker.basename("/home/me/art/bezel.png"), "bezel.png", "posix basename") +eq(FilePicker.basename("C:\\Users\\me\\bezel.png"), "bezel.png", "windows basename") +eq(FilePicker.basename("bezel.png"), "bezel.png", "a bare name is its own basename") + +-- ----------------------------------------------------------- import target + +session() +Studio.addControl() +local ctl = Studio.selectedControl() +check(Studio.adoptImage("bezel.png", "PNGDATA", "bezel"), "bezel import succeeds") +eq(Studio.page().imagePath, "img/bezel.png", "bezel art lands on the page") +eq(ctl.imagePath, nil, "and not on the selected control") +check(Studio.dirty, "importing marks the skin dirty") +check(Studio.status:find("bezel", 1, true) ~= nil, "the status names the target") + +check(Studio.adoptImage("a_button.png", "PNGDATA", "idle"), "idle import succeeds") +eq(ctl.imagePath, "img/a_button.png", "idle art lands on the control") +eq(Studio.page().imagePath, "img/bezel.png", "and leaves the bezel alone") + +check(Studio.adoptImage("a_down.png", "PNGDATA", "pressed"), "pressed import succeeds") +eq(ctl.pressedImagePath, "img/a_down.png", "pressed art lands on the control") +eq(ctl.imagePath, "img/a_button.png", "idle art survives") + +check(not Studio.adoptImage("notes.txt", "junk", "bezel"), "non-art is refused") +eq(Studio.page().imagePath, "img/bezel.png", "and changes nothing") + +-- the imported files are listed for the cycle buttons +local listed = {} +for _, rel in ipairs(Studio.images or {}) do listed[rel] = true end +check(listed["img/bezel.png"], "the imported bezel joins the image list") + +-- -------------------------------------------------------- picker plumbing + +local realOpen, realRead, realAvailable = FilePicker.open, FilePicker.read, FilePicker.available +FilePicker.available=function()return true end +local asked +FilePicker.open = function(prompt, kind) + asked = { prompt = prompt, kind = kind } + return "/tmp/some folder/frame.png" +end +FilePicker.read = function() return "PNGDATA" end + +session() +Studio.importImageFile("bezel") +check(asked ~= nil, "the bezel button opens a picker") +eq(asked.kind, FilePicker.IMAGE, "and asks for images, not archives") +check(asked.prompt:lower():find("bezel", 1, true) ~= nil, "with a bezel prompt") +eq(Studio.page().imagePath, "img/frame.png", "the pick becomes the bezel") +eq(Studio.imageTarget, "bezel", "and the target sticks for the cycle button") + +-- no control selected: an art import says so instead of writing to the page +session() +asked = nil +Studio.importImageFile("idle") +check(asked == nil, "no picker opens without a control to paint") +eq(Studio.page().imagePath, nil, "and the bezel is not overwritten") +check(Studio.status ~= nil, "the studio explains why") + +-- a cancelled dialog leaves the skin exactly as it was +session() +FilePicker.open = function() return nil end +Studio.importImageFile("bezel") +eq(Studio.page().imagePath, nil, "cancelling imports nothing") +check(not Studio.dirty, "and does not dirty the skin") + +FilePicker.open, FilePicker.read = realOpen, realRead +FilePicker.available=realAvailable + +-- --------------------------------------------------------- focus / alt-tab + +session() +Studio.drag = { kind = "control-move", mx = 1, my = 1, bx = 0, by = 0, bw = 1, bh = 1 } +Studio.clicked = true +Studio.focus(false) +eq(Studio.drag, nil, "losing focus drops the in-flight drag") +eq(Studio.clicked, false, "and the queued click") +check(pcall(Studio.visible, false), "minimizing is survivable too") + +-- Actual native main.lua event ownership is exercised by +-- skin_studio_native_host.lua; a text scan of a different host's variable +-- names would not prove that this engine dispatches those events. + +T.finish("skin_studio_image_import") diff --git a/tests/engine/skin_studio_library_reach_bug1912.lua b/tests/engine/skin_studio_library_reach_bug1912.lua new file mode 100644 index 000000000..a8e5f724c --- /dev/null +++ b/tests/engine/skin_studio_library_reach_bug1912.lua @@ -0,0 +1,176 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") +local Kit = require("src.ui.kit.Kit") + +local oldDims = love.graphics.getDimensions +local oldSafe = love.window.getSafeArea +local oldThumb, oldSummary = Studio.libraryThumb, Studio.skinSummary +Studio.libraryThumb = function() return nil end +Studio.skinSummary = function() return "12 buttons 2 pages" end + +local SIZES = { + { 1600, 720, "1600x720 landscape phone" }, + { 2400, 1080, "2400x1080 landscape phone" }, + { 1280, 720, "1280x720" }, + { 1920, 1080, "1920x1080" }, + { 720, 1600, "720x1600 portrait phone" }, + { 400, 800, "400x800 small portrait" }, +} + +local function open(w, h, n) + love.graphics.getDimensions = function() return w, h end + love.window.getSafeArea = function() return 0, 0, w, h end + Studio.skin = TouchSkin.newSkin("t") + Studio.mode = "library" + Studio.modal, Studio.confirm = nil, nil + Studio.available = {} + for i = 1, n do + Studio.available[i] = { id = "skin" .. i, source = "user" } + end + Studio.libraryScroll, Studio.libraryScrollMax = 0, 0 + Studio.libraryPress = nil +end + +local function inside(r, f) + return r.x >= f.x - 0.5 and r.y >= f.y - 0.5 + and r.x + r.w <= f.x + f.w + 0.5 and r.y + r.h <= f.y + f.h + 0.5 +end + +local function tappable(r) + if not r.clip then return { x = r.x, y = r.y, w = r.w, h = r.h } end + local x1 = math.max(r.x, r.clip.x) + local y1 = math.max(r.y, r.clip.y) + local x2 = math.min(r.x + r.w, r.clip.x + r.clip.w) + local y2 = math.min(r.y + r.h, r.clip.y + r.clip.h) + if x2 <= x1 or y2 <= y1 then return nil end + return { x = x1, y = y1, w = x2 - x1, h = y2 - y1 } +end + +for _, size in ipairs(SIZES) do + local w, h, label = size[1], size[2], size[3] + local frame = { x = 0, y = 0, w = w, h = h } + for _, n in ipairs({ 3, 9 }) do + open(w, h, n) + Kit.audit = {} + check(pcall(Studio.draw), label .. " with " .. n .. " skins draws") + + local escaped = nil + for _, r in ipairs(Kit.audit or {}) do + local vis = tappable(r) + if vis and not inside(vis, frame) then escaped = r.label end + end + eq(escaped, nil, label .. "/" .. n + .. ": no tappable control escapes the window") + + local maxScroll = Studio.libraryScrollMax or 0 + local reached = {} + local step = Kit.scrollStep() + local offset = 0 + while true do + Studio.libraryScroll = offset + Kit.audit = {} + check(pcall(Studio.draw), label .. "/" .. n + .. ": draws at scroll " .. math.floor(offset)) + local view = Studio.libraryView + for id, r in pairs(Studio.libraryRects or {}) do + if inside(r, view) and inside(r, frame) then reached[id] = true end + end + if offset >= maxScroll then break end + offset = math.min(maxScroll, offset + step) + end + local missing = nil + for i = 1, n do + if not reached["skin" .. i] then missing = "skin" .. i end + end + eq(missing, nil, label .. "/" .. n + .. ": every skin's Select can be scrolled fully into view") + end +end + +Kit.audit = nil + +do + open(1600, 720, 3) + Studio.draw() + eq(Studio.libraryScrollMax, 0, "three skins fit a 1600x720 window with no scroll") + local view = Studio.libraryView + for i = 1, 3 do + local r = Studio.libraryRects["skin" .. i] + check(r ~= nil and inside(r, view), + "skin" .. i .. "'s Select is on screen at rest") + end +end + +do + open(1600, 720, 9) + Studio.draw() + check((Studio.libraryScrollMax or 0) > 0, + "nine skins overflow the same window and become scrollable") +end + + +do + open(1600, 720, 9) + Studio.draw() + local maxScroll = Studio.libraryScrollMax + local x, y = 800, 400 + + Studio.clicked = false + Studio.touchpressed("f1", x, y) + eq(Studio.clicked, false, "a press in the library does not dispatch a tap") + check(Studio.libraryPress ~= nil, "it arms a drag instead") + Studio.touchmoved("f1", x, y - 200) + check(Studio.libraryScroll > 0, "dragging up scrolls the list") + eq(Studio.libraryScroll, math.min(200, maxScroll), + "by exactly the distance the finger travelled, clamped to the extent") + Studio.touchreleased("f1", x, y - 200) + eq(Studio.clicked, false, "and the drag never becomes a tap on release") + eq(Studio.libraryPress, nil, "the press is cleared") + + Studio.touchmoved("f1", x, y - 400) + eq(Studio.libraryScroll, math.min(200, maxScroll), + "a move after release moves nothing") + + Studio.libraryScroll = 0 + Studio.clicked = false + Studio.touchpressed("f1", x, y) + Studio.touchmoved("f1", x + 2, y - 2) + eq(Studio.libraryScroll, 0, "a jitter inside the tap slop is not a drag") + Studio.touchreleased("f1", x + 2, y - 2) + eq(Studio.clicked, true, "a press with no travel dispatches the tap on release") +end + +do + open(1600, 720, 9) + Studio.draw() + Studio.libraryScroll = 0 + Studio.touchpressed("f1", 800, 400) + Studio.touchmoved("f1", 800, 400 + 500) + eq(Studio.libraryScroll, 0, "dragging down past the top clamps at zero") + Studio.touchreleased("f1", 800, 900) +end + +do + open(1600, 720, 3) + Studio.mode = "editor" + Studio.clicked = false + Studio.libraryPress = nil + Studio.mousepressed(800, 400, 1) + eq(Studio.clicked, true, "the editor still dispatches its tap on press") + eq(Studio.libraryPress, nil, "and never arms a library drag") +end + +Studio.libraryThumb, Studio.skinSummary = oldThumb, oldSummary +love.graphics.getDimensions = oldDims +love.window.getSafeArea = oldSafe + +T.finish("My Skins library reach and scroll (#1912)") diff --git a/tests/engine/skin_studio_native_host.lua b/tests/engine/skin_studio_native_host.lua new file mode 100644 index 000000000..17f483c67 --- /dev/null +++ b/tests/engine/skin_studio_native_host.lua @@ -0,0 +1,163 @@ +-- Actual native main callbacks, editor host handoff and real studio state. +-- Constructor is isolated; no cartridge import, player profile or OS IO. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Save=require('src.core.SaveData') +local files={} +local fs=T.sdk.memfs(files) +fs.createDirectory=function()return true end +fs.remove=function(p)files[p]=nil;return true end +fs.getSaveDirectory=function()return '/memory-only-studio-host'end +love.filesystem=fs;Save.persistenceFs=function()return fs end +local osName='Linux' +love.system=love.system or {};love.system.getOS=function()return osName end +local newImage=love.graphics.newImage +love.graphics.newImage=function(...) + local image=newImage(...) + function image:setFilter(min,mag)self.filter={min,mag}end + return image +end +local Runtime=require('src.mods.Runtime') +Runtime.install(require('src.mods.Events').new(),require('src.mods.Hooks').new(),{}) +local Host=require('src.core.HostShell') +local launcher +local oldHook=Host.setLauncherHook +Host.setLauncherHook=function(fn)launcher=fn;return oldHook(fn)end +dofile('main.lua');Host.setLauncherHook=oldHook +local function up(fn,wanted) + for i=1,100 do + local name,value=debug.getupvalue(fn,i) + if not name then break end + if name==wanted then return value end + end + error('native host has no '..wanted) +end +local open=up(launcher,'openLauncher') +local Importer=require('src.import.RomImporter') +local oldNew,options=Importer.new +local played=0 +local host=setmetatable({tab='crystal',ready={crystal=true},_padCursor={x=90,y=80}, + _padAxis={leftx=1},_padDir={dpright=true},_rawHatDirs={},_padCursorActive=true, + _slotPress={},_touchGesture={},onComplete=function(v) + T.eq(v,'crystal','studio Play retains selected native cartridge');played=played+1 + end},{__index=Importer}) +Importer.new=function(_,opts)options=opts;return host end +open(false);Importer.new=oldNew +T.check(type(options.onEditTouchSkins)=='function','native launcher actually supplies studio action') +local oldGameActions=0 +require('src.core.TouchControls'):setHotkeyHandler(function()oldGameActions=oldGameActions+1 end) +options.onEditTouchSkins() +local Studio=require('src.ui.SkinStudio') +local Skin=require('src.core.TouchSkin') +local Pad=require('src.ui.PadCursor') +local Kit=require('src.ui.kit.Kit') +T.eq(up(love.keypressed,'TouchEditor'),Studio,'actual native host installs real studio owner') +T.eq(up(love.keypressed,'Importer'),nil,'native launcher is suspended') +T.eq(next(host._padAxis),nil,'handoff drops old held axis') +T.eq(host._touchGesture,nil,'handoff drops old launcher gesture') +T.eq(Studio.mode,'library','native entry opens actual skin library') +T.eq(Studio.version,'crystal','editor retains selected cartridge') +love.gamepadaxis(nil,'leftx',1) +T.check(Pad.isActive(),'main forwards controller axis to real studio cursor') +Studio.drag={};Studio.clicked=true +love.focus(false) +T.eq(Studio.drag,nil,'actual focus event releases studio drag with no Game booted') +T.check(not Pad.isActive(),'actual focus event cancels held virtual cursor') +love.gamepadaxis(nil,'leftx',1);love.joystickremoved({}) +T.check(not Pad.isActive(),'device removal clears actual studio cursor') +love.visible(false);T.eq(Studio.pointerDown,false,'native visibility event drops gesture') +love.wheelmoved(0,-3);T.eq(Studio.wheel,-3,'actual wheel event reaches studio scroll owner') +Kit.layout(640,576);Kit.focus='native-name' +love.textinput('OWNED') +local text=Kit.textfield('native-name',0,0,200,40,'','Name') +T.eq(text,'OWNED','native text event reaches actual focused widget') +Kit.blur() +Studio.mode='editor';Studio.imageTarget='bezel' +local dropped={getFilename=function()return 'owned-host.png'end, + open=function()return true end,read=function()return 'OWNED-MEMORY-IMAGE'end,close=function()end} +love.filedropped(dropped) +T.eq(Studio.page().imagePath,'img/owned-host.png','actual file drop imports owned art into selected skin') +T.check(Studio.dirty,'actual import marks native editor dirty') +local previousSkin=Studio.skin +local archive={getFilename=function()return 'another.zip'end, + open=function()return true end,read=function()return 'PENDING-ARCHIVE'end,close=function()end} +love.filedropped(archive) +T.check(Studio.confirm~=nil,'native archive drop asks before discarding unsaved artwork') +T.eq(Studio.skin,previousSkin,'pending archive drop retains actual edited skin') +Studio.confirmNo() +T.eq(Studio.skin,previousSkin,'cancelling drop retains edits') +-- The editor must preserve native DS controls, in addition to Gen1's binds. +local Version=require('src.core.GameVersion');Version.set('platinum') +Studio.addControl();Studio.setBindSpec('x|y') +local Input=require('src.core.Input');Input:init() +local Touch=require('src.core.TouchControls');Touch:setPreview(false) +local w,h=love.graphics.getDimensions() +local cx,cy=Skin.controlGeometry(Studio.page(),Studio.selectedControl(),w,h) +T.check(Touch:touchpressed('ds-buttons',cx,cy),'studio-created native DS bind captures real contact') +T.check(Input:isDown('x') and Input:isDown('y'),'both DS buttons reach actual native input owner') +Touch:touchreleased('ds-buttons',cx,cy) +T.check(not Input:isDown('x') and not Input:isDown('y'),'DS bind release reaches native owner') +Touch:setPreview(true) +Version.set('crystal') +Touch:setPreview(false) +T.eq(Touch:hitTest(cx,cy),nil,'DS-only art cannot steal a GB pointer gesture') +T.eq(Touch:touchpressed('gb-only',cx,cy),nil,'DS-only binding is inert in Crystal') +T.check(not Input:isDown('x') and not Input:isDown('y'),'unsupported buttons cannot reach GB input') +Studio.setBindSpec('x|a') +Touch:touchpressed('mixed',cx,cy) +T.check(Input:isDown('a') and not Input:isDown('x'),'mixed binding retains supported GB button only') +Touch:touchreleased('mixed',cx,cy) +T.check(not Input:isDown('a'),'mixed release retires actual GB input') +Touch:setPreview(true) +-- The studio's mock device occupies a rectangle inside the editor window. +-- Its Test input must use that same rectangle, not the full window surface. +Studio.setBindSpec('a');Studio.testing=true;Touch:setPreview(false) +Studio.lastCanvas={x=60,y=70,w=180,h=300} +local r=Studio.lastCanvas +cx,cy=Skin.controlGeometry(Studio.page(),Studio.selectedControl(),r.w,r.h,r.x,r.y) +Studio.mousepressed(cx,cy,1) +T.check(Input:isDown('a'),'Test input uses actual mock-device coordinates') +T.eq(Skin.surfaceRect,nil,'Test input restores outer skin surface') +Studio.mousereleased(cx,cy,1) +T.check(not Input:isDown('a'),'mock-device release clears native button') +Studio.setBindSpec('reset') +Studio.mousepressed(cx,cy,1);Studio.mousereleased(cx,cy,1) +T.eq(oldGameActions,0,'Test cannot reset or change a suspended actual game') +T.check(Studio.status:find('soft_reset',1,true)~=nil,'Test reports the real parsed hotkey action') +Studio.version='platinum';Studio.setBindSpec('x|y') +Studio.mousepressed(cx,cy,1) +T.check(Input:isDown('x') and Input:isDown('y'),'mock-device input uses selected DS cartridge capabilities') +T.eq(Version.get(),'crystal','mock-device input restores outer cartridge context') +Studio.mousereleased(cx,cy,1) +T.check(not Input:isDown('x') and not Input:isDown('y'),'scoped DS release retires actual inputs') +Studio.version='crystal' +Studio.testing=false;Touch:setPreview(true) +local calls=0 +local mouse=Studio.mousepressed +Studio.mousepressed=function(...)calls=calls+1;return mouse(...)end +for _,platform in ipairs{'iOS','Android'}do + osName=platform + love.touchpressed(platform,400,300) + local after=calls + love.mousepressed(400,300,1,true) + T.eq(calls,after,'synthetic '..platform..' mouse does not duplicate real touch') + love.touchreleased(platform,400,300) + T.eq(Studio.touchId,nil,'actual '..platform..' release ends finger ownership') +end +Studio.mousepressed=mouse;osName='Linux' +Studio.mode='library';Studio.dirty=false +love.gamepadpressed(nil,'b') +T.eq(up(love.keypressed,'TouchEditor'),nil,'normal B closes actual native studio owner') +T.eq(up(love.keypressed,'Importer'),host,'normal close restores the same native launcher') +T.eq(next(host._padDir),nil,'close cannot restore stale held launcher direction') +T.eq(host._padCursor.x,90,'close preserves launcher cursor location') +T.check(host._padCursorActive,'close restores prior pad navigation mode') +options.onEditTouchSkins() +Studio.newSkin();Studio.skinIdField='native_host_play';Studio.play() +T.eq(played,0,'Play never starts a cartridge inside editor draw') +Studio.update(1/60) +T.eq(played,1,'deferred Play reaches actual native ready-cartridge admission') +T.eq(up(love.keypressed,'TouchEditor'),nil,'deferred Play unloads native editor before admission') +T.eq(Save.loadOptions().touchControls.skin,'native_host_play','Play persists actual selected native skin') +T.eq(Skin.active,nil,'unload relinquishes editor viewport before game boot') +T.finish('native studio launcher, callbacks and deferred play admission') diff --git a/tests/engine/skin_studio_test.lua b/tests/engine/skin_studio_test.lua new file mode 100644 index 000000000..d0ca75309 --- /dev/null +++ b/tests/engine/skin_studio_test.lua @@ -0,0 +1,388 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Skin Studio model layer (#1386): canvas presets, control editing, the +-- drag/resize math and the pixel coordinate fields. Drawing is exercised by +-- tests/drivers/skin_studio_shot.lua; this pins the state machine underneath. +-- luajit tests/engine/skin_studio_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") + +local function near(a, b, tol, msg) + check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) .. + ", want " .. tostring(b) .. ")") +end + +-- a studio session without touching options or the filesystem +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.viewZoom = 1 + Studio.aspectLock = true + Studio.drag = nil + Studio.dirty = false + Studio.images = {} + return Studio.skin +end + +-- ------------------------------------------------------------- new skin + +session() +eq(#Studio.skin.pages, 1, "a new skin starts with one page") +eq(#Studio.page().controls, 0, "and no controls") +check(Studio.page().viewport ~= nil, "and a screen cutout to place") +check(Studio.selectedControl() == nil, "nothing selected yet") + +-- ------------------------------------------------------------- controls + +Studio.addControl() +eq(#Studio.page().controls, 1, "addControl appends") +eq(Studio.selected, 1, "and selects what it added") +check(Studio.dirty, "editing marks the skin dirty") +local ctl = Studio.selectedControl() +eq(ctl.spec, "a", "new controls default to GB a") +eq(ctl.buttons[1], "a", "and carry the parsed bind") + +Studio.cycleBind(1) +check(ctl.spec ~= "a", "cycleBind moves off the current bind") +check(#ctl.buttons + #ctl.hotkeys + #ctl.keys > 0 or ctl.decorative, + "cycleBind reparses the bind") +Studio.cycleBind(-1) +eq(ctl.spec, "a", "cycleBind is reversible") + +Studio.duplicateControl() +eq(#Studio.page().controls, 2, "duplicate adds a second control") +eq(Studio.selected, 2, "and selects the copy") +check(Studio.page().controls[2] ~= Studio.page().controls[1], + "the copy is a separate table") +near(Studio.page().controls[2].x, Studio.page().controls[1].x + 0.04, 1e-6, + "the copy is offset so it is visible") + +Studio.deleteControl() +eq(#Studio.page().controls, 1, "delete removes it") +Studio.deleteControl() +eq(#Studio.page().controls, 0, "delete works down to empty") +eq(Studio.selected, nil, "and clears the selection") + +-- ------------------------------------------------------- canvas presets + +local ids = {} +for _, c in ipairs(Studio.CANVASES) do + if c.live then + check(c.id == "this_device", "the live preset is this screen") + else + check(c.w > 0 and c.h > 0, c.id .. " has real pixel dimensions") + end + check(not ids[c.id], c.id .. " appears once") + ids[c.id] = true +end +check(ids.phone_portrait, "a phone portrait preset exists") +check(ids.this_device, "a this-screen live preset exists") +check(ids.desktop_1080, "a desktop preset exists") +check(ids.ultrawide, "an ultrawide preset exists") +check(ids.sgb_border, "a Super Game Boy border preset exists") + +session() +local sgbIndex +for i, c in ipairs(Studio.CANVASES) do + if c.id == "sgb_border" then sgbIndex = i end +end +Studio.setCanvas(sgbIndex) +local canvas = Studio.canvas() +eq(canvas.w, 256, "SGB canvas is 256 wide") +eq(canvas.h, 224, "SGB canvas is 224 tall") +local vp = Studio.page().viewport +near(vp.x * 256, 48, 1e-6, "SGB screen sits 48px from the left") +near(vp.y * 224, 40, 1e-6, "SGB screen sits 40px from the top") +near(vp.w * 256, 160, 1e-6, "SGB screen is 160px wide") +near(vp.h * 224, 144, 1e-6, "SGB screen is 144px tall") + +-- the locked preset refuses to let the cutout be moved or removed +Studio.toggleViewport() +check(Studio.page().viewport ~= nil, "the SGB cutout cannot be toggled off") + +-- presets wrap rather than running off the end of the list +Studio.setCanvas(#Studio.CANVASES + 1) +eq(Studio.canvasIndex, 1, "canvas selection wraps") + +-- the preview must lay the page out the way the game will, so a preset never +-- overwrites the aspect the bezel art (or a cfg) already fixed +session() +local art = Studio.page() +art.aspect, art.aspectFromImage = 0.5625, true +Studio.setCanvas(1) +near(art.aspect, 0.5625, 1e-9, "bezel art keeps its own aspect on a preset switch") +local deckW, deckH = 900, 1700 +local _, dby, _, dbh = TouchSkin.pageBox(art, deckW, deckH) +check(dby > 0 and math.abs(dby + dbh - deckH) < 1e-6, + "so a preset taller than the art pins the deck low, as gameplay does") + +session() +local plain = Studio.page() +plain.aspect, plain.aspectFromImage, plain.aspectFromCfg = nil, nil, nil +Studio.setCanvas(1) +near(plain.aspect, Studio.canvas().w / Studio.canvas().h, 1e-9, + "a page with no art of its own still takes the preset's aspect") + +Studio.setCanvas(1) +Studio.viewZoom = 1 +local cx, cy, cw, ch = Studio.canvasRect(0, 0, 800, 600) +near(cw / ch, Studio.canvas().w / Studio.canvas().h, 1e-6, + "the mock device keeps its aspect") +check(cw <= 800 + 1e-6 and ch <= 600 + 1e-6, "and fits inside the workspace") +near(cx + cw / 2, 400, 1e-6, "centred horizontally") +near(cy + ch / 2, 300, 1e-6, "centred vertically") + +eq(Studio.zoomOut(), 0.75, "zoom out steps to 75%") +local zx, zy, zw, zh = Studio.canvasRect(0, 0, 800, 600) +near(zw, cw * 0.75, 1e-6, "zoom out shrinks the mock device") +near(zh, ch * 0.75, 1e-6, "on both axes") +near(zx + zw / 2, 400, 1e-6, "and stays centred") +near(zw / zh, cw / ch, 1e-6, "keeping the same aspect") +check(zw < cw, "so there is margin around the device for a larger screen hole") +eq(Studio.zoomIn(), 1, "zoom in restores fit") +eq(Studio.zoomOut(), 0.75) +eq(Studio.zoomOut(), 0.5) +eq(Studio.zoomOut(), 0.35) +eq(Studio.zoomOut(), 0.35, "zoom out stops at the smallest level") +eq(Studio.zoomFit(), 1, "fit snaps back to contain") + +local oldDims = love.graphics.getDimensions +love.graphics.getDimensions = function() return 1170, 2532 end +session() +local live = Studio.detectDeviceCanvas() +eq(live.id, "this_device", "detect this screen selects the live canvas") +eq(live.w, 1170, "at the window width") +eq(live.h, 2532, "and the window height") +eq(Studio.canvas().w / Studio.canvas().h, 1170 / 2532, + "so the mock device is this screen's form factor") +Studio.matchOrient = true +Studio.syncCanvasToPage() +eq(Studio.canvas().id, "this_device", + "Match canvas leaves the live screen selected") +love.graphics.getDimensions = oldDims + +-- ----------------------------------------------------------- touch bridge + +session() +Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 } +Studio.touchpressed("finger", 50, 50) +eq(Studio.touchId, "finger", "the first finger owns the Studio gesture") +check(Studio.pointerDown, "a touch is tracked as a held editor pointer") +Studio.touchmoved("finger", 60, 50) +Studio.touchreleased("finger", 60, 50) +eq(Studio.touchId, nil, "lifting clears the captured finger") +check(not Studio.pointerDown, "and releases the editor pointer") + +-- ---------------------------------------------------------------- drag + +session() +Studio.addControl() +ctl = Studio.selectedControl() +local r = { x = 0, y = 0, w = 1000, h = 1000 } +local startX, startY = ctl.x, ctl.y + +Studio.drag = { kind = "control-move", mx = 500, my = 500, + bx = 420, by = 455, bw = 160, bh = 90 } +Studio.updateDrag(600, 500, r) +near(ctl.x, startX + 0.1, 1e-6, "dragging right moves the control right") +near(ctl.y, startY, 1e-6, "and leaves y alone") + +Studio.drag = { kind = "control-resize", handle = "se", mx = 500, my = 500, + bx = 400, by = 400, bw = 100, bh = 100 } +Studio.updateDrag(600, 600, r) +near(ctl.rangeX * 2 * r.w, 200, 1e-6, "the se handle widens the control") +near(ctl.rangeY * 2 * r.h, 200, 1e-6, "and heightens it") + +-- the viewport keeps the Game Boy's 10:9 while the lock is on +Studio.aspectLock = true +Studio.selected = nil +Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0, + bx = 0, by = 0, bw = 400, bh = 400 } +Studio.updateDrag(100, 0, r) +local v = Studio.page().viewport +near(v.w / v.h, 160 / 144, 1e-6, "10:9 lock holds the screen aspect") + +Studio.aspectLock = false +Studio.drag = { kind = "viewport-resize", handle = "se", mx = 0, my = 0, + bx = 0, by = 0, bw = 400, bh = 400 } +Studio.updateDrag(100, 200, r) +v = Studio.page().viewport +near(v.w * r.w, 500, 1e-6, "unlocked, width follows the pointer") +near(v.h * r.h, 600, 1e-6, "and height is free") + +Studio.drag = { kind = "viewport-move", mx = 0, my = 0, + bx = 0, by = 0, bw = 100, bh = 100 } +Studio.updateDrag(-250, 1100, r) +v = Studio.page().viewport +check(v.x < 0, "a screen anchor can move past the left canvas edge") +check(v.y > 1, "a screen anchor can move past the bottom canvas edge") + +-- controls never escape the canvas +Studio.selected = 1 +Studio.drag = { kind = "control-move", mx = 0, my = 0, + bx = 0, by = 0, bw = 100, bh = 100 } +Studio.updateDrag(-100000, -100000, r) +check(ctl.x >= 0 and ctl.y >= 0, "a control cannot be dragged off the top-left") +Studio.updateDrag(100000, 100000, r) +check(ctl.x <= 1 and ctl.y <= 1, "or off the bottom-right") + +-- ------------------------------------------------------- pixel fields + +session() +Studio.addControl() +ctl = Studio.selectedControl() +canvas = Studio.canvas() +eq(canvas.w, 1080, "phone portrait is 1080 wide") + +Studio.commitField("numW", "216") +near(ctl.rangeX * 2 * canvas.w, 216, 1e-6, "W is typed in canvas pixels") +Studio.commitField("numX", "100") +near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X is the left edge in pixels") +Studio.commitField("numH", "180") +near(ctl.rangeY * 2 * canvas.h, 180, 1e-6, "H is typed in canvas pixels") +Studio.commitField("numY", "640") +near((ctl.y - ctl.rangeY) * canvas.h, 640, 1e-4, "Y is the top edge in pixels") +-- X must not have drifted when the later fields were set +near((ctl.x - ctl.rangeX) * canvas.w, 100, 1e-4, "X survives edits to the others") + +local keptX = ctl.x +Studio.commitField("numX", "not a number") +near(ctl.x, keptX, 1e-9, "garbage in a field is ignored") + +-- ---------------------------------------------------------------- pages + +session() +Studio.addPage() +eq(#Studio.skin.pages, 2, "addPage appends a page") +eq(Studio.pageIndex, 2, "and switches to it") +eq(Studio.page().name, "page2", "the new page is named in sequence") +Studio.addControl() +eq(#Studio.skin.pages[2].controls, 1, "controls land on the active page") +eq(#Studio.skin.pages[1].controls, 0, "and not on the other one") + +-- page orientation lock follows the canvas when Match canvas is on (#1503) +session() +Studio.canvasIndex = 1 +Studio.matchOrient = true +eq(Studio.cyclePageOrient(1), "portrait", "cycle starts at portrait from unlocked") +eq(Studio.page().orient, "portrait", "and stores the lock on the page") +eq(Studio.page().name, "portrait", "a generic page is renamed so play can auto-rotate") +eq(Studio.canvas().id, "phone_portrait", "and the canvas stays portrait") + +Studio.addPage() +eq(Studio.cyclePageOrient(1), "portrait", "the new page starts unlocked, first lock is portrait") +Studio.cyclePageOrient(1) +eq(Studio.page().orient, "landscape", "second cycle is landscape") +eq(Studio.canvas().id, "phone_landscape", "Match canvas flips the mock device with the page") + +Studio.pageIndex = 1 +Studio.syncCanvasToPage() +eq(Studio.canvas().id, "phone_portrait", "switching back to the portrait page restores portrait canvas") + +Studio.setCanvas(2) +eq(Studio.pageIndex, 2, "picking a landscape canvas selects the landscape page") + +Studio.matchOrient = false +Studio.pageIndex = 1 +Studio.setCanvas(2) +eq(Studio.pageIndex, 1, "Match canvas off leaves the page when the device changes") +eq(Studio.canvas().id, "phone_landscape", "and still honours the canvas click") + +-- a RetroArch overlay that already auto-rotates locks itself on open (#1503) +session() +Studio.matchOrient = false +Studio.canvasIndex = 2 +Studio.skin = assert(TouchSkin.parse([[ +overlays = 2 +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_descs = 1 +overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05" +overlay1_name = "landscape" +overlay1_full_screen = true +overlay1_descs = 1 +overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05" +]])) +Studio.pageIndex = 1 +check(Studio.applyImportedOrient(), "import of a portrait/landscape pair is automatic") +check(Studio.matchOrient, "and turns Match canvas on") +eq(Studio.page().name, "landscape", "keeping the landscape canvas already on screen") +eq(Studio.page().orient, "landscape", "with the page already locked") + +-- --------------------------------------------------------------- clone + +-- An owned memory-only bundled fixture, rather than excluded reference art. +local seed=TouchSkin.newSkin('owned_clone') +seed.pages[1].controls={TouchSkin.newControl('a',.7,.8,.1,.1,'rect')} +love.filesystem.write('assets/skins/owned_clone/skin.lua',TouchSkin.serialize(seed)) +local source = TouchSkin.load("assets/skins/owned_clone", "owned_clone") +check(source ~= nil, "bundled skin loads for cloning") +if source then + local copy = TouchSkin.clone(source) + eq(#copy.pages, #source.pages, "clone keeps every page") + eq(#copy.pages[1].controls, #source.pages[1].controls, "and every control") + check(copy.pages[1] ~= source.pages[1], "pages are fresh tables") + check(copy.pages[1].controls[1] ~= source.pages[1].controls[1], + "controls are fresh tables") + eq(copy.pages[1].image, source.pages[1].image, "loaded images are shared, not reloaded") + copy.pages[1].controls[1].x = 0.123 + check(source.pages[1].controls[1].x ~= 0.123, + "editing the clone does not touch the loaded skin") +end + +-- ------------------------------------------------------------ play handoff + +-- Play used to call the host handler straight out of the Kit button, which +-- unloaded the studio inside its own draw pass; the rest of that frame then +-- indexed a nil skin and took the app down. The handoff is deferred to +-- update, and a torn-down studio has to survive a draw either way. +session() +Studio.available, Studio.images = {}, {} +Studio.skinIdField, Studio.onClose = "t", function() end +local fired, gotVersion = 0, nil +Studio.version = "red" +Studio.onPlay = function(v) + fired = fired + 1 + gotVersion = v + Studio.unload() +end + +Studio.play() +check(Studio.pendingPlay, "play queues the handoff") +eq(fired, 0, "play does not hand off during the click") +check(Studio.skin ~= nil, "and leaves the skin alive for the rest of the frame") +check(pcall(Studio.draw), "the frame that queued play still draws") + +Studio.update() +eq(fired, 1, "update performs the handoff") +eq(gotVersion, "red", "and passes the launcher tab through") +eq(Studio.skin, nil, "the studio is unloaded by then") +check(pcall(Studio.draw), "a torn-down studio still survives a draw") + +Studio.update() +eq(fired, 1, "the handoff does not repeat") + +-- unload must clear a queued handoff, or closing then reopening would boot +session() +Studio.onPlay = function() fired = fired + 1 end +Studio.play() +check(Studio.pendingPlay, "queued again") +Studio.unload() +check(not Studio.pendingPlay, "unload drops a queued handoff") +Studio.update() +eq(fired, 1, "closing the studio does not start the game") + +-- the studio is a desktop workspace; the launcher only offers it there +check(type(Studio.available_desktop) == "function", "desktop gate is exported") +check(Studio.available_desktop(), "headless/desktop reports available") + +T.finish("skin_studio") diff --git a/tests/engine/skin_studio_ux.lua b/tests/engine/skin_studio_ux.lua new file mode 100644 index 000000000..832aa150e --- /dev/null +++ b/tests/engine/skin_studio_ux.lua @@ -0,0 +1,387 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local Studio = require("src.ui.SkinStudio") + +local function near(a, b, tol, msg) + check(math.abs(a - b) <= (tol or 1e-6), msg .. " (got " .. tostring(a) .. + ", want " .. tostring(b) .. ")") +end + +local function session() + Studio.skin = TouchSkin.newSkin("t") + Studio.skinIdField = "t" + Studio.pageIndex = 1 + Studio.selected = nil + Studio.canvasIndex = 1 + Studio.viewZoom = 1 + Studio.aspectLock = true + Studio.drag = nil + Studio.dirty = false + Studio.images = {} + Studio.thumbs = {} + Studio.available = {} + Studio.availableMeta = {} + Studio.undoStack, Studio.redoStack = {}, {} + Studio.undoTag, Studio.undoAt = nil, nil + Studio.modal, Studio.confirm = nil, nil + Studio.status, Studio.statusErr = nil, false + Studio.imageTarget = "idle" + return Studio.skin +end + +session() +check(not Studio.canUndo(), "a fresh session has nothing to undo") +Studio.addControl() +eq(#Studio.page().controls, 1, "a control was added") +check(Studio.canUndo(), "adding a control is undoable") +Studio.undo() +eq(#Studio.page().controls, 0, "undo takes the control back off") +check(Studio.canRedo(), "and offers a redo") +Studio.redo() +eq(#Studio.page().controls, 1, "redo puts it back") +check(not Studio.canRedo(), "the redo stack is spent") + +Studio.addControl() +check(not Studio.canRedo(), "a fresh edit clears the redo stack") + +session() +Studio.addControl() +local before = Studio.page().controls[1] +Studio.pushUndo() +before.x = 0.9 +Studio.undo() +check(Studio.page().controls[1] ~= before, + "undo restores a copy, not the edited table") +near(Studio.page().controls[1].x, 0.5, 1e-6, "with the pre-edit position") + +session() +for _ = 1, Studio.UNDO_CAP + 10 do Studio.pushUndo() end +eq(#Studio.undoStack, Studio.UNDO_CAP, "the undo stack is capped") + +session() +check(not Studio.undo(), "undo on an empty stack reports nothing to do") +check(not Studio.redo(), "and so does redo") + +local realIsDown = love.keyboard and love.keyboard.isDown +love.keyboard = love.keyboard or {} +local held = {} +love.keyboard.isDown = function(...) + for _, k in ipairs({ ... }) do if held[k] then return true end end + return false +end +session() +Studio.addControl() +Studio.addControl() +held.lctrl = true +Studio.keypressed("z") +eq(#Studio.page().controls, 1, "ctrl+Z undoes one step") +Studio.keypressed("z") +eq(#Studio.page().controls, 0, "and again") +held.lshift = true +Studio.keypressed("z") +eq(#Studio.page().controls, 1, "ctrl+shift+Z redoes instead of undoing further") +held.lshift = nil +Studio.keypressed("y") +eq(#Studio.page().controls, 2, "and ctrl+Y redoes as well") +held.lctrl = nil +if realIsDown then love.keyboard.isDown = realIsDown end + +session() +local ran = 0 +check(Studio.guard("lose it?", function() ran = ran + 1 end), + "a clean skin runs the action straight away") +eq(ran, 1, "and does not prompt") +check(Studio.confirm == nil, "no prompt is left up") + +Studio.dirty = true +check(not Studio.guard("lose it?", function() ran = ran + 1 end), + "a dirty skin defers the action") +eq(ran, 1, "the action has not run yet") +check(Studio.confirm ~= nil, "and a prompt is up") +Studio.confirmNo() +eq(ran, 1, "cancelling drops the action") +check(Studio.confirm == nil, "and closes the prompt") + +Studio.guard("lose it?", function() ran = ran + 1 end) +Studio.confirmYes() +eq(ran, 2, "confirming runs it") +check(Studio.confirm == nil, "and closes the prompt") + +session() +Studio.dirty = true +Studio.openLoadPicker() +check(Studio.confirm ~= nil, "Load prompts over unsaved work") +check(Studio.modal == nil, "and does not open the picker yet") +Studio.confirmYes() +check(Studio.modal ~= nil and Studio.modal.kind == "open", + "confirming opens the picker") +Studio.closeModal() + +eq(Studio.toggleBindPart("nul", "left"), "left", + "a bind starts from decoration") +eq(Studio.toggleBindPart("left", "up"), "left|up", + "directions combine in the canonical order") +eq(Studio.toggleBindPart("up", "left"), "left|up", + "and the order does not depend on which was added first") +eq(Studio.toggleBindPart("left|up", "up"), "left", + "toggling a part off removes it") +eq(Studio.toggleBindPart("left", "left"), "nul", + "removing the last part leaves decoration") +eq(Studio.toggleBindPart("a", "b"), "a|b", "buttons combine as well") +check(Studio.hasBindPart("left|down", "down"), "hasBindPart finds a part") +check(not Studio.hasBindPart("left|down", "up"), "and misses one that is absent") + +session() +check(not Studio.openBindPicker(), "the bind picker needs a selected control") +check(Studio.statusErr, "and says so as an error") +Studio.addControl() +check(Studio.openBindPicker(), "with a control selected it opens") +eq(Studio.modal.kind, "bind", "as the bind modal") +Studio.setBindSpec("start") +eq(Studio.selectedControl().spec, "start", "picking a bind writes the spec") +eq(Studio.selectedControl().buttons[1], "start", "and reparses it") +Studio.undo() +eq(Studio.selectedControl().spec, "a", "the bind change is undoable") +Studio.closeModal() + +Studio.toggleSelectedBindPart("b") +eq(Studio.selectedControl().spec, "a|b", "the combine chips build a pipe bind") +eq(#Studio.selectedControl().buttons, 2, "which fires both buttons") + +local specs = {} +for _, group in ipairs(Studio.BIND_GROUPS) do + check(#group.specs > 0, group.title .. " lists at least one bind") + for _, spec in ipairs(group.specs) do specs[spec] = true end +end +check(specs["a"] and specs["start"], "the GB buttons are reachable") +check(specs["overlay_previous"], "overlay_previous is reachable at last") +check(specs["pause_toggle"] and specs["exit_emulator"], + "so are the hotkeys the old cycle could not reach") +check(specs["key:escape"], "and a keyboard bind can be picked") +check(specs["key:-"] and specs["key:="] and specs["key:1"] + and specs["key:5"] and specs["key:f1"] and specs["key:f2"] + and specs["key:f10"], + "desktop hotkeys can be placed on a mobile skin button") +check(specs["nul"], "decoration is still an option") + +session() +Studio.addControl() +Studio.addControl() +Studio.selected = 1 +local first = Studio.page().controls[1] +check(Studio.moveControlOrder(1), "bring forward moves the control up") +eq(Studio.selected, 2, "and follows it with the selection") +check(Studio.page().controls[2] == first, "the control really moved") +check(not Studio.moveControlOrder(1), "the front control cannot go further") +check(Studio.moveControlOrder(-1), "send back moves it down again") +eq(Studio.selected, 1, "selection follows back") +check(not Studio.moveControlOrder(-1), "and the back control stays put") + +session() +Studio.addControl() +local ctl = Studio.selectedControl() +local canvas = Studio.canvas() +local startX, startY = ctl.x, ctl.y +Studio.nudge(1, 0) +near(ctl.x, startX + 1 / canvas.w, 1e-9, "an arrow moves one canvas pixel") +Studio.nudge(0, 1, true) +near(ctl.y, startY + 10 / canvas.h, 1e-9, "shift moves ten") +Studio.undo() +near(Studio.selectedControl().x, startX, 1e-9, "nudging is undoable") +Studio.selected = nil +check(not Studio.nudge(1, 0), "nothing selected, nothing nudged") + +check(Studio.NUDGES.up[2] == -1 and Studio.NUDGES.down[2] == 1, + "up is negative y on the canvas") +check(Studio.NUDGES.left[1] == -1 and Studio.NUDGES.right[1] == 1, + "and left is negative x") + +local off, line = Studio.snapOffset({ 100, 150, 200 }, { 152, 400 }, 4) +near(off, 2, 1e-9, "an edge within tolerance snaps to the guide") +near(line, 152, 1e-9, "and reports the line it snapped to") +off, line = Studio.snapOffset({ 100 }, { 400 }, 4) +eq(off, 0, "a line out of range does not move anything") +eq(line, nil, "and reports no guide") +off = Studio.snapOffset({ 100, 200 }, { 203, 101 }, 4) +near(off, 1, 1e-9, "the nearest candidate wins") + +session() +Studio.addControl() +local r = { x = 0, y = 0, w = 1000, h = 1000 } +local xs, ys = Studio.snapLines(Studio.page(), r, nil) +check(#xs >= 6 and #ys >= 6, + "snap lines cover the page box and every other control") +local skipped = select(1, Studio.snapLines(Studio.page(), r, 1)) +eq(#skipped, 3, "the dragged control is not a guide for itself") + +session() +Studio.addControl() +Studio.page().controls[1].x = 0.25 +Studio.addControl() +Studio.selected = 2 +local moving = Studio.selectedControl() +moving.x = 0.6 +local target = Studio.page().controls[1] +local bx, by, bw, bh = 0, 0, 0, 0 +local cx, cy, hw, hh = TouchSkin.controlGeometry(Studio.page(), moving, + r.w, r.h, r.x, r.y) +bx, by, bw, bh = cx - hw, cy - hh, hw * 2, hh * 2 +local tcx = select(1, TouchSkin.controlGeometry(Studio.page(), target, + r.w, r.h, r.x, r.y)) +Studio.drag = { kind = "control-move", mx = 0, my = 0, + bx = bx, by = by, bw = bw, bh = bh } +Studio.updateDrag((tcx - cx) + 3, 0, r) +local cx2 = select(1, TouchSkin.controlGeometry(Studio.page(), moving, + r.w, r.h, r.x, r.y)) +near(cx2, tcx, 1e-6, "a near miss snaps onto the other control's centre") +check(Studio.guides ~= nil and Studio.guides.x ~= nil, + "and a guide line is recorded for the canvas to draw") +Studio.drag = nil + +session() +Studio.addPage() +Studio.addPage() +eq(#Studio.skin.pages, 3, "three pages") +check(Studio.setPage(1), "setPage jumps to a page by index") +eq(Studio.pageIndex, 1, "and lands there") +check(not Studio.setPage(9), "an index past the end is refused") +Studio.nextPage() +eq(Studio.pageIndex, 2, "next page still cycles") +local name, detail = Studio.pageLabel(2) +eq(name, "page2", "the page list shows the page name") +check(detail:find("controls", 1, true) ~= nil, "and what is on it") + +check(Studio.renamePage("landscape"), "a page can be renamed") +eq(Studio.page().name, "landscape", "and keeps the new name") +check(not Studio.renamePage(" "), "an empty name is refused") +Studio.undo() +eq(Studio.page().name, "page2", "renaming is undoable") + +Studio.pageIndex = 2 +check(Studio.deletePage(2), "a page can be deleted") +eq(#Studio.skin.pages, 2, "and the skin loses it") +Studio.deletePage(1) +check(not Studio.deletePage(1), "the last page cannot be deleted") +check(Studio.statusErr, "and the studio says why") + +session() +check(not Studio.openImagePicker("idle"), "art needs a selected control") +Studio.addControl() +check(Studio.openImagePicker("idle"), "with one selected the grid opens") +eq(Studio.modal.kind, "image", "as the image modal") +eq(Studio.imageTarget, "idle", "aimed at the idle art") +check(Studio.openImagePicker("bezel"), "the bezel needs no selection") +eq(Studio.currentImagePath(), nil, "a new page has no bezel yet") +Studio.imageTarget = "idle" +Studio.selectedControl().imagePath = "img/a.png" +eq(Studio.currentImagePath(), "img/a.png", "the picker marks the current art") +Studio.chooseImage(nil) +eq(Studio.selectedControl().imagePath, nil, "picking (none) clears the art") +eq(Studio.modal, nil, "and closes the picker") +Studio.undo() +eq(Studio.selectedControl().imagePath, "img/a.png", "clearing art is undoable") + +session() +Studio.setStatus("boom", true) +check(Studio.statusErr, "an error status is flagged") +Studio.addControl() +eq(Studio.status, "boom", "a later edit does not wipe the error off the footer") +Studio.setStatus("fine") +Studio.addControl() +eq(Studio.status, nil, "an ordinary status still clears on the next edit") +Studio.setStatus("boom", true) +Studio.statusAt = -1000 +Studio.expireStatus() +eq(Studio.status, nil, "and an error clears itself after a few seconds") + +local ids = {} +for _, spec in ipairs(Studio.EXPORTS) do ids[spec.id] = spec.label end +check(ids.native and ids.retroarch and ids.delta, + "the export menu offers all three formats") + +session() +Studio.skinIdField = "uxtest" +local nativePath = Studio.exportAs("native") +check(nativePath ~= nil and nativePath:match("%.zip$") ~= nil, + "the native export writes a .zip") +local raPath = Studio.exportAs("retroarch") +check(raPath ~= nil and raPath:match("%.zip$") ~= nil, + "the RetroArch export writes a .zip") +local deltaPath = Studio.exportAs("delta") +check(deltaPath ~= nil and deltaPath:match("%.deltaskin$") ~= nil, + "the Delta export writes a .deltaskin") +check(love.filesystem.read(deltaPath) ~= nil, "and the archive is on disk") +eq(Studio.lastExport, deltaPath, "the last export is remembered for Show file") + +eq(Studio.skinFormat({ format = "retroarch" }), "RetroArch", + "a format badge reads in words") +eq(Studio.skinFormat({ format = "delta" }), "Delta", "Delta included") + +love.graphics.getDimensions = love.graphics.getDimensions + or function() return 1280, 720 end +session() +Studio.addControl() +for _, kind in ipairs({ "bind", "image", "open", "page", "export", "screen" }) do + Studio.openModal(kind) + check(pcall(Studio.draw), "the studio draws with the " .. kind .. " modal up") +end +Studio.closeModal() +check(Studio.openScreenMenu(), "Screen opens the canvas/screen modal") +eq(Studio.modal.kind, "screen", "as the screen modal") +Studio.closeModal() + +-- chrome stays inside the platform safe area (notch / home indicator) +local Kit = require("src.ui.kit.Kit") +local oldDims = love.graphics.getDimensions +local oldSafe = love.window.getSafeArea +love.graphics.getDimensions = function() return 400, 800 end +love.window.getSafeArea = function() return 12, 48, 376, 704 end +session() +Studio.mode = "library" +Studio.available = {} +Kit.audit = {} +check(pcall(Studio.draw), "My Skins draws inside an inset safe area") +local function insideSafe(rects, ox, oy, sw, sh, where) + for _, r in ipairs(rects or {}) do + check(r.x >= ox - 0.5, + where .. " control '" .. r.label .. "' is right of the left inset") + check(r.y >= oy - 0.5, + where .. " control '" .. r.label .. "' is below the notch") + check(r.x + r.w <= ox + sw + 0.5, + where .. " control '" .. r.label .. "' stays left of the right inset") + check(r.y + r.h <= oy + sh + 0.5, + where .. " control '" .. r.label .. "' stays above the home indicator") + end +end +insideSafe(Kit.audit, 12, 48, 376, 704, "library") +Studio.mode = "editor" +Studio.addControl() +Kit.audit = {} +check(pcall(Studio.draw), "the editor draws inside an inset safe area") +insideSafe(Kit.audit, 12, 48, 376, 704, "editor") +check(Studio.lastCanvas ~= nil, "the editor still has a mock device") +check(Studio.lastCanvas.y >= 48 - 0.5, "the mock device is below the notch") +check(Studio.lastCanvas.y + Studio.lastCanvas.h <= 48 + 704 + 0.5, + "and above the home indicator") +Kit.audit = nil +love.graphics.getDimensions = oldDims +love.window.getSafeArea = oldSafe +Studio.closeModal() +Studio.ask("sure?", function() end) +check(pcall(Studio.draw), "and with the confirm prompt up") +Studio.confirmNo() + +Studio.openModal("bind") +Studio.lastCanvas = { x = 0, y = 0, w = 100, h = 100 } +Studio.mousepressed(50, 50, 1) +eq(Studio.drag, nil, "a click under an open modal does not grab a control") +Studio.closeModal() + +T.finish("skin_studio_ux") diff --git a/tests/engine/soft_reset_bug563.lua b/tests/engine/soft_reset_bug563.lua index 65fb22445..d1fa395c7 100644 --- a/tests/engine/soft_reset_bug563.lua +++ b/tests/engine/soft_reset_bug563.lua @@ -106,18 +106,35 @@ 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, } function game:returnToTitle() self.returned = self.returned + 1 end - return game + return setmetatable(game, { __index = Game }) 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..6845cad9e 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") @@ -34,7 +35,9 @@ do return nextFn(game, dt) end, 0, "tool_fixture") - require("src.core.Game").step(fake, 1 / 60) + local Game = require("src.core.Game") + setmetatable(fake, { __index = Game }) + Game.step(fake, 1 / 60) T.eq(table.concat(order, ","), "hook,input,world", "input.step runs before input edges are promoted and before gameplay") hooks:removeOwner("tool_fixture") @@ -71,10 +74,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 +115,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 @@ -133,7 +136,9 @@ do return nextFn(game, viewport) end, 0, "tool_fixture") - require("src.core.Game").draw(fake) + local Game = require("src.core.Game") + setmetatable(fake, { __index = Game }) + Game.draw(fake) T.eq(table.concat(order, ","), "states,present,hud,touch", "render.hud draws after frame composition and before touch controls") hooks:removeOwner("tool_fixture") diff --git a/tests/engine/touch_controls_pad_cursor_test.lua b/tests/engine/touch_controls_pad_cursor_test.lua new file mode 100644 index 000000000..4a0b47127 --- /dev/null +++ b/tests/engine/touch_controls_pad_cursor_test.lua @@ -0,0 +1,71 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- Touch-controls editor pad / Joy-Con cursor (same class as save-editor soft-lock). +-- Opening Touch Controls parked the launcher cursor and dropped all gamepad +-- input; touch still worked. PadCursor + main.lua forwarding restore the +-- virtual pointer. +-- luajit tests/engine/touch_controls_pad_cursor_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local PadCursor = require("src.ui.PadCursor") +local GamepadMap = require("src.core.GamepadMap") + +PadCursor.reset() +PadCursor.gamepadaxis(nil, "leftx", 1) +PadCursor.update(0.05) +check(PadCursor.isActive(), "left stick activates pad cursor") +local x0 = select(1, PadCursor.pointer()) +PadCursor.update(0.05) +check(select(1, PadCursor.pointer()) > x0, "left stick moves cursor right") + +eq(PadCursor.gamepadpressed(nil, "a"), "a", "gamepad a → click") +eq(PadCursor.gamepadpressed(nil, "b"), "b", "gamepad b → close") +eq(PadCursor.gamepadpressed(nil, "leftshoulder"), "tab_prev", "L → size down") +eq(PadCursor.gamepadpressed(nil, "rightshoulder"), "tab_next", "R → size up") + +GamepadMap._setForceNXForTests(true) +eq(PadCursor.gamepadpressed(nil, "a"), "b", "NX SDL a (south) → close (GB b)") +eq(PadCursor.gamepadpressed(nil, "b"), "a", "NX SDL b (east) → click (GB a)") +GamepadMap._setForceNXForTests(false) + +PadCursor.yieldToPointer() +check(not PadCursor.isActive(), "yieldToPointer drops the virtual cursor") + +-- Native default editor keeps its actual pad implementation; it need not +-- replace the separately working save-editor cursor to expose these controls. +local Touch=require('src.core.TouchControls') +local Editor=require('src.ui.TouchControlsEditor') +local Save=require('src.core.SaveData') +local image=love.graphics.newImage +love.graphics.newImage=function(...) + local result=image(...) + function result:setFilter(min,mag)self.filter={min,mag}end + return result +end +love.system=love.system or {};love.system.getOS=function()return 'Linux'end +Editor.load();Editor.draw() +local z=Touch:layout().hotbar +PadCursor.reset() +-- D-pad motion positions the real virtual cursor; a touch must yield it. +Editor.gamepadaxis(nil,'leftx',1);Editor.update(.05) +check(PadCursor.isActive(),'native editor forwards actual gamepad axis') +Editor.touchpressed('finger',z.cx,z.cy) +check(not PadCursor.isActive(),'real editor touch yields virtual cursor') +eq(Editor.drag and Editor.drag.name,'hotbar','native editor captures toggle drag') +Editor.touchreleased('finger') +Editor.gamepadpressed(nil,'rightshoulder') +local scale=Touch:currentBucket().scale +check(scale>1,'native shoulder action changes actual layout scale') +Editor.gamepadpressed(nil,'leftshoulder') +eq(Touch:currentBucket().scale,1,'reverse shoulder restores original scale') +Editor.gamepadaxis(nil,'leftx',1);Editor.update(.05) +Editor.focus(false) +check(not PadCursor.isActive(),'focus loss releases actual virtual cursor') +eq(Editor.drag,nil,'focus loss cancels native editor drag') +Editor.unload() +T.finish('native default touch editor and shared pad cursor') diff --git a/tests/engine/touch_haptics.lua b/tests/engine/touch_haptics.lua new file mode 100644 index 000000000..c63c7a296 --- /dev/null +++ b/tests/engine/touch_haptics.lua @@ -0,0 +1,89 @@ +-- Native haptic preferences and input edges. No physical vibrator claim. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Touch=require('src.core.TouchControls') +local Skin=require('src.core.TouchSkin') +local Input=require('src.core.Input') +local Save=require('src.core.SaveData') +local Version=require('src.core.GameVersion') +T.eq(Touch.normalizeHaptics(nil),'light') +T.eq(Touch.normalizeHaptics('physical'),'light') +T.eq(Touch.normalizeHaptics('medium'),'normal') +T.eq(Touch.normalizeHaptics('heavy'),'strong') +T.eq(Touch.normalizeHaptics('bad'),'light') +T.eq(Touch.cycleHaptics('strong',1),'off') +T.eq(Touch.cycleHaptics('off',-1),'strong') +for _,level in ipairs{'off','light','normal','strong'}do + T.eq(Touch.hapticLabel(level),level:upper(),'native haptic label') +end +love.system=love.system or {} +local oldOS,oldVibrate=love.system.getOS,love.system.vibrate +love.system.getOS=oldOS or function() return "Linux" end +local pulses={} +love.system.vibrate=function(seconds)pulses[#pulses+1]=seconds end +T.eq(Touch.buzz('off'),false,'OFF never pulses') +for _,row in ipairs{{'light',.012},{'normal',.025},{'strong',.045}}do + T.check(Touch.buzz(row[1]),'supported level requests a pulse') + T.eq(pulses[#pulses],row[2],'duration follows reference preset') +end +love.system.vibrate=nil +T.eq(Touch.buzz('light'),false,'runtime without vibrator remains usable') +love.system.vibrate=function(seconds)pulses[#pulses+1]=seconds end +Version.set('crystal');Input:init();Touch:init();Touch.active=true +Touch:applyOptions({haptics='normal'}) +local skin=Skin.newSkin('haptic-memory') +skin.pages[1].controls={Skin.newControl('a',.8,.8,.2,.2,'rect'), + Skin.newControl('key:f5',.2,.8,.2,.2,'rect')} +Skin.setActive(skin) +local w,h=love.graphics.getDimensions() +local ax,ay=Skin.controlGeometry(Skin.page(),Skin.page().controls[1],w,h) +local kx,ky=Skin.controlGeometry(Skin.page(),Skin.page().controls[2],w,h) +local n=#pulses +T.check(Touch:touchpressed('one',ax,ay)) +T.eq(#pulses,n+1,'first native button owner pulses') +Touch:touchpressed('two',ax,ay) +T.eq(#pulses,n+1,'second owner of held button does not pulse') +Touch:touchmoved('one',ax,ay) +T.eq(#pulses,n+1,'stationary held contact does not pulse') +Touch:touchreleased('one',ax,ay) +T.check(Input:isDown('a'),'first release retains other owner') +Touch:touchreleased('two',ax,ay) +T.eq(#pulses,n+1,'release does not pulse') +T.check(not Input:isDown('a')) +Touch:touchpressed('key-one',kx,ky) +T.eq(#pulses,n+2,'key-only control pulses on its first owner') +Touch:touchpressed('key-two',kx,ky) +T.eq(#pulses,n+2,'shared key owner does not retrigger vibration') +Touch:reset() +T.eq(#pulses,n+2,'focus reset releases holds without a new pulse') +Touch.haptics='off';Skin.setActive(skin) +Touch:touchpressed('off',ax,ay);Touch:touchreleased('off',ax,ay) +T.eq(#pulses,n+2,'OFF preference applies to actual skin input') +Skin.setActive(nil) +T.eq(Touch:config().haptics,nil,'layout snapshots do not relocate the global preference') +T.eq(Save.defaultOptions().haptics,'light','new native options retain the reference default') + +-- Normal OPTIONS input, not direct calls to the row implementation. +love.system.getOS=function()return 'Android'end +local Stack=require('src.core.StateStack') +local game={data=T.fixtures.fresh(),save=Save.newGame(),writes=0,input={pressed={}}} +game.stack=setmetatable({},{__index=Stack});game.stack:init() +game.input.wasPressed=function(self,key)return self.pressed[key]end +game.writeOptions=function(self)self.writes=self.writes+1;self.persisted=Save.encode(self.save)end +local menu=require('src.ui.OptionsMenu').new(game);game.stack:push(menu) +local page +for index,row in ipairs(menu.rows) do + if row.id=='haptics' then menu.index=index;page=menu;break end +end +T.check(page~=nil,'mobile native options offer haptic intensity') +if page then + game.input.pressed={right=true};page:update(1/60);game.input.pressed={} + T.eq(game.save.options.haptics,'normal','normal RIGHT changes haptic preference') + T.eq(Touch.haptics,'normal','native controller applies the chosen intensity') + T.eq(game.writes,1,'normal input persists exactly one change') + local saved=assert(Save.decode(game.persisted)) + T.eq(saved.options.haptics,'normal','native save codec retains haptics') +end +love.system.getOS, love.system.vibrate=oldOS,oldVibrate +Touch:reset();Skin.setActive(nil) +T.finish('native haptics preferences, ownership edges and normal options input') diff --git a/tests/engine/touch_hotbar_1592.lua b/tests/engine/touch_hotbar_1592.lua new file mode 100644 index 000000000..681cb7c96 --- /dev/null +++ b/tests/engine/touch_hotbar_1592.lua @@ -0,0 +1,155 @@ +-- From Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") +-- Native headless Image fixtures expose dimensions; the real runtime also +-- supports filters. Record that call locally without changing other suites. +local newImage = love.graphics.newImage +love.graphics.newImage = function(...) + local image = newImage(...) + function image:setFilter(min, mag) self.filter = {min, mag} end + return image +end + +local TC = require("src.core.TouchControls") + +love.window = love.window or {} +local function setRect(w, h) + love.window.getSafeArea = function() return 0, 0, w, h end + love.graphics.getDimensions = function() return w, h end + TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil +end + + +local inList = false +for _, name in ipairs(TC.CONTROLS) do + if name == "hotbar" then inList = true end +end +check(inList, "hotbar is one of the pad's controls, so the editor can drag it") + +for _, r in ipairs({ { 380, 720 }, { 800, 400 } }) do + local L = TC.defaultLayout(r[1], r[2]) + local z = L.hotbar + check(z ~= nil, "defaultLayout places the toggle at " .. r[1] .. "x" .. r[2]) + check(z.cx - z.w / 2 >= 0 and z.cx + z.w / 2 <= r[1], + "the toggle sits inside the rect horizontally") + check(z.cy - z.w / 2 >= 0 and z.cy + z.w / 2 <= r[2], + "and vertically") + check(z.cy < L.dpad.cy, "it is up top, clear of the thumbs") + check(z.cx > L.dpad.cx, "and on the right") +end + + +TC:init() +TC.active = true +TC:ensureImages() +TC.img = TC.img or {} +setRect(380, 720) +TC:applyOptions({ touchControls = { enabled = true } }) +setRect(380, 720) + +local L = TC:layout() +eq(TC:hitTest(L.hotbar.cx, L.hotbar.cy), "hotbar", + "the toggle is what a press at its centre finds") +eq(TC:hitTest(L.a.cx, L.a.cy), "a", "the face buttons are unchanged") + + +check(TC:hotbarStrip() ~= nil, "a strip is laid out") +check(TC:hotbarCellAt(TC:hotbarStrip()[1].x + 1, TC:hotbarStrip()[1].y + 1) == nil, + "but nothing hits while it is folded away") + +check(TC:touchpressed("t1", L.hotbar.cx, L.hotbar.cy), + "the toggle captures its press") +check(TC.hotbarOpen, "and opens the strip") +TC:touchreleased("t1", L.hotbar.cx, L.hotbar.cy) + +local cells = TC:hotbarStrip() +eq(#cells, 6, "six host keys") +local _, _, sw, sh = require("src.core.SafeArea").windowRect() +for _, cell in ipairs(cells) do + check(cell.x >= 0 and cell.x + cell.w <= sw, cell.label .. " fits across") + check(cell.y >= 0 and cell.y + cell.h <= sh, cell.label .. " fits down") +end + + +local keys = {} +local origDown, origUp = love.keypressed, love.keyreleased +love.keypressed = function(key) keys[#keys + 1] = "+" .. key end +love.keyreleased = function(key) keys[#keys + 1] = "-" .. key end + +local save = cells[1] +eq(save.label, "SAVE", "the first cell is quicksave") +local cx, cy = save.x + save.w / 2, save.y + save.h / 2 +local hit = TC:hotbarCellAt(cx, cy) +eq(hit and hit.ctl, save.ctl, "the open strip hit-tests its cells") +check(TC:touchpressed("t2", cx, cy), "a cell captures its press (#807)") +eq(table.concat(keys, " "), "+f1", "the press synthesizes f1 once") +TC:touchreleased("t2", cx, cy) +eq(table.concat(keys, " "), "+f1 -f1", "and the lift releases it once") + +local underA = TC:layout().a +check(TC:hitTest(underA.cx, underA.cy) == "a", "fixture: A is still A") + + +keys = {} +check(TC:touchpressed("t3", cx, cy), "second press lands") +eq(table.concat(keys, " "), "+f1", "held") +TC:reset() +eq(table.concat(keys, " "), "+f1 -f1", "reset releases the synthesized key") +check(not TC.hotbarOpen, "and folds the strip away") + +love.keypressed, love.keyreleased = origDown, origUp + + +TC:applyOptions({ touchControls = { enabled = true }, hotbar = false }) +setRect(380, 720) +L = TC:layout() +check(not TC:hotbarShown(), "options.hotbar = false hides the toggle") +eq(TC:hitTest(L.hotbar.cx, L.hotbar.cy), nil, "nothing to hit there") +check(TC:touchpressed("t4", L.hotbar.cx, L.hotbar.cy) == nil, + "and the press falls through to the mod pointer seam") + +TC:applyOptions({ touchControls = { enabled = true }, hotbar = true }) +setRect(380, 720) +check(TC:hotbarShown(), "and back on again") + + +L = TC:layout() +TC:setControlCenter("hotbar", L.a.cx, L.a.cy) +L = TC:layout() +eq(TC:hitTest(L.a.cx, L.a.cy), "hotbar", + "a toggle dragged onto A is what the editor picks up there") +TC.hotbarOpen = false +check(TC:touchpressed("t5", L.a.cx, L.a.cy), "and the press goes to it too") +check(TC.hotbarOpen, "so hitTest and touchpressed agree on the overlap") +TC:touchreleased("t5", L.a.cx, L.a.cy) +TC.hotbarOpen = false +TC:clearPositions() +setRect(380, 720) + + +TC.img = { + dpad = love.graphics.newImage("assets/touch/dpad.png"), + a = love.graphics.newImage("assets/touch/a.png"), + b = love.graphics.newImage("assets/touch/b.png"), + start = love.graphics.newImage("assets/touch/start.png"), + select = love.graphics.newImage("assets/touch/select.png"), +} +check(TC:visible(), "fixture: the overlay draws at all") +TC.hotbarOpen = true +check(pcall(function() TC:draw() end), "the open strip draws") +TC.hotbarOpen = false +check(pcall(function() TC:draw() end), "and so does the folded toggle") + + +local TouchSkin = require("src.core.TouchSkin") +TouchSkin.setActive(TouchSkin.newSkin("stub")) +check(not TC:hotbarShown(), "a skin suppresses the toggle") +TouchSkin.setActive(nil) + +TC:init() +T.finish("touch_hotbar_1592") diff --git a/tests/engine/touch_hotbar_native.lua b/tests/engine/touch_hotbar_native.lua new file mode 100644 index 000000000..51134bc36 --- /dev/null +++ b/tests/engine/touch_hotbar_native.lua @@ -0,0 +1,203 @@ +-- Native hotbar actions, persistent world, pointer ownership and editor. +-- Memory-only persistence; no ROM, player profile, OS touch or GPU claim. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local files={} +local fs=T.sdk.memfs(files) +fs.remove=function(path) files[path]=nil;return true end +fs.createDirectory=function()return true end +fs.getSaveDirectory=function()return '/memory-only-hotbar' end +love.filesystem=fs +love.system=love.system or {} +love.system.getOS=function()return 'Linux'end +local image=love.graphics.newImage +love.graphics.newImage=function(...) + local result=image(...) + function result:setFilter(min,mag) self.filter={min,mag} end + return result +end +local W,H,OX,OY,SW,SH=640,480,0,0,640,480 +love.graphics.getDimensions=function()return W,H end +love.graphics.getPixelDimensions=function()return W*2,H*2 end +love.window=love.window or {} +love.window.getSafeArea=function()return OX,OY,SW,SH end +local Version=require('src.core.GameVersion') +local Save=require('src.core.SaveData') +Save.persistenceFs=function()return fs end +Save.resetSlotState() +Version.set('crystal') +local Touch=require('src.core.TouchControls') +local Input=require('src.core.Input') +local Game=require('src.core.Game') +local Stack=require('src.core.StateStack') +local Runtime=require('src.mods.Runtime') +local Hooks=require('src.mods.Hooks') +local hooks=Hooks.new() +Runtime.install(require('src.mods.Events').new(),hooks,{}) +local stack=setmetatable({},{__index=Stack});stack:init() +local g=setmetatable({save=Save.newGame({version='crystal'}),data=T.fixtures.fresh(),stack=stack}, {__index=Game}) +local captures=0 +local world={isOverworld=true,captureSave=function(_,save) + captures=captures+1;save.money=4321 +end} +-- This offered viewport base retains its real world-on-stack owner. +g.overworld=world;stack:push(world) +Input:init();Touch:init();Touch.active=true;Touch:ensureImages() +Touch:applyOptions({touchControls={enabled=true}}) +love.keypressed=function(key)g:keypressed(key)end +love.keyreleased=function(key)Input:keyreleased(key)end +local pointers=0 +local unhook=hooks:wrap('input.pointer',function() + pointers=pointers+1;return true +end,0,'hotbar-owner-fixture') +local function tap(label,id) + Touch.hotbarOpen=true + for _,cell in ipairs(Touch:hotbarStrip())do + if cell.label==label then + local x,y=cell.x+cell.w/2,cell.y+cell.h/2 + g:touchpressed(id or label,x,y) + g:touchreleased(id or label,x,y) + return + end + end + error('missing native cell '..label) +end +T.eq(stack:top(),world,'offered native stack actually owns live world') +tap('SAVE') +T.eq(captures,1,'hotbar F1 reaches actual Game.writeSave and live capture') +local saved=Save.load('crystal') +T.check(saved~=nil,'hotbar writes a decodable native save through memory filesystem') +T.eq(saved and saved.money,4321,'actual saved snapshot contains captured world state') +local loaded +-- World admission is separate from IO; verify actual F2/SaveData.load here. +g.restoreSave=function(_,snapshot)loaded=snapshot end +tap('LOAD') +T.eq(loaded and loaded.money,4321,'hotbar F2 reads actual persisted snapshot') +T.eq(pointers,0,'visible native cells have priority over claiming mod pointer hook') + +local speed=g.save.options.speed +tap('SPEED') +T.neq(g.save.options.speed,speed,'hotbar SPEED reaches the native speed ladder') +T.eq(Save.optionsFor('crystal').speed,g.save.options.speed,'speed persists through native options IO') +for _,block in ipairs{'transitioning','engaging','emote','scriptMoves','runner'}do + speed=g.save.options.speed + world[block]=block=='scriptMoves' and {'move'} or block=='runner' and {isRunning=function()return true end} or true + tap('SPEED') + T.eq(g.save.options.speed,speed,'persistent Gen2 world blocks speed while '..block) + world[block]=nil +end +stack:push({isFixedSpeed=true}) +speed=g.save.options.speed;tap('SPEED') +T.eq(g.save.options.speed,speed,'native fixed-speed overlay blocks hotbar speed') +stack:pop();g.linkSession={} +tap('SPEED');T.eq(g.save.options.speed,speed,'native link session blocks speed') +g.linkSession=nil +local Palette=require('src.render.PaletteFX') +local Tilt=require('src.render.Tilt') +local Zoom=require('src.render.Zoom') +Tilt.reset();Zoom.reset() +tap('COLOR') +T.eq(g.save.options.colors,Palette.mode,'COLOR writes actual native palette mode') +tap('TILT');T.eq(Tilt.level,1,'TILT changes actual native renderer state') +tap('ZOOM');T.eq(Zoom.offset,1,'ZOOM changes actual native zoom state') +local round=Save.optionsFor('crystal') +T.eq(round.tilt,1,'tilt persists through actual options IO') +T.eq(round.zoom,1,'zoom persists through actual options IO') +T.eq(pointers,0,'all six actions retain first refusal over mod pointer consumer') + +-- A synchronous option reapply clears Touch.touches during key dispatch. +-- The host must retain the complete gesture through a newly opened screen. +local moved,released=0,0 +local screen={touchmoved=function()moved=moved+1;return true end, + touchreleased=function()released=released+1;return true end} +g.writeOptions=function(self) + Game.writeOptions(self) + Touch:applyOptions(self.save.options) + self.stack:push(screen) +end +Touch.hotbarOpen=true +local cell=Touch:hotbarStrip()[3] +local x,y=cell.x+cell.w/2,cell.y+cell.h/2 +g:touchpressed('reset-during-press',x,y) +T.eq(Touch.touches['reset-during-press'],nil,'fixture: real option apply resets live pad contacts') +g:touchmoved('reset-during-press',320,240) +g:touchreleased('reset-during-press',320,240) +T.eq(moved,0,'new screen cannot inherit movement from hotbar-owned gesture') +T.eq(released,0,'new screen cannot inherit release from hotbar-owned gesture') +T.eq(pointers,0,'mod cannot inherit remainder of reset pad gesture') +T.eq(g._padPointerIds['reset-during-press'],nil,'release retires host gesture owner') +g.writeOptions=nil;stack:pop() +g._padPointerIds={gone=true,kept=true,mouse=true} +love.touch={getTouches=function()return {'kept'}end} +g:_pollPadPointers() +T.eq(g._padPointerIds.gone,nil,'cancelled native contacts do not leak host owners') +T.check(g._padPointerIds.kept,'poll retains an actual held finger') +T.check(g._padPointerIds.mouse,'OS touch list cannot cancel the mouse owner') +g:touchpressed('gone',320,240) +T.eq(pointers,1,'cancelled id can be used by a new ordinary mod gesture') +g._padPointerIds.reused=true +g:touchpressed('reused',320,240) +T.eq(g._padPointerIds.reused,nil,'new press clears an obsolete id owner') +T.eq(pointers,2,'reused id outside pad reaches actual mod hook') +unhook() + +-- Safe-area geometry, rotations and edited bottom toggle at native scale limits. +for _,size in ipairs{{380,720},{800,400}}do + W,H=size[1],size[2];OX,OY,SW,SH=12,20,W-24,H-32 + for _,scale in ipairs{.6,1,1.6}do + Touch:init();Touch.active=true;Touch:ensureImages() + Touch:applyOptions({touchControls={enabled=true}});Touch:setScale(scale) + local L=Touch:layout() + for _,name in ipairs{'l','r'}do + T.eq(Touch:hitTest(L[name].cx,L[name].cy),name,'native shoulder remains reachable '..name) + end + for _,bottom in ipairs{false,true}do + if bottom then Touch:setControlCenter('hotbar',OX+SW/2,OY+SH-2) end + L=Touch:layout();Touch.hotbarOpen=true + for _,entry in ipairs(Touch:hotbarStrip())do + T.check(entry.x>=OX and entry.x+entry.w<=OX+SW,'cell stays in safe horizontal area') + T.check(entry.y>=OY and entry.y+entry.h<=OY+SH,'cell stays in safe vertical area') + T.check(not (L.hotbar.cx>=entry.x and L.hotbar.cx<=entry.x+entry.w + and L.hotbar.cy>=entry.y and L.hotbar.cy<=entry.y+entry.h), + 'strip cannot cover its own closing toggle') + end + end + end +end + +-- Normal Options input persists OFF/ON, preserving orientation preferences. +W,H,OX,OY,SW,SH=640,480,0,0,640,480 +love.system.getOS=function()return 'Android'end +g.input={pressed={}} +g.input.wasPressed=function(self,key)return self.pressed[key]end +local menu=require('src.ui.OptionsMenu').new(g);stack:push(menu) +local page +for index,row in ipairs(menu.rows)do + if row.id=='hotbar' then menu.index=index;page=menu;break end +end +T.check(page~=nil,'mobile Options exposes real hotbar row') +if page then + g.input.pressed={right=true};page:update(1/60);g.input.pressed={} + T.eq(g.save.options.hotbar,false,'normal Options input turns hotbar off') + T.eq(Save.optionsFor('crystal').hotbar,false,'hotbar OFF is persisted') + T.eq(Touch:hotbarShown(),false,'OFF reaches real pad') + g.input.pressed={right=true};page:update(1/60);g.input.pressed={} + T.eq(Touch:hotbarShown(),true,'normal Options input restores hotbar') +end +stack:pop() +local Editor=require('src.ui.TouchControlsEditor') +for _,size in ipairs{{380,720},{800,400}}do + W,H=size[1],size[2];SW,SH=W,H + Editor.load();Editor.draw() + local z=Touch:layout().hotbar + T.eq(Touch:hotbarCellAt(z.cx,z.cy),nil,'layout preview has no invisible live cells') + Editor.touchpressed('edit',z.cx,z.cy) + T.eq(Editor.drag and Editor.drag.name,'hotbar','default toggle stays draggable past editor chrome') + Editor.touchmoved('edit',W*.4,H*.5);Editor.touchreleased('edit') + local movedToggle=Touch:layout().hotbar + T.check(math.abs(movedToggle.cx-W*.4)<1,'actual editor drag moves hotbar center') + Editor.unload() +end +Touch:init();Tilt.reset();Zoom.reset() +Runtime.install(nil,nil,nil) +T.finish('native hotbar actions, ownership, safe area and editor') diff --git a/tests/engine/touch_skin_dpad_area.lua b/tests/engine/touch_skin_dpad_area.lua new file mode 100644 index 000000000..abb10e198 --- /dev/null +++ b/tests/engine/touch_skin_dpad_area.lua @@ -0,0 +1,201 @@ +-- From Gen1Recomp 535a5ec0, Copyright (C) 2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section 7; see licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- RetroArch dpad_area / abxy_area descs (#1533): one hitbox whose fired +-- input is resolved by the angle of the touch from the area centre, and +-- range_mod growing a hitbox only while it is held. The cfg below is the +-- reporter's GBA skin, trimmed to the d-pad and face buttons. +-- luajit tests/engine/touch_skin_dpad_area.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local TouchControls = require("src.core.TouchControls") +local Input = require("src.core.Input") + +local CFG = [[ +overlays = 2 + +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_range_mod = 1.5 +overlay0_alpha_mod = 1 +overlay0_aspect_ratio = 0.45 +overlay0_descs = 13 +overlay0_desc0 = "select,0.41944,0.58793,radial,0.06667,0.03" +overlay0_desc0_overlay = p-btn-select.png +overlay0_desc0_reach_x = 1.25 +overlay0_desc0_reach_y = 1.25 +overlay0_desc1 = "start,0.58056,0.58793,radial,0.06667,0.03" +overlay0_desc1_overlay = p-btn-start.png +overlay0_desc1_reach_x = 1.25 +overlay0_desc1_reach_y = 1.25 +overlay0_desc2 = "up,0.25,0.6625,radial,0.07778,0.035" +overlay0_desc2_overlay = p-btn-dpad-up.png +overlay0_desc2_reach_x = 0 +overlay0_desc3 = "left,0.12222,0.72,radial,0.07778,0.035" +overlay0_desc3_overlay = p-btn-dpad-left.png +overlay0_desc3_reach_x = 0 +overlay0_desc4 = "right,0.37778,0.72,radial,0.07778,0.035" +overlay0_desc4_overlay = p-btn-dpad-right.png +overlay0_desc4_reach_x = 0 +overlay0_desc5 = "down,0.25,0.7775,radial,0.07778,0.035" +overlay0_desc5_overlay = p-btn-dpad-down.png +overlay0_desc5_reach_x = 0 +overlay0_desc6 = "up|left,0.11644,0.6599,radial,0.01667,0.0075" +overlay0_desc6_overlay = p-btn-corner.png +overlay0_desc6_reach_x = 0 +overlay0_desc7 = "up|right,0.38356,0.6599,radial,0.01667,0.0075" +overlay0_desc7_overlay = p-btn-corner.png +overlay0_desc7_reach_x = 0 +overlay0_desc8 = "down|left,0.11644,0.7801,radial,0.01667,0.0075" +overlay0_desc8_overlay = p-btn-corner.png +overlay0_desc8_reach_x = 0 +overlay0_desc9 = "down|right,0.38356,0.7801,radial,0.01667,0.0075" +overlay0_desc9_overlay = p-btn-corner.png +overlay0_desc9_reach_x = 0 +overlay0_desc10 = "dpad_area,0.25,0.72,radial,0.22778,0.1025" +overlay0_desc10_overlay = p-area-dpad.png +overlay0_desc10_reach_x = 1.25 +overlay0_desc10_reach_y = 1.25 +overlay0_desc11 = "a,0.84382,0.69563,radial,0.09722,0.04375" +overlay0_desc11_overlay = p-btn-act2-a.png +overlay0_desc11_reach_x = 1.25 +overlay0_desc11_reach_y = 1.25 +overlay0_desc12 = "b,0.65618,0.74438,radial,0.09722,0.04375" +overlay0_desc12_overlay = p-btn-act2-b.png +overlay0_desc12_reach_x = 1.25 +overlay0_desc12_reach_y = 1.25 + +overlay1_name = "areas" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_descs = 2 +overlay1_desc0 = "dpad_area,0.25,0.5,rect,0.2,0.2" +overlay1_desc0_up = "start" +overlay1_desc0_down = "select" +overlay1_desc0_left = "nul" +overlay1_desc1 = "abxy_area,0.75,0.5,rect,0.2,0.2" +]] + +local skin = assert(TouchSkin.parse(CFG)) +local page = skin.pages[1] + +eq(#page.controls, 12 + 1 + 8, "the dpad_area expands into eight sector controls") +local sectors = {} +for _, ctl in ipairs(page.controls) do + if ctl.sector then sectors[ctl.sector] = ctl end +end +eq(#sectors, 8, "eight sectors, one per direction") +eq(sectors[1].spec, "right", "sector 1 is right") +eq(sectors[2].spec, "right|down", "sector 2 is the down-right diagonal") +eq(sectors[3].spec, "down", "sector 3 is down, y growing downwards") +eq(sectors[7].spec, "up", "sector 7 is up") +eq(sectors[1].x, 0.25, "every sector keeps the area centre") +eq(sectors[5].y, 0.72, "on both axes") +eq(sectors[3].rangeX, 0.22778, "and the whole area range") +eq(sectors[3].shape, "radial", "and the declared hitbox shape") + +local art = page.controls[11] +eq(art.imagePath, "p-area-dpad.png", "the area art rides a decorative desc") +check(art.decorative, "which presses nothing") + +TouchControls:init() +TouchControls.active = true +TouchControls.enabled = true +TouchSkin.setOverlayLive(true) +TouchSkin.setActive(skin) +Input:init() + +local W, H = 720, 1600 +TouchSkin.setSurface(0, 0, W, H) +eq(TouchSkin.page().name, "portrait", "the portrait page is live at 720x1600") + +local BUTTONS = { "up", "down", "left", "right", "a", "b", "start", "select" } +local function heldNow() + local out = {} + for _, btn in ipairs(BUTTONS) do + if Input:isDown(btn) then out[#out + 1] = btn end + end + return table.concat(out, "+") +end + +local function press(nx, ny) + TouchControls:touchpressed("f1", nx * W, ny * H) + local got = heldNow() + TouchControls:touchreleased("f1", nx * W, ny * H) + return got +end + +local function fires(nx, ny, want, why) + eq(press(nx, ny), want, why) +end + +fires(0.25, 0.6625, "up", "the d-pad up arrow fires up alone") +fires(0.12222, 0.72, "left", "the left arrow fires left alone") +fires(0.37778, 0.72, "right", "the right arrow fires right alone") +fires(0.25, 0.7775, "down", "the down arrow fires down alone") +fires(0.11644, 0.6599, "up+left", "the up-left corner fires both, and only both") +fires(0.38356, 0.7801, "down+right", "as does the down-right corner") + +fires(0.32, 0.77, "down+right", + "a spot inside the area but off every arrow resolves by angle: " + .. "(50.4, 80) pixels out is 57.8 degrees, the down-right sector") + +fires(0.84382, 0.69563, "a", "A fires alone") +fires(0.65618, 0.74438, "b", "B fires alone: the 1.5x range_mod does not grow the resting d-pad area over it") +fires(0.58056, 0.58793, "start", "START fires alone") +fires(0.41944, 0.58793, "select", "SELECT fires alone, with no phantom direction") +fires(0.5, 0.3, "", "the screen area presses nothing") + +TouchControls:touchpressed("f2", 0.25 * W, 0.6625 * H) +eq(heldNow(), "up", "slide starts on up") +TouchControls:touchmoved("f2", 0.37778 * W, 0.72 * H) +eq(heldNow(), "right", "sliding across the area swaps direction") +TouchControls:touchmoved("f2", 0.38356 * W, 0.7801 * H) +eq(heldNow(), "down+right", "and picks up the diagonal") +TouchControls:touchmoved("f2", 0.5 * W, 0.3 * H) +eq(heldNow(), "", "sliding out of the area releases it") +TouchControls:touchreleased("f2", 0.5 * W, 0.3 * H) + +local area = sectors[1] +local bx = 0.65618 * W +local by = 0.74438 * H +check(not TouchSkin.hits(page, area, W, H, bx, by, 0, 0, false), + "at rest the area hitbox stops short of B") +check(TouchSkin.hits(page, area, W, H, bx, by, 0, 0, true), + "a held area grows over B so the finger keeps its direction") +TouchControls:touchpressed("f3", 0.37778 * W, 0.72 * H) +TouchControls:touchmoved("f3", bx, by) +eq(heldNow(), "right+b", "sliding from the held area onto B keeps right held") +TouchControls:touchreleased("f3", bx, by) +eq(heldNow(), "", "and lifting clears both") + +TouchSkin.autoOrient = false +TouchSkin.setPage("areas") +eq(TouchSkin.page().name, "areas", "second page is live") + +W, H = 1000, 1000 +TouchSkin.setSurface(0, 0, W, H) + +fires(0.25, 0.35, "start", "_up rebinds the up sector of a dpad_area") +fires(0.25, 0.65, "select", "_down rebinds the down sector") +fires(0.12, 0.5, "", "_left = nul makes that sector inert") +fires(0.38, 0.5, "right", "an unset side keeps the d-pad default") + +fires(0.88, 0.5, "a", "abxy_area right is GB A") +fires(0.75, 0.62, "b", "abxy_area down is GB B") +fires(0.88, 0.62, "a+b", "the down-right sector fires both") +fires(0.75, 0.38, "", "RetroPad X has no GB button, so up is inert") +fires(0.94, 0.68, "a+b", "a rect area still hits inside its corner") +fires(0.75, 0.75, "", "and nothing past its edge") + +TouchSkin.setSurface(nil) +TouchSkin.setActive(nil) +TouchSkin.autoOrient = true + +T.finish("touch_skin_dpad_area") diff --git a/tests/engine/touch_skin_native.lua b/tests/engine/touch_skin_native.lua new file mode 100644 index 000000000..3ad3d339b --- /dev/null +++ b/tests/engine/touch_skin_native.lua @@ -0,0 +1,223 @@ +-- Native host integration, using owned memory-only skins. No ROM/profile IO. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local Skin = require('src.core.TouchSkin') +local Touch = require('src.core.TouchControls') +local Input = require('src.core.Input') +local V = require('src.render.GameViewport') +local Runtime = require('src.mods.Runtime') +local Save = require('src.core.SaveData') +local Game = require('src.core.Game') +local sdk=T.sdk.loadMods({'mods/skin_consumer'},{data=T.fixtures.fresh(),fs=T.sdk.memfs({ + ['mods/skin_consumer/manifest.json']=require('src.link.Json').encode({ + id='skin_consumer',name='Unmodified skin API consumer fixture',version='1.0.0',api=2,entry='main.lua'}), + ['mods/skin_consumer/main.lua']=[[ + local mod=... + local S=require('src.core.TouchSkin') + mod.exports.hasGeometry=type(S.page)=='function' and type(S.controlGeometry)=='function' + mod.exports.hasLibrary=type(S.installArchive)=='function' and type(S.exportDelta)=='function' + ]], +})}) +T.eq(#sdk.errors,0,'actual mod loader resolves native skin API') +T.eq(sdk.loader.apis.skin_consumer.exports.hasGeometry,true,'optional mod geometry consumer receives real API') +T.eq(sdk.loader.apis.skin_consumer.exports.hasLibrary,true,'optional mod library consumer receives real API') +sdk.release() +local W, H = 640, 480 +love.graphics.getDimensions = function() return W, H end +love.graphics.getPixelDimensions = function() return W * 2, H * 2 end +local files, dirs = {}, {skins=true, ['assets/skins']=true} +local fs = {} +function fs.read(path) return files[path] end +function fs.write(path, bytes) files[path] = bytes; return true end +function fs.getInfo(path) + if dirs[path] then return {type='directory'} end + if files[path] then return {type='file', size=#files[path]} end +end +function fs.createDirectory(path) dirs[path]=true; return true end +function fs.remove(path) files[path], dirs[path]=nil,nil; return true end +function fs.getDirectoryItems(path) + local found, out = {}, {} + for _, table_ in ipairs{dirs,files} do + for key in pairs(table_) do + local tail = key:sub(1,#path+1)==path..'/' and key:sub(#path+2) + local name = tail and tail:match('^([^/]+)') + if name and not found[name] then found[name]=true; out[#out+1]=name end + end + end + table.sort(out); return out +end +love.filesystem = fs +-- Same persistence seam as portable/account saves, with an explicit memory fs. +Save.persistenceFs = function() return fs end +local skin = Skin.newSkin('native_owned') +skin.pages[1].aspectFromCfg = false +skin.pages[1].viewport = {x=.1,y=.1,w=.8,h=.5} +skin.pages[1].controls = { + Skin.newControl('a|key:f5', .8,.8,.2,.2,'rect'), + Skin.newControl('hold_fast_forward', .2,.8,.2,.2,'rect'), +} +skin.pages[1].imagePath = 'img/bezel.png' +skin.pages[1].controls[1].imagePath = 'img/a.png' +dirs[skin.root], dirs[skin.root..'/img'] = true,true +files[skin.root..'/img/bezel.png'] = 'owned-bezel-image' +files[skin.root..'/img/a.png'] = 'owned-control-image' +local drawn = {} +love.graphics.newImage = function(path) + return {getWidth=function() return W end, getHeight=function() return H end, + getDimensions=function() return W,H end, setFilter=function() end, path=path} +end +love.graphics.draw = function(image,x,y,...) drawn[#drawn+1]={image=image,x=x,y=y} end +T.eq(Skin.saveTo(skin), 'skins/native_owned', 'native skin saves through persistence owner') +local restored = assert(Skin.load('skins/native_owned','native_owned')) +T.eq(#restored.pages[1].controls, 2, 'saved skin reloads both controls') +T.eq(restored.pages[1].controls[1].spec, 'a|key:f5', 'combined binding survives disk shape') +T.eq(#Skin.list(), 1, 'library discovers the saved native skin') +local export, missing = Skin.export(restored) +T.eq(#missing, 0, 'export finds every owned asset') +T.check(files[export]:find('owned-bezel-image',1,true) ~= nil, 'export contains original bezel bytes') +T.check(files[export]:find('owned-control-image',1,true) ~= nil, 'export contains original control bytes') +local ra = assert(Skin.exportRetroArch(restored)) +T.check(files[ra]:find('overlay0_desc0',1,true) ~= nil, 'RetroArch export contains actual bindings') +local delta = assert(Skin.exportDelta(restored, {system='gbc'})) +T.check(files[delta]:find('info.json',1,true) ~= nil, 'Delta export contains metadata') + +-- The actual option row accepts normal input and retains other layout options. +local Version=require('src.core.GameVersion') +Version.set('crystal') +local Menu=require('src.ui.OptionsMenu') +local Stack=require('src.core.StateStack') +local g={data=T.fixtures.fresh(),save=Save.newGame(),writes=0,input={pressed={}}} +g.stack=setmetatable({},{__index=Stack});g.stack:init() +g.input.wasPressed=function(self,key) return self.pressed[key]==true end +g.writeOptions=function(self) self.writes=self.writes+1;self.persisted=Save.encode(self.save) end +g.save.options.touchControls={layouts={portrait={scale=1.2}}} +local menu=Menu.new(g);g.stack:push(menu) +-- This offered base retains upstream's actual flat OPTIONS page. +local page +for index,row in ipairs(menu.rows) do + if row.id=='touchSkin' then menu.index=index;page=menu;break end +end +T.check(page ~= nil,'installed skin exposes a native Options row') +if page then + g.input.pressed={right=true};page:update(1/60);g.input.pressed={} + T.eq(g.save.options.touchControls.skin,'native_owned','normal RIGHT selects installed skin') + T.eq(Skin.active and Skin.active.id,'native_owned','normal Options input applies real skin') + T.eq(g.writes,1,'normal Options input writes through native owner') + local roundTrip=assert(Save.decode(g.persisted)) + T.eq(roundTrip.options.touchControls.skin,'native_owned','save codec round-trips selected skin') + T.eq(roundTrip.options.touchControls.layouts.portrait.scale,1.2,'skin choice retains layout preferences') + g.input.pressed={right=true};page:update(1/60);g.input.pressed={} + T.eq(g.save.options.touchControls.skin,nil,'normal RIGHT wraps to default pad') + T.eq(Skin.active,nil,'default choice releases custom viewport') +end + +Touch:init(); Input:init() +Touch.active = true +Touch:applyOptions({touchControls={skin='native_owned'}}) +T.eq(Touch.skinId, 'native_owned', 'options select the real saved skin') +T.eq(Touch:config().skin, 'native_owned', 'editor config retains selection') +local keyEdges, speedEdges = {}, {} +love.keypressed = function(key) keyEdges[#keyEdges+1]='+'..key end +love.keyreleased = function(key) keyEdges[#keyEdges+1]='-'..key end +Touch:setHotkeyHandler(function(key, down) speedEdges[#speedEdges+1]=key..':'..tostring(down) end) +local cx,cy=Skin.controlGeometry(Skin.page(),Skin.page().controls[1],W,H) +T.check(Touch:touchpressed('one',cx,cy), 'custom A contact captured') +T.check(Touch:touchpressed('two',cx,cy), 'overlapping second contact captured') +T.check(Input:isDown('a'), 'custom A reaches real native input') +T.eq(#keyEdges,1, 'shared keyboard bind has one press edge') +Touch:touchreleased('one',cx,cy) +T.check(Input:isDown('a'), 'one release retains other A owner') +T.eq(#keyEdges,1, 'one release retains other key owner') +Touch:touchreleased('two',cx,cy) +T.check(not Input:isDown('a'), 'last release drops real native A') +T.eq(keyEdges[2],'-f5', 'last release drops native key') +local sx,sy=Skin.controlGeometry(Skin.page(),Skin.page().controls[2],W,H) +Touch:touchpressed('fast',sx,sy) +T.eq(speedEdges[1],'fast_forward_hold:true','skin speed action reaches host') +Touch:touchpressed('again',cx,cy) +Touch:reset() +T.eq(speedEdges[2],'fast_forward_hold:false','focus reset releases held speed action') +T.eq(keyEdges[#keyEdges],'-f5','focus reset releases keyboard binding') +T.check(not Input:isDown('a'), 'focus reset releases real native input') +Touch:touchpressed('reused',cx,cy) +Touch:touchpressed('reused',sx,sy) +T.check(not Input:isDown('a'), 'reused native pointer id releases previous owner') +Touch:reset() + +-- Decorative desktop skins draw after composition without stealing input. +Touch.active=false; Touch.controllerHidden=true +T.check(Touch:visible(), 'desktop/controller retains bezel artwork') +T.eq(Touch:touchpressed('desktop',cx,cy),nil,'desktop bezel does not capture touch input') +Touch:draw() +T.eq(#drawn,2,'draws bezel and control once each in window space') +Touch:applyOptions({touchControls={enabled=false,skin='native_owned'}}) +T.eq(Skin.active,nil,'disabling controls also clears skin viewport') +T.eq(Touch:visible(),false,'disabled skin is invisible') +Touch:applyOptions({touchControls={skin='missing'}}) +T.eq(Skin.active,nil,'missing skin cannot leave stale art selected') +T.check(Touch.skinError ~= nil,'missing skin reports its load error') + +-- Skin cutouts share the actual viewport owner; mod hooks keep final say. +local Hooks=require('src.mods.Hooks') +local hooks=Hooks.new() +Runtime.install(require('src.mods.Events').new(),hooks,{}) +Touch:applyOptions({touchControls={skin='native_owned'}}) +local x,y,w,h=Skin.viewport(W,H) +local r=V.begin(2) +T.eq(r.x,math.floor(x),'skin reserves native viewport x') +T.eq(r.y,math.floor(y),'skin reserves native viewport y') +T.eq(r.width,math.floor(w),'skin reserves native viewport width') +T.eq(r.height,math.floor(h),'skin reserves native viewport height') +local lx,ly,inside=V.toLocal(r.x+10,r.y+20) +T.eq(lx,10,'pointer maps into real cutout x') +T.eq(ly,20,'pointer maps into real cutout y') +T.check(inside,'pointer inside cutout is accepted') +V.finish({}); V.reset() +local undo=hooks:wrap('render.viewport',function(next,ctx) + local base=next(ctx) + T.eq(base.width,math.floor(w),'mod receives selected skin as default viewport') + return {x=20,y=30,width=100,height=90} +end,0,'owned-fixture') +r=V.begin(2) +T.eq(r.x,20,'layout mod can override selected skin viewport') +T.eq(r.width,100,'layout mod retains final cutout authority') +V.finish({}); V.reset(); undo() +V.begin(4,false) +T.eq(V.active(),false,'existing native dual-screen layout keeps its owner') +V.finish({}); V.reset() +Skin.setActive(nil) +V.begin(2) +T.eq(V.active(),false,'no selected skin allocates no extra viewport') +V.finish({}); V.reset() + +-- Real engine hotkeys must release an explicit speed override and obey locks. +local host=setmetatable({speedOverride=2,save={options={speed=1}},writes=0}, {__index=Game}) +host.speedLocked=function() return false end +host.writeOptions=function(self) self.writes=self.writes+1 end +host:touchSkinHotkey('fast_forward_hold',true) +T.eq(host.speedOverride,4,'native held fast-forward engages') +host:touchSkinHotkey('fast_forward_hold',false) +T.eq(host.speedOverride,2,'native held fast-forward restores explicit override') +host:touchSkinHotkey('fast_forward_toggle',true) +T.eq(host.save.options.speed,2,'native toggle cycles persisted speed') +T.eq(host.writes,1,'native toggle persists through engine owner') +host.speedLocked=function() return true end +host:touchSkinHotkey('fast_forward_toggle',true) +T.eq(host.writes,1,'locked state blocks speed preference changes') +-- The offered native host must supply the actual lock owner; a test-only +-- speedLocked double cannot prove that a runtime skin action works. +host.speedLocked=nil +host.stack=setmetatable({},{__index=Stack});host.stack:init() +host.stack:push({isFixedSpeed=true});host.stack:push({name='options'}) +T.eq(host:speedLocked(),true,'real native lock sees fixed-speed state under menu') +host.speedOverride=4 +T.eq(host:logicSpeed(),1,'real lock overrides held acceleration in logic clock') +host:touchSkinHotkey('fast_forward_toggle',true) +T.eq(host.writes,1,'real lock blocks skin toggle persistence') +host.stack.states={};host.linkSession={} +T.eq(host:speedLocked(),true,'real native lock sees live link session') +host.linkSession=nil +T.eq(host:speedLocked(),false,'settled unlocked host can accelerate') +host:touchSkinHotkey('fast_forward_toggle',true) +T.eq(host.writes,2,'skin toggle reaches actual unlocked native owner') +T.finish('native custom-skin ownership, persistence, rendering and input') diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua new file mode 100644 index 000000000..c2b75ad1b --- /dev/null +++ b/tests/engine/touch_skin_test.lua @@ -0,0 +1,401 @@ +-- From Gen1Recomp 535a5ec0, Copyright (C) 2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section 7; see licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- RetroArch-format touch skins (#1386): .cfg parsing, hitboxes, the press / +-- hotkey path through TouchControls, and the screen viewport the renderer +-- fits the Game Boy picture into. +-- luajit tests/engine/touch_skin_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local TouchSkin = require("src.core.TouchSkin") +local TouchControls = require("src.core.TouchControls") +local Input = require("src.core.Input") + +local CFG = [[ +overlays = 2 + +overlay0_name = "shell" +overlay0_overlay = img/back.png +overlay0_full_screen = true +overlay0_normalized = true +overlay0_alpha_mod = 0.001 +overlay0_range_mod = 1.5 +overlay0_viewport = "0.0,0.0,1.0,0.5" +overlay0_viewport_fill = true + +overlay0_descs = 7 +overlay0_desc0 = "a,0.80,0.75,radial,0.10,0.05" +overlay0_desc0_overlay = img/a.png +overlay0_desc1 = "left|down,0.20,0.90,rect,0.05,0.05" +overlay0_desc2 = "nul,0.20,0.75,rect,0.15,0.10" +overlay0_desc2_overlay = img/dpad.png +overlay0_desc3 = "overlay_next,0.95,0.55,radial,0.03,0.02" +overlay0_desc3_next_target = "second" +overlay0_desc4 = "hold_fast_forward,0.05,0.55,radial,0.03,0.02" +overlay0_desc5 = "reset,0.50,0.98,rect,0.04,0.02" +overlay0_desc6 = "key:f5,0.10,0.98,rect,0.04,0.02" + +overlay1_name = "second" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_descs = 1 +overlay1_desc0 = "start,0.50,0.50,rect,0.10,0.10" +]] + +local skin = assert(TouchSkin.parse(CFG)) +eq(#skin.pages, 2, "two overlay pages parsed") + +local page = skin.pages[1] +eq(page.name, "shell", "page name") +eq(page.imagePath, "img/back.png", "page background path") +check(page.fullScreen, "full_screen parsed") +check(not page.aspectFromCfg, "a cfg without aspect_ratio does not lock aspect") +eq(page.alphaMod, 0.001, "overlay alpha_mod parsed") +eq(#page.controls, 7, "seven descs parsed") + +check(page.viewport ~= nil, "viewport parsed") +eq(page.viewport.h, 0.5, "viewport height") +check(page.viewportFill, "viewport_fill parsed") + +local a, diag, decor, nextBtn, ff, reset, keyBtn = unpack(page.controls) +eq(a.buttons[1], "a", "desc0 binds GB a") +eq(a.shape, "radial", "desc0 is radial") +eq(a.alphaMod, 0.001, "desc inherits the overlay alpha_mod") +eq(a.rangeMod, 1.5, "desc inherits the overlay range_mod") +eq(table.concat(diag.buttons, "+"), "left+down", "pipe-separated binds are one control") +check(decor.decorative, "nul desc is decoration only") +eq(decor.imagePath, "img/dpad.png", "decoration still carries art") +eq(nextBtn.hotkeys[1], "overlay_next", "overlay_next mapped") +eq(nextBtn.nextTarget, "second", "next_target parsed") +eq(ff.hotkeys[1], "fast_forward_hold", "hold_fast_forward mapped") +eq(reset.hotkeys[1], "soft_reset", "reset mapped") +eq(keyBtn.keys[1], "f5", "key: binds a keyboard key") + +-- geometry: x/y are the centre, range_x/range_y are half extents, both +-- normalized to the window when full_screen is set +local W, H = 400, 800 +local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, a, W, H) +eq(cx, 320, "control centre x") +eq(cy, 600, "control centre y") +eq(halfW, 40, "control half width") +eq(halfH, 40, "control half height") + +-- range_mod 1.5 widens the hitbox but not the art +check(TouchSkin.hits(page, a, W, H, 320, 600), "centre hits") +check(TouchSkin.hits(page, a, W, H, 320 + 55, 600), "inside range_mod hits") +check(not TouchSkin.hits(page, a, W, H, 320 + 65, 600), "past range_mod misses") +-- radial, so the corner of the bounding box is outside +check(not TouchSkin.hits(page, a, W, H, 320 + 55, 600 + 55), "radial corner misses") +-- rect hitbox does take its corner +check(TouchSkin.hits(page, diag, W, H, 80 + 19, 720 + 19), "rect corner hits") + +-- ---------------------------------------------------------------- runtime + +TouchControls:init() +TouchControls.active = true +TouchControls.enabled = true +TouchSkin.setOverlayLive(true) +TouchSkin.setActive(skin) +Input:init() + +local ww, wh = love.graphics.getDimensions() +local function at(nx, ny) return nx * ww, ny * wh end + +check(TouchSkin.hasViewport(), "active skin reports a viewport") + +-- the renderer fits the Game Boy picture into this rect instead of the window +local sx, sy, sw, sh, fill = TouchSkin.viewport(W, H) +eq(sx, 0, "viewport x") eq(sy, 0, "viewport y") +eq(sw, 400, "viewport w") eq(sh, 400, "viewport h") +check(fill, "viewport fill flag returned") +eq(TouchSkin.viewport(W, H), 0, "without a locked aspect, viewport tracks the window") + +check(TouchControls:touchpressed("f1", at(0.80, 0.75)), "press on A is captured") +check(Input:isDown("a"), "skin A presses GB a") +TouchControls:touchreleased("f1", at(0.80, 0.75)) +check(not Input:isDown("a"), "lifting releases GB a") + +-- one finger inside a combined desc holds both directions +TouchControls:touchpressed("f2", at(0.20, 0.90)) +check(Input:isDown("left") and Input:isDown("down"), "combined desc holds both buttons") +-- sliding onto the decoration drops them without capturing anything new +TouchControls:touchmoved("f2", at(0.20, 0.75)) +check(not Input:isDown("left") and not Input:isDown("down"), + "sliding off a control releases it") +TouchControls:touchreleased("f2", at(0.20, 0.75)) + +check(TouchControls:touchpressed("f3", at(0.20, 0.75)) == nil, + "a press on decoration alone is not captured") + +-- hotkeys reach the host once per press edge and once per release +local fired = {} +TouchControls:setHotkeyHandler(function(action, pressed) + fired[#fired + 1] = action .. ":" .. tostring(pressed) +end) +TouchControls:touchpressed("f4", at(0.05, 0.55)) +eq(fired[1], "fast_forward_hold:true", "held hotkey fires on press") +TouchControls:touchreleased("f4", at(0.05, 0.55)) +eq(fired[2], "fast_forward_hold:false", "held hotkey fires on release") + +-- a stranded finger unwinds through reset, not just through touchreleased +fired = {} +TouchControls:touchpressed("f5", at(0.05, 0.55)) +TouchControls:touchpressed("f6", at(0.80, 0.75)) +check(Input:isDown("a"), "second finger holds A") +TouchControls:reset() +eq(fired[2], "fast_forward_hold:false", "reset releases held hotkeys") +check(not Input:isDown("a"), "reset releases held buttons") + +-- overlay_next swaps the page, and the new page's controls are what hit +eq(TouchSkin.page().name, "shell", "starts on page 1") +TouchControls:touchpressed("f7", at(0.95, 0.55)) +eq(TouchSkin.page().name, "second", "overlay_next honours next_target") +TouchControls:touchreleased("f7", at(0.95, 0.55)) +TouchControls:touchpressed("f8", at(0.50, 0.50)) +check(Input:isDown("start"), "page 2 control presses GB start") +TouchControls:touchreleased("f8", at(0.50, 0.50)) +TouchSkin.setPage("shell") + +-- ------------------------------------------------------------ persistence + +local cfg = TouchControls.normalizeConfig({ enabled = true, skin = "gb_anim" }) +eq(cfg.skin, "gb_anim", "normalizeConfig keeps the skin id") +eq(TouchControls.normalizeConfig({ skin = "" }).skin, nil, "empty skin id drops") +eq(TouchControls.normalizeConfig({ skin = 7 }).skin, nil, "non-string skin id drops") + +TouchControls.skinId = "gb_anim" +eq(TouchControls:config().skin, "gb_anim", "config() round-trips the skin id") + +-- ----------------------------------------------- screen-hole detection + +-- Border art usually ships with a transparent screen hole and no viewport +-- key. Detection is verified against real art by +-- tests/drivers/tv_skin_shot.lua; here it only has to fail safely. +eq(TouchSkin.detectViewport("assets/skins/tv_crt", nil), nil, + "no bezel path detects nothing") +eq(TouchSkin.detectViewport("assets/skins/tv_crt", ""), nil, + "empty bezel path detects nothing") +eq(TouchSkin.detectViewport("assets/skins/tv_crt", "img/does_not_exist.png"), nil, + "a missing bezel detects nothing") + +-- Bundled third-party artwork is not shipped by this engine; asset loading and +-- export are covered with owned in-memory fixtures in touch_skin_native.lua. + +-- ------------------------------------------- decorative desktop bezels + +-- A skin whose page binds nothing is a frame, not a pad: it draws where the +-- touch overlay does not (desktop), and a gamepad must not hide it. +local BEZEL = [[ +overlays = 1 +overlay0_name = "tv" +overlay0_overlay = img/tv.png +overlay0_full_screen = true +overlay0_normalized = true +overlay0_descs = 1 +overlay0_desc0 = "nul,0.5,0.5,rect,0.5,0.5" +overlay0_viewport = "0.2,0.1,0.6,0.8" +]] +local bezel = assert(TouchSkin.parse(BEZEL)) +TouchSkin.setActive(bezel) + +TouchSkin.setOverlayLive(false) +check(TouchSkin.decorativeOnly(), "a descs-only-nul skin is decoration") +check(TouchSkin.drawable(), "a decorative skin draws with the overlay off (desktop)") +check(TouchSkin.hasViewport(), "and its viewport still places the picture") +TouchControls.active = false +TouchControls.enabled = true +TouchControls.controllerHidden = true +check(TouchControls:visible(), "a gamepad does not hide a decorative bezel") + +-- A selected skin is also a presentation overlay on desktop. The touch +-- input gate remains separate from whether its artwork is drawn. +TouchSkin.setActive(skin) +TouchSkin.setOverlayLive(false) +check(not TouchSkin.decorativeOnly(), "a skin with binds is not decoration") +check(TouchSkin.drawable(), "and it still draws where touch input is off") +check(TouchSkin.hasViewport(), "so its screen placement stays available") +check(TouchControls:visible(), "and it draws over a desktop window") +TouchSkin.setOverlayLive(true) +check(TouchSkin.drawable(), "with the overlay live it draws again") +TouchControls.active = true +TouchControls.controllerHidden = false + +-- ------------------------------------------------------- native format + +local native = TouchSkin.serialize(skin) +check(native:find("^return {"), "serialize emits a Lua data chunk") +local back, nerr = TouchSkin.parseNative(native) +check(back ~= nil, "native round-trip parses: " .. tostring(nerr)) +if back then + eq(#back.pages, #skin.pages, "round-trip keeps the page count") + local bp, sp = back.pages[1], skin.pages[1] + eq(#bp.controls, #sp.controls, "round-trip keeps the control count") + eq(bp.controls[1].spec, sp.controls[1].spec, "round-trip keeps binds") + eq(bp.controls[1].rangeX, sp.controls[1].rangeX, "round-trip keeps half extents") + eq(bp.controls[1].shape, sp.controls[1].shape, "round-trip keeps the hitbox shape") + eq(bp.viewport.h, sp.viewport.h, "round-trip keeps the viewport") + check(bp.viewportFill, "round-trip keeps viewport_fill") + eq(bp.alphaMod, sp.alphaMod, "round-trip keeps alpha_mod") + eq(bp.controls[3].decorative, true, "round-trip keeps decoration") + eq(bp.controls[4].nextTarget, "second", "round-trip keeps next_target") +end + +-- the native format carries the separate pressed image a .cfg cannot +local twoState = assert(TouchSkin.parseNative([[ +return { pages = { { name = "p", controls = { + { bind = "a", x = 0.5, y = 0.5, w = 0.2, h = 0.2, shape = "radial", + image = "up.png", imagePressed = "down.png" }, +} } } } +]])) +eq(twoState.pages[1].controls[1].imagePath, "up.png", "native idle image") +eq(twoState.pages[1].controls[1].pressedImagePath, "down.png", "native pressed image") +eq(twoState.pages[1].controls[1].rangeX, 0.1, "native w is a full width, not a range") + +check(TouchSkin.parseNative("return 5") == nil, "a non-table skin.lua is rejected") +check(TouchSkin.parseNative("return { pages = {} }") == nil, "a pageless skin.lua is rejected") +check(TouchSkin.parseNative("this is not lua") == nil, "a broken skin.lua is rejected") +-- skins are third-party data: the chunk must not see the host globals +check(TouchSkin.parseNative("return { pages = { { controls = {} } }, hit = love ~= nil }") + ~= nil, "skin.lua loads in an empty environment") + +-- --------------------------------------------------------------- export + +local SkinZip = require("src.core.SkinZip") +local blob = SkinZip.encode({ + { name = "skin.lua", data = "return {}\n" }, + { name = "img/a.png", data = "\137PNG\r\n\26\n binary \0 bytes" }, +}) +eq(blob:sub(1, 4), "PK\3\4", "zip starts with a local file header") +check(blob:find("PK\5\6", 1, true) ~= nil, "zip ends with a central directory record") +check(blob:find("img/a.png", 1, true) ~= nil, "zip carries the entry name") +check(blob:find("binary", 1, true) ~= nil, "stored entries keep their bytes verbatim") + + +TouchSkin.setActive(nil) +TouchControls:setHotkeyHandler(nil) + +-- ------------------------------------------ auto-rotate (#1503) + +-- RetroArch overlays name a portrait page and a landscape page and wire +-- overlay_next between them. Play has to pick the one that matches the +-- window, or landscape stretches the portrait ranges into wide ovals. +local ORIENT_CFG = [[ +overlays = 2 +overlay0_name = "portrait" +overlay0_full_screen = true +overlay0_normalized = true +overlay0_aspect_ratio = 0.45 +overlay0_descs = 1 +overlay0_desc0 = "a,0.84382,0.69563,radial,0.09722,0.04375" + +overlay1_name = "landscape" +overlay1_full_screen = true +overlay1_normalized = true +overlay1_aspect_ratio = 2.22222222222222 +overlay1_descs = 1 +overlay1_desc0 = "a,0.8307031248,0.8614400000,radial,0.0364168752,0.0813300000" +]] +local orient = assert(TouchSkin.parse(ORIENT_CFG)) +check(orient.pages[1].aspectFromCfg, "portrait page locks the cfg aspect_ratio") +check(orient.pages[2].aspectFromCfg, "landscape page locks the cfg aspect_ratio") +eq(orient.pages[1].orient, "portrait", "a portrait page name locks on import") +eq(orient.pages[2].orient, "landscape", "a landscape page name locks on import") +check(TouchSkin.hasOrientPair(orient), "the pair is an auto-rotate overlay") +TouchSkin.setActive(orient) +TouchSkin.autoOrient = true + +local PW, PH = 720, 1600 +TouchSkin.setSurface(0, 0, PW, PH) +eq(TouchSkin.page().name, "portrait", "a tall window picks the portrait page") +local _, _, pHalfW, pHalfH = + TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], PW, PH) +eq(math.floor(pHalfW + 0.5), 70, "portrait A half-width follows range_x") +eq(math.floor(pHalfH + 0.5), 70, "portrait A half-height follows range_y") + +local LW, LH = 1600, 720 +TouchSkin.setSurface(0, 0, LW, LH) +eq(TouchSkin.page().name, "landscape", "a wide window picks the landscape page") +local _, _, lHalfW, lHalfH = + TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH) +eq(math.floor(lHalfW + 0.5), 58, "landscape A half-width follows the landscape range") +eq(math.floor(lHalfH + 0.5), 59, "landscape A half-height follows the landscape range") +check(math.abs(lHalfW - lHalfH) < 2, "landscape A stays round instead of stretching") + +-- 16:9 is not the overlay's 20:9. Letterbox to aspect_ratio so A stays round +-- instead of filling the window and turning into a tall oval (#1503). +TouchSkin.setSurface(0, 0, 1920, 1080) +eq(TouchSkin.page().name, "landscape", "16:9 still picks landscape") +local _, _, boxW, boxH = TouchSkin.pageBox(TouchSkin.page(), 1920, 1080) +eq(math.floor(boxW + 0.5), 1920, "letterbox keeps the 16:9 width") +eq(math.floor(boxH + 0.5), 864, "and the overlay's 20:9 height") +local _, _, sHalfW, sHalfH = + TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], 1920, 1080) +check(math.abs(sHalfW - sHalfH) < 2, "A stays round on a 16:9 window") + +TouchSkin.setSurface(0, 0, LW, LH) +local aCx, aCy, aHalfW, aHalfH = + TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH) +local aW, aH = aHalfW * 2, aHalfH * 2 +local sx, sy = TouchSkin.imageFit(210, 210, aW, aH) +eq(sx, aW / 210, "desc art scales to the dest width") +eq(sy, aH / 210, "desc art scales to the dest height") +check(210 * sx <= aW + 1e-6 and 210 * sy <= aH + 1e-6, + "the whole bitmap lands inside its dest box") +check(sx >= math.min(aW / 210, aH / 210) - 1e-6, + "and it is not shrunk below a contain fit") +local lx, ly = TouchSkin.imageFit(327, 193, 218, 129) +check(327 * lx <= 218 + 1e-6 and 193 * ly <= 129 + 1e-6, + "a wide shoulder PNG is not cropped by its dest box") +check(TouchSkin.imageFit(0, 0, aW, aH) == nil, "a zero-sized image has no fit") +local wx, wy = TouchSkin.imageFit(100, 50, 200, 200) +eq(wx, 2, "independent X scale, not one uniform cover scale") +eq(wy, 4, "independent Y scale") +check(aCx > 0 and aCy > 0, "landscape A sits inside the page box") + +local nativeOrient = TouchSkin.parseNative(TouchSkin.serialize(orient)) +check(nativeOrient and nativeOrient.pages[2].aspectFromCfg, + "native export keeps the aspect lock") + +-- an explicit lock wins over the page name, so a studio-authored "main" +-- page can still auto-rotate in play +local LOCK_CFG = [[ +overlays = 2 +overlay0_name = "main" +overlay0_full_screen = true +overlay0_descs = 1 +overlay0_desc0 = "a,0.5,0.5,radial,0.05,0.05" +overlay1_name = "wide" +overlay1_full_screen = true +overlay1_descs = 1 +overlay1_desc0 = "a,0.5,0.5,radial,0.05,0.05" +]] +local locked = assert(TouchSkin.parse(LOCK_CFG)) +locked.pages[1].orient = "portrait" +locked.pages[2].orient = "landscape" +TouchSkin.setActive(locked) +TouchSkin.setSurface(0, 0, 1600, 720) +eq(TouchSkin.page().name, "wide", "orient lock auto-rotates a page not named landscape") +local roundTrip = TouchSkin.parseNative(TouchSkin.serialize(locked)) +eq(roundTrip.pages[1].orient, "portrait", "native export keeps a portrait lock") +eq(roundTrip.pages[2].orient, "landscape", "and a landscape lock") + +TouchSkin.setActive(orient) +TouchSkin.autoOrient = false +TouchSkin.pageIndex = 1 +eq(TouchSkin.page().name, "portrait", + "the studio can keep a portrait page on a landscape canvas") +TouchSkin.autoOrient = true +TouchSkin.setSurface(nil) + +-- pages that are not a portrait/landscape pair (gb_anim) stay put +TouchSkin.setActive(skin) +TouchSkin.setSurface(0, 0, LW, LH) +eq(TouchSkin.page().name, "shell", "a non-oriented skin does not auto-rotate") +TouchSkin.setSurface(nil) +TouchSkin.setActive(nil) + +T.finish("touch_skin") 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/ui_kit_pagination.lua b/tests/engine/ui_kit_pagination.lua new file mode 100644 index 000000000..a943f270c --- /dev/null +++ b/tests/engine/ui_kit_pagination.lua @@ -0,0 +1,129 @@ +-- Adapted from Gen1Recomp535a5ec0, Copyright(C)2026 BOIS CLUB GAMES, LLC. +-- GPLv3 + Section7; licenses/GEN1RECOMP-TOUCH-SKINS.md. +-- The launcher's UI kit: pagination bounds, viewport-derived page size, and +-- the text safety rules. These replace the two FlexLove engine tests +-- (flexlove_wheel_scroll_dt0, launcher_save_slot_overlap_bug748), whose +-- subjects -- a scroll manager fed dt = 0 and an auto-height propagation bug +-- -- no longer exist: there is no scrolling and no layout engine. +-- +-- What DOES need guarding now is the property the whole performance claim +-- rests on: a list draws at most one page of rows regardless of how many +-- items it holds, and the page arithmetic never walks off either end. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") + +-- ---------------------------------------------------------------- page math +-- pageBounds(page, total, perPage) -> first, last, clampedPage, pages +do + local first, last, page, pages = Kit.pageBounds(1, 38, 5) + T.eq(first, 1, "page 1 starts at item 1") + T.eq(last, 5, "page 1 ends at perPage") + T.eq(page, 1, "page 1 is in range") + T.eq(pages, 8, "38 items at 5 per page is 8 pages") + + first, last = Kit.pageBounds(8, 38, 5) + T.eq(first, 36, "the last page starts after the full ones") + T.eq(last, 38, "the last page stops at the item count, not at perPage") + + -- Out of range in both directions clamps rather than producing a window + -- that would index past the list (the launcher repages lists underneath + -- the user: a refresh can shrink an index from 38 mods to 2). + local _, _, low = Kit.pageBounds(0, 38, 5) + T.eq(low, 1, "page 0 clamps up to 1") + local f2, l2, high, p2 = Kit.pageBounds(99, 38, 5) + T.eq(high, 8, "a page past the end clamps to the last page") + T.eq(p2, 8, "the page count is unchanged by clamping") + T.check(l2 >= f2, "a clamped window is never inverted") + + -- An empty list still has one page, and draws no rows. + local ef, el, ep, epages = Kit.pageBounds(1, 0, 5) + T.eq(epages, 1, "an empty list still reports one page") + T.eq(ep, 1, "an empty list sits on page 1") + T.check(el < ef, "an empty page yields an empty row range") + + -- THE INVARIANT: no page ever yields more rows than perPage. + for total = 0, 40 do + for per = 1, 7 do + local _, _, _, np = Kit.pageBounds(1, total, per) + for p = 1, np do + local a, b = Kit.pageBounds(p, total, per) + T.check(b - a + 1 <= per, + ("page %d of %d (total %d) draws at most %d rows"):format(p, np, total, per)) + T.check(b <= total, "a page never runs past the item count") + end + end + end +end + +-- ------------------------------------------------------- viewport page size +-- rowsThatFit derives perPage from the real viewport, which is what lets a +-- tall window show more rows and a phone fewer with no scrolling either way. +do + -- n rows need n*rowH + (n-1)*gap pixels. + T.eq(Kit.rowsThatFit(200, 80, 20, 1, 20), 2, "200px fits two 80px rows + one gap") + T.eq(Kit.rowsThatFit(180, 80, 20, 1, 20), 2, "exactly two rows still fit") + T.eq(Kit.rowsThatFit(179, 80, 20, 1, 20), 1, "one pixel short drops to one row") + T.eq(Kit.rowsThatFit(0, 80, 20, 1, 20), 1, + "a collapsed viewport still shows one row rather than none") + T.eq(Kit.rowsThatFit(-500, 80, 20, 1, 20), 1, + "a negative budget cannot produce a negative page size") + T.eq(Kit.rowsThatFit(100000, 80, 20, 1, 12), 12, "the cap is honoured") +end + +-- ------------------------------------------------------------- text safety +-- Truncation must move whole codepoints. LOVE's Font:getWidth raises +-- "UTF-8 decoding error" on a string cut through a multi-byte sequence, and +-- the launcher lists mod names from third-party indexes -- this crashed the +-- first frame on a Japanese listing. +do + -- A font stub that REFUSES malformed UTF-8, the way LOVE's does. + local font = {} + function font:getWidth(s) + local i = 1 + while i <= #s do + local b = s:byte(i) + local n = (b < 0x80 and 1) or (b >= 0xF0 and 4) or (b >= 0xE0 and 3) + or (b >= 0xC0 and 2) or nil + if not n then error("UTF-8 decoding error: unexpected continuation", 0) end + for k = 1, n - 1 do + local c = s:byte(i + k) + if not c or c < 0x80 or c >= 0xC0 then + error("UTF-8 decoding error: Not enough space", 0) + end + end + i = i + n + end + -- 10px per codepoint, whatever its byte length + local count = 0 + i = 1 + while i <= #s do + local b = s:byte(i) + i = i + ((b < 0x80 and 1) or (b >= 0xF0 and 4) or (b >= 0xE0 and 3) or 2) + count = count + 1 + end + return count * 10 + end + + local jp = "ポケットモンスター" -- 9 codepoints, 27 bytes + local ok, cut = pcall(Theme.ellipsize, font, jp, 55) + T.check(ok, "ellipsize does not raise on multi-byte text: " .. tostring(cut)) + T.check(font:getWidth(cut) <= 55 + 1e-9, "the result fits the budget") + local ok2 = pcall(font.getWidth, font, cut) + T.check(ok2, "the truncated string is still valid UTF-8") + + local okL, cutL = pcall(Theme.ellipsizeLeft, font, jp, 55) + T.check(okL, "ellipsizeLeft does not raise on multi-byte text") + T.check(pcall(font.getWidth, font, cutL), + "the left-truncated string is still valid UTF-8") + + T.eq(Theme.ellipsize(font, jp, 0), "", + "a zero budget means nothing fits, not everything fits") + T.eq(Theme.ellipsize(font, "abc", 999), "abc", + "text that already fits is returned untouched") +end + +T.finish("ui_kit_pagination") 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..0dd82311a 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -13,22 +13,22 @@ 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 }, }, }) 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") @@ -41,6 +41,13 @@ eq(noPayload.version, "2.0.0", "version parsed without assets") eq(noPayload.payload, nil, "no payload asset -> nil") eq(noPayload.sums, nil, "no sums asset -> nil") +-- Assets from the other engine must not be admitted as this engine's update. +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 = 99 }, +} })) +check(foreign ~= nil, "foreign-asset release metadata is parseable") +eq(foreign.payload, nil, "a Gen1 payload cannot replace a Gen2 runtime") + -- rejects: non-semver tag, and a document with no tag at all local bad, badErr = Check.parseRelease(Json.encode({ tag_name = "nightly" })) eq(bad, nil, "non-X.Y.Z tag rejected") @@ -50,13 +57,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..cb8c79337 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") @@ -226,7 +226,7 @@ local body = Json.encode({ 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 +268,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 +294,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..804a7f816 100644 --- a/tests/engine/wide_battle_layout.lua +++ b/tests/engine/wide_battle_layout.lua @@ -38,13 +38,44 @@ 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") + +local mx, my = WideBattle.animationOffset({ { x = 79 } }) +T.eq(mx, 78, "a crossing frame interpolates halfway between picture anchors") +T.eq(my, 4, "the crossing baseline interpolates with the same fraction") +local ox = WideBattle.animationOffset({ { x = 20 } }) +T.check(ox < 20, "motion can overshoot the player anchor without the old 40px clamp") -- 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..d8cc8e062 --- /dev/null +++ b/tests/goldens/fixture_fingerprint.md @@ -0,0 +1,36 @@ +# 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. diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 3ab0752dc..77ad32cd7 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -7de3051a7b3c108e +41e69caf4afa35ab diff --git a/tests/goldens/fixture_fingerprint_sections.txt b/tests/goldens/fixture_fingerprint_sections.txt new file mode 100644 index 000000000..2e9081fa0 --- /dev/null +++ b/tests/goldens/fixture_fingerprint_sections.txt @@ -0,0 +1,7 @@ +pokemon 0ad4d8a59c0daa59 +moves 54b3cb2c1776ed48 +type_chart cc5bd80d003bd459 +statuses 68247ced2ed19a21 +move_effects 3bef37494ca4a845 +constants e4cf6f91ff122fa5 +mods 6a9ee9de68f78902 diff --git a/tests/native_file_io/conf.lua b/tests/native_file_io/conf.lua new file mode 100644 index 000000000..3a9cbdf10 --- /dev/null +++ b/tests/native_file_io/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity='gen2-native-file-io-qa' + t.window=false + t.modules.graphics=false + t.modules.audio=false + t.modules.joystick=false +end diff --git a/tests/native_file_io/main.lua b/tests/native_file_io/main.lua new file mode 100644 index 000000000..671c34b76 --- /dev/null +++ b/tests/native_file_io/main.lua @@ -0,0 +1,49 @@ +-- Actual Windows/LuaJIT/LÖVE file test, launched with the repository root as +-- the one argument. No ROM, mod, player profile, dialog or GPU is used. +function love.load(args) + local root=assert(args[1],'repository root required'):gsub('\\','/') + package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path + local Native=require('src.core.NativeFile') + local Picker=require('src.core.FilePicker') + local fs=love.filesystem + local checks=0 + local function eq(a,b,label) + checks=checks+1;assert(a==b,(label or 'values differ')..': '..tostring(a)..' ~= '..tostring(b)) + end + local dir='owned-'..tostring(os.time())..'-'..love.math.random(1,999999)..'-用户-é-🎮' + local files={} + local oldOpen=io.open + local ok,why=xpcall(function() + eq(love.system.getOS(),'Windows','real Windows host') + eq(require('ffi').os,'Windows','real Windows FFI') + assert(fs.createDirectory(dir)) + local expected=string.rep('\0PNG\255\r\n',20000)..'end' + for _,name in ipairs{'Pokémon é.png','精灵 🎮.spritepack','ascii.png'}do + local rel=dir..'/'..name;files[#files+1]=rel + assert(fs.write(rel,expected)) + local path=fs.getSaveDirectory()..'/'..rel + io.open=function()error('ANSI io.open must not be used on Windows')end + eq(Native.read(path),expected,'wide read exact multi-block binary') + eq(Picker.read(path),expected,'public picker uses native wide reader') + eq(Picker.basename(path),name,'original name retained') + io.open=oldOpen + assert(fs.write(rel,'replacement')) + eq(Picker.read(path),'replacement','readonly handle released before replacement') + eq(fs.remove(rel),true,'read handles closed before removal') + end + -- These refusals still close any opened handle and cannot alias a path + -- through the current Windows ANSI codepage. + local missing=fs.getSaveDirectory()..'/'..dir..'/missing.png' + local bytes,err=Native.read(missing);eq(bytes,nil,'missing path');eq(type(err),'string') + bytes,err=Native.read(missing..'\255');eq(bytes,nil,'invalid UTF-8 refused');eq(err,'invalid UTF-8 file path') + local empty=dir..'/empty.png';files[#files+1]=empty;assert(fs.write(empty,'')) + bytes,err=Native.read(fs.getSaveDirectory()..'/'..empty);eq(bytes,nil);eq(err,'empty file') + eq(fs.remove(empty),true,'empty-file handle closed') + end,debug.traceback) + io.open=oldOpen + for _,rel in ipairs(files)do fs.remove(rel)end + fs.remove(dir) + if ok then print('NATIVE_FILE_IO_WINDOWS '..checks..' checks passed') + else io.stderr:write(tostring(why)..'\n')end + love.event.quit(ok and 0 or 1) +end diff --git a/tests/parity_picker_pointer_grab.lua b/tests/parity_picker_pointer_grab.lua index 6b5e56bcb..abb35380b 100644 --- a/tests/parity_picker_pointer_grab.lua +++ b/tests/parity_picker_pointer_grab.lua @@ -69,7 +69,8 @@ do -- the punctuation is the point: `io.popen(` or `io.popen,` is a CALL, -- and the prose above it in that file mentions the name twice local calls = 0 - for _ in host:gmatch("io%.popen[,(]") do calls = calls + 1 end + local code = host:gsub("%-%-[^\n]*", "") + for _ in code:gmatch("io%.popen[,(]") do calls = calls + 1 end eq(calls, 1, "and the whole engine holds exactly one io.popen, in " .. "HostShell -- which is what makes 'every picker funnels through " .. "commandOutput' a statement about all of them") diff --git a/tests/skin_fs_fixture.lua b/tests/skin_fs_fixture.lua new file mode 100644 index 000000000..8d9394288 --- /dev/null +++ b/tests/skin_fs_fixture.lua @@ -0,0 +1,50 @@ +-- Memory PhysFS fixture for archive-member tests; no real ZIP/GPU acceptance. +local function fixture() + local fs, files, mounts = {}, {}, {} + local function resolve(path) + if files[path] then return path end + for i=#mounts,1,-1 do + local m=mounts[i] + if path==m.point then return m.archive end + if path:sub(1,#m.point+1)==m.point..'/' then + return m.archive..'/'..path:sub(#m.point+2) + end + end + return path + end + function fs.read(path) return files[resolve(path)] end + function fs.write(path,data) files[path]=data; return true end + function fs.remove(path) files[path]=nil; return true end + function fs.getInfo(path) + path=resolve(path) + if files[path] then return {type='file'} end + for key in pairs(files) do + if key:sub(1,#path+1)==path..'/' then return {type='directory'} end + end + end + function fs.getDirectoryItems(path) + path=resolve(path) + local out,seen={},{} + for key in pairs(files) do + if key:sub(1,#path+1)==path..'/' then + local name=key:sub(#path+2):match('^[^/]+') + if name and not seen[name] then seen[name]=true;out[#out+1]=name end + end + end + table.sort(out);return out + end + function fs.mount(archive,point) + if not files[archive] then return false end + for _,m in ipairs(mounts) do if m.archive==archive then return false end end + mounts[#mounts+1]={archive=archive,point=point};return true + end + function fs.unmount(archive) + for i=#mounts,1,-1 do if mounts[i].archive==archive then table.remove(mounts,i);return true end end + return false + end + function fs.createDirectory() return true end + function fs.newFileData(bytes,name) return {contents=bytes,name=name} end + function fs.getSaveDirectory() return '/memory-only-skin-fixture' end + return fs +end +return fixture diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py new file mode 100644 index 000000000..061f96aa6 --- /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", "render_output", "viewport"): + 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 1 then + local G = love.graphics + G.setColor(0, 1, 0, 1) + G.rectangle("fill", 0, 0, 12, 12) + G.setColor(1, 1, 1, 1) + end + end + start() + end, debug.traceback) + if not ok then + error(why) + end +end +local function expected(pixels, rectangle, blue) + local pw, ph = pixels:getDimensions() + local dx, dy = pw / 320, ph / 288 + for y = 0, ph - 1 do + for x = 0, pw - 1 do + local target + if x < 12 * dx and y < 12 * dy then + target = { 0, 1, 0, 1 } + elseif + rectangle + and ( + x < rectangle.x * dx + or y < rectangle.y * dy + or x >= (rectangle.x + rectangle.width) * dx + or y >= (rectangle.y + rectangle.height) * dy + ) + then + target = blue and { 0, 0, 1, 1 } or { 0, 0, 0, 1 } + else + local bx, by = x, y + if rectangle then + bx = math.floor((x - rectangle.x * dx) * baseline:getWidth() / (rectangle.width * dx)) + by = math.floor((y - rectangle.y * dy) * baseline:getHeight() / (rectangle.height * dy)) + end + target = { baseline:getPixel(bx, by) } + end + local got = { pixels:getPixel(x, y) } + for channel = 1, 4 do + check(near(got[channel], target[channel]), "frame pixel " .. x .. "," .. y .. " channel " .. channel) + end + end + end +end +function love.draw() + if waiting then + return + end + waiting = true + local ok, why = xpcall(function() + fixture.exports.window(nil) + local rectangle + if phase >= 2 and phase <= 5 then + rectangle = { x = 80, y = 72, width = 160, height = 144 } + elseif phase == 7 then + rectangle = { x = 0, y = 0, width = 320, height = 288, capture = true } + elseif phase == 8 then + rectangle = { x = 0, y = 0, width = 160, height = 144 } + elseif phase == 9 then + rectangle = { x = 20, y = 20, width = 161, height = 144 } + end + fixture.exports.request(rectangle) + if phase == 3 then + fixture.exports.window(function(next, g, ctx) + love.graphics.setColor(0, 0, 1, 1) + love.graphics.rectangle("fill", 0, 0, ctx.windowWidth, ctx.windowHeight) + return next(g, ctx) + end) + end + if phase == 4 then + fixture.exports.window(function() + local G = love.graphics + G.push("all") + G.setCanvas(foreign) + G.setShader(shader) + G.translate(50, 70) + error("injected window compositor failure") + end) + end + if phase == 5 then + fixture.exports.window(function(next, g, ctx) + V.reset() + return next(g, ctx) + end) + end + local G = love.graphics + local oldNew = G.newCanvas + if phase == 9 then + G.newCanvas = function() + error("injected viewport allocation failure") + end + end + local depth = G.getStackDepth() + local drawn, err = xpcall(function() + Game.draw(game) + end, debug.traceback) + G.newCanvas = oldNew + if not drawn then + error(err) + end + check(G.getCanvas() == nil, "OS window is final target") + check(G.getShader() == nil, "window compositor cannot leak shader") + check(G.getStackDepth() == depth, "graphics stack balanced") + local x, y = G.transformPoint(0, 0) + check(x == 0 and y == 0, "window transform restored") + if phase == 1 then + check(not V.active() and V.target() == nil, "default path has no viewport target") + end + if phase == 5 or phase == 9 then + check(not V.active(), "retirement/allocation failure clears capture") + end + G.captureScreenshot(function(pixels) + local good, problem = xpcall(function() + if phase == 1 then + baseline = pixels + else + local expectedRectangle = rectangle + if phase == 9 then + expectedRectangle = nil + end + expected(pixels, expectedRectangle, phase == 3) + pixels:release() + end + phase = phase + 1 + if phase == 10 then + baseline:release() + index = index + 1 + phase = 1 + if index > #versions then + print("GAME VIEWPORT GPU: " .. checks .. " checks passed across Red/Gold/FireRed") + local a, b, c, d = G.getRendererInfo() + print(table.concat({ a, b, c, d }, " | ")) + love.event.quit(0) + return + end + start() + end + waiting = false + end, debug.traceback) + if not good then + io.stderr:write(problem .. "\n") + love.event.quit(1) + end + end) + end, debug.traceback) + if not ok then + io.stderr:write(why .. "\n") + love.event.quit(1) + end +end diff --git a/tools/modkit.py b/tools/modkit.py index fb0b91499..91c23ffa4 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -323,10 +323,9 @@ def check_requires(repo, mod_dir, manifest): "id": "{{id}}", "name": "{{name}}", "version": "0.1.0", - "api": 2, - "entry": "main.lua", - "profile": "{{profile}}", - "game_version": ">={{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() diff --git a/tools/render-output-check/conf.lua b/tools/render-output-check/conf.lua new file mode 100644 index 000000000..cb7f5b452 --- /dev/null +++ b/tools/render-output-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "gen2-render-output-check" + t.window.title = "Gen2Recomped output contract GPU check" + t.window.width, t.window.height = 320, 288 + t.window.highdpi = true + t.modules.audio = false +end diff --git a/tools/render-output-check/main.lua b/tools/render-output-check/main.lua new file mode 100644 index 000000000..b3265808f --- /dev/null +++ b/tools/render-output-check/main.lua @@ -0,0 +1,165 @@ +-- Actual Renderer, public API-3 mod, real GL draw/readback. No ROM or settings. +function love.errorhandler(message) + io.stderr:write(tostring(message).."\n"..debug.traceback().."\n") + return function() love.event.quit(1) end +end +local root = love.filesystem.getSource():match("^(.*)/tools/render%-output%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +love.filesystem.mount(root, "", true) +local Contract = require("tests.api3_contract.render_output") +local Renderer = require("src.render.Renderer") +local Palette = require("src.render.PaletteFX") +local GBCFX = require("src.render.GBCFX") +local checks, phase, waiting = 0, 1, false +local baseline, fixture, shader, foreign +local function check(ok, message) + checks = checks + 1 + assert(ok, message) +end +local function near(a, b) + return math.abs(a - b) < 1.1 / 255 +end +local function compare(pixels, inverse, hud) + check( + pixels:getWidth() == baseline:getWidth() and pixels:getHeight() == baseline:getHeight(), + "frame dimensions" + ) + local pw, ph = pixels:getDimensions() + for y = 0, ph - 1 do + for x = 0, pw - 1 do + local a, b = { pixels:getPixel(x, y) }, { baseline:getPixel(x, y) } + if hud and x < 20 * (pw / 320) and y < 20 * (ph / 288) then + b = { 0, 1, 0, 1 } + elseif inverse then + for i = 1, 3 do + b[i] = 1 - b[i] + end + end + for i = 1, 4 do + check(near(a[i], b[i]), "GPU pixel " .. x .. "," .. y .. " channel " .. i .. " phase " .. phase) + end + end + end +end +local function scene() + Renderer:setUISize(160, 144) + Renderer:beginFrame() + local G = love.graphics + G.clear(1, 1, 1, 1) + G.setColor(0.2, 0.2, 0.2, 1) + G.rectangle("fill", 0, 0, 80, 72) + G.setColor(0.7, 0.7, 0.7, 1) + G.rectangle("fill", 80, 72, 80, 72) + G.setColor(1, 1, 1, 1) +end +function love.load() + local ok, why = xpcall(function() + Renderer:init() + Palette.setMode("dmg") + GBCFX.setLevel(0) + fixture = Contract.fixture(root .. "/tests/api3_contract", "gen2", "red") + shader = love.graphics.newShader( + [[vec4 effect(vec4 c,Image t,vec2 uv,vec2 p){vec4 s=Texel(t,uv);return vec4(vec3(1.0)-s.rgb,s.a)*c;}]] + ) + foreign = love.graphics.newCanvas(4, 4) + end, debug.traceback) + if not ok then + io.stderr:write(why .. "\n") + love.event.quit(1) + end +end +function love.draw() + if waiting then + return + end + waiting = true + local ok, why = xpcall(function() + fixture.exports.state(phase ~= 1 and phase ~= 7, true) + fixture.exports.gate(nil) + fixture.exports.callback(nil) + if phase == 2 then + fixture.exports.state(true, false) + end + if phase == 3 then + fixture.exports.callback(function(ctx) + love.graphics.draw(ctx.canvas) + return true + end) + end + if phase == 4 then + fixture.exports.callback(function(ctx) + local G = love.graphics + G.setShader(shader) + G.draw(ctx.canvas) + G.push("all") + G.translate(30, 40) + G.setCanvas(foreign) + G.setColor(1, 0, 0, 1) + G.setBlendMode("add") + return true + end) + end + if phase == 5 then + fixture.exports.callback(function() + local G = love.graphics + G.setCanvas(foreign) + G.setShader(shader) + G.translate(70, 90) + error("injected output draw failure") + end) + end + if phase == 6 then + fixture.exports.gate(function() + love.graphics.setShader(shader) + love.graphics.setCanvas(foreign) + return 1 + end) + end + if phase == 7 then + fixture.loader:_rollback("render_output") + end + scene() + local depth = love.graphics.getStackDepth() + Renderer:endFrame() + check(love.graphics.getCanvas() == nil, "frame target restored") + check(love.graphics.getShader() == nil, "shader restored") + check(love.graphics.getStackDepth() == depth, "graphics stack balanced") + local x, y = love.graphics.transformPoint(0, 0) + check(x == 0 and y == 0, "transform restored") + if phase == 1 then + check(Renderer.presentCanvas == nil, "disabled owner creates no present target") + end + if phase == 4 then + love.graphics.setColor(0, 1, 0, 1) + love.graphics.rectangle("fill", 0, 0, 20, 20) + love.graphics.setColor(1, 1, 1, 1) + end + love.graphics.captureScreenshot(function(pixels) + local good, err = xpcall(function() + if phase == 1 then + baseline = pixels + else + compare(pixels, phase == 4, phase == 4) + pixels:release() + end + phase = phase + 1 + if phase == 8 then + print("RENDER OUTPUT GPU: " .. checks .. " checks passed") + local a, b, c, d = love.graphics.getRendererInfo() + print(table.concat({ a, b, c, d }, " | ")) + love.event.quit(0) + end + waiting = false + end, debug.traceback) + if not good then + io.stderr:write(err .. "\n") + love.event.quit(1) + end + end) + end, debug.traceback) + if not ok then + io.stderr:write(why .. "\n") + love.event.quit(1) + end +end