diff --git a/.github/workflows/api3-contract.yml b/.github/workflows/api3-contract.yml new file mode 100644 index 000000000..4da038a33 --- /dev/null +++ b/.github/workflows/api3-contract.yml @@ -0,0 +1,60 @@ +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/**' + push: + branches: [main, dev] + paths: + - '.github/workflows/api3-contract.yml' + - 'src/**' + - 'tests/api3_contract/**' + - 'tests/engine/api3_*.lua' + - 'tools/api3-contract-check/**' +permissions: + contents: read +concurrency: + group: api3-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + compare: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + reference: [79ea62f45fb2ea8f65d081a109431e4eb1bbdb36, 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91] + steps: + - uses: actions/checkout@v7 + with: + path: gen2 + sparse-checkout: | + src + data + tests + tools/api3-contract-check + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: ${{ matrix.reference }} + 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: 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-${{ matrix.reference }} + path: ${{ runner.temp }}/api3-report.json + if-no-files-found: error diff --git a/.github/workflows/async-get.yml b/.github/workflows/async-get.yml new file mode 100644 index 000000000..5cf399334 --- /dev/null +++ b/.github/workflows/async-get.yml @@ -0,0 +1,38 @@ +name: Async mod HTTP and file downloads +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + transport: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y love + - name: Exercise real worker requests against a local server + shell: python + run: | + import os, pathlib, shutil, subprocess, urllib.request, zipfile + host = os.environ['RUNNER_OS'] + if host == 'Linux': + executable = shutil.which('love') + else: + suffix = 'macos' if host == 'macOS' else 'win64' + target = pathlib.Path(os.environ['RUNNER_TEMP']) / 'love-get-check' + target.mkdir() + archive = target / 'runtime.zip' + urllib.request.urlretrieve(f'https://github.com/love2d/love/releases/download/11.5/love-11.5-{suffix}.zip', archive) + with zipfile.ZipFile(archive) as z: z.extractall(target) + executable = next(target.rglob('love.app/Contents/MacOS/love')) if host == 'macOS' else next(target.rglob('lovec.exe')) + if host == 'macOS': executable.chmod(0o755) + subprocess.run(['python', 'tools/check_async_get.py', '--love', str(executable), '--tls'], check=True) diff --git a/.github/workflows/core-update-contract.yml b/.github/workflows/core-update-contract.yml new file mode 100644 index 000000000..3f5fd0b26 --- /dev/null +++ b/.github/workflows/core-update-contract.yml @@ -0,0 +1,43 @@ +name: portable core.update contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: core-update-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check frame dispatch and native host routing + run: | + luajit tests/engine/core_update.lua + luajit tests/engine/core_update_routing.lua + - name: Compare both native engines through the same mod + run: | + mkdir -p /tmp/core-update-contract + python3 tools/core-update-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/core-update-contract/api2-native.json + - name: Check unified callback and API-3 integration + run: | + python3 tools/core-update-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/core-update-contract/api2-unified.json + python3 tools/core-update-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/core-update-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: core-update-contract + path: /tmp/core-update-contract + if-no-files-found: ignore diff --git a/.github/workflows/gba-modal.yml b/.github/workflows/gba-modal.yml new file mode 100644 index 000000000..1042b6ee2 --- /dev/null +++ b/.github/workflows/gba-modal.yml @@ -0,0 +1,44 @@ +name: Native GBA modal ownership +on: + pull_request: + paths: + - '.github/workflows/gba-modal.yml' + - 'src/**' + - 'tests/gba_modal/**' + - 'tests/engine/gen3_modal*.lua' + - 'tools/gba-modal-check/**' + workflow_dispatch: +permissions: + contents: read +jobs: + native-modal: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + path: gen2 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91 + 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 ownership, reload, input and visibility + working-directory: gen2 + run: | + luajit tests/engine/gen3_modal_stack.lua + luajit tests/engine/gen3_modal_shared.lua + - name: Identical consumer on pinned native engines + run: python3 gen2/tools/gba-modal-check/check.py --gen1 gen1-reference --gen2 gen2 --report "${{ runner.temp }}/gba-modal.json" + - uses: actions/upload-artifact@v7 + with: + name: native-gba-modal-comparison + path: ${{ runner.temp }}/gba-modal.json + if-no-files-found: error diff --git a/.github/workflows/input-hook-contract.yml b/.github/workflows/input-hook-contract.yml new file mode 100644 index 000000000..fa6ad76cf --- /dev/null +++ b/.github/workflows/input-hook-contract.yml @@ -0,0 +1,43 @@ +name: portable input hook contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: input-hook-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native input and host routing + run: | + luajit tests/engine/portable_input_hooks.lua + luajit tests/engine/portable_input_routing.lua + - name: Compare both native engines through the same mod + run: | + mkdir -p /tmp/input-hook-contract + python3 tools/input-hook-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/input-hook-contract/api2-native.json + - name: Check unified callback and API-3 integration + run: | + python3 tools/input-hook-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/input-hook-contract/api2-unified.json + python3 tools/input-hook-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/input-hook-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: input-hook-contract + path: /tmp/input-hook-contract + if-no-files-found: ignore diff --git a/.github/workflows/logic-speed-contract.yml b/.github/workflows/logic-speed-contract.yml new file mode 100644 index 000000000..502f0fc04 --- /dev/null +++ b/.github/workflows/logic-speed-contract.yml @@ -0,0 +1,46 @@ +name: portable core.logic_speed contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: logic-speed-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native speed dispatch, ownership and clocks + run: | + luajit tests/engine/logic_speed.lua + luajit tests/engine/logic_speed_routing.lua + luajit tests/engine/logic_speed_clock.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/logic-speed-contract + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/logic-speed-contract/api1-native.json + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/logic-speed-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/logic-speed-contract/api2-unified.json + python3 tools/logic-speed-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/logic-speed-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: logic-speed-contract + path: /tmp/logic-speed-contract + if-no-files-found: ignore diff --git a/.github/workflows/mod-asset-packs.yml b/.github/workflows/mod-asset-packs.yml new file mode 100644 index 000000000..af40962ff --- /dev/null +++ b/.github/workflows/mod-asset-packs.yml @@ -0,0 +1,37 @@ +name: Portable asset pack contracts +on: + pull_request: + paths: + - 'src/import/Pack*.lua' + - 'src/import/Importers.lua' + - 'src/import/CacheFs.lua' + - 'src/import/LuaWriter.lua' + - 'src/import/lttp/**' + - 'src/import/snes/**' + - 'src/import/RomImporter.lua' + - 'src/mods/AssetPacks.lua' + - 'src/mods/StreamMD5.lua' + - 'src/mods/Manifest.lua' + - 'src/mods/Loader.lua' + - 'src/mods/LauncherMods.lua' + - 'src/mods/ManagerState.lua' + - 'tests/engine/asset_pack_contracts.lua' + - 'tests/engine/native_pack_producers.lua' + - 'tests/pack_sources/**' + - 'tests/asset_pack_runtime/**' + - 'tests/native_pack_runtime/**' + - '.github/workflows/mod-asset-packs.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + packs: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - run: sudo apt-get update && sudo apt-get install -y luajit lua5.4 + - run: luajit tests/engine/asset_pack_contracts.lua + - run: luajit tests/engine/native_pack_producers.lua + - name: Check the portable data parser on plain Lua + run: lua5.4 -e 'local d=require("src.import.PackData");local w=require("src.import.LuaWriter");assert(d.decode(w.encode({1,true,"x"}))[3]=="x");assert(not d.decode("return os.exit()"))' diff --git a/.github/workflows/mod-input-contract.yml b/.github/workflows/mod-input-contract.yml new file mode 100644 index 000000000..acbbad865 --- /dev/null +++ b/.github/workflows/mod-input-contract.yml @@ -0,0 +1,44 @@ +name: portable logical mod input contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: mod-input-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check logical input, headless loading and lifecycle + run: | + luajit tests/engine/portable_mod_input.lua + luajit tests/engine/mod_input_lifecycle.lua + luajit tests/engine/mod_input_headless.lua + - name: Compare both native engines through the same mod + run: | + mkdir -p /tmp/mod-input-contract + python3 tools/mod-input-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/mod-input-contract/api2-native.json + - name: Check unified callback and API-3 integration + run: | + python3 tools/mod-input-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/mod-input-contract/api2-unified.json + python3 tools/mod-input-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/mod-input-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: mod-input-contract + path: /tmp/mod-input-contract + if-no-files-found: ignore diff --git a/.github/workflows/mod-native-http.yml b/.github/workflows/mod-native-http.yml new file mode 100644 index 000000000..54929ffcb --- /dev/null +++ b/.github/workflows/mod-native-http.yml @@ -0,0 +1,71 @@ +name: Native bounded HTTP +on: + pull_request: + paths: + - 'src/mods/LogReport.lua' + - 'src/mods/Net.lua' + - 'src/mods/Manifest.lua' + - 'src/mods/Loader.lua' + - 'src/net/**' + - 'src/core/HostShell.lua' + - 'mobile/ios/native/GRHTTPBridge.swift' + - 'mobile/ios/patch_love_src.py' + - 'mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java' + - 'mobile/android/love/src/main/java/org/love2d/android/GameActivity.java' + - 'mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp' + - 'tests/native_http/**' + - 'tests/engine/log_reporting.lua' + - 'tests/engine/bounded_http.lua' + - 'tools/qualify_native_http.py' + - '.github/workflows/mod-native-http.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: native-bounded-http-${{ github.ref }} + cancel-in-progress: true +jobs: + android-bridge-build: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - name: Install native Android build dependencies + run: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "platforms;android-34" "build-tools;34.0.0" "ndk;25.2.9519653"' + - name: Compile the Android HTTP adapter and native Lua binding + working-directory: mobile/android + run: bash ./gradlew --no-daemon :love:compileEmbedDebugJavaWithJavac :love:externalNativeBuildEmbedDebug + native-protocols: + runs-on: macos-15 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - run: brew install luajit + - run: luajit tests/engine/log_reporting.lua && luajit tests/engine/async_get_queue.lua && luajit tests/engine/bounded_http.lua + - name: Verify the pinned host-test dependency + shell: bash + run: | + set -euo pipefail + curl --fail --location https://repo.maven.apache.org/maven2/org/json/json/20240303/json-20240303.jar -o "$RUNNER_TEMP/json-20240303.jar" + printf '%s %s\n' 3cf6cd6892e32e2b4c1c39e0f52f5248a2f5b37646fdfbb79a66b46b618414ed "$RUNNER_TEMP/json-20240303.jar" | shasum -a 256 -c - + - name: Exercise native adapter logic and typecheck iOS + shell: bash + run: | + python3 tools/qualify_native_http.py --swiftc "$(xcrun --find swiftc)" --sdk "$(xcrun --sdk macosx --show-sdk-path)" --ios-sdk "$(xcrun --sdk iphoneos --show-sdk-path)" --java-home "$JAVA_HOME" --json-jar "$RUNNER_TEMP/json-20240303.jar" --output "$RUNNER_TEMP/native-http" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: native-bounded-http + path: | + ${{ runner.temp }}/native-http/*.json + ${{ runner.temp }}/native-http/*.log + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/screen-visibility-contract.yml b/.github/workflows/screen-visibility-contract.yml new file mode 100644 index 000000000..f616e6f26 --- /dev/null +++ b/.github/workflows/screen-visibility-contract.yml @@ -0,0 +1,45 @@ +name: portable screen.render_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: screen-visibility-contract-${{ github.ref }} + cancel-in-progress: true +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: bryanthaboi/gen1recomp + ref: 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36 + path: .contract-reference/gen1 + - name: Install ROM-free test runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check native composition and input/update ownership + run: | + luajit tests/engine/screen_visibility.lua + luajit tests/engine/screen_visibility_routing.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/screen-visibility-contract + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/screen-visibility-contract/api1-native.json + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/screen-visibility-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/screen-visibility-contract/api2-unified.json + python3 tools/screen-visibility-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/screen-visibility-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: screen-visibility-contract + path: /tmp/screen-visibility-contract + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 0ba18515f..7a048e237 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,9 @@ 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 compatibility fixtures; external source remains outside this checkout. +!/tests/fixtures/ +/tests/fixtures/* +!/tests/fixtures/upstream_mods/ +!/tests/engine/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..f7d981d14 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ +# Hinweise zur lokalen QA + +## Vollständige automatische QA-Speicherpflege +Alle neuen Teststarter verwenden `tools/qa_storage.py`: Eingaben vor der ersten Bereinigung mit `qa_source` pinnen, eigene eingefrorene Kopien vor dem Schreiben mit `qa_copy`, QA-Profile mit `qa_profile` und Ausgaben mit `qa_cache` sperren. QA-Prozesse und Kopierkommandos mit `qa_run`/`qa_popen` starten. Die freie Reserve beträgt jetzt 20 GiB. Ein globales 4-GiB-/24-Stunden-Budget gilt gemeinsam für alte und zusätzliche Testkopien. Der lokale LaunchAgent ergänzt alle 15 Minuten die sichere Entdeckung älterer Layouts. Tatsächliche Quell-Checkouts, Modelle, ROM-Originale und Spielstände erhalten; Kopienstände vor Entfernen mit `qa_preserved.py` dauerhaft sichern. Keine eigenen pauschalen `rm`-/Altersbereinigungen ergänzen. Details und Regressionen: `docs/QA-STORAGE.md`, `python3 -m unittest discover -s tests -p 'qa*storage*test.py'`. 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/docs/LEAFGREEN_SUPPORT.md b/docs/LEAFGREEN_SUPPORT.md new file mode 100644 index 000000000..04471aabb --- /dev/null +++ b/docs/LEAFGREEN_SUPPORT.md @@ -0,0 +1,92 @@ +# LeafGreen native cartridge support + +The USA Rev 0 LeafGreen cartridge (`BPGE0`, SHA-1 +`574fa542ffebb14be69902d1d36f1ec0a4afd71e`) has its own manifest, +`leafgreen/` import cache and `_leafgreen` save suffix. Unsupported revisions +remain rejected. This is alpha support, like the existing Gen 3 entries. + +FR/LG share script, inventory, battle and field rules. `GameVersion.isFRLG` +selects those rules explicitly, while `GameVersion.get()` still identifies +the actual cartridge. No FireRed cache, ROM data or player save is reused. +The title uses LeafGreen's logo, Venusaur, leaf animation and cry; trade art, +the TM Case, Berry Pouch, Teachy TV and Game Corner tables use LeafGreen +addresses. + +## Manifest provenance + +`tools/gen3_sibling_manifest.py` relocates the FireRed manifest by symbol +identity from matching pret `pokefirered` ELF builds. Before using any symbol, +it compares every allocated ROM section with the caller's cartridge bytes. +Interior offsets require unchanged symbol sizes, and ambiguous matches fail. +The existing one-byte-before-text/script convention is preserved only at a +verified terminator boundary. Edition-specific leaf/flame symbols are an +explicit mapping. Per-stage overrides avoid applying FireRed addresses as +global Emerald address keys. + +Reference source: pret/pokefirered commit +`ce6406d8c2eaf46d543c8a2606de0c3cb1951982`. Both matching builds pass the +project's cartridge checksum comparison. The manifest records ELF hashes, +verified section lengths and all 780 relocated address identities. Only +offsets/layouts and hashes are committed; cartridges and extracted images, +text, audio and maps stay local. + +To regenerate, build matching FireRed and LeafGreen ELFs, then run: + +```sh +luajit tools/export_frlg_stage_profile.lua /tmp/frlg-stage-profile.json +``` + +Export `Gen3TradeArt.ROMS.BPRE0` with `src.link.Json.encode` to a separate JSON +file, then pass both profiles to `tools/gen3_sibling_manifest.py --help`'s +`--stage-profile` and `--trade-profile` arguments together with the two local +cartridges, matching ELFs, `tools/rom_manifest_firered.json` and the extractor. + +## Validation + +Native rendered run against candidate `7bd716f`, on the `13af468` upstream +baseline used for 0.8.45: + +- Full fresh import: 425 maps, 743 trainers and 124 encounter maps. +- LeafGreen title, intro, Oak speech and naming reach the player bedroom. +- Actual field input advances the player. +- Save/reload retains the LeafGreen version and player name, with no FireRed + save created in the fresh QA identity. +- A real Brock map interaction runs the native battle to victory, returns + to the field and grants the Boulder Badge, TM39 and TM Case once. A level + 100 Mewtwo is an intentional QA party fixture; this is not a full story run. +- All mods disabled. The four captured title/field/battle/reward views were + inspected locally. + +The host was macOS with LÖVE 12/Metal and a QA-only renderer configuration. +This is not a physical iPhone/iPad test. Every run used a fresh own QA save +identity and a migration marker written before boot. The first import-and-title +smoke and a later driver with an incorrect coordinate assertion are retained +locally as superseded evidence, not counted as full gameplay passes. + +`tests/engine/leafgreen_registration.lua` and `leafgreen_manifest.lua` cover +canonical registration, unsupported revisions, FR/LG inventory rules, mixed +Lua/JSON records and restoring original tables before another edition import. +The engine suite has ten pre-existing failures, reproduced on unmodified +`13af468`; this change adds no failing suite. `tests/leafgreen_native_driver.lua` +and `tools/run_gen3_native_smoke.py` reproduce the native opening/battle check +using the caller's cartridge, LÖVE binary and shared QA lock. + +Remaining scope: full story/post-game, link play and physical mobile QA are +not certified. Existing extractor warnings for unsupported Emerald-only +stages and the existing missing `scenes.lua`/generic `summary_info.png` cache +warnings are not being reported as completed LeafGreen features. + +Completed walking steps now use the existing native Gen3 friendship routine +for every Gen3 edition, rather than only Emerald. This also prevents the +generic +2/+2/+1 fallback from being applied to LeafGreen or FireRed. +`tests/gen3_walking_friendship.lua` executes the actual controller branch +for all three Gen3 registrations in this contribution, both coin outcomes +and egg exclusion across 256 steps each. No full story/mobile acceptance +is implied by this dispatch correction. + +A rejected HM replacement now returns to the native summary move chooser. +The closed chooser previously left the underlying learner non-selectable +after the refusal. The actual native menu/HM-reader regression covers reopen, +unavailable-summary fallback, an existing selectable list, retired-screen +ownership and unchanged moves/PP. Presentation is stubbed in this regression; +separate real native input/renderer follow-ups remain necessary. diff --git a/docs/NATIVE-GEN2-FISHING.md b/docs/NATIVE-GEN2-FISHING.md new file mode 100644 index 000000000..9a1b57496 --- /dev/null +++ b/docs/NATIVE-GEN2-FISHING.md @@ -0,0 +1,52 @@ +# Native Gen2 fishing lifecycle + +Gold, Silver and Crystal rod use enters the native overworld's public +`useRod(rodId)` method. The native bag performs a non-random admission check, keeps a refused use +in the bag, and closes itself before calling that method. The upstream START +menu has already exited when it opens the bag. Registered-item use reaches the same method through `goFishing`. +The other generations retain their existing fishing paths. + +`rollFishing(rodId)` returns `nowhere`, `nofish`, `nibble` or `battle` plus +the native individual. It reads the current map's extracted fish group, +time table and active fishing swarm. The existing `encounter.fishing` hook +observes the resolved group and may replace the encounter once. Missing +Gen2 tables cannot fall back to the Gen1 Magikarp rule. Facing-water admission +at a map edge uses the native, un-clamped connection landing; the player +and current map do not move. + +`beginFishing(outcome, wild)` installs one shared `world.fishing` / +`player.fishingState` record with `phase`, `timer`, `outcome`, `wild`, `facing` +and a bobber in current-map cell/pixel coordinates. Native logic ticks own +the 40-frame cast and 40-frame bite, the bite emote/player pose, and the +verdict text. Presentation code can observe these records without owning +the encounter or its clocks. + +The bite verdict clears fishing ownership before native battle admission. +The battle receives the identical already-created Pokemon, including its +DVs, stats, moves and held item. The internal handoff is single-use and bound +to the live game/data/save/map/player/edition and cast position. It cannot be +used for an arbitrary replacement table or a retired session. Ordinary +`BattleState.newWild` calls continue to generate their own Pokemon. + +Map changes, retired world/save/data/player cohorts, cancellation, removed +verdict boxes and lifecycle errors release the cast. Cleanup leaves unrelated +emote/input owners intact. Public callbacks cannot reenter the active rod +admission or roll. + +The ROM-free regression is `tests/engine/gen2_fishing_lifecycle.lua`. It uses +the actual native Pokemon/battle constructors, TextBox input, bag and +registered-item paths with generated fixture data. This is separate from +validation with a caller-owned ROM, naturally earned rod and real GPU/mod +presentation. A source-contract result does not establish that gameplay or +a final packaged release has passed. + +The public lifecycle and frame contract were compared with Gen1Recomp +revision `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. Existing project rights and +notices remain applicable. Contributions by Roxas2712 are +covered by `LICENSE-ROXAS2712.md`. + +This addition extends the field-service ownership foundation in upstream PR +#327, without bundling a separate Gen2 menu implementation, species-name +registry or checkpoint system. The upstream controller retains its own +existing native battle callback; the development fork additionally binds +its checkpoint service at the controller handoff. diff --git a/docs/QA-STORAGE.md b/docs/QA-STORAGE.md new file mode 100644 index 000000000..7dbb481de --- /dev/null +++ b/docs/QA-STORAGE.md @@ -0,0 +1,168 @@ +# Automatische Testcache-Bereinigung + +Die Python-QA-Starter und `tools/perf/run_*.command` verwenden `tools/qa_storage.py`. +Die Bereinigung läuft vor neuen Testschritten und beim Beenden; es ist kein +zusätzlicher täglicher Hintergrundjob nötig. + +Alle beteiligten Worktrees teilen folgende Grenzen für unbenutzte Daten: + +| Daten | Grenze | +| --- | --- | +| Erzeugte ROM-Importkopien und installierte Origami-Testmodkopien | 2 GiB insgesamt | +| Geometrie-Payloads unter `.local/qa` | 4 GiB insgesamt | +| Exportierte QA-Engines, Mod-ZIPs und zusätzliche Referenz-/Testkopien | 4 GiB gemeinsam | +| Kompilierter ULA-Cache in eigenen QA-Profilen | 2 GiB insgesamt | +| Aufbewahrung unbenutzter Cache-Kopien | höchstens 24 Stunden | +| Freier Speicher vor neuen Testschritten und während eigener Kindprozesse | mindestens 20 GiB | + +Die ältesten unbenutzten Gruppen werden zuerst entfernt, sobald Alter oder +Budget überschritten werden. Aktive Daten und Eingaben sind durch gemeinsame +OS-Dateisperren geschützt. Kindprozesse erben diese Sperren. Auch ältere Starter, +die nur die gesamte QA-Gruppe sperren, schützen ihre Engine-Kopien. Eine als +Eingabe gesperrte QA-Identity schützt auch den zugehörigen Engine-Snapshot. +Bereits laufende ältere Programme übernehmen neue Regeln erst beim Neustart. + +## Erhaltene Daten und Quellarchiv + +Quellcode-Repositories, originale Modelle, Spielerprofile, Spielstände, Optionen, +ROM-Originale und Imports unter `baseroms` bleiben erhalten. Ebenso verbleiben +QA-Berichte, Logs, Treiber, Vergleichsbilder und `index.data` an ihrem Ort. +Beliebige fremde Mods und echte Git-Checkouts werden nicht bereinigt. + +Nur eigene `oasc-…-qa-…`-Profile sind für Import-/Testmodbereinigung zugelassen. +Bei Testmods werden ausschließlich echte Installationsordner mit passender +Manifest-ID `ORIGAMI_ASCENDANT_GEN2_PREP` oder `ORIGAMI_ASCENDANT` berücksichtigt. +Runtime-Kopien werden nur unter +`.local/qa/gen2-native-mod*/oasc-gen2recomp-qa-full109-…` oder +`.local/qa/gen2-edition-mod*/oasc-gen2recomp-qa-full109-…` registriert. + +Vor dem Entfernen einer Testmod-/Engine-/ZIP-Kopie werden **alle enthaltenen +Quellcode- und Modellbytes** in einem lokalen, inhaltsadressierten Git-Archiv +unter `~/.local/share/origami-qa-preserved.git` gesichert. Identische Inhalte +werden nur einmal gespeichert. Eine atomar geschriebene +`qa-preserved-sources.json` im jeweiligen QA-Profil oder Laufordner nennt den +Commit jedes gesicherten Unterordners. Erst nach dauerhaftem Git-Checkpoint wird +die Arbeitskopie entfernt. Schlägt das Sichern fehl, bleibt die Kopie erhalten. +Das Quellarchiv ist dauerhaft und wird von der Cache-Bereinigung nicht gelöscht. + +Wiederherstellung in einen neuen leeren Ordner: + +```sh +git --git-dir="$HOME/.local/share/origami-qa-preserved.git" archive COMMIT snapshot \ + | tar -x -C /pfad/zum/leeren-ordner --strip-components=1 +``` + +`.qa-keep` im Laufordner, Profil oder obersten `.local/qa/`-Ordner schützt +Kopien zusätzlich. Ein über `gen3/render-cache` installiertes Geometriepaket und +die über `.local/imports` verknüpften Importvorlagen registrierter Worktrees +bleiben geschützt. Die Nutzdaten dauerhaft erhaltener Originale und Nachweise +zählen nicht zu den löschbaren Cachebudgets. + +## ULA + +`qa_ula(path)` akzeptiert ausschließlich +`LOVE/ula-…-qa…/modstorage/german_rom_source/compiled`. Es löscht keine Dateien +außerhalb dieses kompilierten Caches. Der ULA-Compiler hält außerdem direkt beim +Erzeugen/Benutzen von Cache-Schlüsseln ein Limit von 2 GiB und 24 Stunden ein. +Alte Schlüssel werden zuerst entfernt. Ein einzelner übergroßer Schlüssel bleibt +nur im Speicher; die Übersetzungs-/Grafik- und Registry-Ergebnisse bleiben gleich. +Fehlgeschlagene Cache-Schreibvorgänge räumen ihre unvollständigen Nutzdaten auf. +Die Regenerierung verwendet die erhaltenen Originale und Sprachrezepte. + +## Neue Teststarter + +`qa_source(path)` beziehungsweise `qa_source_identity` pinnen Eingaben **vor** der +ersten Bereinigung. `qa_destination(path)` sperrt eigene Importziele **vor** dem +Kopieren. `qa_cache(path)` registriert Geometrie- und passende Runtime-Kopien. +`qa_ula(path)` sperrt einen kompilierten ULA-Testcache. `qa_run` und `qa_popen` +vererben Sperren und Speicherüberwachung. Explizite Ausgaben außerhalb der oben +beschriebenen QA-Verzeichnisse werden nicht pauschal als löschbar eingestuft. +Zusätzliche bekannte Layouts werden bei Teststarts und durch die regelmäßige +Kontrolle mit Schonzeit sicher aufgenommen; Details stehen unten. + +Prüfung ohne ROM oder echte Benutzerdaten: + +```sh +python3 tests/qa_storage_test.py +python3 tests/qa_extended_storage_test.py +``` + +## Vollständige Kopienpflege und regelmäßige Kontrolle + +Die bisherigen 8 GiB freie Reserve sind auf **20 GiB** angehoben. Neue verwaltete +Tests prüfen sie vor Kopien und Prozessen; laufende eigene QA-Prozessgruppen +werden beim Unterschreiten beendet. Andere Programme werden nicht beendet. + +`qa_legacy_storage.py` ergänzt die zuvor separat bereinigten Referenzkopien: +`.local/*-baseline`, `.local/baseline`, bekannte native Messordner, +`.local/engine-parity/*`, native `.local/qa/gen2recomp-qa-…/engine`, eingefrorene +QA-Referenz-/Unit-Hosts, bekannte ULA-Testmodinstallationen und ausdrücklich +benannte `dist/Origami-Ascendant-Gen2Recomped-…-candidate.zip`-Pakete. +Diese Kopien und die bisherigen Engine-/ZIP-Kopien teilen **ein** globales +Budget von **4 GiB** unbenutzter Daten und höchstens **24 Stunden** Aufbewahrung. +Erzeugte Importkopien aller elf unterstützten Editionen teilen weiter 2 GiB; +Geometrie-Payloads 4 GiB und kompilierte ULA-Testcaches 2 GiB. + +Vor Entfernen werden eigenständig lesbare Quell-/Modellbytes im bestehenden +Git-Archiv gesichert. `baseroms`, ROM-Dateien, Save-Dateien, Speicherstands- und +Einstellungsverzeichnisse verbleiben vor Ort. Echte Git-Checkouts, fremde Mods, +Spielerprofile und `.qa-keep`-Teilbäume sind geschützt. Die Löschung umfasst nur +die tatsächlich gesicherten, anschließend unverändert geprüften Dateien. +Neue oder während der Sicherung veränderte Dateien bleiben erhalten. +`qa-preserved-sources.json` im übergeordneten Verzeichnis nennt den Restore-Commit; +bei teilweise erhaltenen Ordnern wird der archivierte `snapshot` in denselben +Ordner zurückgespielt und mit den vorhandenen ROM-/Save-Teilbäumen zusammengeführt. + +Aktive Referenzen werden mit `qa_source` **vor** der ersten Bereinigung gesperrt; +`qa_copy` sperrt eine eigene eingefrorene Kopie **vor** dem Anlegen/Überschreiben. +`qa_profile` sperrt eine eigene QA-Spielidentity vor deren Installation. +`qa_cache` erfasst auch native Engine-Exporte. Kommandozeilen-Eingaben wie +`--zip`, `--engine-source`, `--reference-root` und `--cache-root` sowie explizite +QA-Referenz-Umgebungsvariablen werden zusätzlich vor der ersten Sammlung gepinnt. +Kindprozesse erben die Sperren über `qa_run`/`qa_popen`. + +Ein lokaler LaunchAgent `com.maarten.origami-qa-cleanup` prüft zusätzlich alle +15 Minuten und bei Anmeldung. Dadurch werden auch liegen gebliebene Kopien +älterer Starter entdeckt. Unregistrierte Kopien erhalten mindestens eine Stunde +Schonzeit; dabei zählt auch die tatsächliche Erstellungs-/Änderungszeit, weil +`copy2` alte Dateizeiten übernimmt. Prozessargumente, offene Dateien, Arbeitsordner +und laufende LÖVE-Identities schützen ältere Leser zusätzlich. Bei fehlender +Prozessinspektion bleiben die Kopien erhalten. Laufende ältere Starter übernehmen +die neuen Prozess- und Platzwächter beim nächsten Start. + +Der Agent verwendet eine unabhängig installierte, versionierte Kopie der vier +kleinen Python-Helfer unter `~/.local/lib/origami-qa-storage/`, ohne Netzwerkzugriff. +Er hängt nicht vom temporären Implementierungs-Worktree ab. Die kleine lokal +erstellte App `~/Applications/Origami QA Cleanup.app` trägt die gezielte +macOS-Dokumentenfreigabe; beim ersten Start kann macOS dafür einen Dialog zeigen. +Es wird kein Festplattenvollzugriff angefordert. Ein erfolgreicher echter +Hintergrundlauf muss alle registrierten Projektwurzeln lesen können; der Status +steht unter `~/.cache/origami-qa-storage/maintenance/status.json`. Das rotierende +Protokoll `~/.cache/origami-qa-storage/maintenance.log` ist mit einer Sicherung auf +insgesamt ungefähr 4 MiB begrenzt. Das Quell-/Modellarchiv ist dauerhaft; +einzigartige erhaltene Originaldaten zählen nicht zum löschbaren Cachebudget. + +Installation/Aktualisierung für alle bestehenden Projektwurzeln (jeweils ein +`--root` angeben): + +```sh +python3 tools/install_qa_maintenance.py --root /absoluter/projektordner +launchctl print gui/$(id -u)/com.maarten.origami-qa-cleanup +``` + +Deaktivieren: + +```sh +launchctl bootout gui/$(id -u)/com.maarten.origami-qa-cleanup +``` + +Die individuelle Plist liegt unter +`~/Library/LaunchAgents/com.maarten.origami-qa-cleanup.plist`. +Die zeitgesteuerte Kontrolle startet keine Spiele oder Tests. Quell-Repositories +und Modelle werden nicht nach Alter gelöscht. + +Zusätzliche Regressionen: + +```sh +python3 tests/qa_legacy_storage_test.py +``` diff --git a/docs/RUBY_NATIVE_SUPPORT.md b/docs/RUBY_NATIVE_SUPPORT.md new file mode 100644 index 000000000..3d3428684 --- /dev/null +++ b/docs/RUBY_NATIVE_SUPPORT.md @@ -0,0 +1,269 @@ +# Ruby native cartridge support + +USA Rev 0 SHA-1: `f28b6ffc97847e94a6c21a63cacf633ee5c8df1e`. This adds an independent `ruby/` +import cache, `_ruby` saves and an ALPHA launcher entry. It does not alias +Emerald's cache, map data or cartridge hash. + +## Cartridge contracts + +`tools/gen3_rs_manifest.py` combines structural table checks with matching +pret/pokeruby ELF symbol identities. Every allocated ROM section in the ELF +must match the supplied cartridge bytes before any symbol is used. Matching +reference source: pret/pokeruby +`5784633ce4ef7ade1a7f2d2d0c288e3d5e6cdd7f`; both RS matching builds pass their +checksum comparisons. The Emerald reference is also byte-verified and is used +only to identify canonical special functions. + +The generated manifest supplies 394 maps, 694 trainers, 349 items, 468 song +slots, 138 movement actions, its own pocket numbering and canonical type ids +for the cartridge's abbreviated type names. RS's raw 4bpp normal font and +mapped small healthbox font are decoded separately. Its three compressed +trainer back sheets are validated, decompressed and matched to their owners. +The 1bpp braille face uses the cartridge's own tile map, paired character +halves and MSB-first field blitter. All 64 patterns must match six independent +raised-dot masks. The six RS window/text header bytes and 32-pixel line pitch +are preserved; Emerald keeps its existing centered window contract. + +Native script special ids are mapped to the engine's existing handlers by +function identity. Extractor readers use the inverse map: a missing identity +returns no function rather than probing an unrelated native slot. 197 of the +342 specials are mapped; this is not a claim that every story feature works. + +Mauville's three RS switches are read from the verified ROM coordinate table +and checked against its map events. The shared beam rules retain RS's own +positions; five renamed Mauville/Petalburg specials map by verified function +identity. Petalburg's door frames/delays are checked against its own ROM. +The generator requires the pinned clean matching source for C-layout metadata. + +The save layout is read from the cartridge's fourteen `sSaveBlockChunks` +records, which use RAM pointers and sizes. RS's 15040-byte SaveBlock1 and +2192-byte SaveBlock2 differ from Emerald. The inventory/money encryption key +is explicitly absent; Pokemon game-of-origin remains Ruby. Flags, vars, +bag capacities and storage boundaries are checked, and all 24 substructure +orders are taken from the matching source. Only metadata and hashes are +committed; no cartridge, extracted image, map, audio or speech files are. + +The RS Pokédex entry is 36 bytes, with two description pointers. The +manifest now declares that layout, independently checked against the matching +ELF table length and the cartridge's Bulbasaur dimensions. Reading Emerald's +32-byte stride corrupted sizes and size-comparison poses for all 386 dex +entries. A version-specific cache revision rebuilds only stale RS caches; +FireRed, LeafGreen and Emerald markers retain their previous values. + +`tests/gen3_dex_entry_stride.lua ` runs the real extractor +and checks all 386 height/weight rows and all four affine pose fields against +independent raw ROM reads. The old 32-byte negative control reproduces 386 +corrupted RS species. FireRed, LeafGreen and Emerald control cartridges pass +their own 36/36/32-byte layouts. Both RS manifest generators reproduce the +committed metadata after matching-ELF byte verification. Fresh native RS imports +also confirm the corrected sentinel sizes and the RS-only completion marker. +No cartridge data is written or included by the regression test. + +## Validation and reproduction + +Candidate `6d74f51` passed the native rendered driver with all mods off: + +- Fresh import and Birch speech/naming reach the actual moving truck. +- Real directional input takes the dynamic truck warp; the normal Mom script + reaches the new house. +- Native save/reload retains the player and the Ruby version and creates + no Emerald save in the fresh QA identity. +- The actual Route 101 bag interaction opens starter selection, records the + chosen species in var `4023`, runs the first Poochyena battle and reaches + Birch's lab through the script's warp. +- Roxanne's real map interaction and native battle return to the field with + the Stone Badge and TM39. A level 100 Mewtwo is an intentional party fixture + for this separate arena test, not evidence of a full story playthrough. +- All 22 braille walls decode without unknown cells. The actual Sealed + Chamber sign opens the ABC wall at its cartridge text coordinates; B closes + the wall and resumes the native runner. Its rendered view was inspected. +- The captured field/first-battle/healthbox/reward views were inspected locally. + +Candidate `c5675e4` additionally passes `rs_gyms_native_driver.lua`: real +walking activates an RS floor switch, its beam picture/passability and saved +re-entry state agree, and Wattson's actual battle awards the Dynamo Badge and +TM34 before disabling all three switches. With a deliberate strong party and +story-state fixture, Randall's actual battle runs every one of the five door +frames in Petalburg and resumes the native script. Both views were inspected. +`rs_gym_puzzles.lua` adds 23 checks for the five aliases, RS switch coordinates, +beam collision, Emerald fallback and Petalburg door writes. + +The run used macOS LÖVE 12/Metal with a QA-only renderer configuration, a fresh +own save identity and a migration marker created before boot. Physical +iPhone/iPad, link play and full story/post-game have not been certified. + +`tests/engine/ruby_registration.lua` verifies edition identity, type ids +and native/canonical special address spaces. `ruby_save_layout.lua` +roundtrips patterned nonzero save blocks through multiple sector rotations and +checks plain RS inventory encryption and game-of-origin. `rs_braille.lua` +checks mapped 1bpp tiles, dot identities, header geometry and rejection of a +corrupted font (13/13). All five suites pass. The engine +suite is 69/79 passing; the same ten failures reproduce on the unmodified +baseline (64/74). + +Reproduce using `tools/run_gen3_native_smoke.py ruby --rom YOUR_ROM +--love YOUR_LOVE12_BINARY --lock YOUR_SHARED_QA_LOCK --driver +tests/ruby_native_driver.lua --speed 8`. The helper snapshots committed +sources and rejects cache reuse if extraction inputs changed. + +The generic summary fallback right-aligns each stat value within its own +column. Previously a value beginning at x=120 overlapped the next label at +x=132. The 15-check `summary_fallback_columns.lua` exercises worst-case +three-digit stats and full HP pairs; actual native summary/IV views were +inspected after the change. Extracted Emerald/FRLG window art keeps its own +unchanged layout. + +Remaining alpha scope includes the original RS title/intro backdrop (the +engine currently uses its fallback title), several UI art/minigame stages, +and unmapped native specials. Generic `scenes.lua`/`summary_info.png` cache +warnings remain disclosed. This is playable native support with concrete +opening/battle tests, not a complete feature-parity certification. + +RS item effects use the cartridge's own `gItemEffectTable`: 163 pointers, +starting with item 13. The generator verifies all 163 pointers against the +matching ELF and C table, including null entries. The previous import used +Emerald's address/count and produced two unrelated records for Ruby instead +of the 63 real effects. `stageOverrides.ITEM_EFFECTS` now supplies the correct +address and count; no item effects are hardcoded by name. The RS cache marker +is `rs-item-effects-layout-v2:` so affected old imports rebuild. + +`tests/gen3_rs_item_effects.lua ` executes the actual +extractor and checks 163 entries / 441 raw effect fields, Potion/Protein/Rare +Candy sentinels and the failing old-address negative control. This is import +metadata evidence; it does not certify every item-use menu or broader story. + +All Gen3 editions now dispatch completed walking steps to the existing native +`Gen3Friendship.step`, once per step. This also prevents the generic older ++2/+2/+1 fallback from being applied to Ruby. The independent regression +`tests/gen3_walking_friendship.lua` runs the actual controller branch for all +available Gen3 edition registrations, both coin outcomes and an egg across +256 steps each (all five in the combined sibling QA candidate). +This narrow step correction is not a full friendship/story acceptance. + +A rejected HM replacement now returns to the native summary move chooser. +Previously the chooser closed before the refusal and left the underlying +learner non-selectable. The actual native menu/HM-reader regression covers +chooser reopen, unavailable-summary fallback, an already-selectable list, +retired-screen ownership and unchanged moves/PP. Presentation is stubbed in +that test; separate real native input/renderer follow-ups remain necessary. + + +### RS field message geometry candidate + +The matching RS field printer calls establish text at tile (2,15), with +26 columns and four rows from the dialogue-frame code. A separate +`gen3FieldMessageWindow` record supplies the native field theme; it does not +become an Emerald battle-window scan anchor. The version-specific +`rs-field-window-v3` cache marker retains earlier dex/item-effect corrections +and requires an updated RS import. Other versions keep their cache markers. +The actual-cartridge extractor test passes the old-missing-record negative +and an unchanged native battle strip. The fresh native import now passes without mods: 240x160 field UI, +26 columns, the extracted 254-character font, two visible native text rows, +A completion and native choice input. An explicit 204-pixel test line wrapped +to four rows under the old 18-column budget and stays at two rows now. Actual +readyReport rejects the old v2 marker and accepts the fresh v3 cache. Frozen +engine source files remain unchanged. The native proof JSON records hashes; +original RS dialogue frame art, full UI and story acceptance remain open. + +## Verified RS item-action tables + +The matching-ELF generator verifies all ten `sItemPopupMenuActions` +text/handler pointer pairs and five fixed six-byte pocket rows. RS prints these +column-major; the extractor transposes four-/six-cell lists for the unchanged +native row-major menu. REGISTER retains its actual RS label and toggle. +Only RS advances to `rs-item-menu-v4:`; previous dex/item-effect/field fixes +remain. No Emerald DESELECT record is invented. + +Both cartridge CPU tests pass ten labels/handler kinds, 22 independent raw +positions, 22 actual native bag consumer cells, native grid/callback methods, +registration eligibility and missing-metadata/unknown-order negatives. +The separate native fresh no-mod run passes all five pocket action grids, +real directional input, REGISTER toggle, quantity cancellation, exactly one +Repel use, untouched other items/team, old-v3 rejection and fresh-v4 readiness. +The 240x160 field window remains intact and every frozen engine file is +unchanged. Numeric/hash-only proof: [`ruby-item-actions-native.json`](ruby-item-actions-native.json). + +The first Ruby attempt stopped during import at the unchanged 8-GiB reserve, +producing no result; that log/input remains local. The later fresh run is a +separate receipt. Original RS bag/menu artwork, Berry Tag, remaining native +UI/story and physical mobile acceptance remain open. This is not a full UI +or complete-story certification. + + +## Verified RS ball art and native send-out + +The matching-ELF generator now supplies the cartridge's twelve ball-sheet and +palette records, shared open halves, eight particle frames and colour table. +It verifies table tags/decompressed sizes and nine actual Thumb send-out/release +sites against the matching ROM, retaining source/function/ELF hashes. RS uses +its own caught jingle (371); it has no Emerald-only frame-40 click. + +Only RS advances to `rs-ball-art-v5:`. This retains the earlier dex, item-effect, +field-window and item-action fixes and requires a fresh RS import; other +editions keep their markers. `RomExtractorGen3` uses a supplied provenance +string when present and retains its existing native decoder/animation logic. + +The actual-cartridge CPU test `tests/gen3_rs_ball_anims.lua ` +checks all twelve composed three-frame sheets, particles, native 39/30-frame +send-outs, RS sounds, invalid Thumb-site/table/old-address controls and pristine +restoration. Independent private PNG decoding also matches all imported ball +and particle pixels; no ROM bytes or extracted images are in this contribution. + +A separate fresh native import with all mods disabled passes both player +genders, real native ball/particle draws, ULTRA item-to-sheet mapping, unchanged +native draw clocks, visible ball-only/particle-only negatives and exact PNG +restoration. All 8,993 frozen engine files remain unchanged during each run. +Numeric/hash-only proof: [`ruby-ball-native.json`](ruby-ball-native.json). + +This is a narrow import/send-out correction, not all balls/throws/catches, +remaining original RS art, full battle/story/link parity or physical iOS +acceptance. Previous v2/v3/v4 proof records remain historical evidence. + + +### Original terrain flag metadata + +The manifest additionally identifies this cartridge's 240 original terrain +flags by offset and SHA256 for the source-metadata reader in #246. The shared +`tools/gen3_source_flags.py` checks the full cartridge hash, pinned public +source hash, every flag byte and a shifted-range negative. The matching-ELF +manifest generator also requires the exact original table symbol offset. +Only metadata is included; no terrain bytes or extracted assets are committed. + +On the composed five-edition integration, actual native layout/map/tileset +extraction plus the compatibility observation backend in #245 matches every +map, cell, border, ordered connection and warp against the original public +import pipeline: 394 maps / 258200 padded cells for this edition. This is +CPU-only data equivalence, not new GPU/gameplay or full-mod acceptance. The +independent flag metadata reproduction passes; a new full matching-ELF rebuild +is not claimed for this metadata-only addition. Existing RS ball/field/item +metadata and cache revision remain intact; #246 composes its source-metadata +marker suffix with the cartridge revision. + + +### Two-lap RS stat flourish (v6) + +HARDEN and IRON_DEFENSE previously omitted their native stat layer: the shared +reader required the later games' three laps, whereas the verified RS task +uses two laps / 64 frames. The reader now chooses the lap signature from the +actual cartridge header (AXVE/AXPE), retaining all existing task, tile, palette, +alpha, literal-pool and drift/wrap checks. The compiled wrap immediate is +ADD124 (the compiler combines -4 and +128); the native sampler is unchanged. +Only the RS cache marker advances to `rs-stat-flourish-v6:`; the v5 ball and +earlier field/item corrections remain. A fresh RS import is required. + +`tools/rs-stat-flourish-check/check.py --rom --elf ` +independently compares both full task/step functions with the retail cartridge +and checks the two-lap C model. `tests/rs_stat_flourish_runtime.lua` checks +frames 0–63, owner identity, terminal frame 64, the existing 96-frame sampler +and unchanged non-RS markers. Initial development expected an unfused ADD128; +the independent retail comparison rejected that assumption before acceptance. + +A fresh no-mod native import on macOS LÖVE 12/Metal passes four controlled +fixtures: both moves on both sides, visible layer-only negatives, exact +same-frame restores, native draw RNG/save/frame preservation and stop at +64 frames. The numeric/hash receipt is [`ruby-stat-flourish-native-v6.json`](ruby-stat-flourish-native-v6.json). No ROM, ELF, extracted artwork, cache or save +is committed. This is an animation/import contract check, not a full battle, +story, all-move or physical-iOS certification. The first Ruby GUI fixture +passed, but its smoke wrapper failed because the driver used a different +report filename; the final committed driver and fresh runs use the runner's +`edition-result.json` contract. Earlier failure records are not reclassified. diff --git a/docs/SAPPHIRE_NATIVE_SUPPORT.md b/docs/SAPPHIRE_NATIVE_SUPPORT.md new file mode 100644 index 000000000..5afe4c7db --- /dev/null +++ b/docs/SAPPHIRE_NATIVE_SUPPORT.md @@ -0,0 +1,268 @@ +# Sapphire native cartridge support + +USA Rev 0 SHA-1: `3ccbbd45f8553c36463f13b938e833f652b793e4`. This adds an independent `sapphire/` +import cache, `_sapphire` saves and an ALPHA launcher entry. It does not alias +Emerald's cache, map data or cartridge hash. + +## Cartridge contracts + +`tools/gen3_rs_manifest.py` combines structural table checks with matching +pret/pokeruby ELF symbol identities. Every allocated ROM section in the ELF +must match the supplied cartridge bytes before any symbol is used. Matching +reference source: pret/pokeruby +`5784633ce4ef7ade1a7f2d2d0c288e3d5e6cdd7f`; both RS matching builds pass their +checksum comparisons. The Emerald reference is also byte-verified and is used +only to identify canonical special functions. + +The generated manifest supplies 394 maps, 694 trainers, 349 items, 468 song +slots, 138 movement actions, its own pocket numbering and canonical type ids +for the cartridge's abbreviated type names. RS's raw 4bpp normal font and +mapped small healthbox font are decoded separately. Its three compressed +trainer back sheets are validated, decompressed and matched to their owners. +The 1bpp braille face uses the cartridge's own tile map, paired character +halves and MSB-first field blitter. All 64 patterns must match six independent +raised-dot masks. The six RS window/text header bytes and 32-pixel line pitch +are preserved; Emerald keeps its existing centered window contract. + +Native script special ids are mapped to the engine's existing handlers by +function identity. Extractor readers use the inverse map: a missing identity +returns no function rather than probing an unrelated native slot. 197 of the +342 specials are mapped; this is not a claim that every story feature works. + +Mauville's three RS switches are read from the verified ROM coordinate table +and checked against its map events. The shared beam rules retain RS's own +positions; five renamed Mauville/Petalburg specials map by verified function +identity. Petalburg's door frames/delays are checked against its own ROM. +The generator requires the pinned clean matching source for C-layout metadata. + +The save layout is read from the cartridge's fourteen `sSaveBlockChunks` +records, which use RAM pointers and sizes. RS's 15040-byte SaveBlock1 and +2192-byte SaveBlock2 differ from Emerald. The inventory/money encryption key +is explicitly absent; Pokemon game-of-origin remains Sapphire. Flags, vars, +bag capacities and storage boundaries are checked, and all 24 substructure +orders are taken from the matching source. Only metadata and hashes are +committed; no cartridge, extracted image, map, audio or speech files are. + +The RS Pokédex entry is 36 bytes, with two description pointers. The +manifest now declares that layout, independently checked against the matching +ELF table length and the cartridge's Bulbasaur dimensions. Reading Emerald's +32-byte stride corrupted sizes and size-comparison poses for all 386 dex +entries. A version-specific cache revision rebuilds only stale RS caches; +FireRed, LeafGreen and Emerald markers retain their previous values. + +`tests/gen3_dex_entry_stride.lua ` runs the real extractor +and checks all 386 height/weight rows and all four affine pose fields against +independent raw ROM reads. The old 32-byte negative control reproduces 386 +corrupted RS species. FireRed, LeafGreen and Emerald control cartridges pass +their own 36/36/32-byte layouts. Both RS manifest generators reproduce the +committed metadata after matching-ELF byte verification. Fresh native RS imports +also confirm the corrected sentinel sizes and the RS-only completion marker. +No cartridge data is written or included by the regression test. + +## Validation and reproduction + +Candidate `d11d8cd` passed the native rendered driver with all mods off: + +- Fresh import and Birch speech/naming reach the actual moving truck. +- Real directional input takes the dynamic truck warp; the normal Mom script + reaches the new house. +- Native save/reload retains the player and the Sapphire version and creates + no Emerald save in the fresh QA identity. +- The actual Route 101 bag interaction opens starter selection, records the + chosen species in var `4023`, runs the first Poochyena battle and reaches + Birch's lab through the script's warp. +- Roxanne's real map interaction and native battle return to the field with + the Stone Badge and TM39. A level 100 Mewtwo is an intentional party fixture + for this separate arena test, not evidence of a full story playthrough. +- All 22 braille walls decode without unknown cells. The actual Sealed + Chamber sign opens the ABC wall at its cartridge text coordinates; B closes + the wall and resumes the native runner. Its rendered view was inspected. +- The captured field/first-battle/healthbox/reward views were inspected locally. + +Candidate `f8c23da` additionally passes `rs_gyms_native_driver.lua`: real +walking activates an RS floor switch, its beam picture/passability and saved +re-entry state agree, and Wattson's actual battle awards the Dynamo Badge and +TM34 before disabling all three switches. With a deliberate strong party and +story-state fixture, Randall's actual battle runs every one of the five door +frames in Petalburg and resumes the native script. Both views were inspected. +`rs_gym_puzzles.lua` adds 23 checks for the five aliases, RS switch coordinates, +beam collision, Emerald fallback and Petalburg door writes. + +The run used macOS LÖVE 12/Metal with a QA-only renderer configuration, a fresh +own save identity and a migration marker created before boot. Physical +iPhone/iPad, link play and full story/post-game have not been certified. + +`tests/engine/sapphire_registration.lua` verifies edition identity, type ids +and native/canonical special address spaces. `sapphire_save_layout.lua` +roundtrips patterned nonzero save blocks through multiple sector rotations and +checks plain RS inventory encryption and game-of-origin. `rs_braille.lua` +checks mapped 1bpp tiles, dot identities, header geometry and rejection of a +corrupted font (13/13). All five suites pass. The engine +suite is 69/79 passing; the same ten failures reproduce on the unmodified +baseline (64/74). + +Reproduce using `tools/run_gen3_native_smoke.py sapphire --rom YOUR_ROM +--love YOUR_LOVE12_BINARY --lock YOUR_SHARED_QA_LOCK --driver +tests/sapphire_native_driver.lua --speed 8`. The helper snapshots committed +sources and rejects cache reuse if extraction inputs changed. + +The generic summary fallback right-aligns each stat value within its own +column. Previously a value beginning at x=120 overlapped the next label at +x=132. The 15-check `summary_fallback_columns.lua` exercises worst-case +three-digit stats and full HP pairs; actual native summary/IV views were +inspected after the change. Extracted Emerald/FRLG window art keeps its own +unchanged layout. + +Remaining alpha scope includes the original RS title/intro backdrop (the +engine currently uses its fallback title), several UI art/minigame stages, +and unmapped native specials. Generic `scenes.lua`/`summary_info.png` cache +warnings remain disclosed. This is playable native support with concrete +opening/battle tests, not a complete feature-parity certification. + +RS item effects use the cartridge's own `gItemEffectTable`: 163 pointers, +starting with item 13. The generator verifies all 163 pointers against the +matching ELF and C table, including null entries. The previous import used +Emerald's address/count and produced two unrelated records instead of the +63 real effects. `stageOverrides.ITEM_EFFECTS` supplies the actual address +and count, and `rs-item-effects-layout-v2:` forces affected imports to rebuild. + +`tests/gen3_rs_item_effects.lua ` runs the real extractor +against all 163 entries / 441 raw effect fields, Potion/Protein/Rare Candy +sentinels and a failing old-address negative control. No ROM or derived +assets are written. This does not certify every item menu or full story. + +All Gen3 editions now dispatch completed walking steps to the existing native +`Gen3Friendship.step`, once per step. This prevents the generic older ++2/+2/+1 fallback from being applied to Sapphire. The independent regression +`tests/gen3_walking_friendship.lua` runs the actual controller branch for each +available Gen3 registration, both coin outcomes and an egg across 256 steps +each (all five in the combined sibling QA candidate). This narrow step +correction is not a full friendship/story acceptance. + +A rejected HM replacement now returns to the native summary move chooser. +Previously the chooser closed before the refusal and stranded the underlying +learner. The actual native menu/HM-reader regression covers reopen, +unavailable-summary fallback, an already-selectable list, retired-screen +ownership and unchanged moves/PP. Its presentation is stubbed; separate +real native input/renderer follow-ups remain necessary. + + +### RS field message geometry candidate + +The matching RS field printer calls establish text at tile (2,15), with +26 columns and four rows from the dialogue-frame code. A separate +`gen3FieldMessageWindow` record supplies the native field theme; it does not +become an Emerald battle-window scan anchor. The version-specific +`rs-field-window-v3` cache marker retains earlier dex/item-effect corrections +and requires an updated RS import. Other versions keep their cache markers. +The actual-cartridge extractor test passes the old-missing-record negative +and an unchanged native battle strip. The fresh native import now passes without mods: 240x160 field UI, +26 columns, the extracted 254-character font, two visible native text rows, +A completion and native choice input. An explicit 204-pixel test line wrapped +to four rows under the old 18-column budget and stays at two rows now. Actual +readyReport rejects the old v2 marker and accepts the fresh v3 cache. Frozen +engine source files remain unchanged. The native proof JSON records hashes; +original RS dialogue frame art, full UI and story acceptance remain open. + +## Verified RS item-action tables + +The matching-ELF generator verifies all ten `sItemPopupMenuActions` +text/handler pointer pairs and five fixed six-byte pocket rows. RS prints these +column-major; the extractor transposes four-/six-cell lists for the unchanged +native row-major menu. REGISTER retains its actual RS label and toggle. +Only RS advances to `rs-item-menu-v4:`; previous dex/item-effect/field fixes +remain. No Emerald DESELECT record is invented. + +Both cartridge CPU tests pass ten labels/handler kinds, 22 independent raw +positions, 22 actual native bag consumer cells, native grid/callback methods, +registration eligibility and missing-metadata/unknown-order negatives. +The separate native fresh no-mod run passes all five pocket action grids, +real directional input, REGISTER toggle, quantity cancellation, exactly one +Repel use, untouched other items/team, old-v3 rejection and fresh-v4 readiness. +The 240x160 field window remains intact and every frozen engine file is +unchanged. Numeric/hash-only proof: [`sapphire-item-actions-native.json`](sapphire-item-actions-native.json). + +The first Ruby attempt stopped during import at the unchanged 8-GiB reserve, +producing no result; that log/input remains local. The later fresh run is a +separate receipt. Original RS bag/menu artwork, Berry Tag, remaining native +UI/story and physical mobile acceptance remain open. This is not a full UI +or complete-story certification. + + +## Verified RS ball art and native send-out + +The matching-ELF generator now supplies the cartridge's twelve ball-sheet and +palette records, shared open halves, eight particle frames and colour table. +It verifies table tags/decompressed sizes and nine actual Thumb send-out/release +sites against the matching ROM, retaining source/function/ELF hashes. RS uses +its own caught jingle (371); it has no Emerald-only frame-40 click. + +Only RS advances to `rs-ball-art-v5:`. This retains the earlier dex, item-effect, +field-window and item-action fixes and requires a fresh RS import; other +editions keep their markers. `RomExtractorGen3` uses a supplied provenance +string when present and retains its existing native decoder/animation logic. + +The actual-cartridge CPU test `tests/gen3_rs_ball_anims.lua ` +checks all twelve composed three-frame sheets, particles, native 39/30-frame +send-outs, RS sounds, invalid Thumb-site/table/old-address controls and pristine +restoration. Independent private PNG decoding also matches all imported ball +and particle pixels; no ROM bytes or extracted images are in this contribution. + +A separate fresh native import with all mods disabled passes both player +genders, real native ball/particle draws, ULTRA item-to-sheet mapping, unchanged +native draw clocks, visible ball-only/particle-only negatives and exact PNG +restoration. All 8,993 frozen engine files remain unchanged during each run. +Numeric/hash-only proof: [`sapphire-ball-native.json`](sapphire-ball-native.json). + +This is a narrow import/send-out correction, not all balls/throws/catches, +remaining original RS art, full battle/story/link parity or physical iOS +acceptance. Previous v2/v3/v4 proof records remain historical evidence. + + +### Original terrain flag metadata + +The manifest additionally identifies this cartridge's 240 original terrain +flags by offset and SHA256 for the source-metadata reader in #246. The shared +`tools/gen3_source_flags.py` checks the full cartridge hash, pinned public +source hash, every flag byte and a shifted-range negative. The matching-ELF +manifest generator also requires the exact original table symbol offset. +Only metadata is included; no terrain bytes or extracted assets are committed. + +On the composed five-edition integration, actual native layout/map/tileset +extraction plus the compatibility observation backend in #245 matches every +map, cell, border, ordered connection and warp against the original public +import pipeline: 394 maps / 258200 padded cells for this edition. This is +CPU-only data equivalence, not new GPU/gameplay or full-mod acceptance. The +independent flag metadata reproduction passes; a new full matching-ELF rebuild +is not claimed for this metadata-only addition. Existing RS ball/field/item +metadata and cache revision remain intact; #246 composes its source-metadata +marker suffix with the cartridge revision. + + +### Two-lap RS stat flourish (v6) + +HARDEN and IRON_DEFENSE previously omitted their native stat layer: the shared +reader required the later games' three laps, whereas the verified RS task +uses two laps / 64 frames. The reader now chooses the lap signature from the +actual cartridge header (AXVE/AXPE), retaining all existing task, tile, palette, +alpha, literal-pool and drift/wrap checks. The compiled wrap immediate is +ADD124 (the compiler combines -4 and +128); the native sampler is unchanged. +Only the RS cache marker advances to `rs-stat-flourish-v6:`; the v5 ball and +earlier field/item corrections remain. A fresh RS import is required. + +`tools/rs-stat-flourish-check/check.py --rom --elf ` +independently compares both full task/step functions with the retail cartridge +and checks the two-lap C model. `tests/rs_stat_flourish_runtime.lua` checks +frames 0–63, owner identity, terminal frame 64, the existing 96-frame sampler +and unchanged non-RS markers. Initial development expected an unfused ADD128; +the independent retail comparison rejected that assumption before acceptance. + +A fresh no-mod native import on macOS LÖVE 12/Metal passes four controlled +fixtures: both moves on both sides, visible layer-only negatives, exact +same-frame restores, native draw RNG/save/frame preservation and stop at +64 frames. The numeric/hash receipt is [`sapphire-stat-flourish-native-v6.json`](sapphire-stat-flourish-native-v6.json). No ROM, ELF, extracted artwork, cache or save +is committed. This is an animation/import contract check, not a full battle, +story, all-move or physical-iOS certification. The first Ruby GUI fixture +passed, but its smoke wrapper failed because the driver used a different +report filename; the final committed driver and fresh runs use the runner's +`edition-result.json` contract. Earlier failure records are not reclassified. diff --git a/docs/api3-portability.md b/docs/api3-portability.md new file mode 100644 index 000000000..52f592613 --- /dev/null +++ b/docs/api3-portability.md @@ -0,0 +1,145 @@ +# 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` for private `mod:` boolean flags; +- `queueScript` for the existing bounded portable script language. + +Check `mod:supports("world", name)` before relying on a call. The other +Gen1 world method names remain callable but explicitly return +`nil, "unsupported: ..."`: warp/object/block mutations, runtime NPC operations, +`startWildBattle` and `invalidateMap` have not yet been verified against the +Gen1 API-3 contract. Their native/API-2 counterparts are unaffected. Native +symbolic flag mappings and `mod.datasets:open` are also explicitly unavailable. +Gen4/5 do not claim the shared world surface in this first entry PR. + +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: 24 comparisons and +48 isolated loader runs. 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. Separate Gen2 tests check API-1/2 behavior, contradictory +manifest fields, honest availability and malformed flag stores. CI pins the +same external commit and uploads the report. + +These are ROM-free loader/runtime-boundary tests, not retail-ROM gameplay +or a proof of every exposed native callback's payload. Native action details +remain covered by the prerequisite PR suites. Extend these shared fixtures +whenever another API-3 service becomes portable; do not make a failing +comparison pass by changing only one engine's mod fixture. diff --git a/docs/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/compatibility-cell-semantics.md b/docs/compatibility-cell-semantics.md new file mode 100644 index 000000000..c028d9be6 --- /dev/null +++ b/docs/compatibility-cell-semantics.md @@ -0,0 +1,67 @@ +# Compatibility terrain semantics + +`src.compat.gen1.MetatileBehavior` and `CollisionClass` translate terrain +semantics for compatibility views without changing native maps or gameplay. +They are independently implemented building blocks for the active-map bridge, +not a replacement field runtime or a completed mod loader. + +Behavior bytes differ across cartridges. For example Hoenn byte 17 describes +interior deep water, while Kanto byte 17 describes fast water. The semantic +namespace preserves shared names, renamed names, Hoenn-only IDs and unmapped +Ruby/Sapphire bytes. Conversion always selects an explicit edition. Metadata +comes from the clean public reference at +`37a49b31f458d51ea9e00b90f2a247272ad09cb8`; regenerate it with +`tools/metatile-compat/export.lua`. Only public interface identifiers and +conversion metadata are included, not ROM assets or runtime source copies. + +`CollisionClass.impl("frlg"|"rse")` provides `classify`, `seed`, and `fromCell`. +Their classified collision bytes are different from the native two-bit GBA +blocking field. Behavior dispatch rows are built once; classification allocates +no cell tables. Hoenn's surfability flags are borrowed live from the caller via +`setTileBits`, including later mutations and retirement. Missing flags do not +invent water classification. Indoor doorway facing, directional ledges, +secondary-metatile categories, nil collision semantics and original category +precedence are preserved. Native Gen2 collision is unaffected. + +Run the same input stream against both implementations: + +```sh +python3 tools/metatile-compat/check.py --reference /path/to/clean/reference --lua luajit +luajit tests/engine/compatibility_cell_semantics.lua +``` + +The oracle check compares all public names, reverse names, all five cartridge +conversions, unknown and fractional inputs, all 10-bit metatile IDs, collision +bit patterns plus nil, all semantic IDs at terrain boundaries, map kinds, +category/direction seeding, and every live surfability flag value 0–7. It +compares 517,218 result tuples per host using two independent rolling digests; +metadata regeneration must also be byte-identical. The focused suite checks +807 expectations, including semantic counterexamples and snapshot isolation. +`benchmark.lua reference|native` is an optional CPU microbenchmark, not an FPS +or gameplay acceptance test. + +## Integration still required + +These modules deliberately have their own namespace. They do not claim the +entire old `game3.mb` or importer contract, including ROM symbol resolution and +tile-flag extraction. The internal `MapViews` backend now observes the native session and live grid +revisions through source metadata supplied by #246. It requires the companion +identity/layout contributions #239/#240; see `native-map-observations.md` for +explicit integration tests. `CollisionClass.new(family, bits)` gives each view +its own flag state without replacing `impl` singleton state. Script writes, +atlas ownership and the complete map/Game3 facade still require integration. +Stale normalized-only caches cannot establish source bytes. Full compatibility +remains open. + +Blattgrün, Rubin and Saphir are included in the explicit cartridge scope. +Their existing upstream contributions are #183, #186 and #187; US Rev 0 support +and separate cache/save namespaces do not imply full story or mobile coverage. + +In seven alternating fresh-process CPU measurements of 1,048,576 calls per +family on macOS LuaJIT, Kanto median time was 0.067380 s for the reference and +0.045807 s for this implementation; Hoenn was 0.035833 s versus 0.026162 s. +Checksums agree. A separate mixed-family process run was slower for Hoenn +(0.097837 s reference versus 0.194992 s candidate), so these numbers establish +no general performance win or frame-rate improvement. Keep both workloads +when integrating with long-lived sessions. Native gameplay does not call these +new helpers yet. diff --git a/docs/frame-pacing.md b/docs/frame-pacing.md new file mode 100644 index 000000000..d3b291cf2 --- /dev/null +++ b/docs/frame-pacing.md @@ -0,0 +1,47 @@ +# P1: VSync and the software deadline + +The run loop records whether it actually presented a frame. After presentation, +`FrameCap.pace` skips the software wait when VSync is active and the FPS cap is +at least the reported display refresh rate. Without presentation, with VSync +off, with an unknown rate, or with a lower FPS cap, the existing carried +deadline, one-millisecond sleep chunks and stall re-anchor remain active. + +The default-on rollback switch is independent of all other performance work: + +```lua +require("src.core.FrameCap").vsyncPacing = false +``` + +Display flags are sampled at most once per second to avoid allocating their +table each frame. Provider/VSync changes invalidate that sample; other display +rate changes are observed on the next periodic sample. Existing option values, +simulation timing, mod interfaces and callback call sites are unchanged. + +`frame pacing: sleep` in the existing F11 FrameProfile measures the helper, +including its software wait. `present (GPU wait / vsync)` retains its original +scope. Removing a redundant software wait can move wall time into present +without changing FPS. + +The owned-window probe runs the real helper and FrameProfile with the switch +off/on, each after 120 warm-up frames and over 120 measured frames. Run through +the guarded launcher: + +```sh +python3 tools/check_frame_pacing.py --love /path/to/love --report /path/to/owned/qa/report.json +``` + +An older public checkout can use `--qa-tools /path/to/fork/tools`. The launcher +pins inputs, uses a separate QA identity, retains raw profile logs and records +SHA-256 hashes of the actual measured source files alongside the Git parent. + +Observed macOS/arm64, 60-Hz display, VSync 1, cap 60: + +| Backend | Software wait off/on, ms/frame | Present off/on, ms/frame | CPU off/on, ms/frame | +| --- | ---: | ---: | ---: | +| LÖVE 11.5 OpenGL | 0.00 / 0.01 | 16.59 / 16.54 | 0.672 / 0.790 | +| LÖVE 12 Metal | 16.43 / 0.01 | 0.13 / 16.56 | 0.769 / 0.629 | + +Both enabled runs made zero extra sleep calls and stayed near 60 FPS, with no +measured interval exceeding 1.5 display periods. These short blank-window +checks establish the pacing decision and its profiler scope. They do not +establish a universal CPU/FPS gain, loaded-world behavior, or handheld results. diff --git a/docs/frame-profile-sections.md b/docs/frame-profile-sections.md new file mode 100644 index 000000000..7814e4ce3 --- /dev/null +++ b/docs/frame-profile-sections.md @@ -0,0 +1,34 @@ +# Frame-profile sections for world, presentation and DS audio + +F11 enables the existing `FrameProfile` report. These additional sections use +its shared no-op closer when profiling is disabled; there is no new setting. +All labels begin with spaces, so nested timings do not get counted again as +top-level frame time. Multiple spans with the same label accumulate. + +| Label | Work measured | +| --- | --- | +| `world: tiles` | Initial map/neighbour draws, declined-pipeline fallback and the flat upper layer | +| `world: entities` | Flat ghosts, main and above-layer entities, or tilted billboards | +| `world: FX` | Flat/tilted effect groups, pipeline `ctx.drawFx` and final map overlays | +| `tiles: ensureWindow` | Window validation/refill, including cached and missing-art returns | +| `renderer: zone blit` | Actual `blitCanvas` calls, including the unshaded fallback | +| `renderer: present pipeline` | Present processing and final native output | +| `voxel: sun pass` | Shadow preparation, including cache-hit/rate-limit decisions | +| `voxel: terrain` | Main and neighbouring terrain submission, per eye | +| `voxel: water` | Water draw-list construction, planar reflection and water submission, per eye | +| `voxel: cards` | Main character and staged-battle card submission, per eye | +| `audio: Nitro render` | One DS mixer block, including SoundData creation | + +These are wall-clock submission timings, not GPU execution times. Reflection +copies of cards contribute to the water section; the cards section measures +the visible scene's card pass. The world labels describe selected drawing +phases rather than an exhaustive partition of `drawWorld`; setup, sorting and +other work remain visible in the enclosing section. Voxel timings run inside +the selected world pipeline. Hook names, arguments and dispatch order are +unchanged. An exception still takes its existing cleanup/error path; an +unfinished span is discarded by the existing profiler contract. + +A1 adds observability and makes no performance claim. Desktop before/after +frame timings and ARM/device timings: **not measured**. Capture the same scene, +viewport, camera, mod configuration and profile window when measuring later +optimizations. Profiling itself is disabled by F11 / `FrameProfile.setEnabled(false)`. diff --git a/docs/gc-frame-budget.md b/docs/gc-frame-budget.md new file mode 100644 index 000000000..abee388ba --- /dev/null +++ b/docs/gc-frame-budget.md @@ -0,0 +1,51 @@ +# P2: incremental GC in unused deadline time + +`Performance.gcBudget` defaults to `true`. At boot the collector uses pause 120 +and step multiplier 300. Each concrete `Performance.CAPS` row additionally has +`gcPause`, `gcStepMul` and `gcBudgetSeconds`; tiers may reduce the default +0.0005-second idle allowance. No options are rewritten. + +The ordinary engine's fixed `step(1)` moves out of `Game:update`. The run loop +uses no-argument incremental steps before its remaining software wait. It starts +no further step after the earlier of the actual deadline and the half-millisecond +allowance, and stops on completion of a cycle. An expired deadline or a stopped +collector receives no work. VSync never gets an invented future budget; only +the actual remainder of its carried deadline is eligible, and no extra sleep +is added after VSync. + +The budget is cooperative: a single indivisible collector step can finish past +the boundary. It is not a preemption guarantee for an arbitrary heap/finalizer. +The F11 child section `frame pacing: gc budget` is nested in the existing +`frame pacing: sleep` section, so the parent includes collection plus waiting. + +The fork's pre-existing Gen3 `Game3.stepGC` delegation stays at its original +update boundary with the original arguments/behavior. A frame owned by that +public seam gets no second idle GC service. Its delegated/custom work remains +outside the engine idle allowance. This preserves consumers that replace it. + +The independent rollback switch is: + +```lua +require("src.core.Performance").gcBudget = false +``` + +The next frame restores the previous pause/multiplier and uses the old native +update step. The separate P1 switch and callback interfaces are unchanged. + +`tools/check_frame_pacing.py --gc --love /path/to/love --report /owned/qa/gc.json` +runs the guarded native comparison. P1 stays enabled for both phases; only P2 +changes. The workload allocates 512 short tables per frame, with 120 warm-up +and 120 measured frames per phase. Raw FrameProfile logs, original tuning, +heap samples and measured-source SHA-256 hashes are retained. + +Observed composed fork, macOS/arm64, 60 Hz, VSync 1, cap 60: + +| Backend | Original fixed step, ms/frame | New idle mean / max, ms | Heap peak before / after, KiB | +| --- | ---: | ---: | ---: | +| LÖVE 11.5 OpenGL | 0.00 (rounded) | 0.098 / 0.499 | 918 / 775 | +| LÖVE 12 Metal | 0.00 (rounded) | 0.120 / 0.278 | 1,179 / 692 | + +Short-workload CPU means were 1.012 → 0.920 ms/frame and 0.717 → 0.651 ms/frame, +respectively. Both retained approximately 60 FPS and restored the original +200/200 tuning when disabled. These are bounded synthetic allocation checks, +not full-game heap, long-session, arbitrary-finalizer or handheld measurements. diff --git a/docs/gen1-layout-compatibility.md b/docs/gen1-layout-compatibility.md new file mode 100644 index 000000000..b7faed0be --- /dev/null +++ b/docs/gen1-layout-compatibility.md @@ -0,0 +1,57 @@ +# Legacy decoded layout compatibility + +Existing mods can require `src.core.game3.layout_native` and construct their +decoded 16px layouts with `fromDecoded(decoded, mapId, pair, sourceBlob)`. +The module supplies the original sampling, overrides, batch/stamp operations, +collision array, revision and packed-worker snapshot contracts. + +Authored width/height and true map dimensions stay separate. Padded and +negative-coordinate samples use the tiled border; padded collision-array +cells remain blocked. Base cells keep their identity and overrides do not +mutate the decoded source. Duplicate batch writes compose in order and advance +one revision. Classified collision bytes, explicit zero and elevation 15 are +preserved. Up to 64 writes invalidate individual view cells; larger writes, +stamps and resets request full invalidation. + +Packed-worker snapshots retain immutable source bytes and detach border and +override metadata. Custom samplers, changed dimensions/cell ownership and +asset overrides reject that packed shortcut, allowing the caller's ordinary +sampled fallback. This follows the original host's contract; decoded source +cells must remain immutable while using its original packed bytes. + +`coll` is the legacy host's classified `COLL_*` byte, **not** the native grid's +raw two-bit collision value. This module does not translate the two schemas +or bind these mod-owned layouts to the game's current map. An old map/session +adapter must own that translation and lifecycle explicitly. Native walking, +elevation, scripts, saves and caches are not changed by this module. + +Verification runs the same consumer with the same original require name on +the public v0.3.94 reference runtime at `37a49b31f458d51ea9e00b90f2a247272ad09cb8` +and this engine. All 245 checks and the full cell-sampling signature match. +Coverage includes borrowed/copied identities, negative borders/padding, +partial cells, zero fields, duplicate writes, source invariance, revision and +invalidation thresholds, custom-sampler rejection and detached worker data. + +```sh +python3 tools/gen1-layout-compat/check.py --gen1 /path/to/pinned-gen1 --lua luajit +``` + +The consumer needs no ROM, graphics context or player profile. This is not a +live-map/GPU test or an Origami 1.0.9 acceptance. Its unchanged entry still +requires the missing `src.core.game3.map` contract and the remaining native +session, atlas, battle and presentation boundaries. + +## Connection coordinates + +`src.core.game3.connections` supplies the matching pure map-view contract: +cardinal aliases, array/keyed definitions, parallel connections, authored +layout dimensions, incoming overlap, destination-local coordinates and +directional boundary lookup. Dive/emerge records remain outside cardinal +placement. It does not load neighbors, mutate positions or replace the +native world's connection controller. + +The identical reference/native consumer checks 12,160 cases and compares +the entire result stream for four cardinal directions and their aliases, +negative/zero/positive offsets, overlapping connections, unavailable +destinations and edge/corner samples. Source connections remain unchanged. +These checks run with the same command above alongside decoded layouts. diff --git a/docs/gen1-mod-compatibility.md b/docs/gen1-mod-compatibility.md new file mode 100644 index 000000000..e7d5a9601 --- /dev/null +++ b/docs/gen1-mod-compatibility.md @@ -0,0 +1,71 @@ +# Existing Gen1Recomp mods on the native host + +The compatibility target is an unchanged mod manifest, entry point and assets. +Compatibility contracts belong in this engine. A mod-specific converter or a +replacement entry point is not evidence that the original mod works here. + +API 3 already exposes portable content, world and UI contracts. Mods declaring +`engine_internals` can additionally import Gen1Recomp's native module names. +Those imports require actual compatible implementations: the old Game3 runtime, +map layouts, caches, battles and render passes are not equivalent to unrelated +native classes with similar names. + +## Map identity + +`src.import.gba.map_catalog` now implements the identity operations used by old +mods: `mapIdFor`, `groupNumFor`, `slotKeyFor`, `resolve`, `isKnown` and +`pretToEngine`. The original five-edition names, including FRLG's older +`SEVII_*` aliases, come from the public Gen1Recomp v0.3.94 catalog at +`37a49b31f458d51ea9e00b90f2a247272ad09cb8`. No ROM data is included. + +Native code can bind those names to its actual `data.maps` table with +`src.compat.gen1.MapIdentity.bind(maps, edition)`. `nativeId(oldName)` returns +the real native key and the exact borrowed definition. `legacyId(nativeKey)` +returns the original name. The ROM group and number establish the identity; +the adapter never rewrites definitions, connections, scripts or saves. +Same-object aliases remain valid. Conflicting definitions for one slot are +rejected. A removed/replaced record or changed slot retires the binding; bind +again after replacing the map table or changing its keys. Malformed slots and +unknown names do not acquire a guessed native destination. + +This is the identity portion of the old catalog, not its tileset discovery or +cache registry. It does not implement `src.core.game3.map`, construct an old +Game3 session, or establish rendering/gameplay acceptance of Origami 1.0.9. +The original package's entry still stops at the missing old map module. + +## Verification + +Run the same old-name consumer against a clean pinned public Gen1 checkout and +this host: + +```sh +python3 tools/gen1-map-compat/check.py --gen1 /path/to/pinned-gen1 --lua luajit +``` + +The check regenerates the complete name metadata from the actual reference +catalog and requires byte equality, then runs the identical consumer on both +engines. There are 34,127 checks per engine across FireRed, LeafGreen, Emerald, +Ruby and Sapphire. The independent native binding suite adds 10,824 checks, +including stale records, duplicate slots, same-object aliases and invalid IDs. +The five-edition comparison requires the host's LeafGreen/Ruby/Sapphire +cartridge support (PRs #183/#186/#187). On a host with FireRed and Emerald +only, pass `--edition firered --edition emerald`; that consumer performs +14,766 checks per engine. The detached binding suite covers all five catalogs +on either host and does not activate an unavailable cartridge. + +Optional `--native-cache /path/to/own-qa-imports --native-edition firered +--native-edition emerald` checks existing QA-only native imports without any +game launch. The accepted imports match all 425 FireRed and 518 Emerald maps, +with zero unknown slots. This is an imported-record check, not a live warp, +renderer, collision or player-save test. Use fresh QA identities for gameplay. + +## Remaining boundaries + +The next map boundary must preserve current-map ownership, layout cells and +revisions, connected placements/residency, semantic IDs and atlas lifetimes +together. The public name lookup alone must not be aliased to `src.world.Map`. +Old cache files must not be mounted as the new cache schema. Battles and UI +need their own state, action, timing and presentation contracts. Each completed +boundary needs unchanged-mod tests, native lifecycle checks and graphics proof +where it affects drawing. Full Origami and general internal-API compatibility +remain unaccepted until those paths work with the original package. diff --git a/docs/hot-path-dispatch.md b/docs/hot-path-dispatch.md new file mode 100644 index 000000000..d1632f35b --- /dev/null +++ b/docs/hot-path-dispatch.md @@ -0,0 +1,51 @@ +# P3: native update/draw dependency dispatch + +`Performance.hotPathHoist` defaults to `true`. Set it to `false` to restore +per-call require/pcall dispatch, the per-frame blit closure and fresh native +presentation records independently of P1/P2. + +`HotModules` lazily retains successful module values. Live fields and explicit +replacement or clearing of `package.loaded` remain observable; failed optional +loads are not cached. The Discord update uses its existing protected call with +the original `dt`, without allocating a wrapper closure after initialization. +No module path, hook argument or hook ordering changes. + +The renderer's blit adapter carries DPI as explicit arguments and still calls +the public `blitCanvas` method. Its existing `uiPresentation` field becomes an +engine-owned live view. A table installed there by a consumer is never mutated +by the reuse path. + +Only the unwrapped native renderer may borrow the private viewport scratch. +Public `endFrame` calls always return detached records, and replacing that +method bypasses the private shortcut. HUD hook arguments are detached at the +moment of dispatch, including a hook registered during compose/output. The +fork's window UI also retains its public record path. Scratch is never included +in a hook record. + +Run `tools/check_frame_pacing.py --hot-path --love /path/to/love --report +/owned/qa/hot.json` through the QA storage guard. The owned synthetic state draws +a rectangle through real `Game:draw`, canvases and composition. Each switch +phase has 120 warm-up and 120 measured frames at 60 Hz, VSync 1, P1 enabled. +The log contains the actual F11 `draw: whole frame` section; raw JSON also records +total draw wall time, process CPU, source fingerprints and an untimed require +counter. This is not a complete overworld, battle or mod scene. Profiling is on +in both measured phases. + +Measured on macOS arm64 (milliseconds/frame, switch off → on): + +| Tree / backend | F11 draw (rounded) | Raw draw mean | Process CPU | require calls | +| --- | ---: | ---: | ---: | ---: | +| Fork / LÖVE 11.5 OpenGL | 0.16 → 0.21 | 0.166 → 0.218 | 0.787 → 0.919 | 15 → 6 | +| Fork / LÖVE 12 Metal | 16.36 → 16.38 | 16.373 → 16.383 | 1.134 → 1.026 | 15 → 6 | +| Public adaptation on ed5189c2 / LÖVE 11.5 OpenGL | 0.21 → 0.21 | 0.223 → 0.215 | 0.952 → 0.936 | 14 → 5 | +| Public adaptation on ed5189c2 / LÖVE 12 Metal | 16.44 → 16.44 | 16.455 → 16.449 | 0.844 → 0.831 | 14 → 5 | + +Queue submission can absorb GPU/VSync waiting in Metal; these are wall-time +sections, not isolated GPU durations. The short runs show reduced dependency +dispatch and known allocations, with mixed CPU/timing results. They do not +establish a general frame-rate or CPU improvement. Dedicated ARM handheld, +Android, long-session and full-game measurements: not measured. + +Regression checks cover cached dependency identity/replacement/retry, detached +public and late HUD records, method wrappers, DPI forwarding and private native +reuse. Existing output/hook tests and the compatibility acceptance are retained. diff --git a/docs/mod-asset-packs.md b/docs/mod-asset-packs.md new file mode 100644 index 000000000..40a6c5412 --- /dev/null +++ b/docs/mod-asset-packs.md @@ -0,0 +1,106 @@ +# Portable asset packs + +Mods may declare `required_assets` and `optional_assets` arrays: + +```json +{"required_assets":[{"importer":"lttp","pack":"sprites","version":">=1.0.0"}], + "optional_assets":[{"importer":"lttp","pack":"music"}]} +``` + +Identities are lowercase letters, numbers, `_` and `-`. Requirements must be +dense arrays, unique across both lists; versions use the existing dependency +range grammar. Unknown optional packs stay unavailable. Missing, incompatible +or incomplete required packs stop the mod before its entry executes and block +hard dependents. The manager reports `missing_assets` and stages a restart once +the requirement is satisfied. The launcher displays the missing pack identity. + +`mod.packs` provides `list`, `info(importer, pack)`, `entries(importer, pack)`, +`entry(importer, pack, entryId)`, `read(importer, pack, entryId)` and +`metadata(importer, pack, entryId)`. Calls return data or `nil, reason`. +Enumerated entries are sorted; nested metadata is detached from the stored +descriptor. Undeclared packs cannot be read through this API. + +## Installing an existing extracted pack + +Copy a portable pack directory, including its `pack.lua` and referenced files, +to `imports/asset_packs///` in the active game-data folder. +Select **Import asset packs from inbox** in the launcher's Mods panel. This +directory flow works with phone file sharing, Android storage and Switch MTP, +without requiring a desktop file picker or a shell command. It preserves the +original inbox files and reports independent failures within a batch. + +The current catalogue recognizes `gen5_bw/battle_sprites`, `pmd_red/sprites`, +and `lttp/sprites`, `lttp/tiles`, `lttp/palettes`, `lttp/sfx`, `lttp/music`. +The final two are catalogue placeholders; no audio extraction is claimed. + +A format-1 `pack.lua` returns a literal table with `format`, `importer`, `pack`, +`kind`, `version`, `source` (cartridge `md5`, optional `name`/`size`) and keyed +`entries`. Each entry records `file`, `size` and optional display/sprite fields. +`metadata={file="animation.lua",size=123}` references another literal table. +The supported kinds are sprite, tileset, palette, font, sfx, music and sample; +their file extensions are checked. No cartridge bytes or extracted assets are +included in the repository or tests. + +Reads are bounded to 8 MiB per descriptor, metadata file or asset. File sizes +are checked before allocation. Data files support literal tables, arrays, +strings, booleans and finite numbers; they are parsed without executing Lua, +with byte, nesting and node limits. Paths cannot leave their pack directory. + +The installer yields after each entry, writes content-addressed objects, +verifies staged bytes and atomically replaces `pack.lua` only after copying all +referenced data. Failed writes, truncated writes, failed replacements and +abandoned jobs leave the previous descriptor and its assets usable. Identical +objects are reused. Unreferenced objects after an interrupted install are +retained; the installer never deletes user source directories or guesses which +old assets are safe to remove. Concurrent installations into one destination +are not offered by the launcher. + +## Native SNES graphics source + +The same inbox action also checks `imports/asset_sources/lttp.sfc` (or +`lttp.smc`) in the active game-data folder. The native extractor recognizes the +1 MiB US revision with MD5 `608c22b8ff930c62dc2de54bcd6eba72`; a 512-byte copier +header is accepted and excluded from source identity. Unknown revisions fail +before extraction. No source cartridge is included or downloaded. + +The extractor writes version `1.1.0` of three packs: 116 tile entries, 109 +sprite entries, and six palette entries. These include 223 graphics sheets, +the 4bpp player sheet, and a 1024×1024 atlas of 4096 overworld metatiles with +map palettes and quadrant flips. Sheet defaults retain palette identities and +transparent index zero in entry attributes. This does not provide audio packs. + +Extraction yields progress between sheets and atlas batches. Each pack uses +the same bounded immutable-object store and atomic descriptor publication as +the portable installer. Errors preserve the previous descriptor for the failed +pack. A changed destination root aborts publication. Complete matching packs +are reused. Raw sources run before portable inbox packs, so an explicitly +provided portable pack wins; independent source errors do not block those +portable installs. Inputs are preserved. + +`src.import.PackProducer` also provides a shared engine-side producer with +`put`, `alias`, `finish`, `cancel` and `state`. It validates and detaches input +data, scopes the destination, stages and verifies writes, and publishes only a +complete nonempty pack. This is not an additional mod write permission. + +## Scope and remaining work + +This implements the portable pack producer/consumer lifecycle and an initial +native SNES graphics producer. Native extraction for the other two catalogue +families, background extraction workers, real-cartridge image/animation parity +and physical device gameplay remain open. A working contract does not establish that +an entire mod initializes or is playable. New engine changes also need a fresh +test release before they are present in distributed phone/console packages. + +Validation: `luajit tests/engine/asset_pack_contracts.lua` exercises actual mod +loading, dependent recovery, required/optional semantics, detached metadata, +bounded reads, atomic failure recovery, inbox batching and launcher job paths. +The platform flags in this ROM-free test exercise the shared control flow; +they are not physical-device launch or gameplay evidence. + +`luajit tests/engine/native_pack_producers.lua` additionally checks decompression, +bitplane decoding, palettes, metatile flips, bounded producer writes, cancellation, +scope changes and failure preservation. `tests/native_pack_runtime` exercises the +complete native extractor through actual LÖVE filesystem and PNG APIs, including +reuse and inbox overrides, under a fresh owned QA identity. Its inputs are +generated format fixtures. No real source cartridge was available for pixel +comparison; the resulting pack counts are not a visual-parity claim. diff --git a/docs/mod-log-reporting.md b/docs/mod-log-reporting.md new file mode 100644 index 000000000..602013f6f --- /dev/null +++ b/docs/mod-log-reporting.md @@ -0,0 +1,60 @@ +# Fixed-destination log reporting and bounded native HTTP + +Declare a fixed HTTPS `log_url` and the `network` permission in the manifest. +API 2/3 reject invalid or ungranted declarations before executing the mod; legacy +declarations warn and discard an invalid destination. + +```lua +local job, why = mod:postLog("diagnostic text", {format="text"}) +-- Later: poll and release through the same interface as GET. +if job then + local result = mod.fetch:poll(job) + if result.status ~= "pending" then mod.fetch:release(job) end +end +``` + +The only option is `format`, either `text` (default) or `json`. Input is at most +65,536 bytes. JSON wraps it in `{ts, mod, format, body}`; its encoded payload is +bounded separately at 512 KiB. Unknown keys and invalid values submit nothing. +The endpoint and mod identity are captured when the API is created. Manifest +mutation, options and redirects cannot change the log destination. POST does not +follow redirects. No server response body reaches the mod, including error and +late/cancelled results. Public log results expose status, progress, HTTP status +and a stable failure diagnostic. The mod's existing network permission applies. + +GET and logs share the existing three-worker queue, global 16-job ceiling and +four-pending-requests-per-mod ceiling. Queue time counts towards the timeout. +Log requests use 20 seconds; other internal requests cap at 30 seconds. Released +or timed-out jobs retain their physical worker slot until completion. Poll reads +only worker results and performs no network, pipe or filesystem reads. + +Desktop POST reuses the bounded curl path. Payloads reside in worker-owned files +and never enter shell syntax; those files are removed after the request. Curl +configuration is disabled and explicit HTTPS protocol limits remain enforced. + +The new mobile `boundedHttpVersion()` / `httpRequestBounded()` protocol carries +binary payloads as bounded Base64 fields. Android reuses the existing JNI route +with a mandatory bounded envelope and a separately validated URLConnection +adapter; legacy cloud HTTP is unchanged. iOS uses a separate Foundation adapter +and private ephemeral sessions, without cookie/credential storage. Both adapters +retain platform TLS verification, bound body accumulation, refuse non-HTTP URLs +and HTTPS downgrades, and cap GET redirects at five. A POST is always HTTPS and +cannot redirect. iOS retains native task slots until terminal delegate callbacks; +Android closes the current connection on the absolute request deadline. + +An HTTP failure, timeout or cancellation does not prove that a server did not +accept the submitted log. The service does not retry POST automatically. + +Verification includes real owned desktop TLS transfers, literal bytes, expanded +JSON payloads, reply suppression, redirect refusal and existing GET/file-download +regressions. `tools/qualify_native_http.py` executes the production Swift and Java +adapter logic with URLProtocol/URLConnection fixtures and typechecks iOS. Those +fixtures do not exercise mobile TLS stacks or physical devices. Native packaging, +real mobile TLS/device acceptance, and resource behavior under suspension remain +separate gates. + +The fixed Switch runtime currently has no bounded HTTP binding; the service +reports unavailable there and does not attempt a subprocess. Its TLS/runtime +adapter still needs integration and qualification. A native source build and +matching Lua payload are required for the mobile protocol. This change does not +establish full mod gameplay or complete platform parity. diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..50c8b2dd0 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1,5 +1,36 @@ # Native modding +## Worker threads + +Mods using `love.thread` may declare `"compute"` in their manifest's +`permissions` array. This uses the bundled +LÖVE thread/channel API. It is separate from a `mod.job` background-job API; +declaring `compute` does not grant or implement that service. Unknown +permissions still fail strict API-2/API-3 manifest validation. +## Background HTTP GET + +A mod declaring `"network"` receives `mod.fetch:get(url, options)`, `:poll(handle)`, +`:cancel(handle)`, `:release(handle)`, `:available()` and `:capabilities()`. +Handles belong to their creating mod. Poll returns a detached table with +`status` (`pending`, `ok`, `error` or `cancelled`), `body`, `err`, +`receivedBytes` and, when known, `httpStatus`. Always release finished handles. +Rollback and unload release a mod's requests automatically. + +GET transport runs in three LÖVE workers. The engine caps active/queued work at +16 requests, with four pending requests per mod. `maxSeconds` defaults to 20 +and caps at 30, including queue time; `maxBytes` defaults to 4 MiB and caps at +16 MiB. `boundedHttpsGet = 1` requires HTTPS throughout redirects. Desktop +capabilities advertise this constraint. Desktop curl is required; mobile +bridges are not yet supported by this API. Remote URLs and headers are passed +through a private curl config file, outside shell syntax. + +Cancel discards the result immediately. A curl already executing may occupy +its worker until its bounded timeout; releasing a handle cannot bypass the +global worker limit. Shutdown waits for those bounded operations. This adds +GET support; it does not implement POST, arbitrary requests or `mod.job`. +The older download-to-file API still performs a synchronous first poll. + + The modding book lives on the [project wiki](https://github.com/bryanthaboi/gen1recomp/wiki). @@ -18,6 +49,76 @@ 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. + +## 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, `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). + +## Per-state main-screen visibility + +The [portable screen visibility contract](portable-screen-visibility.md) +describes `screen.render_visible(next, state)`: hide a native state's main +render, opacity and palette ownership while keeping its update/input owner. + +## Logic speed hook + +The [portable logic speed contract](portable-logic-speed.md) documents +`core.logic_speed(next, game)`, its native speed resolution, hard locks and +explicit-override precedence for native API 1/2, unified API 2 and API 3. + ## Bulk storage (`mod.storage`) `mod.save` is the right home for a handful of per-playthrough values: it lives @@ -70,6 +171,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 +196,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 +268,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 +319,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 +354,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 +398,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 +429,13 @@ 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. +With `network` and a fixed HTTPS `log_url`, `mod:postLog` sends bounded one-way +diagnostics through the shared workers. See [log reporting](mod-log-reporting.md) +for payload, ownership and native platform boundaries. diff --git a/docs/native-atlas-compatibility.md b/docs/native-atlas-compatibility.md new file mode 100644 index 000000000..0b4e753b3 --- /dev/null +++ b/docs/native-atlas-compatibility.md @@ -0,0 +1,70 @@ +# Native atlas resources for unchanged GBA mods + +`src.core.game3.tileset_native` projects imported native tileset banks into the +atlas resources expected by existing GBA mods. Mod manifests and assets do not +need rewriting. The projection owns its images, image data and quads; it never +lends a native renderer sheet to a consumer that can recolor or release it. + +The pixel model retains the source atlas's 16-column slot layout, transparent +upper plane, source BGR555 rounding, primary color-zero clearing and reserved +background palette rules. Imported original palette words are retained beside +the existing RGB data. Custom RGB remains exact. Palette changes only revisit +metatiles using the changed slots; tile changes only invalidate affected decodes. +Indexed pixel locations remain stable during recoloring. + +Each declared tile animation observes its own period without a combined-cycle +limit. Palette programs use their original words when available. The resource +view observes `TileRenderer.animFrame()` and does not advance another terrain +clock. Uploads maintain independent dirty histories for two or three image +buffers, including switches between those modes. Flush calls remain visible to +installed mod wrappers, and existing field batches can follow image rotation. + +`asset_stream` and `warm` provide prioritized cooperative preparation, +cancellation and teardown. Explicit `get` may finish a pending request, as the +consumer contract requires. Atlas leases preserve retired resources until the +last borrower releases them. Queue failures retain diagnostics and clean up +partial allocations; replacing public cache entries also retires unreferenced +owned resources. Loader cleanup runs after mod wrappers unwind. + +## Verification + +Integration prerequisites: terrain palette decoding/import (#259), semantic +pair identities (#250), early native session resolution (#254), and the map +fixture/read-view foundations from #251 with its dependencies. Five-edition +tests also require the separate cartridge registrations (#183, #186, #187). +Loader cleanup overlaps those integration branches and should retain both +map/player retirement and the resource retirement added here. + +From the repository root, with LuaJIT: + +``` +luajit tools/native-atlas/pixels-check.lua +luajit tools/native-atlas/palette-check.lua +luajit tools/native-atlas/resources-check.lua +luajit tools/native-atlas/warm-check.lua +luajit tests/run_engine.lua +luajit tests/run_modkit.lua +``` + +The synthetic pixel goldens come from the independent source compositor, +including its palette merge operation. The LÖVE host in +`tools/native-atlas/host` exercises real texture uploads and readbacks; it +requires a fresh QA identity, the requested graphics backend, and explicit +`NATIVE_ATLAS_ENGINE` / `NATIVE_ATLAS_OUT` paths. The cartridge comparison host +hashes normalized planes per metatile, without writing cartridge artwork. +Private cartridge inputs and player data are excluded from the public tests. + +## Remaining integration + +This resource layer does not establish complete game or renderer compatibility. +The distinct session facade, map writes, full renderer and gameplay services +still need integration. A replacement draw path must retain one native terrain +clock owner. Original tile callback phase semantics and palette profiles across +all five editions still need complete auditing. Old imports without original +palette words retain their RGB; automatic cache upgrading is a separate task. +The cooperative stream does not yet implement generic worker-task protocols. + +Final GPU and cartridge receipts must identify the exact tested source snapshot; +successful pixel comparisons alone do not prove unchanged-mod startup or play. +Native QA uses separate identities, preserves player saves and keeps at least +8 GiB free disk reserve. diff --git a/docs/native-battle-animation-runtime.md b/docs/native-battle-animation-runtime.md new file mode 100644 index 000000000..5baca515a --- /dev/null +++ b/docs/native-battle-animation-runtime.md @@ -0,0 +1,72 @@ +# Native battle animation bridge + +`src.core.game3.battle.anim` presents the admitted GBA battle through its native +`BattleState`, `Gen3MoveAnim` and `Gen3BallAnim` owners. It does not load another +battle engine or rewrite a mod package. FireRed, LeafGreen, Emerald, Ruby and +Sapphire use the same boundary and their own imported identifiers and records. + +## Shared clock and ownership + +The native update opens one presentation phase. A source `Anim.update()` can +advance that phase while a custom ending or pose holds the combat update. +Repeated source calls and a later native base call consume the same display +steps. The native dt drives sprite animations. Outside that phase, source +updates cannot manufacture frames. + +Move/ball effects, visual waits, HP drain and EXP fill remain native operations. +Only the native combat update consumes command rows, input and outcomes. +Source-launched clips occupy the native animation player and hold the command +queue until that clip finishes. A presentation serial distinguishes a finished +source clip from a newer native animation on the same player. Reset/unload +cancels only a clip still owned by that source admission. + +`present(id)` has stable identity for an admitted battler. It observes native +display HP/maximum/level/status/EXP, visibility, offsets and affine effects. +Source visual overrides compose around the existing native sprite draw; +reading a native effect never applies it a second time. `shownBattler` carries +an owned presentation override without replacing the native combatant. +Replacing the session, save, data or catalogue retires old poses and callbacks. + +`GameVersion.layout(id)` also reports the registered GBA family (`frlg` or +`rse`) so an unchanged field companion probe can recognize the native host. +It supports current-version and colon queries without changing cache/save +selection; an explicit registered layout takes precedence. + +## Geometry and screen effects + +`anim_coords` exposes authored anchors and ordering for singles and doubles. +Current imported cartridge coordinates take precedence over the standard +240x160 anchors. Temporary side bindings restore on success and failure and +preserve trailing nil arguments/results. + +Supported screen effects are inversion, fade to white/black, darkening, +grayscale and blending to a custom colour. Repeated begin calls reapply within +one guarded render seam; nested seams restore their caller's shader. The native +graphics guard restores render targets, transforms, shaders and leaked pushes +after an error. Synthetic asymmetric sprites test orientation as well as colour. + +## Current boundary + +This bridge implements native move launch/observation, source pose tweens, +display ratios, sequence observations, screen effects and coordinate bindings. +It deliberately does not advertise a complete presentation API version. +Authored animation commands, mutable VM/sprite/task/palette callbacks, +particle bands, trainer/intro/switch/catch stage control, source HP/EXP tween +commands and complete custom UI captures still require integration. A module +being importable is not evidence of a complete mod boot or playable campaign. + +## Validation + +- `tests/engine/native_battle_animation.lua`: all five editions; shared clocks, + owned clip completion, held command queues, stale poses/callbacks and 180-frame + native trajectory comparisons per edition with and without the bridge. +- `tools/battle-animation-runtime/lifecycle-check.lua`: SDK entry failure, + teardown and native peer callback identities. +- `tools/battle-animation-runtime/coords-check.lua`: optional local contract + oracle supplied through `BATTLE_COORDS_ORACLE`; no external engine is bundled. +- `tools/battle-animation-runtime/host`: actual Metal/OpenGL sprite and screen + effect rendering, pixel comparisons and graphics error restoration. + +Local qualification runs use the repository's QA storage manager, frozen source +copies and dedicated LOVE identities. They use synthetic imported data and art; +they do not open player profiles, save files or ROM originals. diff --git a/docs/native-battle-chrome.md b/docs/native-battle-chrome.md new file mode 100644 index 000000000..74cd2af70 --- /dev/null +++ b/docs/native-battle-chrome.md @@ -0,0 +1,42 @@ +# Native battle chrome and particle observations + +Battle panel wrappers enter the existing native panel pass. Repeated base +calls paint once, including translucent artwork. Window queries use imported +cartridge templates and the actual native surface width. Healthbox captures +validate the current battle and battler, use native geometry, and apply owned +offsets inside a restored graphics scope. Native shown ailment state stays +separate from queued logical status changes. + +Trainer images and picture offsets resolve from native imported records. +Additional genuine window templates and the native trainer portrait index +are retained by new imports. Existing imports retain their available records; +unavailable windows and artwork are reported rather than invented. + +The particle observation module projects actual native event poses at the +current clip/frame. It cannot advance a clock or create combat particles. +Reversible position, scale, reflection, visibility and custom drawing changes +compose around the same native event painter, including OAM-composited draws. +The ordinary native path avoids these observations until a consumer asks for +the pool. Retired events and unloaded owners leave no visible source rows. +This is not a complete external animation VM or managed-sprite API. + +ROM-free tests cover all five GBA editions. The real Metal/OpenGL host uses +asymmetric synthetic sprites and panels, independent pixel remapping, native +text shaders, multiple logical widths and healthbox offsets. It does not load +the original package or demonstrate complete gameplay. + +The unchanged local package has progressed beyond the previously missing +battle chrome, healthbox, trainer-picture and picture-coordinate imports. +Additional battle/UI contracts remain. The complete battle presentation +protocol is not advertised by this change. No private runtime or cartridge +assets are included. + +## Public-host composition + +The API-2 candidate builds on #327 and its declared prerequisites. It passes +315 chrome/particle, 916 companion, 1,466 terrain, 486 collision and nine +lifecycle checks, plus 3/3 SDK suites. Engine: 95/106 versus reference 94/105, +with the identical eleven inherited failed suites. Actual Metal and OpenGL +runs each pass the same 778 graphics checks, 90 comparisons and 185 readbacks +on the composed public candidate. No full unchanged-package gameplay +acceptance is inferred from those fixtures. diff --git a/docs/native-battle-transition-runtime.md b/docs/native-battle-transition-runtime.md new file mode 100644 index 000000000..884b63d32 --- /dev/null +++ b/docs/native-battle-transition-runtime.md @@ -0,0 +1,46 @@ +# Native transition observations and source phases + +`src.core.game3.battle_transition` observes the actual `BattleTransition` on +the native StateStack. Entry, tick, draw and finish wrappers are dispatched at +that state's native phases. Calling the source tick outside the update scope +does not advance it. Duplicate base calls reuse a phase result; completion +retires one exact admission and invokes its native continuation at most once. +Covered, finished and retired-dataset admissions cannot pop another screen. +Aborting does not invoke the battle callback. Failed presentation-end notices +are reported without discarding the native continuation. + +The source sequence identity, frame and phase are projections of that state. +There is no copied task scheduler, simulation, palette timer or audio loop. +Registered native style selection and foreground draws remain authoritative. +The enum/selection catalogue distinguishes FRLG, Emerald and RS, including +RS's shorter enum, class ordinals, class-based exceptions and level equality. +The inherited Emerald class profile is corrected during RS import. An RS-only +cache revision transparently rebuilds old imports. Runtime profile reads stay +authoritative, including deliberately authored Emerald-shaped profiles. +See https://github.com/pret/pokeruby/blob/master/include/constants/trainers.h. + +For existing geometric native styles, an owned copy of the current world +override passes through `drawWorld` before the renderer blits it. Only the +transition's own wipe/veil packets are consumed. Borrowed world images are +never mutated or released. Explicit legacy orientation is normalized once; +native orientation stays upright. Scratch targets are reused until their +view changes, then released on exit/teardown. Foreground `def.draw` styles +keep their native UI pass and are not invoked a second time as world effects. + +Verification uses the actual native stack and state, an independent original +selection oracle, and Metal/OpenGL pixel comparisons against the previous +native renderer. It includes explicit orientation negatives, callback replay, +covered UI, retired saves, native peer draws and error/teardown checks. + +## Open contracts + +This is not original task/visual parity or complete renderer/game acceptance. +FRLG spiral and the additional Emerald/Frontier effect tasks are not yet +implemented in the native renderer. Native mugshots still need their complete +portrait/task state. `defs()` reports owned native style descriptors, not the +original task-function tables; `_fx` task internals are not fabricated. +`blackSpans`/`screenCoverage` explicitly report unavailable native mask +observations. Full original palette/audio tasks, foreground/UI-owned world +effect composition, arbitrary source effect authoring, all custom renderer +paths, five-edition battle/gameplay and full mod-coexistence acceptance remain +required. Package loading past this module alone is not a gameplay result. diff --git a/docs/native-companion-runtime.md b/docs/native-companion-runtime.md new file mode 100644 index 000000000..ad505fd4a --- /dev/null +++ b/docs/native-companion-runtime.md @@ -0,0 +1,134 @@ +# Native companion runtime + +Source consumers on FireRed, LeafGreen, Emerald, Ruby and Sapphire can use +native party residents, encounter admission, field phases and modal screens. +The adapters do not load another game engine or copy an application save. + +## Native ownership + +- Session party members are typed views with cartridge-numbered identifiers. + HP, status, friendship, nickname and held items write to their admitted + resident. Move IDs and maximum PP are read-only; PP edits are validated + together before committing. Retired views reject writes. +- Bag handles use native pocket/capacity rules. Capacity queries run against + detached inventory containers; negative quantities cannot remove items. + Dex and Safari queries inspect the admitted native session. +- Authored wild encounters validate species, level, personality, six IVs, + moves/PP, item, HP and status before construction. Native encounter + admission retains its healthy-party, busy-world and single-pending-request + guards. Native cleanup runs even after the requester is unloaded; its + continuation runs at most once. Roaming identity and wounds remain native. +- Field locks belong to a mod and tag. Interactions, follower updates and + party-menu wrappers consume their native phase once. Short player gestures + advance on the player's fixed step and respect the native pose owner. +- Draw-only actors join native sorting temporarily. They do not become NPCs, + collision objects or independently updated entities. Rendering errors restore + the original entity collection. Follower artwork can use an owned renderer. +- Dialogue uses the native TextBox printer. Choice, evolution and move-reminder + screens use the native StateStack. A UI flow owns its child screens across + pop/push continuations; retirement removes those screens and discards its + callback without removing an explicitly foreign overlay. +- EXP awards share native growth, stats and level-up events. Damage queries + use the native formula/ruleset. Imported moves, item effects, fonts, sounds + and cries remain native resources. Nested two-tone text restores both the + shader binding and the outer ink/shadow uniforms. + +Optional conveniences attach through an owned option provider. Timed outdoor +encounters reorder existing land entries while preserving native position +weights and rates. Ball shortcuts use idle command admission and the ordinary +bag/catch transaction. Repel reuse asks before consuming another item. +Summary details cycle native stats, IVs and EVs without changing the Pokemon. +Disabling the owner disables these options. + +## Observation and retirement boundaries + +Field damage queries initialize the native type chart and evaluate supported +move-effect handlers against detached combatants. Queries cannot spend resident +PP, change HP, advance scripts or consume the native RNG. Source sound commands +use separate owned voices; stopping or unloading one mod cannot stop a native +script's voice or another mod's voice. Read-only warp resolution observes the +admitted native warp record without firing the gameplay destination hook. + +Map previews inspect the actual native stack state and refuse to dismiss a +covered screen. Weather queries use the native active weather byte or imported +map header. Terrain animation metadata comes from admitted imported banks and +source behavior identifiers. A detached decoder samples their original pixels; +reading metadata never rewinds or advances a live atlas or the native clock. +Missing native door artwork or a missing door animation producer is reported +explicitly rather than replaced with a synthetic animation timer. + +## Qualification + +ROM-free fixtures cover FireRed, LeafGreen, Emerald, Ruby and Sapphire. Run: + +```sh +luajit tests/run_engine.lua +luajit tests/run_modkit.lua +luajit tests/mod_examples_tests.lua +``` + +The native companion suites include resident/PP validation, effect-specific +damage, field locks, owned encounter admission, native menus, evolution and +move-reminder continuations, sound isolation, warp reads, animation metadata +and teardown. Callback coverage uses the public SDK and actual native battle +and follower producers, including callback failures and owner rollback. + +The GPU host checks battler orientation, native presentation phases, text ink +and shadows, nested shader state, image decoding, explicit palette-index +artwork and blend pixels with independent synthetic artwork: + +```sh +python3 tools/check_companion_runtime.py --love /absolute/path/to/love --backend opengl +``` + +LÖVE 12 on macOS additionally supports `--backend metal`. Request only backends +supported by the supplied build. An optional `--companion-mod /absolute/package` +checks unchanged external package initialization for all five editions in +separate processes. The package must export `active` and `rendererInstalled`; +no selected package, an entry error, or an inactive renderer fails the probe. +The external package and its artwork are never included in this repository. +Initialization and synthetic pixel tests do not establish a complete playthrough. + +Managed starters pin their sources, freeze committed candidates, retain a +20 GiB free-space reserve and use disposable profiles. Player profiles and +player saves are not read or modified. + +## Public composition and presentation scope + +The public companion branch composes the existing native session, map, player, +modal, terrain, palette, atlas, script, sprite, field-view, asset-pack, worker +permission and bounded transport offers. The branch carries these prerequisites +so its tests run against the checked-out public candidate. Native battle chrome +and field presentation are included from their existing offers as well. + +The supported battle path remains the native battle controller and renderer. +An external package may request a separate presentation API with additional +command-taking, task, intro, switch or catch-sequence contracts. Those contracts +are not advertised here. Its optional alternate battle card can use the native +fallback while the package's field renderer and native companion services stay +active. This qualification must not be presented as full alternate battle-card +support or a complete playthrough of every edition. + +Existing source copyright and licence notices remain in place. Original +contributions offered by Roxas2712 use the specific upstream permission in +[LICENSE-ROXAS2712.md](../LICENSE-ROXAS2712.md). + +## Completion evidence + +Public runtime `79c37058` passes **194/194 engine suites**, **3/3 SDK suites**, +the gallery, all **200 API-3 comparisons / 400 isolated loader runs** and +**14/14 unchanged external fixture entrypoints**. Each supported GPU host +passes **575 assertions, 30 pixel comparisons and 100 readbacks**: LÖVE 11.5 +OpenGL and LÖVE 12 Metal/OpenGL. The unchanged external 1.0.9 package +initializes on all five editions on each of those three hosts. + +Disposable FireRed and Emerald ROM imports additionally exercise a staged +native field, resident party edits, dialogue, field damage, wild battle, a +native move, battle return and mod unload. The external field renderer stays +active after the battle. FireRed is bound to `79c37058`; Emerald is bound to +`df85f6d0`. These are admission/action flows, not complete playthroughs or +performance benchmarks. The optional alternate battle card uses the native +fallback described above. + +[Machine-readable revision-bound evidence](qa/native-companion-completion.json) +records the individual scopes and outcomes. diff --git a/docs/native-companion-terrain.md b/docs/native-companion-terrain.md new file mode 100644 index 000000000..ab1bbf0a9 --- /dev/null +++ b/docs/native-companion-terrain.md @@ -0,0 +1,31 @@ +# Native companion terrain observations + +Contributions by Roxas2712 — . +Existing upstream rights and notices remain applicable to the shared native machinery. + +The GBA source namespace now reads grass, next elevation and ledge landing +from the native map owner. Elevation zero remains a real height; bridge +markers preserve the incoming height. Native movement and source queries +share the same transition and ledge classifier. Dataset overrides, older +standing-cell ledges, later-edition jump distances and callback arity remain +native. Queries never move actors, execute movement hooks or trigger warps. + +`src.core.game3.encounters` provides `ensureLoaded`, `terrainAt` and `tableFor` +(`table_for` alias). Tables expose detached land/water/rocks/fishing areas, +with cartridge species indices and source level-range field names. Map +aliases resolve through the checked native map binding. Native header and +script-variable changes are visible on the next query; unknown species or +retired maps are refused instead of silently substituting another monster. +Altering Cave header selection is shared with native encounter generation. +These reads do not consume RNG or install another import cache. + +This is a partial interface. Encounter rolling, encounter identity transfer +into battles, companion actor collection, follower commands and UI hooks +still need their own integrations. No complete companion or presentation +capability version is advertised, and unchanged Origami 1.0.9 initialization +is not yet expected to succeed. + +Validation uses synthetic native owners for all five GBA editions, actual +native hop dispatch, height/bridge transitions, cave/underwater terrain, +sparse species indices, alternate headers, retired bindings, detached +records and real SDK failure/teardown alongside a native peer. diff --git a/docs/native-field-collision.md b/docs/native-field-collision.md new file mode 100644 index 000000000..da94d2fbc --- /dev/null +++ b/docs/native-field-collision.md @@ -0,0 +1,52 @@ +# Compatibility collision reads and native entry permission + +The collision module reads the live native GBA session. Classified COLL bytes +remain detached source-format observations, while `canEnter` calls the existing +native permission owner. No collision grid, actor state, warp event, script or +save is changed by a query. Hypothetical origins, elevations and surf states +use an observational mover; its real native actor and connected-map aliases +are excluded from self-collision. + +Source behavior comes from original bank attributes and the existing semantic +translator, including neighboring banks and authored borders. The existing +map graph determines which metatile/pair a world coordinate samples. Native +grid revisions and source-bank replacements remain visible. Classified arrays +are cached by owned layout/revision; mutating such an observation cannot +change native entry permission. Native extents remain authoritative for entry +and bounds even where the source art layout has padding. + +The module exposes current-map/behavior/elevation/cell/grid observations, +native walkable/water queries, source-format active warp observations and +`canEnter`. Header-only warps on non-warp terrain stay inactive. Loader teardown +retires readers and arrays after wrapper unwind. Held detached cells do not +retain native maps or source banks. Sessions whose world belongs to an earlier +dataset cannot grant movement. + +## Verification and integration + +`luajit tools/field-collision/check.lua` runs 486 ROM-free checks across all five +editions using real native map and collision implementations. It covers native +permission/reason agreement, source-bank semantics, connected/border sampling, +grid changes, hypothetical requests, active/dead warps, detached overrides, +dataset replacement, real loader teardown and weak-reference retirement. +The player phase's 261 checks also pass with this session guard. + +The composed fork's full unchanged 1.0.9 source loader probe now reaches the +missing `src.core.game3.tileset_native` module for all five editions. Full +initialization, GPU rendering and gameplay remain unaccepted. These probes +use fixture game data and read the full source package without modifying it. + +The standalone upstream diff requires player/session PR #254 and map-read +PR #251 with its terrain/identity dependencies. All-five testing also requires +cartridge PRs #183, #186 and #187. Its focused fixture runs in the composed +fork; it cannot independently exercise missing prerequisite interfaces on +pristine upstream main. + +## Remaining work + +Custom source-authored collision providers, binding/loading a different map, +typed script writes and the rest of the source collision/field service +surface still need integration. They must enter native commands rather than +turning the detached presentation grid into a second simulation. The atlas, +distinct session facade, field/UI/battle services and end-to-end acceptance +remain separate workstreams. diff --git a/docs/native-field-effect-sheets.md b/docs/native-field-effect-sheets.md new file mode 100644 index 000000000..7be235b44 --- /dev/null +++ b/docs/native-field-effect-sheets.md @@ -0,0 +1,44 @@ +# Owned field-effect artwork + +`src.core.game3.field_effects` now supplies the resource part of the compatible +field-effect interface: `install`, `manifest`, `manifestObject`, `loadSheet` +(`getSpriteSheet`), `invalidate`, `release` and owner cancellation. It does not +implement effect tasks, timers, start/wait commands, actor lists, reflections +or draw phases. No empty task observations or additional simulation are used +to imply that those contracts have been completed. + +The native catalogue uses identified imported tall-grass, surf-blob, ripple, +sparkle, FRLG bird and independent emote-strip records when present. It does +not claim that every edition imports each record. Each record's own geometry +and frame order remain authoritative. Independent native emote strips are +not renamed into a presumed combined fifteen-frame strip. Additional artwork +and the native effect phase/state bridge remain required. + +A loaded sheet owns its ImageData, Image, full-frame quads and eight-pixel +front crops. Neither Assets' image cache nor an actor SpriteRenderer is lent. +Repeated requests share these owned resources with per-mod leases. Retirement +uses a private allocation list: replacing public sheet fields cannot cause a +borrowed image to be released. Actual generated-asset overrides are resolved +at each request; changed paths/geometry retire the preceding owned sheet. + +An explicitly installed cache may provide the original `field_effects/objects.lua` +and raw RGBA payloads through its own `read` method. No conversion file is +written. Manifests run with an empty environment and bounded instructions, +then validate scalar/table metadata, finite geometry, names, paths and duplicate +identities. Payload length must exactly match the declared geometry. An authored +manifest remains authoritative; a missing/malformed declared asset is not +silently substituted with unrelated native artwork. Public manifest results +are detached copies; ordinary cached sheet requests avoid copying the manifest. + +Providers bind to the current game/dataset/save/mod-set/edition cohort. +Replacement, failed-entry rollback, late validation rejection, asset flushes +and teardown retire owned allocations. Reentrant cache reads revalidate the +cohort before publication. Preparation failures release partial allocations +and allow a later retry. There is a bounded texture-memory budget; exhausting +it reports an unavailable resource instead of returning a partial sheet. + +Validation uses synthetic, asymmetric pixel fixtures for all five GBA editions, +actual SDK owner rollback/teardown and real Metal/OpenGL image/frame/crop +readbacks. These are resource tests, not original effect-task parity, cartridge +artwork extraction parity, unchanged package initialization, gameplay, full +renderer or complete mod-coexistence acceptance. diff --git a/docs/native-field-move-presentation.md b/docs/native-field-move-presentation.md new file mode 100644 index 000000000..a130ad565 --- /dev/null +++ b/docs/native-field-move-presentation.md @@ -0,0 +1,29 @@ +# Native field-move presentation ownership + +`src.core.game3.field_move_show_mon` uses the existing `Gen3FieldMove` +StateStack admission. `start` and the native entry seam use that admission's +Pokemon, optional pose and guarded continuation. `step` consumes only the +current native update scope. Native draw wrappers consume one draw body; +explicit source draws render the current native picture in the caller's +coordinates. The renderer's frame serial prevents the normal UI pass from +painting the same externally rendered cut-in again. Drawing never advances +the streak or player-pose clock. + +The cut-in retains its own 240x160 GBA surface. A mod's dialogue-box theme +cannot shrink it to the Game Boy surface or enlarge it to a DS surface. + +The native admission owns its front image and resolves actual generated-asset +overrides. Completion, explicit reset, failed entry and owner retirement +release it and restore the prior player pose. Covered admissions cannot pop a +foreign screen or advance through direct source ticks. Cancelling a source +owner under another screen retires its callback immediately and leaves that +cover in place. Continuations preserve nil returns/errors and cannot execute +twice or before native completion. Retired callbacks release captured data, +and cohort references are weak so retained wrappers cannot keep a retired +save alive. Observer release leaves engine-owned continuations intact. + +The optional cartridge profile, authenticated strip import and supported cry +options are documented in [native-field-move-runtime.md](native-field-move-runtime.md). +The ordinary native style is retained without an active observer. Full +portrait/form/personality handling, complete field task scheduling and the +unchanged complete renderer/gameplay remain open acceptance work. diff --git a/docs/native-field-move-runtime.md b/docs/native-field-move-runtime.md new file mode 100644 index 000000000..8c9a39d19 --- /dev/null +++ b/docs/native-field-move-runtime.md @@ -0,0 +1,23 @@ +# Imported field-move presentation on the native state + +This extends the native `Gen3FieldMove` admission with a cartridge presentation program. The normal native style remains the default when there is no active compatible observer. Existing native sprite hooks and asset overrides still resolve the front picture. No second world, player or task clock is created. + +The compatible field-move namespace exposes `start`, `isActive`, `step`, `draw`, `reset` and `invalidate`. Entry configures an actual stack state. Only that state's native update advances the presentation. Wrapped base calls share a once-only phase; an arbitrary `step()` cannot drive another frame. Read-only `_fx` observations are detached from the actual state. Rendering an effect through the source world pass suppresses the same admission's native UI draw within the renderer's frame serial. + +The optional program uses separate indoor and outdoor 256×80 strips, a 24-tick optional pose, cartridge-style window reveal/hide, picture motion/hold and one cry. `noDuck` and per-call cry volume feed the existing audio engine; the next ordinary cry restores its normal volume. An unrelated active cry retains ownership of its music attenuation. + +## Imported data and ownership + +The existing field-effects import stage authenticates the actual complete ROM SHA-1 before extracting strips. Bounds and declared tile counts are checked. The compact cache contains indices and palette words; RGBA is expanded only on admission. An older manifest can use the audited exact-digest profile, while discovery writes explicit optional `fieldMoveStreaks` metadata. Unknown revisions decline this artifact. The GBA-only cache revision forces an import refresh without migrating player data. + +Profiles cover FireRed, LeafGreen, Emerald, Ruby and Sapphire. Source locations and presentation facts were checked against the corresponding primary cartridge projects: [FireRed](https://github.com/pret/pokefirered/blob/master/src/field_effect.c), [Emerald](https://github.com/pret/pokeemerald/blob/master/src/field_effect.c), [Ruby/Sapphire](https://github.com/pret/pokeruby/blob/master/src/field_effect.c). No cartridge payloads are bundled. + +A mounted field-art provider can supply the same raw strips directly. Missing or malformed declared provider data fails admission; it cannot silently select another provider. Front images, strips, image data and quads are privately tracked per admission, including failed preparation and reload. Changing a public sprite field never transfers ownership of a borrowed native image. Pose ownership uses an admission token, so an old admission cannot restore over a newer pose. + +Callbacks and states belong to the current game/data/save/mod-set/edition/stack/world. A covered state cannot complete another screen. Source-owner cancellation removes only its own admission, including behind another screen, and discards captured continuations. Observer teardown preserves native continuations. Duplicate, early, reentrant and failed continuations retain their guards. + +## Validation and remaining scope + +ROM-free checks exercise admission, wrappers, cohort changes, resource ownership, actual SDK entry/late-validation/teardown, and the existing music/cry engines. Independent private oracles use immutable original blobs for per-tick state comparison and separately decode both strips from all five ROMs. The GPU host compares the original drawing routine against the native program on Metal/OpenGL, including repeated and externally composed draws. Front artwork in that host is an asymmetric synthetic fixture; it does **not** prove every cartridge portrait, form or personality variant. + +Full special-form/personality picture handling, native field-effect tasks and actor/overlay scheduling, party-slot selection, the unchanged complete renderer and real full-package gameplay are still separate acceptance work. A loadable module or matching cut-in is not full mod compatibility. diff --git a/docs/native-field-runtime.md b/docs/native-field-runtime.md new file mode 100644 index 000000000..fed1ef238 --- /dev/null +++ b/docs/native-field-runtime.md @@ -0,0 +1,40 @@ +# Native field state, audio and display adapters + +Field consumers observe the admitted native world on FireRed, LeafGreen, +Emerald, Ruby and Sapphire. Native scripts, transitions, player state and +resource owners remain responsible for simulation and timing. + +Source fades join the native stack and palette task. Warp helpers use native +destination resolution and native admission; observational destination reads +do not dispatch the gameplay hook or change a save. Source START menus use +the native menu stack. Dataset views expose admitted imported data without +mounting an unrelated cache or another cartridge. + +Sound effects use mod-owned cache variants. Source volume and stop requests +cannot change an ordinary native script's voice or a different mod's voice. +Owner retirement and session replacement release those records. The source +adapter observes the native sound service rather than advancing an audio clock. + +Healing snapshots and native drawing share one sprite selector. It reads the +current native task, imported monitor/glow artwork and cartridge offsets; +it cannot advance the task, play its sound or call its continuation. Snapshot +coordinates use the native player's 240x160 anchor independently of drawable +resolution or an external supersampling texture. + +Weather observations read the native active value and imported map header. +Map-preview observations inspect the real native stack; dismissing a covered +preview is refused. Missing general door artwork or a missing native door +producer is reported explicitly. Observations do not create a replacement +animation task. + +The public companion composition includes these prerequisites and qualifies +its checked-out candidate. See [native companion qualification](native-companion-runtime.md) +for commands, GPU hosts, unchanged-package probes and the separate optional +battle-presentation boundary. Earlier draft measurements are superseded by +the revision-bound completion evidence recorded there. + +Managed local starters retain a 20 GiB free reserve, use disposable identities +and leave player profiles and saves untouched. External runtime code, ROMs +and private game assets are not part of this contribution. Existing notices +remain intact; original Roxas2712 contributions use the specific upstream +permission in [LICENSE-ROXAS2712.md](../LICENSE-ROXAS2712.md). diff --git a/docs/native-field-view.md b/docs/native-field-view.md new file mode 100644 index 000000000..de787c45e --- /dev/null +++ b/docs/native-field-view.md @@ -0,0 +1,61 @@ +# Native GBA field presentation + +`src.core.game3.field_view` connects a source field-draw replacement to the +admitted native overworld. `OverworldController:drawWorld` invokes its `draw` +callback with the native Game3 facade and actual world-view dimensions. The +ordinary renderer remains the direct path when there is no replacement or +presentation override. Requiring the module does not create a session. + +The default `draw(game, width, height, options)` paints the native actors and +native field-effect bodies using owned source terrain atlases. It also works +for offscreen captures. `actorsOnly`, `skipActors`, billboard placement and the +`endUnderActors` upper-layer callback have explicit render scopes. A retained +upper-layer callback expires when that draw ends. Graphics pushes, canvas and +shader state are fenced, including errors. A declining source draw falls back +to the native renderer without consuming another tile-animation cadence. +Rendering never steps native actors, field effects, scripts or Flash tasks. + +Terrain remains in 16px cells. The native room's true extent determines a +connection seam; the source layout's even storage padding cannot enlarge the +room. Camera placement uses the player's cell centre, the capture viewport +and explicit source panning. Connected pairs use their own atlases. Reused +batches rebind rotating texture uploads without rebuilding stationary cell +geometry. The typed map graph is observed once per preparation, rather than +rebuilding map/neighbor observations for each visible cell. + +Palette edits affect owned source atlases and survive asset-cache invalidation; +the caller's palette words are detached. Borrowed native sheets are neither +patched nor released. Transparent reflection covers use the same current +source tile indices and palette words as the source terrain. Batches, cover +images and atlas leases retire with their rendering cohort. Save/dataset/mod +owner changes and a replaced native map cannot revive a retired presentation. +The fork additionally shares admitted-world lookup across map readers and +snapshot/warm-bound observers. Persistent-world hosts require that admission +follow-up; this public field change does not invent a replacement stack. + +Flash radii and default levels come from the imported native records. Native +updates retain the one-pixel animation clock. Both field paths use integer +scanline masks, with compressed equal-span runs and a private geometry cache. +Mask visibility accounts for the actual player centre under camera pan. + +Validation uses synthetic native maps, bank records and actors, not ROMs or +player saves. CPU coverage checks native admission, source atlas ownership, +options, camera coordinates, reloads, palette lifetime and native draw cadence. +The GPU host checks all five GBA editions on LÖVE 11.5 OpenGL and LÖVE 12 +OpenGL/Metal, three capture sizes, true room/connection boundaries, upright +actor markers, palette/texture uploads, scanline masks and resource retirement. +The independent pinned scanline comparison covers 5,025 radius/view/centre +combinations. These checks do not establish unchanged-mod initialization, +original effect-task/art/timing parity, full gameplay or device acceptance. + +Open work includes source-specific mosaic/order customization and void-fade +presentation, full effect-task interfaces, original-mod initialization and a +real gameplay route. iOS, Android and Switch still need their own runtime and +playability qualification. Native actor/effect behavior itself continues to be +owned by the engine. The public integration requires the native draw helpers +proposed in #291 and the separately published map/atlas/session prerequisites; +it must not replace missing host ownership with a simulated world. + +The public patch also reuses the committed native GraphicsScope guard, which +is absent from the public base. It fences real graphics state and leaves +borrowed canvases and images with their native owners. diff --git a/docs/native-map-observations.md b/docs/native-map-observations.md new file mode 100644 index 000000000..eca87d936 --- /dev/null +++ b/docs/native-map-observations.md @@ -0,0 +1,102 @@ +# Native-backed compatibility map observations + +`src.compat.gen1.MapViews.new(getGame, getEdition)` observes the existing native +GBA session. It does not load a second world, import an old player/controller, +change map IDs or write native state. The callbacks resolve the current native +Game owner and registered edition. Construction requires companion map identity +(#239), decoded layout (#240), terrain semantics (#245) and source import +metadata (#246). Five-edition integration additionally needs #183/#186/#187. + +The returned handle exposes `current()`, `map(canonicalOrNativeId)`, +`isCurrent(view)`, `invalidate(idOrNil)` and `release()`. An unavailable or +retired source returns nil and a reason. `isCurrent` means that the observation +still matches its source revision; a resident neighbour may also be current. +`current()` selects the active map. Release also drops resolver closures. + +A view owns its decoded `midLayout`, ordered `connections` and `warps`. It +exposes canonical `id`, explicit `nativeId`/`nativePair`, true dimensions, +kind/environment and a publication revision. Odd layouts preserve their true +extent, source border dimensions and original indoor/outdoor padding policy. +Classified collision uses exact source attributes, original Hoenn surfability +flags and the original final blocking/warp guard. Native normalized attribute +and two-bit collision representations remain unchanged. Classifier instances +own their flags; observing one edition cannot replace singleton flag state. + +Resident native maps contribute their actual block/collision/elevation patches +and grid revision. A new revision publishes a complete owned view; a retained +older view remains readable. Repeated observations keep identity while the +source is unchanged. The cache is weak, and publication stamps use opaque +identity tokens instead of retaining native maps or renderers. Native APIs +such as `setGen3Cells` and `clearBlockPatches` invalidate observations naturally. +Direct in-place changes outside native APIs require explicit `invalidate`; +replacing the dataset owner retires the binding. Overrides applied to an owned +layout never modify native gameplay cells. Consumers own their snapshot edits. + +## Reproduction + +ROM-free composed integration: `luajit tools/map-views/check.lua` (211 checks). +The helper is outside the automatically discovered engine tier because it +requires the companion contributions above. It uses real native Map objects +with synthetic source records, including alias identity, neighbour writes, +map/title/dataset switches, singleton isolation and GC ownership canaries. +The classifier's independent engine suite passes 807 checks. Source flag and +source layout extraction suites pass 13 and 21 checks respectively. + +The real-ROM comparison uses a clean public reference pinned to +`37a49b31f458d51ea9e00b90f2a247272ad09cb8` and locally owned US cartridges. +Do not run it inside a player profile. Keep at least 8 GiB free. Create fresh +oracle/output directories, then create `oracle/inputs.json` mapping each of +`firered`, `leafgreen`, `emerald`, `ruby`, `sapphire` to its absolute ROM path. +For each edition, from the clean reference checkout run: + +```sh +luajit /absolute/engine/tools/map-views/oracle.lua /absolute/engine EDITION /absolute/ROM SHA1 /absolute/oracle/EDITION.json +``` + +Use the exact SHA1 in that edition's `tools/rom_manifest_EDITION.json`. The +reference tool executes the original census registration, padding, source +attribute/flag translation, collision classification and final pack guard. +Then run the native host from any directory with LÖVE 12: + +```sh +POKEPORT_IDENTITY=oasc-gen2recomp-qa-mapviews-UNIQUE \ +MAPVIEWS_ENGINE=/absolute/engine MAPVIEWS_ORACLE=/absolute/oracle \ +MAPVIEWS_OUT=/absolute/fresh-output love /absolute/engine/tools/map-views/rom-host +``` + +The host disables graphics/window/audio, hash-checks every cartridge and runs +the actual native layout/map/tileset extractor. Only image construction/writes +are bypassed; generated tables stay in memory. It compares two independent +rolling digests of every classified cell, elevation, border, true/padded +extent, kind/environment, ordered connection and warp. It also compares map +inventory counts. Neither tool exports cartridge assets. Source flag checks +use real `love.data.hash`; the ROM-free flag suite tests validation with a hash +double, not cryptographic correctness. + +## Measured scope + +The final frozen native sources match all 2,156 maps / 1,320,112 padded cells: +FireRed 425/240512, LeafGreen 425/240512, Emerald 518/322688, +Ruby 394/258200, Sapphire 394/258200. Zero maps are missing or different, +including connection order and warp destinations. Production/tool hashes and +counts are recorded in `docs/qa/native-map-observations.json`. + +An initial border-dimension assertion failed on an unused zero-sized FRLG +layout. The correction retains native import behavior and leaves unavailable +source metadata nil. The permanent extractor test covers that record. Earlier +failed attempts are retained separately; they are not counted as passing runs. +Existing RS extractor warnings about unimplemented movement behaviors remain. + +This is an internal observation backend. It is not installed as the old map +module, provides no legacy atlas-name alias, and does not supply Game3 lifecycle, +script writes, UI, battles or audio. Native gameplay/GPU and unchanged full +Origami execution are not established by these CPU data comparisons. Full +compatibility remains false; existing native Gen2 ownership remains intact. + + +The final composed source at `e64a5b8d` passes 328/328 engine suites, +SDK 3/3 and Dramatic Shape B30. Its first full run passed 327/328: the +synthetic palette-cartridge fixture inherited retail flag metadata without +providing those source bytes or a real hash implementation. The test fixture +now declares only its generated source records; production checksum validation +is unchanged. That original failure and the later passing run are retained. diff --git a/docs/native-map-runtime.md b/docs/native-map-runtime.md new file mode 100644 index 000000000..0e43282bf --- /dev/null +++ b/docs/native-map-runtime.md @@ -0,0 +1,102 @@ +# Native-backed map read interface + +`src.core.game3.map` now resolves the established read-side entry points over +one real native GBA session. It is a distinct module, not an alias to the native +Map class and not a second player/script simulation. It never loads Game on a +read; the native Game must already exist. Native Gen2 maps and save keys remain +unchanged. Companion identities/layout/source contracts are #239/#240/#245/#246 +and the updated #250 tileset-alias metadata. Additional cartridge registrations +remain #183/#186/#187. + +Supported reads: `current`, `currentDef`, `definitions(game)`, +`ensureMidLayout(game,id,ownedDef)`, `loadNeighborsDepth1`, `computeWorld`, +`refreshWorld`, `worldMidAt`, `loadedLayoutCount`, `overscanSlices`, `warmRect` +and `warmNear`. Neighbour direction lookup retains the first edge; the list +retains every cardinal parallel connection in source order. Source definitions +also preserve dive/emerge metadata. `worldMidAt` follows established reverse +strip precedence, vertical/horizontal priority, world placements and border +fallback. Custom definitions preserve the original public callback/dimension +contract. Native-owned definitions connect and sample at the true grid extent; +source art padding remains intact and cannot hide the first neighbour column. + +`MapRuntime` owns canonical definitions with original semantic pair/tileset +aliases, numeric header fields, detached warps/connections and lazy layouts. +Original FRLG/Emerald popup flags and the distinct RS type/exception policies +are represented without changing the native engine's existing flags. All +metadata fields are checked against the real source Dataset builder. + +Definitions are stable within a dataset owner. Lazy layout reads observe real +native grid revisions and resident patches, publish a newer layout revision, +and retain readable older snapshots. Presentation overrides and metadata edits +are local to the owned view. `ensureMidLayout` rejects a shared native or foreign +definition instead of attaching fields to it. A held lazy definition becomes +unavailable after its owner is retired. It cannot retain a native map/renderer. +`Map.release()` retires the context; the actual mod loader calls it after wrapper +unwind on reload and teardown, without importing compatibility modules on an +ordinary native boot. Full invalidation of the internal runtime retires its +owned metadata; typed cell APIs publish grid revisions normally. + +## Validation + +- `luajit tools/map-runtime/check.lua`: 236 ROM-free checks, including real native + Map patch/restore APIs, canonical metadata, semantic layouts, all parallel + neighbours, true/padded boundary counterexamples, epoch retirement, actual + Loader teardown/wrapper unwind and weak map/renderer ownership controls. +- Run `tools/map-runtime/graph-consumer.lua` from the clean public reference + at `37a49b31f458d51ea9e00b90f2a247272ad09cb8` and from the composed engine. The + identical consumer compares 1,000 deterministic custom-definition scenarios, + including cycles, aliases, parallel/cardinal/non-cardinal edges, hop/reach + admission, callback order and 50 cell samples per graph. Both independent + rolling digests agree across 194,623 result values. JSON key order is not + significant. This comparison covers custom definitions; the true-extent + native boundary is an explicit separately checked adaptation. +- A frozen real LÖVE 12 CPU-only host runs actual native layout/map/tileset + extraction from independently hash-verified local cartridges. It matches all + 2,156 definitions and 1,320,112 padded cells against original source outputs. + The source side invokes the real map-tree writer and Dataset builder using + in-memory metadata only; its art/binary writes are discarded. The native side + bypasses only image construction/writes. Original layout/collision/border/ + connection/warp digest outputs are independently compared too. Zero metadata + or layout differences occur in all five editions. +- Loading unchanged Origami 1.0.9 in the real SDK loader now advances past the + former missing map module and stops at the missing player module. This is + source-load progress, not successful mod initialization or gameplay. + +For real-data reproduction, verify each whole ROM SHA1 against its registered +manifest, keep at least 8 GiB free, use a clean pinned reference and fresh QA +output directories. From that reference run for every edition: + +```sh +luajit /absolute/engine/tools/map-runtime/oracle.lua /absolute/engine EDITION /absolute/ROM SHA1 /absolute/oracle/EDITION.json +``` + +Create `oracle/inputs.json` with all five edition-to-ROM paths. Generate the +separate layout oracle as described in `native-map-observations.md`. Run with +LÖVE 12, using a fresh identity and output directory: + +```sh +POKEPORT_IDENTITY=oasc-gen2recomp-qa-mapruntime-UNIQUE \ +MAPRUNTIME_ENGINE=/absolute/engine MAPRUNTIME_ORACLE=/absolute/oracle \ +MAPRUNTIME_LAYOUT_ORACLE=/absolute/layout-oracle MAPRUNTIME_OUT=/absolute/output \ +love /absolute/engine/tools/map-runtime/rom-host +``` + +The standalone ROM-free tool and real-data host require the companion PRs above; +they intentionally live outside the default independent engine suite. Receipts +and production/tool hashes: `docs/qa/native-map-runtime.json`. No cartridge +assets, player profiles or private mod sources are committed. + +## Remaining contracts + +Simulation `Map.load`, initial-avatar changes and asset warming/scheduling are +not implemented here and are not silently delegated to incompatible functions. +Game3 lifecycle, player/script writes, atlas resources/animation, UI/battles/audio +and unchanged full-package execution remain open. No new GPU/gameplay or complete +map-runtime certification follows from the source-data comparisons. + + +The final frozen composed source `9fcc703e` passes 329/329 engine suites, +SDK 3/3 and Dramatic Shape B30. All five unchanged 1.0.9 source-load probes +assert actual mod discovery and advance to the same missing player module. +An earlier absolute-path SDK probe discovered no mod; it is retained as an +unadmitted attempt and does not count as initialization or compatibility. diff --git a/docs/native-money-command-layouts.md b/docs/native-money-command-layouts.md new file mode 100644 index 000000000..c51f7021e --- /dev/null +++ b/docs/native-money-command-layouts.md @@ -0,0 +1,18 @@ +# Native money-window script layouts + +`updatemoneybox` ($95) consumes three bytes (x, y, ignore) in all five GBA +editions. Treating it as operand-free decodes those bytes as commands. +`hidemoneybox` ($94) consumes x/y in Ruby/Sapphire, whereas the later games +removed both reads. Resolve this distinction per extractor, without mutating +the shared command table or duplicating manifests. Discovery uses the corrected common +update width. Only GBA import markers change; save schemas and files do not. + +Primary references: + +- https://github.com/pret/pokefirered/blob/master/src/scrcmd.c +- https://github.com/pret/pokeemerald/blob/master/src/scrcmd.c +- https://github.com/pret/pokeruby/blob/master/src/scrcmd.c + +`tools/script-command-widths/check.lua` decodes sentinel streams for every +edition and switches RS/later tables in both directions. It checks both +following assignments and the final terminator, not only the declared widths. diff --git a/docs/native-object-sheets.md b/docs/native-object-sheets.md new file mode 100644 index 000000000..7678498c7 --- /dev/null +++ b/docs/native-object-sheets.md @@ -0,0 +1,76 @@ +# Original object sheets alongside native actor art + +The GBA sprite importer normalizes cartridge facings into native standing, +walking and second-step rows, with separate running sheets. Consumers of an +original image-table interface need different row numbers and additional +poses. Reusing the native image directly loses those semantics. + +`Gen3ObjectSheets` adds an independent compact cache containing the original +row-major indexed pixels, all picture-table slots, exact 16-bit palette words, +dimensions, inanimate flags, subsprite anchors and reflection metadata. Each +pixel uses one byte rather than four RGBA bytes or a Lua number/table slot. +Tile allocation is compiled once per sheet, and byte-pair lookup expands tiled +4bpp data without allocating a table entry for every pixel. RGBA expansion is +available on demand for a later owned texture upload. + +Schema 2 also retains each normalized native sheet's original frame indices, +final image/geometry defaults and running-sheet mapping. An owned resource +adapter can project a native asset override into original slots without +assuming that row numbers match. Special native stage rewrites retain their +actual final definitions rather than guessed default filenames. Hoenn's +player/rival avatar state tables, link pairs and gender-specific flags are +read from edition-specific cartridge offsets. + +Picture arrays end at their own boundaries, including lists named by adjacent +graphics-info records. Mixed-size arrays retain their slots; a slot for a +different graphics row's dimensions remains transparent instead of consuming +adjacent art. This preserves all nine berry-stage slots and their proper +small/tall projections. Ruby/Sapphire's eight-byte subsprite records and the +four-byte records of the later editions are handled separately. + +The cache declares its schema, edition and cartridge SHA-1. The original +interface uses paired reflection metadata on FireRed, LeafGreen, Ruby and +Sapphire and a raw reflection profile on Emerald. The independently derived +native reflection palette is retained under `nativeReflectionPaletteTag` in +both profiles. This does not change the native engine's reflection rules. + +`extractOverworldSprites` writes `gen3_object_sheets.lua` in addition to its +existing outputs. Existing normalized sprite records, image names, frame +numbers, running sheets and renderer behavior are unchanged. Data loads this +optional module only for a GBA dataset; an earlier generation cannot inherit +it from a cache overlay. The GBA importer's required-file check requests the +normal automatic re-import for an older cache missing this module. No global +cache-format bump, mod-package conversion or player-save change is involved. +The GBA-only completion marker includes the object-sheet schema, so a cache +from an earlier schema follows the same automatic regeneration path even if +the module already exists. Earlier-generation markers remain unchanged. + +## Verification + +`tools/object-sheets/check.lua` exercises tile order, alpha, palette expansion, +noncontiguous pictures, mixed-size stages, both subsprite layouts, actual cache +serialization and old/new cache readiness across five GBA and five earlier +editions. The fixture uses the actual native marker producer, without reading +or writing an installed cache or changing the production cache-format API. + +The optional `oracle-host` compares the pinned independent extraction contract +against all five cartridge inputs. It compares every original indexed/RGBA +pixel, palette word, frame count and anchor. Its baseline mode runs the previous +native importer and checks that the new importer's normalized sprite records +and composed image pixels are identical. It also compares avatar tables and +checks native walk/run frame mappings against independently extracted original +indices. Native color expansion floors 5-bit channels while the original-order +interface rounds them; the mapping comparison preserves each rule. Special +native geometry/stage rewrites and the additional native table rows are +reported separately. Generated object modules also round-trip through the actual Lua +cache writer and LuaJIT compiler. Emerald's seven additional native table rows +are reported separately; they do not replace any original sheet. + +## Remaining runtime work + +This adds the data required by the sprite interface. It does not yet supply +`src.core.game3.ow_sprites`, GPU textures/quads, palette-override resources, +asset-override projection, prefetch/lifetime services or the original renderer. +Full unchanged package startup, special live actor poses and gameplay/GPU +acceptance remain open. No full compatibility or performance claim follows +from a CPU pixel comparison. diff --git a/docs/native-object-views.md b/docs/native-object-views.md new file mode 100644 index 000000000..7cdc7329d --- /dev/null +++ b/docs/native-object-views.md @@ -0,0 +1,53 @@ +# Native object and connected-map views + +`src.core.game3.objects` observes the live native GBA NPC pool. Importing it +does not create a Game, a world, an NPC, a texture or a second update loop. +`src.core.game3.ghosts` observes the native connected-map pools in map-local +coordinates; the consumer applies each connection placement exactly once. + +The imported object `index` is its preserved cartridge local ID. Canonical +map names resolve through the checked group/number binding, including when +native storage uses different map keys. The current and connected maps may +contain the same local ID without sharing an actor view. Invalid map names +cannot fall back to current-map objects. Conflicting live local IDs decline +the pool rather than publishing an ambiguous identity. + +Views have stable identities within a native cohort and expose live position, +movement, elevation, facing, step phase, visibility and actual resolved sprite +row. Their template and nested script data are owned copies. Extension fields +stay on the view; source code never receives a native actor or sprite resource. +The native pose rules supply jump lift on an observational copy. Queries do not +advance animation, NPC AI, script moves, playtime or the game RNG. + +Writes are limited to validated native visibility, facing, freeze state, +passability, horizontal presentation offset and in-range fixed-frame commands. +Movement-type changes and facing the player use the native NPC methods. +Position, movement state, elevation and raw graphics identity writes decline: +those changes need the actual native map/movement/script command owners. +View clearing retires observations only; it never deletes the native cast. + +An observed removal or a map, player, world, save, dataset, map-registry, +loader or edition replacement irreversibly retires an old handle. Reload/exit +retires the facade through the existing compatibility teardown, after wrapper +unwind. New observations can adopt the still-live native cast. + +## Validation + +`tools/object-runtime/check.lua` uses the real native NPC class and updater +with synthetic field metadata across FireRed, LeafGreen, Emerald, Ruby and +Sapphire. It checks live movement observations, native pose rules, connected +map identities/offsets, typed commands, detached records and cohort retirement. +It does not import cartridges or use player profiles. + +## Remaining actor contracts + +This is not complete actor compatibility or gameplay acceptance. Runtime +spawning/removal, script movement streams/tracks, virtual/network actors, +subpriority/fade/figure-eight commands, foreign carry/snapshots, template +visibility persistence and prefetch/adoption protocols remain to be connected +to their native owners. Source collision wrappers are not yet called by native +NPC AI or trainer sight. Sprite pixel/frame/anchor resources and special berry, +disguise and multipart poses still require the separate sprite interface. +`Objects.loadMap` can observe only the already active native map, and cannot +load or instantiate another map. No unchanged renderer or GPU acceptance is +claimed by this read-side interface. diff --git a/docs/native-pair-identities.md b/docs/native-pair-identities.md new file mode 100644 index 000000000..6706bb85c --- /dev/null +++ b/docs/native-pair-identities.md @@ -0,0 +1,88 @@ +# Semantic tileset pair identities + +Compatibility texture names resolve from the exact original primary AND +secondary bank addresses. A map name or shared primary graphics is insufficient: +two layouts may share graphics yet use different secondary metatiles/attributes. +`src.compat.gen1.PairIdentity` translates source identity metadata without +renaming native maps, mutating shared pair/bank records, building textures or +changing the native renderer. + +`lookup(edition, primaryAddress, secondaryAddress)` returns the semantic pair, +primary and secondary names, and original dataset tileset alias. `bind(nativePairs, nativeBanks, edition)` creates +an owned binding with `nativeId(name)`, `canonicalId(nativeId)`, +`descriptor(nameOrNativeId)` and `release()`. Descriptors are fresh tables and +expose semantic names, the original dataset tileset alias and explicit native pair/bank keys. They contain no raw +bank or GPU object. The binding retains the supplied native owner until release. +Unknown source pairs fail explicitly; no primary-only or map-name fallback is +used. Replacing a native record, changing its source address, role or pair keys +retires that lookup. Rebind to a new dataset owner after a session change. +Aliases to the same exact definition are safe. Different native definitions +claiming one semantic pair make reverse lookup ambiguous instead of choosing +one arbitrarily. Release drops all native table references. + +The metadata contains 303 original pairs across five US cartridge editions: +FireRed 60, LeafGreen 60, Emerald 75, Ruby 54 and Sapphire 54. It is numeric +source-address and public semantic-name metadata, not cartridge bytes or art. +It applies to the pinned retail identities in the native manifests. It does +not claim automatic recognition of arbitrary modified ROM layouts. + +## Verification and regeneration + +Run `luajit tests/engine/compatibility_pair_identity.lua`: 4,339 checks cover all +303 rows and original dataset tileset aliases, round trips, independent descriptor edits, alias/ambiguity handling, +unknown secondary sources, owner/bank replacement, source/role/key changes, +release and weak ownership canaries. The metadata generator is byte-stable. + +For regeneration, use a clean public reference at +`37a49b31f458d51ea9e00b90f2a247272ad09cb8` and your locally owned US cartridges. +Verify the complete ROM SHA1 against `tools/rom_manifest_EDITION.json` before +running each export from the reference directory: + +```sh +luajit /absolute/engine/tools/pair-identities/export.lua EDITION /absolute/ROM SHA1 /absolute/exports/EDITION.json +``` + +Create one JSON per edition: firered, leafgreen, emerald, ruby and sapphire. +`export.lua` invokes the original complete map census/registration, recording +resolved semantic pair names and both physical source header addresses. +`assemble.py` rejects contradictory pair/bank names and duplicate map entries: + +```sh +python3 tools/pair-identities/assemble.py /absolute/exports --output /tmp/PairNames.lua +cmp src/compat/gen1/PairNames.lua /tmp/PairNames.lua +``` + +For the real native cross-check, put the five absolute ROM paths in +`exports/inputs.json` keyed by edition and create a fresh output directory. +With companion map identity #239 and cartridge registrations #183/#186/#187: + +```sh +POKEPORT_IDENTITY=oasc-gen2recomp-qa-pairs-UNIQUE \ +PAIRS_ENGINE=/absolute/engine PAIRS_ORACLE=/absolute/exports \ +PAIRS_OUT=/absolute/fresh-output love /absolute/engine/tools/pair-identities/rom-host +``` + +Use LÖVE 12 and retain at least 8 GiB free. The host has no graphics, window or +audio; it checks each ROM SHA1, runs real layout/map/tileset extraction with +only image generation/writes bypassed, and verifies pair names/addresses and +reverse native ownership for every actual map. It matched all 2,156 maps and +303 pairs. Source hashes/counts are in `docs/qa/native-pair-identities.json`. +These are new CPU import/identity measurements; no GPU/gameplay acceptance is +claimed. No player profiles or saves are used. + +The helper is an internal atlas-identity prerequisite. Native texture leases, +animated palette revisions and the full older atlas API remain to be connected. +It is not installed as a replacement field or tileset runtime. Full unchanged +Origami execution and overall feature parity remain open. + +The frozen composed source at `f9f95603` passes 329/329 engine suites, +SDK 3/3 and Dramatic Shape B30. These results include the existing Gen2 +regression suites; they are not a full visual or story-parity certification. + +The source dataset tileset alias is verified independently, rather than +guessed from the pair spelling. The native map facade needs this additional +name while preserving its explicit native pair key. Real source/native +comparison agrees on all 303 aliases and 2,156 map assignments. + +The source-alias addition also passes the final composed regression at +`9fcc703e`: engine 329/329, SDK 3/3 and Dramatic Shape B30. diff --git a/docs/native-player-runtime.md b/docs/native-player-runtime.md new file mode 100644 index 000000000..3d2f1023f --- /dev/null +++ b/docs/native-player-runtime.md @@ -0,0 +1,56 @@ +# Native player phase compatibility + +The compatibility player module operates on the live native GBA actor. It does +not create a second avatar or copy the source movement/event simulation. + +Importing it alone leaves ordinary native input unchanged. A wrapper of its +`update(game,input)` entry point is dispatched from the real overworld player +phase. The base update accepts an input proxy, preserving the native field +handler's interaction, movement, collision, bike and movement-hook ownership. +An idle `tick` is deferred until a wrapper has had the opportunity to submit +its `forcedStep(direction,1)` arrival. There is at most one native player clock +advance in that phase; the shared native `finishPlayerStep` runs synchronously +before the wrapper resumes. Repeated ticks cannot duplicate arrivals. Scripted +NPC/player ordering, including a script queue draining on its landing frame, +continues through the original native path. + +Position, movement and elevation fields address the current native actor; +fractional body coordinates are preserved. Bike state addresses the native +in-memory save. Running eligibility asks the existing native rules without +requiring the B button to be held. Position synchronization changes only the +in-memory native player record and never calls persistence. Input proxies are +restored even when a handler raises. Menu/script/transition ownership prevents +additional input, and actual loader teardown unwinds wrappers and retires phase +observations without bootstrapping compatibility modules on native-only boots. + +## Verification + +`luajit tools/player-runtime/check.lua` runs 261 ROM-free checks across all five +GBA editions and checks the three native GBC editions keep their original phase. +It uses the real native player clock, input handler and arrival bookkeeping on +small synthetic fields, with authored event handlers replaced by counting +callbacks. Coverage includes fractional writes, forced arrivals, repeat ticks, +running gates, surf bob, script queue drain ordering, input exceptions, world +replacement and real loader teardown. It is also registered in the engine tier. + +The composed fork passes 330/330 engine suites, SDK 3/3 and Dramatic Shape B30. +A read-only SDK probe of the full unchanged 1.0.9 source package now reaches +the missing `src.core.game3.collision` interface on all five editions. A previous +reduced source fixture lacks vendor metadata after this point and is excluded +from that result. These are loader/CPU checks, not GPU or gameplay acceptance. + +## Remaining contracts + +This is the player phase foundation, not complete mod compatibility. The +distinct Game3 session facade, collision/field services, full scripted avatar +operations and pose/atlas resources still need integration. In particular, do +not claim all surf/dive/ledge/bike transitions or unchanged rendering have been +accepted from these synthetic checks. Real maps, actual events and visuals +must be verified after the remaining modules allow the package to initialize. + +The standalone upstream branch runs 108 checks on its two registered GBA +editions; LeafGreen, Ruby and Sapphire are explicitly skipped there until +cartridge PRs #183, #186 and #187 are integrated. All five run in the composed +fork. On pristine upstream main `13af468b`, the engine tier has 10 existing +failures (64/74 suites). The player branch has the same 10 failures (65/75), +with no newly failing suite. The composed fork includes the existing fixes. diff --git a/docs/native-script-space.md b/docs/native-script-space.md new file mode 100644 index 000000000..ac1adf19b --- /dev/null +++ b/docs/native-script-space.md @@ -0,0 +1,54 @@ +# Field script observations over native execution + +The `src.core.game3.scripting.space` module binds an unchanged consumer to the +actual GBA field runner, numeric command-backed flags/vars and current script +context. It does not load Game, create another world, write a save file or tick +a second field loop. Native map-entry and idle-frame callbacks open scopes; +source wrappers can consume the underlying callback once, or defer it. Native +runner update alone opens the VM tick scope. Extra render/query calls cannot +consume waits, and a wrapper cannot hide a failed native body and retry it. + +Map entry temporarily exposes the actual entering world before StateStack has +published it. The scope restores on errors and nested visits. Map/runner/save/ +dataset/loader/edition changes retire held VM views. Dataset projections survive +ordinary map changes. Exact owned cancellation uses native lock/fade cleanup +without inventing a trainer result, and declines foreign owners or active UI/ +movement that has not finished. Preview replacements suspend live writes; loader +rollback releases only replacements belonging to the failed mod. + +Connected-map object observations run the existing compiler and a bounded, +restricted native command pass on detached flags/vars/home templates. They do +not dispatch script hooks or script lifecycle events, borrow live entities, or +invoke UI/audio/IO commands. A loop exhausts the budget rather than blocking +the game. No preview result is committed to the live save. + +The import retains opcode/address, raw operand payload/specification and the +original special number alongside normalized native IR. GBA cache markers +automatically require regeneration; player saves are unchanged. Script/event +projections are owned copies. Edited/authored/replaced script lists currently +fail explicitly instead of silently executing an unchanged native list. + +## Verification + +- `tools/script-space/check.lua`: actual native compiler/runner and loader + rollback, all five edition identities; phase ordering, duplicate calls, + waits, retired views, preview isolation, bounded neighbour evaluation, + command ownership and operand projections. +- `tools/script-space/oracle-host`: actual five-cartridge map/script extraction, + unchanged normalized IR against the corrected decoder before this metadata, + raw-byte checks, independent original disassembly and Lua cache round-trip. + The test reads caller-provided cartridges; none are distributed. +- The oracle visibly corrects reference-table discrepancies for money-window + commands against the actual read counts in the primary sources linked in + `native-money-command-layouts.md`. It does not pad missing operands with zeros. + +## Remaining integration + +This is not complete script API or game acceptance. Rich text/control-code and +canonical movement IR, symbolic label/flag catalogues, an overlay compiler for +authored/edited scripts, arbitrary source VM construction and full immediate/ +return-to-field scheduling remain open. The native compiler still has unsupported +command lowerings. Full field services, complete session collections, renderer, +shaders, UI, battle, audio/RTC and all-edition gameplay/coexistence acceptance +remain required. The immutable full Origami package must be probed again after +each concrete bridge; module presence is not a claim that the game runs. diff --git a/docs/native-script-state.md b/docs/native-script-state.md new file mode 100644 index 000000000..067222f55 --- /dev/null +++ b/docs/native-script-state.md @@ -0,0 +1,55 @@ +# Native numeric script state and session schemas + +The source-facing session now exposes numeric `flags` and `vars` maps. These +are typed views over the existing native save, not a second script store. +Reading a missing value does not allocate native state. Writes run the native +flag commands and variable writer, preserving flag events, live visibility, +edition-specific league aliases and derived object-graphics slots. Context +variables stay outside persistent session maps and snapshots; the special +range ends at 0x8014 in Kanto and 0x8015 in Hoenn. + +Whole-map replacements validate every key/value and conflicting numeric/string +alias before any command runs. They preserve native-only named flags and +context variables. Session enumeration includes its existing scalar schema, +numeric state, detached playtime and presentation fields. Recursive copy code +can obtain an isolated flags/vars snapshot without borrowing native tables. +The session retires its maps when its game data, maps, save, edition or mod +loader changes, and on explicit release. + +LuaJIT's Lua-5.1 `pairs` and `next` do not consult `__pairs`. `TableIteration` +installs dispatchers before the mod mutation journals open. Only registered +views opt in; ordinary inputs go to the original host functions, including +the host's own treatment of metatables. `pairs(view)` uses an owned snapshot. +Numeric maps implement `next(view, key)` through a typed native cursor, without +quadratic snapshot reconstruction or lending the native table. Deleting the +current variable while iterating keeps the underlying native cursor semantics. +Registry callbacks do not capture their own weak key. + +Mutation-journal iteration is captured independently of later global wrappers. +Failed entry wrappers unwind before rollback iterates registries or calls +optional resource services. Native mods may wrap the iteration functions in +the usual forwarding style; rollback and teardown retain the infrastructure +and take back the mod's wrapper. + +## Validation + +``` +luajit tools/script-stores/check.lua +luajit tools/script-stores/session-check.lua +``` + +The backend suite exercises all five editions through the real native command +implementations, compiler and runner. The session suite verifies live reads, +numeric writes, replacements, aliases, special-variable exclusion, ordinary +table semantics, weak-key lifetime, recursive copies, direct cursor mutation +and real SDK entry rollback with a native iteration-wrapper peer. + +## Remaining scripting integration + +This adds session schemas used by the forthcoming scripting-space bridge. +It does not supply `src.core.game3.scripting.space`, a replacement VM, symbol +catalogues, complete party/bag/storage schemas, detached preview construction +or all source event/field/battle/UI/audio services. The unchanged original +package still stops at the scripting-space import. Full renderer, five-edition +gameplay, native-mod coexistence and performance acceptance remain open. +QA uses isolated identities, preserves player data and keeps 8 GiB free. diff --git a/docs/native-session-runtime.md b/docs/native-session-runtime.md new file mode 100644 index 000000000..38487895a --- /dev/null +++ b/docs/native-session-runtime.md @@ -0,0 +1,101 @@ +# Native game/session and frame boundaries + +`src.core.Game3` is a distinct class with per-native-owner facade instances. +It does not alias the central `Game`, instantiate a second engine or bootstrap +Game when imported. Standalone construction without an explicit native owner +declines instead of silently sharing another game; full source construction +remains separate. Explicit owner resolution rejects retired handles. A source +session is scoped to its native save, dataset, map table and edition; replacing +one retires its observations and queued callbacks. + +## Native ownership + +The native Game retains its frame, fixed-step, input, script, battle, audio and +save owners. Captured base methods run with that native Game, while source +wrappers receive their stable facade. A wrapper may hold an entry. Repeated base +calls in one entry retain the first native result, including nil-bearing return +lists, instead of advancing again. Native failures cannot be hidden or retried. +The native frame's existing collector step also exposes the source `stepGC` +seam; there is no additional collector running beside it. + +The runtime services one deferred batch at `Game:_step`, after input promotion +and stack recovery. New callbacks wait for the next native phase. Nested drains, +save/dataset/loader changes, stop, teardown and replacement batches cannot revive +an old cohort. A source runtime wrapper may hold that field phase. Callback +failures preserve diagnostics and owner attribution without replaying the batch. +`Runtime.update` cannot start a separate simulation outside that phase. + +Native playtime remains seconds. The source view projects hours/minutes/seconds +and fractional seconds without changing the save representation or integrating +another accumulator. Repeated RTC/VBlank observations do not tick another clock. +The VBlank property here observes serviced native phases; complete source RTC +and time-event interfaces are separate from this lifecycle boundary. + +## Callback and input interoperability + +Requiring the Game3/runtime namespace identifies the mod's expected game-handle +contract through the existing mod seam journal. This does not require changing a +manifest or recognizing a particular mod ID. Its game-bearing events and direct +game hook arguments receive the facade. Its `next` calls unwrap those handles +before native participants or the native body run. Other participants receive +the original game/payload, and custom event payloads keep their own contract. +Rollback and teardown remove that namespace selection. Record/context families +other than these explicit game/save boundaries still require their own adapters. + +Desktop mouse wrappers run before the native mouse path; native synthesized touch +is not also delivered as another source touch. Physical touch, editors, importers, +boot reports and the native pointer owners retain their entry paths. Pointer and +frame failures restore their scopes. Other generations bypass the source facade. + +Native title callbacks lend single-use actions to a source intro. A delayed +action still invokes the original native new-game/continue callback once and +declines if its save, dataset, loader or boot scope was replaced. Native boot UI, +naming, RNG, map entry and persistence are not copied into this facade. +Source reset callbacks run before mod wrappers unwind; cleanup is idempotent. + +## Session surface and remaining work + +The initial session view covers edition/generation, canonical current map and +position, player header, money/coins, bike/surf observations, native options/mod +state and the playtime projection. Supported scalar writes are typed and change +only the corresponding in-memory native fields. Coordinates, map changes and +clock replacement remain native commands. Temporary presentation extensions and +preview replacements never replace the underlying native save/stack. Persistence +continues through the existing native services and `mod.save`. + +Numeric flags and variables now use typed native command-backed views, with +session/map iteration and detached recursive-copy support; see +`native-script-state.md`. Party, bag, storage, dex, complete session schemas, +preview isolation, custom +boot UI and further field/actor/battle/audio services still need their typed +adapters. A differently shaped native collection is not exposed under a source +name. The data facade supplies the prior owned map views; other data schemas need +their own contracts. Unchanged-mod startup, full renderer/gameplay, GPU and +performance acceptance remain incomplete. + +## Checks + +Run with LuaJIT from the repository root: + +``` +luajit tools/session-runtime/check.lua +luajit tools/session-runtime/dispatch-check.lua +luajit tools/session-runtime/callback-check.lua +luajit tests/run_engine.lua +luajit tests/run_modkit.lua +``` + +These ROM-free checks exercise the actual native Game/StateStack/player phases, +frame clock/audio/GC ownership, title callbacks with a synthetic disk reader, +failure/replacement paths, all five editions, other-generation isolation, and +unchanged SDK consumers sharing callbacks with a native peer. They do not open +player profiles. Native QA must use the shared 8 GiB free-space guard. + +The same callback consumer runs on API 2 and API 3 where supported, selected by +`SESSION_RUNTIME_API`; its default is the host's actual advertised API level. +Integration prerequisites are the early native owner/player boundary (#254), +source map identities (#239), and owned map read views (#251 with dependencies) +when the data facade is used. The five-edition checks also need the separate +cartridge registrations (#183, #186, #187). Loading an original API-3 mod on +upstream additionally requires the existing API-3 stack; this boundary does not +raise or bypass the advertised mod API version. diff --git a/docs/native-sprite-runtime.md b/docs/native-sprite-runtime.md new file mode 100644 index 000000000..238e55ddb --- /dev/null +++ b/docs/native-sprite-runtime.md @@ -0,0 +1,66 @@ +# Owned original-order sprite resources + +`src.core.game3.ow_sprites` exposes the original image-table sprite interface +over the native GBA dataset. It owns CPU image data, textures and zero-based +quads. It does not lend native `Assets` textures, run an actor simulation, +advance a clock, import a cartridge or create a game session. + +The schema-2 object cache supplies indexed original frames, palettes, anchors, +avatar state tables and the native walk/run frame maps. Native image-definition +and asset-search overrides are projected into those original slots. The +`overworld/g3_NNN_source.png` override accepts a whole original-order sheet. +Static native definitions retain their one-frame behavior. Original-only poses +remain available when normalized walk/run art is overridden. Resource geometry +and the per-runtime 128 MiB CPU/texture budget are checked before publication. + +An explicit cache provider may supply SVOW versions 1–4 and RGBA payloads without +a game session. Missing entries fall back to the corresponding native sheet; +declared invalid metadata or pixels fail rather than borrowing unrelated art. +Generated manifest chunks use an empty environment and an instruction guard; +LuaJIT compilation is disabled for that chunk so loops cannot evade the guard. + +Preparation yields between decode rows, uploads and quad batches. Synchronous +getters flush the same job; asynchronous users step through the existing stream. +Cancellation, edition/dataset/save/mod-loader/asset-search changes and teardown +retire all owned allocations, including incomplete work and displaced public +cache entries. A failed mod loses only its claims on shared resources. Reflection +claims include every consumer; caller-injected graphics remain caller-owned. +Late script-validation failures unwind entry wrappers before owner cleanup. + +Pose, field-move, fishing and avatar observations use the existing native state. +The Hoenn and Kanto running frame rules differ. Observing a pose or selecting +graphics never advances the live player's pose timers or movement. + +## Validation + +Run with LuaJIT from the repository root: + +``` +luajit tools/sprite-runtime/resources-check.lua +luajit tools/sprite-runtime/cache-check.lua +SESSION_RUNTIME_API=2 luajit tools/sprite-runtime/lifecycle-check.lua +SESSION_RUNTIME_API=3 luajit tools/sprite-runtime/lifecycle-check.lua +``` + +The lifecycle suite loads real SDK entries, a native peer, an entry failure and +a successful entry rejected by later script validation. The resource suite is +also reused by `tools/sprite-runtime/host` with real GPU texture readbacks. +The host takes explicit `SPRITE_RUNTIME_ENGINE`, `SPRITE_RUNTIME_INPUTS`, +`SPRITE_RUNTIME_EXPECTED`, `SPRITE_RUNTIME_OUT`, `POKEPORT_IDENTITY` and +`POKEPORT_GRAPHICS_BACKEND` inputs. Expected cartridge pixels come from an +independent extraction contract, not this implementation's metadata. + +`pose-oracle.lua` can run the reference and this implementation in separate +processes for independent pose comparisons. Cartridge dumps, generated artwork, +player data and private mod sources are not part of these public fixtures. + +## Remaining integration + +This interface does not establish full original-package or renderer acceptance. +The unchanged package still requires further scripting, collection, field, UI, +battle and audio services. Automatic field scheduling, arbitrary actor-provider +semantics and special live berry/disguise/multipart actors need further work. +Native asset projection does not implement arbitrary native renderer monkey +patches. Full five-edition gameplay, native-mod coexistence, performance and app +packaging remain separate acceptance requirements. QA keeps an 8 GiB reserve +and uses isolated identities, never player profiles. diff --git a/docs/native-terrain-palettes.md b/docs/native-terrain-palettes.md new file mode 100644 index 000000000..56768a3d7 --- /dev/null +++ b/docs/native-terrain-palettes.md @@ -0,0 +1,31 @@ +# Native terrain palette programs + +Normal GBA imports now classify terrain palette callbacks and preserve original +BGR555 palette words beside existing RGB data. Exact-ROM profiles cover Emerald +and FireRed Rev 0. Their pinned source references and digests are recorded in +`tools/gen3_palette_program_profiles.json`; unknown revisions or unproved +programs remain explicitly unavailable. No callback code executes. Bounded +read-only initializer analysis only identifies an audited runtime program. + +Emerald's Battle Dome program supplies four unfaded palettes on a four-tick +cadence. FireRed's audited terrain callbacks are tile-only. Palette frame records +retain both native RGB and original words; existing RGB values are unchanged. +The stage authenticates the actual cartridge bytes, clears stale success on +failure and writes diagnostics to the existing tileset cache. The other three +GBA editions and arbitrary modified cartridges are not certified by these +profiles. Existing imports need a cache refresh before the new metadata exists; +automatic upgrading remains separate. + +The native decoder observes independent palette ticks. The renderer refreshes +both terrain layers, cached batches, borders and reflection covers while keeping +their GPU texture identities. A bounded CPU picture cache avoids rebaking +repeating palettes; decoder samples retain at most four pictures and invalidate +on in-place source edits. Missing or invalid metadata retains base rendering. +No additional simulation, RNG, save field or mod manifest change is introduced. + +ROM-free tests cover real synthetic headers and automatic stage serialization, +initializer/recipe failures, mutable source invalidation, cache limits, native +sheet/border/reflection pixels, unchanged legacy rendering and upload failure +cleanup. Run the six `gen3_palette_*` / `gen3_static_palette_reuse` engine suites +and `python3 tests/test_gen3_palette_profiles.py`. These checks do not establish +full-game, GPU, performance, cartridge fading or map-local reset-phase parity. diff --git a/docs/portable-core-update.md b/docs/portable-core-update.md new file mode 100644 index 000000000..74ed54262 --- /dev/null +++ b/docs/portable-core-update.md @@ -0,0 +1,84 @@ +# Portable interactive frame hook + +Gen2Recomped dispatches `core.update` at the same host boundary as Gen1Recomp: +`main.lua` calls `PlatformHooks.update(game, dt)` for each normal interactive +Game frame. Native API 1/2, unified API 2 and API 3 use the same signature: + +```lua +local mod = ... +local paused = false +mod.hooks:wrap("core.update", function(next, game, dt) + -- Poll/update the mod's own overlay even while Game simulation is paused. + if paused then return end + return next(game, dt) +end) +``` + +The arguments are the original opaque Game handle and frame delta. The hook +runs once at the interactive frame boundary, outside the fixed-step loop; +fast-forward does not multiply callback frequency. Vanilla calls +`game:update(dt)` and discards its return values, matching Gen1's helper. + +Omitting `next` pauses that frame's Game update. The pausing wrapper still +runs on subsequent frames, so its overlay can observe its own state and resume. Calling +`next()` forwards the current arguments; explicit `next(otherGame, otherDt)` +changes both downstream wrapper arguments and the actual native update target. +This differs from raw input hooks, whose native bodies retain the original +input event. Returned booleans have no independent consume meaning: whether +the callback calls `next` determines whether Game updates. + +The helper returns hook-chain results, including nil holes. LÖVE's interactive +host callback ignores those results, just as Gen1 does. Existing priority, +unsubscribe, owner cleanup and error rules apply. A wrapper that fails before +forwarding is skipped; a failure after forwarding does not replay the native +update. Native update errors propagate without being retried. Unified +callbacks retain their existing cached-continuation behavior; this feature +does not change the hook bus's rules for repeated explicit `next` calls. + +`mod:supports("hooks", "core.update")` reports availability before the mod entry +runs, and the unified callback catalog accepts the hook without record +conversion. The Game identity remains unchanged unless a wrapper deliberately +passes a replacement to `next`. + +## Host routing + +The hook pauses Game simulation only. Work outside Game:update, including +normal host housekeeping and drawing, retains its native routing. A boot +report, launcher/importer, editor or a frame with no Game does not dispatch +the hook. + +Autopilot and coroutine-driver runs keep their existing direct deterministic +`Game:update(1/60)` calls and bypass `core.update`, matching Gen1. Each scripted +observation still has its own step, including multiple iterations per rendered +frame. A failed or completed driver returns through its existing exit path. +Do not install an additional wrapper on Game:update to implement this seam; +that would also affect scripted loops and could double dispatch it. + +With no subscriber, the existing Runtime/bus fallback calls the native update. +Its default function is hoisted, and dispatch allocates no payload table or +per-frame continuation closure on that unsubscribed path. + +## Verification + +```sh +luajit tests/engine/core_update.lua +luajit tests/engine/core_update_routing.lua +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The reference is clean Gen1Recomp +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same committed mod entry chunk +runs through both engines' native loaders on Red, Gold, FireRed and Emerald. +Tests load the real main.lua and invoke the actual love.update callback, +including Gen1's profiler wrapper. Test-only upvalue injection supplies a +Game:update spy and host-owner fixtures without importing a cartridge; it does +not replace the native routing or PlatformHooks implementations. Unrelated +host housekeeping is isolated behind a counter. The checker compares frame +journals and rejects dirty or unexpected Gen1 sources. + +The Gen2 routing matrix covers every supported host version in API 1, +native/unified API 2 and API 3. These checks are ROM-free and headless; they +verify dispatch and lifecycle behavior rather than full-world simulation, +GPU rendering or native OS event delivery. diff --git a/docs/portable-input-hooks.md b/docs/portable-input-hooks.md new file mode 100644 index 000000000..25ebda673 --- /dev/null +++ b/docs/portable-input-hooks.md @@ -0,0 +1,81 @@ +# Portable raw input hooks + +Gen2Recomped dispatches `input.key`, `input.gamepad`, and `input.wheel` +through `mod.hooks:wrap`, matching the native Gen1Recomp callbacks at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. A mod overlay can observe or +capture input without replacing engine methods or importing private modules. + +| Hook | Callback arguments after `next` | Payload | +| --- | --- | --- | +| `input.key` | `game, event` | `{ phase = "pressed" or "released", key = key }` | +| `input.gamepad` | `game, event` | Button: `{ phase = "pressed" or "released", joystick = joystick, button = button }` | +| `input.gamepad` | `game, event` | Axis: `{ phase = "axis", joystick = joystick, axis = axis, value = value }` | +| `input.wheel` | `game, dy` | Original numeric vertical delta, including fractions and zero | + +`game` and `joystick` retain their native identities. The joystick is an +opaque host handle, not a portable serializable record. Button events do not +include axis fields, and axis events do not include `button`. Keyboard +scancodes/repeat flags and horizontal wheel deltas are not part of this +contract. + +```lua +local mod = ... +local captured = false +mod.hooks:wrap("input.key", function(next, game, event) + if captured then return end + return next(game, event) +end) +``` + +Omitting `next` suppresses the entire normal Game callback, including its +hotkeys, top-state capture, Input updates and axis transitions. It also +suppresses wheel zoom. A returned boolean has no independent consume meaning: +calling `next` and then returning `true` or `false` has already forwarded the +event. `next()` forwards the current hook arguments. As in Gen1Recomp, +`next(game, replacement)` changes what downstream wrappers see; the native +body still acts on the original callback arguments. This is not a synthetic +input API. + +Releases can be captured too. If a mod forwards a press, it must also let the +matching release through or deliberately manage its capture lifecycle; +otherwise the native held state remains set. Prefer capturing complete +press/release pairs. Activating an overlay between a forwarded press and its +release needs particular care. Unsubscribing removes callbacks, not held state. + +Hooks run at the live Game boundary. Launcher/importer/editor routing and +accelerometer filtering outside that boundary retain their existing behavior. +Within Gen2Recomped, existing native routing is preserved: emergency hotkeys +precede top-state key capture, releases update Input before capture observers, +and the free camera keeps its existing wheel behavior (including zero delta). +Gen1-specific gamepad polling/watchdog bookkeeping is still owned by Gen1; +this contract does not promise identical internal input algorithms. + +When no hook chain is installed, calls bypass the hook bus and allocate no +hook payload or continuation closure. Existing bus priority, error handling, unsubscribe and +owner cleanup apply. Wrappers that fail before `next` are skipped; a failure +after forwarding does not replay native side effects; native errors propagate. + +The hooks work with native API 1/2, unified API 2 and API 3. The central +callback and capability catalogs include them, so +`mod:supports("hooks", name)` reports availability before the mod entry runs. +This change builds on the common API-3 entry feature (PR #102). + +## Verification + +```sh +luajit tests/engine/portable_input_hooks.lua +luajit tests/engine/portable_input_routing.lua +python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The ROM-free checker loads the same committed example entry chunk through +each engine's real mod loader and invokes its native Game callbacks with the +real Input singleton. It covers Red, Gold, FireRed and Emerald, checking raw +payloads/identity, forwarded and suppressed input, held releases, fractional +and zero wheel values, continuation rewrites, callback errors and unsubscribe. +It compares input-state journals across engines and refuses a dirty or +unexpected Gen1 reference. It does not replace Gen1 callback implementations. +The Gen2 routing suite additionally covers every supported host version, +menu capture, engine hotkeys, touch bookkeeping and free-camera zoom. diff --git a/docs/portable-logic-speed.md b/docs/portable-logic-speed.md new file mode 100644 index 000000000..03f8502b1 --- /dev/null +++ b/docs/portable-logic-speed.md @@ -0,0 +1,92 @@ +# Portable logic speed hook + +`core.logic_speed(next, game)` wraps native speed resolution on Gen2Recomped, +matching Gen1Recomp. Native API 1/2, unified API 2 and API 3 all receive the +original opaque Game handle, with no record/ID conversion or extra arguments. +`mod:supports("hooks", "core.logic_speed")` reports the native producer on every +Gen2Recomped host, including before a Game/save exists. + +```lua +local mod = ... +local fastForward = false +mod.hooks:wrap("core.logic_speed", function(next, game) + if fastForward then return 20 end + return next(game) +end) +``` + +A call without `next` supplies the multiplier. `next()` retains the current +Game; `next(otherGame)` forwards that replacement to downstream wrappers and +the native `_resolveLogicSpeed` method. The native resolver reads live options. +These are engine handles: a replacement must implement that native method. +`next` resolves options; it does not re-enter the replacement's `logicSpeed` +policy checks. The original caller's hard locks and launch override have +already been checked before the chain runs. + +The precedence is: + +1. A link session or open link transport forces literal 1X and skips the hook. +2. Any stacked state with `isFixedSpeed` or `isMinigame` forces literal 1X and + skips the hook, including when a menu covers that state. These markers are + opt-in metadata, not a claim that every existing minigame declares them. +3. An explicit `--speed` / `POKEPORT_SPEED` override skips the hook and uses the + existing native clamp. +4. The hook chain wraps the saved-option resolver. Without a subscriber, the + resolver runs directly without a per-call closure or payload allocation. + +Gen2Recomped keeps its global `save.options.speed` and existing ladder +`1, 2, 4, 10, 20, 30, 50, 75, 100, 200`. Gen1 has a 3X rung and may use +category-specific options or cartridge ladders. A portable mod should choose +common supported levels or accept the host's nearest native level. This hook +does not migrate settings or promise identical ladders. + +The first hook result is clamped at the outer producer boundary. Numeric +strings are accepted; invalid/nil/false/table, NaN and infinite results become +1X; finite values choose the nearest native level. Hooks cannot introduce a +zero/negative timestep or poison the accumulator. Existing priority, +unsubscribe, owner cleanup and error rules apply: failures before forwarding +fall through; failures after forwarding preserve the downstream result without +replaying resolution. Native resolver failures propagate without retry. +Unified continuations retain their existing caching rules. + +The hook runs per unlocked `Game:logicSpeed()` call. Normal `Game:update` +samples once per frame, outside the fixed-step loop. Native title/intro code +may also query the multiplier; this is not a once-per-frame notification. +Only simulation time is multiplied. Audio, camera and presentation updates +retain their real-time delta. If a hard lock starts during an accelerated +step, the installed fixed-step callback drops the remaining frame budget; +it does not arm the separate hitch-suppression path or slow the next frame. + +Gen1's GBA boot/cinematic restrictions remain native to that host (and explicit +overrides keep their existing boot precedence). Gen2's own title/intro code +retains its timing. This feature does not transplant Gen1-only boot states. + +## Verification + +```sh +luajit tests/engine/logic_speed.lua +luajit tests/engine/logic_speed_routing.lua +luajit tests/engine/logic_speed_clock.lua +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp --api 1 +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/logic-speed-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The checker requires clean Gen1Recomp +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36` and rejects a dirty/unexpected +reference. The same committed mod source runs through both native loaders on +Red, Gold, FireRed and Emerald, calling the real Game/Game2/Game3 speed and +lock methods on ROM-free owner fixtures. Journals compare the common contract; +separate assertions preserve native ladder and Gen1 GBA boot differences. +Gen1 does not opt into unified callbacks for API 2; that run verifies the same +consumer against native Gen1 API 2 and unified Gen2 API 2. API 3 is unified +on both engines. + +The Gen2 routing matrix covers all supported versions in native API 1/2, +unified API 2 and API 3, plus rollback, owner cleanup and no-mod behavior. +The clock suite captures the real callback installed by `Game:load` (isolating +unrelated import/render/save setup), then runs real `Game:update` and +`FixedStep` to check acceleration, mid-frame lock transitions and real-time +audio/presentation. These tests require no ROM or GPU and do not claim a +full imported-game playthrough or live network session. diff --git a/docs/portable-mod-input.md b/docs/portable-mod-input.md new file mode 100644 index 000000000..51c705fff --- /dev/null +++ b/docs/portable-mod-input.md @@ -0,0 +1,78 @@ +# Portable logical mod input + +`mod.input` implements Gen1Recomp's source-owned logical input contract in +Gen2Recomped, for native API 1/2, unified API 2 and API 3. The signatures are: + +| Method | Result | Effect | +| --- | --- | --- | +| `mod.input:tap(game, button)` | `nil` | Queue an edge for the next fixed step and immediately release this call's source | +| `mod.input:press(game, button)` | opaque token | Queue an edge and hold this call's source until released | +| `mod.input:release(token)` | boolean | Release this mod's token once; return `false` for missing, foreign, forged, retired or already released tokens | + +Buttons are exactly `up`, `down`, `left`, `right`, `a`, `b`, `start`, and +`select`, in lowercase. They are logical actions, independent of keyboard +aliases or user rebindings. Unknown buttons raise an error before input is +changed. The common contract does not include shoulder or DS face buttons. + +Wait for `game.ready` to obtain the live game; both `tap` and `press` take +that explicit handle. Constructing or reading `mod.input` does not create a +Game/Input instance, register callbacks, or require graphics. + +```lua +local mod = ... +mod.events:on("game.ready", function(event) + local game = event.game + mod.input:tap(game, "a") + local token = mod.input:press(game, "right") + -- Later, when this mod's action ends: + mod.input:release(token) +end) +``` + +Keep tokens in memory; they carry no editable fields and cannot be copied or +serialized. Each press owns a separate source. Releasing one token preserves +other tokens for the same button, other mods, keyboard/gamepad input and +virtual controls. Multiple taps before the same fixed step coalesce into that +step's single `wasPressed` edge. A tap's own source never remains held, but +another source can still hold the button. + +The normal Input queue determines edge lifetime. Releasing a token or +cleaning up a mod releases its held source; it does not undo an edge already +queued. A press followed by a release before the next step still yields one +edge and no held state. An Input reset also clears queued edges. + +Gen2Recomped retires tokens whenever their Input instance resets, including +focus/visibility changes, controller removal and input initialization. The +handle remains usable after recovery. Rollback retires the failing mod's +handle; hot reload, a new loader session and launcher teardown retire old +handles. A retired handle refuses new taps/presses. Tokens cannot release +sources belonging to a replacement session, even for the same mod ID. +Outstanding holds remain registered for teardown even if the caller loses +its token or loader references. + +Logical input goes directly to Input's per-source bookkeeping. It does not +synthesize OS events or dispatch `input.key`, `input.gamepad`, `input.wheel` +or engine display hotkeys. A raw-input hook can therefore request a logical +button without recursively entering itself. + +## Verification + +The reference is unmodified Gen1Recomp +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The checker loads the same example +entry chunk through both native mod loaders, emits `game.ready`, and checks +the real Input singleton's held/edge state on Red, Gold, FireRed and Emerald. +It tests the shared contract; the Gen2 lifecycle suite separately verifies +Gen2's reset and retired-handle behavior. + +```sh +luajit tests/engine/portable_mod_input.lua +luajit tests/engine/mod_input_lifecycle.lua +luajit tests/engine/mod_input_headless.lua +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The lifecycle tests cover all supported Gen2Recomped host versions under +API 1, native/unified API 2 and API 3. The checks are ROM-free and headless; +they do not measure OS event delivery or physical controller hardware. diff --git a/docs/portable-screen-visibility.md b/docs/portable-screen-visibility.md new file mode 100644 index 000000000..50f6672d3 --- /dev/null +++ b/docs/portable-screen-visibility.md @@ -0,0 +1,98 @@ +# Portable state visibility predicate + +Gen2Recomped dispatches `screen.render_visible(next, state)` through native +API 1/2, unified API 2 and API 3 on every supported host. A companion or custom +UI can mirror a native state elsewhere while omitting its main-screen render: + +```lua +local mod = ... +local mirrored = false +mod.hooks:wrap("screen.render_visible", function(next, state) + if mirrored and state.screenId == "BagMenu" then return false end + return next(state) +end) +``` + +The default is `true`. Only a literal first result of `false` hides a state; +nil, zero, strings and tables do not. `next()` retains current arguments; +`next(state)` forwards the supplied state to downstream predicates. The native +default ignores that argument and returns true. These returns do not consume +input, pop a screen, pause simulation or replace the state that is drawn. + +Treat this as a pure predicate. Composition may ask about the same state +several times, including opacity selection, the main draw loop and palette +selection. Do not mutate states, open/close screens or depend on a specific +number of calls. Use descriptive fields such as `screenId` or `kind`, checking +which identities the host's actual screen exposes. This hook does not establish +a new cross-generation catalog of screen IDs. + +A hidden state: + +- Does not draw through `StateStack:draw` or the main Game composition loop. +- Cannot be the topmost opaque render base, a world-backdrop opaque fallback, + or the owner of main-screen UI/world palette zones. +- Stays in the same stack position and keeps normal update/input ownership. + Native covered-state animation still uses the original opacity rules; + simulation and keyboard handling never evaluate this render predicate. + +The hook controls individual main-screen states, not the whole output window +or a separate device display. It does not change whole-stack surface sizing, +fill/anchor preferences or native lifecycle semantics. Hiding an empty/sole +state draws no state; it does not promise a particular clear color. Separate +external world passes remain subject to their native host routing. + +`mod:supports("hooks", "screen.render_visible")` reports the real Gen2 producer, +including during mod entry before a Game is created. Unsubscribed calls return +true directly, without allocating a predicate closure or payload. Existing +priority, cleanup, rollback and error rules apply. A wrapper failure before +forwarding falls through; one after forwarding preserves the downstream +result. Native draw failures propagate and are not retried. Unified +continuations retain their existing caching rules. + +## Reference boundaries + +The verified Gen1Recomp reference is clean commit +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Its Gen1 and Gold-family games +use the common StateStack producer. Its separate GBA UI stack does **not** +dispatch this predicate, despite listing the name in its callback catalog. +Gen2Recomped uses its common native stack for all five generations, so this +addition is also available on its GBA/DS hosts. The shared tests compare Red, +Yellow, Gold and Crystal; the Gen2-only matrix covers every supported version. +No Gen1 sources are modified to make the contract pass. + +Native Gen1 API 1/2 passes the original state; Gen1 API 3 currently supplies a +shallow context view and can restore edits through its generic adapter. +Gen2 passes the original opaque state in all four tested modes. Therefore +table identity, arbitrary state edits, metatable access and replacement-state +mutation are **not** shared guarantees. The same pure, property-based consumer +runs on both engines unchanged. Tests assert each host's existing identity +behavior separately. They do not retrofit Gen1's adapter into Gen2 or mask +that remaining distinction. Gen1 API 2 ignores the unified opt-in, so that +comparison uses native Gen1 API 2 against unified Gen2 API 2. + +Gen1 Gold draws its overworld outside the state stack and has its own native +widescreen/palette routing. Common journals compare state draw order and render +base, not external-world draw counts or palette implementations that differ +between hosts. The Gen1/Gen2 palette paths that use the shared Renderer are +asserted separately. + +## Verification + +```sh +luajit tests/engine/screen_visibility.lua +luajit tests/engine/screen_visibility_routing.lua +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp --api 1 +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/screen-visibility-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The checker rejects a dirty/unexpected reference and runs the same committed +mod through both native loaders. It invokes the real StateStack and Game draw +methods (including native Gold `drawScene`) with ROM-free states and isolated +Renderer presentation methods. The Gen2 matrix also checks animation/input +ownership, world palettes/clear ownership, hidden backdrop states, no-mod fast +paths, owner removal and failed-entry rollback. The CI workflow pins the same +reference and publishes machine-readable contract reports. These headless +checks do not claim a cartridge playthrough, actual mirrored-device output +or GPU pixel equivalence. diff --git a/docs/portable-world-scripts.md b/docs/portable-world-scripts.md new file mode 100644 index 000000000..1391146ba --- /dev/null +++ b/docs/portable-world-scripts.md @@ -0,0 +1,164 @@ +# 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). Flags, 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. diff --git a/docs/qa/compatibility-cell-semantics.json b/docs/qa/compatibility-cell-semantics.json new file mode 100644 index 000000000..9e2c9f25e --- /dev/null +++ b/docs/qa/compatibility-cell-semantics.json @@ -0,0 +1,33 @@ +{ + "referenceCommit": "37a49b31f458d51ea9e00b90f2a247272ad09cb8", + "comparison": { + "hashA": 1852260190, + "hashB": 556508393, + "checks": 517218 + }, + "nativeGridModified": false, + "playerProfilesUsed": false, + "nativeMapViewInstalled": false, + "fullCompatibility": false, + "sourceSha256": { + "src/compat/gen1/MetatileBehavior.lua": "b842b9f4dde1233e1ce14850fc0b887208a3a8e33d361e5d365b59ba3a3a83b5", + "src/compat/gen1/MetatileNames.lua": "5961db4e419b21e673caa268e3dca58b22e206cb58886b71712674bf8aeb00a4", + "src/compat/gen1/CollisionClass.lua": "5ab3ae9372aea359d9f0e017f2ff3c280dba3fc37f982e2d930857d9db13cd3c", + "tests/engine/compatibility_cell_semantics.lua": "db1c596cca3c8facfe25cadc2f24ded9f5e7306ffde3ed0d2c1961dcb891e9bc", + "tools/metatile-compat/benchmark.lua": "9aae9c28d705c5358a7160bec4bef4350b59fcea684181aaf6fc44543809168f", + "tools/metatile-compat/check.py": "06a8e698c1e86127fd97b2c21208cef57ec0db35262995694315d32db2b938e7", + "tools/metatile-compat/consumer.lua": "e729420eec2b1f1ae536e2860ed15944638527c7df38c8e5e7e639c4d67cc6c8", + "tools/metatile-compat/export.lua": "3326fbfdf518834b3a4dcbc7e00438c171813f1c628786618352ad1576c936b0", + "src/compat/gen1/MapViews.lua": "61f7f9aec22c735045bc2a39a3346e9c51b9bc94ce7f7f250b71bc3e27a81e55" + }, + "isolatedClassifierChecks": 807, + "nativeMapViewBackendAvailable": true, + "companionIntegrationRequired": [ + 239, + 240, + 246 + ], + "mapViewLifecycleChecks": 211, + "fiveEditionDataProof": "docs/qa/native-map-observations.json", + "nativeMapViewInstalledAsLegacyModule": false +} diff --git a/docs/qa/follower-callback-owner.json b/docs/qa/follower-callback-owner.json new file mode 100644 index 000000000..ce9f7ef94 --- /dev/null +++ b/docs/qa/follower-callback-owner.json @@ -0,0 +1,23 @@ +{ + "schema": 1, + "date": "2026-10-26", + "forkCommit": "8e336d459619d26f7099825248a655f2d5385940", + "publicCandidateCommit": "b09178f0", + "publicScope": "Exact public Game3/ModSeams candidate files over the already offered native callback/rollback prerequisites; not a new standalone or full-package acceptance.", + "checks": { + "nativeFollowerOwner": 12, + "passed": true, + "unchangedEntrypoints": 17 + }, + "assertions": [ + "native callback game/world identity retained", + "follower-only namespace owns its presentation", + "native sprite resource retained", + "successful/error callbacks restore owner context", + "rollback retires callback ownership and presentation" + ], + "publicCandidateSha256": { + "src/core/Game3.lua": "e95fab5c64933978a1830188bef14b18feb3d6a68a6d2460a074e6ac94476c9b", + "src/mods/ModSeams.lua": "18f75d4be62028c0907bd81f92413e5a356316f5d6882c2ec608e8ff62143d15" + } +} diff --git a/docs/qa/gen1-mod-compatibility/decoded-layout.json b/docs/qa/gen1-mod-compatibility/decoded-layout.json new file mode 100644 index 000000000..b696ccca9 --- /dev/null +++ b/docs/qa/gen1-mod-compatibility/decoded-layout.json @@ -0,0 +1,29 @@ +{ + "gen1Commit": "37a49b31f458d51ea9e00b90f2a247272ad09cb8", + "contracts": { + "decoded-layout": { + "checksPerHost": 245, + "consumerSha256": "584ac72235937e1574d3c6cfd520e001576d70bfa4e9d2b10588bf5552d9d9f0" + }, + "connections": { + "checksPerHost": 12160, + "consumerSha256": "31c51257eebf99d435940c1513d4179403b8922ca7e1e5b94f564ff2a2f5fec3" + } + }, + "sourceSha256": { + "src/core/game3/layout_native.lua": "48d1290f884f5651feb60e0982ac436873e236e73c97d8bc0bcd642780285b5e", + "src/core/game3/connections.lua": "bde29d7af7cec3add78c24126ac5f395a529a4a3798899b2105c66d0a3501854", + "tests/engine/gen1_layout_native.lua": "0cfc94f5ce71e02d64fddba00a1aaf316a2af636e35c4b368bf190303a2f2b01", + "tests/gba_layout/consumer.lua": "d0d001e426c87a988d4f070716aa2c8879e6f4f1d2ba25cdfbf87af03dafe799", + "tests/engine/gen1_connections.lua": "d52763f0ccf8e79ea373d8969c8074ec7fd973c8be8d99eef591a29c02311ed0", + "tests/gba_layout/connections.lua": "1941fe96ffb0bdda59238db75502fad8517552e5a1aee08f924cc1425a89f31f", + "tools/gen1-layout-compat/worker.lua": "6e1ad80137d964535e075bb4e79b10761483c95d496a3384dfd22e7e879ec965", + "tools/gen1-layout-compat/connections.lua": "9964235ccab2fbf27d8bc41476a2b66fd5b031b427881fec022e508f293c0f26", + "tools/gen1-layout-compat/check.py": "f3646f876023f88daeceef696c6699418d644d88fbf6bfba42c9913dfd67151e" + }, + "nativeGridBridged": false, + "collisionOrSaveChanges": false, + "playerProfilesUsed": false, + "originalOrigamiLoads": false, + "fullLegacyMapContract": false +} diff --git a/docs/qa/gen1-mod-compatibility/map-identities.json b/docs/qa/gen1-mod-compatibility/map-identities.json new file mode 100644 index 000000000..f4db099ca --- /dev/null +++ b/docs/qa/gen1-mod-compatibility/map-identities.json @@ -0,0 +1,36 @@ +{ + "gen1Commit": "37a49b31f458d51ea9e00b90f2a247272ad09cb8", + "checksPerHost": { + "firered": 5955, + "emerald": 8811 + }, + "consumerSha256": "1066f707e0a6f7d922aa628d47de662ead5a8b24edee66c9cb38f03eb9d7a3f7", + "nativeImports": [ + { + "edition": "firered", + "matched": 425, + "unknown": 0, + "mapsSha256": "6a2713df9f1a97572a3a88edd8682aaa9e4547fe206a4d93a03e9f7818484d09" + }, + { + "edition": "emerald", + "matched": 518, + "unknown": 0, + "mapsSha256": "f332ea7f1fe3505a71b72163dce80f7e80f8293fb8155cdbcc585eef3f1de907" + } + ], + "mapRecordsRenamed": false, + "playerSavesUsed": false, + "originalOrigamiLoads": false, + "fullLegacyMapContract": false, + "sourceSha256": { + "src/compat/gen1/MapIdentity.lua": "ee4985ed875161978cbad524c4c90a27318862c000bf72ce85881f92e43b71f3", + "src/compat/gen1/MapNames.lua": "f881aa9238715603a1fffb56a56e36091c272e2027460535bbfe3443d7b4cdc4", + "src/import/gba/map_catalog.lua": "97783c17580428ae5eecbdde7d47bf128a6d37f8b3d028781e97b7d096cb20d4", + "tests/engine/gen1_map_identity.lua": "bd4f0b92f9a713f7fb7aa19273cf40f982744df63b4fddc64622816333bfc84d", + "tools/gen1-map-compat/consumer.lua": "39ab1f716470e7647144e848e93c7bba82fa738b784dd5742a5104f24c318852", + "tools/gen1-map-compat/export.lua": "cf29c79cb719b08bcbba89e24f7d4a98838742d2d6434577140fcabdfc2d27fa", + "tools/gen1-map-compat/native.lua": "f349add9bdf1de39e6fee90b5a96cfa29e9b32abb76e12f3cc3b2de664371a3d", + "tools/gen1-map-compat/check.py": "9133f57d3734ef3064f912ba00a33a2f34c55a5383017d289695d95574f24f88" + } +} diff --git a/docs/qa/native-companion-completion.json b/docs/qa/native-companion-completion.json new file mode 100644 index 000000000..33a1725fa --- /dev/null +++ b/docs/qa/native-companion-completion.json @@ -0,0 +1,211 @@ +{ + "publicRuntimeHead": "79c3705850aa4173f529bdee27e9e640ead1e863", + "upstreamBase": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d", + "gates": { + "engine": { + "passed": 194, + "total": 194 + }, + "sdk": { + "passed": 3, + "total": 3 + }, + "exitCodes": { + "engine": 0, + "sdk": 0, + "gallery": 0, + "contract": 0, + "fixtures": 0 + }, + "externalEntrypoints": 14, + "api3Comparisons": 200, + "isolatedApi3LoaderRuns": 400 + }, + "gpu": [ + { + "love": "12.0", + "passed": true, + "checks": 575, + "backend": "metal", + "renderer": "Metal", + "comparisons": 30, + "readbacks": 100 + }, + { + "love": "12.0", + "passed": true, + "checks": 575, + "backend": "opengl", + "renderer": "OpenGL", + "comparisons": 30, + "readbacks": 100 + }, + { + "love": "11.5", + "passed": true, + "checks": 575, + "backend": "opengl", + "renderer": "OpenGL", + "comparisons": 30, + "readbacks": 100 + } + ], + "stagedNativeFlows": [ + { + "runtimeHead": "79c3705850aa4173f529bdee27e9e640ead1e863", + "edition": "firered", + "backend": "metal", + "romSha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc", + "stagedSetup": true, + "passed": true, + "steps": [ + "unchanged package initialized", + "native field admitted", + "resident party edits", + "native owned dialogue", + "imported field damage", + "native wild battle", + "native battle action", + "native battle return", + "owned unload" + ], + "rendererActiveAfterBattle": true + }, + { + "runtimeHead": "df85f6d08d9ad738ea2125ec4c0bd38dc4bae930", + "edition": "emerald", + "backend": "metal", + "romSha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7", + "stagedSetup": true, + "passed": true, + "steps": [ + "unchanged package initialized", + "native field admitted", + "resident party edits", + "native owned dialogue", + "imported field damage", + "native wild battle", + "native battle action", + "native battle return", + "owned unload" + ], + "rendererActiveAfterBattle": true + } + ], + "qualificationLimits": [ + "Initialization probes do not prove a playthrough.", + "The optional alternate battle presentation contract is unavailable; native battle fallback remains active.", + "Door reads report missing native artwork/producers rather than inventing a task.", + "No private package source or game assets are included." + ], + "playerProfilesUsed": false, + "freeDiskReserveGiB": 20, + "unchangedPackageInitialization": [ + { + "love": "12.0", + "passed": true, + "edition": "firered", + "backend": "metal", + "modVersion": "1.0.9" + }, + { + "love": "12.0", + "passed": true, + "edition": "leafgreen", + "backend": "metal", + "modVersion": "1.0.9" + }, + { + "love": "12.0", + "passed": true, + "edition": "emerald", + "backend": "metal", + "modVersion": "1.0.9" + }, + { + "love": "12.0", + "passed": true, + "edition": "ruby", + "backend": "metal", + "modVersion": "1.0.9" + }, + { + "love": "12.0", + "passed": true, + "edition": "sapphire", + "backend": "metal", + "modVersion": "1.0.9" + }, + { + "love": "12.0", + "passed": true, + "edition": "firered", + "backend": "opengl", + "modVersion": "1.0.9" + }, + { + "love": "12.0", + "passed": true, + "edition": "leafgreen", + "backend": "opengl", + "modVersion": "1.0.9" + }, + { + "love": "12.0", + "passed": true, + "edition": "emerald", + "backend": "opengl", + "modVersion": "1.0.9" + }, + { + "love": "12.0", + "passed": true, + "edition": "ruby", + "backend": "opengl", + "modVersion": "1.0.9" + }, + { + "love": "12.0", + "passed": true, + "edition": "sapphire", + "backend": "opengl", + "modVersion": "1.0.9" + }, + { + "love": "11.5", + "passed": true, + "edition": "firered", + "backend": "opengl", + "modVersion": "1.0.9" + }, + { + "love": "11.5", + "passed": true, + "edition": "leafgreen", + "backend": "opengl", + "modVersion": "1.0.9" + }, + { + "love": "11.5", + "passed": true, + "edition": "emerald", + "backend": "opengl", + "modVersion": "1.0.9" + }, + { + "love": "11.5", + "passed": true, + "edition": "ruby", + "backend": "opengl", + "modVersion": "1.0.9" + }, + { + "love": "11.5", + "passed": true, + "edition": "sapphire", + "backend": "opengl", + "modVersion": "1.0.9" + } + ], + "unchangedPackageManifestSha256": "e579d25dc33b2de21bd0872812660719b43d56b0f88158bec5d609124e830e97" +} diff --git a/docs/qa/native-foundation/gba-battle-source-boundary.md b/docs/qa/native-foundation/gba-battle-source-boundary.md new file mode 100644 index 000000000..5f24b3cc6 --- /dev/null +++ b/docs/qa/native-foundation/gba-battle-source-boundary.md @@ -0,0 +1,75 @@ +# Native GBA battle source boundary + +This is an incremental presentation and observation bridge over the existing +`BattleState`. It does not establish complete unchanged-package boot or play. + +## Implemented boundaries + +- The admitted native stack owns battle identity, input, combat, RNG and exit. +- Stable source observations translate cartridge species/move/item/ability/type + identifiers and flattened Pokemon fields. Trainer class and trainer id remain + different fields. HP/status changes and PP transactions reach native Pokemon; + unsupported move replacements fail before an update. +- Native start notifications, update, UI pump, whole-screen drawing and the + background pass have independent scopes. Repeated base calls consume each + native body once. Calls outside an update scope cannot tick combat. +- Every queued native update/draw rechecks the original admission after source + hooks, including the inner UI/background hooks. Save, dataset, loader, stack, + edition and imported catalogue replacement retire the old binding; restoring + an earlier pointer cannot revive that binding. Native method bodies execute + outside a source callback owner. +- Explicit battle/UI captures suppress the repeated native screen draw within + the same renderer frame and render target. A separate preview target cannot + suppress the main screen. Capturing never advances combat. +- LuaJIT `pairs`/`next` expose typed battle fields, preserving nested typed + identity. Iterator registry values are weak, so an unused observation cannot + retain its own weak key through a closure. +- Covered battles remain observable, but cannot update or pop their cover. + Mod retirement invalidates its source views without cancelling native combat. + Completed views keep detached observations and reject further native writes. +- Basic source wild/trainer requests reuse the native encounter admission and + cleanup provider when present. Authored source parties, extended options, + headless simulation and source-controlled link termination remain unsupported. + Constructor-request acceptance is not yet independently qualified here. +- Pokemon observations use native imported records. Height and weight convert + metres/kilograms to cartridge decimetres/hectograms. Static pictures are owned + by the source service; native animation frames remain borrowed. +- Separate presentation players reuse native `MonAnim`/`PicAnim` timing. Their + timing is the existing native implementation, not a proof of exact source + sprite callbacks. They cannot consume combat commands. + +Older native controllers without HUD visibility or move-grid query methods +return an explicit unavailable reason for those observations. This does not +advertise the complete presentation protocol on that host. + +## Qualification + +The focused engine suites cover all five registered GBA editions using +synthetic imported data and actual native constructors, stack admissions and +exit. They exercise repeated base calls, covered states, UI input/draw scopes, +owner/data/edition retirement, numeric identity, PP and detached final views. +View-only collection checks isolate this bridge from the host's global facade +cache; they do not certify the entire host's Game/save collection policy. + +`tools/battle-source-runtime/host` draws the actual native `Gen3Battle` on a +GPU. It compares default and wrapped pixels, replaces only the field pass, +checks that the native command-area pixels remain identical, and checks +graphics restoration after a failing callback. Artwork and battle records are +synthetic; these are not original-package or cartridge gameplay checks. + +## Remaining presentation work + +The UI module deliberately does not advertise the complete sprite/particle +presentation protocol. Native animation VM, effect, healthbox, trainer, ball, +text, catch, switch and intro presentation interfaces still need their own +bindings. So do complete party/save collection schemas and authored source +Pokemon operations. Original-package initialization, real play sequences, +source effect timing/art parity and physical target acceptance remain open. + +The last unchanged-package loader probe advances beyond battle, Pokemon, +motion and UI imports to the missing `src.core.game3.battle.anim` interface. + +Older native hosts also retire already-loaded battle, Pokemon-picture and +motion services at loader unwind, even when their stream service is not yet +driven by loader retirement. This releases observations and owned pictures; +it does not pop or finish the actual native battle. diff --git a/docs/qa/native-map-observations.json b/docs/qa/native-map-observations.json new file mode 100644 index 000000000..33acb5edf --- /dev/null +++ b/docs/qa/native-map-observations.json @@ -0,0 +1,92 @@ +{ + "referenceCommit": "37a49b31f458d51ea9e00b90f2a247272ad09cb8", + "status": "pass", + "nativeHost": "real LOVE 12, CPU only; graphics/window/audio disabled", + "editions": { + "firered": { + "cells": 240512, + "maps": 425, + "different": [], + "missing": [] + }, + "leafgreen": { + "cells": 240512, + "maps": 425, + "different": [], + "missing": [] + }, + "emerald": { + "cells": 322688, + "maps": 518, + "different": [], + "missing": [] + }, + "ruby": { + "cells": 258200, + "maps": 394, + "different": [], + "missing": [] + }, + "sapphire": { + "cells": 258200, + "maps": 394, + "different": [], + "missing": [] + } + }, + "maps": 2156, + "paddedCells": 1320112, + "lifecycleChecks": 211, + "classifierChecks": 807, + "sourceFlagsChecks": 13, + "sourceLayoutChecks": 21, + "flagMetadataReproduced": [ + "emerald", + "ruby", + "sapphire" + ], + "playerProfilesUsed": false, + "nativeGridModified": false, + "gpu": false, + "unchangedOriginalPackageAcceptance": false, + "fullCompatibility": false, + "minimumFreeGiB": 8, + "sourceSha256": { + "src/compat/gen1/MapViews.lua": "61f7f9aec22c735045bc2a39a3346e9c51b9bc94ce7f7f250b71bc3e27a81e55", + "src/compat/gen1/CollisionClass.lua": "5ab3ae9372aea359d9f0e017f2ff3c280dba3fc37f982e2d930857d9db13cd3c", + "src/import/Gen3SourceFlags.lua": "ff7ea9ce067cf888f9dee49027549223e6ea2ee34deaf42b65cba82826ab58f8", + "src/import/RomExtractorGen3.lua": "683fbd918786fef8720d143f7bcfd10596cec3de507e4da8ea79d105ab9a1a5d", + "src/import/RomImporter.lua": "4eed8598fb286ad01a80b62d1a500cf9361b271cba53d34564d574bb2c694849", + "tools/gen3_source_flags.py": "38f7dce1dc02a3642a18ca9979e05e0e1c37cd49da6902dc10da8ae74a811b9b", + "tests/engine/gen3_source_flags.lua": "4790d60bfa4d2f8cf892ad4d5a4b6c8d9fa27969f1e0364fc9dd697c40a5846d", + "tests/engine/gen3_source_map_layouts.lua": "bec844a2bee57e1e17f8135d5708aac20f472bb059d043fb6d4245bc5329e57b", + "tests/engine/compatibility_cell_semantics.lua": "db1c596cca3c8facfe25cadc2f24ded9f5e7306ffde3ed0d2c1961dcb891e9bc", + "tools/map-views/check.lua": "884615dd956f5333cb8bcb9ee46121bef713cd29ee2ce0369dc2a2b0f250cbfc", + "tools/map-views/oracle.lua": "cb413fc8a8655c462e48a6e4898f9446dc31fed5531dab7ee002d11cd9f9ac33", + "tools/map-views/fixture.lua": "04d5bd49fd4aaf426d7c037f604804a3a98a1b18b6c26092ee337fcb1650f167", + "tools/map-views/digest.lua": "570b740c8e4ff7546f9de6c0566f959bff96276203b34f8d06978df9643f19be", + "tools/map-views/rom-host/main.lua": "e2b774616123ff57e9a84f9c991181b75617f48687facffe2a38479b6f746f20", + "tools/map-views/rom-host/conf.lua": "aab859ae64d4165dd4b68b81174128c7883e0725bfd0f327ccfdab151224ec5d", + "tools/rom_manifest_firered.json": "17d094d51122455fded887e37865d8545c460266da1e651c31a2a5661c0c4a6c", + "tools/rom_manifest_leafgreen.json": "39371eaf56a59002949c1cab732feb8c9f0a014df3f31092643f851a73cef861", + "tools/rom_manifest_emerald.json": "0969978b8c13896f707333491ea96fbd2b88b90541451e7d710e03887323b710", + "tools/rom_manifest_ruby.json": "a26468a22baba62083a146bf5f280256859b918b02942db89dda0ae23fd276fb", + "tools/rom_manifest_sapphire.json": "5d860da0dc136e2b0419f837fa6c6eed736fdbd12eb3129c2f5f2122fdb17036" + }, + "composedRegression": { + "commit": "e64a5b8d", + "engineSuitesPassed": 328, + "engineSuitesTotal": 328, + "sdkSuitesPassed": 3, + "sdkSuitesTotal": 3, + "dramaticShapeB30": "pass", + "firstAttempt": { + "engineSuitesPassed": 327, + "engineSuitesTotal": 328, + "failure": "synthetic palette cartridge inherited retail flag metadata without its original bytes/hash host", + "resolution": "scope synthetic fixture metadata to the bytes it generates; production flag checksum remains mandatory", + "retainedFailure": true + }, + "syntheticPaletteFixtureSha256": "292c1e2363b331ff21d10c55aa55390b6d2216384204866e1e15ec11a9e092bb" + } +} diff --git a/docs/qa/native-map-runtime.json b/docs/qa/native-map-runtime.json new file mode 100644 index 000000000..94e9b9bbc --- /dev/null +++ b/docs/qa/native-map-runtime.json @@ -0,0 +1,111 @@ +{ + "referenceCommit": "37a49b31f458d51ea9e00b90f2a247272ad09cb8", + "nativeCpuImport": { + "gpu": false, + "status": "pass", + "editions": { + "firered": { + "maps": 425, + "metadataDifferent": [], + "layoutDifferent": [], + "cells": 240512 + }, + "leafgreen": { + "maps": 425, + "metadataDifferent": [], + "layoutDifferent": [], + "cells": 240512 + }, + "emerald": { + "maps": 518, + "metadataDifferent": [], + "layoutDifferent": [], + "cells": 322688 + }, + "ruby": { + "maps": 394, + "metadataDifferent": [], + "layoutDifferent": [], + "cells": 258200 + }, + "sapphire": { + "maps": 394, + "metadataDifferent": [], + "layoutDifferent": [], + "cells": 258200 + } + }, + "fullCompatibility": false, + "playerProfilesUsed": false + }, + "syntheticChecks": 236, + "customDefinitionGraphComparison": { + "values": 194623, + "cases": 1000, + "hashA": 733965428, + "hashB": 2084460788 + }, + "realMapsMatched": 2156, + "realPaddedCellsMatched": 1320112, + "nativeDefinitionWrites": false, + "nativePlayerOrScriptOwnerChanged": false, + "originalEntryProgress": { + "previousMissingModule": "src.core.game3.map", + "currentMissingModule": "src.core.game3.player", + "unchangedOriginalPackageLoaded": false, + "editions": { + "firered": { + "initialized": false, + "missingModule": "src.core.game3.player" + }, + "leafgreen": { + "initialized": false, + "missingModule": "src.core.game3.player" + }, + "emerald": { + "initialized": false, + "missingModule": "src.core.game3.player" + }, + "ruby": { + "initialized": false, + "missingModule": "src.core.game3.player" + }, + "sapphire": { + "initialized": false, + "missingModule": "src.core.game3.player" + } + }, + "sourceIdentity": { + "version": "1.0.9", + "manifestEntryMainMatchPinnedSource": true + }, + "unadmittedAttemptRetained": "An absolute-path SDK attempt discovered no mod and is excluded; final relative-path probes assert mod discovery." + }, + "playerProfilesUsed": false, + "minimumFreeGiB": 8, + "newGpuAcceptance": false, + "fullCompatibility": false, + "sourceSha256": { + "src/compat/gen1/MapRuntime.lua": "e03ce2834a778a77d382a6d26b1a5ac6540619521182e941c325afe8eb34b8c0", + "src/compat/gen1/MapGraph.lua": "eb93413b3950b56790f305b1b27f650ed32ae9372e308bec32ccdd8528375d41", + "src/core/game3/map.lua": "46205d43526a25e8e246b3d46db7bd83e7c89fc44fa78ddcee137ebb953d0c96", + "src/mods/Loader.lua": "2a0bd033bb94c1db76d300029395689428f1db9df3077b8df8ba9c6b155c828b", + "tools/map-runtime/check.lua": "c2deb123128521f6f2df28b58c8a79be47481d8077775c9a70bf34447c675640", + "tools/map-runtime/oracle.lua": "5766540cc86b766caeef26f74b28da95ca02d9eeb56c35fc44ae595f0f65a175", + "tools/map-runtime/projection.lua": "e4e922bfb801195fc77642d9243b544e141553ba4b0ef118144c0ae6ddee9a8a", + "tools/map-runtime/graph-consumer.lua": "c2187de06296e6f31db0c0e1679561c914857d486b4aecff6e31affc58f0c346", + "tools/map-runtime/fixture.lua": "23c3f572424dd9920a8c8f3436d8f918e47474808f667111eeca99439797c2c9", + "tools/map-runtime/rom-host/main.lua": "f16121883e375cda83323bc78b36d1e4f146088b7e1d7c999256026dbf77927b", + "tools/map-runtime/rom-host/conf.lua": "ca03796261c6b6e10f2785d2c32f0b23b7e9b8356921594825173401ad0fd940" + }, + "sourceHashScope": "composed five-edition host; companion contributions required", + "composedRegression": { + "commit": "9fcc703ebff48e62eb0c008fbc739eae1faa42be", + "engineSuitesPassed": 329, + "engineSuitesTotal": 329, + "sdkSuitesPassed": 3, + "sdkSuitesTotal": 3, + "dramaticShapeB30": "pass", + "fullCompatibility": false + } +} diff --git a/docs/qa/native-pair-identities.json b/docs/qa/native-pair-identities.json new file mode 100644 index 000000000..579805f52 --- /dev/null +++ b/docs/qa/native-pair-identities.json @@ -0,0 +1,70 @@ +{ + "referenceCommit": "37a49b31f458d51ea9e00b90f2a247272ad09cb8", + "nativeCpuImport": { + "status": "pass", + "editions": { + "leafgreen": { + "maps": 425, + "pairs": 60 + }, + "emerald": { + "maps": 518, + "pairs": 75 + }, + "ruby": { + "maps": 394, + "pairs": 54 + }, + "sapphire": { + "maps": 394, + "pairs": 54 + }, + "firered": { + "maps": 425, + "pairs": 60 + } + }, + "gpu": false, + "fullCompatibility": false, + "playerProfilesUsed": false + }, + "isolatedChecks": 4339, + "nativeMapsMatched": 2156, + "sourcePairsMatched": 303, + "metadataRegeneration": "byte-identical", + "nativeMapsRenamed": false, + "texturesAllocated": false, + "nativeRenderOwnerModified": false, + "playerProfilesUsed": false, + "minimumFreeGiB": 8, + "gpu": false, + "fullCompatibility": false, + "sourceSha256": { + "src/compat/gen1/PairIdentity.lua": "e24d2169b4277d184fd0f734ca78b99269ba42b3c1a63c56836ed2eb75a7b1b8", + "src/compat/gen1/PairNames.lua": "2aebd4e5e552fc3200d584b3bbc928620656db18e3c0cf5831c816d8065a52cd", + "tests/engine/compatibility_pair_identity.lua": "2c94d2673b947b5eeb31032631e4b260ea56b2e9b6d7e86c265786a9f0c783e0", + "tools/pair-identities/export.lua": "a9ac14a4e95b9c498ddc6ebffd7231ba8b1914fcd41f094ad56bfad5cac5e861", + "tools/pair-identities/assemble.py": "508ee4d9a6e8a13d93bcd54dd858f409a1ca8ef6aaa10a9a48577c5b57f0e792", + "tools/pair-identities/rom-host/main.lua": "fbe43b5778b9b1fa088f62196c0dcfdf6ca5702f973a5dbc9a3b84b6b3a6fa3a", + "tools/pair-identities/rom-host/conf.lua": "a969e23fed727643e0fa3d571df52a4c45b754cd31d39d74e45022ed296c7c13" + }, + "sourceTilesetAliasesMatched": 303, + "previousComposedRegression": { + "commit": "f9f956032bf05c0beb620168f5743fcbf558478c", + "engineSuitesPassed": 329, + "engineSuitesTotal": 329, + "sdkSuitesPassed": 3, + "sdkSuitesTotal": 3, + "dramaticShapeB30": "pass", + "fullCompatibility": false + }, + "composedRegression": { + "commit": "9fcc703ebff48e62eb0c008fbc739eae1faa42be", + "engineSuitesPassed": 329, + "engineSuitesTotal": 329, + "sdkSuitesPassed": 3, + "sdkSuitesTotal": 3, + "dramaticShapeB30": "pass", + "fullCompatibility": false + } +} diff --git a/docs/ruby-ball-native.json b/docs/ruby-ball-native.json new file mode 100644 index 000000000..51bd3e617 --- /dev/null +++ b/docs/ruby-ball-native.json @@ -0,0 +1,145 @@ +{ + "status": "pass", + "edition": "ruby", + "verification": { + "identity": "gen2recomp-qa-ruby-20261025144844471309", + "sourceCommit": "325c9ef74c964f814091f0247faf3c1ba7b37d9f", + "engineArchiveSHA256": "46a40bfd5679e0139982efc947e08618518bd396efc7f2d502e47fe9af01d6ef", + "frozenInputAllocation": { + "independentClonedFiles": 1718, + "clonedLogicalBytes": 345598908, + "mutableHardlinks": false + }, + "cartridgeSHA1": "f28b6ffc97847e94a6c21a63cacf633ee5c8df1e", + "loveSHA256": "cb136e72f2f2639f889245c0a6fca355178da71f05e460a9f3d01d0fafcc1b0b", + "driverSHA256": "5c92b08f61bd72b8dda779b0c9a135a996ff61ee65de2468802c8f8c1ea61af5", + "qaRenderer": "metal", + "qaHostConfigChanged": true, + "physicalIOSDeviceTested": false, + "engineFilesUnchanged": 8993 + }, + "native": { + "ballSource": "ROM:matching RS sBallSpriteSheets 020A92C, palettes 020A98C, shared open 0D030D0, colors 040B4D4, particles 0D1A7F4; verified RS Thumb send-out and release sites", + "status": "pass", + "edition": "ruby", + "fullFeatureParity": false, + "physicalIOSDeviceTested": false, + "cases": [ + { + "realNativeParticles": true, + "visibleBallNegative": true, + "visibleParticlesNegative": true, + "restoreExact": true, + "gender": "boy", + "modsDisabled": true, + "drawNoClockAdvance": true, + "nativeUltraSheet": 3, + "actualBallFrames": { + "enemy": 30, + "player": 39 + } + }, + { + "realNativeParticles": true, + "visibleBallNegative": true, + "visibleParticlesNegative": true, + "restoreExact": true, + "gender": "girl", + "modsDisabled": true, + "drawNoClockAdvance": true, + "nativeUltraSheet": 3, + "actualBallFrames": { + "enemy": 30, + "player": 39 + } + } + ] + }, + "independentPNGChecks": [ + { + "gender": "boy", + "side": "enemy", + "changedPixels": { + "ball": 160, + "particles": 98 + }, + "restoreExact": true + }, + { + "gender": "boy", + "side": "player", + "changedPixels": { + "ball": 160, + "particles": 189 + }, + "restoreExact": true + }, + { + "gender": "girl", + "side": "enemy", + "changedPixels": { + "ball": 160, + "particles": 98 + }, + "restoreExact": true + }, + { + "gender": "girl", + "side": "player", + "changedPixels": { + "ball": 160, + "particles": 189 + }, + "restoreExact": true + } + ], + "PNG_SHA256": { + "native-ball-boy-enemy-ball-negative.png": "2f0135d356b396ef38f0cf9ae57ea5446f3d82418a3fb2aac6f06126f88ad112", + "native-ball-boy-enemy-ball-restored.png": "397a99efd59d715834922df7ad2e9db4dc3546c1aae9a8dbefbf180c669e2eae", + "native-ball-boy-enemy-particles-negative.png": "2252b90c49d3ab17c0d139d19e742c7fbeb81690c4f5455920827c658ee63383", + "native-ball-boy-enemy-particles-restored.png": "397a99efd59d715834922df7ad2e9db4dc3546c1aae9a8dbefbf180c669e2eae", + "native-ball-boy-enemy.png": "397a99efd59d715834922df7ad2e9db4dc3546c1aae9a8dbefbf180c669e2eae", + "native-ball-boy-player-ball-negative.png": "a7ac10d94e54a1eec1b2f2c630b52057cbf6b249817516dc84c5f9a563d3bc5d", + "native-ball-boy-player-ball-restored.png": "a6a13e87f6e21f2ca023e478f8204d18305d5e19b577844d056cb10dd06da03c", + "native-ball-boy-player-particles-negative.png": "5654e210ad29c8be79f7d2ad89affc9eddcfe7c71066eb96ebda69aacaa3d746", + "native-ball-boy-player-particles-restored.png": "a6a13e87f6e21f2ca023e478f8204d18305d5e19b577844d056cb10dd06da03c", + "native-ball-boy-player.png": "a6a13e87f6e21f2ca023e478f8204d18305d5e19b577844d056cb10dd06da03c", + "native-ball-girl-enemy-ball-negative.png": "315ef9753ddfec2df15571e83fd2cbe942834d1cee6538f9c268f1e12e9f77e7", + "native-ball-girl-enemy-ball-restored.png": "4ae70001b7ef0f1e6b2ecd07a59b811b165f7c52395fe5751a473f9f53b6ed8f", + "native-ball-girl-enemy-particles-negative.png": "00af5d171c9de08701ebc8b5c3c45b6a459b303b678b91184874fb24746ef61e", + "native-ball-girl-enemy-particles-restored.png": "4ae70001b7ef0f1e6b2ecd07a59b811b165f7c52395fe5751a473f9f53b6ed8f", + "native-ball-girl-enemy.png": "4ae70001b7ef0f1e6b2ecd07a59b811b165f7c52395fe5751a473f9f53b6ed8f", + "native-ball-girl-player-ball-negative.png": "4f89925e908228a16a20960b868560376cfca5a2b5928a4365eda1c10ea861e7", + "native-ball-girl-player-ball-restored.png": "799cdcaf56885772a203caf19cf8b2b358944af1bbc124205848c83c939802b3", + "native-ball-girl-player-particles-negative.png": "b6057f9e7b194c3fc9ac73ad27f58964f1a48a49a7898d69d3aa20bb1ffca66b", + "native-ball-girl-player-particles-restored.png": "799cdcaf56885772a203caf19cf8b2b358944af1bbc124205848c83c939802b3", + "native-ball-girl-player.png": "799cdcaf56885772a203caf19cf8b2b358944af1bbc124205848c83c939802b3" + }, + "romOrExtractedArtIncluded": false, + "fullFeatureParity": false, + "physicalIOSDeviceTested": false, + "independentRawCartridgePNGCheck": { + "status": "pass", + "edition": "ruby", + "identity": "gen2recomp-qa-ruby-20261025144844471309", + "cartridgeSHA1": "f28b6ffc97847e94a6c21a63cacf633ee5c8df1e", + "art": [ + { + "file": "balls.png", + "sha256": "8d3bef7f1ef0c4192d66b7cfccd0d713688693d6af429bbb999ef3ebed0e1389", + "width": 48, + "height": 192, + "cartridgeDecodedRGBAExact": true, + "opaquePixels": 4632 + }, + { + "file": "ball_particles.png", + "sha256": "996e0ec6c032501bade50d92b79f8810dada1a57111bde78c20041f49cdc825a", + "width": 64, + "height": 8, + "cartridgeDecodedRGBAExact": true, + "opaquePixels": 160 + } + ] + } +} diff --git a/docs/ruby-field-window-native.json b/docs/ruby-field-window-native.json new file mode 100644 index 000000000..6ed97b245 --- /dev/null +++ b/docs/ruby-field-window-native.json @@ -0,0 +1,50 @@ +{ + "status": "pass", + "run": "gen2recomp-qa-ruby-20261025064834912371", + "verification": { + "identity": "gen2recomp-qa-ruby-20261025064834912371", + "sourceCommit": "91c6aec649a9962f50daa979e96dba35dbc9c30a", + "engineArchiveSHA256": "06bb8e8ce8dbe5eb07a800cec07a187a9a85cf65b5e34244ae82f53fa5875f56", + "frozenInputAllocation": { + "independentClonedFiles": 1716, + "clonedLogicalBytes": 345532366, + "mutableHardlinks": false + }, + "cartridgeSHA1": "f28b6ffc97847e94a6c21a63cacf633ee5c8df1e", + "loveSHA256": "cb136e72f2f2639f889245c0a6fca355178da71f05e460a9f3d01d0fafcc1b0b", + "driverSHA256": "083fb27d51dad2f2b6d6df81407872a8832f22ffd7ae16ccacc67d467d1bf45f", + "qaRenderer": "metal", + "qaHostConfigChanged": true, + "physicalIOSDeviceTested": false, + "engineFilesUnchanged": 8975 + }, + "nativeResult": { + "physicalIOSDeviceTested": false, + "nativeNoMods": true, + "fieldWindow": { + "ty": 14, + "width": 240, + "height": 160, + "maxCols": 26, + "th": 6, + "tw": 28, + "tx": 1 + }, + "nativeFontCharacters": 254, + "explicitWidthFixturePixels": 204, + "oldNarrowFixtureLines": 4, + "newFixtureLines": 2, + "actualDialogueInput": true, + "actualChoiceInput": true, + "oldCacheRevisionRejected": true, + "freshCacheReady": true, + "fieldMetadataSeparateFromBattle": true, + "originalRSDialogueFrameArtPorted": false, + "status": "pass", + "edition": "ruby", + "fullRSUI": false, + "fullStory": false + }, + "nativeLogSHA256": "0788f13acf4f2e8ec9aa072cafeedba38fe91ef3523c78045a7ef15cf54ecbe2", + "scope": "Fresh native cartridge import with mods disabled; exact field geometry, native font and actual dialogue/choice input. Explicit width fixture, not story completion or original RS frame-art acceptance." +} diff --git a/docs/ruby-item-actions-native.json b/docs/ruby-item-actions-native.json new file mode 100644 index 000000000..9e63d8005 --- /dev/null +++ b/docs/ruby-item-actions-native.json @@ -0,0 +1,74 @@ +{ + "status": "pass", + "scope": "Fresh native import with mods disabled; actual RS bag action-table input and native consumption fixtures", + "verification": { + "identity": "gen2recomp-qa-ruby-20261025074659781363", + "sourceCommit": "31d2e37429aab99550318b5ae3ad2ba5a89c015d", + "engineArchiveSHA256": "aa440da6f0fe9a992230fbbc4ab55d099c393e9ec0818f5c0d1a4c3f80e1c5db", + "driverSHA256": "8413d29ca881994f653f357222a1399b0dafefe767ee2dd961e6eccf4067c2ae", + "cartridgeSHA1": "f28b6ffc97847e94a6c21a63cacf633ee5c8df1e", + "loveSHA256": "cb136e72f2f2639f889245c0a6fca355178da71f05e460a9f3d01d0fafcc1b0b", + "qaRenderer": "metal", + "physicalIOSDeviceTested": false, + "engineFilesUnchanged": 8978, + "frozenInputAllocation": { + "independentClonedFiles": 1716, + "clonedLogicalBytes": 345539705, + "mutableHardlinks": false + } + }, + "result": { + "edition": "ruby", + "nativeNoMods": true, + "actualPocketActionGrids": { + "TM_HM": [ + "use", + "give", + "blank", + "cancel" + ], + "BERRY": [ + "checkTag", + "blank", + "use", + "give", + "toss", + "cancel" + ], + "ITEM": [ + "use", + "give", + "toss", + "cancel" + ], + "BALL": [ + "give", + "blank", + "toss", + "cancel" + ], + "KEY_ITEM": [ + "use", + "register", + "blank", + "cancel" + ] + }, + "nativeColumnMajorPositions": true, + "nativeDirectionalInput": true, + "nativeRegisterToggle": true, + "rsRegisterLabelPreserved": true, + "nativeQuantityCancel": true, + "nativeRepelUse": true, + "unchangedOtherItemsAndTeam": true, + "oldV3CacheRejected": true, + "freshV4CacheReady": true, + "fieldWindowPreserved": true, + "originalRSBagArtworkPorted": false, + "berryTagScreenImplemented": false, + "fullRSUI": false, + "fullStory": false, + "physicalIOSDeviceTested": false, + "status": "pass" + } +} diff --git a/docs/ruby-stat-flourish-native-v6.json b/docs/ruby-stat-flourish-native-v6.json new file mode 100644 index 000000000..2debef261 --- /dev/null +++ b/docs/ruby-stat-flourish-native-v6.json @@ -0,0 +1,126 @@ +{ + "native": { + "modsEnabled": false, + "physicalIOSTested": false, + "cases": [ + { + "player": true, + "laps": 2, + "life": 64, + "move": "HARDEN", + "source": "ROM:the stat-up task at 00E0A4C, its step at 00E0CD0, tiles 0D1D410, tilemap 0D1D574 and palette 0D1D54C", + "restoreExact": true, + "visible": true + }, + { + "player": false, + "laps": 2, + "life": 64, + "move": "HARDEN", + "source": "ROM:the stat-up task at 00E0A4C, its step at 00E0CD0, tiles 0D1D410, tilemap 0D1D574 and palette 0D1D54C", + "restoreExact": true, + "visible": true + }, + { + "player": true, + "laps": 2, + "life": 64, + "move": "IRON_DEFENSE", + "source": "ROM:the stat-up task at 00E0A4C, its step at 00E0CD0, tiles 0D1D410, tilemap 0D1D574 and palette 0D1D54C", + "restoreExact": true, + "visible": true + }, + { + "player": false, + "laps": 2, + "life": 64, + "move": "IRON_DEFENSE", + "source": "ROM:the stat-up task at 00E0A4C, its step at 00E0CD0, tiles 0D1D410, tilemap 0D1D574 and palette 0D1D54C", + "restoreExact": true, + "visible": true + } + ], + "status": "pass", + "edition": "ruby" + }, + "verification": { + "identity": "gen2recomp-qa-ruby-20261025185640609035", + "sourceCommit": "0fe4d5e5eb1a3aa153a3f28c12b83129b20fe4c0", + "engineArchiveSHA256": "d0052d70d705ef7451e7e90dde9843b5fc062b8cd80680a6e442c7548d135fca", + "frozenInputAllocation": { + "independentClonedFiles": 1718, + "clonedLogicalBytes": 345600252, + "mutableHardlinks": false + }, + "cartridgeSHA1": "f28b6ffc97847e94a6c21a63cacf633ee5c8df1e", + "loveSHA256": "cb136e72f2f2639f889245c0a6fca355178da71f05e460a9f3d01d0fafcc1b0b", + "driverSHA256": "79e484d44f74cfa6c2ee0b428b705e7e8bf1e8e4f544d121fb04541c69dfc487", + "qaRenderer": "metal", + "qaHostConfigChanged": true, + "physicalIOSDeviceTested": false, + "engineFilesUnchanged": 8999 + }, + "retailTask": { + "gameCode": "AXVE", + "revision": 0, + "romSHA1": "f28b6ffc97847e94a6c21a63cacf633ee5c8df1e", + "elfSHA256": "0879e42c7b0dca3810cc652248a2e97e837c2c7ddd9c0a1c2704684ec7da2f13", + "functions": { + "sub_80E0CD0": { + "address": "080E0CD0", + "bytes": 340, + "sha256": "d89c92fa9c9c19e9f5263ef7a3396497332317db3fdfefd8457c5fc0a584dad8", + "retailExact": true + }, + "sub_80E0A4C": { + "address": "080E0A4C", + "bytes": 644, + "sha256": "f095f351e79679543bffc5c33dad247f68f8b0c04f11ffeff621be9dc58d0c9c", + "retailExact": true + } + }, + "status": "pass", + "signature": { + "add4": 1, + "compare128": 1, + "add124": 1, + "compare2": 1 + }, + "taskFrames": 64, + "laps": 2, + "frame31": -124, + "frame32": 0, + "frame63": -124, + "frame64": 0 + }, + "pixels": [ + { + "case": "stat-harden-player", + "rgbaSHA256": "b7d3b604ad4410b7b7943213c23279be856a4f7483b0f7f05c7f495e396f507d", + "negativeSHA256": "c7f19f898cc49950c34fe08b022c1676756b7dc313b39ba67edca59cdb7951a5", + "changedBytes": 4472, + "restoreExact": true + }, + { + "case": "stat-harden-enemy", + "rgbaSHA256": "add6c9d3d91a097ffa3158b3cadbf91c15d20fbf12824b385917584086faf4f6", + "negativeSHA256": "c7f19f898cc49950c34fe08b022c1676756b7dc313b39ba67edca59cdb7951a5", + "changedBytes": 7248, + "restoreExact": true + }, + { + "case": "stat-iron_defense-player", + "rgbaSHA256": "b7d3b604ad4410b7b7943213c23279be856a4f7483b0f7f05c7f495e396f507d", + "negativeSHA256": "c7f19f898cc49950c34fe08b022c1676756b7dc313b39ba67edca59cdb7951a5", + "changedBytes": 4472, + "restoreExact": true + }, + { + "case": "stat-iron_defense-enemy", + "rgbaSHA256": "add6c9d3d91a097ffa3158b3cadbf91c15d20fbf12824b385917584086faf4f6", + "negativeSHA256": "c7f19f898cc49950c34fe08b022c1676756b7dc313b39ba67edca59cdb7951a5", + "changedBytes": 7248, + "restoreExact": true + } + ] +} diff --git a/docs/sapphire-ball-native.json b/docs/sapphire-ball-native.json new file mode 100644 index 000000000..1225bac4e --- /dev/null +++ b/docs/sapphire-ball-native.json @@ -0,0 +1,145 @@ +{ + "status": "pass", + "edition": "sapphire", + "verification": { + "identity": "gen2recomp-qa-sapphire-20261025145027389116", + "sourceCommit": "d866452f8ee0bd0974361e6d5a726f5b2e9480f4", + "engineArchiveSHA256": "7b189f5088e1324cb0ceea90bb9dd9566d66181b978c64c5d3b339051c6cf1b1", + "frozenInputAllocation": { + "independentClonedFiles": 1718, + "clonedLogicalBytes": 345598964, + "mutableHardlinks": false + }, + "cartridgeSHA1": "3ccbbd45f8553c36463f13b938e833f652b793e4", + "loveSHA256": "cb136e72f2f2639f889245c0a6fca355178da71f05e460a9f3d01d0fafcc1b0b", + "driverSHA256": "5c92b08f61bd72b8dda779b0c9a135a996ff61ee65de2468802c8f8c1ea61af5", + "qaRenderer": "metal", + "qaHostConfigChanged": true, + "physicalIOSDeviceTested": false, + "engineFilesUnchanged": 8993 + }, + "native": { + "ballSource": "ROM:matching RS sBallSpriteSheets 020A8BC, palettes 020A91C, shared open 0D030D0, colors 040B52C, particles 0D1A7F4; verified RS Thumb send-out and release sites", + "physicalIOSDeviceTested": false, + "edition": "sapphire", + "fullFeatureParity": false, + "cases": [ + { + "realNativeParticles": true, + "visibleBallNegative": true, + "visibleParticlesNegative": true, + "restoreExact": true, + "nativeUltraSheet": 3, + "modsDisabled": true, + "drawNoClockAdvance": true, + "gender": "boy", + "actualBallFrames": { + "enemy": 30, + "player": 39 + } + }, + { + "realNativeParticles": true, + "visibleBallNegative": true, + "visibleParticlesNegative": true, + "restoreExact": true, + "nativeUltraSheet": 3, + "modsDisabled": true, + "drawNoClockAdvance": true, + "gender": "girl", + "actualBallFrames": { + "enemy": 30, + "player": 39 + } + } + ], + "status": "pass" + }, + "independentPNGChecks": [ + { + "gender": "boy", + "side": "enemy", + "changedPixels": { + "ball": 160, + "particles": 98 + }, + "restoreExact": true + }, + { + "gender": "boy", + "side": "player", + "changedPixels": { + "ball": 160, + "particles": 189 + }, + "restoreExact": true + }, + { + "gender": "girl", + "side": "enemy", + "changedPixels": { + "ball": 160, + "particles": 98 + }, + "restoreExact": true + }, + { + "gender": "girl", + "side": "player", + "changedPixels": { + "ball": 160, + "particles": 189 + }, + "restoreExact": true + } + ], + "PNG_SHA256": { + "native-ball-boy-enemy-ball-negative.png": "2f0135d356b396ef38f0cf9ae57ea5446f3d82418a3fb2aac6f06126f88ad112", + "native-ball-boy-enemy-ball-restored.png": "397a99efd59d715834922df7ad2e9db4dc3546c1aae9a8dbefbf180c669e2eae", + "native-ball-boy-enemy-particles-negative.png": "2252b90c49d3ab17c0d139d19e742c7fbeb81690c4f5455920827c658ee63383", + "native-ball-boy-enemy-particles-restored.png": "397a99efd59d715834922df7ad2e9db4dc3546c1aae9a8dbefbf180c669e2eae", + "native-ball-boy-enemy.png": "397a99efd59d715834922df7ad2e9db4dc3546c1aae9a8dbefbf180c669e2eae", + "native-ball-boy-player-ball-negative.png": "4f89925e908228a16a20960b868560376cfca5a2b5928a4365eda1c10ea861e7", + "native-ball-boy-player-ball-restored.png": "799cdcaf56885772a203caf19cf8b2b358944af1bbc124205848c83c939802b3", + "native-ball-boy-player-particles-negative.png": "b6057f9e7b194c3fc9ac73ad27f58964f1a48a49a7898d69d3aa20bb1ffca66b", + "native-ball-boy-player-particles-restored.png": "799cdcaf56885772a203caf19cf8b2b358944af1bbc124205848c83c939802b3", + "native-ball-boy-player.png": "799cdcaf56885772a203caf19cf8b2b358944af1bbc124205848c83c939802b3", + "native-ball-girl-enemy-ball-negative.png": "315ef9753ddfec2df15571e83fd2cbe942834d1cee6538f9c268f1e12e9f77e7", + "native-ball-girl-enemy-ball-restored.png": "4ae70001b7ef0f1e6b2ecd07a59b811b165f7c52395fe5751a473f9f53b6ed8f", + "native-ball-girl-enemy-particles-negative.png": "00af5d171c9de08701ebc8b5c3c45b6a459b303b678b91184874fb24746ef61e", + "native-ball-girl-enemy-particles-restored.png": "4ae70001b7ef0f1e6b2ecd07a59b811b165f7c52395fe5751a473f9f53b6ed8f", + "native-ball-girl-enemy.png": "4ae70001b7ef0f1e6b2ecd07a59b811b165f7c52395fe5751a473f9f53b6ed8f", + "native-ball-girl-player-ball-negative.png": "86ac084549895e0786a1fa7ebd3f1ab14af14ba4bd8d561ffe63faaa676663a7", + "native-ball-girl-player-ball-restored.png": "af99c96c30faadf4b562d78ffb68d9f7168cd6e0ac7f357e91082cb13e6801c5", + "native-ball-girl-player-particles-negative.png": "6b303b308287c38f3838bd93398d9bbd79385dc801d5e92c4fd3292b0d3ee804", + "native-ball-girl-player-particles-restored.png": "af99c96c30faadf4b562d78ffb68d9f7168cd6e0ac7f357e91082cb13e6801c5", + "native-ball-girl-player.png": "af99c96c30faadf4b562d78ffb68d9f7168cd6e0ac7f357e91082cb13e6801c5" + }, + "romOrExtractedArtIncluded": false, + "fullFeatureParity": false, + "physicalIOSDeviceTested": false, + "independentRawCartridgePNGCheck": { + "status": "pass", + "edition": "sapphire", + "identity": "gen2recomp-qa-sapphire-20261025145027389116", + "cartridgeSHA1": "3ccbbd45f8553c36463f13b938e833f652b793e4", + "art": [ + { + "file": "balls.png", + "sha256": "8d3bef7f1ef0c4192d66b7cfccd0d713688693d6af429bbb999ef3ebed0e1389", + "width": 48, + "height": 192, + "cartridgeDecodedRGBAExact": true, + "opaquePixels": 4632 + }, + { + "file": "ball_particles.png", + "sha256": "996e0ec6c032501bade50d92b79f8810dada1a57111bde78c20041f49cdc825a", + "width": 64, + "height": 8, + "cartridgeDecodedRGBAExact": true, + "opaquePixels": 160 + } + ] + } +} diff --git a/docs/sapphire-field-window-native.json b/docs/sapphire-field-window-native.json new file mode 100644 index 000000000..273142ebf --- /dev/null +++ b/docs/sapphire-field-window-native.json @@ -0,0 +1,50 @@ +{ + "status": "pass", + "run": "gen2recomp-qa-sapphire-20261025065249244120", + "verification": { + "identity": "gen2recomp-qa-sapphire-20261025065249244120", + "sourceCommit": "ca5a7bc48e3ab9832c8f9d29d9633a12c6a5c781", + "engineArchiveSHA256": "ce40cb4ed1082aa9d8157930053d48b38cfd1d4dc38fe9ebfa22d7a342e5d3b3", + "frozenInputAllocation": { + "independentClonedFiles": 1716, + "clonedLogicalBytes": 345532422, + "mutableHardlinks": false + }, + "cartridgeSHA1": "3ccbbd45f8553c36463f13b938e833f652b793e4", + "loveSHA256": "cb136e72f2f2639f889245c0a6fca355178da71f05e460a9f3d01d0fafcc1b0b", + "driverSHA256": "083fb27d51dad2f2b6d6df81407872a8832f22ffd7ae16ccacc67d467d1bf45f", + "qaRenderer": "metal", + "qaHostConfigChanged": true, + "physicalIOSDeviceTested": false, + "engineFilesUnchanged": 8975 + }, + "nativeResult": { + "oldCacheRevisionRejected": true, + "edition": "sapphire", + "fieldMetadataSeparateFromBattle": true, + "originalRSDialogueFrameArtPorted": false, + "fullRSUI": false, + "fullStory": false, + "physicalIOSDeviceTested": false, + "status": "pass", + "freshCacheReady": true, + "nativeNoMods": true, + "fieldWindow": { + "width": 240, + "height": 160, + "tx": 1, + "ty": 14, + "tw": 28, + "th": 6, + "maxCols": 26 + }, + "nativeFontCharacters": 254, + "explicitWidthFixturePixels": 204, + "oldNarrowFixtureLines": 4, + "newFixtureLines": 2, + "actualDialogueInput": true, + "actualChoiceInput": true + }, + "nativeLogSHA256": "0d054a48f33249029c369b33eaa9c3b7b0c2c71eef5c78a128ac66e3e8124567", + "scope": "Fresh native cartridge import with mods disabled; exact field geometry, native font and actual dialogue/choice input. Explicit width fixture, not story completion or original RS frame-art acceptance." +} diff --git a/docs/sapphire-item-actions-native.json b/docs/sapphire-item-actions-native.json new file mode 100644 index 000000000..b398d9999 --- /dev/null +++ b/docs/sapphire-item-actions-native.json @@ -0,0 +1,74 @@ +{ + "status": "pass", + "scope": "Fresh native import with mods disabled; actual RS bag action-table input and native consumption fixtures", + "verification": { + "identity": "gen2recomp-qa-sapphire-20261025074804538424", + "sourceCommit": "95a9e3bb3f48554f3c74a737b38130747890557d", + "engineArchiveSHA256": "bad7e0dd001e4751898b95c7433ded9a2a2489cf71bdf46020b35cd6a4b2af19", + "driverSHA256": "8413d29ca881994f653f357222a1399b0dafefe767ee2dd961e6eccf4067c2ae", + "cartridgeSHA1": "3ccbbd45f8553c36463f13b938e833f652b793e4", + "loveSHA256": "cb136e72f2f2639f889245c0a6fca355178da71f05e460a9f3d01d0fafcc1b0b", + "qaRenderer": "metal", + "physicalIOSDeviceTested": false, + "engineFilesUnchanged": 8978, + "frozenInputAllocation": { + "independentClonedFiles": 1716, + "clonedLogicalBytes": 345539761, + "mutableHardlinks": false + } + }, + "result": { + "status": "pass", + "nativeNoMods": true, + "actualPocketActionGrids": { + "KEY_ITEM": [ + "use", + "register", + "blank", + "cancel" + ], + "TM_HM": [ + "use", + "give", + "blank", + "cancel" + ], + "BERRY": [ + "checkTag", + "blank", + "use", + "give", + "toss", + "cancel" + ], + "ITEM": [ + "use", + "give", + "toss", + "cancel" + ], + "BALL": [ + "give", + "blank", + "toss", + "cancel" + ] + }, + "nativeColumnMajorPositions": true, + "nativeDirectionalInput": true, + "nativeRegisterToggle": true, + "rsRegisterLabelPreserved": true, + "nativeQuantityCancel": true, + "nativeRepelUse": true, + "unchangedOtherItemsAndTeam": true, + "oldV3CacheRejected": true, + "freshV4CacheReady": true, + "fieldWindowPreserved": true, + "originalRSBagArtworkPorted": false, + "berryTagScreenImplemented": false, + "fullRSUI": false, + "fullStory": false, + "physicalIOSDeviceTested": false, + "edition": "sapphire" + } +} diff --git a/docs/sapphire-stat-flourish-native-v6.json b/docs/sapphire-stat-flourish-native-v6.json new file mode 100644 index 000000000..661cd5cbb --- /dev/null +++ b/docs/sapphire-stat-flourish-native-v6.json @@ -0,0 +1,126 @@ +{ + "native": { + "edition": "sapphire", + "status": "pass", + "physicalIOSTested": false, + "modsEnabled": false, + "cases": [ + { + "source": "ROM:the stat-up task at 00E0A4C, its step at 00E0CD0, tiles 0D1D410, tilemap 0D1D574 and palette 0D1D54C", + "laps": 2, + "move": "HARDEN", + "restoreExact": true, + "visible": true, + "life": 64, + "player": true + }, + { + "source": "ROM:the stat-up task at 00E0A4C, its step at 00E0CD0, tiles 0D1D410, tilemap 0D1D574 and palette 0D1D54C", + "laps": 2, + "move": "HARDEN", + "restoreExact": true, + "visible": true, + "life": 64, + "player": false + }, + { + "source": "ROM:the stat-up task at 00E0A4C, its step at 00E0CD0, tiles 0D1D410, tilemap 0D1D574 and palette 0D1D54C", + "laps": 2, + "move": "IRON_DEFENSE", + "restoreExact": true, + "visible": true, + "life": 64, + "player": true + }, + { + "source": "ROM:the stat-up task at 00E0A4C, its step at 00E0CD0, tiles 0D1D410, tilemap 0D1D574 and palette 0D1D54C", + "laps": 2, + "move": "IRON_DEFENSE", + "restoreExact": true, + "visible": true, + "life": 64, + "player": false + } + ] + }, + "verification": { + "identity": "gen2recomp-qa-sapphire-20261025185823026577", + "sourceCommit": "14df1dd6f83a877ae89d9089181d16e1eec60277", + "engineArchiveSHA256": "b3f55ee99928a35c979104c8ebf5aeda9b1ebabd7a18ce1b06ba2684313af9c0", + "frozenInputAllocation": { + "independentClonedFiles": 1718, + "clonedLogicalBytes": 345600308, + "mutableHardlinks": false + }, + "cartridgeSHA1": "3ccbbd45f8553c36463f13b938e833f652b793e4", + "loveSHA256": "cb136e72f2f2639f889245c0a6fca355178da71f05e460a9f3d01d0fafcc1b0b", + "driverSHA256": "79e484d44f74cfa6c2ee0b428b705e7e8bf1e8e4f544d121fb04541c69dfc487", + "qaRenderer": "metal", + "qaHostConfigChanged": true, + "physicalIOSDeviceTested": false, + "engineFilesUnchanged": 8999 + }, + "retailTask": { + "gameCode": "AXPE", + "revision": 0, + "romSHA1": "3ccbbd45f8553c36463f13b938e833f652b793e4", + "elfSHA256": "053c21460f4629394d38b5967972010f0812402c1219bc4f6aa4517e285d9260", + "functions": { + "sub_80E0CD0": { + "address": "080E0CD0", + "bytes": 340, + "sha256": "777edafd71c96112e2ffe5992297406c82ef24c2335ef8c17a0682c8c49e6365", + "retailExact": true + }, + "sub_80E0A4C": { + "address": "080E0A4C", + "bytes": 644, + "sha256": "3f37b193e768c8b506384f04da9d187e409ed11c454de759cfa85d5af4a060fa", + "retailExact": true + } + }, + "status": "pass", + "signature": { + "add4": 1, + "compare128": 1, + "add124": 1, + "compare2": 1 + }, + "taskFrames": 64, + "laps": 2, + "frame31": -124, + "frame32": 0, + "frame63": -124, + "frame64": 0 + }, + "pixels": [ + { + "case": "stat-harden-player", + "rgbaSHA256": "9a177c5869211d2551a2d754acab7c3a8fe1079062e665ee60975e6169b2db97", + "negativeSHA256": "d9d035bb75e613c9b03193ec055615b2f8c330e2f4ef378fab292ede42618d74", + "changedBytes": 4472, + "restoreExact": true + }, + { + "case": "stat-harden-enemy", + "rgbaSHA256": "6da083eb844e96ab990f427deea4bcc7a2c4483401254fdbb620d5218b6e526c", + "negativeSHA256": "d9d035bb75e613c9b03193ec055615b2f8c330e2f4ef378fab292ede42618d74", + "changedBytes": 7248, + "restoreExact": true + }, + { + "case": "stat-iron_defense-player", + "rgbaSHA256": "9a177c5869211d2551a2d754acab7c3a8fe1079062e665ee60975e6169b2db97", + "negativeSHA256": "d9d035bb75e613c9b03193ec055615b2f8c330e2f4ef378fab292ede42618d74", + "changedBytes": 4472, + "restoreExact": true + }, + { + "case": "stat-iron_defense-enemy", + "rgbaSHA256": "6da083eb844e96ab990f427deea4bcc7a2c4483401254fdbb620d5218b6e526c", + "negativeSHA256": "d9d035bb75e613c9b03193ec055615b2f8c330e2f4ef378fab292ede42618d74", + "changedBytes": 7248, + "restoreExact": true + } + ] +} 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..64d83da74 --- /dev/null +++ b/docs/unified-mod-content.md @@ -0,0 +1,164 @@ +# 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`, `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. +Native effect selectors and item behavior remain opaque: changing a move's +effect on Gen 2–5, changing later item behavior, or changing a DS move type +without a native type adapter returns `nil, reason`. This avoids changing a +symbolic field while leaving an incompatible native selector behind. Power, +accuracy, PP, category, price and other represented fields can be edited. + +### 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/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-draw-hot-paths.md b/docs/world-draw-hot-paths.md new file mode 100644 index 000000000..30a70b297 --- /dev/null +++ b/docs/world-draw-hot-paths.md @@ -0,0 +1,52 @@ +# P4: native world draw dispatch and private worklists + +`Performance.worldDrawHoist` defaults to `true`. Set it to `false` to disable +private list reuse and whole-loop protection and restore per-frame bound FX, +height/entity/focus adapters and individual protected entity draws. The switch +is independent of P1–P3. + +Native FX bodies, entity drawing, ground rise and canopy focus are methods with +explicit frame arguments. Ordinary flat draws use these methods directly. +Fresh no-argument bindings remain at public pipeline and billboard callback +seams: a retained FX callback must keep its own frame/camera. No record, method +signature, hook order or render pipeline field changes. + +Reflectors use a private counted instance list; removed entities are released. +Reflection cover cells use two private numeric columns and a reusable seen set. +Nested draws borrow separate work storage while the outer lease is busy. +An overridden public `rippleCells` still receives fresh pair tables in the old +shape. None of the new pools is included in a public callback record. + +The ordinary entity loop has one protected call. Its original sprite-depth +cleanup still runs before the original error is rethrown. The deferred above- +terrain pass retains its original ordering after the top layer. + +The guarded native host runs 64 synthetic rectangle actors through actual +`drawWorld`, reflection coverage, FX and native canvas composition. There is no +ROM, save/profile, extracted asset or mod scene. Each phase has 120 warm-up and +120 measured frames; VSync 1, 60 Hz, P1/P3 enabled. GC is stopped in each measured +window to expose allocation growth, then restarted. Heap growth includes all +host/profiler allocations and is not retained memory or a leak measurement. + +Observed macOS arm64, switch off → on: + +| Tree / backend | F11 world entities, ms/frame | F11 FX, ms/frame | Raw draw mean, ms | CPU, ms/frame | Heap growth / 120 frames, KiB | +| --- | ---: | ---: | ---: | ---: | ---: | +| Fork / LÖVE 11.5 OpenGL | 0.04 → 0.05 | 0.01 → 0.01 | 0.395 → 0.468 | 1.101 → 1.243 | 2758 → 1059 | +| Fork / LÖVE 12 Metal | 0.02 → 0.02 | 0.01 → 0.01 | 16.405 → 16.408 | 0.728 → 0.703 | 2748 → 1043 | +| Public adaptation / LÖVE 11.5 OpenGL | 0.03 → 0.02 | 0.01 → 0.01 | 0.270 → 0.241 | 0.911 → 0.801 | 2758 → 1059 | +| Public adaptation / LÖVE 12 Metal | 0.02 → 0.02 | 0.01 → 0.01 | 16.417 → 16.407 | 0.612 → 0.597 | 2749 → 1043 | + +Untimed protected calls fall from 55 to 1 in this scene (nine actors are deferred +to the above-terrain pass). Heap growth is about 61% lower. Short timing results +are mixed, and Metal can place GPU/VSync waiting in submission. No general FPS +or CPU improvement is established. Full game, Android, dedicated ARM handheld +and long session: not measured. + +Reproduce with `tools/check_frame_pacing.py --world-draw --love /path/to/love +--report /owned/qa/world.json`. The guarded runner retains raw F11 logs and +measured-source fingerprints. `tests/engine/world_draw_hot.lua` exercises four +editions, operation order/coordinates, batch-call count, private list reuse, +shrinking lists, public ripple records, reentrant covers, retained FX callbacks +and sprite-depth cleanup. A separate local comparison with the unchanged +pre-P4 controller also matched all draw traces in those four editions. 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/examples/portable_battle_auxiliary/main.lua b/examples/portable_battle_auxiliary/main.lua new file mode 100644 index 000000000..d509dd253 --- /dev/null +++ b/examples/portable_battle_auxiliary/main.lua @@ -0,0 +1,18 @@ +-- Set claim(true) when a custom auxiliary battle UI is available. Its opener +-- belongs in onOpen; return true only when taking ownership of this input. +local mod = ... +local claimed, seen = false, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.claim(value) + claimed = value == true +end +mod.exports.stop = mod.hooks:wrap("battle.menu_auxiliary", function(next, game, context) + seen.calls, seen.game, seen.context = seen.calls + 1, game, context + if claimed then + if mod.exports.onOpen then + mod.exports.onOpen(game, context) + end + return true + end + return next(game, context) +end) diff --git a/examples/portable_battle_auxiliary/manifest.json b/examples/portable_battle_auxiliary/manifest.json new file mode 100644 index 000000000..ae5e702db --- /dev/null +++ b/examples/portable_battle_auxiliary/manifest.json @@ -0,0 +1 @@ +{"id":"portable_battle_auxiliary","name":"Portable Battle Auxiliary Action","version":"1.0.0","api":3,"entry":"main.lua","games":["all"],"generations":[1,2,3,4,5],"affects_link":false} diff --git a/examples/portable_caught_marker/main.lua b/examples/portable_caught_marker/main.lua new file mode 100644 index 000000000..80f2568b7 --- /dev/null +++ b/examples/portable_caught_marker/main.lua @@ -0,0 +1,20 @@ +local mod = ... +local state = { mode = "native", calls = 0 } +mod.exports.state, mod.exports.api = state, mod +local stop = mod.hooks:wrap("battle.caught_marker_visible", function(next, battle) + state.calls, state.battle = state.calls + 1, battle + if state.mode == "show" then + return true + end + if state.mode == "hide" then + return false + end + if state.mode == "nil" then + return nil + end + if state.mode == "zero" then + return 0 + end + return next(battle) +end) +mod.exports.stop = stop diff --git a/examples/portable_caught_marker/manifest.json b/examples/portable_caught_marker/manifest.json new file mode 100644 index 000000000..089c1b927 --- /dev/null +++ b/examples/portable_caught_marker/manifest.json @@ -0,0 +1,8 @@ +{ + "id": "portable_caught_marker", + "name": "Portable Caught Marker", + "version": "1.0.0", + "api": 3, + "games": ["gen1", "gen2", "gen3"], + "entry": "main.lua" +} diff --git a/examples/portable_core_update/README.md b/examples/portable_core_update/README.md new file mode 100644 index 000000000..766ec3342 --- /dev/null +++ b/examples/portable_core_update/README.md @@ -0,0 +1,13 @@ +# Portable interactive frame example + +Copy into `mods/`. This unchanged entry chunk works on Gen1Recomp and +Gen2Recomped. `exports.pause(true)` pauses Game updates while the mod continues +to observe interactive frames; `pause(false)` resumes. `exports.stop()` removes +the hook. The example starts unpaused and issues no synthetic input. + +The public API, poll count and latest Game/delta observation are exported for +the headless contract tests. It retains only the latest observation, without +allocating an unbounded frame journal. The API-2 manifest is also exercised as +unified API 2 and API 3 by the checker. See +[the frame contract](../../docs/portable-core-update.md), particularly the +separate deterministic-driver routing. diff --git a/examples/portable_core_update/main.lua b/examples/portable_core_update/main.lua new file mode 100644 index 000000000..74b6c9901 --- /dev/null +++ b/examples/portable_core_update/main.lua @@ -0,0 +1,18 @@ +-- Observe every interactive frame, including frames whose simulation is paused. +local mod = ... +local paused, polls, seen = false, 0, {} +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.pause(value) + paused = value +end +function mod.exports.polls() + return polls +end +mod.exports.stop = mod.hooks:wrap("core.update", function(next, game, dt) + polls = polls + 1 + seen.game, seen.dt = game, dt + if paused then + return "paused" + end + return next(game, dt) +end) diff --git a/examples/portable_core_update/manifest.json b/examples/portable_core_update/manifest.json new file mode 100644 index 000000000..4ebf27460 --- /dev/null +++ b/examples/portable_core_update/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_core_update", + "name": "Portable Frame Update", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/examples/portable_input/README.md b/examples/portable_input/README.md new file mode 100644 index 000000000..06a051d02 --- /dev/null +++ b/examples/portable_input/README.md @@ -0,0 +1,17 @@ +# Portable input overlay example + +Copy this directory into `mods/`. The same `main.lua` runs on Gen1Recomp and +Gen2Recomped. `exports.events` journals the three raw input hooks; +`exports.capture(true)` captures complete input events and `capture(false)` +forwards them. `exports.stop()` removes the subscriptions. The exported `api` +lets the headless contract runner add further public wrappers for error and +priority checks. + +Keep capture paired across presses and releases. Forwarding a press and +swallowing its release leaves that native input held. The example starts +with capture disabled and leaves native input active. + +The manifest uses API 2 for compatibility with current standalone hosts. The +contract checker also loads this exact entry chunk with unified API-2 and +API-3 manifests when those host features are installed. See +[the hook contract](../../docs/portable-input-hooks.md). diff --git a/examples/portable_input/main.lua b/examples/portable_input/main.lua new file mode 100644 index 000000000..de8a7d2ca --- /dev/null +++ b/examples/portable_input/main.lua @@ -0,0 +1,24 @@ +-- The same entry chunk runs on both engines; no private engine imports. +local mod = ... +local events, captured = {}, false +mod.exports.events = events +mod.exports.api = mod +function mod.exports.capture(value) + captured = value +end +local off = {} +for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + off[#off + 1] = mod.hooks:wrap(name, function(next, game, event) + events[#events + 1] = { name = name, game = game, event = event } + -- An overlay owns both press and release while capture is active. + if captured then + return "captured" + end + return next(game, event) + end) +end +function mod.exports.stop() + for _, unsubscribe in ipairs(off) do + unsubscribe() + end +end diff --git a/examples/portable_input/manifest.json b/examples/portable_input/manifest.json new file mode 100644 index 000000000..914b02d7c --- /dev/null +++ b/examples/portable_input/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "portable_input", + "name": "Portable Input Hooks", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "games": [ + "all" + ], + "generations": [ + 1, + 2, + 3, + 4, + 5 + ], + "affects_link": false +} diff --git a/examples/portable_logic_speed/main.lua b/examples/portable_logic_speed/main.lua new file mode 100644 index 000000000..ed0e63757 --- /dev/null +++ b/examples/portable_logic_speed/main.lua @@ -0,0 +1,17 @@ +-- A speed overlay can choose a multiplier or restore the host's saved option. +-- Link/fixed-speed locks and explicit launch overrides remain the host's choice. +local mod = ... +local override, seen = nil, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.setSpeed(value) + override = value +end +mod.exports.stop = mod.hooks:wrap("core.logic_speed", function(next, game, ...) + seen.calls = seen.calls + 1 + seen.game, seen.extra = game, select("#", ...) + if override ~= nil then + return override + end + seen.vanilla = next(game) + return seen.vanilla +end) diff --git a/examples/portable_logic_speed/manifest.json b/examples/portable_logic_speed/manifest.json new file mode 100644 index 000000000..93d141c6f --- /dev/null +++ b/examples/portable_logic_speed/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_logic_speed", + "name": "Portable Logic Speed", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/examples/portable_mod_input/README.md b/examples/portable_mod_input/README.md new file mode 100644 index 000000000..fe3001d71 --- /dev/null +++ b/examples/portable_mod_input/README.md @@ -0,0 +1,15 @@ +# Portable logical input example + +Copy this directory into `mods/`. The unchanged entry chunk runs on both +Gen1Recomp and Gen2Recomped. It obtains the Game through `game.ready` and +exports `tap(button)`, `press(button)` and `release(token)` helpers, together +with the underlying public `input` handle for contract tests. It does not +send input until a helper is called. + +`press` returns an opaque token which must be released by the same mod. +Available buttons are `up`, `down`, `left`, `right`, `a`, `b`, `start`, and +`select`. Do not store tokens in saves. See +[the contract and lifecycle rules](../../docs/portable-mod-input.md). + +The manifest uses API 2. The contract runner also exercises the exact same +entry chunk with unified API-2 and API-3 manifests. diff --git a/examples/portable_mod_input/main.lua b/examples/portable_mod_input/main.lua new file mode 100644 index 000000000..44119e6cb --- /dev/null +++ b/examples/portable_mod_input/main.lua @@ -0,0 +1,16 @@ +-- Logical buttons, not keyboard aliases; unchanged across both engines. +local mod = ... +local game +mod.exports.input = mod.input +mod.events:on("game.ready", function(event) + game = event.game +end) +function mod.exports.tap(button) + return mod.input:tap(game, button) +end +function mod.exports.press(button) + return mod.input:press(game, button) +end +function mod.exports.release(token) + return mod.input:release(token) +end diff --git a/examples/portable_mod_input/manifest.json b/examples/portable_mod_input/manifest.json new file mode 100644 index 000000000..69112779f --- /dev/null +++ b/examples/portable_mod_input/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_mod_input", + "name": "Portable Logical Input", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/examples/portable_screen_visibility/main.lua b/examples/portable_screen_visibility/main.lua new file mode 100644 index 000000000..63ab03435 --- /dev/null +++ b/examples/portable_screen_visibility/main.lua @@ -0,0 +1,16 @@ +-- A companion can mirror a native menu while its original state keeps input. +-- Keep the predicate pure: composition may ask about a state repeatedly. +local mod = ... +local hidden, seen = {}, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(screenId, value) + hidden[screenId] = value == true +end +mod.exports.stop = mod.hooks:wrap("screen.render_visible", function(next, state, ...) + seen.calls = seen.calls + 1 + seen.state, seen.extra = state, select("#", ...) + if hidden[state.screenId] then + return false + end + return next(state) +end) diff --git a/examples/portable_screen_visibility/manifest.json b/examples/portable_screen_visibility/manifest.json new file mode 100644 index 000000000..ec5483f0e --- /dev/null +++ b/examples/portable_screen_visibility/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_screen_visibility", + "name": "Portable Screen Visibility", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/main.lua b/main.lua index cf453d89d..b84dfd830 100644 --- a/main.lua +++ b/main.lua @@ -241,6 +241,7 @@ local lastLogFlush = 0 -- unrelated push overflows it and ends the process with a traceback pointing at -- innocent code. See src/render/GraphicsStack.lua. local GraphicsStack = require("src.render.GraphicsStack") +local PlatformHooks = require("src.core.PlatformHooks") -- Declared HERE rather than further down beside its first mark: bootGame below -- calls BootTrace.booted(), and a `local` declared after a function's body is -- not in scope inside it -- the name resolved to the nil GLOBAL instead and @@ -701,7 +702,9 @@ function love.update(dt) -- unguarded dereference here is not a theoretical race -- it is -- "attempt to index upvalue 'Game' (a nil value)" a second after launch. if not Game then return end - Game:update(dt) + -- Only the interactive game frame is a core.update seam, as in Gen1. + -- Scripted act+step loops above retain their deterministic direct updates. + PlatformHooks.update(Game, dt) end -- THE LAUNCHER'S OWN BOTTOM SCREEN. @@ -947,7 +950,7 @@ function love.wheelmoved(x, y) Game:wheelmoved(x, y) end -function love.mousepressed(x, y, button, istouch) +local function nativeMousePressed(x, y, button, istouch) if bootReport then return dismissBootReport() end if TouchEditor then -- Android primary touch already arrived via love.touchpressed; a second @@ -971,7 +974,7 @@ function love.mousepressed(x, y, button, istouch) end end -function love.mousereleased(x, y, button, istouch) +local function nativeMouseReleased(x, y, button, istouch) if TouchEditor then if love.system.getOS() == "Android" then return end return TouchEditor.mousereleased(x, y, button) @@ -998,7 +1001,7 @@ function love.mousereleased(x, y, button, istouch) end end -function love.mousemoved(x, y, dx, dy, istouch) +local function nativeMouseMoved(x, y, dx, dy, istouch) -- FREE-CAMERA LOOK FIRST, and only when one is up: `Game:cameraLook` -- answers false on every other map and camera, so the touch path below -- keeps every case it had. Warp back to centre after a real look so the @@ -1029,6 +1032,34 @@ function love.mousemoved(x, y, dx, dy, istouch) end end +-- Source mouse wrappers see one desktop event. The native pointer path keeps +-- its touch synthesis, without also reporting that synthesis as a second +-- source touch event. Editors/importers and physical touch keep their owners. +function love.mousepressed(x, y, button, istouch) + local facade=package.loaded['src.core.Game3'] + if Game and not(bootReport or editorMode or Importer or TouchEditor or istouch) + and facade and facade._pointer then + return facade._pointer('mousepressed',Game,nativeMousePressed,x,y,button,istouch) + end + return nativeMousePressed(x,y,button,istouch) +end +function love.mousereleased(x, y, button, istouch) + local facade=package.loaded['src.core.Game3'] + if Game and not(bootReport or editorMode or Importer or TouchEditor or istouch) + and facade and facade._pointer then + return facade._pointer('mousereleased',Game,nativeMouseReleased,x,y,button,istouch) + end + return nativeMouseReleased(x,y,button,istouch) +end +function love.mousemoved(x, y, dx, dy, istouch) + local facade=package.loaded['src.core.Game3'] + if Game and not(bootReport or editorMode or Importer or TouchEditor or istouch or Game.lookWarping) + and facade and facade._pointer then + return facade._pointer('mousemoved',Game,nativeMouseMoved,x,y,dx,dy,istouch) + end + return nativeMouseMoved(x,y,dx,dy,istouch) +end + function love.textinput(text) if TouchEditor then return end if Importer then return Importer:textinput(text) end @@ -1203,6 +1234,9 @@ function love.quit() if package.loaded["src.translation.Service"] then pcall(package.loaded["src.translation.Service"].shutdown) end + if package.loaded["src.net.Fetch"] then + pcall(package.loaded["src.net.Fetch"].shutdown) + end end function love.filedropped(file) @@ -1221,6 +1255,9 @@ end function love.run() BootTrace.mark("love.run enter") + local Performance = require("src.core.Performance") + local GCFrameBudget = require("src.core.GCFrameBudget") + GCFrameBudget.configure(Performance) if love.load then love.load(love.arg.parseGameArguments(arg), arg) end BootTrace.mark("love.load returned") @@ -1258,6 +1295,7 @@ function love.run() -- update dt if love.timer then dt = love.timer.step() end + GCFrameBudget.beginFrame(Performance) -- call update and draw -- @@ -1274,12 +1312,14 @@ function love.run() closeU() end + local presented = false if love.graphics and love.graphics.isActive() then love.graphics.origin() love.graphics.clear(love.graphics.getBackgroundColor()) if love.draw then love.draw() end local closeP = FrameProfile.section("present (GPU wait / vsync)") love.graphics.present() + presented = true closeP() end -- Counted after present, so a frame in the trace means a frame that was @@ -1312,24 +1352,9 @@ function love.run() if love.timer then if paced then - -- Sleep out the remainder of the frame budget, measured from the - -- carried deadline, in small chunks so the OS timer stays - -- responsive. vsync is untouched: when it already paces slower - -- than the cap the remainder is <= 0 and this rounds to a no-op. - local budget = 1 / FrameCap.current - nextFrame = nextFrame + budget - local now = love.timer.getTime() - -- A stall (alt-tab, a GC pause, a blocked import) can leave the - -- deadline more than a full budget in the past; re-anchor to now so - -- we pace the next frame rather than burst uncapped to catch up. - if now - nextFrame > budget then - nextFrame = now - end - while true do - local remaining = nextFrame - love.timer.getTime() - if remaining <= 0 then break end - love.timer.sleep(remaining < 0.001 and remaining or 0.001) - end + local closeSleep = FrameProfile.section("frame pacing: sleep") + nextFrame = FrameCap.pace(love.timer, love.window, presented, nextFrame, GCFrameBudget.idle) + closeSleep() else love.timer.sleep(0.001) end diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index ec2348985..024e3fa3a 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -22,6 +22,7 @@ #include "wrap_System.h" #include "common/android.h" #include "sdl/System.h" +#include namespace love { @@ -199,6 +200,24 @@ int w_httpRequest(lua_State *L) return 1; } +// Bounded worker transport added by Roxas2712; shares the existing JNI route. +int w_httpRequestBounded(lua_State *L) +{ + size_t size = 0; + const char *json = luaL_checklstring(L, 1, &size); + if (size > 1024 * 1024 || std::memchr(json, 0, size) != nullptr) + lua_pushliteral(L, "{\"error\":\"invalid bounded request\"}"); + else + { + std::string envelope = "{\"boundedEnvelope\":"; + envelope.append(json, size); + envelope.push_back('}'); + luax_pushstring(L, instance()->httpRequest(envelope.c_str())); + } + return 1; +} +int w_boundedHttpVersion(lua_State *L) { lua_pushinteger(L, 1); return 1; } + int w_walkerNotify(lua_State *L) { const char *json = luaL_checkstring(L, 1); @@ -263,6 +282,8 @@ static const luaL_Reg functions[] = { "mkdirs", w_mkdirs }, { "walkerNotify", w_walkerNotify }, { "httpRequest", w_httpRequest }, + { "httpRequestBounded", w_httpRequestBounded }, + { "boundedHttpVersion", w_boundedHttpVersion }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, { "httpDownload", w_httpDownload }, diff --git a/mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java b/mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java new file mode 100644 index 000000000..8d36bea24 --- /dev/null +++ b/mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package org.love2d.android; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.concurrent.Semaphore; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.json.JSONObject; + +/** Worker-only bounded transport; the existing cloud transport is unchanged. */ +final class BoundedHttp { + static final int MAX_BODY = 512 * 1024, MAX_BYTES = 16 * 1024 * 1024; + private static final Semaphore slots = new Semaphore(16); + private static final ScheduledThreadPoolExecutor deadlines = new ScheduledThreadPoolExecutor(3, new ThreadFactory() { + public Thread newThread(Runnable task) { + Thread t = new Thread(task, "bounded-http-deadline"); t.setDaemon(true); return t; + } + }); + static { deadlines.setRemoveOnCancelPolicy(true); } + private static String string(JSONObject req, String key, int maximum) throws Exception { + Object value = req.get(key); + if (!(value instanceof String)) throw new Exception("invalid field"); + String text = (String) value; + if (text.length() > maximum) throw new Exception("field too large"); + for (int i=0;imaximum) throw new Exception("invalid bounds"); + return n; + } + private static boolean flag(JSONObject req,String key) throws Exception { + Object value=req.get(key);if (!(value instanceof Boolean)) throw new Exception("invalid flag");return (Boolean)value; + } + private static URL url(String value, boolean secure) throws Exception { + if (value.length()>8192) throw new Exception("URL too long"); + for (int i=0;i current=new AtomicReference<>(); + ScheduledFuture timeout=null;boolean acquired=false; + try { + if (req.toString().length()>1024*1024 || number(req,"boundedVersion",1,1)!=1) throw new Exception("invalid protocol"); + String method=string(req,"method",4); + if (!method.equals("GET") && !method.equals("POST")) throw new Exception("unsupported method"); + boolean secure=flag(req,"httpsOnly") || method.equals("POST"); + boolean discard=flag(req,"discardBody"); + URL target=url(string(req,"url",8192),secure); + String agent=string(req,"userAgent",512),accept=string(req,"accept",512),mime=string(req,"contentType",128); + double limit=number(req,"maxBytes",1,MAX_BYTES); + if (Math.floor(limit)!=limit) throw new Exception("invalid byte bound"); + int maximum=(int)limit; + double seconds=number(req,"maxSeconds",0.001,30); + byte[] body=null; + if (method.equals("POST")) { + Object encoded=req.get("bodyBase64"); + if (!(encoded instanceof String) || ((String)encoded).length()>((MAX_BODY+2)/3)*4) throw new Exception("request too large"); + body=android.util.Base64.decode((String)encoded,android.util.Base64.DEFAULT); + if (body.length>MAX_BODY) throw new Exception("request too large"); + } + if (!slots.tryAcquire()) throw new Exception("transport busy");acquired=true; + final long deadline=System.nanoTime()+(long)(seconds*1000000000L); + timeout=deadlines.schedule(new Runnable() { + public void run() { HttpURLConnection c=current.get();if(c!=null)c.disconnect(); } + },Math.max(1,(long)Math.ceil(seconds*1000)),TimeUnit.MILLISECONDS); + int redirects=0; + while (true) { + HttpURLConnection conn=(HttpURLConnection)target.openConnection();current.set(conn); + conn.setInstanceFollowRedirects(false);conn.setConnectTimeout(Math.min(10000,remaining(deadline))); + conn.setUseCaches(false); + conn.setReadTimeout(remaining(deadline));conn.setRequestMethod(method); + conn.setRequestProperty("User-Agent",agent);conn.setRequestProperty("Accept",accept); + conn.setRequestProperty("Accept-Encoding","identity"); + if (body!=null) { + conn.setRequestProperty("Content-Type",mime);conn.setDoOutput(true);conn.setFixedLengthStreamingMode(body.length); + try (OutputStream out=conn.getOutputStream()) { out.write(body); } + } + remaining(deadline);int status=conn.getResponseCode();result.put("status",status); + if (status==301 || status==302 || status==303 || status==307 || status==308) { + String location=conn.getHeaderField("Location"); + if (!method.equals("GET") || location==null || ++redirects>5) throw new Exception("redirect refused"); + target=url(new URL(target,location).toString(),secure);conn.disconnect();current.set(null);continue; + } + if (status<200 || status>=300) throw new Exception("HTTP status"); + if (!discard) { + long declared=-1;String length=conn.getHeaderField("Content-Length"); + if (length!=null) {try {declared=Long.parseLong(length);} catch(NumberFormatException bad) {throw new Exception("invalid length");}} + if (declared>maximum) throw new Exception("response too large"); + ByteArrayOutputStream bytes=new ByteArrayOutputStream(Math.min(maximum,65536)); + try (InputStream in=conn.getInputStream()) { + byte[] chunk=new byte[8192];int n; + while (true) { + conn.setReadTimeout(remaining(deadline));n=in.read(chunk);if(n<0)break; + if (n>maximum-bytes.size()) throw new Exception("response too large");bytes.write(chunk,0,n); + } + } + if (declared>=0 && declared!=bytes.size()) throw new Exception("partial response"); + result.put("bodyBase64",android.util.Base64.encodeToString(bytes.toByteArray(),android.util.Base64.NO_WRAP)); + } + remaining(deadline);return result.toString(); + } + } catch (Exception error) { + try {result.remove("bodyBase64");result.put("error","bounded HTTP request failed");} catch(Exception ignored){} + return result.toString(); + } finally { + if (timeout!=null)timeout.cancel(false); + try {HttpURLConnection conn=current.get();if(conn!=null)conn.disconnect();} + finally {if(acquired)slots.release();} + } + } +} diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 4eedb2f2c..1eed05e29 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -1175,6 +1175,8 @@ public static String httpRequest(String json) { HttpURLConnection conn = null; try { org.json.JSONObject req = new org.json.JSONObject(json); + if (req.has("boundedEnvelope")) return BoundedHttp.request(req.getJSONObject("boundedEnvelope")); + if (req.has("boundedVersion")) return BoundedHttp.request(req); String method = req.optString("method", "GET"); URL url = new URL(req.getString("url")); conn = (HttpURLConnection) url.openConnection(); diff --git a/mobile/ios/native/GRHTTPBridge.swift b/mobile/ios/native/GRHTTPBridge.swift new file mode 100644 index 000000000..c91c4e97a --- /dev/null +++ b/mobile/ios/native/GRHTTPBridge.swift @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import Foundation +import CoreFoundation + +enum GRHTTPFailure: Error { case invalid } +struct GRHTTPRequest { + let url: URL, method: String, agent: String, accept: String, mime: String + let seconds: Double, maximum: Int, secure: Bool, discard: Bool, body: Data? + static func text(_ value: Any?, maximum: Int) throws -> String { + guard let text=value as? String, text.utf8.count<=maximum, + !text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + else { throw GRHTTPFailure.invalid } + return text + } + static func number(_ value: Any?, minimum: Double, maximum: Double) throws -> Double { + guard let n=value as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID(), + n.doubleValue.isFinite, n.doubleValue>=minimum, n.doubleValue<=maximum + else { throw GRHTTPFailure.invalid } + return n.doubleValue + } + static func flag(_ value: Any?) throws -> Bool { + guard let n=value as? NSNumber, CFGetTypeID(n)==CFBooleanGetTypeID() else { throw GRHTTPFailure.invalid } + return n.boolValue + } + static func allowed(_ url: URL, secure: Bool) -> Bool { + guard let scheme=url.scheme?.lowercased(), let host=url.host, !host.isEmpty, + url.absoluteString.utf8.count<=8192, + !url.absoluteString.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) }) + else { return false } + return scheme=="https" || (!secure && scheme=="http") + } + init(_ json: String) throws { + guard json.utf8.count<=1024*1024, let data=json.data(using:.utf8), + let doc=try JSONSerialization.jsonObject(with:data) as? [String:Any] + else { throw GRHTTPFailure.invalid } + guard try Self.number(doc["boundedVersion"],minimum:1,maximum:1)==1 else { throw GRHTTPFailure.invalid } + method=try Self.text(doc["method"],maximum:4) + guard method=="GET" || method=="POST" else { throw GRHTTPFailure.invalid } + secure=try Self.flag(doc["httpsOnly"]) || method=="POST" + discard=try Self.flag(doc["discardBody"]) + let address=try Self.text(doc["url"],maximum:8192) + guard !address.unicodeScalars.contains(where:{CharacterSet.whitespacesAndNewlines.contains($0)}), + let target=URL(string:address), Self.allowed(target,secure:secure) + else { throw GRHTTPFailure.invalid } + url=target + agent=try Self.text(doc["userAgent"],maximum:512);accept=try Self.text(doc["accept"],maximum:512) + mime=try Self.text(doc["contentType"],maximum:128) + seconds=try Self.number(doc["maxSeconds"],minimum:0.001,maximum:30) + let bytes=try Self.number(doc["maxBytes"],minimum:1,maximum:16*1024*1024) + guard bytes.rounded(.down)==bytes else { throw GRHTTPFailure.invalid };maximum=Int(bytes) + if method=="POST" { + let encoded=try Self.text(doc["bodyBase64"],maximum:((512*1024+2)/3)*4) + guard let decoded=Data(base64Encoded:encoded), decoded.count<=512*1024 else { throw GRHTTPFailure.invalid } + body=decoded + } else { body=nil } + } +} + +private final class GRHTTPTransfer: NSObject, URLSessionDataDelegate, @unchecked Sendable { + let request: GRHTTPRequest + let done=DispatchSemaphore(value:0) + private let gate=NSLock() + private var result: [String:Any]? + private var bytes=Data() + private var status: Int? + private var declared: Int64 = -1 + private var redirects=0 + private var released=false + private let release: () -> Void + init(_ request: GRHTTPRequest, release: @escaping () -> Void) { self.request=request;self.release=release } + func finish(error: Bool) { + gate.lock() + if result != nil { gate.unlock();return } + var value: [String:Any]=[:] + if let code=status { value["status"]=code } + if error {value["error"]="bounded HTTP request failed"} + else if !request.discard {value["bodyBase64"]=bytes.base64EncodedString()} + result=value;gate.unlock();done.signal() + } + func take() -> [String:Any] { gate.lock();defer{gate.unlock()};return result ?? ["error":"bounded HTTP request failed"] } + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + gate.lock() + let allowed=request.method=="GET" && redirects<5 && result==nil && + newRequest.url.map{GRHTTPRequest.allowed($0,secure:request.secure)}==true + if allowed {redirects+=1} else {status=response.statusCode} + gate.unlock() + if allowed { + var next=newRequest;next.setValue(request.agent,forHTTPHeaderField:"User-Agent") + next.setValue(request.accept,forHTTPHeaderField:"Accept") + next.setValue("identity",forHTTPHeaderField:"Accept-Encoding") + completionHandler(next) + } else {finish(error:true);completionHandler(nil)} + } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard let http=response as? HTTPURLResponse else {finish(error:true);completionHandler(.cancel);return} + gate.lock();status=http.statusCode;declared=response.expectedContentLength + let valid=result==nil && (200..<300).contains(http.statusCode) + let oversized=declared>Int64(request.maximum) + gate.unlock() + if !valid || (!request.discard && oversized) {finish(error:true);completionHandler(.cancel)} + else if request.discard {finish(error:false);completionHandler(.cancel)} + else {completionHandler(.allow)} + } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + gate.lock() + if result != nil {gate.unlock();return} + let oversized=data.count>request.maximum-bytes.count + if !oversized {bytes.append(data)} + gate.unlock() + if oversized {finish(error:true);dataTask.cancel()} + } + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + gate.lock() + let incomplete=declared>=0 && declared != Int64(bytes.count) + let firstRelease = !released;released=true + gate.unlock() + finish(error:error != nil || incomplete) + // A cancelled platform task retains its native slot until this terminal callback. + if firstRelease {release()} + } +} + +enum GRBoundedHTTP { + private static let pool=NSLock() + private static var active=0 + private static func acquire() -> Bool {pool.lock();defer{pool.unlock()};if active>=16{return false};active+=1;return true} + private static func release() {pool.lock();active-=1;pool.unlock()} + static func perform(_ json: String, configuration: URLSessionConfiguration? = nil) -> String { + let failure="{\"error\":\"bounded HTTP request failed\"}" + guard let request=try? GRHTTPRequest(json), acquire() else {return failure} + let transfer=GRHTTPTransfer(request,release:release) + let config=configuration ?? URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest=request.seconds;config.timeoutIntervalForResource=request.seconds + config.httpShouldSetCookies=false;config.httpCookieStorage=nil;config.urlCredentialStorage=nil + config.urlCache=nil;config.requestCachePolicy = .reloadIgnoringLocalCacheData + let session=URLSession(configuration:config,delegate:transfer,delegateQueue:nil) + var message=URLRequest(url:request.url,timeoutInterval:request.seconds) + message.httpMethod=request.method;message.httpBody=request.body + message.setValue(request.agent,forHTTPHeaderField:"User-Agent") + message.setValue(request.accept,forHTTPHeaderField:"Accept") + message.setValue("identity",forHTTPHeaderField:"Accept-Encoding") + if request.body != nil {message.setValue(request.mime,forHTTPHeaderField:"Content-Type")} + let task=session.dataTask(with:message);task.resume() + if transfer.done.wait(timeout:.now()+request.seconds) == .timedOut {transfer.finish(error:true);task.cancel()} + session.invalidateAndCancel() + guard let data=try? JSONSerialization.data(withJSONObject:transfer.take()), + let value=String(data:data,encoding:.utf8) else {return failure} + return value + } +} + +@objc(GRHTTPBridge) +public final class GRHTTPBridge: NSObject { + @objc(requestWithJSON:) + public static func request(json: UnsafePointer?) -> NSString { + guard let json=json, let value=String(validatingUTF8:json) else {return "{\"error\":\"invalid bounded request\"}"} + return GRBoundedHTTP.perform(value) as NSString + } +} diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index 9c0fa0ac4..c0c8e5b55 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -29,7 +29,7 @@ PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj" ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements" -NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m") +NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRHTTPBridge.swift", "GRBootstrap.m") MARKER = "gen1recomp iOS picker bridge" @@ -40,6 +40,7 @@ #include #include #include +#include #include "filesystem/Filesystem.h" #endif """ % MARKER @@ -94,6 +95,32 @@ { return gr_callBridge(L, "GRHealthBridge", "syncStepsWithCommand:saveDir:", "sync"); } +int w_boundedHttpVersion(lua_State *L) +{ + lua_pushinteger(L, objc_getClass("GRHTTPBridge") != nullptr ? 1 : 0); + return 1; +} +int w_httpRequestBounded(lua_State *L) +{ + size_t size = 0; + const char *json = luaL_checklstring(L, 1, &size); + Class cls = objc_getClass("GRHTTPBridge"); + if (cls == nullptr || size > 1024 * 1024 || std::memchr(json, 0, size) != nullptr) + { + lua_pushliteral(L, "{\\\"error\\\":\\\"invalid bounded request\\\"}"); + return 1; + } + typedef id (*GRNew)(Class, SEL); + typedef id (*GRRequest)(Class, SEL, const char *); + typedef const char *(*GRUTF8)(id, SEL); + typedef void (*GRDrain)(id, SEL); + id pool = ((GRNew)objc_msgSend)(objc_getClass("NSAutoreleasePool"), sel_registerName("new")); + id result = ((GRRequest)objc_msgSend)(cls, sel_registerName("requestWithJSON:"), json); + const char *text = result == nullptr ? nullptr : ((GRUTF8)objc_msgSend)(result, sel_registerName("UTF8String")); + lua_pushstring(L, text == nullptr ? "{\\\"error\\\":\\\"bounded request failed\\\"}" : text); + ((GRDrain)objc_msgSend)(pool, sel_registerName("drain")); + return 1; +} #endif // LOVE_IOS // --------------------------------------------------------------------------- @@ -103,6 +130,8 @@ { "pickFile", w_pickFile }, { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, + { "httpRequestBounded", w_httpRequestBounded }, + { "boundedHttpVersion", w_boundedHttpVersion }, #endif """ @@ -114,6 +143,8 @@ ID_BUILD_PICKER = "6E1AC0DE0002000000000001" ID_BUILD_OBJC = "6E1AC0DE0002000000000002" ID_BUILD_HEALTH = "6E1AC0DE0002000000000003" +ID_FILE_HTTP = "6E1AC0DE0001000000000004" +ID_BUILD_HTTP = "6E1AC0DE0002000000000004" SOURCES_PHASE_ID = "FA0B7F021A95AAF3000E1D17" # love-ios Sources phase IOS_APP_CONFIG_IDS = ( "FA0B7F261A95AAF4000E1D17", # Debug @@ -124,6 +155,7 @@ PBX_SOURCES = ( ("GRPickerBridge.swift", ID_FILE_PICKER, ID_BUILD_PICKER, "sourcecode.swift"), ("GRHealthBridge.swift", ID_FILE_HEALTH, ID_BUILD_HEALTH, "sourcecode.swift"), + ("GRHTTPBridge.swift", ID_FILE_HTTP, ID_BUILD_HTTP, "sourcecode.swift"), ("GRBootstrap.m", ID_FILE_OBJC, ID_BUILD_OBJC, "sourcecode.c.objc"), ) diff --git a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua index 2084e56b4..2da5d6398 100644 --- a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua +++ b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua @@ -14,6 +14,7 @@ local V = ... local Mat4 = V.require("Mat4") local Perf = V.require("Perf") +local FrameProfile = require("src.core.FrameProfile") -- The PERFORMANCE tier's ceilings (lib/Tier.lua). Top-level here rather -- than lazily as in ShadowMap/Water/AntiAlias: Tier requires nothing from -- this mod, so there is no load order to protect, and the sun's rate @@ -2725,8 +2726,10 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- reaches far north and barely south, which is right for every rung -- but a head free to face south. local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh) + local closeSun = FrameProfile.section(" voxel: sun pass") castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor, water, nbWater, battleCards, battleToken) + closeSun() -- Everything between beginScene and endScene, as one function: the flat -- path runs it once, a VR frame runs it once PER EYE -- same posed @@ -2734,12 +2737,15 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- about anything but their viewpoint. local function drawScene() + local closeTerrain = FrameProfile.section(" voxel: terrain") Voxel3D.draw(terrain, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + closeTerrain() + -- Without a shadow map (headless, or a driver that could not make the -- canvas) the old flat decals stand in: ground-only, characters only, -- but better than a world with nothing under anybody. They go down @@ -2824,6 +2830,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- lake would otherwise wear one as a black smear. Water covers them, -- which is the same answer the shadow map's own pass gives (see -- ShadowMap.sprites) -- people do not shadow water either way. + local closeWater = FrameProfile.section(" voxel: water") local waterDraws = {} if water then waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil } @@ -2859,6 +2866,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) planarTex, planarY) end + closeWater() + -- AND THE RIPPLE RINGS ON TOP OF IT, BEFORE ANYBODY IS STANDING IN IT. -- -- THIS LINE'S POSITION IS THE WHOLE FIX. Reported from play: "the ripples @@ -2920,10 +2929,12 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- drawEntity resolves the lean-over-the-wall-in-front case, and a -- character genuinely behind a building is far deeper and loses the -- test, so buildings and trees really occlude. + local closeCards = FrameProfile.section(" voxel: cards") drawCast(state, posed, atlasFor) -- and the staged fight's mons over them, exactly as they were painted -- into the water's reflection copy a few lines up (see drawBattleCards) drawBattleCards() + closeCards() -- tall grass last, pulled camera-ward exactly as far as the characters -- were (same per-vertex shader bias, so grass never drifts either): -- relative depth between a walker and the tuft row south of their feet diff --git a/mods/examples/example_silly_oak/README.md b/mods/examples/example_silly_oak/README.md index 7278827ea..3a9e77ce0 100644 --- a/mods/examples/example_silly_oak/README.md +++ b/mods/examples/example_silly_oak/README.md @@ -35,8 +35,8 @@ SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \ | Seam | Where | |---|---| -| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list | -| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids | +| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list | +| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids | | step kinds `say` / `yesno` / `choice` | `main.lua` | | pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` | | `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` | diff --git a/src/audio/NitroAudio.lua b/src/audio/NitroAudio.lua index c27fff829..81b0caf87 100644 --- a/src/audio/NitroAudio.lua +++ b/src/audio/NitroAudio.lua @@ -1,5 +1,6 @@ -- Nintendo DS SSEQ transport over the ROM's SBNK/SWAR instruments. -- PCM/ADPCM mixing is local; no replacement MIDI soundfont is used. +local FrameProfile = require("src.core.FrameProfile") local Sdat=require('src.import.Gen4Sdat') local Audio={};local Player={};Player.__index=Player local archives,players={},setmetatable({}, {__mode="k"}) @@ -194,6 +195,7 @@ function Player:tick() for _,v in ipairs(self.voices) do v.ticks=v.ticks-1;if v.ticks<=0 and v.release==0 then v.release=math.floor(RATE*0.04) end end end function Player:render() + local closeRender=FrameProfile.section(" audio: Nitro render") local sound=love.sound.newSoundData(BLOCK,RATE,16,2) for i=0,BLOCK-1 do self.tickPhase=self.tickPhase+self.tempo*48/60/RATE @@ -215,6 +217,7 @@ function Player:render() sound:setSample(i,1,math.max(-1,math.min(1,left))) sound:setSample(i,2,math.max(-1,math.min(1,right))) end + closeRender() return sound end function Player:update() diff --git a/src/battle/BattleCommands.lua b/src/battle/BattleCommands.lua new file mode 100644 index 000000000..fff9aa581 --- /dev/null +++ b/src/battle/BattleCommands.lua @@ -0,0 +1,362 @@ +-- Native command dispatch shared by BattleState input and mod.battle intents. +-- Keep gameplay mutations here, after the mod facade's admission checks. +local Strings = require("src.core.Strings") +local Targeting = require("src.battle.Targeting") +local Commands, Methods = {}, {} +Commands.methods = Methods + +function Methods.chooseMenu(self, choice) + -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / + -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the + -- third slot IS the ball, so the item action becomes the throw. + if choice == "item" and self.bugContest then + self:bugContestBall() + return true + end + if choice == "fight" and self.ghost then + self:say(Strings("%s is too\nscared to move!", self.player.name)) + self.phase = "messages" + self.afterQueue = "menu" + self:act(function() + self:executeAction(self.enemy, self.player, self:enemyAction()) + end) + self:act(function() + self:endOfTurn() + end) + elseif choice == "fight" then + -- After the menu: own trapping/Bide or foe Wrap skips the move + -- list and forces the locked action (core.asm:320-329) + local fightLock = self:fightLockedAction(self.player) + if fightLock then + self:resolveTurn(fightLock) + return true + end + local who = self:menuBattler() + if not self:playerHasPP(who) then + -- _NoMovesLeftText, then Struggle engages + self:say(Strings("%s has no\nmoves left!", who.name)) + self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) + return true + end + self.phase = "moveSelect" + self.moveIndex = math.min(self.moveIndex, #who.curMoves) + self.moveSwapIndex = nil + elseif choice == "run" then + self:tryRun() + elseif choice == "item" then + self:openItems() + else + self:openParty() + end + return true +end + +function Methods.chooseMove(self, index) + local chooser = self:menuBattler() + local moves = chooser.curMoves + self.moveIndex = index + local mv = moves[self.moveIndex] + local mvDef = mv and self.data.moves[mv.id] + if chooser.disabledSlot == self.moveIndex then + self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) + self.phase = "messages" + self.afterQueue = "menu" + -- TAUNT and TORMENT refuse the move at SELECTION rather than after the + -- fact, the way DISABLE already does, so the player is never told to + -- pick again for something the game was never going to allow. + elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then + -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block + self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tauntBlocks(chooser, mvDef) then + self:say(Strings("%s can't use\n%s after the\ntaunt!", chooser.name, mvDef and mvDef.name or mv.id)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self:tormentBlocks(chooser, mvDef) then + self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", chooser.name)) + self.phase = "messages" + self.afterQueue = "menu" + elseif self.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef) then + self:say(require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef)) + self.phase = "messages" + self.afterQueue = "menu" + elseif mv.pp <= 0 then + self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) + self.phase = "messages" + self.afterQueue = "menu" + elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then + -- WHICH OF THE TWO, when the move takes one and there are two. + -- + -- Emerald's selector cycles over the legal slots and names the + -- candidate; nothing else in the menu changes. A single battle + -- never gets here, because needsChoice answers false whenever there + -- is only one thing to aim at -- and on the Game Boy cartridges the + -- moves carry no target byte at all, so it answers false for every + -- move in the game. + self.pendingMove = mv + self.targetChoices = Targeting.choices(self, self:menuBattler()) + self.targetIndex = 1 + self.phase = "targetSelect" + else + self:chooseAction(mv) + end + return true +end + +function Methods.chooseTarget(self, index) + local list = self.targetChoices or {} + self.targetIndex = index + local mv = self.pendingMove + local pick = list[self.targetIndex] + self.pendingMove, self.targetChoices = nil, nil + self.chosenTargets = self.chosenTargets or {} + local who = self:menuBattler() + if who and who.position then + self.chosenTargets[who.position] = pick + end + self:chooseAction(mv) + return true +end + +function Methods.chooseMimic(self, index) + local moves = self.mimicMoves + self.mimicIndex = index + local pick = moves[self.mimicIndex] + local ctx = self.mimicCtx + self.mimicMoves, self.mimicCtx = nil, nil + self.phase = "messages" + self.nextInsert = 0 -- the copy's anim + text go to the queue head + self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot) + return true +end + +local SAFARI_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 } +function Methods.chooseSafari(self, action) + self.menuIndex = SAFARI_INDEX[action] + self:safariAction(action) + return true +end + +local function backToFirst(self) + if not self:isDouble() or self:choosingSlotNow() ~= 2 then + return nil + end + local left = self:battlerAt(0) + local pending = self.pendingActions and self.pendingActions[0] + if + left + and not left.ally + and (left.mon and left.mon.hp or 0) > 0 + and pending + and not self:menuLockedAction(left) + then + return left + end +end +function Methods.cancelMove(self) + if self.phase == "moveSelect" then + self.moveSwapIndex, self.phase = nil, "menu" + elseif self.phase == "targetSelect" then + self.pendingMove, self.targetChoices = nil, nil + self.phase = "moveSelect" + elseif self.phase == "menu" and backToFirst(self) then + self.pendingActions[0] = nil + if self.chosenTargets then + self.chosenTargets[0] = nil + end + self.choosingSlot, self.menuIndex, self.moveIndex = 0, 1, 1 + pcall(function() + require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") + end) + else + return nil, "nothing to cancel" + end + return true +end + +local function moveReason(b, actor, slot, move) + if not move or not b.data.moves[move.id] then + return "invalid move slot" + end + if actor.disabledSlot == slot then + return "move is disabled" + end + if (move.pp or 0) <= 0 then + return "move has no PP" + end + local def = b.data.moves[move.id] + if b.bw and require("src.battle.Gen5MoveRules").moveBlocked(b, actor, def, true) then + return "move is blocked by native BW rules" + end + if b.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(b, actor, def) then + return "move is blocked by native Gen4 rules" + end + if b:tauntBlocks(actor, def) then + return "move is blocked by Taunt" + end + if b:tormentBlocks(actor, def) then + return "move is blocked by Torment" + end +end + +-- Admission checks are read-only. Native refusal messages still belong to +-- chooseMove when driven by ordinary input; invalid API requests do not act. +function Commands.reason(b, intent) + if b.kind == "link" or b.spectating or b.demo or b.dudeDemo or b.pokedudeDemo or b.palace or b.arena then + return "battle controller owns input" + end + if b.result or b.dead or b.waitingUI then + return "battle is not ready" + end + if intent.kind == "safari" then + if b.phase ~= "menu" or not b.safari then + return "safari menu is not active" + end + if (b.safari.balls or 0) <= 0 then + return "no safari balls remain" + end + return + end + if b.safari then + return "safari controller owns input" + end + local actor = intent.kind == "mimic" and b.mimicCtx and b.mimicCtx.user or b:menuBattler() + if not (actor and actor.mon) or actor.ally or (actor.mon.hp or 0) <= 0 then + return "player battler is not ready" + end + if + intent.kind ~= "mimic" + and ( + b:menuLockedAction(actor) + or actor.mustRecharge + or actor.charging + or actor.rageMove + or (actor.thrashTurns and actor.thrashTurns > 0) + ) + then + return "player battler is locked" + end + if intent.kind == "menu" then + if b.phase ~= "menu" then + return "battle menu is not active" + end + local acting = b.player or b:activeOn(1) + if not acting or (acting.mon.hp or 0) <= 0 or b:menuLockedAction(acting) then + return "battle menu is not ready" + end + elseif intent.kind == "move" then + if b.phase ~= "moveSelect" then + return "move menu is not active" + end + if b.moveSwapIndex then + return "move reorder is active" + end + return moveReason(b, actor, intent.slot, (actor.curMoves or {})[intent.slot]) + elseif intent.kind == "target" then + if b.phase ~= "targetSelect" then + return "target menu is not active" + end + local move, slot = b.pendingMove, nil + for index, mv in ipairs(actor.curMoves or {}) do + if mv == move then + slot = index + break + end + end + if not slot then + return "pending move is no longer current" + end + local reason = moveReason(b, actor, slot, move) + if reason then + return reason + end + if not Targeting.needsChoice(b, actor, b.data.moves[move.id]) then + return "target choice is no longer needed" + end + local pick = b:battlerAt(intent.target) + if not (pick and pick.mon and (pick.mon.hp or 0) > 0) then + return "target is not on the field" + end + local offered = false + for _, target in ipairs(b.targetChoices or {}) do + if target == pick then + offered = true + break + end + end + if not offered then + return "target is not in the current selector" + end + for _, target in ipairs(Targeting.choices(b, actor)) do + if target == pick then + return + end + end + return "target is no longer eligible" + elseif intent.kind == "back" then + if b.phase == "moveSelect" or b.phase == "targetSelect" or (b.phase == "menu" and backToFirst(b)) then + return + end + return "nothing to cancel" + elseif intent.kind == "mimic" then + if b.phase ~= "mimicSelect" then + return "mimic menu is not active" + end + local ctx, pick = b.mimicCtx, (b.mimicMoves or {})[intent.index] + if not ctx or not pick or ctx.user ~= actor or not ctx.target or not ctx.moveInst then + return "invalid mimic context" + end + local targetPresent = false + for pos = 0, 3 do + if b:battlerAt(pos) == ctx.target then + targetPresent = true + end + end + if not targetPresent or #(actor.curMoves or {}) == 0 then + return "invalid mimic context" + end + local onField = false + for _, pos in ipairs({ 0, 2 }) do + if b:battlerAt(pos) == actor then + onField = true + end + end + if not onField then + return "mimic user is no longer on the field" + end + local source = (ctx.target.curMoves or {})[pick.slot] + if not source or source.id ~= pick.id then + return "mimic option is no longer current" + end + else + return "unknown battle intent" + end +end + +local DISPATCH = { + menu = "chooseMenu", + move = "chooseMove", + target = "chooseTarget", + back = "cancelMove", + safari = "chooseSafari", + mimic = "chooseMimic", +} +function Commands.dispatch(b, intent) + -- Ordinary input already performs this at its menu boundary. + if b.phase == "menu" and not b.safari then + b:clearTurnFlinches() + end + if intent.kind == "target" then + local pick = b:battlerAt(intent.target) + for index, target in ipairs(b.targetChoices or {}) do + if target == pick then + return b:chooseTarget(index) + end + end + return nil, "target is not in the current selector" + end + local value = intent.choice or intent.slot or intent.action or intent.index + return b[DISPATCH[intent.kind]](b, value) +end +return Commands diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 8f8e1409c..51f84bc3b 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -19,6 +19,7 @@ local HoldItems = require("src.battle.HoldItems") local Experience = require("src.battle.Experience") local HeldItems = require("src.battle.HeldItems") local Font = require("src.render.Font") +local MenuAuxiliary = require("src.battle.MenuAuxiliary") local Logger = require("src.core.Logger") local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") @@ -38,6 +39,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 +55,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 @@ -356,6 +362,26 @@ end -- its RIGHT. Left and right then did nothing at all, because a vertical list -- has no use for them. The fix is one clause, not new maths: the grid this -- layout already draws is the grid it should navigate. +function BattleState:caughtMarkerVisible(battler) + return require("src.battle.CaughtMarker").visible(self, battler) +end + +local caughtBallQuads = setmetatable({}, { __mode = "k" }) +function BattleState:drawCaughtBall(x, y) + local Assets = require("src.render.Assets") + local path = "assets/generated/battle/balls.png" + if not Assets.exists(Assets.resolve(path)) then return false end + local image = Assets.image(path) + local quad = caughtBallQuads[image] + if not quad then + quad = love.graphics.newQuad(0, 0, 8, 8, image:getDimensions()) + caughtBallQuads[image] = quad + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, quad, x, y) + return true +end + function BattleState:moveGridNavigate(index, count, input) if not (self:isWideBattleLayout() or self:gen3Layout() or self:gen4Layout()) then @@ -596,10 +622,7 @@ function BattleState:sgbPalettes() return nil end -local Rulesets = { - gen1_faithful = require("src.battle.rulesets.gen1_faithful"), - modern_clean = require("src.battle.rulesets.modern_clean"), -} +local Rulesets = require('src.battle.Rulesets') -- the Poké Ball toss chain (TossBallAnimation) plays even with battle -- animations off: PlayMoveAnimation jumps to it before checking wOptions @@ -1364,6 +1387,17 @@ function BattleState.giveWildHeldItem(data, mon, rng, compoundEyes) end function BattleState.newWild(game, species, level, opts) + local prepared + if opts and opts.nativeFishingMon then + assert(not opts.wildRecord, 'ambiguous native wild individual') + prepared=require('src.world.Gen2Fishing').takePrepared(game,opts.nativeFishingMon,species,level) + end + local authored + if opts and opts.wildRecord then + local why;authored,why=require('src.battle.WildRecord').validate(game.data,opts.wildRecord) + assert(authored,why) + assert(authored.species==species and authored.level==level,'encounter identity mismatch') + end local self = newBattle(game) self.kind = "wild" local playerMon = Party.firstHealthy(game.save.party) @@ -1385,7 +1419,7 @@ function BattleState.newWild(game, species, level, opts) and game.data.pokemon[species] and game.data.pokemon[species].forms then form=require("src.pokemon.PolishedUnown").choose(game.save) end - local wild = Pokemon.new(game.data, species, level, nil, form) + local wild = prepared or Pokemon.new(game.data, species, level, nil, form) -- SYNCHRONIZE'S NATURE AND CUTE CHARM'S GENDER, decided with the slot (see -- src/world/Gen4WildLead.lua) and applied the cartridge's way: personality -- re-drawn until it carries them. @@ -1399,7 +1433,7 @@ function BattleState.newWild(game, species, level, opts) -- without a third field. 147 species in Hoenn name something. local compoundEyes = (game.data.constants or {}).gen == 4 and require("src.world.Gen4WildLead").lead(game.data, game.save).ability == "COMPOUND_EYES" - BattleState.giveWildHeldItem(game.data, wild, nil, compoundEyes) + if not prepared then BattleState.giveWildHeldItem(game.data, wild, nil, compoundEyes) end -- BATTLETYPE_SHINY (`loadvar 3, 7` before the loadwildmon) overwrites the -- rolled DVs with the fixed shiny pair; the Lake of Rage Gyarados is the -- only encounter in Gold that uses it. @@ -1428,7 +1462,9 @@ function BattleState.newWild(game, species, level, opts) if opts and opts.roamerSeed then Pokemon.applySeed(game.data, wild, opts.roamerSeed) end + if authored then require('src.battle.WildRecord').apply(game.data,wild,authored)end self.enemy = makeBattler(game.data, wild, false) + self.caughtMark = require("src.battle.CaughtMarker").caught(game, wild.species) require("src.battle.Gen2Wild").initialize(self, opts) if self.roamer then local kept = math.floor(tonumber(opts.roamerHP) or 0) @@ -2482,6 +2518,8 @@ function BattleState:stepHPDrain() b.draining = nil b.drainHold = Timing.hpDrainClosingFrames(b.isPlayer == true) - 1 busy = true + elseif b.drainHold == 0 then + b.drainHold = nil -- the closing frame has already been consumed end end end @@ -2604,11 +2642,9 @@ function BattleState:beginMsgLine() self.shown[#self.shown + 1] = {} end -function BattleState:updateQueue() - if self.waitingUI then - if self.game.stack:top() ~= self then return true end - self.waitingUI = nil - end +-- Only display controllers live here. Command holds, HP awards, turn rows +-- and completion remain in updateQueue, even while a mod holds presentation. +function BattleState:updateMovePresentation() -- HOENN'S PARTICLES TICK BEFORE THE QUEUE'S OWN HOLD, and the whole -- reason the animations looked broken is that they used to tick after it. -- @@ -2659,11 +2695,16 @@ function BattleState:updateQueue() if not anim.caught then self.gen3Ball = nil end end end +end + +-- Display-only waits and bar completion. Returning idle grants no command; +-- only updateQueue may consume a battle row after this gate. +function BattleState:updateQueuePresentation() + self:updateMovePresentation() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if source and source._holdsNativeQueue(self) then return true end -- a queued hold (faint slide, hit blink) counts down before the next row - if self.waitFrames and self.waitFrames > 0 then - self.waitFrames = self.waitFrames - 1 - return true - end + if self:stepPresentationWait() then return true end -- WaitForSoundToFinish (home/delay.asm:15-20) blocks until the sfx has -- actually stopped sounding, which is how the original gives a sound its -- own clear window instead of letting the next beat play over it @@ -2693,6 +2734,15 @@ function BattleState:updateQueue() if self:stepExpBar() then return true end self.expFilling = nil end + return false +end + +function BattleState:updateQueue() + if self.waitingUI then + if self.game.stack:top() ~= self then return true end + self.waitingUI = nil + end + if self:updateQueuePresentation() then return true end if self.animPlaying then self.animPlayer:update() if self.animPlayer.pollEffects and self.applyAnimEffect then @@ -2728,10 +2778,11 @@ function BattleState:updateQueue() end if not self.current then local item = table.remove(self.queue, 1) - if not item then return false end + if not item then self.nextInsert = nil; return false end if item.fn then self.nextInsert = 0 -- sayNext inserts right after this item item.fn() + self.nextInsert = nil self.current = nil return true end @@ -3251,7 +3302,7 @@ function BattleState:gen3MusicKind() local species = mon and mon.species local def = species and self.data.pokemon and self.data.pokemon[species] local table_ = GEN3_LEGEND_MUSIC - if require("src.core.GameVersion").get() == "firered" then + if require("src.core.GameVersion").isFRLG() then table_ = GEN3_LEGEND_MUSIC_FRLG end local kind = table_[tostring((def and (def.translationSourceName or def.name)) or species):upper()] @@ -3654,7 +3705,7 @@ function BattleState:enter() -- FIRERED'S INTRO (battle_intro.c BattleIntroSlide1): the window opens out -- of the middle for 32 frames before anything moves, then both halves of -- the field and both battlers travel the whole 240 pixels at 2 a frame - if require("src.core.GameVersion").get() == "firered" and self:gen3Layout() then + if require("src.core.GameVersion").isFRLG() and self:gen3Layout() then self.introSlide = 120 self.frlgIntro = { hold = 32, t = 0, light = nil } end @@ -4287,17 +4338,39 @@ function BattleState:touchpressed(_, px, py) end return true end +function BattleState:updateBattlerPresentation(dt) + for _, b in activeBattlers(self) do + if b and b.picAnim then b.picAnim:update(dt) end + if b and b.monAnim then b.monAnim:update(dt) end + end +end + +function BattleState:stepPresentationWait() + if self.waitFrames and self.waitFrames > 0 then + self.waitFrames = self.waitFrames - 1 + return true + end + return false +end + +-- Advance an ending/pose hold without executing battle commands or input. +function BattleState:updatePresentation(dt) + self:tickFx() + self:updateBattlerPresentation(dt) + self:updateQueuePresentation() +end + function BattleState:update(dt) + local shortcuts=package.loaded['src.mods.FeatureOptions']and require('src.battle.QuickItem') + local quick=shortcuts and shortcuts.request(self) + local auxiliary = MenuAuxiliary.request(self) self:tickFx() -- one sample a second into the fight, while it is still on screen self._stadiumReportTicks = (self._stadiumReportTicks or 0) + 1 if self._stadiumReportTicks == 60 then self:reportStadiumBattle("mid") end -- Crystal pic animations run off the wall clock like every other battle -- pic effect; a Gold/Silver battler has no picAnim and this is a no-op. - for _, b in activeBattlers(self) do - if b and b.picAnim then b.picAnim:update(dt) end - if b and b.monAnim then b.monAnim:update(dt) end - end + self:updateBattlerPresentation(dt) local input = self.game.input -- L PUTS THE BOTTOM SCREEN AWAY, AND BRINGS IT BACK. @@ -4440,7 +4513,7 @@ function BattleState:update(dt) if self.palPark then require("src.battle.Gen4PalParkBattle").skipRow(self, dir) end end if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4466,12 +4539,13 @@ 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 if self.phase == "menu" then + if quick and shortcuts.dispatch(self,quick)then return end -- WHO THIS MENU IS FOR, when the left flank is gone. -- -- `self.player` is the left flank, and in a double battle that flank can @@ -4507,6 +4581,9 @@ function BattleState:update(dt) self:resolveTurn(locked) return end + -- Capture eligibility before presentation catch-up can hide an unfinished + -- HP/status drain. Dispatch only at the ordinary player-decision boundary. + if auxiliary and MenuAuxiliary.dispatch(self, auxiliary) then return end -- PLATINUM'S ACTION MENU IS NOT A 2x2, and walking it as one is what a -- player feels as the cursor jumping about. -- @@ -4546,60 +4623,10 @@ function BattleState:update(dt) -- asked (fainted, or locked into a move it cannot choose). if input:wasPressed("b") and self:isDouble() and self:choosingSlotNow() == BattleState.POS.PLAYER_RIGHT then - local left = self:battlerAt(BattleState.POS.PLAYER_LEFT) - local pend = self.pendingActions and self.pendingActions[BattleState.POS.PLAYER_LEFT] - if left and not left.ally and (left.mon and left.mon.hp or 0) > 0 and pend - and not self:menuLockedAction(left) then - self.pendingActions[BattleState.POS.PLAYER_LEFT] = nil - if self.chosenTargets then self.chosenTargets[BattleState.POS.PLAYER_LEFT] = nil end - self.choosingSlot = BattleState.POS.PLAYER_LEFT - self.menuIndex, self.moveIndex = 1, 1 - pcall(function() require("src.core.Sound").play(self.game.data, "SEQ_SE_DP_DECIDE") end) - return - end + if self:cancelMove() then return end end if input:wasPressed("a") then - local choice = self:menuActions()[self.menuIndex] - -- BattleMenu_Pack `.contest` (core.asm:4990): `ld a, PARK_BALL / - -- ld [wCurItem], a / call DoItemEffect`. No pack, no submenu -- the - -- third slot IS the ball, so the item action becomes the throw. - if choice == "item" and self.bugContest then - self:bugContestBall() - return - end - if choice == "fight" and self.ghost then - self:say(Strings("%s is too\nscared to move!", self.player.name)) - self.phase = "messages" - self.afterQueue = "menu" - self:act(function() - self:executeAction(self.enemy, self.player, self:enemyAction()) - end) - self:act(function() self:endOfTurn() end) - elseif choice == "fight" then - -- After the menu: own trapping/Bide or foe Wrap skips the move - -- list and forces the locked action (core.asm:320-329) - local fightLock = self:fightLockedAction(self.player) - if fightLock then - self:resolveTurn(fightLock) - return - end - local who = self:menuBattler() - if not self:playerHasPP(who) then - -- _NoMovesLeftText, then Struggle engages - self:say(Strings("%s has no\nmoves left!", who.name)) - self:chooseAction({ id = "STRUGGLE", pp = 1, struggle = true }) - return - end - self.phase = "moveSelect" - self.moveIndex = math.min(self.moveIndex, #who.curMoves) - self.moveSwapIndex = nil - elseif choice == "run" then - self:tryRun() - elseif choice == "item" then - self:openItems() - else - self:openParty() - end + self:chooseMenu(self:menuActions()[self.menuIndex]) end return end @@ -4625,63 +4652,14 @@ function BattleState:update(dt) self.moveSwapIndex = self.moveIndex end elseif input:wasPressed("b") then - self.moveSwapIndex = nil - self.phase = "menu" + self:cancelMove() elseif input:wasPressed("a") then if self.moveSwapIndex then self:swapMoves(self.moveSwapIndex, self.moveIndex) self.moveSwapIndex = nil return end - local mv = moves[self.moveIndex] - local mvDef = mv and self.data.moves[mv.id] - if chooser.disabledSlot == self.moveIndex then - self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!")) - self.phase = "messages" - self.afterQueue = "menu" - -- TAUNT and TORMENT refuse the move at SELECTION rather than after the - -- fact, the way DISABLE already does, so the player is never told to - -- pick again for something the game was never going to allow. - elseif self.bw and require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true) then - -- BLACK AND WHITE: the Choice lock, Imprison, Gravity, Heal Block - self:say(require("src.battle.Gen5MoveRules").moveBlocked(self, chooser, mvDef, true)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tauntBlocks(chooser, mvDef) then - self:say(Strings("%s can't use\n%s after the\ntaunt!", - chooser.name, mvDef and mvDef.name or mv.id)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self:tormentBlocks(chooser, mvDef) then - self:say(Strings("%s can't use the\nsame move twice in\na row due to the\nTORMENT!", - chooser.name)) - self.phase = "messages" - self.afterQueue = "menu" - elseif self.g4fx and require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef) then - -- PLATINUM / HGSS: Gravity and Heal Block (BattleSystem_CanUseMove) - self:say(require("src.battle.Gen4MoveEffects").menuRefusal(self, chooser, mvDef)) - self.phase = "messages" - self.afterQueue = "menu" - elseif mv.pp <= 0 then - self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!")) - self.phase = "messages" - self.afterQueue = "menu" - elseif Targeting.needsChoice(self, self:choosingBattler(), mvDef) then - -- WHICH OF THE TWO, when the move takes one and there are two. - -- - -- Emerald's selector cycles over the legal slots and names the - -- candidate; nothing else in the menu changes. A single battle - -- never gets here, because needsChoice answers false whenever there - -- is only one thing to aim at -- and on the Game Boy cartridges the - -- moves carry no target byte at all, so it answers false for every - -- move in the game. - self.pendingMove = mv - self.targetChoices = Targeting.choices(self, self:menuBattler()) - self.targetIndex = 1 - self.phase = "targetSelect" - else - self:chooseAction(mv) - end + self:chooseMove(self.moveIndex) end return end @@ -4699,16 +4677,9 @@ function BattleState:update(dt) elseif input:wasPressed("right") or input:wasPressed("down") then self.targetIndex = self.targetIndex < #list and self.targetIndex + 1 or 1 elseif input:wasPressed("b") then - self.pendingMove, self.targetChoices = nil, nil - self.phase = "moveSelect" + self:cancelMove() elseif input:wasPressed("a") then - local mv = self.pendingMove - local pick = list[self.targetIndex] - self.pendingMove, self.targetChoices = nil, nil - self.chosenTargets = self.chosenTargets or {} - local who = self:menuBattler() - if who and who.position then self.chosenTargets[who.position] = pick end - self:chooseAction(mv) + self:chooseTarget(self.targetIndex) end return end @@ -4729,12 +4700,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 @@ -8317,7 +8283,7 @@ function BattleState:performMove(user, target, moveInst, isCalled, suppressAnima -- straight past `charge` while the sun is up and fires on the turn it was -- selected. Every other charge move ignores the weather. if record and record.charge and not releasing and not skipCharge then - local branchAware = GameVersion.get() == "firered" + local branchAware = GameVersion.isFRLG() and hasMoveTurnVariant(move.anim) if not branchAware then self:cancelMoveAnim() end user.charging = moveInst @@ -12516,7 +12482,16 @@ function BattleState:drawHUDs(slide) love.graphics.translate(hudShake, 0) end love.graphics.setColor(0, 0, 0, 1) - Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) + local enemyNameX = nameX(1, self.enemy.name) + local enemyNameWidth = Font.draw(self.enemy.name, enemyNameX, 0) + if self:caughtMarkerVisible() then + if require("src.core.GameVersion").isGen2() then + hudTile(0x5d, 8, 8) + else + self:drawCaughtBall(enemyNameX + enemyNameWidth, 0) + end + love.graphics.setColor(0, 0, 0, 1) + end if self.enemy.shownStatus then Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) else @@ -12963,4 +12938,57 @@ function BattleState:drawClassic() end end +-- Source wrappers observe the real GBA owner. Unrequested modules and other +-- generations keep the direct native path; there is no additional clock. +do + local update, draw, field, exit, enter = BattleState.update, BattleState.draw, + BattleState.drawBattleField, BattleState.exit, BattleState.enter + local Clock = require('src.battle.PresentationClock') + for _, name in ipairs{'tickFx','updateBattlerPresentation','updateMovePresentation', + 'stepHPDrain','stepExpBar','stepPresentationWait','updateQueuePresentation'} do + local body = BattleState[name] + BattleState[name] = function(self, ...) + return Clock.once(self, name, body, ...) + end + end + BattleState._drawNative = draw + local battlerDraw = BattleState.drawBattlerPic + function BattleState:drawBattlerPic(battler, ...) + local anim = GameVersion.isGen3() and package.loaded['src.core.game3.battle.anim'] + if anim then return anim._drawBattler(self, battler, battlerDraw, ...) end + return battlerDraw(self, battler, ...) + end + function BattleState:enter() + enter(self) + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeEnter(self) end + end + function BattleState:update(dt) + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + local anim = source and package.loaded['src.core.game3.battle.anim'] + if anim then + return Clock.run(self, dt, function() + return source._nativeUpdate(self, dt, update) + end) + end + if source then return source._nativeUpdate(self, dt, update) end + return update(self, dt) + end + function BattleState:draw() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeDraw(self, draw) end + return draw(self) + end + function BattleState:drawBattleField() + local source = GameVersion.isGen3() and package.loaded['src.core.game3.battle'] + if source then return source._nativeField(self, field) end + return field(self) + end + function BattleState:exit() + local source = package.loaded['src.core.game3.battle'] + if source then source._nativeExit(self) end + return exit(self) + end +end + return BattleState diff --git a/src/battle/CaughtMarker.lua b/src/battle/CaughtMarker.lua new file mode 100644 index 000000000..b76d76305 --- /dev/null +++ b/src/battle/CaughtMarker.lua @@ -0,0 +1,82 @@ +-- Eligibility and strict boolean verdict shared by the native GB/GBA renderers. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local M = {} +function M.caught(game, species) + local dex = game and game.save and game.save.pokedex + return not not (dex and ((dex.owned and dex.owned[species]) or (dex.caught and dex.caught[species]))) +end +function M.visible(battle, battler) + local gen = Version.generation() + battler = battler or battle.enemy + if + gen > 3 + or not battler + or battler.isPlayer + or not battler.mon + or (battle.kind ~= "wild" and battle.kind ~= "safari") + or not M.caught(battle.game, battler.mon.species) + then + return false + end + -- Gold latches the already-caught bit when the encounter starts, then checks + -- the active species again when drawing (e.g. after Transform). + if gen == 2 and not battle.caughtMark then + return false + end + if + gen == 3 + and ( + battle.demo + or battle.dudeDemo + or battle.pokedudeDemo + or battle.firstBattle + or (battle.ghost and not battle.ghostUnveiled) + ) + then + return false + end + local default = gen > 1 + if not Runtime.wantsHook("battle.caught_marker_visible") then + return default + end + return Runtime.call("battle.caught_marker_visible", function() + return default + end, battle) == true +end +-- Legacy standalone compositors can carry a partial battle fixture. +function M.forBattle(battle, battler) + local method = battle.caughtMarkerVisible + if type(method) == "function" then + return method(battle, battler) + end + return M.visible(battle, battler) +end +-- The GBA renderer creates this element for a presented healthbox and retains +-- its verdict until that presentation changes, as in the reference engine. +function M.gbaVisible(battle, battler) + if not battler or battler.shownStatus or (battle.isDouble and battle:isDouble()) then + return false + end + local old = battler._caughtIcon + local species = battler.mon and battler.mon.species + if + old + and old.mon == battler.mon + and old.species == species + and old.name == battler.name + and old.status == battler.shownStatus + then + return old.show + end + local show = M.visible(battle, battler) + battler._caughtIcon = { + mon = battler.mon, + species = species, + name = battler.name, + status = battler.shownStatus, + show = show, + } + return show +end +return M diff --git a/src/battle/Experience.lua b/src/battle/Experience.lua index 2c0dc6401..701c78196 100644 --- a/src/battle/Experience.lua +++ b/src/battle/Experience.lua @@ -122,6 +122,15 @@ function Experience.apply(data, mon, defeatedDef, level, isTrainer, end -- Lucky Egg is a per-recipient x1.5 held-item boost in Generation II. gained = HeldItems.modifyExperience(data, mon, gained) + return Experience.add(data, mon, gained) +end + +-- Shared absolute award: scripted rewards use native growth, stats and events. +-- Defeat-only EVs, held boosts and exp.gain remain in apply above. +function Experience.add(data, mon, gained) + assert(type(gained)=='number'and gained==gained and gained>=0 and gained%1==0,'invalid experience award') + local speciesDef=require('src.pokemon.Gen4Forms').definition(data,mon) + local consts=data.constants mon.exp = mon.exp + gained local cap = consts and consts.levelCap or 100 diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index dcaa62f71..0e1302c91 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -493,7 +493,7 @@ function Gen3Battle.terrainFor(game) local cy = player and (player.cellY or player.y) local behaviour = Gen3Battle.behaviourUnder(game) - if GameVersion.get() == "firered" then + if GameVersion.isFRLG() then -- BattleSetup_GetTerrainId, in source order. if behaviour == "TALL_GRASS" or behaviour == "CYCLING_ROAD_PULL_DOWN_GRASS" then @@ -900,7 +900,23 @@ local function levelAt(battle, battler, x, y, text) end end -local function drawStatusPanel(battle, battler, x, y, player) +-- Native imports carry tile 70 of the healthbox elements. Older caches use +-- the existing Pokédex marker, the same fallback as the reference renderer. +local function drawCaughtMarker(battle, battler, x, y) + if not require("src.battle.CaughtMarker").gbaVisible(battle, battler) then return end + local record = hudRecord(battle) + local path = record and record.images and record.images.caught + local function exists(p) return p and Assets.exists(Assets.resolve(p)) end + if not exists(path) then + path = require("src.core.GameVersion").isFRLG() + and "assets/generated/pokedex_frlg/caught.png" or "assets/generated/ui/pokedex/caught_ball.png" + end + if not exists(path) then return end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(Assets.image(path), x + 8, y + 16) +end + +local function drawStatusPanelBody(battle, battler, x, y, player) local record = hudRecord(battle) local images = record and record.images or {} local panel = hudImage(player and images.player or images.opponent) @@ -913,6 +929,7 @@ local function drawStatusPanel(battle, battler, x, y, player) Font.drawBox(tx, ty, tw, th) love.graphics.setColor(0, 0, 0, 1) Font.draw(fitName(battler.name, 56), x + 8, y + 8) + if not player then drawCaughtMarker(battle, battler, x, y) end levelAt(battle, battler, x + tw * 8 - 40, y + 8) if player then Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp), @@ -923,6 +940,7 @@ local function drawStatusPanel(battle, battler, x, y, player) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(panel, x, y) + if not player then drawCaughtMarker(battle, battler, x, y) end -- THE SMALL FACE, which is what the cartridge sets a healthbox in. -- @@ -1204,6 +1222,16 @@ end -- -- AND THE FOE'S ROW IS A TRAINER'S ONLY. A wild battle has no party to show, -- so BattleIntroDrawPartySummaryScreens raises the player's alone. +-- Expose the same native painter to source healthbox captures. +Gen3Battle.drawStatusPanel=drawStatusPanelBody +local function drawStatusPanel(battle,battler,x,y,player) + local source=package.loaded['src.core.game3.battle.healthbox'] + if source then return source._nativeDraw(battle,battler,x,y,player,function() + return drawStatusPanelBody(battle,battler,x,y,player) + end)end + return drawStatusPanelBody(battle,battler,x,y,player) +end + local function summaryRecord(battle) local record = hudRecord(battle) local summary = record and record.partySummary @@ -1439,6 +1467,26 @@ local function healthboxAt(battle, key, panel, fx, fy) return Gen3Battle.hudPlace(constants, which, h, Gen3Battle.extra(battle)) end +-- Geometry comes from the same panel images and placement records as HUDs. +function Gen3Battle.healthboxGeometry(battle,id) + local player=id%2==0 + local record=hudRecord(battle);local images=record and record.images or{} + local key=player and 'player' or 'opponent' + if id>=2 then key=key..'Doubles' end + local image=hudImage(images[key]or images[player and 'player'or'opponent']) + local h=image and image:getHeight()or 32 + if battle:isDouble()then + local keys={[0]='playerLeft','opponentLeft','playerRight','opponentRight'} + local c=battle.data.constants.gen3BattlerCoords + local pos=c and c.healthbox and c.healthbox[keys[id]] + if not pos then return nil,'native double healthbox placement unavailable'end + return pos.x-32+(player and Gen3Battle.extra(battle)or 0),pos.y-math.floor(h/2),h + end + if id>=2 then return nil,'absent single-battle slot'end + local x,y=healthboxAt(battle,player and 'singlesPlayer'or'singlesOpponent',image) + return x,y,h +end + local function drawHUDs(battle, slide) local record = hudRecord(battle) local images = record and record.images or {} @@ -1781,7 +1829,7 @@ end -- Draws the panel and answers whether it did, so a caller can fall back to -- its own drawn box on a cache that has none. -local function drawPanel(battle, which) +local function drawPanelBody(battle, which) local img, record = panelImage(battle, which) if not img then return false end love.graphics.setColor(1, 1, 1, 1) @@ -1792,6 +1840,14 @@ local function drawPanel(battle, which) return true end +-- Source panel wrappers run inside this existing native drawing phase. +Gen3Battle.drawPanel=drawPanelBody +local function drawPanel(battle,which) + local source=package.loaded['src.ui.game3.battle_chrome'] + if source then return source._panel(battle,which,function()return drawPanelBody(battle,which)end)end + return drawPanelBody(battle,which) +end + -- THE TEXT IS PRINTED TWICE. Every string in a Gen 3 battle is a foreground -- pass over a shadow pass one pixel down and right; the shadow is what makes -- white legible on teal, and dropping it is most of why black-on-white was @@ -1807,6 +1863,8 @@ local function paintPair(battle, key) return norm(row.foreground), norm(row.shadow) end +Gen3Battle.textColors=paintPair + -- ...ONCE, THROUGH THE PAGE'S OWN TWO TONES. -- -- Reported from play: "the textbox in battle doesnt seem to be using the @@ -1963,7 +2021,11 @@ local function drawCommandMenu(battle) local prompt = record and record.prompt local chooser = (battle.menuBattler and battle:menuBattler()) or battle.player local who = chooser and chooser.name or "" - if prompt and prompt[1] and prompt[2] then + local quick=package.loaded['src.mods.FeatureOptions']and require('src.battle.QuickItem').label(battle) + if quick then + Font.draw(fitName(quick,88),w.prompt.textX+dx,w.prompt.row1+dy) + Font.draw("L: NEXT R: THROW",w.prompt.textX+dx,w.prompt.row2+dy) + elseif prompt and prompt[1] and prompt[2] then Font.draw(prompt[1], w.prompt.textX + dx, w.prompt.row1 + dy) local second = tostring(prompt[2]):gsub("{MON}", fitName(who, 88)) Font.draw(second, w.prompt.textX + dx, w.prompt.row2 + dy) diff --git a/src/battle/Gen3MoveAnim.lua b/src/battle/Gen3MoveAnim.lua index f6a3a09ea..ab1700be9 100644 --- a/src/battle/Gen3MoveAnim.lua +++ b/src/battle/Gen3MoveAnim.lua @@ -754,6 +754,7 @@ function Gen3MoveAnim:start(moveId, attackerIsPlayer, attackerPosition, targetPo branchCtx, battle) local anim = self:recordFor(moveId, branchCtx) if not hasVisual(anim) then return false end + self.presentationSerial = (self.presentationSerial or 0) + 1 self.events = anim.events or {} self.shakes = anim.shakes self.blends = anim.blends @@ -4243,7 +4244,14 @@ local function drawHardwareAlphaImage(g, image, blend, ...) end Gen3MoveAnim.drawHardwareAlphaImage = drawHardwareAlphaImage -function Gen3MoveAnim:drawEvent(event, battle, Gen3Battle, Assets, pose, +function Gen3MoveAnim:drawEvent(event,battle,Gen3Battle,Assets,pose,allowSplitBlend) + local source=package.loaded['src.core.game3.battle.anim_sprites'] + if source then return source._drawEvent(self,event,battle,function(...) + return self:drawEventBody(...) + end,event,battle,Gen3Battle,Assets,pose,allowSplitBlend)end + return self:drawEventBody(event,battle,Gen3Battle,Assets,pose,allowSplitBlend) +end +function Gen3MoveAnim:drawEventBody(event, battle, Gen3Battle, Assets, pose, allowSplitBlend) pose = pose or self:eventPose(event, battle, Gen3Battle) if not pose then return false end diff --git a/src/battle/Gen4MoveEffects.lua b/src/battle/Gen4MoveEffects.lua index 2bd10da81..4f1447ee2 100644 --- a/src/battle/Gen4MoveEffects.lua +++ b/src/battle/Gen4MoveEffects.lua @@ -509,9 +509,13 @@ F.REMOVE_PROTECT = { local FLAVOR_STAT = { [11] = "attack", [12] = "spatk", [13] = "speed", [14] = "spdef", [15] = "defense" } local STAT_UP = { [16] = "attack", [17] = "defense", [18] = "speed", [19] = "spatk", [20] = "spdef" } local CURE = { [1] = "PAR", [2] = "SLP", [3] = "PSN", [4] = "BRN", [5] = "FRZ" } -local CURE_TEXT = { PAR = "%s was\ncured of paralysis!", SLP = "%s woke up!", - PSN = "%s was\ncured of poisoning!", BRN = "%s's\nburn was healed!", - FRZ = "%s thawed out!" } +local CURE_TEXT = { + PAR = Strings.source("%s was\ncured of paralysis!"), + SLP = Strings.source("%s woke up!"), + PSN = Strings.source("%s was\ncured of poisoning!"), + BRN = Strings.source("%s's\nburn was healed!"), + FRZ = Strings.source("%s thawed out!"), +} local function eat(battle, eater, effect, param, def, out) local max = maxHp(eater) diff --git a/src/battle/MenuAuxiliary.lua b/src/battle/MenuAuxiliary.lua new file mode 100644 index 000000000..2364a1c7d --- /dev/null +++ b/src/battle/MenuAuxiliary.lua @@ -0,0 +1,163 @@ +-- Public START action at an idle player command menu, before native navigation. +local Runtime = require("src.mods.Runtime") +local M = {} +local BUSY = { + "current", + "nextInsert", + "pendingHit", + "waitingUI", + "waitingSound", + "draining", + "animPlaying", + "growIn", + "shrinkOut", + "ghostReveal", + "mimicCtx", + "result", + "gen3BallPlaying", + "gen4TouchPress", +} +local COUNTERS = { "waitFrames", "hitSfxWait", "introSlide" } +local VARIANTS = { + "safari", + "ghost", + "scopeReveal", + "demo", + "pokedudeDemo", + "noCatch", + "palace", + "arena", + "ally", + "factory", + "pyramid", + "pike", + "battleTower", + "trainerTower", + "secretBase", + "eReader", +} +local function nonempty(value) + return type(value) == "table" and next(value) ~= nil +end +local function defaultAction() + return false +end +function M.ready(battle) + local game = battle and battle.game + if + not game + or not game.save + or not game.data + or battle.phase ~= "menu" + or (battle.kind ~= "wild" and battle.kind ~= "trainer") + then + return false + end + local stack = game.stack + if not stack or not stack.top or stack:top() ~= battle then + return false + end + if + nonempty(battle.queue) + or nonempty(battle.pendingActions) + or nonempty(battle.chosenTargets) + or (battle.afterQueue and battle.afterQueue ~= "menu") + then + return false + end + for _, field in ipairs(VARIANTS) do + if battle[field] then + return false + end + end + for _, field in ipairs(BUSY) do + if battle[field] then + return false + end + end + for _, field in ipairs(COUNTERS) do + local value = battle[field] + if value and value ~= 0 then + return false + end + end + if battle.showEnemyTrainer or battle.showPlayerBack or battle.enemySendingOut then + return false + end + local actor = battle.choosingBattler and battle:choosingBattler() or battle.player + if + not actor + or actor.ally + or not actor.mon + or (actor.mon.hp or 0) <= 0 + or (battle.menuLockedAction and battle:menuLockedAction(actor)) + or actor.bwSkyDropped + then + return false + end + local count = battle.isDouble and battle:isDouble() and 4 or 2 + local enemyStanding = false + for position = 0, count - 1 do + local battler = battle.battlerAt and battle:battlerAt(position) + or (position == 0 and battle.player or position == 1 and battle.enemy) + if battler and battler.mon then + local mon = battler.mon + if + (mon.hp or 0) <= 0 + or battler.fainted + or battler.faintQueued + or battler.draining + or battler.drainFloor ~= nil + or battler.drainHold ~= nil + or battler.shownHP ~= mon.hp + or battler.shownStatus ~= mon.status + then + return false + end + if position % 2 == 1 then + enemyStanding = true + end + elseif count == 2 then + return false + end + end + return enemyStanding +end +function M.request(battle) + if not Runtime.wantsHook("battle.menu_auxiliary") then + return + end + local game = battle.game + if not game or not game.input or not game.input:wasPressed("start") or not M.ready(battle) then + return + end + return { + game = game, + save = game.save, + data = game.data, + stack = game.stack, + session = game.sessionStartedAt, + kind = battle.kind, + } +end +local function current(battle, token) + local game = token.game + return battle.game == game + and game.save == token.save + and game.data == token.data + and game.stack == token.stack + and game.sessionStartedAt == token.session + and battle.kind == token.kind + and M.ready(battle) +end +function M.dispatch(battle, token) + -- Retire input captured for an owner/session that changed during native FX. + if not current(battle, token) then + return true + end + local result = Runtime.call("battle.menu_auxiliary", defaultAction, token.game, { kind = token.kind }) + -- A pushed overlay or changed session must never receive the same input as + -- the retired battle menu, even if the wrapper returned false or raised. + return result == true or not current(battle, token) +end +return M diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 5772141ae..d22511a19 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -386,7 +386,7 @@ MoveEffects.primary = { -- mosaic and HandleSpeciesGfxDataChange performs it at callback 46. -- Gen3MoveAnim owns that visual midpoint, so changing the FireRed sprite -- here would expose the copied species for the first 45 callbacks. - if GameVersion.get() ~= "firered" then + if not GameVersion.isFRLG() then user.sprite = battle:speciesSprite(target.mon.species, user.isPlayer) or user.sprite -- Black / White draw a live animation over `sprite`, so their picture diff --git a/src/battle/PresentationClock.lua b/src/battle/PresentationClock.lua new file mode 100644 index 000000000..00d8d13a1 --- /dev/null +++ b/src/battle/PresentationClock.lua @@ -0,0 +1,34 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Presentation holds borrow a native update; they never drain its command +-- queue. Results (including failures and nils) are shared within that update. +local M={} +local phases=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function M.active(screen)return phases[screen]~=nil end +function M.delta(screen)return phases[screen]and phases[screen].delta end +function M.once(screen,key,body,...) + local phase=phases[screen] + if not phase then return body(screen,...)end + local entry=phase.steps[key] + if not entry then + entry={};phase.steps[key]=entry + entry.result=pack(pcall(body,screen,...)) + end + local r=entry.result + if not r then return nil,'presentation step is already executing'end + if not r[1]then error(r[2],0)end + return unpack(r,2,r.n) +end +function M.run(screen,dt,body,...) + if phases[screen]then return body(...)end + local phase={steps={},delta=dt};phases[screen]=phase + local result=pack(pcall(body,...));phases[screen]=nil + -- A wrapper cannot swallow a failed display step and retry that step. + for _,entry in pairs(phase.steps)do + if entry.result and not entry.result[1]then error(entry.result[2],0)end + end + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +return M diff --git a/src/battle/QuickItem.lua b/src/battle/QuickItem.lua new file mode 100644 index 000000000..78200d40a --- /dev/null +++ b/src/battle/QuickItem.lua @@ -0,0 +1,49 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional ball shortcuts enter the ordinary native bag/catch transaction. +local Ready=require('src.battle.MenuAuxiliary') +local Effects=require('src.inventory.ItemEffects') +local Q={} +local function available(battle) + local features=package.loaded['src.mods.FeatureOptions'] + return features and features.enabled('quickBall')and require('src.core.GameVersion').isGen3() + and battle.kind=='wild'and not battle:isDouble()and Ready.ready(battle) +end +local function balls(battle) + local out={} + for key,n in pairs(battle.game.save.inventory or{})do + local def=battle.data.items[key] + if n>0 and Effects.isBall(key,def)then out[#out+1]=key end + end + table.sort(out,function(a,b) + local x,y=battle.data.items[a],battle.data.items[b] + local i,j=x and x.index or 65535,y and y.index or 65535 + if i~=j then return i=0 and n<=max and n%1==0 end +function M.validate(data,record) + if type(record)~='table'then return nil,'encounter record required'end + for key in pairs(record)do if not fields[key]then return nil,'unsupported encounter field: '..tostring(key)end end + if not(data.pokemon and data.pokemon[record.species])then return nil,'unknown native species'end + if not(uint(record.level,100)and record.level>=1)then return nil,'invalid encounter level'end + local out={species=record.species,level=record.level} + if record.personality~=nil then + if not uint(record.personality,4294967295)then return nil,'invalid encounter personality'end + out.personality=record.personality + end + if record.ivs~=nil then + if type(record.ivs)~='table'then return nil,'invalid encounter IVs'end + out.ivs={} + for _,key in ipairs(stats)do + if not uint(record.ivs[key],31)then return nil,'invalid encounter IV '..key end + out.ivs[key]=record.ivs[key] + end + for key in pairs(record.ivs)do if out.ivs[key]==nil then return nil,'unknown encounter IV '..tostring(key)end end + end + if record.item~=nil then + if record.item~=false and not(data.items and data.items[record.item])then return nil,'unknown encounter item'end + out.item=record.item + end + if record.hp~=nil then + if not(uint(record.hp,65535)and record.hp>0)then return nil,'invalid encounter HP'end + out.hp=record.hp + end + if record.status~=nil then + if not uint(record.status,255)or record.status>7 and not({[8]=true,[16]=true,[32]=true,[64]=true,[128]=true})[record.status]then + return nil,'invalid encounter status' + end + out.status=record.status + end + if record.moves~=nil then + if type(record.moves)~='table'or#record.moves<1 or#record.moves>4 then return nil,'invalid encounter moves'end + out.moves={} + for i,row in ipairs(record.moves)do + local id=type(row)=='table'and row.id or row + local def=data.moves and data.moves[id] + if not def then return nil,'unknown encounter move'end + local pp=type(row)=='table'and row.pp or def.pp + if not(uint(pp,255)and pp<=(def.pp or 0))then return nil,'invalid encounter PP'end + out.moves[i]={id=id,pp=pp} + end + for i in pairs(record.moves)do + if not uint(i,4)or i<1 or i>#out.moves then return nil,'invalid encounter move slot'end + end + end + if record.roamer~=nil and type(record.roamer)~='boolean'then return nil,'invalid roaming encounter'end + out.roamer=record.roamer + return out +end +function M.apply(data,mon,record) + if record.personality~=nil or record.ivs then + require('src.pokemon.Pokemon').applySeed(data,mon,{personality=record.personality,ivs=record.ivs}) + end + if record.item~=nil then mon.item=record.item or nil end + if record.moves then mon.moves=record.moves end + if record.hp then mon.hp=math.min(record.hp,mon.stats.hp)end + if record.status~=nil then + local bits=record.status + mon.status=bits>0 and(bits<=7 and'SLP'or({[8]='PSN',[16]='BRN',[32]='FRZ',[64]='PAR',[128]='PSN'})[bits])or nil + mon.sleepTurns=bits>0 and bits<=7 and bits or nil;mon.toxic=bits==128 or nil + end +end +return M diff --git a/src/compat/game3/Follower.lua b/src/compat/game3/Follower.lua new file mode 100644 index 000000000..1bc30375c --- /dev/null +++ b/src/compat/game3/Follower.lua @@ -0,0 +1,30 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Native=require('src.world.OptionalFollower') +local F={setShouldSpawn=Native.setShouldSpawn} +function F.current(world) + local game=package.loaded['src.core.Game'] + world=world or game and game.overworld + local npc=world and Native.current(world)or nil + if npc and require('src.core.GameVersion').isGen3()then return require('src.core.game3.objects').find(npc.def.index)end + return npc +end +function F.actor() + local npc=F.current();if not(npc and npc.visible~=false and not npc.hidden and npc.sprite)then return nil end + return{kind='follower',i=npc.localId,x=npc.px,y=npc.py,sortY=npc.py,elevation=npc.currentElevation, + facing=npc.facing,walkPhase=npc.moving and 1 or 0,renderer=npc.sprite} +end +function F.reset() + local game,world=require('src.compat.gen1.NativeSession').resolve() + if not game then return false end + return Native.reset(world) +end +function F.at(_,x,y) + local npc=F.current();if npc and not npc.moving and npc.cellX==x and npc.cellY==y then return npc end +end +function F.update()return require('src.compat.gen1.ScriptBoundary').invoke('follower.update')end +function F._dispatch(game,world,body) + return require('src.compat.gen1.ScriptBoundary').dispatch('follower.update',body,function(...) + return require('src.compat.gen1.SourceCall').call(F,'update',...) + end,require('src.core.Game3').forNative(game)) +end +return F diff --git a/src/compat/gen1/AtlasPixels.lua b/src/compat/gen1/AtlasPixels.lua new file mode 100644 index 000000000..54f8cec0e --- /dev/null +++ b/src/compat/gen1/AtlasPixels.lua @@ -0,0 +1,235 @@ +-- A source-format pixel projection over the native bank decoder. No ROM/cache +-- conversion, simulation, graphics allocation or independent clock lives here. +local Tiles=require('src.render.Gen3Tiles') +local M={COLS=16} +local floor,concat=math.floor,table.concat +local component5={} +for n=0,31 do + local value=floor(n*255/31) + component5[value]=n +end +local function integer(v,hi)return type(v)=='number'and v>=0 and v<=hi and v%1==0 end +local function bytes(values) + local out={};for i=1,#values do out[i]=string.char(values[i])end;return concat(out) +end +local function le16(n)return string.char(n%256,floor(n/256)%256)end +local function word(data,index) + if type(data)~='string'then return nil end + local lo,hi=data:byte(index*2+1,index*2+2) + if hi then return lo+hi*256 end +end +local function matches(value,rgb) + return value~=nil and rgb and floor((value%32)*255/31)==rgb[1] + and floor((floor(value/32)%32)*255/31)==rgb[2] + and floor((floor(value/1024)%32)*255/31)==rgb[3] +end +function M.colors(words) + local out={} + for i=1,16 do + local word=words[i]or 0;assert(integer(word,65535),'invalid palette word') + out[i]={floor((word%32)*255/31+.5),floor((floor(word/32)%32)*255/31+.5),floor((floor(word/1024)%32)*255/31+.5)} + end + return out +end +function M.new(pair,layout,checkpoint) + for _,bank in ipairs({pair.primary,pair.secondary})do + assert(type(bank)=='table'and integer(bank.metatileCount,1024) + and type(bank.metatiles)=='string'and#bank.metatiles>=bank.metatileCount*16 + and type(bank.attributes)=='string'and#bank.attributes>=bank.metatileCount*2 + and type(bank.tiles)=='string','native atlas bank data unavailable') + for slot=1,16 do + local palette=bank.palettes and bank.palettes[slot] + assert(type(palette)=='table','native atlas palette data unavailable') + for color=1,16 do + local rgb=palette[color];assert(type(rgb)=='table','native atlas palette data unavailable') + for channel=1,3 do assert(integer(rgb[channel],255),'invalid native atlas colour')end + end + end + end + local decoder=Tiles.new(pair,layout) + local self={decoder=decoder,mids={},midToSlot={},indexed={},under={},over={},paletteOverrides={},revision=0,checkpoint=checkpoint, + background='\0\0\0\255',paletteUsers={},slotPalettes={},indexRevision=0} + local usedTiles={} + local function add(mid) + local slot=#self.mids;self.mids[slot+1]=mid;self.midToSlot[mid]=slot + for _,entry in ipairs(decoder:entries(mid))do + local set=usedTiles[entry.tile]or{};usedTiles[entry.tile]=set;set[slot]=true + end + if checkpoint and slot%16==15 then checkpoint('index')end + end + local primaryCount=math.min(pair.primary.metatileCount or 0,decoder.metatilesInPrimary) + for mid=0,primaryCount-1 do add(mid)end + for mid=0,(pair.secondary and pair.secondary.metatileCount or 0)-1 do add(decoder.metatilesInPrimary+mid)end + assert(#self.mids>0 and #self.mids<=1024,'invalid atlas metatile count') + self.cols,self.rows=M.COLS,math.max(1,math.ceil(#self.mids/M.COLS)) + self.width,self.height=self.cols*16,self.rows*16 + local function activeCycle(slot,color) + local bank=decoder:paletteBank(slot) + local selected=decoder.paletteState and decoder.paletteState[slot=cycle.first and at=(layout.palettesTotal or 13)and not cycle then + slot=0;record=decoder:paletteBank(0);cycle,frame=nil,nil;reservedBase=true + end + local palette=reservedBase and record.palettes[1]or decoder:palette(slot)or{} + local rgb=palette[color+1]or{0,0,0} + local original=record and word(record.sourcePaletteBytes,slot*16+color) + if cycle then + local at=slot*16+color + if cycle.kind=='frames'then original=cycle.sourceFrames and word(cycle.sourceFrames[frame+1],at-cycle.first) + else + local from=cycle.first+(at-cycle.first-cycle.direction*frame)%cycle.count + rgb=record.palettes[floor(from/16)+1][from%16+1] + original=word(record.sourcePaletteBytes,from) + if from%16==0 and floor(from/16)0 then old._pendingRetirement=true + else Upload.release(old);owned[old]=nil end + end + api._pairs[job.key]=result;owned[result]=true;jobs[job.key]=nil;job.co,job.ts=nil,nil + api._gen=api._gen+1;touch(job.key) + runtime:sweepRetired() + return result + end + function runtime:step(budget) + if not session()then return 0 end + local clock=love and love.timer and love.timer.getTime or os.clock + local deadline=clock()+(budget or .002);local count=0 + repeat + local chosen + for _,job in pairs(jobs)do + if not chosen or job.priority=deadline + return count + end + function runtime:cancel(name) + local canonical=resolve(name);local job=canonical and jobs[canonical] + if job then releaseJob(job);jobs[canonical]=nil;return true end + return false + end + function runtime:cancelPending() + for _,job in pairs(jobs)do releaseJob(job)end;jobs={} + end + function runtime:apply(ts,under,over) + local model=ts._model;if not model then return false end + local anyUnder,anyOver=next(under)~=nil,next(over)~=nil + local fullUnder=model.backgroundChanged + for _,info in ipairs({{under,false,ts.imageData,model.under},{over,true,ts.overImageData,model.over}})do + local dirty,isOver,target,rgba=info[1],info[2],info[3],info[4] + if not isOver and fullUnder then + local surface=Upload.data(model.width,model.height,model:rgba(false)) + target:paste(surface,0,0,0,0,model.width,model.height);surface:release();ts._dirtyUnder=nil + else + for slot in pairs(dirty)do Upload.patch(target,ts.cols,slot,rgba[slot],api.PORTABLE_PIXEL_WRITES);api.markDirty(ts,isOver,slot)end + end + end + if ts._indexRevision~=model.indexRevision then ts.slotPix={};ts._indexRevision=model.indexRevision end + ts.bgr=model.bgr + if anyUnder or anyOver or fullUnder then api.flush(ts,anyUnder or fullUnder,anyOver);return true end + return false + end + function runtime:sync(ts) + if not(ts and ts.image and not ts._retired)then return nil end + if ts._model then + local renderer=package.loaded['src.render.TileRenderer'] + local ticks=renderer and renderer.animFrame and renderer.animFrame()or 0 + local under,over=ts._model:update(ticks);self:apply(ts,under,over) + end + return ts + end + function runtime:get(name) + if not session()then return nil,'native GBA tileset data unavailable'end + local cached=api._pairs[name] + if cached and cached.image and not cached._stamp then touch(name);return cached end + local canonical,pair,key=resolve(name);if not canonical then return nil,pair end + cached=api._pairs[canonical] + if cached and cached.image and same(cached._stamp,key)then touch(canonical);return self:sync(cached)end + self:prefetch(canonical,0) + local job=jobs[canonical];if not job then return nil,'native atlas build unavailable'end + local result,why + repeat result,why=advance(job)until result~=false + if not result then return nil,why end + return self:sync(result) + end + function runtime:update() + if not session()then return end + for _,ts in pairs(api._pairs)do self:sync(ts)end + end + function runtime:drop(name) + local ts=api._pairs[name];if not ts then return false end + for key,value in pairs(api._pairs)do if value==ts then api._pairs[key]=nil;api._use[key]=nil end end + Upload.release(ts);owned[ts]=nil;return true + end + return runtime +end +return M diff --git a/src/compat/gen1/AtlasUpload.lua b/src/compat/gen1/AtlasUpload.lua new file mode 100644 index 000000000..e48ce5e1a --- /dev/null +++ b/src/compat/gen1/AtlasUpload.lua @@ -0,0 +1,104 @@ +-- Owned compatibility textures. Each upload buffer remembers every change +-- since its last use, including when a third buffer joins an existing pair. +local M={} +local ffiOK,ffi=pcall(require,'ffi') +local function release(object)if object and object.release then pcall(object.release,object)end end +function M.data(w,h,bytes)return love.image.newImageData(w,h,'rgba8',bytes)end +function M.image(data) + local image=love.graphics.newImage(data);image:setFilter('nearest','nearest');return image +end +function M.patch(data,cols,slot,rgba,portable) + local x,y=(slot%cols)*16,math.floor(slot/cols)*16 + if not portable and ffiOK and data.getFFIPointer and data:getFormat()=='rgba8' then + local pointer=ffi.cast('uint8_t*',data:getFFIPointer()) + if pointer~=nil then + local width=data:getWidth() + for row=0,15 do ffi.copy(pointer+((y+row)*width+x)*4,rgba:sub(row*64+1,row*64+64),64)end + return + end + end + local cell=M.data(16,16,rgba);data:paste(cell,x,y,0,0,16,16);release(cell) +end +local function retarget(old,new) + if old==new then return end + local view=package.loaded['src.core.game3.field_view'];if type(view)~='table'then return end + local seen={} + local function visit(value) + if type(value)~='table'and type(value)~='userdata'then return end + if value.getTexture and value.setTexture then + if value:getTexture()==old then value:setTexture(new)end + elseif type(value)=='table'and not seen[value]then + seen[value]=true;for _,child in pairs(value)do visit(child)end + end + end + for _,key in ipairs({'_nativeBatch','_nativeBatches','_nativeOverBatches','_nativeOverByRow','_voidFrom'})do visit(view[key])end +end +local function pool(ts,over,count) + local key=over and'overImage'or'image';local data=ts[over and'overImageData'or'imageData'] + if not(ts[key]and data)then return nil end + ts._uploadBuffers=ts._uploadBuffers or{} + local state=ts._uploadBuffers[key] + if not state then state={entries={{image=ts[key],slots={}}},cursor=1};ts._uploadBuffers[key]=state end + while #state.entries100 then return false,'invalid wild level'end + -- Resolve has already validated the cartridge key; the native plan avoids + -- interpreting it again as a portable species name. + local plan={resolve=function()return species end,construct=function(g,key) + return require('src.battle.BattleState').newWild(g,key,foe.level) + end} + return start(loader,owner,current,world,licensed,require,nil,plan)(species,foe.level,opts.onDone) + end + if opts.foe~=nil then return false,'trainer requests require the imported trainer id'end + if opts.trainerId==nil then return false,'native trainer id required'end + return start(loader,owner,current,world,licensed,require,nil, + require('src.mods.ScriptBattles').nativeTrainer)(opts.trainerId,1,opts.onDone) +end +-- Full source encounter records use the same guarded admission as portable +-- battles; numeric source IDs are converted before the native plan is built. +function R.startWild(value,encounter,opts) + opts=opts or{} + if type(opts)~='table'or type(encounter)~='table'then return nil,'invalid wild encounter request'end + for key in pairs(opts)do if key~='done'then return nil,'native wild option unavailable: '..tostring(key)end end + if opts.done~=nil and type(opts.done)~='function'then return nil,'invalid battle continuation'end + local game,why=Native.resolve(value) + if not game or game~=Native.game()then return nil,why or'foreign native game'end + local host=require('src.mods.Runtime');local facade=require('src.core.Game3') + local owner=host.currentMod or facade._callbackOwner() + local loader=game.mods;local mod=loader and loader.mods and loader.mods[owner] + if not(mod and loader.hooks and loader.hooks.wrap)then return nil,'battle request has no live mod owner'end + local data,save,edition=game.data,game.save,Version.get();local ids=Ids.new(data) + local fields={species=true,level=true,personality=true,ivs=true,item=true,hp=true,status=true,moves=true,pp=true,roamer=true} + local raw={} + for key,v in pairs(encounter)do if not fields[key]then return nil,'unsupported encounter field: '..tostring(key)end;raw[key]=v end + raw.species=ids:native('pokemon',encounter.species) + if encounter.item~=nil then + raw.item=encounter.item==0 and false or ids:native('items',encounter.item) + if raw.item==nil then return nil,'unknown cartridge held item'end + end + if encounter.ivs~=nil then + if type(encounter.ivs)~='table'then return nil,'invalid encounter IVs'end + raw.ivs={} + local names={hp='hp',atk='attack',def='defense',spe='speed',spa='spatk',spd='spdef', + attack='attack',defense='defense',speed='speed',spatk='spatk',spdef='spdef'} + for key,v in pairs(encounter.ivs)do + local target=names[key];if not target or raw.ivs[target]~=nil then return nil,'invalid encounter IV name'end + raw.ivs[target]=v + end + end + raw.pp=nil + if encounter.moves~=nil then + if type(encounter.moves)~='table'then return nil,'invalid encounter moves'end + raw.moves={} + for i,row in ipairs(encounter.moves)do + local id=type(row)=='table'and(row.id or row.move or row.moveId)or row + local native=ids:native('moves',id) + if not native then return nil,'unknown cartridge move'end + local pp=type(row)=='table'and row.pp or type(encounter.pp)=='table'and encounter.pp[i] + raw.moves[i]={id=native,pp=pp or data.moves[native].pp} + end + for i in pairs(encounter.moves)do + if type(i)~='number'or i%1~=0 or i<1 or i>#raw.moves then return nil,'invalid encounter move slot'end + end + end + local record;record,why=require('src.battle.WildRecord').validate(data,raw) + if not record then return nil,why end + local roamer + if record.roamer then + roamer=require('src.world.Gen3Roamers').active(save) + if not roamer or roamer.species~=record.species or roamer.level~=record.level + or record.personality~=nil and roamer.personality~=record.personality then return nil,'roaming individual changed'end + -- A source snapshot cannot overwrite the native roaming individual. + record.personality=roamer.personality + record.ivs=nil + record.hp=nil;record.status=nil + + end + local function licensed() + return game.mods==loader and game.data==data and game.save==save and Version.get()==edition + and loader.mods[owner]==mod and mod.enabled~=false and not mod.failed and ids:valid() + end + local plan={resolve=function()return record.species end,construct=function(g,key) + local options={wildRecord=record} + if roamer then options.roamer='gen3';options.roamerSeed=roamer;options.roamerHP=roamer.hp;options.roamerStatus=roamer.status end + local battle=require('src.battle.BattleState').newWild(g,key,record.level,options) + if save.safari then battle:makeSafari(save.safari)end + return battle + end} + return require('src.mods.Api3WildBattle').new(loader,owner,function()return Native.game()end, + function(g)return{overworld=function()return Native.world(g)end}end,licensed,require,nil,plan)(record.species,record.level,opts.done) +end + +return R diff --git a/src/compat/gen1/BattleRuntime.lua b/src/compat/gen1/BattleRuntime.lua new file mode 100644 index 000000000..4ef2fc296 --- /dev/null +++ b/src/compat/gen1/BattleRuntime.lua @@ -0,0 +1,77 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observe real native stack admissions. Completed source views keep no save, +-- controller, imported dataset or borrowed GPU resource alive. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Views=require('src.compat.gen1.BattleView') +local Ids=require('src.compat.gen1.BattleIds') +local R={};R.__index=R +local function owner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner() +end +local function cohort(c) + return c.game.data==c.data and c.game.save==c.save and c.game.mods==c.loader + and c.game.stack==c.stack and Version.get()==c.edition and c.ids:valid() +end +local function member(c) + if c.retired or not cohort(c)then return false end + for _,state in ipairs(c.stack.states or{})do if state==c.screen then return true end end + return false +end +function R.new()return setmetatable({records=setmetatable({},{__mode='kv'})},R)end +function R:retire(c,completed) + if not c or c.retired then return end + c.views:retire();c.retired=true + self.records[c.screen]=nil + if self.current==c then self.current=nil end + if completed then + self.last=c.views.view + self.lastSession=setmetatable({c.game,c.data,c.save,c.stack,c.loader},{__mode='v'}) + self.lastEdition=c.edition + end + c.game,c.screen,c.data,c.save,c.stack,c.loader,c.ids=nil,nil,nil,nil,nil,nil,nil +end +function R:bind(screen) + local game=screen and Native.game(screen.game) + if not(game and game==Native.game()and screen.isBattleState and screen.data==game.data and game.stack and game.save)then return end + local c=self.records[screen] + if c and member(c)then local who=owner();if who then c.users[who]=true end;return c end + if c then self:retire(c,false)end + c={screen=screen,game=game,data=game.data,save=game.save,stack=game.stack,loader=game.mods,edition=Version.get(),users={},ids=Ids.new(game.data)} + if not member(c)then return end + if self.current and self.current~=c then self:retire(self.current,false)end + c.views=Views.new(game,screen,function()return member(c)end,c.ids) + local who=owner();if who then c.users[who]=true end + self.records[screen]=c;self.current=c;self.last=nil + return c +end +function R:active(value) + local game=Native.game(value) + if value~=nil and game~=Native.game()then return nil end + local last=self.lastSession + if last and(not game or last[1]~=game or last[2]~=game.data or last[3]~=game.save + or last[4]~=game.stack or last[5]~=game.mods or self.lastEdition~=Version.get())then + self.last,self.lastSession,self.lastEdition=nil,nil,nil + end + if self.current and(not member(self.current)or self.current.game~=game)then self:retire(self.current,false)end + if not game then return end + for i=#(game.stack and game.stack.states or{}),1,-1 do + local state=game.stack.states[i] + if state.isBattleState and state.game==game then return self:bind(state)end + end +end +function R:exit(screen) + local c=self.records[screen] + if c then self:retire(c,cohort(c))end +end +function R:release() + if self.current then self:retire(self.current,false)end + self.last,self.lastSession,self.lastEdition=nil,nil,nil +end +function R:poll()self:active()end +function R:releaseOwner(who) + if self.current and self.current.users[who]then self:retire(self.current,false)end +end +R.member=member +return R diff --git a/src/compat/gen1/BattleView.lua b/src/compat/gen1/BattleView.lua new file mode 100644 index 000000000..3921f615b --- /dev/null +++ b/src/compat/gen1/BattleView.lua @@ -0,0 +1,234 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Live, typed source observations over one admitted native battle. Views own +-- their arrays; they never lend the native controller/save under source names. +local Ids=require('src.compat.gen1.BattleIds') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local residents=setmetatable({},{__mode="k"}) +function M.nativeMon(view) + local d=residents[view];if not d then return nil,false end + local e=d.environment + if not e.context or not e.valid()then return nil,true end + e:flush();return d.entry.raw,true,e.context.game +end +local statNames={attack='attack',defense='defense',speed='speed',spAtk='spatk',spDef='spdef',hp='hp'} +local statusBits={PSN=8,BRN=16,FRZ=32,PAR=64,TOX=128} +local function status(mon) + if mon.status=='SLP'then return math.max(1,math.min(7,tonumber(mon.sleepTurns)or 1))end + if mon.status=='PSN'and mon.toxic then return 128 end + return statusBits[mon.status]or 0 +end +local function integer(v,lo,hi)return type(v)=='number'and v==v and v%1==0 and v>=lo and v<=hi end +local monKeys={'species','nickname','level','hp','maxHp','attack','defense','speed','spAtk','spDef', + 'moves','pp','maxPp','item','status','ability','personality','friendship','isEgg','otId','otSecretId'} +local battlerKeys={'id','side','flank','mon','species','type1','type2','ability','item','status','fainted','stages','partyIndex'} +local stateKeys={'player','enemy','battlers','party','playerParty','enemyParty','turn','wild','double','over','result', + 'weather','weatherTurns','session','playerIndex','enemyIndex','trainerClass','trainerName','trainerPicId', + 'link','safari','tutorial','oldManTutorial','pokedude','firstBattle','ghostBattle','kinds','rng'} +local reserved={} +for _,key in ipairs(stateKeys)do reserved[key]=true end +function M.new(game,screen,valid,identifiers) + local e={context={game=game,screen=screen,data=game.data},entries=setmetatable({},{__mode='k'}), + caches={},arrays={},valid=valid,retired=false} + local ids=identifiers or Ids.new(game.data) + local function at(pos) + local s=e.context and e.context.screen;if not s then return end + if pos==0 then return s.player elseif pos==1 then return s.enemy end + return s.battlerAt and s:battlerAt(pos) + end + local read + local function collection(name,values,convert) + local a=e.arrays[name] + if not a then a={view={}};e.arrays[name]=a end + local out=a.view;local old=#out + for i,value in ipairs(values or{})do out[i]=convert and convert(value,i)or value end + for i=#(values or{})+1,old do out[i]=nil end + return out + end + local function proxy(kind,raw) + if not raw then return nil end + local cache=e.caches[kind];if not cache then cache=setmetatable({},{__mode='kv'});e.caches[kind]=cache end + local existing=cache[raw];if existing then return existing end + local d={kind=kind,raw=raw};local view={};cache[raw]=view;e.entries[view]=d + if kind=="mon"then residents[view]={entry=d,environment=e}end + setmetatable(view,{__index=function(_,key) + if d.snapshot then return d.snapshot[key]end + if not e.context or not e.valid()then return nil end + return read(d,key) + end,__newindex=function(t,key,value) + assert(e.context and e.valid(),'native battle view retired') + if kind=='mon'and(key=='hp'or key=='nickname'or key=='item'or key=='status')then + local mon=d.raw + if key=='hp'then assert(integer(value,0,mon.stats.hp),'invalid native HP');mon.hp=value + elseif key=='nickname'then assert(type(value)=='string'and#value<=32,'invalid native nickname');mon.nickname=value + elseif key=='item'then + local item=ids:native('items',value);assert(value==0 or item,'unknown native held item');mon.item=item + else + assert(integer(value,0,255),'invalid native status') + local inverse={[8]='PSN',[16]='BRN',[32]='FRZ',[64]='PAR',[128]='PSN'} + assert(value==0 or value<=7 or inverse[value],'unsupported combined native status') + mon.status=value>0 and(value<=7 and'SLP'or inverse[value])or nil + mon.sleepTurns=value>0 and value<=7 and value or nil;mon.toxic=value==128 or nil + end + elseif kind=='state'and not reserved[key]then + -- Presentation extensions belong to this view; they cannot replace the + -- controller's command queue, party aliases or completion result. + rawset(t,key,value) + else error('native battle '..kind..' '..tostring(key)..' is not directly writable',2)end + end}) + -- LuaJIT does not consult __pairs. Keep registry values weak so its + -- weak key cannot retain itself through this environment's public view. + local observation=setmetatable({environment=e,entry=d},{__mode='v'}) + Iteration.register(view,function(spec,source) + local environment,entry=spec.environment,spec.entry + assert(environment and entry,'native battle view retired') + local out={};for key,value in Iteration.rawPairs(source)do out[key]=value end + if entry.snapshot then for key,value in pairs(entry.snapshot)do out[key]=value end;return out end + assert(environment.context and environment.valid(),'native battle view retired') + local keys=entry.kind=='state'and stateKeys or entry.kind=='battler'and battlerKeys or entry.kind=='mon'and monKeys + or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'} + for _,key in ipairs(keys)do out[key]=environment.read(entry,key)end + return out + end,observation) + return view + end + local function partyIndex(party,mon)for i,value in ipairs(party or{})do if value==mon then return i end end end + local function party()local c=e.context;return c.screen.playerParty or c.game.save.party or{}end + local function moveArrays(mon) + local d=e.moveArrays and e.moveArrays[mon] + if not d then + e.moveArrays=e.moveArrays or{};d={moves={},pp={},maxPp={},baseline={}};e.moveArrays[mon]=d + end + -- PP edits use an explicit native transaction before the next native body. + if not d.pending then + for _,key in ipairs{'moves','pp','maxPp'}do for i in pairs(d[key])do d[key][i]=nil end end + d.baseline={};d.ids={};d.maximum={} + for i,mv in ipairs(mon.moves or{})do + d.moves[i]=ids:read('moves',mv.id);d.pp[i]=mv.pp;d.baseline[i]=mv.pp + local def=(e.context.data.moves or{})[mv.id] + d.maxPp[i]=def and def.pp and def.pp+math.floor(def.pp/5)*(mv.ppUps or 0)or nil + d.ids[i]=d.moves[i];d.maximum[i]=d.maxPp[i] + end + d.pending=true + end + return d + end + read=function(d,key) + local raw=d.raw;local c=e.context + if d.kind=='mon'then + if key=='species'then return ids:read('pokemon',raw.species)end + if key=='item'then return ids:read('items',raw.item)end + if key=='ability'then + local b;for pos=0,3 do local actual=at(pos);if actual and actual.mon==raw then b=actual;break end end + return ids:read('abilities',b and b.ability or raw.ability) + end + if key=='hp'then return raw.hp end + if key=='maxHp'then return raw.stats and raw.stats.hp end + if statNames[key]then return raw.stats and raw.stats[statNames[key]]end + if key=='status'then return status(raw)end + if key=='moves'or key=='pp'or key=='maxPp'then return moveArrays(raw)[key]end + if key=='otId'then return raw.otId or raw.otID end + if key=='otSecretId'then return raw.otSecretId or raw.secretId end + if key=='friendship'then return raw.friendship or raw.happiness end + if key=='nickname'or key=='level'or key=='personality'or key=='isEgg'then return raw[key]end + elseif d.kind=='battler'then + local pos=raw.position + if pos==nil then for i=0,3 do if at(i)==raw then pos=i;break end end end + if key=='id'then return pos end + if key=='side'then return pos and(pos%2==0 and'player'or'enemy')end + if key=='flank'then return pos and(pos<2 and'left'or'right')end + if key=='mon'then return proxy('mon',raw.mon)end + if key=='species'then return ids:read('pokemon',raw.species or raw.mon.species)end + if key=='ability'then return ids:read('abilities',raw.ability)end + if key=='item'then return ids:read('items',raw.mon.item)end + if key=='status'then return status(raw.mon)end + if key=='fainted'then return raw.fainted==true or raw.mon.hp<=0 end + if key=='partyIndex'then return partyIndex(pos and pos%2==0 and party()or c.screen.enemyParty,raw.mon)or 1 end + if key=='stages'then return proxy('stages',raw.stages or{})end + if key=='type1'or key=='type2'then return ids:read('types',(raw.curTypes or{})[key=='type1'and 1 or 2])end + elseif d.kind=='stages'then return raw[statNames[key]or key] + elseif d.kind=='state'then + if key=='player'or key=='enemy'then return proxy('battler',raw[key])end + if key=='battlers'then + e.battlers=e.battlers or{};for pos=0,3 do e.battlers[pos]=proxy('battler',at(pos))end;return e.battlers + end + if key=='party'or key=='playerParty'then return collection('party',party(),function(mon)return proxy('mon',mon)end)end + if key=='enemyParty'then + e.wildParty=e.wildParty or{};e.wildParty[1]=raw.enemy and raw.enemy.mon + return collection('enemyParty',raw.enemyParty or e.wildParty,function(mon)return proxy('mon',mon)end) + end + if key=='turn'then return raw.turnCount or 0 end + if key=='wild'then return raw.kind=='wild'end + if key=='double'then return raw.isDouble and raw:isDouble()or raw.double==true end + if key=='over'then return raw.result~=nil or raw.dead==true end + if key=='result'then return raw.result end + if key=='weather'then return raw.field and raw.field.weather end + if key=='weatherTurns'then return raw.field and raw.field.weatherTurns end + if key=='playerIndex'then return partyIndex(party(),raw.player and raw.player.mon)end + if key=='enemyIndex'then return raw.enemyIndex or(raw.enemy and 1)end + if key=='trainerClass'then return raw.trainer and raw.trainer.class or raw.trainerClass end + if key=='trainerName'then return raw.trainerName or raw.trainer and raw.trainer.name end + if key=='trainerPicId'then return raw.trainer and(raw.trainer.picIndex or raw.trainer.index)end + if key=='session'then return require('src.core.Game3').forNative(c.game).session end + if key=='link'then return raw.kind=='link'end + if key=='safari'then return raw.safari~=nil and raw.safari~=false end + if key=='ghostBattle'then return raw.ghost==true end + if key=='tutorial'or key=='oldManTutorial'then return raw.demo==true or raw.dudeDemo==true end + if key=='pokedude'then return raw.pokedudeDemo~=nil and raw.pokedudeDemo~=false end + if key=='firstBattle'then return raw.firstBattle==true end + if key=='kinds'then + e.kinds=e.kinds or{};e.kinds.tutorial=raw.pokedudeDemo and'pokedude'or(raw.demo and'oldMan'or nil);return e.kinds + end + if key=='rng'then return e.rng end + end + end + e.read=read + e.view=proxy('state',screen) + e.rng=function(...)assert(e.context and e.valid(),'native battle RNG retired');return e.context.screen.rng(...)end + function e:flush() + if not self.context or not self.valid()then return false end + local writes={} + for mon,d in pairs(self.moveArrays or{})do + for i,value in pairs(d.moves)do assert(value==d.ids[i],'source move replacement requires native move learning')end + for i,value in pairs(d.ids)do assert(d.moves[i]==value,'source move replacement requires native move learning')end + for i,value in pairs(d.maxPp)do assert(value==d.maximum[i],'source maximum PP is read-only')end + for i,value in pairs(d.maximum)do assert(d.maxPp[i]==value,'source maximum PP is read-only')end + for i in pairs(d.baseline)do assert(d.pp[i]~=nil,'source PP slot cannot be removed')end + for i,value in pairs(d.pp)do + assert(d.baseline[i]~=nil,'unknown source PP slot') + if value~=d.baseline[i]then + local mv=mon.moves and mon.moves[i];local maximum=d.maxPp[i] + assert(mv and mv.pp==d.baseline[i],'native move PP changed during source edit') + assert(integer(value,0,maximum or 99),'invalid source move PP') + writes[#writes+1]={move=mv,value=value} + end + end + end + for _,write in ipairs(writes)do write.move.pp=write.value end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + return true + end + function e:retire() + if self.retired then return end + for mon,d in pairs(self.moveArrays or{})do d.pending=false;moveArrays(mon)end + -- Populate only declared observations. No native queue, functions, save or + -- data table is copied into the completed record or retained by its views. + local seen={} + repeat + local pending=false + for view,d in pairs(self.entries)do if not seen[view]then + pending=true;seen[view]=true;local snapshot={} + for _,key in ipairs(d.kind=='state'and stateKeys or d.kind=='battler'and battlerKeys or d.kind=='mon'and monKeys or{'attack','defense','speed','spAtk','spDef','accuracy','evasion'})do + if key~='session'and key~='rng'then snapshot[key]=read(d,key)end + end + d.snapshot=snapshot + end end + if not pending then break end + until false + for _,d in pairs(self.entries)do d.raw=nil end + self.context,self.moveArrays,self.wildParty,self.valid=nil,nil,nil,function()return false end + self.retired=true;ids=nil + end + return e +end +return M diff --git a/src/compat/gen1/CollisionClass.lua b/src/compat/gen1/CollisionClass.lua new file mode 100644 index 000000000..a59801d58 --- /dev/null +++ b/src/compat/gen1/CollisionClass.lua @@ -0,0 +1,137 @@ +-- Classify compatibility-view cells without writing the native collision grid. +-- Behavior precedence is compiled once into dispatch rows. Hoenn surf flags +-- remain live borrowed input: a provider may change a flag after setTileBits. +local MB = require("src.compat.gen1.MetatileBehavior") +local M = {} +local categories = { WATER = 0x29, TALL_GRASS = 0x18, TREE = 7, CLIFF = 7, + COAST_CLIFF = 7, BLOCKED = 7, BUILDING = 7, DOOR = 0x71, SIGN = 0x82, + PC = 0x93, COUNTER = 0x90, CAVE = 0x2b, LEDGE = 0xa3 } +local ledges = { E = 0xa0, W = 0xa1, N = 0xa2, S = 0xa3 } +local function seed(category, direction, kind) + if category == "LEDGE" then return ledges[direction or "S"] or 0xa3 end + if category == "WARP_KEEP_FACING" or (category == "DOOR" and kind == "indoor") + or (category == "STAIR" and direction == "WARP") then return 0x72 end + return categories[category] or 0 +end +local function dispatch() + local rows = {} + local function add(ids, category, direction) + for _, id in ipairs(ids) do rows[id] = { category, direction } end + end + return rows, add +end +local fr, add = dispatch() +add({2, 209}, "TALL_GRASS"); add({33, 43}, "SAND") +add({56}, "LEDGE", "E"); add({57}, "LEDGE", "W") +add({58}, "LEDGE", "N"); add({59}, "LEDGE", "S") +add({97, 106, 107, 108, 109, 110, 111}, "STAIR", "WARP") +add({42}, "STAIR"); add({96, 98, 99, 100, 101, 103, 105}, "DOOR") +add({102, 104, 113}, "WARP_KEEP_FACING"); add({132, 135, 136}, "SIGN") +add({131}, "PC"); add({128}, "COUNTER"); add({8}, "CAVE"); add({12}, "ROCK_DECK") +local water, shallow, scenery, piers = {}, {}, {}, {} +for _, id in ipairs({16,17,18,19,21,26,27,80,81,82,83}) do water[id] = true end +shallow[22], shallow[23] = true, true +for _, mid in ipairs({10,11,12,14,15,19}) do scenery[mid] = "TREE" end +for mid = 20, 31 do scenery[mid] = "TREE" end +for mid = 36, 39 do scenery[mid] = "TREE" end +for _, mid in ipairs({112,113,114,115,117,122,123,124,125,178,179,180,181}) do scenery[mid] = "CLIFF" end +for _, mid in ipairs({453,454,455,692,694}) do piers[mid] = true end +-- The reference's shrub list contains only IDs >= 640 and is guarded by +-- mid < 640, so it admits no cells. Do not turn that dead branch into foliage. +local function ground(kind) return kind == "town" and "TOWN_PATH" or "SHORT_GRASS" end +local Frlg = { seed = seed } +function Frlg.classify(mid, collision, behavior, kind) + local b = behavior or 0 + if water[b] or shallow[b] then + if (collision or 0) ~= 0 then return "BLOCKED", nil end + return water[b] and "WATER" or "PATH", nil + end + local row = fr[b] + if row then return row[1], row[2] end + if kind ~= "indoor" and scenery[mid] then + return collision ~= 0 and scenery[mid] or ground(kind), nil + end + if piers[mid] and collision == 0 then return "PIER", nil end + if collision ~= 0 then + if mid >= 640 then return (kind == "town" or kind == "indoor") and "BUILDING" or "CLIFF", nil end + return "BLOCKED", nil + end + return ground(kind), nil +end +local rse, addRse = dispatch() +local function names(text, category, direction) + local ids = {}; for name in text:gmatch("%S+") do ids[#ids + 1] = MB.require(name) end + addRse(ids, category, direction) +end +names("TALL_GRASS LONG_GRASS", "TALL_GRASS"); names("SAND DEEP_SAND", "SAND") +names("JUMP_EAST", "LEDGE", "E"); names("JUMP_WEST", "LEDGE", "W") +names("JUMP_NORTH", "LEDGE", "N"); names("JUMP_SOUTH", "LEDGE", "S") +names("LADDER UP_ESCALATOR DOWN_ESCALATOR", "STAIR", "WARP") +names("ANIMATED_DOOR NON_ANIMATED_DOOR WATER_DOOR DEEP_SOUTH_WARP AQUA_HIDEOUT_WARP EAST_ARROW_WARP WEST_ARROW_WARP NORTH_ARROW_WARP SOUTH_ARROW_WARP WATER_SOUTH_ARROW_WARP SHOAL_CAVE_ENTRANCE STAIRS_OUTSIDE_ABANDONED_SHIP", "DOOR") +names("CRACKED_FLOOR_HOLE LAVARIDGE_GYM_B1F_WARP LAVARIDGE_GYM_1F_WARP MOSSDEEP_GYM_WARP MT_PYRE_HOLE BATTLE_PYRAMID_WARP", "WARP_KEEP_FACING") +for _, name in ipairs({"PC", "COUNTER", "CAVE"}) do names(name, name) end +names("MOUNTAIN_TOP", "ROCK_DECK") +local Rse = { seed = seed, TILE_FLAG_SURFABLE = 2 } +local puddle, shallowWater = MB.PUDDLE, MB.SHALLOW_WATER +function Rse.setTileBits(bits) Rse._tileBits = type(bits) == "table" and bits or nil end +local blockedRow, waterRow, pathRow = {"BLOCKED"}, {"WATER"}, {"PATH"} +local townRow, groundRow = {"TOWN_PATH"}, {"SHORT_GRASS"} +for _, row in pairs(rse) do + row[3], row[4] = seed(row[1], row[2]), seed(row[1], row[2], "indoor") +end +for _, row in ipairs({blockedRow, waterRow, pathRow, townRow, groundRow}) do + row[3], row[4] = seed(row[1]), seed(row[1], nil, "indoor") +end +local function rseCell(owner, collision, behavior, kind) + local b, blocked = behavior or 0, (collision or 0) ~= 0 + local bits = owner._tileBits and owner._tileBits[b] + if bits ~= nil and math.floor(bits / owner.TILE_FLAG_SURFABLE) % 2 == 1 then + return blocked and blockedRow or waterRow + end + if b == puddle or b == shallowWater then return blocked and blockedRow or pathRow end + return rse[b] or (blocked and blockedRow or (kind == "town" and townRow or groundRow)) +end +function Rse.classify(_, collision, behavior, kind) + local row = rseCell(Rse, collision, behavior, kind) + return row[1], row[2] +end +local rseClassify = Rse.classify +function Frlg.fromCell(mid, collision, behavior, kind) + local category, direction = Frlg.classify(mid, collision, behavior, kind) + return Frlg.seed(category, direction, kind), category, direction +end +function Rse.fromCell(mid, collision, behavior, kind) + -- The fixed rows already own both door-arrival variants. Preserve the + -- public extension seam when a mod replaces either stage of classification. + if Rse.classify == rseClassify and Rse.seed == seed then + local row = rseCell(Rse, collision, behavior, kind) + return row[kind == "indoor" and 4 or 3], row[1], row[2] + end + local category, direction = Rse.classify(mid, collision, behavior, kind) + return Rse.seed(category, direction, kind), category, direction +end +function M.impl(family) + assert(family == "frlg" or family == "rse", "unsupported compatibility collision family") + return family == "frlg" and Frlg or Rse +end +-- Live views own their flag state. They never replace the singleton used by +-- existing consumers, nor borrow another edition's active classification. +function M.new(family, bits) + M.impl(family) + local instance = { seed = seed } + if family == "rse" then + instance.TILE_FLAG_SURFABLE = 2 + function instance.setTileBits(value) instance._tileBits = type(value) == "table" and value or nil end + instance.setTileBits(bits) + function instance.classify(_, collision, behavior, kind) + local row = rseCell(instance, collision, behavior, kind) + return row[1], row[2] + end + else instance.classify = Frlg.classify end + function instance.fromCell(mid, collision, behavior, kind) + local category, direction = instance.classify(mid, collision, behavior, kind) + return instance.seed(category, direction, kind), category, direction + end + return instance +end +return M diff --git a/src/compat/gen1/CollisionRuntime.lua b/src/compat/gen1/CollisionRuntime.lua new file mode 100644 index 000000000..32b719ebe --- /dev/null +++ b/src/compat/gen1/CollisionRuntime.lua @@ -0,0 +1,94 @@ +-- Source observations and native permission queries have different alphabets: +-- classified COLL bytes are views; actual entry permission stays native. +local Session=require('src.compat.gen1.NativeSession') +local Map=require('src.core.game3.map') +local Tiles=require('src.render.Gen3Tiles') +local Behavior=require('src.compat.gen1.MetatileBehavior') +local Version=require('src.core.GameVersion') +local Native=require('src.world.Collision') +local M={} +local function integer(v)return type(v)=='number'and v==v and math.abs(v)=0 and mid<1024)then return nil end + local g,_,_,maps=context(game);if not g or not maps:isOwned(def)then return nil end + local native=g.data.maps[def.nativeId] + local pair=native and g.data.tilesets[native.tileset] + local sets=g.data.map_tilesets + local a=pair and sets and sets[pair.primaryKey] + local b=pair and sets and sets[pair.secondaryKey] + local geometry=g.data.constants and g.data.constants.gen3Layout + if not(a and b and geometry)then return nil end + local cached=readers[pair] + if not cached or cached.primary~=a or cached.secondary~=b or cached.geometry~=geometry + or cached.split~=geometry.metatilesInPrimary then + cached={primary=a,secondary=b,geometry=geometry,split=geometry.metatilesInPrimary, + reader=Tiles.new({primary=a,secondary=b},geometry)} + readers[pair]=cached + end + local raw=cached.reader:sourceBehavior(mid) + return raw~=nil and Behavior.fromRaw(Version.get(),raw)or nil + end + function runtime:behavior(def,x,y,game) + if not(integer(x)and integer(y))then return nil end + local layout=def and def.midLayout + if not layout or x<0 or y<0 or x>=layout.width or y>=layout.height then return nil end + return self:behaviorMid(def,layout:midAt(x,y),game) + end + function runtime:grid(game) + local def=self:current(game);local layout=def and def.midLayout + if not layout then return nil end + local cached=grids[layout] + if not cached or cached.revision~=layout._revision then + cached={revision=layout._revision,cells=layout:collArray()};grids[layout]=cached + end + return cached.cells + end + function runtime:canEnter(game,x,y,opts) + if not(integer(x)and integer(y))then return false,'bounds'end + local g,w,p=Session.resolve(game);if not g then return false,'bounds'end + opts=opts or {} + local fx,fy=opts.fromX or p.cellX,opts.fromY or p.cellY + if not(integer(fx)and integer(fy))then return false,'bounds'end + local direction=opts.dir + if direction~=nil and not directions[direction]then return false,'direction'end + if not direction then + if fx==x and fy~=y then direction=y>fy and'down'or'up' + elseif fy==y and fx~=x then direction=x>fx and'right'or'left'end + end + local elevation=opts.elevation + if elevation==nil then elevation=p.elevation end + local surfing=opts.surfing + if surfing==nil then surfing=p.surfing end + local cast=w.cast or w.entities or {} + local mover=p + if fx~=p.cellX or fy~=p.cellY or elevation~=p.elevation or surfing~=p.surfing then + mover={};for k,v in pairs(p)do mover[k]=v end + mover.cellX,mover.cellY,mover.elevation,mover.surfing=fx,fy,elevation,surfing + local filtered={} + for _,entity in ipairs(cast)do + if entity~=p and entity.of~=p then filtered[#filtered+1]=entity end + end + cast=filtered + end + return Native.mayEnter(w.map,cast,mover,x,y,direction) + end + function runtime:release() + readers=setmetatable({},{__mode='k'});grids=setmetatable({},{__mode='k'}) + end + return runtime +end +return M diff --git a/src/compat/gen1/DecodedLayout.lua b/src/compat/gen1/DecodedLayout.lua new file mode 100644 index 000000000..7619a9585 --- /dev/null +++ b/src/compat/gen1/DecodedLayout.lua @@ -0,0 +1,134 @@ +-- Legacy decoded 16px layout contract. These are mod-owned layouts, +-- not aliases to the shared native Map or its two-bit collision grid. +-- coll here is the old classified COLL_* byte; it is not raw GBA collision. +local Layout = {} +Layout.__index = Layout + +function Layout.fromDecoded(decoded, mapId, pair, blob) + local layout = setmetatable({ + mapId = mapId, pair = pair or "sevii_outdoor", + width = decoded.width or 0, height = decoded.height or 0, + trueWidth = decoded.trueWidth or decoded.width or 0, + trueHeight = decoded.trueHeight or decoded.height or 0, + borderWidth = decoded.borderWidth or 1, borderHeight = decoded.borderHeight or 1, + borderMids = decoded.borderMids or { 0 }, cells = decoded.cells or {}, + overrides = {}, _revision = 0, + }, Layout) + if blob then + layout._workerSource = { + blob = blob, cells = decoded.cells, width = decoded.width, height = decoded.height, + trueWidth = decoded.trueWidth or decoded.width, trueHeight = decoded.trueHeight or decoded.height, + } + end + return layout +end + +local function outside(layout, x, y) + return x < 0 or y < 0 or x >= (layout.trueWidth or layout.width) + or y >= (layout.trueHeight or layout.height) +end +local function borderMid(layout, x, y) + return layout.borderMids[(y % layout.borderHeight) * layout.borderWidth + x % layout.borderWidth + 1] or 0 +end +function Layout:cellAt(x, y) + local row = self.overrides[y * 1024 + x] + if row then return row end + if outside(self, x, y) then return { mid = borderMid(self, x, y), coll = 255, elev = 0 } end + return self.cells[y * self.width + x + 1] or { mid = 0, coll = 255, elev = 0 } +end +local baseCellAt = Layout.cellAt +local function field(layout, x, y, key, missing, border) + if layout.cellAt ~= baseCellAt then return layout:cellAt(x, y)[key] end + local row = layout.overrides[y * 1024 + x] + if row then return row[key] end + if outside(layout, x, y) then + if border ~= nil then return border end + return borderMid(layout, x, y) + end + row = layout.cells[y * layout.width + x + 1] + if row then return row[key] end + return missing +end +function Layout:midAt(x, y) return field(self, x, y, "mid", 0) end +function Layout:collAt(x, y) return field(self, x, y, "coll", 255, 255) end +function Layout:elevAt(x, y) return field(self, x, y, "elev", 0, 0) end + +function Layout:collArray() + if self.width * self.height <= 0 then return nil end + local out = {} + for y = 0, self.height - 1 do + for x = 0, self.width - 1 do + local i = y * self.width + x + 1 + local override, row = self.overrides[y * 1024 + x], self.cells[i] + -- Padding stays blocked even if a caller stored an outside override. + out[i] = not outside(self, x, y) and (override and override.coll or row and row.coll or 255) or 255 + end + end + return out +end + +local function invalidate(layout, coordinates) + local view = package.loaded["src.core.game3.field_view"] + if not view then return end + if coordinates and #coordinates <= 128 and view.invalidateLayoutCell then + for i = 1, #coordinates, 2 do view.invalidateLayoutCell(layout, coordinates[i], coordinates[i + 1]) end + else view._nativeDirty = true end +end +function Layout:applyOverride(x, y, mid, coll, elev) + self._revision = (self._revision or 0) + 1 + x, y = tonumber(x) or 0, tonumber(y) or 0 + self.overrides[y * 1024 + x] = { mid = tonumber(mid) or 0, coll = coll ~= nil and coll or 255, elev = elev or 0 } + invalidate(self, { x, y }) +end +function Layout:setMetatiles(rows) + local coordinates = {} + for _, row in ipairs(rows or {}) do + local x, y = tonumber(row.x) or 0, tonumber(row.y) or 0 + local before = self:cellAt(x, y) + self.overrides[y * 1024 + x] = { + mid = tonumber(row.mid) or before.mid or 0, + coll = row.coll ~= nil and row.coll or before.coll, + elev = row.elev ~= nil and row.elev or before.elev or 0, + } + coordinates[#coordinates + 1] = x; coordinates[#coordinates + 1] = y + end + if #coordinates > 0 then self._revision = (self._revision or 0) + 1; invalidate(self, coordinates) end + return #coordinates / 2 +end +function Layout:stamp(source, ox, oy) + if type(source) ~= "table" or type(source.cells) ~= "table" then return 0 end + ox, oy = tonumber(ox) or 0, tonumber(oy) or 0 + local count = 0 + for y = 0, (source.trueHeight or source.height or 0) - 1 do + for x = 0, (source.trueWidth or source.width or 0) - 1 do + local row = source.cells[y * source.width + x + 1] + if row then + self.overrides[(oy + y) * 1024 + ox + x] = { mid = row.mid or 0, coll = row.coll ~= nil and row.coll or 255, elev = row.elev or 0 } + count = count + 1 + end + end + end + invalidate(self) + if count > 0 then self._revision = (self._revision or 0) + 1 end + return count +end +function Layout:clearOverrides() + if next(self.overrides) then self.overrides = {}; self._revision = (self._revision or 0) + 1 end + invalidate(self) +end + +function Layout:workerPacked() + local source = self._workerSource + if not source or self.cells ~= source.cells or self.width ~= source.width or self.height ~= source.height + or self.trueWidth ~= source.trueWidth or self.trueHeight ~= source.trueHeight + or self.cellAt ~= baseCellAt or self.midAt ~= Layout.midAt then return nil end + local assets = package.loaded["src.render.Assets"] + if assets and assets.loader and #assets.loader:overrideOrder() > 0 then return nil end + local overrides, borders = {}, {} + for key, row in pairs(self.overrides) do overrides[key] = { mid = row.mid } end + for i = 1, self.borderWidth * self.borderHeight do borders[i] = self.borderMids[i] end + return { blob = source.blob, off = 17 + source.blob:byte(15) * source.blob:byte(16) * 2, + width = self.width, trueWidth = self.trueWidth, trueHeight = self.trueHeight, + borderWidth = self.borderWidth, borderHeight = self.borderHeight, borderMids = borders, overrides = overrides } +end +return Layout diff --git a/src/compat/gen1/FieldEffectCache.lua b/src/compat/gen1/FieldEffectCache.lua new file mode 100644 index 000000000..4c7817682 --- /dev/null +++ b/src/compat/gen1/FieldEffectCache.lua @@ -0,0 +1,67 @@ +-- Explicit cache input only. No cartridge import or player filesystem I/O. +local M={} +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 copy(v,state,depth) + local kind=type(v) + if kind=='number'then assert(v==v and math.abs(v)1048576 then return nil,'field manifest size limit'end + if bytes:byte(1)==27 then return nil,'field manifest must be text'end + local chunk,why + if setfenv then chunk,why=loadstring(bytes,'@field-effect-manifest');if chunk then setfenv(chunk,{})end + else chunk,why=load(bytes,'@field-effect-manifest','t',{})end + if not chunk then return nil,why end + if jit and jit.off then jit.off(chunk,true)end + local hook,mask,count=debug.gethook() + debug.sethook(function()error('field manifest instruction limit')end,'',200000) + local ok,result=pcall(chunk);debug.sethook(hook,mask,count) + if not ok or type(result)~='table'then return nil,'invalid field manifest'end + local safe,value=pcall(copy,result,{nodes=0,seen={}},0) + if not safe then return nil,tostring(value)end + return value +end +local function relative(path) + if type(path)~='string'or#path==0 or#path>256 or path:sub(1,1)=='/'or path:find('[\\:%z]')then return false end + for part in path:gmatch('[^/]+')do if part=='..'or part=='.'then return false end end + return not path:find('//',1,true) +end +function M.manifest(cache,root,family) + local bytes=cache:read(root..'/objects.lua') + if bytes==nil then return nil end + local manifest,why=decode(bytes);if not manifest then return nil,why end + if manifest.family and manifest.family~=family then return nil,'field manifest belongs to another cartridge family'end + local names={} + for _,key in ipairs({'objects','extras'})do + local list=manifest[key] + if list~=nil and type(list)~='table'then return nil,'invalid field object list'end + for _,row in pairs(list or{})do + if type(row)~='table'or type(row.name)~='string'or not row.name:match('^[%w_%-]+$')or#row.name>80 + or not integer(row.fw,8,128)or not integer(row.fh,8,128)or not integer(row.frames,1,256) + or row.fw*row.fh*row.frames>1048576 or not relative(row.rgba)then + return nil,'invalid field object geometry or path' + end + if names[row.name]then return nil,'ambiguous field object name'end + names[row.name]=row + end + end + manifest._byName=names;return manifest +end +function M.rgba(cache,root,row) + local bytes=cache:read(root..'/'..row.rgba) + if type(bytes)~='string'or#bytes~=row.fw*row.fh*row.frames*4 then return nil,'invalid field RGBA payload'end + return bytes +end +M.copy=function(value)return copy(value,{nodes=0,seen={}},0)end +return M diff --git a/src/compat/gen1/FieldEffectRuntime.lua b/src/compat/gen1/FieldEffectRuntime.lua new file mode 100644 index 000000000..c06e2cfe4 --- /dev/null +++ b/src/compat/gen1/FieldEffectRuntime.lua @@ -0,0 +1,147 @@ +-- Owned field-effect artwork over the native dataset or an explicit cache. +-- This service has no actor, animation, task, RNG or gameplay clock. +local Session=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Catalog=require('src.render.Gen3FieldEffectCatalog') +local Cache=require('src.compat.gen1.FieldEffectCache') +local Assets=require('src.render.Assets') +local R={};R.__index=R +local ENGINE={} +local function owner() + local runtime=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return runtime and runtime.currentMod or facade and facade._callbackOwner and facade._callbackOwner()or ENGINE +end +local function release(value)if value and value.release then pcall(value.release,value)end end +local function root() + local extract=package.loaded['src.import.gba.extract_island1'] + return ((extract and extract.CACHE_ROOT)or'data/generated/gba')..'/field_effects' +end +local function signature(r)return table.concat({r.path or r.rgba,r.fw,r.fh,r.frames},'\0')end +function R.new() + return setmetatable({loaded={},owned={},providers={},bytes=0,maxBytes=64*1024*1024},R) +end +function R:drop(sheet) + local held=self.owned[sheet];if not held then return end + self.owned[sheet]=nil;self.bytes=self.bytes-held.bytes + -- Public sheet tables are mutable. Retire only allocations recorded at + -- construction, never an image/quad a mod later assigned to that table. + for _,part in ipairs(held.parts)do release(part)end + sheet.image,sheet.imageData,sheet.quads,sheet.quadsFront=nil,nil,{},{};sheet._retired=true +end +function R:invalidate() + for sheet in pairs(self.owned)do self:drop(sheet)end + self.loaded={};self.context=nil +end +function R:release()self:invalidate();self.providers={}end +function R:current() + local game,why=Session.game();local edition=Version.get() + if not(game and type(game.data)=='table'and type(game.data.constants)=='table')then + self:release();return nil,why or'no native field-effect dataset' + end + local provider + for i=#self.providers,1,-1 do + local p=self.providers[i] + if p.game==game and p.data==game.data and p.save==game.save and p.mods==game.mods and p.edition==edition then + provider=provider or p + else table.remove(self.providers,i)end + end + local c=self.context;local prefix=root() + if c and(c.game~=game or c.data~=game.data or c.constants~=game.data.constants or c.sprites~=game.data.sprites + or c.save~=game.save or c.mods~=game.mods or c.edition~=edition or c.provider~=provider + or c.root~=prefix or c.assets~=Assets.loader)then self:invalidate();c=nil end + if not c then + c={game=game,data=game.data,constants=game.data.constants,sprites=game.data.sprites, + save=game.save,mods=game.mods,edition=edition,provider=provider,root=prefix,assets=Assets.loader} + self.context=c + end + return c +end +function R:install(cache) + if cache~=nil and(type(cache)~='table'or type(cache.read)~='function')then return nil,'invalid field cache provider'end + local c,why=self:current();if not c then return nil,why end + local who=owner() + for i=#self.providers,1,-1 do if self.providers[i].owner==who then table.remove(self.providers,i)end end + if cache then self.providers[#self.providers+1]={owner=who,cache=cache,game=c.game,data=c.data,save=c.save,mods=c.mods,edition=c.edition}end + self:invalidate();return self:manifest() +end +function R:catalog() + local c,why=self:current();if not c then return nil,why end + if c.provider and not c.providerRead then + c.providerRead=true + local ok,manifest,err=pcall(Cache.manifest,c.provider.cache,c.root,Version.isFRLG(c.edition)and'frlg'or'rse') + if not ok then c.manifestError=tostring(manifest)else c.manifest,c.manifestError=manifest,err end + if self:current()~=c then return nil,'field cache cohort retired during manifest read'end + end + if c.manifestError then return nil,c.manifestError end + return c +end +function R:manifest() + local c,why=self:catalog();if not c then return nil,why end + if c.manifest then return Cache.copy(c.manifest)end + local manifest={family=Version.isFRLG(c.edition)and'frlg'or'rse',objects={},extras={},_byName={}} + for name,row in pairs(Catalog.records(c.data))do + local item={name=name,fw=row.fw,fh=row.fh,frames=row.frames,nativePath=row.path} + manifest.objects[#manifest.objects+1]=item;manifest._byName[name]=item + end + table.sort(manifest.objects,function(a,b)return a.name80 or not name:match('^[%w_%-]+$')then return nil,'invalid field sheet name'end + local c,why=self:catalog();if not c then return nil,why end + if c.manifest then + local row=c.manifest._byName[name] + if not row then return nil,'field sheet absent from installed manifest'end + return row,c,true + end + local row=Catalog.records(c.data)[name] + if not row then return nil,'native field sheet unavailable'end + return row,c,false +end +function R:get(name) + local row,c,explicit=self:record(name);if not row then return nil,c end + local sig=signature(row) + if not explicit then sig=sig..'\0'..Assets.resolve(row.path)end + local cached=self.loaded[name];local held=cached and self.owned[cached] + if held and held.signature==sig then held.owners[owner()]=true;return cached end + if cached then self:drop(cached);self.loaded[name]=nil end + if not(love and love.image and love.graphics)then return nil,'field sheet graphics unavailable'end + local size=row.fw*row.fh*row.frames*8 + if self.bytes+size>self.maxBytes then return nil,'owned field sheet budget exhausted'end + local parts={};local function keep(x)parts[#parts+1]=x;return x end + local ok,sheet=pcall(function() + local data + if explicit then + local bytes,why=Cache.rgba(c.provider.cache,c.root,row);assert(bytes,why) + data=keep(love.image.newImageData(row.fw,row.fh*row.frames,'rgba8',bytes)) + else data=keep(love.image.newImageData(Assets.resolve(row.path)))end + assert(data:getWidth()==row.fw and data:getHeight()==row.fh*row.frames,'field sheet image dimensions differ') + local limits=love.graphics.getSystemLimits and love.graphics.getSystemLimits() + assert(not limits or row.fh*row.frames<=limits.texturesize,'field sheet exceeds GPU limit') + local image=keep(love.graphics.newImage(data));image:setFilter('nearest','nearest') + local made={image=image,imageData=data,fw=row.fw,fh=row.fh,frames=row.frames,quads={},quadsFront={}} + for i=0,row.frames-1 do + made.quads[i]=keep(love.graphics.newQuad(0,i*row.fh,row.fw,row.fh,row.fw,row.fh*row.frames)) + made.quadsFront[i]=keep(love.graphics.newQuad(0,i*row.fh+row.fh-8,row.fw,8,row.fw,row.fh*row.frames)) + end + assert(self:current()==c,'field sheet cohort retired during preparation') + local latest,context,provider=self:record(name) + assert(context==c and provider==explicit and signature(latest)==signature(row) + and(explicit or sig==signature(latest)..'\0'..Assets.resolve(latest.path)),'field sheet record changed during preparation') + assert(self.bytes+size<=self.maxBytes,'owned field sheet budget exhausted during preparation') + return made + end) + if not ok then for _,part in ipairs(parts)do release(part)end;return nil,tostring(sheet)end + self.owned[sheet]={signature=sig,bytes=size,parts=parts,owners={[owner()]=true}};self.bytes=self.bytes+size + self.loaded[name]=sheet;return sheet +end +function R:releaseOwner(who) + for i=#self.providers,1,-1 do if self.providers[i].owner==who then table.remove(self.providers,i)end end + for name,sheet in pairs(self.loaded)do + local held=self.owned[sheet] + if held then held.owners[who]=nil;if next(held.owners)==nil then self:drop(sheet);self.loaded[name]=nil end end + end + self:current() +end +function R:poll()self:current()end +return R diff --git a/src/compat/gen1/FieldMoveRuntime.lua b/src/compat/gen1/FieldMoveRuntime.lua new file mode 100644 index 000000000..cf4cc368d --- /dev/null +++ b/src/compat/gen1/FieldMoveRuntime.lua @@ -0,0 +1,137 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Bind compatible field-move operations to the actual native stack state. +local Session=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local R={};R.__index=R +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function owner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner and facade._callbackOwner() +end +local function current(c) + return not c.retired and c.game.data==c.data and c.game.save==c.save and c.game.mods==c.mods and Version.get()==c.edition +end +function R.new()return setmetatable({records=setmetatable({},{__mode='kv'}),callbacks=setmetatable({},{__mode='k'})},R)end +function R:bind(state) + local c=self.records[state];if c then return current(c)and state:valid(false)and c or nil end + local game=Session.game(state.game);if not(game and state:valid(false))then return nil end + c={state=state,game=game,data=game.data,save=game.save,mods=game.mods,edition=Version.get(),owner=owner(),sourceStarted=state.sourceOwned==true} + c.nativeCallback=state.onDone + local callback=c.nativeCallback + local admission=setmetatable({state},{__mode='v'}) + c.continue=function() + if c.detachedCallback then return c.detachedCallback()end + if c.cancelled then return false,'native field move owner retired'end + if c.error then error(c.error,0)end + if c.result then return unpack(c.result,1,c.result.n)end + if c.running then return false,'native field move continuation is already executing'end + local live=admission[1] + if not(c.exited and c.completed and live and live.callbackReady)then return false,'native field move has not completed'end + c.running=true;local fn=callback;callback=nil;c.nativeCallback=nil;admission[1]=nil + local result=pack(pcall(function()if fn then return fn()end end));fn=nil;c.running=false + if not result[1]then c.error=result[2];error(result[2],0)end + c.result={n=result.n-1};for i=2,result.n do c.result[i-1]=result[i]end + return unpack(c.result,1,c.result.n) + end + c.forgetCallback=function()callback=nil;c.nativeCallback=nil;admission[1]=nil end + state.onDone=c.continue;c.installed=c.continue;self.records[state]=c;self.callbacks[c]=true + return c +end +function R:current() + local game=Session.game();if not game then return nil end + local class=package.loaded['src.world.Gen3FieldMove'] + if not class then return nil end + for i=#(game.stack and game.stack.states or{}),1,-1 do + local state=game.stack.states[i] + if getmetatable(state)==class and not state.done then return self:bind(state)end + end +end +function R:exit(state) + local c=self.records[state];if not c or c.exited then return end + c.exited=true;c.completed=state.completed==true and current(c) + if not c.completed then c.forgetCallback()end + self.records[state]=nil;c.retired=true;c.state,c.game,c.data,c.save,c.mods=nil,nil,nil,nil,nil +end +function R:detach(c) + local state=c.state + if state and state.onDone==c.installed then state.onDone=c.nativeCallback + elseif state then c.detachedCallback=c.nativeCallback end + c.forgetCallback();c.retired=true + if state then self.records[state]=nil end + c.state,c.game,c.data,c.save,c.mods=nil,nil,nil,nil,nil +end +local function cancelCallback(c) + c.cancelled=true;c.result=nil;c.error=nil;c.forgetCallback() +end +function R:release() + local selected={};for c in pairs(self.callbacks)do selected[#selected+1]=c end + for _,c in ipairs(selected)do + if c.sourceStarted then + if c.state then c.state:retire()end;cancelCallback(c) + elseif c.state then self:detach(c)end + end + self.records=setmetatable({},{__mode='kv'}) +end +function R:cancelOwner(who) + local selected={} + for c in pairs(self.callbacks)do if c.owner==who then selected[#selected+1]=c end end + for _,c in ipairs(selected)do + if c.sourceStarted then + if c.state then c.state:retire()end;cancelCallback(c) + elseif c.state then self:detach(c)end + end + return #selected>0 +end +function R:mon(game,value) + local mon=type(value)=='table'and value or{species=value};local out={} + for k,v in pairs(mon)do out[k]=v end + if type(out.species)=='number'and not(game.data.pokemon and game.data.pokemon[out.species])then out.species=(game.data.constants.speciesOrder or{})[out.species]or out.species end + if not(game.data.pokemon and game.data.pokemon[out.species])then return nil,'native species artwork unavailable'end + if mon.isShiny~=nil then out.shiny=mon.isShiny==true end + return out +end +function R:start(mon,opts,callback) + local game,world=Session.resolve() + if not(game and world and Session.acceptsInput(game,world,world.player))then return false,'no available native field-move owner'end + if self:current()then return false,'native field move is busy'end + local converted,why=self:mon(game,mon);if not converted then return false,why end + opts=opts or{};if type(opts)~='table'or(callback~=nil and type(callback)~='function')then return false,'invalid field move options'end + return require('src.world.Gen3FieldMove').show(game,converted,callback, + {presentation='cartridge',pose=opts.pose==true,noDuck=opts.noDuck==true,sourceStarted=true,sourceOwner=owner()}) +end +function R:configure(c,mon,opts,callback) + if not(current(c)and c.state:valid(true))then return false,'native field move admission retired'end + if c.configured then return true end + opts=opts or{};assert(type(opts)=='table','invalid field move options') + local converted,why=self:mon(c.game,mon);if not converted then return false,why end + local ok,err=c.state:configure(converted,opts,callback);if not ok then return false,err end + c.installed=callback + c.owner=c.owner or owner();c.configured=true + return true +end +function R:phase(c,name,body,fn,...) + if not c or not current(c)or not c.state:valid(name=='step')then return false,'native field move admission retired'end + local result=pack(Boundary.dispatch('field_move.'..name,body,fn,...)) + if c.error then error(c.error,0)end + return unpack(result,1,result.n) +end +function R:reset() + local c=self:current();if not c then return false end + local who=owner();if c.owner and who and c.owner~=who then return false,'native field move belongs to another mod'end + return c.state:complete(false) +end +function R:snapshot() + local c=self:current();local fx=c and c.state.effect + if not fx then return nil end + local out={} + for k,v in pairs(fx)do if type(v)~='table'and type(v)~='function'then out[k]=v end end + if fx.sprite then out.sprite={};for k,v in pairs(fx.sprite)do out.sprite[k]=v end end + local order=c.data.constants.speciesOrder or{} + for id,species in pairs(order)do if species==fx.species then out.species=id;break end end + out.shiny=require('src.pokemon.Pokemon').isShiny(c.state.mon) + out.picSpecies=out.species + return out +end +return R diff --git a/src/compat/gen1/FieldTiles.lua b/src/compat/gen1/FieldTiles.lua new file mode 100644 index 000000000..ac4c40890 --- /dev/null +++ b/src/compat/gen1/FieldTiles.lua @@ -0,0 +1,198 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Owned field batches sample the typed 16px map views. Native map renderers +-- and their borrowed sheets remain owned by the ordinary engine renderer. +local Map=require('src.core.game3.map') +local Native=require('src.core.game3.tileset_native') +local Graph=require('src.compat.gen1.MapGraph') +local Tiles={};Tiles.__index=Tiles +local function release(value)if value and value.release then value:release()end end +function Tiles.new(api)return setmetatable({api=api,under={},over={},atlases={},leases={},covers={}},Tiles)end +function Tiles:release() + for _,store in ipairs{self.under,self.over}do for _,batch in pairs(store)do release(batch)end end + for _,lease in pairs(self.leases)do lease:release()end + for _,lease in pairs(self.pending or{})do lease:release()end + self.pending=nil + for _,byMid in pairs(self.covers)do for _,entry in pairs(byMid)do release(entry.image);release(entry.prior)end end + self.covers={} + self.under,self.over,self.atlases,self.leases={},{},{},{} + self.window,self.stamp=nil,nil + self.def,self.cam,self.w,self.h,self.neighbors,self.world,self.extents=nil,nil,nil,nil,nil,nil,nil + self.api._nativeBatches,self.api._nativeOverBatches=self.under,self.over + self.api._nativeBatch=nil +end +local function same(a,b) + if not a or #a~=#b then return false end + for i=1,#a do if a[i]~=b[i]then return false end end;return true +end +function Tiles:prepare(game,cam,w,h) + local def,why=Map.currentDef();if not def then return false,why end + local layout=def.midLayout + local world=Map.refreshWorld(game,math.ceil(w/16),math.ceil(h/16),def.id) + local neighbors=Map.neighborList + local runtime=Map._context(game) + local extents={} + local function extent(target) + local size=extents[target] + if not size then + local l=target.midLayout + size=runtime:isOwned(target)and{target.width,target.height}or{l.width,l.height} + extents[target]=size + end + return size[1],size[2] + end + extent(def) + for _,edge in ipairs(world)do extent(edge.def)end + for _,edge in ipairs(neighbors)do extent(edge.def)end + -- Snapshot the typed graph once; resolving the public map facade for every + -- visible cell repeatedly rebuilt the same neighbor/owner observations. + local function sample(cx,cy)return Graph.midAt(cx,cy,def,neighbors,world,extent)end + local x,y=math.floor(cam.x/16)-1,math.floor(cam.y/16)-1 + local cols,rows=math.ceil(w/16)+3,math.ceil(h/16)+3 + local stamp={def,layout,layout._revision or 0,x,y,cols,rows} + for _,edge in ipairs(world)do + local l=edge.def.midLayout + stamp[#stamp+1]=edge.def;stamp[#stamp+1]=l;stamp[#stamp+1]=l._revision or 0 + stamp[#stamp+1]=edge.ox;stamp[#stamp+1]=edge.oy + end + local changed=self.api._nativeDirty or not same(self.stamp,stamp) + for pair,prior in pairs(self.atlases)do + local ts=Native.get(pair) + if ts~=prior or not ts or ts._retired then changed=true;break end + end + if changed then + local cells,pairsUsed={},{} + for cy=y,y+rows-1 do for cx=x,x+cols-1 do + local mid,pair=sample(cx,cy);pair=pair or def.pair or layout.pair + if not pairsUsed[pair]then + local ts,err=Native.get(pair);if not ts then return false,err end + pairsUsed[pair]=ts + end + cells[#cells+1]={cx=cx,cy=cy,mid=mid,pair=pair} + end end + -- Acquire the new cohort before releasing old references, including when + -- a source wrapper temporarily redirects a pair to another owned atlas. + local leases={} + for _,lease in pairs(self.pending or{})do lease:release()end;self.pending=nil + for pair in pairs(pairsUsed)do + local lease,err=Native.acquire(pair) + if not lease then for _,held in pairs(leases)do held:release()end;return false,err end + leases[pair]=lease + end + self.pending=leases + local capacity=cols*rows + for _,store in ipairs{self.under,self.over}do + for pair,batch in pairs(store)do + if not pairsUsed[pair]or batch:getBufferSize()4 then + local quad=n-5;local pixels=model.decoder:tilePixels(entry.tile) + for y=0,7 do for x=0,7 do + local sx,sy=entry.flipX and 7-x or x,entry.flipY and 7-y or y + local color=pixels[sy*8+sx+1]or 0 + if color~=0 then + local offset=(math.floor(quad/2)*8+y)*16+(quad%2)*8+x+1 + local at=(entry.palette*16+color)*4+1 + out[offset]=model.paletteKey:sub(at,at+3) + end + end end + end + end + return table.concat(out) +end +function Tiles:reflectionCover(cx,cy,cam,bgY) + local mid,pair=Graph.midAt(cx,cy,self.def,self.neighbors,self.world,function(target) + local size=self.extents[target];return size[1],size[2] + end) + local ts=self.atlases[pair or self.def.pair] + local model=ts and ts._model + if not model then return false,'reflection source pixels unavailable'end + local layer=model.decoder:reflectionCoverLayer(mid) + if layer==1 then return self:cell(cx,cy,cam,bgY)end + if layer~=2 then return true end + local cache=self.covers[ts];if not cache then cache={};self.covers[ts]=cache end + local entry=cache[mid] + if not entry or entry.revision~=model.revision then + local bytes=reflectionRGBA(model,mid) + if not entry then entry={};cache[mid]=entry end + if entry.bytes~=bytes then + local Upload=require('src.compat.gen1.AtlasUpload') + local data=Upload.data(16,16,bytes) + local image=Upload.image(data);data:release() + release(entry.prior);entry.prior,entry.image=entry.image,image;entry.bytes=bytes + end + entry.revision=model.revision + end + love.graphics.draw(entry.image,cx*16-math.floor(cam.x),cy*16-math.floor(bgY)) + return true +end +return Tiles diff --git a/src/compat/gen1/MapGraph.lua b/src/compat/gen1/MapGraph.lua new file mode 100644 index 000000000..db680b250 --- /dev/null +++ b/src/compat/gen1/MapGraph.lua @@ -0,0 +1,80 @@ +-- Connected read views. Geometry stays in map-cell coordinates; custom +-- legacy definitions keep their own layout dimensions and callback order. +local Connections=require('src.core.game3.connections') +local G={} +function G.compute(maps,root,hops,reachW,reachH,ensure,size) + local source=maps and maps[root];if not source then return {} end + size=size or Connections.sizeOf + if ensure then ensure(root,source)end + local rw,rh=size(source) + local pending={{root,source,0,0,0}};local seen={[root]=true};local out={};local head=1 + while head<=#pending do + local item=pending[head];pending[head]=false;head=head+1 + local sw,sh=size(item[2]) + for _,edge in ipairs(Connections.each(item[2]))do + local target=maps[edge.map] + if target and target~=source and not seen[edge.map]then + if ensure then ensure(edge.map,target)end + local dw,dh=size(target);local x,y + if edge.dir=='north'then x,y=edge.offset,-dh + elseif edge.dir=='south'then x,y=edge.offset,sh + elseif edge.dir=='west'then x,y=-dw,edge.offset + else x,y=sw,edge.offset end + x,y=x+item[3],y+item[4] + local nearby=reachW and reachH and x+dw>-reachW and x-reachH and y=0 and y>=0 and x=h and'south') + local horizontal=x<0 and'west'or(x>=w and'east') + for _,direction in ipairs({vertical or false,horizontal or false})do + if direction then for i=#(neighbors or{}),1,-1 do + local edge=neighbors[i];local l=edge.def and edge.def.midLayout + if edge.dir==direction and l then + local dw,dh=bounds(edge.def,extent) + local off=tonumber(edge.offset)or 0;local nx,ny + if direction=='north'then nx,ny=x-off,dh+y + elseif direction=='south'then nx,ny=x-off,y-h + elseif direction=='west'then nx,ny=dw+x,y-off + else nx,ny=x-w,y-off end + if inside(nx,ny,dw,dh)then return l:midAt(nx,ny),l.pair or edge.def.pair or pair end + end + end end + end + for _,entry in ipairs(world or{})do + local l=entry.def~=def and entry.def and entry.def.midLayout + if l then + local dw,dh=bounds(entry.def,extent) + local nx,ny=x-entry.ox,y-entry.oy + if inside(nx,ny,dw,dh)then return l:midAt(nx,ny),l.pair or entry.def.pair or pair end + end + end + return layout:midAt(x,y),pair,true +end +function G.near(entry,x0,y0,x1,y1) + if not x0 then return true end + local layout=entry.def and entry.def.midLayout + local w,h=layout and layout.width,layout and layout.height + if not(w and h)then return true end + local x,y=entry.ox or 0,entry.oy or 0 + return x+w>x0 and x<=x1 and y+h>y0 and y<=y1 +end +return G diff --git a/src/compat/gen1/MapIdentity.lua b/src/compat/gen1/MapIdentity.lua new file mode 100644 index 000000000..4dc69d681 --- /dev/null +++ b/src/compat/gen1/MapIdentity.lua @@ -0,0 +1,110 @@ +-- Gen1Recomp names and native map records meet at the ROM group/number. +-- Never rename shared definitions, saves, connections or script operands. +local Names = require("src.compat.gen1.MapNames") +local Identity = {} +local indexes = {} + +local function uint(n) + return type(n) == "number" and n >= 0 and n <= 255 and n % 1 == 0 +end +local function slot(g, n) + return uint(g) and uint(n) and (g .. ":" .. n) or nil +end +local function index(edition) + if indexes[edition] then return indexes[edition] end + local rows = Names[edition] + if not rows then return nil end + local out = { slots = {}, aliases = {} } + for _, row in ipairs(rows) do + local key = assert(slot(row[1], row[2])) + assert(not out.slots[key], "duplicate Gen1 map slot") + out.slots[key] = row + for _, alias in ipairs({ key, row[3], row[4], row[5] }) do + assert(not out.aliases[alias] or out.aliases[alias] == row, "ambiguous Gen1 map alias") + out.aliases[alias] = row + end + end + indexes[edition] = out + return out +end + +function Identity.lookup(edition, name, number) + local catalog = index(edition) + if not catalog then return nil end + local row + if number ~= nil then + local key = slot(tonumber(name), tonumber(number)) + row = key and catalog.slots[key] + elseif type(name) == "string" then + row = catalog.aliases[name] + end + if row then return row[3], row[1], row[2], row[4] end +end + +-- A binding is tied to one actual data.maps table. Native keys may be +-- renamed by the host's story setup; the checked group/number stays stable. +function Identity.bind(maps, edition) + assert(type(maps) == "table", "map identity binding needs data.maps") + assert(index(edition), "unsupported Gen1 map edition") + local native, ambiguous = {}, {} + local function equal(a,b,seen,top) + 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(top and(k=='id'or k=='sourceId'))and not equal(v,b[k],seen)then return false end + end + for k,v in pairs(b)do + if not(top and(k=='id'or k=='sourceId'))and a[k]==nil then return false end + end + return true + end + local function generatedAlias(key,def) + local source=def.sourceId;local original=type(source)=='string'and maps[source] + return type(original)=='table'and original~=def and original.id==source and def.id==key + and require('src.core.MapIdentity').normalize(source)==key and equal(def,original,nil,true) + end + for key, def in pairs(maps) do + local s = type(def) == "table" and slot(def.group, def.number) + -- Data's generic spelling aliases are shallow copies with sourceId. + -- Only an unchanged, correctly spelled copy is an alias, not a second + -- cartridge room. Authored variants continue to make the slot ambiguous. + if s and type(key) == "string" and not generatedAlias(key,def) then + local prior = native[s] + if prior and prior.def ~= def then ambiguous[s] = true + elseif not prior or key == def.id then native[s] = { key = key, def = def } end + end + end + local function record(g, n) + local s = slot(g, n) + if ambiguous[s] then return nil, "ambiguous native map slot" end + local row = native[s] + if not row then return nil, "native map unavailable" end + local def = row.def + if maps[row.key] ~= def or def.group ~= g or def.number ~= n then + return nil, "native map binding retired" + end + return row + end + local binding = {} + function binding:nativeId(legacyId) + local _, g, n = Identity.lookup(edition, legacyId) + if not g then return nil, "unknown Gen1 map id" end + local row, why = record(g, n) + if not row then return nil, why end + return row.key, row.def + end + function binding:legacyId(nativeId) + local def = type(nativeId) == "string" and maps[nativeId] + if type(def) ~= "table" then return nil, "unknown native map id" end + local row, why = record(def.group, def.number) + if not row then return nil, why end + if row.def ~= def then return nil, "native map binding retired" end + local name = Identity.lookup(edition, def.group, def.number) + if not name then return nil, "unknown Gen1 map slot" end + return name + end + return binding +end + +return Identity diff --git a/src/compat/gen1/MapNames.lua b/src/compat/gen1/MapNames.lua new file mode 100644 index 000000000..1e2dfb13b --- /dev/null +++ b/src/compat/gen1/MapNames.lua @@ -0,0 +1,2170 @@ +-- Generated from bryanthaboi/gen1recomp v0.3.94 (37a49b31f458d51ea9e00b90f2a247272ad09cb8). +-- Public map identity metadata only; no ROM bytes. Regenerate with tools/gen1-map-compat/export.lua. +return { + ["firered"]={ + {0,0,"FR_BATTLE_COLOSSEUM_2P","BattleColosseum_2P",nil}, + {0,1,"FR_TRADE_CENTER","TradeCenter",nil}, + {0,2,"FR_RECORD_CORNER","RecordCorner",nil}, + {0,3,"FR_BATTLE_COLOSSEUM_4P","BattleColosseum_4P",nil}, + {0,4,"FR_UNION_ROOM","UnionRoom",nil}, + {1,0,"FR_VIRIDIAN_FOREST","ViridianForest",nil}, + {1,1,"FR_MT_MOON_1F","MtMoon_1F",nil}, + {1,2,"FR_MT_MOON_B1F","MtMoon_B1F",nil}, + {1,3,"FR_MT_MOON_B2F","MtMoon_B2F",nil}, + {1,4,"FR_SSANNE_EXTERIOR","SSAnne_Exterior",nil}, + {1,5,"FR_SSANNE_1F_CORRIDOR","SSAnne_1F_Corridor",nil}, + {1,6,"FR_SSANNE_2F_CORRIDOR","SSAnne_2F_Corridor",nil}, + {1,7,"FR_SSANNE_3F_CORRIDOR","SSAnne_3F_Corridor",nil}, + {1,8,"FR_SSANNE_B1F_CORRIDOR","SSAnne_B1F_Corridor",nil}, + {1,9,"FR_SSANNE_DECK","SSAnne_Deck",nil}, + {1,10,"FR_SSANNE_KITCHEN","SSAnne_Kitchen",nil}, + {1,11,"FR_SSANNE_CAPTAINS_OFFICE","SSAnne_CaptainsOffice",nil}, + {1,12,"FR_SSANNE_1F_ROOM1","SSAnne_1F_Room1",nil}, + {1,13,"FR_SSANNE_1F_ROOM2","SSAnne_1F_Room2",nil}, + {1,14,"FR_SSANNE_1F_ROOM3","SSAnne_1F_Room3",nil}, + {1,15,"FR_SSANNE_1F_ROOM4","SSAnne_1F_Room4",nil}, + {1,16,"FR_SSANNE_1F_ROOM5","SSAnne_1F_Room5",nil}, + {1,17,"FR_SSANNE_1F_ROOM7","SSAnne_1F_Room7",nil}, + {1,18,"FR_SSANNE_2F_ROOM1","SSAnne_2F_Room1",nil}, + {1,19,"FR_SSANNE_2F_ROOM2","SSAnne_2F_Room2",nil}, + {1,20,"FR_SSANNE_2F_ROOM3","SSAnne_2F_Room3",nil}, + {1,21,"FR_SSANNE_2F_ROOM4","SSAnne_2F_Room4",nil}, + {1,22,"FR_SSANNE_2F_ROOM5","SSAnne_2F_Room5",nil}, + {1,23,"FR_SSANNE_2F_ROOM6","SSAnne_2F_Room6",nil}, + {1,24,"FR_SSANNE_B1F_ROOM1","SSAnne_B1F_Room1",nil}, + {1,25,"FR_SSANNE_B1F_ROOM2","SSAnne_B1F_Room2",nil}, + {1,26,"FR_SSANNE_B1F_ROOM3","SSAnne_B1F_Room3",nil}, + {1,27,"FR_SSANNE_B1F_ROOM4","SSAnne_B1F_Room4",nil}, + {1,28,"FR_SSANNE_B1F_ROOM5","SSAnne_B1F_Room5",nil}, + {1,29,"FR_SSANNE_1F_ROOM6","SSAnne_1F_Room6",nil}, + {1,30,"FR_UNDERGROUND_PATH_NORTH_ENTRANCE","UndergroundPath_NorthEntrance",nil}, + {1,31,"FR_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL","UndergroundPath_NorthSouthTunnel",nil}, + {1,32,"FR_UNDERGROUND_PATH_SOUTH_ENTRANCE","UndergroundPath_SouthEntrance",nil}, + {1,33,"FR_UNDERGROUND_PATH_WEST_ENTRANCE","UndergroundPath_WestEntrance",nil}, + {1,34,"FR_UNDERGROUND_PATH_EAST_WEST_TUNNEL","UndergroundPath_EastWestTunnel",nil}, + {1,35,"FR_UNDERGROUND_PATH_EAST_ENTRANCE","UndergroundPath_EastEntrance",nil}, + {1,36,"FR_DIGLETTS_CAVE_NORTH_ENTRANCE","DiglettsCave_NorthEntrance",nil}, + {1,37,"FR_DIGLETTS_CAVE_B1F","DiglettsCave_B1F",nil}, + {1,38,"FR_DIGLETTS_CAVE_SOUTH_ENTRANCE","DiglettsCave_SouthEntrance",nil}, + {1,39,"FR_VICTORY_ROAD_1F","VictoryRoad_1F",nil}, + {1,40,"FR_VICTORY_ROAD_2F","VictoryRoad_2F",nil}, + {1,41,"FR_VICTORY_ROAD_3F","VictoryRoad_3F",nil}, + {1,42,"FR_ROCKET_HIDEOUT_B1F","RocketHideout_B1F",nil}, + {1,43,"FR_ROCKET_HIDEOUT_B2F","RocketHideout_B2F",nil}, + {1,44,"FR_ROCKET_HIDEOUT_B3F","RocketHideout_B3F",nil}, + {1,45,"FR_ROCKET_HIDEOUT_B4F","RocketHideout_B4F",nil}, + {1,46,"FR_ROCKET_HIDEOUT_ELEVATOR","RocketHideout_Elevator",nil}, + {1,47,"FR_SILPH_CO_1F","SilphCo_1F",nil}, + {1,48,"FR_SILPH_CO_2F","SilphCo_2F",nil}, + {1,49,"FR_SILPH_CO_3F","SilphCo_3F",nil}, + {1,50,"FR_SILPH_CO_4F","SilphCo_4F",nil}, + {1,51,"FR_SILPH_CO_5F","SilphCo_5F",nil}, + {1,52,"FR_SILPH_CO_6F","SilphCo_6F",nil}, + {1,53,"FR_SILPH_CO_7F","SilphCo_7F",nil}, + {1,54,"FR_SILPH_CO_8F","SilphCo_8F",nil}, + {1,55,"FR_SILPH_CO_9F","SilphCo_9F",nil}, + {1,56,"FR_SILPH_CO_10F","SilphCo_10F",nil}, + {1,57,"FR_SILPH_CO_11F","SilphCo_11F",nil}, + {1,58,"FR_SILPH_CO_ELEVATOR","SilphCo_Elevator",nil}, + {1,59,"FR_POKEMON_MANSION_1F","PokemonMansion_1F",nil}, + {1,60,"FR_POKEMON_MANSION_2F","PokemonMansion_2F",nil}, + {1,61,"FR_POKEMON_MANSION_3F","PokemonMansion_3F",nil}, + {1,62,"FR_POKEMON_MANSION_B1F","PokemonMansion_B1F",nil}, + {1,63,"FR_SAFARI_ZONE_CENTER","SafariZone_Center",nil}, + {1,64,"FR_SAFARI_ZONE_EAST","SafariZone_East",nil}, + {1,65,"FR_SAFARI_ZONE_NORTH","SafariZone_North",nil}, + {1,66,"FR_SAFARI_ZONE_WEST","SafariZone_West",nil}, + {1,67,"FR_SAFARI_ZONE_CENTER_REST_HOUSE","SafariZone_Center_RestHouse",nil}, + {1,68,"FR_SAFARI_ZONE_EAST_REST_HOUSE","SafariZone_East_RestHouse",nil}, + {1,69,"FR_SAFARI_ZONE_NORTH_REST_HOUSE","SafariZone_North_RestHouse",nil}, + {1,70,"FR_SAFARI_ZONE_WEST_REST_HOUSE","SafariZone_West_RestHouse",nil}, + {1,71,"FR_SAFARI_ZONE_SECRET_HOUSE","SafariZone_SecretHouse",nil}, + {1,72,"FR_CERULEAN_CAVE_1F","CeruleanCave_1F",nil}, + {1,73,"FR_CERULEAN_CAVE_2F","CeruleanCave_2F",nil}, + {1,74,"FR_CERULEAN_CAVE_B1F","CeruleanCave_B1F",nil}, + {1,75,"FR_POKEMON_LEAGUE_LORELEIS_ROOM","PokemonLeague_LoreleisRoom",nil}, + {1,76,"FR_POKEMON_LEAGUE_BRUNOS_ROOM","PokemonLeague_BrunosRoom",nil}, + {1,77,"FR_POKEMON_LEAGUE_AGATHAS_ROOM","PokemonLeague_AgathasRoom",nil}, + {1,78,"FR_POKEMON_LEAGUE_LANCES_ROOM","PokemonLeague_LancesRoom",nil}, + {1,79,"FR_POKEMON_LEAGUE_CHAMPIONS_ROOM","PokemonLeague_ChampionsRoom",nil}, + {1,80,"FR_POKEMON_LEAGUE_HALL_OF_FAME","PokemonLeague_HallOfFame",nil}, + {1,81,"FR_ROCK_TUNNEL_1F","RockTunnel_1F",nil}, + {1,82,"FR_ROCK_TUNNEL_B1F","RockTunnel_B1F",nil}, + {1,83,"FR_SEAFOAM_ISLANDS_1F","SeafoamIslands_1F",nil}, + {1,84,"FR_SEAFOAM_ISLANDS_B1F","SeafoamIslands_B1F",nil}, + {1,85,"FR_SEAFOAM_ISLANDS_B2F","SeafoamIslands_B2F",nil}, + {1,86,"FR_SEAFOAM_ISLANDS_B3F","SeafoamIslands_B3F",nil}, + {1,87,"FR_SEAFOAM_ISLANDS_B4F","SeafoamIslands_B4F",nil}, + {1,88,"FR_POKEMON_TOWER_1F","PokemonTower_1F",nil}, + {1,89,"FR_POKEMON_TOWER_2F","PokemonTower_2F",nil}, + {1,90,"FR_POKEMON_TOWER_3F","PokemonTower_3F",nil}, + {1,91,"FR_POKEMON_TOWER_4F","PokemonTower_4F",nil}, + {1,92,"FR_POKEMON_TOWER_5F","PokemonTower_5F",nil}, + {1,93,"FR_POKEMON_TOWER_6F","PokemonTower_6F",nil}, + {1,94,"FR_POKEMON_TOWER_7F","PokemonTower_7F",nil}, + {1,95,"FR_POWER_PLANT","PowerPlant",nil}, + {1,96,"FR_MT_EMBER_RUBY_PATH_B4F","MtEmber_RubyPath_B4F",nil}, + {1,97,"FR_MT_EMBER_EXTERIOR","MtEmber_Exterior",nil}, + {1,98,"FR_MT_EMBER_SUMMIT_PATH_1F","MtEmber_SummitPath_1F",nil}, + {1,99,"FR_MT_EMBER_SUMMIT_PATH_2F","MtEmber_SummitPath_2F",nil}, + {1,100,"FR_MT_EMBER_SUMMIT_PATH_3F","MtEmber_SummitPath_3F",nil}, + {1,101,"FR_MT_EMBER_SUMMIT","MtEmber_Summit",nil}, + {1,102,"FR_MT_EMBER_RUBY_PATH_B5F","MtEmber_RubyPath_B5F",nil}, + {1,103,"FR_MT_EMBER_RUBY_PATH_1F","MtEmber_RubyPath_1F",nil}, + {1,104,"FR_MT_EMBER_RUBY_PATH_B1F","MtEmber_RubyPath_B1F",nil}, + {1,105,"FR_MT_EMBER_RUBY_PATH_B2F","MtEmber_RubyPath_B2F",nil}, + {1,106,"FR_MT_EMBER_RUBY_PATH_B3F","MtEmber_RubyPath_B3F",nil}, + {1,107,"FR_MT_EMBER_RUBY_PATH_B1F_STAIRS","MtEmber_RubyPath_B1F_Stairs",nil}, + {1,108,"FR_MT_EMBER_RUBY_PATH_B2F_STAIRS","MtEmber_RubyPath_B2F_Stairs",nil}, + {1,109,"FR_THREE_ISLAND_BERRY_FOREST","ThreeIsland_BerryForest",nil}, + {1,110,"FR_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE","FourIsland_IcefallCave_Entrance",nil}, + {1,111,"FR_FOUR_ISLAND_ICEFALL_CAVE_1F","FourIsland_IcefallCave_1F",nil}, + {1,112,"FR_FOUR_ISLAND_ICEFALL_CAVE_B1F","FourIsland_IcefallCave_B1F",nil}, + {1,113,"FR_FOUR_ISLAND_ICEFALL_CAVE_BACK","FourIsland_IcefallCave_Back",nil}, + {1,114,"FR_FIVE_ISLAND_ROCKET_WAREHOUSE","FiveIsland_RocketWarehouse",nil}, + {1,115,"FR_SIX_ISLAND_DOTTED_HOLE_1F","SixIsland_DottedHole_1F",nil}, + {1,116,"FR_SIX_ISLAND_DOTTED_HOLE_B1F","SixIsland_DottedHole_B1F",nil}, + {1,117,"FR_SIX_ISLAND_DOTTED_HOLE_B2F","SixIsland_DottedHole_B2F",nil}, + {1,118,"FR_SIX_ISLAND_DOTTED_HOLE_B3F","SixIsland_DottedHole_B3F",nil}, + {1,119,"FR_SIX_ISLAND_DOTTED_HOLE_B4F","SixIsland_DottedHole_B4F",nil}, + {1,120,"FR_SIX_ISLAND_DOTTED_HOLE_SAPPHIRE_ROOM","SixIsland_DottedHole_SapphireRoom",nil}, + {1,121,"FR_SIX_ISLAND_PATTERN_BUSH","SixIsland_PatternBush",nil}, + {1,122,"FR_SIX_ISLAND_ALTERING_CAVE","SixIsland_AlteringCave",nil}, + {2,0,"FR_NAVEL_ROCK_EXTERIOR","NavelRock_Exterior",nil}, + {2,1,"FR_TRAINER_TOWER_1F","TrainerTower_1F",nil}, + {2,2,"FR_TRAINER_TOWER_2F","TrainerTower_2F",nil}, + {2,3,"FR_TRAINER_TOWER_3F","TrainerTower_3F",nil}, + {2,4,"FR_TRAINER_TOWER_4F","TrainerTower_4F",nil}, + {2,5,"FR_TRAINER_TOWER_5F","TrainerTower_5F",nil}, + {2,6,"FR_TRAINER_TOWER_6F","TrainerTower_6F",nil}, + {2,7,"FR_TRAINER_TOWER_7F","TrainerTower_7F",nil}, + {2,8,"FR_TRAINER_TOWER_8F","TrainerTower_8F",nil}, + {2,9,"FR_TRAINER_TOWER_ROOF","TrainerTower_Roof",nil}, + {2,10,"FR_TRAINER_TOWER_LOBBY","TrainerTower_Lobby",nil}, + {2,11,"FR_TRAINER_TOWER_ELEVATOR","TrainerTower_Elevator",nil}, + {2,12,"FR_FIVE_ISLAND_LOST_CAVE_ENTRANCE","FiveIsland_LostCave_Entrance",nil}, + {2,13,"FR_FIVE_ISLAND_LOST_CAVE_ROOM1","FiveIsland_LostCave_Room1",nil}, + {2,14,"FR_FIVE_ISLAND_LOST_CAVE_ROOM2","FiveIsland_LostCave_Room2",nil}, + {2,15,"FR_FIVE_ISLAND_LOST_CAVE_ROOM3","FiveIsland_LostCave_Room3",nil}, + {2,16,"FR_FIVE_ISLAND_LOST_CAVE_ROOM4","FiveIsland_LostCave_Room4",nil}, + {2,17,"FR_FIVE_ISLAND_LOST_CAVE_ROOM5","FiveIsland_LostCave_Room5",nil}, + {2,18,"FR_FIVE_ISLAND_LOST_CAVE_ROOM6","FiveIsland_LostCave_Room6",nil}, + {2,19,"FR_FIVE_ISLAND_LOST_CAVE_ROOM7","FiveIsland_LostCave_Room7",nil}, + {2,20,"FR_FIVE_ISLAND_LOST_CAVE_ROOM8","FiveIsland_LostCave_Room8",nil}, + {2,21,"FR_FIVE_ISLAND_LOST_CAVE_ROOM9","FiveIsland_LostCave_Room9",nil}, + {2,22,"FR_FIVE_ISLAND_LOST_CAVE_ROOM10","FiveIsland_LostCave_Room10",nil}, + {2,23,"FR_FIVE_ISLAND_LOST_CAVE_ROOM11","FiveIsland_LostCave_Room11",nil}, + {2,24,"FR_FIVE_ISLAND_LOST_CAVE_ROOM12","FiveIsland_LostCave_Room12",nil}, + {2,25,"FR_FIVE_ISLAND_LOST_CAVE_ROOM13","FiveIsland_LostCave_Room13",nil}, + {2,26,"FR_FIVE_ISLAND_LOST_CAVE_ROOM14","FiveIsland_LostCave_Room14",nil}, + {2,27,"FR_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER","SevenIsland_TanobyRuins_MoneanChamber",nil}, + {2,28,"FR_SEVEN_ISLAND_TANOBY_RUINS_LIPTOO_CHAMBER","SevenIsland_TanobyRuins_LiptooChamber",nil}, + {2,29,"FR_SEVEN_ISLAND_TANOBY_RUINS_WEEPTH_CHAMBER","SevenIsland_TanobyRuins_WeepthChamber",nil}, + {2,30,"FR_SEVEN_ISLAND_TANOBY_RUINS_DILFORD_CHAMBER","SevenIsland_TanobyRuins_DilfordChamber",nil}, + {2,31,"FR_SEVEN_ISLAND_TANOBY_RUINS_SCUFIB_CHAMBER","SevenIsland_TanobyRuins_ScufibChamber",nil}, + {2,32,"FR_SEVEN_ISLAND_TANOBY_RUINS_RIXY_CHAMBER","SevenIsland_TanobyRuins_RixyChamber",nil}, + {2,33,"FR_SEVEN_ISLAND_TANOBY_RUINS_VIAPOIS_CHAMBER","SevenIsland_TanobyRuins_ViapoisChamber",nil}, + {2,34,"FR_THREE_ISLAND_DUNSPARCE_TUNNEL","ThreeIsland_DunsparceTunnel",nil}, + {2,35,"FR_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY","SevenIsland_SevaultCanyon_TanobyKey",nil}, + {2,36,"FR_NAVEL_ROCK_1F","NavelRock_1F",nil}, + {2,37,"FR_NAVEL_ROCK_SUMMIT","NavelRock_Summit",nil}, + {2,38,"FR_NAVEL_ROCK_BASE","NavelRock_Base",nil}, + {2,39,"FR_NAVEL_ROCK_SUMMIT_PATH_2F","NavelRock_SummitPath_2F",nil}, + {2,40,"FR_NAVEL_ROCK_SUMMIT_PATH_3F","NavelRock_SummitPath_3F",nil}, + {2,41,"FR_NAVEL_ROCK_SUMMIT_PATH_4F","NavelRock_SummitPath_4F",nil}, + {2,42,"FR_NAVEL_ROCK_SUMMIT_PATH_5F","NavelRock_SummitPath_5F",nil}, + {2,43,"FR_NAVEL_ROCK_BASE_PATH_B1F","NavelRock_BasePath_B1F",nil}, + {2,44,"FR_NAVEL_ROCK_BASE_PATH_B2F","NavelRock_BasePath_B2F",nil}, + {2,45,"FR_NAVEL_ROCK_BASE_PATH_B3F","NavelRock_BasePath_B3F",nil}, + {2,46,"FR_NAVEL_ROCK_BASE_PATH_B4F","NavelRock_BasePath_B4F",nil}, + {2,47,"FR_NAVEL_ROCK_BASE_PATH_B5F","NavelRock_BasePath_B5F",nil}, + {2,48,"FR_NAVEL_ROCK_BASE_PATH_B6F","NavelRock_BasePath_B6F",nil}, + {2,49,"FR_NAVEL_ROCK_BASE_PATH_B7F","NavelRock_BasePath_B7F",nil}, + {2,50,"FR_NAVEL_ROCK_BASE_PATH_B8F","NavelRock_BasePath_B8F",nil}, + {2,51,"FR_NAVEL_ROCK_BASE_PATH_B9F","NavelRock_BasePath_B9F",nil}, + {2,52,"FR_NAVEL_ROCK_BASE_PATH_B10F","NavelRock_BasePath_B10F",nil}, + {2,53,"FR_NAVEL_ROCK_BASE_PATH_B11F","NavelRock_BasePath_B11F",nil}, + {2,54,"FR_NAVEL_ROCK_B1F","NavelRock_B1F",nil}, + {2,55,"FR_NAVEL_ROCK_FORK","NavelRock_Fork",nil}, + {2,56,"FR_BIRTH_ISLAND_EXTERIOR","BirthIsland_Exterior",nil}, + {2,57,"FR_ONE_ISLAND_KINDLE_ROAD_EMBER_SPA","OneIsland_KindleRoad_EmberSpa",nil}, + {2,58,"FR_BIRTH_ISLAND_HARBOR","BirthIsland_Harbor",nil}, + {2,59,"FR_NAVEL_ROCK_HARBOR","NavelRock_Harbor",nil}, + {3,0,"FR_PALLET_TOWN","PalletTown",nil}, + {3,1,"FR_VIRIDIAN_CITY","ViridianCity",nil}, + {3,2,"FR_PEWTER_CITY","PewterCity",nil}, + {3,3,"FR_CERULEAN_CITY","CeruleanCity",nil}, + {3,4,"FR_LAVENDER_TOWN","LavenderTown",nil}, + {3,5,"FR_VERMILION_CITY","VermilionCity",nil}, + {3,6,"FR_CELADON_CITY","CeladonCity",nil}, + {3,7,"FR_FUCHSIA_CITY","FuchsiaCity",nil}, + {3,8,"FR_CINNABAR_ISLAND","CinnabarIsland",nil}, + {3,9,"FR_INDIGO_PLATEAU_EXTERIOR","IndigoPlateau_Exterior",nil}, + {3,10,"FR_SAFFRON_CITY","SaffronCity",nil}, + {3,11,"FR_SAFFRON_CITY_CONNECTION","SaffronCity_Connection",nil}, + {3,12,"SEVII_ONE_ISLAND","OneIsland",nil}, + {3,13,"FR_TWO_ISLAND","TwoIsland",nil}, + {3,14,"FR_THREE_ISLAND","ThreeIsland",nil}, + {3,15,"FR_FOUR_ISLAND","FourIsland",nil}, + {3,16,"FR_FIVE_ISLAND","FiveIsland",nil}, + {3,17,"FR_SEVEN_ISLAND","SevenIsland",nil}, + {3,18,"FR_SIX_ISLAND","SixIsland",nil}, + {3,19,"FR_ROUTE_1","Route1",nil}, + {3,20,"FR_ROUTE_2","Route2",nil}, + {3,21,"FR_ROUTE_3","Route3",nil}, + {3,22,"FR_ROUTE_4","Route4",nil}, + {3,23,"FR_ROUTE_5","Route5",nil}, + {3,24,"FR_ROUTE_6","Route6",nil}, + {3,25,"FR_ROUTE_7","Route7",nil}, + {3,26,"FR_ROUTE_8","Route8",nil}, + {3,27,"FR_ROUTE_9","Route9",nil}, + {3,28,"FR_ROUTE_10","Route10",nil}, + {3,29,"FR_ROUTE_11","Route11",nil}, + {3,30,"FR_ROUTE_12","Route12",nil}, + {3,31,"FR_ROUTE_13","Route13",nil}, + {3,32,"FR_ROUTE_14","Route14",nil}, + {3,33,"FR_ROUTE_15","Route15",nil}, + {3,34,"FR_ROUTE_16","Route16",nil}, + {3,35,"FR_ROUTE_17","Route17",nil}, + {3,36,"FR_ROUTE_18","Route18",nil}, + {3,37,"FR_ROUTE_19","Route19",nil}, + {3,38,"FR_ROUTE_20","Route20",nil}, + {3,39,"FR_ROUTE_21_NORTH","Route21_North",nil}, + {3,40,"FR_ROUTE_21_SOUTH","Route21_South",nil}, + {3,41,"FR_ROUTE_22","Route22",nil}, + {3,42,"FR_ROUTE_23","Route23",nil}, + {3,43,"FR_ROUTE_24","Route24",nil}, + {3,44,"FR_ROUTE_25","Route25",nil}, + {3,45,"SEVII_ONE_ISLAND_KINDLE_ROAD","OneIsland_KindleRoad",nil}, + {3,46,"SEVII_ONE_ISLAND_TREASURE_BEACH","OneIsland_TreasureBeach",nil}, + {3,47,"FR_TWO_ISLAND_CAPE_BRINK","TwoIsland_CapeBrink",nil}, + {3,48,"FR_THREE_ISLAND_BOND_BRIDGE","ThreeIsland_BondBridge",nil}, + {3,49,"FR_THREE_ISLAND_PORT","ThreeIsland_Port",nil}, + {3,50,"FR_PROTOTYPE_SEVII_ISLE_6","Prototype_SeviiIsle_6",nil}, + {3,51,"FR_PROTOTYPE_SEVII_ISLE_7","Prototype_SeviiIsle_7",nil}, + {3,52,"FR_PROTOTYPE_SEVII_ISLE_8","Prototype_SeviiIsle_8",nil}, + {3,53,"FR_PROTOTYPE_SEVII_ISLE_9","Prototype_SeviiIsle_9",nil}, + {3,54,"FR_FIVE_ISLAND_RESORT_GORGEOUS","FiveIsland_ResortGorgeous",nil}, + {3,55,"FR_FIVE_ISLAND_WATER_LABYRINTH","FiveIsland_WaterLabyrinth",nil}, + {3,56,"FR_FIVE_ISLAND_MEADOW","FiveIsland_Meadow",nil}, + {3,57,"FR_FIVE_ISLAND_MEMORIAL_PILLAR","FiveIsland_MemorialPillar",nil}, + {3,58,"FR_SIX_ISLAND_OUTCAST_ISLAND","SixIsland_OutcastIsland",nil}, + {3,59,"FR_SIX_ISLAND_GREEN_PATH","SixIsland_GreenPath",nil}, + {3,60,"FR_SIX_ISLAND_WATER_PATH","SixIsland_WaterPath",nil}, + {3,61,"FR_SIX_ISLAND_RUIN_VALLEY","SixIsland_RuinValley",nil}, + {3,62,"FR_SEVEN_ISLAND_TRAINER_TOWER","SevenIsland_TrainerTower",nil}, + {3,63,"FR_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE","SevenIsland_SevaultCanyon_Entrance",nil}, + {3,64,"FR_SEVEN_ISLAND_SEVAULT_CANYON","SevenIsland_SevaultCanyon",nil}, + {3,65,"FR_SEVEN_ISLAND_TANOBY_RUINS","SevenIsland_TanobyRuins",nil}, + {4,0,"FR_PLAYERS_HOUSE_1F","PalletTown_PlayersHouse_1F",nil}, + {4,1,"FR_PLAYERS_HOUSE_2F","PalletTown_PlayersHouse_2F",nil}, + {4,2,"FR_RIVALS_HOUSE","PalletTown_RivalsHouse",nil}, + {4,3,"FR_OAKS_LAB","PalletTown_ProfessorOaksLab",nil}, + {5,0,"FR_VIRIDIAN_CITY_HOUSE","ViridianCity_House",nil}, + {5,1,"FR_VIRIDIAN_CITY_GYM","ViridianCity_Gym",nil}, + {5,2,"FR_VIRIDIAN_CITY_SCHOOL","ViridianCity_School",nil}, + {5,3,"FR_VIRIDIAN_CITY_MART","ViridianCity_Mart",nil}, + {5,4,"FR_VIRIDIAN_CITY_POKEMON_CENTER_1F","ViridianCity_PokemonCenter_1F",nil}, + {5,5,"FR_VIRIDIAN_CITY_POKEMON_CENTER_2F","ViridianCity_PokemonCenter_2F",nil}, + {6,0,"FR_PEWTER_CITY_MUSEUM_1F","PewterCity_Museum_1F",nil}, + {6,1,"FR_PEWTER_CITY_MUSEUM_2F","PewterCity_Museum_2F",nil}, + {6,2,"FR_PEWTER_CITY_GYM","PewterCity_Gym",nil}, + {6,3,"FR_PEWTER_CITY_MART","PewterCity_Mart",nil}, + {6,4,"FR_PEWTER_CITY_HOUSE1","PewterCity_House1",nil}, + {6,5,"FR_PEWTER_CITY_POKEMON_CENTER_1F","PewterCity_PokemonCenter_1F",nil}, + {6,6,"FR_PEWTER_CITY_POKEMON_CENTER_2F","PewterCity_PokemonCenter_2F",nil}, + {6,7,"FR_PEWTER_CITY_HOUSE2","PewterCity_House2",nil}, + {7,0,"FR_CERULEAN_CITY_HOUSE1","CeruleanCity_House1",nil}, + {7,1,"FR_CERULEAN_CITY_HOUSE2","CeruleanCity_House2",nil}, + {7,2,"FR_CERULEAN_CITY_HOUSE3","CeruleanCity_House3",nil}, + {7,3,"FR_CERULEAN_CITY_POKEMON_CENTER_1F","CeruleanCity_PokemonCenter_1F",nil}, + {7,4,"FR_CERULEAN_CITY_POKEMON_CENTER_2F","CeruleanCity_PokemonCenter_2F",nil}, + {7,5,"FR_CERULEAN_CITY_GYM","CeruleanCity_Gym",nil}, + {7,6,"FR_CERULEAN_CITY_BIKE_SHOP","CeruleanCity_BikeShop",nil}, + {7,7,"FR_CERULEAN_CITY_MART","CeruleanCity_Mart",nil}, + {7,8,"FR_CERULEAN_CITY_HOUSE4","CeruleanCity_House4",nil}, + {7,9,"FR_CERULEAN_CITY_HOUSE5","CeruleanCity_House5",nil}, + {8,0,"FR_LAVENDER_TOWN_POKEMON_CENTER_1F","LavenderTown_PokemonCenter_1F",nil}, + {8,1,"FR_LAVENDER_TOWN_POKEMON_CENTER_2F","LavenderTown_PokemonCenter_2F",nil}, + {8,2,"FR_LAVENDER_TOWN_VOLUNTEER_POKEMON_HOUSE","LavenderTown_VolunteerPokemonHouse",nil}, + {8,3,"FR_LAVENDER_TOWN_HOUSE1","LavenderTown_House1",nil}, + {8,4,"FR_LAVENDER_TOWN_HOUSE2","LavenderTown_House2",nil}, + {8,5,"FR_LAVENDER_TOWN_MART","LavenderTown_Mart",nil}, + {9,0,"FR_VERMILION_CITY_HOUSE1","VermilionCity_House1",nil}, + {9,1,"FR_VERMILION_CITY_POKEMON_CENTER_1F","VermilionCity_PokemonCenter_1F",nil}, + {9,2,"FR_VERMILION_CITY_POKEMON_CENTER_2F","VermilionCity_PokemonCenter_2F",nil}, + {9,3,"FR_VERMILION_CITY_POKEMON_FAN_CLUB","VermilionCity_PokemonFanClub",nil}, + {9,4,"FR_VERMILION_CITY_HOUSE2","VermilionCity_House2",nil}, + {9,5,"FR_VERMILION_CITY_MART","VermilionCity_Mart",nil}, + {9,6,"FR_VERMILION_CITY_GYM","VermilionCity_Gym",nil}, + {9,7,"FR_VERMILION_CITY_HOUSE3","VermilionCity_House3",nil}, + {10,0,"FR_CELADON_CITY_DEPARTMENT_STORE_1F","CeladonCity_DepartmentStore_1F",nil}, + {10,1,"FR_CELADON_CITY_DEPARTMENT_STORE_2F","CeladonCity_DepartmentStore_2F",nil}, + {10,2,"FR_CELADON_CITY_DEPARTMENT_STORE_3F","CeladonCity_DepartmentStore_3F",nil}, + {10,3,"FR_CELADON_CITY_DEPARTMENT_STORE_4F","CeladonCity_DepartmentStore_4F",nil}, + {10,4,"FR_CELADON_CITY_DEPARTMENT_STORE_5F","CeladonCity_DepartmentStore_5F",nil}, + {10,5,"FR_CELADON_CITY_DEPARTMENT_STORE_ROOF","CeladonCity_DepartmentStore_Roof",nil}, + {10,6,"FR_CELADON_CITY_DEPARTMENT_STORE_ELEVATOR","CeladonCity_DepartmentStore_Elevator",nil}, + {10,7,"FR_CELADON_CITY_CONDOMINIUMS_1F","CeladonCity_Condominiums_1F",nil}, + {10,8,"FR_CELADON_CITY_CONDOMINIUMS_2F","CeladonCity_Condominiums_2F",nil}, + {10,9,"FR_CELADON_CITY_CONDOMINIUMS_3F","CeladonCity_Condominiums_3F",nil}, + {10,10,"FR_CELADON_CITY_CONDOMINIUMS_ROOF","CeladonCity_Condominiums_Roof",nil}, + {10,11,"FR_CELADON_CITY_CONDOMINIUMS_ROOF_ROOM","CeladonCity_Condominiums_RoofRoom",nil}, + {10,12,"FR_CELADON_CITY_POKEMON_CENTER_1F","CeladonCity_PokemonCenter_1F",nil}, + {10,13,"FR_CELADON_CITY_POKEMON_CENTER_2F","CeladonCity_PokemonCenter_2F",nil}, + {10,14,"FR_CELADON_CITY_GAME_CORNER","CeladonCity_GameCorner",nil}, + {10,15,"FR_CELADON_CITY_GAME_CORNER_PRIZE_ROOM","CeladonCity_GameCorner_PrizeRoom",nil}, + {10,16,"FR_CELADON_CITY_GYM","CeladonCity_Gym",nil}, + {10,17,"FR_CELADON_CITY_RESTAURANT","CeladonCity_Restaurant",nil}, + {10,18,"FR_CELADON_CITY_HOUSE1","CeladonCity_House1",nil}, + {10,19,"FR_CELADON_CITY_HOTEL","CeladonCity_Hotel",nil}, + {11,0,"FR_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE","FuchsiaCity_SafariZone_Entrance",nil}, + {11,1,"FR_FUCHSIA_CITY_MART","FuchsiaCity_Mart",nil}, + {11,2,"FR_FUCHSIA_CITY_SAFARI_ZONE_OFFICE","FuchsiaCity_SafariZone_Office",nil}, + {11,3,"FR_FUCHSIA_CITY_GYM","FuchsiaCity_Gym",nil}, + {11,4,"FR_FUCHSIA_CITY_HOUSE1","FuchsiaCity_House1",nil}, + {11,5,"FR_FUCHSIA_CITY_POKEMON_CENTER_1F","FuchsiaCity_PokemonCenter_1F",nil}, + {11,6,"FR_FUCHSIA_CITY_POKEMON_CENTER_2F","FuchsiaCity_PokemonCenter_2F",nil}, + {11,7,"FR_FUCHSIA_CITY_WARDENS_HOUSE","FuchsiaCity_WardensHouse",nil}, + {11,8,"FR_FUCHSIA_CITY_HOUSE2","FuchsiaCity_House2",nil}, + {11,9,"FR_FUCHSIA_CITY_HOUSE3","FuchsiaCity_House3",nil}, + {12,0,"FR_CINNABAR_ISLAND_GYM","CinnabarIsland_Gym",nil}, + {12,1,"FR_CINNABAR_ISLAND_POKEMON_LAB_ENTRANCE","CinnabarIsland_PokemonLab_Entrance",nil}, + {12,2,"FR_CINNABAR_ISLAND_POKEMON_LAB_LOUNGE","CinnabarIsland_PokemonLab_Lounge",nil}, + {12,3,"FR_CINNABAR_ISLAND_POKEMON_LAB_RESEARCH_ROOM","CinnabarIsland_PokemonLab_ResearchRoom",nil}, + {12,4,"FR_CINNABAR_ISLAND_POKEMON_LAB_EXPERIMENT_ROOM","CinnabarIsland_PokemonLab_ExperimentRoom",nil}, + {12,5,"FR_CINNABAR_ISLAND_POKEMON_CENTER_1F","CinnabarIsland_PokemonCenter_1F",nil}, + {12,6,"FR_CINNABAR_ISLAND_POKEMON_CENTER_2F","CinnabarIsland_PokemonCenter_2F",nil}, + {12,7,"FR_CINNABAR_ISLAND_MART","CinnabarIsland_Mart",nil}, + {13,0,"FR_INDIGO_PLATEAU_POKEMON_CENTER_1F","IndigoPlateau_PokemonCenter_1F",nil}, + {13,1,"FR_INDIGO_PLATEAU_POKEMON_CENTER_2F","IndigoPlateau_PokemonCenter_2F",nil}, + {14,0,"FR_SAFFRON_CITY_COPYCATS_HOUSE_1F","SaffronCity_CopycatsHouse_1F",nil}, + {14,1,"FR_SAFFRON_CITY_COPYCATS_HOUSE_2F","SaffronCity_CopycatsHouse_2F",nil}, + {14,2,"FR_SAFFRON_CITY_DOJO","SaffronCity_Dojo",nil}, + {14,3,"FR_SAFFRON_CITY_GYM","SaffronCity_Gym",nil}, + {14,4,"FR_SAFFRON_CITY_HOUSE","SaffronCity_House",nil}, + {14,5,"FR_SAFFRON_CITY_MART","SaffronCity_Mart",nil}, + {14,6,"FR_SAFFRON_CITY_POKEMON_CENTER_1F","SaffronCity_PokemonCenter_1F",nil}, + {14,7,"FR_SAFFRON_CITY_POKEMON_CENTER_2F","SaffronCity_PokemonCenter_2F",nil}, + {14,8,"FR_SAFFRON_CITY_MR_PSYCHICS_HOUSE","SaffronCity_MrPsychicsHouse",nil}, + {14,9,"FR_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB","SaffronCity_PokemonTrainerFanClub",nil}, + {15,0,"FR_ROUTE_2_VIRIDIAN_FOREST_SOUTH_ENTRANCE","Route2_ViridianForest_SouthEntrance",nil}, + {15,1,"FR_ROUTE_2_HOUSE","Route2_House",nil}, + {15,2,"FR_ROUTE_2_EAST_BUILDING","Route2_EastBuilding",nil}, + {15,3,"FR_ROUTE_2_VIRIDIAN_FOREST_NORTH_ENTRANCE","Route2_ViridianForest_NorthEntrance",nil}, + {16,0,"FR_ROUTE_4_POKEMON_CENTER_1F","Route4_PokemonCenter_1F",nil}, + {16,1,"FR_ROUTE_4_POKEMON_CENTER_2F","Route4_PokemonCenter_2F",nil}, + {17,0,"FR_ROUTE_5_POKEMON_DAY_CARE","Route5_PokemonDayCare",nil}, + {17,1,"FR_ROUTE_5_SOUTH_ENTRANCE","Route5_SouthEntrance",nil}, + {18,0,"FR_ROUTE_6_NORTH_ENTRANCE","Route6_NorthEntrance",nil}, + {18,1,"FR_ROUTE_6_UNUSED_HOUSE","Route6_UnusedHouse",nil}, + {19,0,"FR_ROUTE_7_EAST_ENTRANCE","Route7_EastEntrance",nil}, + {20,0,"FR_ROUTE_8_WEST_ENTRANCE","Route8_WestEntrance",nil}, + {21,0,"FR_ROUTE_10_POKEMON_CENTER_1F","Route10_PokemonCenter_1F",nil}, + {21,1,"FR_ROUTE_10_POKEMON_CENTER_2F","Route10_PokemonCenter_2F",nil}, + {22,0,"FR_ROUTE_11_EAST_ENTRANCE_1F","Route11_EastEntrance_1F",nil}, + {22,1,"FR_ROUTE_11_EAST_ENTRANCE_2F","Route11_EastEntrance_2F",nil}, + {23,0,"FR_ROUTE_12_NORTH_ENTRANCE_1F","Route12_NorthEntrance_1F",nil}, + {23,1,"FR_ROUTE_12_NORTH_ENTRANCE_2F","Route12_NorthEntrance_2F",nil}, + {23,2,"FR_ROUTE_12_FISHING_HOUSE","Route12_FishingHouse",nil}, + {24,0,"FR_ROUTE_15_WEST_ENTRANCE_1F","Route15_WestEntrance_1F",nil}, + {24,1,"FR_ROUTE_15_WEST_ENTRANCE_2F","Route15_WestEntrance_2F",nil}, + {25,0,"FR_ROUTE_16_HOUSE","Route16_House",nil}, + {25,1,"FR_ROUTE_16_NORTH_ENTRANCE_1F","Route16_NorthEntrance_1F",nil}, + {25,2,"FR_ROUTE_16_NORTH_ENTRANCE_2F","Route16_NorthEntrance_2F",nil}, + {26,0,"FR_ROUTE_18_EAST_ENTRANCE_1F","Route18_EastEntrance_1F",nil}, + {26,1,"FR_ROUTE_18_EAST_ENTRANCE_2F","Route18_EastEntrance_2F",nil}, + {27,0,"FR_ROUTE_19_UNUSED_HOUSE","Route19_UnusedHouse",nil}, + {28,0,"FR_ROUTE_22_NORTH_ENTRANCE","Route22_NorthEntrance",nil}, + {29,0,"FR_ROUTE_23_UNUSED_HOUSE","Route23_UnusedHouse",nil}, + {30,0,"FR_ROUTE_25_SEA_COTTAGE","Route25_SeaCottage",nil}, + {31,0,"FR_SEVEN_ISLAND_HOUSE_ROOM1","SevenIsland_House_Room1",nil}, + {31,1,"FR_SEVEN_ISLAND_HOUSE_ROOM2","SevenIsland_House_Room2",nil}, + {31,2,"FR_SEVEN_ISLAND_MART","SevenIsland_Mart",nil}, + {31,3,"FR_SEVEN_ISLAND_POKEMON_CENTER_1F","SevenIsland_PokemonCenter_1F",nil}, + {31,4,"FR_SEVEN_ISLAND_POKEMON_CENTER_2F","SevenIsland_PokemonCenter_2F",nil}, + {31,5,"FR_SEVEN_ISLAND_UNUSED_HOUSE","SevenIsland_UnusedHouse",nil}, + {31,6,"FR_SEVEN_ISLAND_HARBOR","SevenIsland_Harbor",nil}, + {32,0,"SEVII_ONE_ISLAND_POKECENTER","OneIsland_PokemonCenter_1F",nil}, + {32,1,"SEVII_ONE_ISLAND_POKECENTER_2F","OneIsland_PokemonCenter_2F",nil}, + {32,2,"SEVII_ONE_ISLAND_HOUSE1","OneIsland_House1",nil}, + {32,3,"SEVII_ONE_ISLAND_HOUSE2","OneIsland_House2",nil}, + {32,4,"SEVII_ONE_ISLAND_HARBOR","OneIsland_Harbor",nil}, + {33,0,"FR_TWO_ISLAND_JOYFUL_GAME_CORNER","TwoIsland_JoyfulGameCorner",nil}, + {33,1,"FR_TWO_ISLAND_HOUSE","TwoIsland_House",nil}, + {33,2,"FR_TWO_ISLAND_POKEMON_CENTER_1F","TwoIsland_PokemonCenter_1F",nil}, + {33,3,"FR_TWO_ISLAND_POKEMON_CENTER_2F","TwoIsland_PokemonCenter_2F",nil}, + {33,4,"FR_TWO_ISLAND_HARBOR","TwoIsland_Harbor",nil}, + {34,0,"FR_THREE_ISLAND_HOUSE1","ThreeIsland_House1",nil}, + {34,1,"FR_THREE_ISLAND_POKEMON_CENTER_1F","ThreeIsland_PokemonCenter_1F",nil}, + {34,2,"FR_THREE_ISLAND_POKEMON_CENTER_2F","ThreeIsland_PokemonCenter_2F",nil}, + {34,3,"FR_THREE_ISLAND_MART","ThreeIsland_Mart",nil}, + {34,4,"FR_THREE_ISLAND_HOUSE2","ThreeIsland_House2",nil}, + {34,5,"FR_THREE_ISLAND_HOUSE3","ThreeIsland_House3",nil}, + {34,6,"FR_THREE_ISLAND_HOUSE4","ThreeIsland_House4",nil}, + {34,7,"FR_THREE_ISLAND_HOUSE5","ThreeIsland_House5",nil}, + {35,0,"FR_FOUR_ISLAND_POKEMON_DAY_CARE","FourIsland_PokemonDayCare",nil}, + {35,1,"FR_FOUR_ISLAND_POKEMON_CENTER_1F","FourIsland_PokemonCenter_1F",nil}, + {35,2,"FR_FOUR_ISLAND_POKEMON_CENTER_2F","FourIsland_PokemonCenter_2F",nil}, + {35,3,"FR_FOUR_ISLAND_HOUSE1","FourIsland_House1",nil}, + {35,4,"FR_FOUR_ISLAND_LORELEIS_HOUSE","FourIsland_LoreleisHouse",nil}, + {35,5,"FR_FOUR_ISLAND_HARBOR","FourIsland_Harbor",nil}, + {35,6,"FR_FOUR_ISLAND_HOUSE2","FourIsland_House2",nil}, + {35,7,"FR_FOUR_ISLAND_MART","FourIsland_Mart",nil}, + {36,0,"FR_FIVE_ISLAND_POKEMON_CENTER_1F","FiveIsland_PokemonCenter_1F",nil}, + {36,1,"FR_FIVE_ISLAND_POKEMON_CENTER_2F","FiveIsland_PokemonCenter_2F",nil}, + {36,2,"FR_FIVE_ISLAND_HARBOR","FiveIsland_Harbor",nil}, + {36,3,"FR_FIVE_ISLAND_HOUSE1","FiveIsland_House1",nil}, + {36,4,"FR_FIVE_ISLAND_HOUSE2","FiveIsland_House2",nil}, + {37,0,"FR_SIX_ISLAND_POKEMON_CENTER_1F","SixIsland_PokemonCenter_1F",nil}, + {37,1,"FR_SIX_ISLAND_POKEMON_CENTER_2F","SixIsland_PokemonCenter_2F",nil}, + {37,2,"FR_SIX_ISLAND_HARBOR","SixIsland_Harbor",nil}, + {37,3,"FR_SIX_ISLAND_HOUSE","SixIsland_House",nil}, + {37,4,"FR_SIX_ISLAND_MART","SixIsland_Mart",nil}, + {38,0,"FR_THREE_ISLAND_HARBOR","ThreeIsland_Harbor",nil}, + {39,0,"FR_FIVE_ISLAND_RESORT_GORGEOUS_HOUSE","FiveIsland_ResortGorgeous_House",nil}, + {40,0,"FR_TWO_ISLAND_CAPE_BRINK_HOUSE","TwoIsland_CapeBrink_House",nil}, + {41,0,"FR_SIX_ISLAND_WATER_PATH_HOUSE1","SixIsland_WaterPath_House1",nil}, + {41,1,"FR_SIX_ISLAND_WATER_PATH_HOUSE2","SixIsland_WaterPath_House2",nil}, + {42,0,"FR_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE","SevenIsland_SevaultCanyon_House",nil}, + }, + ["leafgreen"]={ + {0,0,"FR_BATTLE_COLOSSEUM_2P","BattleColosseum_2P",nil}, + {0,1,"FR_TRADE_CENTER","TradeCenter",nil}, + {0,2,"FR_RECORD_CORNER","RecordCorner",nil}, + {0,3,"FR_BATTLE_COLOSSEUM_4P","BattleColosseum_4P",nil}, + {0,4,"FR_UNION_ROOM","UnionRoom",nil}, + {1,0,"FR_VIRIDIAN_FOREST","ViridianForest",nil}, + {1,1,"FR_MT_MOON_1F","MtMoon_1F",nil}, + {1,2,"FR_MT_MOON_B1F","MtMoon_B1F",nil}, + {1,3,"FR_MT_MOON_B2F","MtMoon_B2F",nil}, + {1,4,"FR_SSANNE_EXTERIOR","SSAnne_Exterior",nil}, + {1,5,"FR_SSANNE_1F_CORRIDOR","SSAnne_1F_Corridor",nil}, + {1,6,"FR_SSANNE_2F_CORRIDOR","SSAnne_2F_Corridor",nil}, + {1,7,"FR_SSANNE_3F_CORRIDOR","SSAnne_3F_Corridor",nil}, + {1,8,"FR_SSANNE_B1F_CORRIDOR","SSAnne_B1F_Corridor",nil}, + {1,9,"FR_SSANNE_DECK","SSAnne_Deck",nil}, + {1,10,"FR_SSANNE_KITCHEN","SSAnne_Kitchen",nil}, + {1,11,"FR_SSANNE_CAPTAINS_OFFICE","SSAnne_CaptainsOffice",nil}, + {1,12,"FR_SSANNE_1F_ROOM1","SSAnne_1F_Room1",nil}, + {1,13,"FR_SSANNE_1F_ROOM2","SSAnne_1F_Room2",nil}, + {1,14,"FR_SSANNE_1F_ROOM3","SSAnne_1F_Room3",nil}, + {1,15,"FR_SSANNE_1F_ROOM4","SSAnne_1F_Room4",nil}, + {1,16,"FR_SSANNE_1F_ROOM5","SSAnne_1F_Room5",nil}, + {1,17,"FR_SSANNE_1F_ROOM7","SSAnne_1F_Room7",nil}, + {1,18,"FR_SSANNE_2F_ROOM1","SSAnne_2F_Room1",nil}, + {1,19,"FR_SSANNE_2F_ROOM2","SSAnne_2F_Room2",nil}, + {1,20,"FR_SSANNE_2F_ROOM3","SSAnne_2F_Room3",nil}, + {1,21,"FR_SSANNE_2F_ROOM4","SSAnne_2F_Room4",nil}, + {1,22,"FR_SSANNE_2F_ROOM5","SSAnne_2F_Room5",nil}, + {1,23,"FR_SSANNE_2F_ROOM6","SSAnne_2F_Room6",nil}, + {1,24,"FR_SSANNE_B1F_ROOM1","SSAnne_B1F_Room1",nil}, + {1,25,"FR_SSANNE_B1F_ROOM2","SSAnne_B1F_Room2",nil}, + {1,26,"FR_SSANNE_B1F_ROOM3","SSAnne_B1F_Room3",nil}, + {1,27,"FR_SSANNE_B1F_ROOM4","SSAnne_B1F_Room4",nil}, + {1,28,"FR_SSANNE_B1F_ROOM5","SSAnne_B1F_Room5",nil}, + {1,29,"FR_SSANNE_1F_ROOM6","SSAnne_1F_Room6",nil}, + {1,30,"FR_UNDERGROUND_PATH_NORTH_ENTRANCE","UndergroundPath_NorthEntrance",nil}, + {1,31,"FR_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL","UndergroundPath_NorthSouthTunnel",nil}, + {1,32,"FR_UNDERGROUND_PATH_SOUTH_ENTRANCE","UndergroundPath_SouthEntrance",nil}, + {1,33,"FR_UNDERGROUND_PATH_WEST_ENTRANCE","UndergroundPath_WestEntrance",nil}, + {1,34,"FR_UNDERGROUND_PATH_EAST_WEST_TUNNEL","UndergroundPath_EastWestTunnel",nil}, + {1,35,"FR_UNDERGROUND_PATH_EAST_ENTRANCE","UndergroundPath_EastEntrance",nil}, + {1,36,"FR_DIGLETTS_CAVE_NORTH_ENTRANCE","DiglettsCave_NorthEntrance",nil}, + {1,37,"FR_DIGLETTS_CAVE_B1F","DiglettsCave_B1F",nil}, + {1,38,"FR_DIGLETTS_CAVE_SOUTH_ENTRANCE","DiglettsCave_SouthEntrance",nil}, + {1,39,"FR_VICTORY_ROAD_1F","VictoryRoad_1F",nil}, + {1,40,"FR_VICTORY_ROAD_2F","VictoryRoad_2F",nil}, + {1,41,"FR_VICTORY_ROAD_3F","VictoryRoad_3F",nil}, + {1,42,"FR_ROCKET_HIDEOUT_B1F","RocketHideout_B1F",nil}, + {1,43,"FR_ROCKET_HIDEOUT_B2F","RocketHideout_B2F",nil}, + {1,44,"FR_ROCKET_HIDEOUT_B3F","RocketHideout_B3F",nil}, + {1,45,"FR_ROCKET_HIDEOUT_B4F","RocketHideout_B4F",nil}, + {1,46,"FR_ROCKET_HIDEOUT_ELEVATOR","RocketHideout_Elevator",nil}, + {1,47,"FR_SILPH_CO_1F","SilphCo_1F",nil}, + {1,48,"FR_SILPH_CO_2F","SilphCo_2F",nil}, + {1,49,"FR_SILPH_CO_3F","SilphCo_3F",nil}, + {1,50,"FR_SILPH_CO_4F","SilphCo_4F",nil}, + {1,51,"FR_SILPH_CO_5F","SilphCo_5F",nil}, + {1,52,"FR_SILPH_CO_6F","SilphCo_6F",nil}, + {1,53,"FR_SILPH_CO_7F","SilphCo_7F",nil}, + {1,54,"FR_SILPH_CO_8F","SilphCo_8F",nil}, + {1,55,"FR_SILPH_CO_9F","SilphCo_9F",nil}, + {1,56,"FR_SILPH_CO_10F","SilphCo_10F",nil}, + {1,57,"FR_SILPH_CO_11F","SilphCo_11F",nil}, + {1,58,"FR_SILPH_CO_ELEVATOR","SilphCo_Elevator",nil}, + {1,59,"FR_POKEMON_MANSION_1F","PokemonMansion_1F",nil}, + {1,60,"FR_POKEMON_MANSION_2F","PokemonMansion_2F",nil}, + {1,61,"FR_POKEMON_MANSION_3F","PokemonMansion_3F",nil}, + {1,62,"FR_POKEMON_MANSION_B1F","PokemonMansion_B1F",nil}, + {1,63,"FR_SAFARI_ZONE_CENTER","SafariZone_Center",nil}, + {1,64,"FR_SAFARI_ZONE_EAST","SafariZone_East",nil}, + {1,65,"FR_SAFARI_ZONE_NORTH","SafariZone_North",nil}, + {1,66,"FR_SAFARI_ZONE_WEST","SafariZone_West",nil}, + {1,67,"FR_SAFARI_ZONE_CENTER_REST_HOUSE","SafariZone_Center_RestHouse",nil}, + {1,68,"FR_SAFARI_ZONE_EAST_REST_HOUSE","SafariZone_East_RestHouse",nil}, + {1,69,"FR_SAFARI_ZONE_NORTH_REST_HOUSE","SafariZone_North_RestHouse",nil}, + {1,70,"FR_SAFARI_ZONE_WEST_REST_HOUSE","SafariZone_West_RestHouse",nil}, + {1,71,"FR_SAFARI_ZONE_SECRET_HOUSE","SafariZone_SecretHouse",nil}, + {1,72,"FR_CERULEAN_CAVE_1F","CeruleanCave_1F",nil}, + {1,73,"FR_CERULEAN_CAVE_2F","CeruleanCave_2F",nil}, + {1,74,"FR_CERULEAN_CAVE_B1F","CeruleanCave_B1F",nil}, + {1,75,"FR_POKEMON_LEAGUE_LORELEIS_ROOM","PokemonLeague_LoreleisRoom",nil}, + {1,76,"FR_POKEMON_LEAGUE_BRUNOS_ROOM","PokemonLeague_BrunosRoom",nil}, + {1,77,"FR_POKEMON_LEAGUE_AGATHAS_ROOM","PokemonLeague_AgathasRoom",nil}, + {1,78,"FR_POKEMON_LEAGUE_LANCES_ROOM","PokemonLeague_LancesRoom",nil}, + {1,79,"FR_POKEMON_LEAGUE_CHAMPIONS_ROOM","PokemonLeague_ChampionsRoom",nil}, + {1,80,"FR_POKEMON_LEAGUE_HALL_OF_FAME","PokemonLeague_HallOfFame",nil}, + {1,81,"FR_ROCK_TUNNEL_1F","RockTunnel_1F",nil}, + {1,82,"FR_ROCK_TUNNEL_B1F","RockTunnel_B1F",nil}, + {1,83,"FR_SEAFOAM_ISLANDS_1F","SeafoamIslands_1F",nil}, + {1,84,"FR_SEAFOAM_ISLANDS_B1F","SeafoamIslands_B1F",nil}, + {1,85,"FR_SEAFOAM_ISLANDS_B2F","SeafoamIslands_B2F",nil}, + {1,86,"FR_SEAFOAM_ISLANDS_B3F","SeafoamIslands_B3F",nil}, + {1,87,"FR_SEAFOAM_ISLANDS_B4F","SeafoamIslands_B4F",nil}, + {1,88,"FR_POKEMON_TOWER_1F","PokemonTower_1F",nil}, + {1,89,"FR_POKEMON_TOWER_2F","PokemonTower_2F",nil}, + {1,90,"FR_POKEMON_TOWER_3F","PokemonTower_3F",nil}, + {1,91,"FR_POKEMON_TOWER_4F","PokemonTower_4F",nil}, + {1,92,"FR_POKEMON_TOWER_5F","PokemonTower_5F",nil}, + {1,93,"FR_POKEMON_TOWER_6F","PokemonTower_6F",nil}, + {1,94,"FR_POKEMON_TOWER_7F","PokemonTower_7F",nil}, + {1,95,"FR_POWER_PLANT","PowerPlant",nil}, + {1,96,"FR_MT_EMBER_RUBY_PATH_B4F","MtEmber_RubyPath_B4F",nil}, + {1,97,"FR_MT_EMBER_EXTERIOR","MtEmber_Exterior",nil}, + {1,98,"FR_MT_EMBER_SUMMIT_PATH_1F","MtEmber_SummitPath_1F",nil}, + {1,99,"FR_MT_EMBER_SUMMIT_PATH_2F","MtEmber_SummitPath_2F",nil}, + {1,100,"FR_MT_EMBER_SUMMIT_PATH_3F","MtEmber_SummitPath_3F",nil}, + {1,101,"FR_MT_EMBER_SUMMIT","MtEmber_Summit",nil}, + {1,102,"FR_MT_EMBER_RUBY_PATH_B5F","MtEmber_RubyPath_B5F",nil}, + {1,103,"FR_MT_EMBER_RUBY_PATH_1F","MtEmber_RubyPath_1F",nil}, + {1,104,"FR_MT_EMBER_RUBY_PATH_B1F","MtEmber_RubyPath_B1F",nil}, + {1,105,"FR_MT_EMBER_RUBY_PATH_B2F","MtEmber_RubyPath_B2F",nil}, + {1,106,"FR_MT_EMBER_RUBY_PATH_B3F","MtEmber_RubyPath_B3F",nil}, + {1,107,"FR_MT_EMBER_RUBY_PATH_B1F_STAIRS","MtEmber_RubyPath_B1F_Stairs",nil}, + {1,108,"FR_MT_EMBER_RUBY_PATH_B2F_STAIRS","MtEmber_RubyPath_B2F_Stairs",nil}, + {1,109,"FR_THREE_ISLAND_BERRY_FOREST","ThreeIsland_BerryForest",nil}, + {1,110,"FR_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE","FourIsland_IcefallCave_Entrance",nil}, + {1,111,"FR_FOUR_ISLAND_ICEFALL_CAVE_1F","FourIsland_IcefallCave_1F",nil}, + {1,112,"FR_FOUR_ISLAND_ICEFALL_CAVE_B1F","FourIsland_IcefallCave_B1F",nil}, + {1,113,"FR_FOUR_ISLAND_ICEFALL_CAVE_BACK","FourIsland_IcefallCave_Back",nil}, + {1,114,"FR_FIVE_ISLAND_ROCKET_WAREHOUSE","FiveIsland_RocketWarehouse",nil}, + {1,115,"FR_SIX_ISLAND_DOTTED_HOLE_1F","SixIsland_DottedHole_1F",nil}, + {1,116,"FR_SIX_ISLAND_DOTTED_HOLE_B1F","SixIsland_DottedHole_B1F",nil}, + {1,117,"FR_SIX_ISLAND_DOTTED_HOLE_B2F","SixIsland_DottedHole_B2F",nil}, + {1,118,"FR_SIX_ISLAND_DOTTED_HOLE_B3F","SixIsland_DottedHole_B3F",nil}, + {1,119,"FR_SIX_ISLAND_DOTTED_HOLE_B4F","SixIsland_DottedHole_B4F",nil}, + {1,120,"FR_SIX_ISLAND_DOTTED_HOLE_SAPPHIRE_ROOM","SixIsland_DottedHole_SapphireRoom",nil}, + {1,121,"FR_SIX_ISLAND_PATTERN_BUSH","SixIsland_PatternBush",nil}, + {1,122,"FR_SIX_ISLAND_ALTERING_CAVE","SixIsland_AlteringCave",nil}, + {2,0,"FR_NAVEL_ROCK_EXTERIOR","NavelRock_Exterior",nil}, + {2,1,"FR_TRAINER_TOWER_1F","TrainerTower_1F",nil}, + {2,2,"FR_TRAINER_TOWER_2F","TrainerTower_2F",nil}, + {2,3,"FR_TRAINER_TOWER_3F","TrainerTower_3F",nil}, + {2,4,"FR_TRAINER_TOWER_4F","TrainerTower_4F",nil}, + {2,5,"FR_TRAINER_TOWER_5F","TrainerTower_5F",nil}, + {2,6,"FR_TRAINER_TOWER_6F","TrainerTower_6F",nil}, + {2,7,"FR_TRAINER_TOWER_7F","TrainerTower_7F",nil}, + {2,8,"FR_TRAINER_TOWER_8F","TrainerTower_8F",nil}, + {2,9,"FR_TRAINER_TOWER_ROOF","TrainerTower_Roof",nil}, + {2,10,"FR_TRAINER_TOWER_LOBBY","TrainerTower_Lobby",nil}, + {2,11,"FR_TRAINER_TOWER_ELEVATOR","TrainerTower_Elevator",nil}, + {2,12,"FR_FIVE_ISLAND_LOST_CAVE_ENTRANCE","FiveIsland_LostCave_Entrance",nil}, + {2,13,"FR_FIVE_ISLAND_LOST_CAVE_ROOM1","FiveIsland_LostCave_Room1",nil}, + {2,14,"FR_FIVE_ISLAND_LOST_CAVE_ROOM2","FiveIsland_LostCave_Room2",nil}, + {2,15,"FR_FIVE_ISLAND_LOST_CAVE_ROOM3","FiveIsland_LostCave_Room3",nil}, + {2,16,"FR_FIVE_ISLAND_LOST_CAVE_ROOM4","FiveIsland_LostCave_Room4",nil}, + {2,17,"FR_FIVE_ISLAND_LOST_CAVE_ROOM5","FiveIsland_LostCave_Room5",nil}, + {2,18,"FR_FIVE_ISLAND_LOST_CAVE_ROOM6","FiveIsland_LostCave_Room6",nil}, + {2,19,"FR_FIVE_ISLAND_LOST_CAVE_ROOM7","FiveIsland_LostCave_Room7",nil}, + {2,20,"FR_FIVE_ISLAND_LOST_CAVE_ROOM8","FiveIsland_LostCave_Room8",nil}, + {2,21,"FR_FIVE_ISLAND_LOST_CAVE_ROOM9","FiveIsland_LostCave_Room9",nil}, + {2,22,"FR_FIVE_ISLAND_LOST_CAVE_ROOM10","FiveIsland_LostCave_Room10",nil}, + {2,23,"FR_FIVE_ISLAND_LOST_CAVE_ROOM11","FiveIsland_LostCave_Room11",nil}, + {2,24,"FR_FIVE_ISLAND_LOST_CAVE_ROOM12","FiveIsland_LostCave_Room12",nil}, + {2,25,"FR_FIVE_ISLAND_LOST_CAVE_ROOM13","FiveIsland_LostCave_Room13",nil}, + {2,26,"FR_FIVE_ISLAND_LOST_CAVE_ROOM14","FiveIsland_LostCave_Room14",nil}, + {2,27,"FR_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER","SevenIsland_TanobyRuins_MoneanChamber",nil}, + {2,28,"FR_SEVEN_ISLAND_TANOBY_RUINS_LIPTOO_CHAMBER","SevenIsland_TanobyRuins_LiptooChamber",nil}, + {2,29,"FR_SEVEN_ISLAND_TANOBY_RUINS_WEEPTH_CHAMBER","SevenIsland_TanobyRuins_WeepthChamber",nil}, + {2,30,"FR_SEVEN_ISLAND_TANOBY_RUINS_DILFORD_CHAMBER","SevenIsland_TanobyRuins_DilfordChamber",nil}, + {2,31,"FR_SEVEN_ISLAND_TANOBY_RUINS_SCUFIB_CHAMBER","SevenIsland_TanobyRuins_ScufibChamber",nil}, + {2,32,"FR_SEVEN_ISLAND_TANOBY_RUINS_RIXY_CHAMBER","SevenIsland_TanobyRuins_RixyChamber",nil}, + {2,33,"FR_SEVEN_ISLAND_TANOBY_RUINS_VIAPOIS_CHAMBER","SevenIsland_TanobyRuins_ViapoisChamber",nil}, + {2,34,"FR_THREE_ISLAND_DUNSPARCE_TUNNEL","ThreeIsland_DunsparceTunnel",nil}, + {2,35,"FR_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY","SevenIsland_SevaultCanyon_TanobyKey",nil}, + {2,36,"FR_NAVEL_ROCK_1F","NavelRock_1F",nil}, + {2,37,"FR_NAVEL_ROCK_SUMMIT","NavelRock_Summit",nil}, + {2,38,"FR_NAVEL_ROCK_BASE","NavelRock_Base",nil}, + {2,39,"FR_NAVEL_ROCK_SUMMIT_PATH_2F","NavelRock_SummitPath_2F",nil}, + {2,40,"FR_NAVEL_ROCK_SUMMIT_PATH_3F","NavelRock_SummitPath_3F",nil}, + {2,41,"FR_NAVEL_ROCK_SUMMIT_PATH_4F","NavelRock_SummitPath_4F",nil}, + {2,42,"FR_NAVEL_ROCK_SUMMIT_PATH_5F","NavelRock_SummitPath_5F",nil}, + {2,43,"FR_NAVEL_ROCK_BASE_PATH_B1F","NavelRock_BasePath_B1F",nil}, + {2,44,"FR_NAVEL_ROCK_BASE_PATH_B2F","NavelRock_BasePath_B2F",nil}, + {2,45,"FR_NAVEL_ROCK_BASE_PATH_B3F","NavelRock_BasePath_B3F",nil}, + {2,46,"FR_NAVEL_ROCK_BASE_PATH_B4F","NavelRock_BasePath_B4F",nil}, + {2,47,"FR_NAVEL_ROCK_BASE_PATH_B5F","NavelRock_BasePath_B5F",nil}, + {2,48,"FR_NAVEL_ROCK_BASE_PATH_B6F","NavelRock_BasePath_B6F",nil}, + {2,49,"FR_NAVEL_ROCK_BASE_PATH_B7F","NavelRock_BasePath_B7F",nil}, + {2,50,"FR_NAVEL_ROCK_BASE_PATH_B8F","NavelRock_BasePath_B8F",nil}, + {2,51,"FR_NAVEL_ROCK_BASE_PATH_B9F","NavelRock_BasePath_B9F",nil}, + {2,52,"FR_NAVEL_ROCK_BASE_PATH_B10F","NavelRock_BasePath_B10F",nil}, + {2,53,"FR_NAVEL_ROCK_BASE_PATH_B11F","NavelRock_BasePath_B11F",nil}, + {2,54,"FR_NAVEL_ROCK_B1F","NavelRock_B1F",nil}, + {2,55,"FR_NAVEL_ROCK_FORK","NavelRock_Fork",nil}, + {2,56,"FR_BIRTH_ISLAND_EXTERIOR","BirthIsland_Exterior",nil}, + {2,57,"FR_ONE_ISLAND_KINDLE_ROAD_EMBER_SPA","OneIsland_KindleRoad_EmberSpa",nil}, + {2,58,"FR_BIRTH_ISLAND_HARBOR","BirthIsland_Harbor",nil}, + {2,59,"FR_NAVEL_ROCK_HARBOR","NavelRock_Harbor",nil}, + {3,0,"FR_PALLET_TOWN","PalletTown",nil}, + {3,1,"FR_VIRIDIAN_CITY","ViridianCity",nil}, + {3,2,"FR_PEWTER_CITY","PewterCity",nil}, + {3,3,"FR_CERULEAN_CITY","CeruleanCity",nil}, + {3,4,"FR_LAVENDER_TOWN","LavenderTown",nil}, + {3,5,"FR_VERMILION_CITY","VermilionCity",nil}, + {3,6,"FR_CELADON_CITY","CeladonCity",nil}, + {3,7,"FR_FUCHSIA_CITY","FuchsiaCity",nil}, + {3,8,"FR_CINNABAR_ISLAND","CinnabarIsland",nil}, + {3,9,"FR_INDIGO_PLATEAU_EXTERIOR","IndigoPlateau_Exterior",nil}, + {3,10,"FR_SAFFRON_CITY","SaffronCity",nil}, + {3,11,"FR_SAFFRON_CITY_CONNECTION","SaffronCity_Connection",nil}, + {3,12,"SEVII_ONE_ISLAND","OneIsland",nil}, + {3,13,"FR_TWO_ISLAND","TwoIsland",nil}, + {3,14,"FR_THREE_ISLAND","ThreeIsland",nil}, + {3,15,"FR_FOUR_ISLAND","FourIsland",nil}, + {3,16,"FR_FIVE_ISLAND","FiveIsland",nil}, + {3,17,"FR_SEVEN_ISLAND","SevenIsland",nil}, + {3,18,"FR_SIX_ISLAND","SixIsland",nil}, + {3,19,"FR_ROUTE_1","Route1",nil}, + {3,20,"FR_ROUTE_2","Route2",nil}, + {3,21,"FR_ROUTE_3","Route3",nil}, + {3,22,"FR_ROUTE_4","Route4",nil}, + {3,23,"FR_ROUTE_5","Route5",nil}, + {3,24,"FR_ROUTE_6","Route6",nil}, + {3,25,"FR_ROUTE_7","Route7",nil}, + {3,26,"FR_ROUTE_8","Route8",nil}, + {3,27,"FR_ROUTE_9","Route9",nil}, + {3,28,"FR_ROUTE_10","Route10",nil}, + {3,29,"FR_ROUTE_11","Route11",nil}, + {3,30,"FR_ROUTE_12","Route12",nil}, + {3,31,"FR_ROUTE_13","Route13",nil}, + {3,32,"FR_ROUTE_14","Route14",nil}, + {3,33,"FR_ROUTE_15","Route15",nil}, + {3,34,"FR_ROUTE_16","Route16",nil}, + {3,35,"FR_ROUTE_17","Route17",nil}, + {3,36,"FR_ROUTE_18","Route18",nil}, + {3,37,"FR_ROUTE_19","Route19",nil}, + {3,38,"FR_ROUTE_20","Route20",nil}, + {3,39,"FR_ROUTE_21_NORTH","Route21_North",nil}, + {3,40,"FR_ROUTE_21_SOUTH","Route21_South",nil}, + {3,41,"FR_ROUTE_22","Route22",nil}, + {3,42,"FR_ROUTE_23","Route23",nil}, + {3,43,"FR_ROUTE_24","Route24",nil}, + {3,44,"FR_ROUTE_25","Route25",nil}, + {3,45,"SEVII_ONE_ISLAND_KINDLE_ROAD","OneIsland_KindleRoad",nil}, + {3,46,"SEVII_ONE_ISLAND_TREASURE_BEACH","OneIsland_TreasureBeach",nil}, + {3,47,"FR_TWO_ISLAND_CAPE_BRINK","TwoIsland_CapeBrink",nil}, + {3,48,"FR_THREE_ISLAND_BOND_BRIDGE","ThreeIsland_BondBridge",nil}, + {3,49,"FR_THREE_ISLAND_PORT","ThreeIsland_Port",nil}, + {3,50,"FR_PROTOTYPE_SEVII_ISLE_6","Prototype_SeviiIsle_6",nil}, + {3,51,"FR_PROTOTYPE_SEVII_ISLE_7","Prototype_SeviiIsle_7",nil}, + {3,52,"FR_PROTOTYPE_SEVII_ISLE_8","Prototype_SeviiIsle_8",nil}, + {3,53,"FR_PROTOTYPE_SEVII_ISLE_9","Prototype_SeviiIsle_9",nil}, + {3,54,"FR_FIVE_ISLAND_RESORT_GORGEOUS","FiveIsland_ResortGorgeous",nil}, + {3,55,"FR_FIVE_ISLAND_WATER_LABYRINTH","FiveIsland_WaterLabyrinth",nil}, + {3,56,"FR_FIVE_ISLAND_MEADOW","FiveIsland_Meadow",nil}, + {3,57,"FR_FIVE_ISLAND_MEMORIAL_PILLAR","FiveIsland_MemorialPillar",nil}, + {3,58,"FR_SIX_ISLAND_OUTCAST_ISLAND","SixIsland_OutcastIsland",nil}, + {3,59,"FR_SIX_ISLAND_GREEN_PATH","SixIsland_GreenPath",nil}, + {3,60,"FR_SIX_ISLAND_WATER_PATH","SixIsland_WaterPath",nil}, + {3,61,"FR_SIX_ISLAND_RUIN_VALLEY","SixIsland_RuinValley",nil}, + {3,62,"FR_SEVEN_ISLAND_TRAINER_TOWER","SevenIsland_TrainerTower",nil}, + {3,63,"FR_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE","SevenIsland_SevaultCanyon_Entrance",nil}, + {3,64,"FR_SEVEN_ISLAND_SEVAULT_CANYON","SevenIsland_SevaultCanyon",nil}, + {3,65,"FR_SEVEN_ISLAND_TANOBY_RUINS","SevenIsland_TanobyRuins",nil}, + {4,0,"FR_PLAYERS_HOUSE_1F","PalletTown_PlayersHouse_1F",nil}, + {4,1,"FR_PLAYERS_HOUSE_2F","PalletTown_PlayersHouse_2F",nil}, + {4,2,"FR_RIVALS_HOUSE","PalletTown_RivalsHouse",nil}, + {4,3,"FR_OAKS_LAB","PalletTown_ProfessorOaksLab",nil}, + {5,0,"FR_VIRIDIAN_CITY_HOUSE","ViridianCity_House",nil}, + {5,1,"FR_VIRIDIAN_CITY_GYM","ViridianCity_Gym",nil}, + {5,2,"FR_VIRIDIAN_CITY_SCHOOL","ViridianCity_School",nil}, + {5,3,"FR_VIRIDIAN_CITY_MART","ViridianCity_Mart",nil}, + {5,4,"FR_VIRIDIAN_CITY_POKEMON_CENTER_1F","ViridianCity_PokemonCenter_1F",nil}, + {5,5,"FR_VIRIDIAN_CITY_POKEMON_CENTER_2F","ViridianCity_PokemonCenter_2F",nil}, + {6,0,"FR_PEWTER_CITY_MUSEUM_1F","PewterCity_Museum_1F",nil}, + {6,1,"FR_PEWTER_CITY_MUSEUM_2F","PewterCity_Museum_2F",nil}, + {6,2,"FR_PEWTER_CITY_GYM","PewterCity_Gym",nil}, + {6,3,"FR_PEWTER_CITY_MART","PewterCity_Mart",nil}, + {6,4,"FR_PEWTER_CITY_HOUSE1","PewterCity_House1",nil}, + {6,5,"FR_PEWTER_CITY_POKEMON_CENTER_1F","PewterCity_PokemonCenter_1F",nil}, + {6,6,"FR_PEWTER_CITY_POKEMON_CENTER_2F","PewterCity_PokemonCenter_2F",nil}, + {6,7,"FR_PEWTER_CITY_HOUSE2","PewterCity_House2",nil}, + {7,0,"FR_CERULEAN_CITY_HOUSE1","CeruleanCity_House1",nil}, + {7,1,"FR_CERULEAN_CITY_HOUSE2","CeruleanCity_House2",nil}, + {7,2,"FR_CERULEAN_CITY_HOUSE3","CeruleanCity_House3",nil}, + {7,3,"FR_CERULEAN_CITY_POKEMON_CENTER_1F","CeruleanCity_PokemonCenter_1F",nil}, + {7,4,"FR_CERULEAN_CITY_POKEMON_CENTER_2F","CeruleanCity_PokemonCenter_2F",nil}, + {7,5,"FR_CERULEAN_CITY_GYM","CeruleanCity_Gym",nil}, + {7,6,"FR_CERULEAN_CITY_BIKE_SHOP","CeruleanCity_BikeShop",nil}, + {7,7,"FR_CERULEAN_CITY_MART","CeruleanCity_Mart",nil}, + {7,8,"FR_CERULEAN_CITY_HOUSE4","CeruleanCity_House4",nil}, + {7,9,"FR_CERULEAN_CITY_HOUSE5","CeruleanCity_House5",nil}, + {8,0,"FR_LAVENDER_TOWN_POKEMON_CENTER_1F","LavenderTown_PokemonCenter_1F",nil}, + {8,1,"FR_LAVENDER_TOWN_POKEMON_CENTER_2F","LavenderTown_PokemonCenter_2F",nil}, + {8,2,"FR_LAVENDER_TOWN_VOLUNTEER_POKEMON_HOUSE","LavenderTown_VolunteerPokemonHouse",nil}, + {8,3,"FR_LAVENDER_TOWN_HOUSE1","LavenderTown_House1",nil}, + {8,4,"FR_LAVENDER_TOWN_HOUSE2","LavenderTown_House2",nil}, + {8,5,"FR_LAVENDER_TOWN_MART","LavenderTown_Mart",nil}, + {9,0,"FR_VERMILION_CITY_HOUSE1","VermilionCity_House1",nil}, + {9,1,"FR_VERMILION_CITY_POKEMON_CENTER_1F","VermilionCity_PokemonCenter_1F",nil}, + {9,2,"FR_VERMILION_CITY_POKEMON_CENTER_2F","VermilionCity_PokemonCenter_2F",nil}, + {9,3,"FR_VERMILION_CITY_POKEMON_FAN_CLUB","VermilionCity_PokemonFanClub",nil}, + {9,4,"FR_VERMILION_CITY_HOUSE2","VermilionCity_House2",nil}, + {9,5,"FR_VERMILION_CITY_MART","VermilionCity_Mart",nil}, + {9,6,"FR_VERMILION_CITY_GYM","VermilionCity_Gym",nil}, + {9,7,"FR_VERMILION_CITY_HOUSE3","VermilionCity_House3",nil}, + {10,0,"FR_CELADON_CITY_DEPARTMENT_STORE_1F","CeladonCity_DepartmentStore_1F",nil}, + {10,1,"FR_CELADON_CITY_DEPARTMENT_STORE_2F","CeladonCity_DepartmentStore_2F",nil}, + {10,2,"FR_CELADON_CITY_DEPARTMENT_STORE_3F","CeladonCity_DepartmentStore_3F",nil}, + {10,3,"FR_CELADON_CITY_DEPARTMENT_STORE_4F","CeladonCity_DepartmentStore_4F",nil}, + {10,4,"FR_CELADON_CITY_DEPARTMENT_STORE_5F","CeladonCity_DepartmentStore_5F",nil}, + {10,5,"FR_CELADON_CITY_DEPARTMENT_STORE_ROOF","CeladonCity_DepartmentStore_Roof",nil}, + {10,6,"FR_CELADON_CITY_DEPARTMENT_STORE_ELEVATOR","CeladonCity_DepartmentStore_Elevator",nil}, + {10,7,"FR_CELADON_CITY_CONDOMINIUMS_1F","CeladonCity_Condominiums_1F",nil}, + {10,8,"FR_CELADON_CITY_CONDOMINIUMS_2F","CeladonCity_Condominiums_2F",nil}, + {10,9,"FR_CELADON_CITY_CONDOMINIUMS_3F","CeladonCity_Condominiums_3F",nil}, + {10,10,"FR_CELADON_CITY_CONDOMINIUMS_ROOF","CeladonCity_Condominiums_Roof",nil}, + {10,11,"FR_CELADON_CITY_CONDOMINIUMS_ROOF_ROOM","CeladonCity_Condominiums_RoofRoom",nil}, + {10,12,"FR_CELADON_CITY_POKEMON_CENTER_1F","CeladonCity_PokemonCenter_1F",nil}, + {10,13,"FR_CELADON_CITY_POKEMON_CENTER_2F","CeladonCity_PokemonCenter_2F",nil}, + {10,14,"FR_CELADON_CITY_GAME_CORNER","CeladonCity_GameCorner",nil}, + {10,15,"FR_CELADON_CITY_GAME_CORNER_PRIZE_ROOM","CeladonCity_GameCorner_PrizeRoom",nil}, + {10,16,"FR_CELADON_CITY_GYM","CeladonCity_Gym",nil}, + {10,17,"FR_CELADON_CITY_RESTAURANT","CeladonCity_Restaurant",nil}, + {10,18,"FR_CELADON_CITY_HOUSE1","CeladonCity_House1",nil}, + {10,19,"FR_CELADON_CITY_HOTEL","CeladonCity_Hotel",nil}, + {11,0,"FR_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE","FuchsiaCity_SafariZone_Entrance",nil}, + {11,1,"FR_FUCHSIA_CITY_MART","FuchsiaCity_Mart",nil}, + {11,2,"FR_FUCHSIA_CITY_SAFARI_ZONE_OFFICE","FuchsiaCity_SafariZone_Office",nil}, + {11,3,"FR_FUCHSIA_CITY_GYM","FuchsiaCity_Gym",nil}, + {11,4,"FR_FUCHSIA_CITY_HOUSE1","FuchsiaCity_House1",nil}, + {11,5,"FR_FUCHSIA_CITY_POKEMON_CENTER_1F","FuchsiaCity_PokemonCenter_1F",nil}, + {11,6,"FR_FUCHSIA_CITY_POKEMON_CENTER_2F","FuchsiaCity_PokemonCenter_2F",nil}, + {11,7,"FR_FUCHSIA_CITY_WARDENS_HOUSE","FuchsiaCity_WardensHouse",nil}, + {11,8,"FR_FUCHSIA_CITY_HOUSE2","FuchsiaCity_House2",nil}, + {11,9,"FR_FUCHSIA_CITY_HOUSE3","FuchsiaCity_House3",nil}, + {12,0,"FR_CINNABAR_ISLAND_GYM","CinnabarIsland_Gym",nil}, + {12,1,"FR_CINNABAR_ISLAND_POKEMON_LAB_ENTRANCE","CinnabarIsland_PokemonLab_Entrance",nil}, + {12,2,"FR_CINNABAR_ISLAND_POKEMON_LAB_LOUNGE","CinnabarIsland_PokemonLab_Lounge",nil}, + {12,3,"FR_CINNABAR_ISLAND_POKEMON_LAB_RESEARCH_ROOM","CinnabarIsland_PokemonLab_ResearchRoom",nil}, + {12,4,"FR_CINNABAR_ISLAND_POKEMON_LAB_EXPERIMENT_ROOM","CinnabarIsland_PokemonLab_ExperimentRoom",nil}, + {12,5,"FR_CINNABAR_ISLAND_POKEMON_CENTER_1F","CinnabarIsland_PokemonCenter_1F",nil}, + {12,6,"FR_CINNABAR_ISLAND_POKEMON_CENTER_2F","CinnabarIsland_PokemonCenter_2F",nil}, + {12,7,"FR_CINNABAR_ISLAND_MART","CinnabarIsland_Mart",nil}, + {13,0,"FR_INDIGO_PLATEAU_POKEMON_CENTER_1F","IndigoPlateau_PokemonCenter_1F",nil}, + {13,1,"FR_INDIGO_PLATEAU_POKEMON_CENTER_2F","IndigoPlateau_PokemonCenter_2F",nil}, + {14,0,"FR_SAFFRON_CITY_COPYCATS_HOUSE_1F","SaffronCity_CopycatsHouse_1F",nil}, + {14,1,"FR_SAFFRON_CITY_COPYCATS_HOUSE_2F","SaffronCity_CopycatsHouse_2F",nil}, + {14,2,"FR_SAFFRON_CITY_DOJO","SaffronCity_Dojo",nil}, + {14,3,"FR_SAFFRON_CITY_GYM","SaffronCity_Gym",nil}, + {14,4,"FR_SAFFRON_CITY_HOUSE","SaffronCity_House",nil}, + {14,5,"FR_SAFFRON_CITY_MART","SaffronCity_Mart",nil}, + {14,6,"FR_SAFFRON_CITY_POKEMON_CENTER_1F","SaffronCity_PokemonCenter_1F",nil}, + {14,7,"FR_SAFFRON_CITY_POKEMON_CENTER_2F","SaffronCity_PokemonCenter_2F",nil}, + {14,8,"FR_SAFFRON_CITY_MR_PSYCHICS_HOUSE","SaffronCity_MrPsychicsHouse",nil}, + {14,9,"FR_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB","SaffronCity_PokemonTrainerFanClub",nil}, + {15,0,"FR_ROUTE_2_VIRIDIAN_FOREST_SOUTH_ENTRANCE","Route2_ViridianForest_SouthEntrance",nil}, + {15,1,"FR_ROUTE_2_HOUSE","Route2_House",nil}, + {15,2,"FR_ROUTE_2_EAST_BUILDING","Route2_EastBuilding",nil}, + {15,3,"FR_ROUTE_2_VIRIDIAN_FOREST_NORTH_ENTRANCE","Route2_ViridianForest_NorthEntrance",nil}, + {16,0,"FR_ROUTE_4_POKEMON_CENTER_1F","Route4_PokemonCenter_1F",nil}, + {16,1,"FR_ROUTE_4_POKEMON_CENTER_2F","Route4_PokemonCenter_2F",nil}, + {17,0,"FR_ROUTE_5_POKEMON_DAY_CARE","Route5_PokemonDayCare",nil}, + {17,1,"FR_ROUTE_5_SOUTH_ENTRANCE","Route5_SouthEntrance",nil}, + {18,0,"FR_ROUTE_6_NORTH_ENTRANCE","Route6_NorthEntrance",nil}, + {18,1,"FR_ROUTE_6_UNUSED_HOUSE","Route6_UnusedHouse",nil}, + {19,0,"FR_ROUTE_7_EAST_ENTRANCE","Route7_EastEntrance",nil}, + {20,0,"FR_ROUTE_8_WEST_ENTRANCE","Route8_WestEntrance",nil}, + {21,0,"FR_ROUTE_10_POKEMON_CENTER_1F","Route10_PokemonCenter_1F",nil}, + {21,1,"FR_ROUTE_10_POKEMON_CENTER_2F","Route10_PokemonCenter_2F",nil}, + {22,0,"FR_ROUTE_11_EAST_ENTRANCE_1F","Route11_EastEntrance_1F",nil}, + {22,1,"FR_ROUTE_11_EAST_ENTRANCE_2F","Route11_EastEntrance_2F",nil}, + {23,0,"FR_ROUTE_12_NORTH_ENTRANCE_1F","Route12_NorthEntrance_1F",nil}, + {23,1,"FR_ROUTE_12_NORTH_ENTRANCE_2F","Route12_NorthEntrance_2F",nil}, + {23,2,"FR_ROUTE_12_FISHING_HOUSE","Route12_FishingHouse",nil}, + {24,0,"FR_ROUTE_15_WEST_ENTRANCE_1F","Route15_WestEntrance_1F",nil}, + {24,1,"FR_ROUTE_15_WEST_ENTRANCE_2F","Route15_WestEntrance_2F",nil}, + {25,0,"FR_ROUTE_16_HOUSE","Route16_House",nil}, + {25,1,"FR_ROUTE_16_NORTH_ENTRANCE_1F","Route16_NorthEntrance_1F",nil}, + {25,2,"FR_ROUTE_16_NORTH_ENTRANCE_2F","Route16_NorthEntrance_2F",nil}, + {26,0,"FR_ROUTE_18_EAST_ENTRANCE_1F","Route18_EastEntrance_1F",nil}, + {26,1,"FR_ROUTE_18_EAST_ENTRANCE_2F","Route18_EastEntrance_2F",nil}, + {27,0,"FR_ROUTE_19_UNUSED_HOUSE","Route19_UnusedHouse",nil}, + {28,0,"FR_ROUTE_22_NORTH_ENTRANCE","Route22_NorthEntrance",nil}, + {29,0,"FR_ROUTE_23_UNUSED_HOUSE","Route23_UnusedHouse",nil}, + {30,0,"FR_ROUTE_25_SEA_COTTAGE","Route25_SeaCottage",nil}, + {31,0,"FR_SEVEN_ISLAND_HOUSE_ROOM1","SevenIsland_House_Room1",nil}, + {31,1,"FR_SEVEN_ISLAND_HOUSE_ROOM2","SevenIsland_House_Room2",nil}, + {31,2,"FR_SEVEN_ISLAND_MART","SevenIsland_Mart",nil}, + {31,3,"FR_SEVEN_ISLAND_POKEMON_CENTER_1F","SevenIsland_PokemonCenter_1F",nil}, + {31,4,"FR_SEVEN_ISLAND_POKEMON_CENTER_2F","SevenIsland_PokemonCenter_2F",nil}, + {31,5,"FR_SEVEN_ISLAND_UNUSED_HOUSE","SevenIsland_UnusedHouse",nil}, + {31,6,"FR_SEVEN_ISLAND_HARBOR","SevenIsland_Harbor",nil}, + {32,0,"SEVII_ONE_ISLAND_POKECENTER","OneIsland_PokemonCenter_1F",nil}, + {32,1,"SEVII_ONE_ISLAND_POKECENTER_2F","OneIsland_PokemonCenter_2F",nil}, + {32,2,"SEVII_ONE_ISLAND_HOUSE1","OneIsland_House1",nil}, + {32,3,"SEVII_ONE_ISLAND_HOUSE2","OneIsland_House2",nil}, + {32,4,"SEVII_ONE_ISLAND_HARBOR","OneIsland_Harbor",nil}, + {33,0,"FR_TWO_ISLAND_JOYFUL_GAME_CORNER","TwoIsland_JoyfulGameCorner",nil}, + {33,1,"FR_TWO_ISLAND_HOUSE","TwoIsland_House",nil}, + {33,2,"FR_TWO_ISLAND_POKEMON_CENTER_1F","TwoIsland_PokemonCenter_1F",nil}, + {33,3,"FR_TWO_ISLAND_POKEMON_CENTER_2F","TwoIsland_PokemonCenter_2F",nil}, + {33,4,"FR_TWO_ISLAND_HARBOR","TwoIsland_Harbor",nil}, + {34,0,"FR_THREE_ISLAND_HOUSE1","ThreeIsland_House1",nil}, + {34,1,"FR_THREE_ISLAND_POKEMON_CENTER_1F","ThreeIsland_PokemonCenter_1F",nil}, + {34,2,"FR_THREE_ISLAND_POKEMON_CENTER_2F","ThreeIsland_PokemonCenter_2F",nil}, + {34,3,"FR_THREE_ISLAND_MART","ThreeIsland_Mart",nil}, + {34,4,"FR_THREE_ISLAND_HOUSE2","ThreeIsland_House2",nil}, + {34,5,"FR_THREE_ISLAND_HOUSE3","ThreeIsland_House3",nil}, + {34,6,"FR_THREE_ISLAND_HOUSE4","ThreeIsland_House4",nil}, + {34,7,"FR_THREE_ISLAND_HOUSE5","ThreeIsland_House5",nil}, + {35,0,"FR_FOUR_ISLAND_POKEMON_DAY_CARE","FourIsland_PokemonDayCare",nil}, + {35,1,"FR_FOUR_ISLAND_POKEMON_CENTER_1F","FourIsland_PokemonCenter_1F",nil}, + {35,2,"FR_FOUR_ISLAND_POKEMON_CENTER_2F","FourIsland_PokemonCenter_2F",nil}, + {35,3,"FR_FOUR_ISLAND_HOUSE1","FourIsland_House1",nil}, + {35,4,"FR_FOUR_ISLAND_LORELEIS_HOUSE","FourIsland_LoreleisHouse",nil}, + {35,5,"FR_FOUR_ISLAND_HARBOR","FourIsland_Harbor",nil}, + {35,6,"FR_FOUR_ISLAND_HOUSE2","FourIsland_House2",nil}, + {35,7,"FR_FOUR_ISLAND_MART","FourIsland_Mart",nil}, + {36,0,"FR_FIVE_ISLAND_POKEMON_CENTER_1F","FiveIsland_PokemonCenter_1F",nil}, + {36,1,"FR_FIVE_ISLAND_POKEMON_CENTER_2F","FiveIsland_PokemonCenter_2F",nil}, + {36,2,"FR_FIVE_ISLAND_HARBOR","FiveIsland_Harbor",nil}, + {36,3,"FR_FIVE_ISLAND_HOUSE1","FiveIsland_House1",nil}, + {36,4,"FR_FIVE_ISLAND_HOUSE2","FiveIsland_House2",nil}, + {37,0,"FR_SIX_ISLAND_POKEMON_CENTER_1F","SixIsland_PokemonCenter_1F",nil}, + {37,1,"FR_SIX_ISLAND_POKEMON_CENTER_2F","SixIsland_PokemonCenter_2F",nil}, + {37,2,"FR_SIX_ISLAND_HARBOR","SixIsland_Harbor",nil}, + {37,3,"FR_SIX_ISLAND_HOUSE","SixIsland_House",nil}, + {37,4,"FR_SIX_ISLAND_MART","SixIsland_Mart",nil}, + {38,0,"FR_THREE_ISLAND_HARBOR","ThreeIsland_Harbor",nil}, + {39,0,"FR_FIVE_ISLAND_RESORT_GORGEOUS_HOUSE","FiveIsland_ResortGorgeous_House",nil}, + {40,0,"FR_TWO_ISLAND_CAPE_BRINK_HOUSE","TwoIsland_CapeBrink_House",nil}, + {41,0,"FR_SIX_ISLAND_WATER_PATH_HOUSE1","SixIsland_WaterPath_House1",nil}, + {41,1,"FR_SIX_ISLAND_WATER_PATH_HOUSE2","SixIsland_WaterPath_House2",nil}, + {42,0,"FR_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE","SevenIsland_SevaultCanyon_House",nil}, + }, + ["emerald"]={ + {0,0,"EM_PETALBURG_CITY","PetalburgCity","MAP_PETALBURG_CITY"}, + {0,1,"EM_SLATEPORT_CITY","SlateportCity","MAP_SLATEPORT_CITY"}, + {0,2,"EM_MAUVILLE_CITY","MauvilleCity","MAP_MAUVILLE_CITY"}, + {0,3,"EM_RUSTBORO_CITY","RustboroCity","MAP_RUSTBORO_CITY"}, + {0,4,"EM_FORTREE_CITY","FortreeCity","MAP_FORTREE_CITY"}, + {0,5,"EM_LILYCOVE_CITY","LilycoveCity","MAP_LILYCOVE_CITY"}, + {0,6,"EM_MOSSDEEP_CITY","MossdeepCity","MAP_MOSSDEEP_CITY"}, + {0,7,"EM_SOOTOPOLIS_CITY","SootopolisCity","MAP_SOOTOPOLIS_CITY"}, + {0,8,"EM_EVER_GRANDE_CITY","EverGrandeCity","MAP_EVER_GRANDE_CITY"}, + {0,9,"EM_LITTLEROOT_TOWN","LittlerootTown","MAP_LITTLEROOT_TOWN"}, + {0,10,"EM_OLDALE_TOWN","OldaleTown","MAP_OLDALE_TOWN"}, + {0,11,"EM_DEWFORD_TOWN","DewfordTown","MAP_DEWFORD_TOWN"}, + {0,12,"EM_LAVARIDGE_TOWN","LavaridgeTown","MAP_LAVARIDGE_TOWN"}, + {0,13,"EM_FALLARBOR_TOWN","FallarborTown","MAP_FALLARBOR_TOWN"}, + {0,14,"EM_VERDANTURF_TOWN","VerdanturfTown","MAP_VERDANTURF_TOWN"}, + {0,15,"EM_PACIFIDLOG_TOWN","PacifidlogTown","MAP_PACIFIDLOG_TOWN"}, + {0,16,"EM_ROUTE101","Route101","MAP_ROUTE101"}, + {0,17,"EM_ROUTE102","Route102","MAP_ROUTE102"}, + {0,18,"EM_ROUTE103","Route103","MAP_ROUTE103"}, + {0,19,"EM_ROUTE104","Route104","MAP_ROUTE104"}, + {0,20,"EM_ROUTE105","Route105","MAP_ROUTE105"}, + {0,21,"EM_ROUTE106","Route106","MAP_ROUTE106"}, + {0,22,"EM_ROUTE107","Route107","MAP_ROUTE107"}, + {0,23,"EM_ROUTE108","Route108","MAP_ROUTE108"}, + {0,24,"EM_ROUTE109","Route109","MAP_ROUTE109"}, + {0,25,"EM_ROUTE110","Route110","MAP_ROUTE110"}, + {0,26,"EM_ROUTE111","Route111","MAP_ROUTE111"}, + {0,27,"EM_ROUTE112","Route112","MAP_ROUTE112"}, + {0,28,"EM_ROUTE113","Route113","MAP_ROUTE113"}, + {0,29,"EM_ROUTE114","Route114","MAP_ROUTE114"}, + {0,30,"EM_ROUTE115","Route115","MAP_ROUTE115"}, + {0,31,"EM_ROUTE116","Route116","MAP_ROUTE116"}, + {0,32,"EM_ROUTE117","Route117","MAP_ROUTE117"}, + {0,33,"EM_ROUTE118","Route118","MAP_ROUTE118"}, + {0,34,"EM_ROUTE119","Route119","MAP_ROUTE119"}, + {0,35,"EM_ROUTE120","Route120","MAP_ROUTE120"}, + {0,36,"EM_ROUTE121","Route121","MAP_ROUTE121"}, + {0,37,"EM_ROUTE122","Route122","MAP_ROUTE122"}, + {0,38,"EM_ROUTE123","Route123","MAP_ROUTE123"}, + {0,39,"EM_ROUTE124","Route124","MAP_ROUTE124"}, + {0,40,"EM_ROUTE125","Route125","MAP_ROUTE125"}, + {0,41,"EM_ROUTE126","Route126","MAP_ROUTE126"}, + {0,42,"EM_ROUTE127","Route127","MAP_ROUTE127"}, + {0,43,"EM_ROUTE128","Route128","MAP_ROUTE128"}, + {0,44,"EM_ROUTE129","Route129","MAP_ROUTE129"}, + {0,45,"EM_ROUTE130","Route130","MAP_ROUTE130"}, + {0,46,"EM_ROUTE131","Route131","MAP_ROUTE131"}, + {0,47,"EM_ROUTE132","Route132","MAP_ROUTE132"}, + {0,48,"EM_ROUTE133","Route133","MAP_ROUTE133"}, + {0,49,"EM_ROUTE134","Route134","MAP_ROUTE134"}, + {0,50,"EM_UNDERWATER_ROUTE124","Underwater_Route124","MAP_UNDERWATER_ROUTE124"}, + {0,51,"EM_UNDERWATER_ROUTE126","Underwater_Route126","MAP_UNDERWATER_ROUTE126"}, + {0,52,"EM_UNDERWATER_ROUTE127","Underwater_Route127","MAP_UNDERWATER_ROUTE127"}, + {0,53,"EM_UNDERWATER_ROUTE128","Underwater_Route128","MAP_UNDERWATER_ROUTE128"}, + {0,54,"EM_UNDERWATER_ROUTE129","Underwater_Route129","MAP_UNDERWATER_ROUTE129"}, + {0,55,"EM_UNDERWATER_ROUTE105","Underwater_Route105","MAP_UNDERWATER_ROUTE105"}, + {0,56,"EM_UNDERWATER_ROUTE125","Underwater_Route125","MAP_UNDERWATER_ROUTE125"}, + {1,0,"EM_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F","LittlerootTown_BrendansHouse_1F","MAP_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F"}, + {1,1,"EM_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F","LittlerootTown_BrendansHouse_2F","MAP_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F"}, + {1,2,"EM_LITTLEROOT_TOWN_MAYS_HOUSE_1F","LittlerootTown_MaysHouse_1F","MAP_LITTLEROOT_TOWN_MAYS_HOUSE_1F"}, + {1,3,"EM_LITTLEROOT_TOWN_MAYS_HOUSE_2F","LittlerootTown_MaysHouse_2F","MAP_LITTLEROOT_TOWN_MAYS_HOUSE_2F"}, + {1,4,"EM_LITTLEROOT_TOWN_PROFESSOR_BIRCHS_LAB","LittlerootTown_ProfessorBirchsLab","MAP_LITTLEROOT_TOWN_PROFESSOR_BIRCHS_LAB"}, + {2,0,"EM_OLDALE_TOWN_HOUSE1","OldaleTown_House1","MAP_OLDALE_TOWN_HOUSE1"}, + {2,1,"EM_OLDALE_TOWN_HOUSE2","OldaleTown_House2","MAP_OLDALE_TOWN_HOUSE2"}, + {2,2,"EM_OLDALE_TOWN_POKEMON_CENTER_1F","OldaleTown_PokemonCenter_1F","MAP_OLDALE_TOWN_POKEMON_CENTER_1F"}, + {2,3,"EM_OLDALE_TOWN_POKEMON_CENTER_2F","OldaleTown_PokemonCenter_2F","MAP_OLDALE_TOWN_POKEMON_CENTER_2F"}, + {2,4,"EM_OLDALE_TOWN_MART","OldaleTown_Mart","MAP_OLDALE_TOWN_MART"}, + {3,0,"EM_DEWFORD_TOWN_HOUSE1","DewfordTown_House1","MAP_DEWFORD_TOWN_HOUSE1"}, + {3,1,"EM_DEWFORD_TOWN_POKEMON_CENTER_1F","DewfordTown_PokemonCenter_1F","MAP_DEWFORD_TOWN_POKEMON_CENTER_1F"}, + {3,2,"EM_DEWFORD_TOWN_POKEMON_CENTER_2F","DewfordTown_PokemonCenter_2F","MAP_DEWFORD_TOWN_POKEMON_CENTER_2F"}, + {3,3,"EM_DEWFORD_TOWN_GYM","DewfordTown_Gym","MAP_DEWFORD_TOWN_GYM"}, + {3,4,"EM_DEWFORD_TOWN_HALL","DewfordTown_Hall","MAP_DEWFORD_TOWN_HALL"}, + {3,5,"EM_DEWFORD_TOWN_HOUSE2","DewfordTown_House2","MAP_DEWFORD_TOWN_HOUSE2"}, + {4,0,"EM_LAVARIDGE_TOWN_HERB_SHOP","LavaridgeTown_HerbShop","MAP_LAVARIDGE_TOWN_HERB_SHOP"}, + {4,1,"EM_LAVARIDGE_TOWN_GYM_1F","LavaridgeTown_Gym_1F","MAP_LAVARIDGE_TOWN_GYM_1F"}, + {4,2,"EM_LAVARIDGE_TOWN_GYM_B1F","LavaridgeTown_Gym_B1F","MAP_LAVARIDGE_TOWN_GYM_B1F"}, + {4,3,"EM_LAVARIDGE_TOWN_HOUSE","LavaridgeTown_House","MAP_LAVARIDGE_TOWN_HOUSE"}, + {4,4,"EM_LAVARIDGE_TOWN_MART","LavaridgeTown_Mart","MAP_LAVARIDGE_TOWN_MART"}, + {4,5,"EM_LAVARIDGE_TOWN_POKEMON_CENTER_1F","LavaridgeTown_PokemonCenter_1F","MAP_LAVARIDGE_TOWN_POKEMON_CENTER_1F"}, + {4,6,"EM_LAVARIDGE_TOWN_POKEMON_CENTER_2F","LavaridgeTown_PokemonCenter_2F","MAP_LAVARIDGE_TOWN_POKEMON_CENTER_2F"}, + {5,0,"EM_FALLARBOR_TOWN_MART","FallarborTown_Mart","MAP_FALLARBOR_TOWN_MART"}, + {5,1,"EM_FALLARBOR_TOWN_BATTLE_TENT_LOBBY","FallarborTown_BattleTentLobby","MAP_FALLARBOR_TOWN_BATTLE_TENT_LOBBY"}, + {5,2,"EM_FALLARBOR_TOWN_BATTLE_TENT_CORRIDOR","FallarborTown_BattleTentCorridor","MAP_FALLARBOR_TOWN_BATTLE_TENT_CORRIDOR"}, + {5,3,"EM_FALLARBOR_TOWN_BATTLE_TENT_BATTLE_ROOM","FallarborTown_BattleTentBattleRoom","MAP_FALLARBOR_TOWN_BATTLE_TENT_BATTLE_ROOM"}, + {5,4,"EM_FALLARBOR_TOWN_POKEMON_CENTER_1F","FallarborTown_PokemonCenter_1F","MAP_FALLARBOR_TOWN_POKEMON_CENTER_1F"}, + {5,5,"EM_FALLARBOR_TOWN_POKEMON_CENTER_2F","FallarborTown_PokemonCenter_2F","MAP_FALLARBOR_TOWN_POKEMON_CENTER_2F"}, + {5,6,"EM_FALLARBOR_TOWN_COZMOS_HOUSE","FallarborTown_CozmosHouse","MAP_FALLARBOR_TOWN_COZMOS_HOUSE"}, + {5,7,"EM_FALLARBOR_TOWN_MOVE_RELEARNERS_HOUSE","FallarborTown_MoveRelearnersHouse","MAP_FALLARBOR_TOWN_MOVE_RELEARNERS_HOUSE"}, + {6,0,"EM_VERDANTURF_TOWN_BATTLE_TENT_LOBBY","VerdanturfTown_BattleTentLobby","MAP_VERDANTURF_TOWN_BATTLE_TENT_LOBBY"}, + {6,1,"EM_VERDANTURF_TOWN_BATTLE_TENT_CORRIDOR","VerdanturfTown_BattleTentCorridor","MAP_VERDANTURF_TOWN_BATTLE_TENT_CORRIDOR"}, + {6,2,"EM_VERDANTURF_TOWN_BATTLE_TENT_BATTLE_ROOM","VerdanturfTown_BattleTentBattleRoom","MAP_VERDANTURF_TOWN_BATTLE_TENT_BATTLE_ROOM"}, + {6,3,"EM_VERDANTURF_TOWN_MART","VerdanturfTown_Mart","MAP_VERDANTURF_TOWN_MART"}, + {6,4,"EM_VERDANTURF_TOWN_POKEMON_CENTER_1F","VerdanturfTown_PokemonCenter_1F","MAP_VERDANTURF_TOWN_POKEMON_CENTER_1F"}, + {6,5,"EM_VERDANTURF_TOWN_POKEMON_CENTER_2F","VerdanturfTown_PokemonCenter_2F","MAP_VERDANTURF_TOWN_POKEMON_CENTER_2F"}, + {6,6,"EM_VERDANTURF_TOWN_WANDAS_HOUSE","VerdanturfTown_WandasHouse","MAP_VERDANTURF_TOWN_WANDAS_HOUSE"}, + {6,7,"EM_VERDANTURF_TOWN_FRIENDSHIP_RATERS_HOUSE","VerdanturfTown_FriendshipRatersHouse","MAP_VERDANTURF_TOWN_FRIENDSHIP_RATERS_HOUSE"}, + {6,8,"EM_VERDANTURF_TOWN_HOUSE","VerdanturfTown_House","MAP_VERDANTURF_TOWN_HOUSE"}, + {7,0,"EM_PACIFIDLOG_TOWN_POKEMON_CENTER_1F","PacifidlogTown_PokemonCenter_1F","MAP_PACIFIDLOG_TOWN_POKEMON_CENTER_1F"}, + {7,1,"EM_PACIFIDLOG_TOWN_POKEMON_CENTER_2F","PacifidlogTown_PokemonCenter_2F","MAP_PACIFIDLOG_TOWN_POKEMON_CENTER_2F"}, + {7,2,"EM_PACIFIDLOG_TOWN_HOUSE1","PacifidlogTown_House1","MAP_PACIFIDLOG_TOWN_HOUSE1"}, + {7,3,"EM_PACIFIDLOG_TOWN_HOUSE2","PacifidlogTown_House2","MAP_PACIFIDLOG_TOWN_HOUSE2"}, + {7,4,"EM_PACIFIDLOG_TOWN_HOUSE3","PacifidlogTown_House3","MAP_PACIFIDLOG_TOWN_HOUSE3"}, + {7,5,"EM_PACIFIDLOG_TOWN_HOUSE4","PacifidlogTown_House4","MAP_PACIFIDLOG_TOWN_HOUSE4"}, + {7,6,"EM_PACIFIDLOG_TOWN_HOUSE5","PacifidlogTown_House5","MAP_PACIFIDLOG_TOWN_HOUSE5"}, + {8,0,"EM_PETALBURG_CITY_WALLYS_HOUSE","PetalburgCity_WallysHouse","MAP_PETALBURG_CITY_WALLYS_HOUSE"}, + {8,1,"EM_PETALBURG_CITY_GYM","PetalburgCity_Gym","MAP_PETALBURG_CITY_GYM"}, + {8,2,"EM_PETALBURG_CITY_HOUSE1","PetalburgCity_House1","MAP_PETALBURG_CITY_HOUSE1"}, + {8,3,"EM_PETALBURG_CITY_HOUSE2","PetalburgCity_House2","MAP_PETALBURG_CITY_HOUSE2"}, + {8,4,"EM_PETALBURG_CITY_POKEMON_CENTER_1F","PetalburgCity_PokemonCenter_1F","MAP_PETALBURG_CITY_POKEMON_CENTER_1F"}, + {8,5,"EM_PETALBURG_CITY_POKEMON_CENTER_2F","PetalburgCity_PokemonCenter_2F","MAP_PETALBURG_CITY_POKEMON_CENTER_2F"}, + {8,6,"EM_PETALBURG_CITY_MART","PetalburgCity_Mart","MAP_PETALBURG_CITY_MART"}, + {9,0,"EM_SLATEPORT_CITY_STERNS_SHIPYARD_1F","SlateportCity_SternsShipyard_1F","MAP_SLATEPORT_CITY_STERNS_SHIPYARD_1F"}, + {9,1,"EM_SLATEPORT_CITY_STERNS_SHIPYARD_2F","SlateportCity_SternsShipyard_2F","MAP_SLATEPORT_CITY_STERNS_SHIPYARD_2F"}, + {9,2,"EM_SLATEPORT_CITY_BATTLE_TENT_LOBBY","SlateportCity_BattleTentLobby","MAP_SLATEPORT_CITY_BATTLE_TENT_LOBBY"}, + {9,3,"EM_SLATEPORT_CITY_BATTLE_TENT_CORRIDOR","SlateportCity_BattleTentCorridor","MAP_SLATEPORT_CITY_BATTLE_TENT_CORRIDOR"}, + {9,4,"EM_SLATEPORT_CITY_BATTLE_TENT_BATTLE_ROOM","SlateportCity_BattleTentBattleRoom","MAP_SLATEPORT_CITY_BATTLE_TENT_BATTLE_ROOM"}, + {9,5,"EM_SLATEPORT_CITY_NAME_RATERS_HOUSE","SlateportCity_NameRatersHouse","MAP_SLATEPORT_CITY_NAME_RATERS_HOUSE"}, + {9,6,"EM_SLATEPORT_CITY_POKEMON_FAN_CLUB","SlateportCity_PokemonFanClub","MAP_SLATEPORT_CITY_POKEMON_FAN_CLUB"}, + {9,7,"EM_SLATEPORT_CITY_OCEANIC_MUSEUM_1F","SlateportCity_OceanicMuseum_1F","MAP_SLATEPORT_CITY_OCEANIC_MUSEUM_1F"}, + {9,8,"EM_SLATEPORT_CITY_OCEANIC_MUSEUM_2F","SlateportCity_OceanicMuseum_2F","MAP_SLATEPORT_CITY_OCEANIC_MUSEUM_2F"}, + {9,9,"EM_SLATEPORT_CITY_HARBOR","SlateportCity_Harbor","MAP_SLATEPORT_CITY_HARBOR"}, + {9,10,"EM_SLATEPORT_CITY_HOUSE","SlateportCity_House","MAP_SLATEPORT_CITY_HOUSE"}, + {9,11,"EM_SLATEPORT_CITY_POKEMON_CENTER_1F","SlateportCity_PokemonCenter_1F","MAP_SLATEPORT_CITY_POKEMON_CENTER_1F"}, + {9,12,"EM_SLATEPORT_CITY_POKEMON_CENTER_2F","SlateportCity_PokemonCenter_2F","MAP_SLATEPORT_CITY_POKEMON_CENTER_2F"}, + {9,13,"EM_SLATEPORT_CITY_MART","SlateportCity_Mart","MAP_SLATEPORT_CITY_MART"}, + {10,0,"EM_MAUVILLE_CITY_GYM","MauvilleCity_Gym","MAP_MAUVILLE_CITY_GYM"}, + {10,1,"EM_MAUVILLE_CITY_BIKE_SHOP","MauvilleCity_BikeShop","MAP_MAUVILLE_CITY_BIKE_SHOP"}, + {10,2,"EM_MAUVILLE_CITY_HOUSE1","MauvilleCity_House1","MAP_MAUVILLE_CITY_HOUSE1"}, + {10,3,"EM_MAUVILLE_CITY_GAME_CORNER","MauvilleCity_GameCorner","MAP_MAUVILLE_CITY_GAME_CORNER"}, + {10,4,"EM_MAUVILLE_CITY_HOUSE2","MauvilleCity_House2","MAP_MAUVILLE_CITY_HOUSE2"}, + {10,5,"EM_MAUVILLE_CITY_POKEMON_CENTER_1F","MauvilleCity_PokemonCenter_1F","MAP_MAUVILLE_CITY_POKEMON_CENTER_1F"}, + {10,6,"EM_MAUVILLE_CITY_POKEMON_CENTER_2F","MauvilleCity_PokemonCenter_2F","MAP_MAUVILLE_CITY_POKEMON_CENTER_2F"}, + {10,7,"EM_MAUVILLE_CITY_MART","MauvilleCity_Mart","MAP_MAUVILLE_CITY_MART"}, + {11,0,"EM_RUSTBORO_CITY_DEVON_CORP_1F","RustboroCity_DevonCorp_1F","MAP_RUSTBORO_CITY_DEVON_CORP_1F"}, + {11,1,"EM_RUSTBORO_CITY_DEVON_CORP_2F","RustboroCity_DevonCorp_2F","MAP_RUSTBORO_CITY_DEVON_CORP_2F"}, + {11,2,"EM_RUSTBORO_CITY_DEVON_CORP_3F","RustboroCity_DevonCorp_3F","MAP_RUSTBORO_CITY_DEVON_CORP_3F"}, + {11,3,"EM_RUSTBORO_CITY_GYM","RustboroCity_Gym","MAP_RUSTBORO_CITY_GYM"}, + {11,4,"EM_RUSTBORO_CITY_POKEMON_SCHOOL","RustboroCity_PokemonSchool","MAP_RUSTBORO_CITY_POKEMON_SCHOOL"}, + {11,5,"EM_RUSTBORO_CITY_POKEMON_CENTER_1F","RustboroCity_PokemonCenter_1F","MAP_RUSTBORO_CITY_POKEMON_CENTER_1F"}, + {11,6,"EM_RUSTBORO_CITY_POKEMON_CENTER_2F","RustboroCity_PokemonCenter_2F","MAP_RUSTBORO_CITY_POKEMON_CENTER_2F"}, + {11,7,"EM_RUSTBORO_CITY_MART","RustboroCity_Mart","MAP_RUSTBORO_CITY_MART"}, + {11,8,"EM_RUSTBORO_CITY_FLAT1_1F","RustboroCity_Flat1_1F","MAP_RUSTBORO_CITY_FLAT1_1F"}, + {11,9,"EM_RUSTBORO_CITY_FLAT1_2F","RustboroCity_Flat1_2F","MAP_RUSTBORO_CITY_FLAT1_2F"}, + {11,10,"EM_RUSTBORO_CITY_HOUSE1","RustboroCity_House1","MAP_RUSTBORO_CITY_HOUSE1"}, + {11,11,"EM_RUSTBORO_CITY_CUTTERS_HOUSE","RustboroCity_CuttersHouse","MAP_RUSTBORO_CITY_CUTTERS_HOUSE"}, + {11,12,"EM_RUSTBORO_CITY_HOUSE2","RustboroCity_House2","MAP_RUSTBORO_CITY_HOUSE2"}, + {11,13,"EM_RUSTBORO_CITY_FLAT2_1F","RustboroCity_Flat2_1F","MAP_RUSTBORO_CITY_FLAT2_1F"}, + {11,14,"EM_RUSTBORO_CITY_FLAT2_2F","RustboroCity_Flat2_2F","MAP_RUSTBORO_CITY_FLAT2_2F"}, + {11,15,"EM_RUSTBORO_CITY_FLAT2_3F","RustboroCity_Flat2_3F","MAP_RUSTBORO_CITY_FLAT2_3F"}, + {11,16,"EM_RUSTBORO_CITY_HOUSE3","RustboroCity_House3","MAP_RUSTBORO_CITY_HOUSE3"}, + {12,0,"EM_FORTREE_CITY_HOUSE1","FortreeCity_House1","MAP_FORTREE_CITY_HOUSE1"}, + {12,1,"EM_FORTREE_CITY_GYM","FortreeCity_Gym","MAP_FORTREE_CITY_GYM"}, + {12,2,"EM_FORTREE_CITY_POKEMON_CENTER_1F","FortreeCity_PokemonCenter_1F","MAP_FORTREE_CITY_POKEMON_CENTER_1F"}, + {12,3,"EM_FORTREE_CITY_POKEMON_CENTER_2F","FortreeCity_PokemonCenter_2F","MAP_FORTREE_CITY_POKEMON_CENTER_2F"}, + {12,4,"EM_FORTREE_CITY_MART","FortreeCity_Mart","MAP_FORTREE_CITY_MART"}, + {12,5,"EM_FORTREE_CITY_HOUSE2","FortreeCity_House2","MAP_FORTREE_CITY_HOUSE2"}, + {12,6,"EM_FORTREE_CITY_HOUSE3","FortreeCity_House3","MAP_FORTREE_CITY_HOUSE3"}, + {12,7,"EM_FORTREE_CITY_HOUSE4","FortreeCity_House4","MAP_FORTREE_CITY_HOUSE4"}, + {12,8,"EM_FORTREE_CITY_HOUSE5","FortreeCity_House5","MAP_FORTREE_CITY_HOUSE5"}, + {12,9,"EM_FORTREE_CITY_DECORATION_SHOP","FortreeCity_DecorationShop","MAP_FORTREE_CITY_DECORATION_SHOP"}, + {13,0,"EM_LILYCOVE_CITY_COVE_LILY_MOTEL_1F","LilycoveCity_CoveLilyMotel_1F","MAP_LILYCOVE_CITY_COVE_LILY_MOTEL_1F"}, + {13,1,"EM_LILYCOVE_CITY_COVE_LILY_MOTEL_2F","LilycoveCity_CoveLilyMotel_2F","MAP_LILYCOVE_CITY_COVE_LILY_MOTEL_2F"}, + {13,2,"EM_LILYCOVE_CITY_LILYCOVE_MUSEUM_1F","LilycoveCity_LilycoveMuseum_1F","MAP_LILYCOVE_CITY_LILYCOVE_MUSEUM_1F"}, + {13,3,"EM_LILYCOVE_CITY_LILYCOVE_MUSEUM_2F","LilycoveCity_LilycoveMuseum_2F","MAP_LILYCOVE_CITY_LILYCOVE_MUSEUM_2F"}, + {13,4,"EM_LILYCOVE_CITY_CONTEST_LOBBY","LilycoveCity_ContestLobby","MAP_LILYCOVE_CITY_CONTEST_LOBBY"}, + {13,5,"EM_LILYCOVE_CITY_CONTEST_HALL","LilycoveCity_ContestHall","MAP_LILYCOVE_CITY_CONTEST_HALL"}, + {13,6,"EM_LILYCOVE_CITY_POKEMON_CENTER_1F","LilycoveCity_PokemonCenter_1F","MAP_LILYCOVE_CITY_POKEMON_CENTER_1F"}, + {13,7,"EM_LILYCOVE_CITY_POKEMON_CENTER_2F","LilycoveCity_PokemonCenter_2F","MAP_LILYCOVE_CITY_POKEMON_CENTER_2F"}, + {13,8,"EM_LILYCOVE_CITY_UNUSED_MART","LilycoveCity_UnusedMart","MAP_LILYCOVE_CITY_UNUSED_MART"}, + {13,9,"EM_LILYCOVE_CITY_POKEMON_TRAINER_FAN_CLUB","LilycoveCity_PokemonTrainerFanClub","MAP_LILYCOVE_CITY_POKEMON_TRAINER_FAN_CLUB"}, + {13,10,"EM_LILYCOVE_CITY_HARBOR","LilycoveCity_Harbor","MAP_LILYCOVE_CITY_HARBOR"}, + {13,11,"EM_LILYCOVE_CITY_MOVE_DELETERS_HOUSE","LilycoveCity_MoveDeletersHouse","MAP_LILYCOVE_CITY_MOVE_DELETERS_HOUSE"}, + {13,12,"EM_LILYCOVE_CITY_HOUSE1","LilycoveCity_House1","MAP_LILYCOVE_CITY_HOUSE1"}, + {13,13,"EM_LILYCOVE_CITY_HOUSE2","LilycoveCity_House2","MAP_LILYCOVE_CITY_HOUSE2"}, + {13,14,"EM_LILYCOVE_CITY_HOUSE3","LilycoveCity_House3","MAP_LILYCOVE_CITY_HOUSE3"}, + {13,15,"EM_LILYCOVE_CITY_HOUSE4","LilycoveCity_House4","MAP_LILYCOVE_CITY_HOUSE4"}, + {13,16,"EM_LILYCOVE_CITY_DEPARTMENT_STORE_1F","LilycoveCity_DepartmentStore_1F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_1F"}, + {13,17,"EM_LILYCOVE_CITY_DEPARTMENT_STORE_2F","LilycoveCity_DepartmentStore_2F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_2F"}, + {13,18,"EM_LILYCOVE_CITY_DEPARTMENT_STORE_3F","LilycoveCity_DepartmentStore_3F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_3F"}, + {13,19,"EM_LILYCOVE_CITY_DEPARTMENT_STORE_4F","LilycoveCity_DepartmentStore_4F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_4F"}, + {13,20,"EM_LILYCOVE_CITY_DEPARTMENT_STORE_5F","LilycoveCity_DepartmentStore_5F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_5F"}, + {13,21,"EM_LILYCOVE_CITY_DEPARTMENT_STORE_ROOFTOP","LilycoveCity_DepartmentStoreRooftop","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_ROOFTOP"}, + {13,22,"EM_LILYCOVE_CITY_DEPARTMENT_STORE_ELEVATOR","LilycoveCity_DepartmentStoreElevator","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_ELEVATOR"}, + {14,0,"EM_MOSSDEEP_CITY_GYM","MossdeepCity_Gym","MAP_MOSSDEEP_CITY_GYM"}, + {14,1,"EM_MOSSDEEP_CITY_HOUSE1","MossdeepCity_House1","MAP_MOSSDEEP_CITY_HOUSE1"}, + {14,2,"EM_MOSSDEEP_CITY_HOUSE2","MossdeepCity_House2","MAP_MOSSDEEP_CITY_HOUSE2"}, + {14,3,"EM_MOSSDEEP_CITY_POKEMON_CENTER_1F","MossdeepCity_PokemonCenter_1F","MAP_MOSSDEEP_CITY_POKEMON_CENTER_1F"}, + {14,4,"EM_MOSSDEEP_CITY_POKEMON_CENTER_2F","MossdeepCity_PokemonCenter_2F","MAP_MOSSDEEP_CITY_POKEMON_CENTER_2F"}, + {14,5,"EM_MOSSDEEP_CITY_MART","MossdeepCity_Mart","MAP_MOSSDEEP_CITY_MART"}, + {14,6,"EM_MOSSDEEP_CITY_HOUSE3","MossdeepCity_House3","MAP_MOSSDEEP_CITY_HOUSE3"}, + {14,7,"EM_MOSSDEEP_CITY_STEVENS_HOUSE","MossdeepCity_StevensHouse","MAP_MOSSDEEP_CITY_STEVENS_HOUSE"}, + {14,8,"EM_MOSSDEEP_CITY_HOUSE4","MossdeepCity_House4","MAP_MOSSDEEP_CITY_HOUSE4"}, + {14,9,"EM_MOSSDEEP_CITY_SPACE_CENTER_1F","MossdeepCity_SpaceCenter_1F","MAP_MOSSDEEP_CITY_SPACE_CENTER_1F"}, + {14,10,"EM_MOSSDEEP_CITY_SPACE_CENTER_2F","MossdeepCity_SpaceCenter_2F","MAP_MOSSDEEP_CITY_SPACE_CENTER_2F"}, + {14,11,"EM_MOSSDEEP_CITY_GAME_CORNER_1F","MossdeepCity_GameCorner_1F","MAP_MOSSDEEP_CITY_GAME_CORNER_1F"}, + {14,12,"EM_MOSSDEEP_CITY_GAME_CORNER_B1F","MossdeepCity_GameCorner_B1F","MAP_MOSSDEEP_CITY_GAME_CORNER_B1F"}, + {15,0,"EM_SOOTOPOLIS_CITY_GYM_1F","SootopolisCity_Gym_1F","MAP_SOOTOPOLIS_CITY_GYM_1F"}, + {15,1,"EM_SOOTOPOLIS_CITY_GYM_B1F","SootopolisCity_Gym_B1F","MAP_SOOTOPOLIS_CITY_GYM_B1F"}, + {15,2,"EM_SOOTOPOLIS_CITY_POKEMON_CENTER_1F","SootopolisCity_PokemonCenter_1F","MAP_SOOTOPOLIS_CITY_POKEMON_CENTER_1F"}, + {15,3,"EM_SOOTOPOLIS_CITY_POKEMON_CENTER_2F","SootopolisCity_PokemonCenter_2F","MAP_SOOTOPOLIS_CITY_POKEMON_CENTER_2F"}, + {15,4,"EM_SOOTOPOLIS_CITY_MART","SootopolisCity_Mart","MAP_SOOTOPOLIS_CITY_MART"}, + {15,5,"EM_SOOTOPOLIS_CITY_HOUSE1","SootopolisCity_House1","MAP_SOOTOPOLIS_CITY_HOUSE1"}, + {15,6,"EM_SOOTOPOLIS_CITY_HOUSE2","SootopolisCity_House2","MAP_SOOTOPOLIS_CITY_HOUSE2"}, + {15,7,"EM_SOOTOPOLIS_CITY_HOUSE3","SootopolisCity_House3","MAP_SOOTOPOLIS_CITY_HOUSE3"}, + {15,8,"EM_SOOTOPOLIS_CITY_HOUSE4","SootopolisCity_House4","MAP_SOOTOPOLIS_CITY_HOUSE4"}, + {15,9,"EM_SOOTOPOLIS_CITY_HOUSE5","SootopolisCity_House5","MAP_SOOTOPOLIS_CITY_HOUSE5"}, + {15,10,"EM_SOOTOPOLIS_CITY_HOUSE6","SootopolisCity_House6","MAP_SOOTOPOLIS_CITY_HOUSE6"}, + {15,11,"EM_SOOTOPOLIS_CITY_HOUSE7","SootopolisCity_House7","MAP_SOOTOPOLIS_CITY_HOUSE7"}, + {15,12,"EM_SOOTOPOLIS_CITY_LOTAD_AND_SEEDOT_HOUSE","SootopolisCity_LotadAndSeedotHouse","MAP_SOOTOPOLIS_CITY_LOTAD_AND_SEEDOT_HOUSE"}, + {15,13,"EM_SOOTOPOLIS_CITY_MYSTERY_EVENTS_HOUSE_1F","SootopolisCity_MysteryEventsHouse_1F","MAP_SOOTOPOLIS_CITY_MYSTERY_EVENTS_HOUSE_1F"}, + {15,14,"EM_SOOTOPOLIS_CITY_MYSTERY_EVENTS_HOUSE_B1F","SootopolisCity_MysteryEventsHouse_B1F","MAP_SOOTOPOLIS_CITY_MYSTERY_EVENTS_HOUSE_B1F"}, + {16,0,"EM_EVER_GRANDE_CITY_SIDNEYS_ROOM","EverGrandeCity_SidneysRoom","MAP_EVER_GRANDE_CITY_SIDNEYS_ROOM"}, + {16,1,"EM_EVER_GRANDE_CITY_PHOEBES_ROOM","EverGrandeCity_PhoebesRoom","MAP_EVER_GRANDE_CITY_PHOEBES_ROOM"}, + {16,2,"EM_EVER_GRANDE_CITY_GLACIAS_ROOM","EverGrandeCity_GlaciasRoom","MAP_EVER_GRANDE_CITY_GLACIAS_ROOM"}, + {16,3,"EM_EVER_GRANDE_CITY_DRAKES_ROOM","EverGrandeCity_DrakesRoom","MAP_EVER_GRANDE_CITY_DRAKES_ROOM"}, + {16,4,"EM_EVER_GRANDE_CITY_CHAMPIONS_ROOM","EverGrandeCity_ChampionsRoom","MAP_EVER_GRANDE_CITY_CHAMPIONS_ROOM"}, + {16,5,"EM_EVER_GRANDE_CITY_HALL1","EverGrandeCity_Hall1","MAP_EVER_GRANDE_CITY_HALL1"}, + {16,6,"EM_EVER_GRANDE_CITY_HALL2","EverGrandeCity_Hall2","MAP_EVER_GRANDE_CITY_HALL2"}, + {16,7,"EM_EVER_GRANDE_CITY_HALL3","EverGrandeCity_Hall3","MAP_EVER_GRANDE_CITY_HALL3"}, + {16,8,"EM_EVER_GRANDE_CITY_HALL4","EverGrandeCity_Hall4","MAP_EVER_GRANDE_CITY_HALL4"}, + {16,9,"EM_EVER_GRANDE_CITY_HALL5","EverGrandeCity_Hall5","MAP_EVER_GRANDE_CITY_HALL5"}, + {16,10,"EM_EVER_GRANDE_CITY_POKEMON_LEAGUE_1F","EverGrandeCity_PokemonLeague_1F","MAP_EVER_GRANDE_CITY_POKEMON_LEAGUE_1F"}, + {16,11,"EM_EVER_GRANDE_CITY_HALL_OF_FAME","EverGrandeCity_HallOfFame","MAP_EVER_GRANDE_CITY_HALL_OF_FAME"}, + {16,12,"EM_EVER_GRANDE_CITY_POKEMON_CENTER_1F","EverGrandeCity_PokemonCenter_1F","MAP_EVER_GRANDE_CITY_POKEMON_CENTER_1F"}, + {16,13,"EM_EVER_GRANDE_CITY_POKEMON_CENTER_2F","EverGrandeCity_PokemonCenter_2F","MAP_EVER_GRANDE_CITY_POKEMON_CENTER_2F"}, + {16,14,"EM_EVER_GRANDE_CITY_POKEMON_LEAGUE_2F","EverGrandeCity_PokemonLeague_2F","MAP_EVER_GRANDE_CITY_POKEMON_LEAGUE_2F"}, + {17,0,"EM_ROUTE104_MR_BRINEYS_HOUSE","Route104_MrBrineysHouse","MAP_ROUTE104_MR_BRINEYS_HOUSE"}, + {17,1,"EM_ROUTE104_PRETTY_PETAL_FLOWER_SHOP","Route104_PrettyPetalFlowerShop","MAP_ROUTE104_PRETTY_PETAL_FLOWER_SHOP"}, + {18,0,"EM_ROUTE111_WINSTRATE_FAMILYS_HOUSE","Route111_WinstrateFamilysHouse","MAP_ROUTE111_WINSTRATE_FAMILYS_HOUSE"}, + {18,1,"EM_ROUTE111_OLD_LADYS_REST_STOP","Route111_OldLadysRestStop","MAP_ROUTE111_OLD_LADYS_REST_STOP"}, + {19,0,"EM_ROUTE112_CABLE_CAR_STATION","Route112_CableCarStation","MAP_ROUTE112_CABLE_CAR_STATION"}, + {19,1,"EM_MT_CHIMNEY_CABLE_CAR_STATION","MtChimney_CableCarStation","MAP_MT_CHIMNEY_CABLE_CAR_STATION"}, + {20,0,"EM_ROUTE114_FOSSIL_MANIACS_HOUSE","Route114_FossilManiacsHouse","MAP_ROUTE114_FOSSIL_MANIACS_HOUSE"}, + {20,1,"EM_ROUTE114_FOSSIL_MANIACS_TUNNEL","Route114_FossilManiacsTunnel","MAP_ROUTE114_FOSSIL_MANIACS_TUNNEL"}, + {20,2,"EM_ROUTE114_LANETTES_HOUSE","Route114_LanettesHouse","MAP_ROUTE114_LANETTES_HOUSE"}, + {21,0,"EM_ROUTE116_TUNNELERS_REST_HOUSE","Route116_TunnelersRestHouse","MAP_ROUTE116_TUNNELERS_REST_HOUSE"}, + {22,0,"EM_ROUTE117_POKEMON_DAY_CARE","Route117_PokemonDayCare","MAP_ROUTE117_POKEMON_DAY_CARE"}, + {23,0,"EM_ROUTE121_SAFARI_ZONE_ENTRANCE","Route121_SafariZoneEntrance","MAP_ROUTE121_SAFARI_ZONE_ENTRANCE"}, + {24,0,"EM_METEOR_FALLS_1F_1R","MeteorFalls_1F_1R","MAP_METEOR_FALLS_1F_1R"}, + {24,1,"EM_METEOR_FALLS_1F_2R","MeteorFalls_1F_2R","MAP_METEOR_FALLS_1F_2R"}, + {24,2,"EM_METEOR_FALLS_B1F_1R","MeteorFalls_B1F_1R","MAP_METEOR_FALLS_B1F_1R"}, + {24,3,"EM_METEOR_FALLS_B1F_2R","MeteorFalls_B1F_2R","MAP_METEOR_FALLS_B1F_2R"}, + {24,4,"EM_RUSTURF_TUNNEL","RusturfTunnel","MAP_RUSTURF_TUNNEL"}, + {24,5,"EM_UNDERWATER_SOOTOPOLIS_CITY","Underwater_SootopolisCity","MAP_UNDERWATER_SOOTOPOLIS_CITY"}, + {24,6,"EM_DESERT_RUINS","DesertRuins","MAP_DESERT_RUINS"}, + {24,7,"EM_GRANITE_CAVE_1F","GraniteCave_1F","MAP_GRANITE_CAVE_1F"}, + {24,8,"EM_GRANITE_CAVE_B1F","GraniteCave_B1F","MAP_GRANITE_CAVE_B1F"}, + {24,9,"EM_GRANITE_CAVE_B2F","GraniteCave_B2F","MAP_GRANITE_CAVE_B2F"}, + {24,10,"EM_GRANITE_CAVE_STEVENS_ROOM","GraniteCave_StevensRoom","MAP_GRANITE_CAVE_STEVENS_ROOM"}, + {24,11,"EM_PETALBURG_WOODS","PetalburgWoods","MAP_PETALBURG_WOODS"}, + {24,12,"EM_MT_CHIMNEY","MtChimney","MAP_MT_CHIMNEY"}, + {24,13,"EM_JAGGED_PASS","JaggedPass","MAP_JAGGED_PASS"}, + {24,14,"EM_FIERY_PATH","FieryPath","MAP_FIERY_PATH"}, + {24,15,"EM_MT_PYRE_1F","MtPyre_1F","MAP_MT_PYRE_1F"}, + {24,16,"EM_MT_PYRE_2F","MtPyre_2F","MAP_MT_PYRE_2F"}, + {24,17,"EM_MT_PYRE_3F","MtPyre_3F","MAP_MT_PYRE_3F"}, + {24,18,"EM_MT_PYRE_4F","MtPyre_4F","MAP_MT_PYRE_4F"}, + {24,19,"EM_MT_PYRE_5F","MtPyre_5F","MAP_MT_PYRE_5F"}, + {24,20,"EM_MT_PYRE_6F","MtPyre_6F","MAP_MT_PYRE_6F"}, + {24,21,"EM_MT_PYRE_EXTERIOR","MtPyre_Exterior","MAP_MT_PYRE_EXTERIOR"}, + {24,22,"EM_MT_PYRE_SUMMIT","MtPyre_Summit","MAP_MT_PYRE_SUMMIT"}, + {24,23,"EM_AQUA_HIDEOUT_1F","AquaHideout_1F","MAP_AQUA_HIDEOUT_1F"}, + {24,24,"EM_AQUA_HIDEOUT_B1F","AquaHideout_B1F","MAP_AQUA_HIDEOUT_B1F"}, + {24,25,"EM_AQUA_HIDEOUT_B2F","AquaHideout_B2F","MAP_AQUA_HIDEOUT_B2F"}, + {24,26,"EM_UNDERWATER_SEAFLOOR_CAVERN","Underwater_SeafloorCavern","MAP_UNDERWATER_SEAFLOOR_CAVERN"}, + {24,27,"EM_SEAFLOOR_CAVERN_ENTRANCE","SeafloorCavern_Entrance","MAP_SEAFLOOR_CAVERN_ENTRANCE"}, + {24,28,"EM_SEAFLOOR_CAVERN_ROOM1","SeafloorCavern_Room1","MAP_SEAFLOOR_CAVERN_ROOM1"}, + {24,29,"EM_SEAFLOOR_CAVERN_ROOM2","SeafloorCavern_Room2","MAP_SEAFLOOR_CAVERN_ROOM2"}, + {24,30,"EM_SEAFLOOR_CAVERN_ROOM3","SeafloorCavern_Room3","MAP_SEAFLOOR_CAVERN_ROOM3"}, + {24,31,"EM_SEAFLOOR_CAVERN_ROOM4","SeafloorCavern_Room4","MAP_SEAFLOOR_CAVERN_ROOM4"}, + {24,32,"EM_SEAFLOOR_CAVERN_ROOM5","SeafloorCavern_Room5","MAP_SEAFLOOR_CAVERN_ROOM5"}, + {24,33,"EM_SEAFLOOR_CAVERN_ROOM6","SeafloorCavern_Room6","MAP_SEAFLOOR_CAVERN_ROOM6"}, + {24,34,"EM_SEAFLOOR_CAVERN_ROOM7","SeafloorCavern_Room7","MAP_SEAFLOOR_CAVERN_ROOM7"}, + {24,35,"EM_SEAFLOOR_CAVERN_ROOM8","SeafloorCavern_Room8","MAP_SEAFLOOR_CAVERN_ROOM8"}, + {24,36,"EM_SEAFLOOR_CAVERN_ROOM9","SeafloorCavern_Room9","MAP_SEAFLOOR_CAVERN_ROOM9"}, + {24,37,"EM_CAVE_OF_ORIGIN_ENTRANCE","CaveOfOrigin_Entrance","MAP_CAVE_OF_ORIGIN_ENTRANCE"}, + {24,38,"EM_CAVE_OF_ORIGIN_1F","CaveOfOrigin_1F","MAP_CAVE_OF_ORIGIN_1F"}, + {24,39,"EM_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP1","CaveOfOrigin_UnusedRubySapphireMap1","MAP_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP1"}, + {24,40,"EM_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP2","CaveOfOrigin_UnusedRubySapphireMap2","MAP_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP2"}, + {24,41,"EM_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP3","CaveOfOrigin_UnusedRubySapphireMap3","MAP_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP3"}, + {24,42,"EM_CAVE_OF_ORIGIN_B1F","CaveOfOrigin_B1F","MAP_CAVE_OF_ORIGIN_B1F"}, + {24,43,"EM_VICTORY_ROAD_1F","VictoryRoad_1F","MAP_VICTORY_ROAD_1F"}, + {24,44,"EM_VICTORY_ROAD_B1F","VictoryRoad_B1F","MAP_VICTORY_ROAD_B1F"}, + {24,45,"EM_VICTORY_ROAD_B2F","VictoryRoad_B2F","MAP_VICTORY_ROAD_B2F"}, + {24,46,"EM_SHOAL_CAVE_LOW_TIDE_ENTRANCE_ROOM","ShoalCave_LowTideEntranceRoom","MAP_SHOAL_CAVE_LOW_TIDE_ENTRANCE_ROOM"}, + {24,47,"EM_SHOAL_CAVE_LOW_TIDE_INNER_ROOM","ShoalCave_LowTideInnerRoom","MAP_SHOAL_CAVE_LOW_TIDE_INNER_ROOM"}, + {24,48,"EM_SHOAL_CAVE_LOW_TIDE_STAIRS_ROOM","ShoalCave_LowTideStairsRoom","MAP_SHOAL_CAVE_LOW_TIDE_STAIRS_ROOM"}, + {24,49,"EM_SHOAL_CAVE_LOW_TIDE_LOWER_ROOM","ShoalCave_LowTideLowerRoom","MAP_SHOAL_CAVE_LOW_TIDE_LOWER_ROOM"}, + {24,50,"EM_SHOAL_CAVE_HIGH_TIDE_ENTRANCE_ROOM","ShoalCave_HighTideEntranceRoom","MAP_SHOAL_CAVE_HIGH_TIDE_ENTRANCE_ROOM"}, + {24,51,"EM_SHOAL_CAVE_HIGH_TIDE_INNER_ROOM","ShoalCave_HighTideInnerRoom","MAP_SHOAL_CAVE_HIGH_TIDE_INNER_ROOM"}, + {24,52,"EM_NEW_MAUVILLE_ENTRANCE","NewMauville_Entrance","MAP_NEW_MAUVILLE_ENTRANCE"}, + {24,53,"EM_NEW_MAUVILLE_INSIDE","NewMauville_Inside","MAP_NEW_MAUVILLE_INSIDE"}, + {24,54,"EM_ABANDONED_SHIP_DECK","AbandonedShip_Deck","MAP_ABANDONED_SHIP_DECK"}, + {24,55,"EM_ABANDONED_SHIP_CORRIDORS_1F","AbandonedShip_Corridors_1F","MAP_ABANDONED_SHIP_CORRIDORS_1F"}, + {24,56,"EM_ABANDONED_SHIP_ROOMS_1F","AbandonedShip_Rooms_1F","MAP_ABANDONED_SHIP_ROOMS_1F"}, + {24,57,"EM_ABANDONED_SHIP_CORRIDORS_B1F","AbandonedShip_Corridors_B1F","MAP_ABANDONED_SHIP_CORRIDORS_B1F"}, + {24,58,"EM_ABANDONED_SHIP_ROOMS_B1F","AbandonedShip_Rooms_B1F","MAP_ABANDONED_SHIP_ROOMS_B1F"}, + {24,59,"EM_ABANDONED_SHIP_ROOMS2_B1F","AbandonedShip_Rooms2_B1F","MAP_ABANDONED_SHIP_ROOMS2_B1F"}, + {24,60,"EM_ABANDONED_SHIP_UNDERWATER1","AbandonedShip_Underwater1","MAP_ABANDONED_SHIP_UNDERWATER1"}, + {24,61,"EM_ABANDONED_SHIP_ROOM_B1F","AbandonedShip_Room_B1F","MAP_ABANDONED_SHIP_ROOM_B1F"}, + {24,62,"EM_ABANDONED_SHIP_ROOMS2_1F","AbandonedShip_Rooms2_1F","MAP_ABANDONED_SHIP_ROOMS2_1F"}, + {24,63,"EM_ABANDONED_SHIP_CAPTAINS_OFFICE","AbandonedShip_CaptainsOffice","MAP_ABANDONED_SHIP_CAPTAINS_OFFICE"}, + {24,64,"EM_ABANDONED_SHIP_UNDERWATER2","AbandonedShip_Underwater2","MAP_ABANDONED_SHIP_UNDERWATER2"}, + {24,65,"EM_ABANDONED_SHIP_HIDDEN_FLOOR_CORRIDORS","AbandonedShip_HiddenFloorCorridors","MAP_ABANDONED_SHIP_HIDDEN_FLOOR_CORRIDORS"}, + {24,66,"EM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOMS","AbandonedShip_HiddenFloorRooms","MAP_ABANDONED_SHIP_HIDDEN_FLOOR_ROOMS"}, + {24,67,"EM_ISLAND_CAVE","IslandCave","MAP_ISLAND_CAVE"}, + {24,68,"EM_ANCIENT_TOMB","AncientTomb","MAP_ANCIENT_TOMB"}, + {24,69,"EM_UNDERWATER_ROUTE134","Underwater_Route134","MAP_UNDERWATER_ROUTE134"}, + {24,70,"EM_UNDERWATER_SEALED_CHAMBER","Underwater_SealedChamber","MAP_UNDERWATER_SEALED_CHAMBER"}, + {24,71,"EM_SEALED_CHAMBER_OUTER_ROOM","SealedChamber_OuterRoom","MAP_SEALED_CHAMBER_OUTER_ROOM"}, + {24,72,"EM_SEALED_CHAMBER_INNER_ROOM","SealedChamber_InnerRoom","MAP_SEALED_CHAMBER_INNER_ROOM"}, + {24,73,"EM_SCORCHED_SLAB","ScorchedSlab","MAP_SCORCHED_SLAB"}, + {24,74,"EM_AQUA_HIDEOUT_UNUSED_RUBY_MAP1","AquaHideout_UnusedRubyMap1","MAP_AQUA_HIDEOUT_UNUSED_RUBY_MAP1"}, + {24,75,"EM_AQUA_HIDEOUT_UNUSED_RUBY_MAP2","AquaHideout_UnusedRubyMap2","MAP_AQUA_HIDEOUT_UNUSED_RUBY_MAP2"}, + {24,76,"EM_AQUA_HIDEOUT_UNUSED_RUBY_MAP3","AquaHideout_UnusedRubyMap3","MAP_AQUA_HIDEOUT_UNUSED_RUBY_MAP3"}, + {24,77,"EM_SKY_PILLAR_ENTRANCE","SkyPillar_Entrance","MAP_SKY_PILLAR_ENTRANCE"}, + {24,78,"EM_SKY_PILLAR_OUTSIDE","SkyPillar_Outside","MAP_SKY_PILLAR_OUTSIDE"}, + {24,79,"EM_SKY_PILLAR_1F","SkyPillar_1F","MAP_SKY_PILLAR_1F"}, + {24,80,"EM_SKY_PILLAR_2F","SkyPillar_2F","MAP_SKY_PILLAR_2F"}, + {24,81,"EM_SKY_PILLAR_3F","SkyPillar_3F","MAP_SKY_PILLAR_3F"}, + {24,82,"EM_SKY_PILLAR_4F","SkyPillar_4F","MAP_SKY_PILLAR_4F"}, + {24,83,"EM_SHOAL_CAVE_LOW_TIDE_ICE_ROOM","ShoalCave_LowTideIceRoom","MAP_SHOAL_CAVE_LOW_TIDE_ICE_ROOM"}, + {24,84,"EM_SKY_PILLAR_5F","SkyPillar_5F","MAP_SKY_PILLAR_5F"}, + {24,85,"EM_SKY_PILLAR_TOP","SkyPillar_Top","MAP_SKY_PILLAR_TOP"}, + {24,86,"EM_MAGMA_HIDEOUT_1F","MagmaHideout_1F","MAP_MAGMA_HIDEOUT_1F"}, + {24,87,"EM_MAGMA_HIDEOUT_2F_1R","MagmaHideout_2F_1R","MAP_MAGMA_HIDEOUT_2F_1R"}, + {24,88,"EM_MAGMA_HIDEOUT_2F_2R","MagmaHideout_2F_2R","MAP_MAGMA_HIDEOUT_2F_2R"}, + {24,89,"EM_MAGMA_HIDEOUT_3F_1R","MagmaHideout_3F_1R","MAP_MAGMA_HIDEOUT_3F_1R"}, + {24,90,"EM_MAGMA_HIDEOUT_3F_2R","MagmaHideout_3F_2R","MAP_MAGMA_HIDEOUT_3F_2R"}, + {24,91,"EM_MAGMA_HIDEOUT_4F","MagmaHideout_4F","MAP_MAGMA_HIDEOUT_4F"}, + {24,92,"EM_MAGMA_HIDEOUT_3F_3R","MagmaHideout_3F_3R","MAP_MAGMA_HIDEOUT_3F_3R"}, + {24,93,"EM_MAGMA_HIDEOUT_2F_3R","MagmaHideout_2F_3R","MAP_MAGMA_HIDEOUT_2F_3R"}, + {24,94,"EM_MIRAGE_TOWER_1F","MirageTower_1F","MAP_MIRAGE_TOWER_1F"}, + {24,95,"EM_MIRAGE_TOWER_2F","MirageTower_2F","MAP_MIRAGE_TOWER_2F"}, + {24,96,"EM_MIRAGE_TOWER_3F","MirageTower_3F","MAP_MIRAGE_TOWER_3F"}, + {24,97,"EM_MIRAGE_TOWER_4F","MirageTower_4F","MAP_MIRAGE_TOWER_4F"}, + {24,98,"EM_DESERT_UNDERPASS","DesertUnderpass","MAP_DESERT_UNDERPASS"}, + {24,99,"EM_ARTISAN_CAVE_B1F","ArtisanCave_B1F","MAP_ARTISAN_CAVE_B1F"}, + {24,100,"EM_ARTISAN_CAVE_1F","ArtisanCave_1F","MAP_ARTISAN_CAVE_1F"}, + {24,101,"EM_UNDERWATER_MARINE_CAVE","Underwater_MarineCave","MAP_UNDERWATER_MARINE_CAVE"}, + {24,102,"EM_MARINE_CAVE_ENTRANCE","MarineCave_Entrance","MAP_MARINE_CAVE_ENTRANCE"}, + {24,103,"EM_MARINE_CAVE_END","MarineCave_End","MAP_MARINE_CAVE_END"}, + {24,104,"EM_TERRA_CAVE_ENTRANCE","TerraCave_Entrance","MAP_TERRA_CAVE_ENTRANCE"}, + {24,105,"EM_TERRA_CAVE_END","TerraCave_End","MAP_TERRA_CAVE_END"}, + {24,106,"EM_ALTERING_CAVE","AlteringCave","MAP_ALTERING_CAVE"}, + {24,107,"EM_METEOR_FALLS_STEVENS_CAVE","MeteorFalls_StevensCave","MAP_METEOR_FALLS_STEVENS_CAVE"}, + {25,0,"EM_SECRET_BASE_RED_CAVE1","SecretBase_RedCave1","MAP_SECRET_BASE_RED_CAVE1"}, + {25,1,"EM_SECRET_BASE_BROWN_CAVE1","SecretBase_BrownCave1","MAP_SECRET_BASE_BROWN_CAVE1"}, + {25,2,"EM_SECRET_BASE_BLUE_CAVE1","SecretBase_BlueCave1","MAP_SECRET_BASE_BLUE_CAVE1"}, + {25,3,"EM_SECRET_BASE_YELLOW_CAVE1","SecretBase_YellowCave1","MAP_SECRET_BASE_YELLOW_CAVE1"}, + {25,4,"EM_SECRET_BASE_TREE1","SecretBase_Tree1","MAP_SECRET_BASE_TREE1"}, + {25,5,"EM_SECRET_BASE_SHRUB1","SecretBase_Shrub1","MAP_SECRET_BASE_SHRUB1"}, + {25,6,"EM_SECRET_BASE_RED_CAVE2","SecretBase_RedCave2","MAP_SECRET_BASE_RED_CAVE2"}, + {25,7,"EM_SECRET_BASE_BROWN_CAVE2","SecretBase_BrownCave2","MAP_SECRET_BASE_BROWN_CAVE2"}, + {25,8,"EM_SECRET_BASE_BLUE_CAVE2","SecretBase_BlueCave2","MAP_SECRET_BASE_BLUE_CAVE2"}, + {25,9,"EM_SECRET_BASE_YELLOW_CAVE2","SecretBase_YellowCave2","MAP_SECRET_BASE_YELLOW_CAVE2"}, + {25,10,"EM_SECRET_BASE_TREE2","SecretBase_Tree2","MAP_SECRET_BASE_TREE2"}, + {25,11,"EM_SECRET_BASE_SHRUB2","SecretBase_Shrub2","MAP_SECRET_BASE_SHRUB2"}, + {25,12,"EM_SECRET_BASE_RED_CAVE3","SecretBase_RedCave3","MAP_SECRET_BASE_RED_CAVE3"}, + {25,13,"EM_SECRET_BASE_BROWN_CAVE3","SecretBase_BrownCave3","MAP_SECRET_BASE_BROWN_CAVE3"}, + {25,14,"EM_SECRET_BASE_BLUE_CAVE3","SecretBase_BlueCave3","MAP_SECRET_BASE_BLUE_CAVE3"}, + {25,15,"EM_SECRET_BASE_YELLOW_CAVE3","SecretBase_YellowCave3","MAP_SECRET_BASE_YELLOW_CAVE3"}, + {25,16,"EM_SECRET_BASE_TREE3","SecretBase_Tree3","MAP_SECRET_BASE_TREE3"}, + {25,17,"EM_SECRET_BASE_SHRUB3","SecretBase_Shrub3","MAP_SECRET_BASE_SHRUB3"}, + {25,18,"EM_SECRET_BASE_RED_CAVE4","SecretBase_RedCave4","MAP_SECRET_BASE_RED_CAVE4"}, + {25,19,"EM_SECRET_BASE_BROWN_CAVE4","SecretBase_BrownCave4","MAP_SECRET_BASE_BROWN_CAVE4"}, + {25,20,"EM_SECRET_BASE_BLUE_CAVE4","SecretBase_BlueCave4","MAP_SECRET_BASE_BLUE_CAVE4"}, + {25,21,"EM_SECRET_BASE_YELLOW_CAVE4","SecretBase_YellowCave4","MAP_SECRET_BASE_YELLOW_CAVE4"}, + {25,22,"EM_SECRET_BASE_TREE4","SecretBase_Tree4","MAP_SECRET_BASE_TREE4"}, + {25,23,"EM_SECRET_BASE_SHRUB4","SecretBase_Shrub4","MAP_SECRET_BASE_SHRUB4"}, + {25,24,"EM_BATTLE_COLOSSEUM_2P","BattleColosseum_2P","MAP_BATTLE_COLOSSEUM_2P"}, + {25,25,"EM_TRADE_CENTER","TradeCenter","MAP_TRADE_CENTER"}, + {25,26,"EM_RECORD_CORNER","RecordCorner","MAP_RECORD_CORNER"}, + {25,27,"EM_BATTLE_COLOSSEUM_4P","BattleColosseum_4P","MAP_BATTLE_COLOSSEUM_4P"}, + {25,28,"EM_CONTEST_HALL","ContestHall","MAP_CONTEST_HALL"}, + {25,29,"EM_UNUSED_CONTEST_HALL1","UnusedContestHall1","MAP_UNUSED_CONTEST_HALL1"}, + {25,30,"EM_UNUSED_CONTEST_HALL2","UnusedContestHall2","MAP_UNUSED_CONTEST_HALL2"}, + {25,31,"EM_UNUSED_CONTEST_HALL3","UnusedContestHall3","MAP_UNUSED_CONTEST_HALL3"}, + {25,32,"EM_UNUSED_CONTEST_HALL4","UnusedContestHall4","MAP_UNUSED_CONTEST_HALL4"}, + {25,33,"EM_UNUSED_CONTEST_HALL5","UnusedContestHall5","MAP_UNUSED_CONTEST_HALL5"}, + {25,34,"EM_UNUSED_CONTEST_HALL6","UnusedContestHall6","MAP_UNUSED_CONTEST_HALL6"}, + {25,35,"EM_CONTEST_HALL_BEAUTY","ContestHallBeauty","MAP_CONTEST_HALL_BEAUTY"}, + {25,36,"EM_CONTEST_HALL_TOUGH","ContestHallTough","MAP_CONTEST_HALL_TOUGH"}, + {25,37,"EM_CONTEST_HALL_COOL","ContestHallCool","MAP_CONTEST_HALL_COOL"}, + {25,38,"EM_CONTEST_HALL_SMART","ContestHallSmart","MAP_CONTEST_HALL_SMART"}, + {25,39,"EM_CONTEST_HALL_CUTE","ContestHallCute","MAP_CONTEST_HALL_CUTE"}, + {25,40,"EM_INSIDE_OF_TRUCK","InsideOfTruck","MAP_INSIDE_OF_TRUCK"}, + {25,41,"EM_SS_TIDAL_CORRIDOR","SSTidalCorridor","MAP_SS_TIDAL_CORRIDOR"}, + {25,42,"EM_SS_TIDAL_LOWER_DECK","SSTidalLowerDeck","MAP_SS_TIDAL_LOWER_DECK"}, + {25,43,"EM_SS_TIDAL_ROOMS","SSTidalRooms","MAP_SS_TIDAL_ROOMS"}, + {25,44,"EM_BATTLE_PYRAMID_SQUARE01","BattlePyramidSquare01","MAP_BATTLE_PYRAMID_SQUARE01"}, + {25,45,"EM_BATTLE_PYRAMID_SQUARE02","BattlePyramidSquare02","MAP_BATTLE_PYRAMID_SQUARE02"}, + {25,46,"EM_BATTLE_PYRAMID_SQUARE03","BattlePyramidSquare03","MAP_BATTLE_PYRAMID_SQUARE03"}, + {25,47,"EM_BATTLE_PYRAMID_SQUARE04","BattlePyramidSquare04","MAP_BATTLE_PYRAMID_SQUARE04"}, + {25,48,"EM_BATTLE_PYRAMID_SQUARE05","BattlePyramidSquare05","MAP_BATTLE_PYRAMID_SQUARE05"}, + {25,49,"EM_BATTLE_PYRAMID_SQUARE06","BattlePyramidSquare06","MAP_BATTLE_PYRAMID_SQUARE06"}, + {25,50,"EM_BATTLE_PYRAMID_SQUARE07","BattlePyramidSquare07","MAP_BATTLE_PYRAMID_SQUARE07"}, + {25,51,"EM_BATTLE_PYRAMID_SQUARE08","BattlePyramidSquare08","MAP_BATTLE_PYRAMID_SQUARE08"}, + {25,52,"EM_BATTLE_PYRAMID_SQUARE09","BattlePyramidSquare09","MAP_BATTLE_PYRAMID_SQUARE09"}, + {25,53,"EM_BATTLE_PYRAMID_SQUARE10","BattlePyramidSquare10","MAP_BATTLE_PYRAMID_SQUARE10"}, + {25,54,"EM_BATTLE_PYRAMID_SQUARE11","BattlePyramidSquare11","MAP_BATTLE_PYRAMID_SQUARE11"}, + {25,55,"EM_BATTLE_PYRAMID_SQUARE12","BattlePyramidSquare12","MAP_BATTLE_PYRAMID_SQUARE12"}, + {25,56,"EM_BATTLE_PYRAMID_SQUARE13","BattlePyramidSquare13","MAP_BATTLE_PYRAMID_SQUARE13"}, + {25,57,"EM_BATTLE_PYRAMID_SQUARE14","BattlePyramidSquare14","MAP_BATTLE_PYRAMID_SQUARE14"}, + {25,58,"EM_BATTLE_PYRAMID_SQUARE15","BattlePyramidSquare15","MAP_BATTLE_PYRAMID_SQUARE15"}, + {25,59,"EM_BATTLE_PYRAMID_SQUARE16","BattlePyramidSquare16","MAP_BATTLE_PYRAMID_SQUARE16"}, + {25,60,"EM_UNION_ROOM","UnionRoom","MAP_UNION_ROOM"}, + {26,0,"EM_SAFARI_ZONE_NORTHWEST","SafariZone_Northwest","MAP_SAFARI_ZONE_NORTHWEST"}, + {26,1,"EM_SAFARI_ZONE_NORTH","SafariZone_North","MAP_SAFARI_ZONE_NORTH"}, + {26,2,"EM_SAFARI_ZONE_SOUTHWEST","SafariZone_Southwest","MAP_SAFARI_ZONE_SOUTHWEST"}, + {26,3,"EM_SAFARI_ZONE_SOUTH","SafariZone_South","MAP_SAFARI_ZONE_SOUTH"}, + {26,4,"EM_BATTLE_FRONTIER_OUTSIDE_WEST","BattleFrontier_OutsideWest","MAP_BATTLE_FRONTIER_OUTSIDE_WEST"}, + {26,5,"EM_BATTLE_FRONTIER_BATTLE_TOWER_LOBBY","BattleFrontier_BattleTowerLobby","MAP_BATTLE_FRONTIER_BATTLE_TOWER_LOBBY"}, + {26,6,"EM_BATTLE_FRONTIER_BATTLE_TOWER_ELEVATOR","BattleFrontier_BattleTowerElevator","MAP_BATTLE_FRONTIER_BATTLE_TOWER_ELEVATOR"}, + {26,7,"EM_BATTLE_FRONTIER_BATTLE_TOWER_CORRIDOR","BattleFrontier_BattleTowerCorridor","MAP_BATTLE_FRONTIER_BATTLE_TOWER_CORRIDOR"}, + {26,8,"EM_BATTLE_FRONTIER_BATTLE_TOWER_BATTLE_ROOM","BattleFrontier_BattleTowerBattleRoom","MAP_BATTLE_FRONTIER_BATTLE_TOWER_BATTLE_ROOM"}, + {26,9,"EM_SOUTHERN_ISLAND_EXTERIOR","SouthernIsland_Exterior","MAP_SOUTHERN_ISLAND_EXTERIOR"}, + {26,10,"EM_SOUTHERN_ISLAND_INTERIOR","SouthernIsland_Interior","MAP_SOUTHERN_ISLAND_INTERIOR"}, + {26,11,"EM_SAFARI_ZONE_REST_HOUSE","SafariZone_RestHouse","MAP_SAFARI_ZONE_REST_HOUSE"}, + {26,12,"EM_SAFARI_ZONE_NORTHEAST","SafariZone_Northeast","MAP_SAFARI_ZONE_NORTHEAST"}, + {26,13,"EM_SAFARI_ZONE_SOUTHEAST","SafariZone_Southeast","MAP_SAFARI_ZONE_SOUTHEAST"}, + {26,14,"EM_BATTLE_FRONTIER_OUTSIDE_EAST","BattleFrontier_OutsideEast","MAP_BATTLE_FRONTIER_OUTSIDE_EAST"}, + {26,15,"EM_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_PARTNER_ROOM","BattleFrontier_BattleTowerMultiPartnerRoom","MAP_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_PARTNER_ROOM"}, + {26,16,"EM_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_CORRIDOR","BattleFrontier_BattleTowerMultiCorridor","MAP_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_CORRIDOR"}, + {26,17,"EM_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_BATTLE_ROOM","BattleFrontier_BattleTowerMultiBattleRoom","MAP_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_BATTLE_ROOM"}, + {26,18,"EM_BATTLE_FRONTIER_BATTLE_DOME_LOBBY","BattleFrontier_BattleDomeLobby","MAP_BATTLE_FRONTIER_BATTLE_DOME_LOBBY"}, + {26,19,"EM_BATTLE_FRONTIER_BATTLE_DOME_CORRIDOR","BattleFrontier_BattleDomeCorridor","MAP_BATTLE_FRONTIER_BATTLE_DOME_CORRIDOR"}, + {26,20,"EM_BATTLE_FRONTIER_BATTLE_DOME_PRE_BATTLE_ROOM","BattleFrontier_BattleDomePreBattleRoom","MAP_BATTLE_FRONTIER_BATTLE_DOME_PRE_BATTLE_ROOM"}, + {26,21,"EM_BATTLE_FRONTIER_BATTLE_DOME_BATTLE_ROOM","BattleFrontier_BattleDomeBattleRoom","MAP_BATTLE_FRONTIER_BATTLE_DOME_BATTLE_ROOM"}, + {26,22,"EM_BATTLE_FRONTIER_BATTLE_PALACE_LOBBY","BattleFrontier_BattlePalaceLobby","MAP_BATTLE_FRONTIER_BATTLE_PALACE_LOBBY"}, + {26,23,"EM_BATTLE_FRONTIER_BATTLE_PALACE_CORRIDOR","BattleFrontier_BattlePalaceCorridor","MAP_BATTLE_FRONTIER_BATTLE_PALACE_CORRIDOR"}, + {26,24,"EM_BATTLE_FRONTIER_BATTLE_PALACE_BATTLE_ROOM","BattleFrontier_BattlePalaceBattleRoom","MAP_BATTLE_FRONTIER_BATTLE_PALACE_BATTLE_ROOM"}, + {26,25,"EM_BATTLE_FRONTIER_BATTLE_PYRAMID_LOBBY","BattleFrontier_BattlePyramidLobby","MAP_BATTLE_FRONTIER_BATTLE_PYRAMID_LOBBY"}, + {26,26,"EM_BATTLE_FRONTIER_BATTLE_PYRAMID_FLOOR","BattleFrontier_BattlePyramidFloor","MAP_BATTLE_FRONTIER_BATTLE_PYRAMID_FLOOR"}, + {26,27,"EM_BATTLE_FRONTIER_BATTLE_PYRAMID_TOP","BattleFrontier_BattlePyramidTop","MAP_BATTLE_FRONTIER_BATTLE_PYRAMID_TOP"}, + {26,28,"EM_BATTLE_FRONTIER_BATTLE_ARENA_LOBBY","BattleFrontier_BattleArenaLobby","MAP_BATTLE_FRONTIER_BATTLE_ARENA_LOBBY"}, + {26,29,"EM_BATTLE_FRONTIER_BATTLE_ARENA_CORRIDOR","BattleFrontier_BattleArenaCorridor","MAP_BATTLE_FRONTIER_BATTLE_ARENA_CORRIDOR"}, + {26,30,"EM_BATTLE_FRONTIER_BATTLE_ARENA_BATTLE_ROOM","BattleFrontier_BattleArenaBattleRoom","MAP_BATTLE_FRONTIER_BATTLE_ARENA_BATTLE_ROOM"}, + {26,31,"EM_BATTLE_FRONTIER_BATTLE_FACTORY_LOBBY","BattleFrontier_BattleFactoryLobby","MAP_BATTLE_FRONTIER_BATTLE_FACTORY_LOBBY"}, + {26,32,"EM_BATTLE_FRONTIER_BATTLE_FACTORY_PRE_BATTLE_ROOM","BattleFrontier_BattleFactoryPreBattleRoom","MAP_BATTLE_FRONTIER_BATTLE_FACTORY_PRE_BATTLE_ROOM"}, + {26,33,"EM_BATTLE_FRONTIER_BATTLE_FACTORY_BATTLE_ROOM","BattleFrontier_BattleFactoryBattleRoom","MAP_BATTLE_FRONTIER_BATTLE_FACTORY_BATTLE_ROOM"}, + {26,34,"EM_BATTLE_FRONTIER_BATTLE_PIKE_LOBBY","BattleFrontier_BattlePikeLobby","MAP_BATTLE_FRONTIER_BATTLE_PIKE_LOBBY"}, + {26,35,"EM_BATTLE_FRONTIER_BATTLE_PIKE_CORRIDOR","BattleFrontier_BattlePikeCorridor","MAP_BATTLE_FRONTIER_BATTLE_PIKE_CORRIDOR"}, + {26,36,"EM_BATTLE_FRONTIER_BATTLE_PIKE_THREE_PATH_ROOM","BattleFrontier_BattlePikeThreePathRoom","MAP_BATTLE_FRONTIER_BATTLE_PIKE_THREE_PATH_ROOM"}, + {26,37,"EM_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_NORMAL","BattleFrontier_BattlePikeRoomNormal","MAP_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_NORMAL"}, + {26,38,"EM_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_FINAL","BattleFrontier_BattlePikeRoomFinal","MAP_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_FINAL"}, + {26,39,"EM_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_WILD_MONS","BattleFrontier_BattlePikeRoomWildMons","MAP_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_WILD_MONS"}, + {26,40,"EM_BATTLE_FRONTIER_RANKING_HALL","BattleFrontier_RankingHall","MAP_BATTLE_FRONTIER_RANKING_HALL"}, + {26,41,"EM_BATTLE_FRONTIER_LOUNGE1","BattleFrontier_Lounge1","MAP_BATTLE_FRONTIER_LOUNGE1"}, + {26,42,"EM_BATTLE_FRONTIER_EXCHANGE_SERVICE_CORNER","BattleFrontier_ExchangeServiceCorner","MAP_BATTLE_FRONTIER_EXCHANGE_SERVICE_CORNER"}, + {26,43,"EM_BATTLE_FRONTIER_LOUNGE2","BattleFrontier_Lounge2","MAP_BATTLE_FRONTIER_LOUNGE2"}, + {26,44,"EM_BATTLE_FRONTIER_LOUNGE3","BattleFrontier_Lounge3","MAP_BATTLE_FRONTIER_LOUNGE3"}, + {26,45,"EM_BATTLE_FRONTIER_LOUNGE4","BattleFrontier_Lounge4","MAP_BATTLE_FRONTIER_LOUNGE4"}, + {26,46,"EM_BATTLE_FRONTIER_SCOTTS_HOUSE","BattleFrontier_ScottsHouse","MAP_BATTLE_FRONTIER_SCOTTS_HOUSE"}, + {26,47,"EM_BATTLE_FRONTIER_LOUNGE5","BattleFrontier_Lounge5","MAP_BATTLE_FRONTIER_LOUNGE5"}, + {26,48,"EM_BATTLE_FRONTIER_LOUNGE6","BattleFrontier_Lounge6","MAP_BATTLE_FRONTIER_LOUNGE6"}, + {26,49,"EM_BATTLE_FRONTIER_LOUNGE7","BattleFrontier_Lounge7","MAP_BATTLE_FRONTIER_LOUNGE7"}, + {26,50,"EM_BATTLE_FRONTIER_RECEPTION_GATE","BattleFrontier_ReceptionGate","MAP_BATTLE_FRONTIER_RECEPTION_GATE"}, + {26,51,"EM_BATTLE_FRONTIER_LOUNGE8","BattleFrontier_Lounge8","MAP_BATTLE_FRONTIER_LOUNGE8"}, + {26,52,"EM_BATTLE_FRONTIER_LOUNGE9","BattleFrontier_Lounge9","MAP_BATTLE_FRONTIER_LOUNGE9"}, + {26,53,"EM_BATTLE_FRONTIER_POKEMON_CENTER_1F","BattleFrontier_PokemonCenter_1F","MAP_BATTLE_FRONTIER_POKEMON_CENTER_1F"}, + {26,54,"EM_BATTLE_FRONTIER_POKEMON_CENTER_2F","BattleFrontier_PokemonCenter_2F","MAP_BATTLE_FRONTIER_POKEMON_CENTER_2F"}, + {26,55,"EM_BATTLE_FRONTIER_MART","BattleFrontier_Mart","MAP_BATTLE_FRONTIER_MART"}, + {26,56,"EM_FARAWAY_ISLAND_ENTRANCE","FarawayIsland_Entrance","MAP_FARAWAY_ISLAND_ENTRANCE"}, + {26,57,"EM_FARAWAY_ISLAND_INTERIOR","FarawayIsland_Interior","MAP_FARAWAY_ISLAND_INTERIOR"}, + {26,58,"EM_BIRTH_ISLAND_EXTERIOR","BirthIsland_Exterior","MAP_BIRTH_ISLAND_EXTERIOR"}, + {26,59,"EM_BIRTH_ISLAND_HARBOR","BirthIsland_Harbor","MAP_BIRTH_ISLAND_HARBOR"}, + {26,60,"EM_TRAINER_HILL_ENTRANCE","TrainerHill_Entrance","MAP_TRAINER_HILL_ENTRANCE"}, + {26,61,"EM_TRAINER_HILL_1F","TrainerHill_1F","MAP_TRAINER_HILL_1F"}, + {26,62,"EM_TRAINER_HILL_2F","TrainerHill_2F","MAP_TRAINER_HILL_2F"}, + {26,63,"EM_TRAINER_HILL_3F","TrainerHill_3F","MAP_TRAINER_HILL_3F"}, + {26,64,"EM_TRAINER_HILL_4F","TrainerHill_4F","MAP_TRAINER_HILL_4F"}, + {26,65,"EM_TRAINER_HILL_ROOF","TrainerHill_Roof","MAP_TRAINER_HILL_ROOF"}, + {26,66,"EM_NAVEL_ROCK_EXTERIOR","NavelRock_Exterior","MAP_NAVEL_ROCK_EXTERIOR"}, + {26,67,"EM_NAVEL_ROCK_HARBOR","NavelRock_Harbor","MAP_NAVEL_ROCK_HARBOR"}, + {26,68,"EM_NAVEL_ROCK_ENTRANCE","NavelRock_Entrance","MAP_NAVEL_ROCK_ENTRANCE"}, + {26,69,"EM_NAVEL_ROCK_B1F","NavelRock_B1F","MAP_NAVEL_ROCK_B1F"}, + {26,70,"EM_NAVEL_ROCK_FORK","NavelRock_Fork","MAP_NAVEL_ROCK_FORK"}, + {26,71,"EM_NAVEL_ROCK_UP1","NavelRock_Up1","MAP_NAVEL_ROCK_UP1"}, + {26,72,"EM_NAVEL_ROCK_UP2","NavelRock_Up2","MAP_NAVEL_ROCK_UP2"}, + {26,73,"EM_NAVEL_ROCK_UP3","NavelRock_Up3","MAP_NAVEL_ROCK_UP3"}, + {26,74,"EM_NAVEL_ROCK_UP4","NavelRock_Up4","MAP_NAVEL_ROCK_UP4"}, + {26,75,"EM_NAVEL_ROCK_TOP","NavelRock_Top","MAP_NAVEL_ROCK_TOP"}, + {26,76,"EM_NAVEL_ROCK_DOWN01","NavelRock_Down01","MAP_NAVEL_ROCK_DOWN01"}, + {26,77,"EM_NAVEL_ROCK_DOWN02","NavelRock_Down02","MAP_NAVEL_ROCK_DOWN02"}, + {26,78,"EM_NAVEL_ROCK_DOWN03","NavelRock_Down03","MAP_NAVEL_ROCK_DOWN03"}, + {26,79,"EM_NAVEL_ROCK_DOWN04","NavelRock_Down04","MAP_NAVEL_ROCK_DOWN04"}, + {26,80,"EM_NAVEL_ROCK_DOWN05","NavelRock_Down05","MAP_NAVEL_ROCK_DOWN05"}, + {26,81,"EM_NAVEL_ROCK_DOWN06","NavelRock_Down06","MAP_NAVEL_ROCK_DOWN06"}, + {26,82,"EM_NAVEL_ROCK_DOWN07","NavelRock_Down07","MAP_NAVEL_ROCK_DOWN07"}, + {26,83,"EM_NAVEL_ROCK_DOWN08","NavelRock_Down08","MAP_NAVEL_ROCK_DOWN08"}, + {26,84,"EM_NAVEL_ROCK_DOWN09","NavelRock_Down09","MAP_NAVEL_ROCK_DOWN09"}, + {26,85,"EM_NAVEL_ROCK_DOWN10","NavelRock_Down10","MAP_NAVEL_ROCK_DOWN10"}, + {26,86,"EM_NAVEL_ROCK_DOWN11","NavelRock_Down11","MAP_NAVEL_ROCK_DOWN11"}, + {26,87,"EM_NAVEL_ROCK_BOTTOM","NavelRock_Bottom","MAP_NAVEL_ROCK_BOTTOM"}, + {26,88,"EM_TRAINER_HILL_ELEVATOR","TrainerHill_Elevator","MAP_TRAINER_HILL_ELEVATOR"}, + {27,0,"EM_ROUTE104_PROTOTYPE","Route104_Prototype","MAP_ROUTE104_PROTOTYPE"}, + {27,1,"EM_ROUTE104_PROTOTYPE_PRETTY_PETAL_FLOWER_SHOP","Route104_PrototypePrettyPetalFlowerShop","MAP_ROUTE104_PROTOTYPE_PRETTY_PETAL_FLOWER_SHOP"}, + {28,0,"EM_ROUTE109_SEASHORE_HOUSE","Route109_SeashoreHouse","MAP_ROUTE109_SEASHORE_HOUSE"}, + {29,0,"EM_ROUTE110_TRICK_HOUSE_ENTRANCE","Route110_TrickHouseEntrance","MAP_ROUTE110_TRICK_HOUSE_ENTRANCE"}, + {29,1,"EM_ROUTE110_TRICK_HOUSE_END","Route110_TrickHouseEnd","MAP_ROUTE110_TRICK_HOUSE_END"}, + {29,2,"EM_ROUTE110_TRICK_HOUSE_CORRIDOR","Route110_TrickHouseCorridor","MAP_ROUTE110_TRICK_HOUSE_CORRIDOR"}, + {29,3,"EM_ROUTE110_TRICK_HOUSE_PUZZLE1","Route110_TrickHousePuzzle1","MAP_ROUTE110_TRICK_HOUSE_PUZZLE1"}, + {29,4,"EM_ROUTE110_TRICK_HOUSE_PUZZLE2","Route110_TrickHousePuzzle2","MAP_ROUTE110_TRICK_HOUSE_PUZZLE2"}, + {29,5,"EM_ROUTE110_TRICK_HOUSE_PUZZLE3","Route110_TrickHousePuzzle3","MAP_ROUTE110_TRICK_HOUSE_PUZZLE3"}, + {29,6,"EM_ROUTE110_TRICK_HOUSE_PUZZLE4","Route110_TrickHousePuzzle4","MAP_ROUTE110_TRICK_HOUSE_PUZZLE4"}, + {29,7,"EM_ROUTE110_TRICK_HOUSE_PUZZLE5","Route110_TrickHousePuzzle5","MAP_ROUTE110_TRICK_HOUSE_PUZZLE5"}, + {29,8,"EM_ROUTE110_TRICK_HOUSE_PUZZLE6","Route110_TrickHousePuzzle6","MAP_ROUTE110_TRICK_HOUSE_PUZZLE6"}, + {29,9,"EM_ROUTE110_TRICK_HOUSE_PUZZLE7","Route110_TrickHousePuzzle7","MAP_ROUTE110_TRICK_HOUSE_PUZZLE7"}, + {29,10,"EM_ROUTE110_TRICK_HOUSE_PUZZLE8","Route110_TrickHousePuzzle8","MAP_ROUTE110_TRICK_HOUSE_PUZZLE8"}, + {29,11,"EM_ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE","Route110_SeasideCyclingRoadSouthEntrance","MAP_ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE"}, + {29,12,"EM_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE","Route110_SeasideCyclingRoadNorthEntrance","MAP_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE"}, + {30,0,"EM_ROUTE113_GLASS_WORKSHOP","Route113_GlassWorkshop","MAP_ROUTE113_GLASS_WORKSHOP"}, + {31,0,"EM_ROUTE123_BERRY_MASTERS_HOUSE","Route123_BerryMastersHouse","MAP_ROUTE123_BERRY_MASTERS_HOUSE"}, + {32,0,"EM_ROUTE119_WEATHER_INSTITUTE_1F","Route119_WeatherInstitute_1F","MAP_ROUTE119_WEATHER_INSTITUTE_1F"}, + {32,1,"EM_ROUTE119_WEATHER_INSTITUTE_2F","Route119_WeatherInstitute_2F","MAP_ROUTE119_WEATHER_INSTITUTE_2F"}, + {32,2,"EM_ROUTE119_HOUSE","Route119_House","MAP_ROUTE119_HOUSE"}, + {33,0,"EM_ROUTE124_DIVING_TREASURE_HUNTERS_HOUSE","Route124_DivingTreasureHuntersHouse","MAP_ROUTE124_DIVING_TREASURE_HUNTERS_HOUSE"}, + }, + ["ruby"]={ + {0,0,"RU_PETALBURG_CITY","PetalburgCity","MAP_PETALBURG_CITY"}, + {0,1,"RU_SLATEPORT_CITY","SlateportCity","MAP_SLATEPORT_CITY"}, + {0,2,"RU_MAUVILLE_CITY","MauvilleCity","MAP_MAUVILLE_CITY"}, + {0,3,"RU_RUSTBORO_CITY","RustboroCity","MAP_RUSTBORO_CITY"}, + {0,4,"RU_FORTREE_CITY","FortreeCity","MAP_FORTREE_CITY"}, + {0,5,"RU_LILYCOVE_CITY","LilycoveCity","MAP_LILYCOVE_CITY"}, + {0,6,"RU_MOSSDEEP_CITY","MossdeepCity","MAP_MOSSDEEP_CITY"}, + {0,7,"RU_SOOTOPOLIS_CITY","SootopolisCity","MAP_SOOTOPOLIS_CITY"}, + {0,8,"RU_EVER_GRANDE_CITY","EverGrandeCity","MAP_EVER_GRANDE_CITY"}, + {0,9,"RU_LITTLEROOT_TOWN","LittlerootTown","MAP_LITTLEROOT_TOWN"}, + {0,10,"RU_OLDALE_TOWN","OldaleTown","MAP_OLDALE_TOWN"}, + {0,11,"RU_DEWFORD_TOWN","DewfordTown","MAP_DEWFORD_TOWN"}, + {0,12,"RU_LAVARIDGE_TOWN","LavaridgeTown","MAP_LAVARIDGE_TOWN"}, + {0,13,"RU_FALLARBOR_TOWN","FallarborTown","MAP_FALLARBOR_TOWN"}, + {0,14,"RU_VERDANTURF_TOWN","VerdanturfTown","MAP_VERDANTURF_TOWN"}, + {0,15,"RU_PACIFIDLOG_TOWN","PacifidlogTown","MAP_PACIFIDLOG_TOWN"}, + {0,16,"RU_ROUTE101","Route101","MAP_ROUTE101"}, + {0,17,"RU_ROUTE102","Route102","MAP_ROUTE102"}, + {0,18,"RU_ROUTE103","Route103","MAP_ROUTE103"}, + {0,19,"RU_ROUTE104","Route104","MAP_ROUTE104"}, + {0,20,"RU_ROUTE105","Route105","MAP_ROUTE105"}, + {0,21,"RU_ROUTE106","Route106","MAP_ROUTE106"}, + {0,22,"RU_ROUTE107","Route107","MAP_ROUTE107"}, + {0,23,"RU_ROUTE108","Route108","MAP_ROUTE108"}, + {0,24,"RU_ROUTE109","Route109","MAP_ROUTE109"}, + {0,25,"RU_ROUTE110","Route110","MAP_ROUTE110"}, + {0,26,"RU_ROUTE111","Route111","MAP_ROUTE111"}, + {0,27,"RU_ROUTE112","Route112","MAP_ROUTE112"}, + {0,28,"RU_ROUTE113","Route113","MAP_ROUTE113"}, + {0,29,"RU_ROUTE114","Route114","MAP_ROUTE114"}, + {0,30,"RU_ROUTE115","Route115","MAP_ROUTE115"}, + {0,31,"RU_ROUTE116","Route116","MAP_ROUTE116"}, + {0,32,"RU_ROUTE117","Route117","MAP_ROUTE117"}, + {0,33,"RU_ROUTE118","Route118","MAP_ROUTE118"}, + {0,34,"RU_ROUTE119","Route119","MAP_ROUTE119"}, + {0,35,"RU_ROUTE120","Route120","MAP_ROUTE120"}, + {0,36,"RU_ROUTE121","Route121","MAP_ROUTE121"}, + {0,37,"RU_ROUTE122","Route122","MAP_ROUTE122"}, + {0,38,"RU_ROUTE123","Route123","MAP_ROUTE123"}, + {0,39,"RU_ROUTE124","Route124","MAP_ROUTE124"}, + {0,40,"RU_ROUTE125","Route125","MAP_ROUTE125"}, + {0,41,"RU_ROUTE126","Route126","MAP_ROUTE126"}, + {0,42,"RU_ROUTE127","Route127","MAP_ROUTE127"}, + {0,43,"RU_ROUTE128","Route128","MAP_ROUTE128"}, + {0,44,"RU_ROUTE129","Route129","MAP_ROUTE129"}, + {0,45,"RU_ROUTE130","Route130","MAP_ROUTE130"}, + {0,46,"RU_ROUTE131","Route131","MAP_ROUTE131"}, + {0,47,"RU_ROUTE132","Route132","MAP_ROUTE132"}, + {0,48,"RU_ROUTE133","Route133","MAP_ROUTE133"}, + {0,49,"RU_ROUTE134","Route134","MAP_ROUTE134"}, + {0,50,"RU_UNDERWATER1","Underwater1","MAP_UNDERWATER1"}, + {0,51,"RU_UNDERWATER2","Underwater2","MAP_UNDERWATER2"}, + {0,52,"RU_UNDERWATER3","Underwater3","MAP_UNDERWATER3"}, + {0,53,"RU_UNDERWATER4","Underwater4","MAP_UNDERWATER4"}, + {1,0,"RU_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F","LittlerootTown_BrendansHouse_1F","MAP_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F"}, + {1,1,"RU_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F","LittlerootTown_BrendansHouse_2F","MAP_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F"}, + {1,2,"RU_LITTLEROOT_TOWN_MAYS_HOUSE_1F","LittlerootTown_MaysHouse_1F","MAP_LITTLEROOT_TOWN_MAYS_HOUSE_1F"}, + {1,3,"RU_LITTLEROOT_TOWN_MAYS_HOUSE_2F","LittlerootTown_MaysHouse_2F","MAP_LITTLEROOT_TOWN_MAYS_HOUSE_2F"}, + {1,4,"RU_LITTLEROOT_TOWN_PROFESSOR_BIRCHS_LAB","LittlerootTown_ProfessorBirchsLab","MAP_LITTLEROOT_TOWN_PROFESSOR_BIRCHS_LAB"}, + {2,0,"RU_OLDALE_TOWN_HOUSE1","OldaleTown_House1","MAP_OLDALE_TOWN_HOUSE1"}, + {2,1,"RU_OLDALE_TOWN_HOUSE2","OldaleTown_House2","MAP_OLDALE_TOWN_HOUSE2"}, + {2,2,"RU_OLDALE_TOWN_POKEMON_CENTER_1F","OldaleTown_PokemonCenter_1F","MAP_OLDALE_TOWN_POKEMON_CENTER_1F"}, + {2,3,"RU_OLDALE_TOWN_POKEMON_CENTER_2F","OldaleTown_PokemonCenter_2F","MAP_OLDALE_TOWN_POKEMON_CENTER_2F"}, + {2,4,"RU_OLDALE_TOWN_MART","OldaleTown_Mart","MAP_OLDALE_TOWN_MART"}, + {3,0,"RU_DEWFORD_TOWN_HOUSE1","DewfordTown_House1","MAP_DEWFORD_TOWN_HOUSE1"}, + {3,1,"RU_DEWFORD_TOWN_POKEMON_CENTER_1F","DewfordTown_PokemonCenter_1F","MAP_DEWFORD_TOWN_POKEMON_CENTER_1F"}, + {3,2,"RU_DEWFORD_TOWN_POKEMON_CENTER_2F","DewfordTown_PokemonCenter_2F","MAP_DEWFORD_TOWN_POKEMON_CENTER_2F"}, + {3,3,"RU_DEWFORD_TOWN_GYM","DewfordTown_Gym","MAP_DEWFORD_TOWN_GYM"}, + {3,4,"RU_DEWFORD_TOWN_HALL","DewfordTown_Hall","MAP_DEWFORD_TOWN_HALL"}, + {3,5,"RU_DEWFORD_TOWN_HOUSE2","DewfordTown_House2","MAP_DEWFORD_TOWN_HOUSE2"}, + {4,0,"RU_LAVARIDGE_TOWN_HERB_SHOP","LavaridgeTown_HerbShop","MAP_LAVARIDGE_TOWN_HERB_SHOP"}, + {4,1,"RU_LAVARIDGE_TOWN_GYM_1F","LavaridgeTown_Gym_1F","MAP_LAVARIDGE_TOWN_GYM_1F"}, + {4,2,"RU_LAVARIDGE_TOWN_GYM_B1F","LavaridgeTown_Gym_B1F","MAP_LAVARIDGE_TOWN_GYM_B1F"}, + {4,3,"RU_LAVARIDGE_TOWN_HOUSE","LavaridgeTown_House","MAP_LAVARIDGE_TOWN_HOUSE"}, + {4,4,"RU_LAVARIDGE_TOWN_MART","LavaridgeTown_Mart","MAP_LAVARIDGE_TOWN_MART"}, + {4,5,"RU_LAVARIDGE_TOWN_POKEMON_CENTER_1F","LavaridgeTown_PokemonCenter_1F","MAP_LAVARIDGE_TOWN_POKEMON_CENTER_1F"}, + {4,6,"RU_LAVARIDGE_TOWN_POKEMON_CENTER_2F","LavaridgeTown_PokemonCenter_2F","MAP_LAVARIDGE_TOWN_POKEMON_CENTER_2F"}, + {5,0,"RU_FALLARBOR_TOWN_MART","FallarborTown_Mart","MAP_FALLARBOR_TOWN_MART"}, + {5,1,"RU_FALLARBOR_TOWN_CONTEST_LOBBY","FallarborTown_ContestLobby","MAP_FALLARBOR_TOWN_CONTEST_LOBBY"}, + {5,2,"RU_FALLARBOR_TOWN_CONTEST_HALL","FallarborTown_ContestHall","MAP_FALLARBOR_TOWN_CONTEST_HALL"}, + {5,3,"RU_FALLARBOR_TOWN_POKEMON_CENTER_1F","FallarborTown_PokemonCenter_1F","MAP_FALLARBOR_TOWN_POKEMON_CENTER_1F"}, + {5,4,"RU_FALLARBOR_TOWN_POKEMON_CENTER_2F","FallarborTown_PokemonCenter_2F","MAP_FALLARBOR_TOWN_POKEMON_CENTER_2F"}, + {5,5,"RU_FALLARBOR_TOWN_HOUSE1","FallarborTown_House1","MAP_FALLARBOR_TOWN_HOUSE1"}, + {5,6,"RU_FALLARBOR_TOWN_HOUSE2","FallarborTown_House2","MAP_FALLARBOR_TOWN_HOUSE2"}, + {6,0,"RU_VERDANTURF_TOWN_CONTEST_LOBBY","VerdanturfTown_ContestLobby","MAP_VERDANTURF_TOWN_CONTEST_LOBBY"}, + {6,1,"RU_VERDANTURF_TOWN_CONTEST_HALL","VerdanturfTown_ContestHall","MAP_VERDANTURF_TOWN_CONTEST_HALL"}, + {6,2,"RU_VERDANTURF_TOWN_MART","VerdanturfTown_Mart","MAP_VERDANTURF_TOWN_MART"}, + {6,3,"RU_VERDANTURF_TOWN_POKEMON_CENTER_1F","VerdanturfTown_PokemonCenter_1F","MAP_VERDANTURF_TOWN_POKEMON_CENTER_1F"}, + {6,4,"RU_VERDANTURF_TOWN_POKEMON_CENTER_2F","VerdanturfTown_PokemonCenter_2F","MAP_VERDANTURF_TOWN_POKEMON_CENTER_2F"}, + {6,5,"RU_VERDANTURF_TOWN_WANDAS_HOUSE","VerdanturfTown_WandasHouse","MAP_VERDANTURF_TOWN_WANDAS_HOUSE"}, + {6,6,"RU_VERDANTURF_TOWN_FRIENDSHIP_RATERS_HOUSE","VerdanturfTown_FriendshipRatersHouse","MAP_VERDANTURF_TOWN_FRIENDSHIP_RATERS_HOUSE"}, + {6,7,"RU_VERDANTURF_TOWN_HOUSE","VerdanturfTown_House","MAP_VERDANTURF_TOWN_HOUSE"}, + {7,0,"RU_PACIFIDLOG_TOWN_POKEMON_CENTER_1F","PacifidlogTown_PokemonCenter_1F","MAP_PACIFIDLOG_TOWN_POKEMON_CENTER_1F"}, + {7,1,"RU_PACIFIDLOG_TOWN_POKEMON_CENTER_2F","PacifidlogTown_PokemonCenter_2F","MAP_PACIFIDLOG_TOWN_POKEMON_CENTER_2F"}, + {7,2,"RU_PACIFIDLOG_TOWN_HOUSE1","PacifidlogTown_House1","MAP_PACIFIDLOG_TOWN_HOUSE1"}, + {7,3,"RU_PACIFIDLOG_TOWN_HOUSE2","PacifidlogTown_House2","MAP_PACIFIDLOG_TOWN_HOUSE2"}, + {7,4,"RU_PACIFIDLOG_TOWN_HOUSE3","PacifidlogTown_House3","MAP_PACIFIDLOG_TOWN_HOUSE3"}, + {7,5,"RU_PACIFIDLOG_TOWN_HOUSE4","PacifidlogTown_House4","MAP_PACIFIDLOG_TOWN_HOUSE4"}, + {7,6,"RU_PACIFIDLOG_TOWN_HOUSE5","PacifidlogTown_House5","MAP_PACIFIDLOG_TOWN_HOUSE5"}, + {8,0,"RU_PETALBURG_CITY_WALLYS_HOUSE","PetalburgCity_WallysHouse","MAP_PETALBURG_CITY_WALLYS_HOUSE"}, + {8,1,"RU_PETALBURG_CITY_GYM","PetalburgCity_Gym","MAP_PETALBURG_CITY_GYM"}, + {8,2,"RU_PETALBURG_CITY_HOUSE1","PetalburgCity_House1","MAP_PETALBURG_CITY_HOUSE1"}, + {8,3,"RU_PETALBURG_CITY_HOUSE2","PetalburgCity_House2","MAP_PETALBURG_CITY_HOUSE2"}, + {8,4,"RU_PETALBURG_CITY_POKEMON_CENTER_1F","PetalburgCity_PokemonCenter_1F","MAP_PETALBURG_CITY_POKEMON_CENTER_1F"}, + {8,5,"RU_PETALBURG_CITY_POKEMON_CENTER_2F","PetalburgCity_PokemonCenter_2F","MAP_PETALBURG_CITY_POKEMON_CENTER_2F"}, + {8,6,"RU_PETALBURG_CITY_MART","PetalburgCity_Mart","MAP_PETALBURG_CITY_MART"}, + {9,0,"RU_SLATEPORT_CITY_STERNS_SHIPYARD_1F","SlateportCity_SternsShipyard_1F","MAP_SLATEPORT_CITY_STERNS_SHIPYARD_1F"}, + {9,1,"RU_SLATEPORT_CITY_STERNS_SHIPYARD_2F","SlateportCity_SternsShipyard_2F","MAP_SLATEPORT_CITY_STERNS_SHIPYARD_2F"}, + {9,2,"RU_SLATEPORT_CITY_CONTEST_LOBBY","SlateportCity_ContestLobby","MAP_SLATEPORT_CITY_CONTEST_LOBBY"}, + {9,3,"RU_SLATEPORT_CITY_CONTEST_HALL","SlateportCity_ContestHall","MAP_SLATEPORT_CITY_CONTEST_HALL"}, + {9,4,"RU_SLATEPORT_CITY_HOUSE1","SlateportCity_House1","MAP_SLATEPORT_CITY_HOUSE1"}, + {9,5,"RU_SLATEPORT_CITY_POKEMON_FAN_CLUB","SlateportCity_PokemonFanClub","MAP_SLATEPORT_CITY_POKEMON_FAN_CLUB"}, + {9,6,"RU_SLATEPORT_CITY_OCEANIC_MUSEUM_1F","SlateportCity_OceanicMuseum_1F","MAP_SLATEPORT_CITY_OCEANIC_MUSEUM_1F"}, + {9,7,"RU_SLATEPORT_CITY_OCEANIC_MUSEUM_2F","SlateportCity_OceanicMuseum_2F","MAP_SLATEPORT_CITY_OCEANIC_MUSEUM_2F"}, + {9,8,"RU_SLATEPORT_CITY_HARBOR","SlateportCity_Harbor","MAP_SLATEPORT_CITY_HARBOR"}, + {9,9,"RU_SLATEPORT_CITY_HOUSE2","SlateportCity_House2","MAP_SLATEPORT_CITY_HOUSE2"}, + {9,10,"RU_SLATEPORT_CITY_POKEMON_CENTER_1F","SlateportCity_PokemonCenter_1F","MAP_SLATEPORT_CITY_POKEMON_CENTER_1F"}, + {9,11,"RU_SLATEPORT_CITY_POKEMON_CENTER_2F","SlateportCity_PokemonCenter_2F","MAP_SLATEPORT_CITY_POKEMON_CENTER_2F"}, + {9,12,"RU_SLATEPORT_CITY_MART","SlateportCity_Mart","MAP_SLATEPORT_CITY_MART"}, + {10,0,"RU_MAUVILLE_CITY_GYM","MauvilleCity_Gym","MAP_MAUVILLE_CITY_GYM"}, + {10,1,"RU_MAUVILLE_CITY_BIKE_SHOP","MauvilleCity_BikeShop","MAP_MAUVILLE_CITY_BIKE_SHOP"}, + {10,2,"RU_MAUVILLE_CITY_HOUSE1","MauvilleCity_House1","MAP_MAUVILLE_CITY_HOUSE1"}, + {10,3,"RU_MAUVILLE_CITY_GAME_CORNER","MauvilleCity_GameCorner","MAP_MAUVILLE_CITY_GAME_CORNER"}, + {10,4,"RU_MAUVILLE_CITY_HOUSE2","MauvilleCity_House2","MAP_MAUVILLE_CITY_HOUSE2"}, + {10,5,"RU_MAUVILLE_CITY_POKEMON_CENTER_1F","MauvilleCity_PokemonCenter_1F","MAP_MAUVILLE_CITY_POKEMON_CENTER_1F"}, + {10,6,"RU_MAUVILLE_CITY_POKEMON_CENTER_2F","MauvilleCity_PokemonCenter_2F","MAP_MAUVILLE_CITY_POKEMON_CENTER_2F"}, + {10,7,"RU_MAUVILLE_CITY_MART","MauvilleCity_Mart","MAP_MAUVILLE_CITY_MART"}, + {11,0,"RU_RUSTBORO_CITY_DEVON_CORP_1F","RustboroCity_DevonCorp_1F","MAP_RUSTBORO_CITY_DEVON_CORP_1F"}, + {11,1,"RU_RUSTBORO_CITY_DEVON_CORP_2F","RustboroCity_DevonCorp_2F","MAP_RUSTBORO_CITY_DEVON_CORP_2F"}, + {11,2,"RU_RUSTBORO_CITY_DEVON_CORP_3F","RustboroCity_DevonCorp_3F","MAP_RUSTBORO_CITY_DEVON_CORP_3F"}, + {11,3,"RU_RUSTBORO_CITY_GYM","RustboroCity_Gym","MAP_RUSTBORO_CITY_GYM"}, + {11,4,"RU_RUSTBORO_CITY_POKEMON_SCHOOL","RustboroCity_PokemonSchool","MAP_RUSTBORO_CITY_POKEMON_SCHOOL"}, + {11,5,"RU_RUSTBORO_CITY_POKEMON_CENTER_1F","RustboroCity_PokemonCenter_1F","MAP_RUSTBORO_CITY_POKEMON_CENTER_1F"}, + {11,6,"RU_RUSTBORO_CITY_POKEMON_CENTER_2F","RustboroCity_PokemonCenter_2F","MAP_RUSTBORO_CITY_POKEMON_CENTER_2F"}, + {11,7,"RU_RUSTBORO_CITY_MART","RustboroCity_Mart","MAP_RUSTBORO_CITY_MART"}, + {11,8,"RU_RUSTBORO_CITY_FLAT1_1F","RustboroCity_Flat1_1F","MAP_RUSTBORO_CITY_FLAT1_1F"}, + {11,9,"RU_RUSTBORO_CITY_FLAT1_2F","RustboroCity_Flat1_2F","MAP_RUSTBORO_CITY_FLAT1_2F"}, + {11,10,"RU_RUSTBORO_CITY_HOUSE1","RustboroCity_House1","MAP_RUSTBORO_CITY_HOUSE1"}, + {11,11,"RU_RUSTBORO_CITY_CUTTERS_HOUSE","RustboroCity_CuttersHouse","MAP_RUSTBORO_CITY_CUTTERS_HOUSE"}, + {11,12,"RU_RUSTBORO_CITY_HOUSE2","RustboroCity_House2","MAP_RUSTBORO_CITY_HOUSE2"}, + {11,13,"RU_RUSTBORO_CITY_FLAT2_1F","RustboroCity_Flat2_1F","MAP_RUSTBORO_CITY_FLAT2_1F"}, + {11,14,"RU_RUSTBORO_CITY_FLAT2_2F","RustboroCity_Flat2_2F","MAP_RUSTBORO_CITY_FLAT2_2F"}, + {11,15,"RU_RUSTBORO_CITY_FLAT2_3F","RustboroCity_Flat2_3F","MAP_RUSTBORO_CITY_FLAT2_3F"}, + {11,16,"RU_RUSTBORO_CITY_HOUSE3","RustboroCity_House3","MAP_RUSTBORO_CITY_HOUSE3"}, + {12,0,"RU_FORTREE_CITY_HOUSE1","FortreeCity_House1","MAP_FORTREE_CITY_HOUSE1"}, + {12,1,"RU_FORTREE_CITY_GYM","FortreeCity_Gym","MAP_FORTREE_CITY_GYM"}, + {12,2,"RU_FORTREE_CITY_POKEMON_CENTER_1F","FortreeCity_PokemonCenter_1F","MAP_FORTREE_CITY_POKEMON_CENTER_1F"}, + {12,3,"RU_FORTREE_CITY_POKEMON_CENTER_2F","FortreeCity_PokemonCenter_2F","MAP_FORTREE_CITY_POKEMON_CENTER_2F"}, + {12,4,"RU_FORTREE_CITY_MART","FortreeCity_Mart","MAP_FORTREE_CITY_MART"}, + {12,5,"RU_FORTREE_CITY_HOUSE2","FortreeCity_House2","MAP_FORTREE_CITY_HOUSE2"}, + {12,6,"RU_FORTREE_CITY_HOUSE3","FortreeCity_House3","MAP_FORTREE_CITY_HOUSE3"}, + {12,7,"RU_FORTREE_CITY_HOUSE4","FortreeCity_House4","MAP_FORTREE_CITY_HOUSE4"}, + {12,8,"RU_FORTREE_CITY_HOUSE5","FortreeCity_House5","MAP_FORTREE_CITY_HOUSE5"}, + {12,9,"RU_FORTREE_CITY_DECORATION_SHOP","FortreeCity_DecorationShop","MAP_FORTREE_CITY_DECORATION_SHOP"}, + {13,0,"RU_LILYCOVE_CITY_COVE_LILY_MOTEL_1F","LilycoveCity_CoveLilyMotel_1F","MAP_LILYCOVE_CITY_COVE_LILY_MOTEL_1F"}, + {13,1,"RU_LILYCOVE_CITY_COVE_LILY_MOTEL_2F","LilycoveCity_CoveLilyMotel_2F","MAP_LILYCOVE_CITY_COVE_LILY_MOTEL_2F"}, + {13,2,"RU_LILYCOVE_CITY_LILYCOVE_MUSEUM_1F","LilycoveCity_LilycoveMuseum_1F","MAP_LILYCOVE_CITY_LILYCOVE_MUSEUM_1F"}, + {13,3,"RU_LILYCOVE_CITY_LILYCOVE_MUSEUM_2F","LilycoveCity_LilycoveMuseum_2F","MAP_LILYCOVE_CITY_LILYCOVE_MUSEUM_2F"}, + {13,4,"RU_LILYCOVE_CITY_CONTEST_LOBBY","LilycoveCity_ContestLobby","MAP_LILYCOVE_CITY_CONTEST_LOBBY"}, + {13,5,"RU_LILYCOVE_CITY_CONTEST_HALL","LilycoveCity_ContestHall","MAP_LILYCOVE_CITY_CONTEST_HALL"}, + {13,6,"RU_LILYCOVE_CITY_POKEMON_CENTER_1F","LilycoveCity_PokemonCenter_1F","MAP_LILYCOVE_CITY_POKEMON_CENTER_1F"}, + {13,7,"RU_LILYCOVE_CITY_POKEMON_CENTER_2F","LilycoveCity_PokemonCenter_2F","MAP_LILYCOVE_CITY_POKEMON_CENTER_2F"}, + {13,8,"RU_LILYCOVE_CITY_UNUSED_MART","LilycoveCity_UnusedMart","MAP_LILYCOVE_CITY_UNUSED_MART"}, + {13,9,"RU_LILYCOVE_CITY_POKEMON_TRAINER_FAN_CLUB","LilycoveCity_PokemonTrainerFanClub","MAP_LILYCOVE_CITY_POKEMON_TRAINER_FAN_CLUB"}, + {13,10,"RU_LILYCOVE_CITY_HARBOR","LilycoveCity_Harbor","MAP_LILYCOVE_CITY_HARBOR"}, + {13,11,"RU_LILYCOVE_CITY_EMPTY_MAP","LilycoveCity_EmptyMap","MAP_LILYCOVE_CITY_EMPTY_MAP"}, + {13,12,"RU_LILYCOVE_CITY_MOVE_DELETERS_HOUSE","LilycoveCity_MoveDeletersHouse","MAP_LILYCOVE_CITY_MOVE_DELETERS_HOUSE"}, + {13,13,"RU_LILYCOVE_CITY_HOUSE1","LilycoveCity_House1","MAP_LILYCOVE_CITY_HOUSE1"}, + {13,14,"RU_LILYCOVE_CITY_HOUSE2","LilycoveCity_House2","MAP_LILYCOVE_CITY_HOUSE2"}, + {13,15,"RU_LILYCOVE_CITY_HOUSE3","LilycoveCity_House3","MAP_LILYCOVE_CITY_HOUSE3"}, + {13,16,"RU_LILYCOVE_CITY_HOUSE4","LilycoveCity_House4","MAP_LILYCOVE_CITY_HOUSE4"}, + {13,17,"RU_LILYCOVE_CITY_DEPARTMENT_STORE_1F","LilycoveCity_DepartmentStore_1F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_1F"}, + {13,18,"RU_LILYCOVE_CITY_DEPARTMENT_STORE_2F","LilycoveCity_DepartmentStore_2F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_2F"}, + {13,19,"RU_LILYCOVE_CITY_DEPARTMENT_STORE_3F","LilycoveCity_DepartmentStore_3F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_3F"}, + {13,20,"RU_LILYCOVE_CITY_DEPARTMENT_STORE_4F","LilycoveCity_DepartmentStore_4F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_4F"}, + {13,21,"RU_LILYCOVE_CITY_DEPARTMENT_STORE_5F","LilycoveCity_DepartmentStore_5F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_5F"}, + {13,22,"RU_LILYCOVE_CITY_DEPARTMENT_STORE_ROOFTOP","LilycoveCity_DepartmentStoreRooftop","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_ROOFTOP"}, + {13,23,"RU_LILYCOVE_CITY_DEPARTMENT_STORE_ELEVATOR","LilycoveCity_DepartmentStoreElevator","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_ELEVATOR"}, + {14,0,"RU_MOSSDEEP_CITY_GYM","MossdeepCity_Gym","MAP_MOSSDEEP_CITY_GYM"}, + {14,1,"RU_MOSSDEEP_CITY_HOUSE1","MossdeepCity_House1","MAP_MOSSDEEP_CITY_HOUSE1"}, + {14,2,"RU_MOSSDEEP_CITY_HOUSE2","MossdeepCity_House2","MAP_MOSSDEEP_CITY_HOUSE2"}, + {14,3,"RU_MOSSDEEP_CITY_POKEMON_CENTER_1F","MossdeepCity_PokemonCenter_1F","MAP_MOSSDEEP_CITY_POKEMON_CENTER_1F"}, + {14,4,"RU_MOSSDEEP_CITY_POKEMON_CENTER_2F","MossdeepCity_PokemonCenter_2F","MAP_MOSSDEEP_CITY_POKEMON_CENTER_2F"}, + {14,5,"RU_MOSSDEEP_CITY_MART","MossdeepCity_Mart","MAP_MOSSDEEP_CITY_MART"}, + {14,6,"RU_MOSSDEEP_CITY_HOUSE3","MossdeepCity_House3","MAP_MOSSDEEP_CITY_HOUSE3"}, + {14,7,"RU_MOSSDEEP_CITY_STEVENS_HOUSE","MossdeepCity_StevensHouse","MAP_MOSSDEEP_CITY_STEVENS_HOUSE"}, + {14,8,"RU_MOSSDEEP_CITY_HOUSE4","MossdeepCity_House4","MAP_MOSSDEEP_CITY_HOUSE4"}, + {14,9,"RU_MOSSDEEP_CITY_SPACE_CENTER_1F","MossdeepCity_SpaceCenter_1F","MAP_MOSSDEEP_CITY_SPACE_CENTER_1F"}, + {14,10,"RU_MOSSDEEP_CITY_SPACE_CENTER_2F","MossdeepCity_SpaceCenter_2F","MAP_MOSSDEEP_CITY_SPACE_CENTER_2F"}, + {14,11,"RU_MOSSDEEP_CITY_GAME_CORNER_1F","MossdeepCity_GameCorner_1F","MAP_MOSSDEEP_CITY_GAME_CORNER_1F"}, + {14,12,"RU_MOSSDEEP_CITY_GAME_CORNER_B1F","MossdeepCity_GameCorner_B1F","MAP_MOSSDEEP_CITY_GAME_CORNER_B1F"}, + {15,0,"RU_SOOTOPOLIS_CITY_GYM_1F","SootopolisCity_Gym_1F","MAP_SOOTOPOLIS_CITY_GYM_1F"}, + {15,1,"RU_SOOTOPOLIS_CITY_GYM_B1F","SootopolisCity_Gym_B1F","MAP_SOOTOPOLIS_CITY_GYM_B1F"}, + {15,2,"RU_SOOTOPOLIS_CITY_POKEMON_CENTER_1F","SootopolisCity_PokemonCenter_1F","MAP_SOOTOPOLIS_CITY_POKEMON_CENTER_1F"}, + {15,3,"RU_SOOTOPOLIS_CITY_POKEMON_CENTER_2F","SootopolisCity_PokemonCenter_2F","MAP_SOOTOPOLIS_CITY_POKEMON_CENTER_2F"}, + {15,4,"RU_SOOTOPOLIS_CITY_MART","SootopolisCity_Mart","MAP_SOOTOPOLIS_CITY_MART"}, + {15,5,"RU_SOOTOPOLIS_CITY_HOUSE1","SootopolisCity_House1","MAP_SOOTOPOLIS_CITY_HOUSE1"}, + {15,6,"RU_SOOTOPOLIS_CITY_HOUSE2","SootopolisCity_House2","MAP_SOOTOPOLIS_CITY_HOUSE2"}, + {15,7,"RU_SOOTOPOLIS_CITY_HOUSE3","SootopolisCity_House3","MAP_SOOTOPOLIS_CITY_HOUSE3"}, + {15,8,"RU_SOOTOPOLIS_CITY_HOUSE4","SootopolisCity_House4","MAP_SOOTOPOLIS_CITY_HOUSE4"}, + {15,9,"RU_SOOTOPOLIS_CITY_HOUSE5","SootopolisCity_House5","MAP_SOOTOPOLIS_CITY_HOUSE5"}, + {15,10,"RU_SOOTOPOLIS_CITY_HOUSE6","SootopolisCity_House6","MAP_SOOTOPOLIS_CITY_HOUSE6"}, + {15,11,"RU_SOOTOPOLIS_CITY_HOUSE7","SootopolisCity_House7","MAP_SOOTOPOLIS_CITY_HOUSE7"}, + {15,12,"RU_SOOTOPOLIS_CITY_HOUSE8","SootopolisCity_House8","MAP_SOOTOPOLIS_CITY_HOUSE8"}, + {16,0,"RU_EVER_GRANDE_CITY_SIDNEYS_ROOM","EverGrandeCity_SidneysRoom","MAP_EVER_GRANDE_CITY_SIDNEYS_ROOM"}, + {16,1,"RU_EVER_GRANDE_CITY_PHOEBES_ROOM","EverGrandeCity_PhoebesRoom","MAP_EVER_GRANDE_CITY_PHOEBES_ROOM"}, + {16,2,"RU_EVER_GRANDE_CITY_GLACIAS_ROOM","EverGrandeCity_GlaciasRoom","MAP_EVER_GRANDE_CITY_GLACIAS_ROOM"}, + {16,3,"RU_EVER_GRANDE_CITY_DRAKES_ROOM","EverGrandeCity_DrakesRoom","MAP_EVER_GRANDE_CITY_DRAKES_ROOM"}, + {16,4,"RU_EVER_GRANDE_CITY_CHAMPIONS_ROOM","EverGrandeCity_ChampionsRoom","MAP_EVER_GRANDE_CITY_CHAMPIONS_ROOM"}, + {16,5,"RU_EVER_GRANDE_CITY_CORRIDOR1","EverGrandeCity_Corridor1","MAP_EVER_GRANDE_CITY_CORRIDOR1"}, + {16,6,"RU_EVER_GRANDE_CITY_CORRIDOR2","EverGrandeCity_Corridor2","MAP_EVER_GRANDE_CITY_CORRIDOR2"}, + {16,7,"RU_EVER_GRANDE_CITY_CORRIDOR3","EverGrandeCity_Corridor3","MAP_EVER_GRANDE_CITY_CORRIDOR3"}, + {16,8,"RU_EVER_GRANDE_CITY_CORRIDOR4","EverGrandeCity_Corridor4","MAP_EVER_GRANDE_CITY_CORRIDOR4"}, + {16,9,"RU_EVER_GRANDE_CITY_CORRIDOR5","EverGrandeCity_Corridor5","MAP_EVER_GRANDE_CITY_CORRIDOR5"}, + {16,10,"RU_EVER_GRANDE_CITY_POKEMON_LEAGUE","EverGrandeCity_PokemonLeague","MAP_EVER_GRANDE_CITY_POKEMON_LEAGUE"}, + {16,11,"RU_EVER_GRANDE_CITY_HALL_OF_FAME","EverGrandeCity_HallOfFame","MAP_EVER_GRANDE_CITY_HALL_OF_FAME"}, + {16,12,"RU_EVER_GRANDE_CITY_POKEMON_CENTER_1F","EverGrandeCity_PokemonCenter_1F","MAP_EVER_GRANDE_CITY_POKEMON_CENTER_1F"}, + {16,13,"RU_EVER_GRANDE_CITY_POKEMON_CENTER_2F","EverGrandeCity_PokemonCenter_2F","MAP_EVER_GRANDE_CITY_POKEMON_CENTER_2F"}, + {17,0,"RU_ROUTE104_MR_BRINEYS_HOUSE","Route104_MrBrineysHouse","MAP_ROUTE104_MR_BRINEYS_HOUSE"}, + {17,1,"RU_ROUTE104_PRETTY_PETAL_FLOWER_SHOP","Route104_PrettyPetalFlowerShop","MAP_ROUTE104_PRETTY_PETAL_FLOWER_SHOP"}, + {18,0,"RU_ROUTE111_WINSTRATE_FAMILYS_HOUSE","Route111_WinstrateFamilysHouse","MAP_ROUTE111_WINSTRATE_FAMILYS_HOUSE"}, + {18,1,"RU_ROUTE111_OLD_LADYS_REST_STOP","Route111_OldLadysRestStop","MAP_ROUTE111_OLD_LADYS_REST_STOP"}, + {19,0,"RU_ROUTE112_CABLE_CAR_STATION","Route112_CableCarStation","MAP_ROUTE112_CABLE_CAR_STATION"}, + {19,1,"RU_MT_CHIMNEY_CABLE_CAR_STATION","MtChimney_CableCarStation","MAP_MT_CHIMNEY_CABLE_CAR_STATION"}, + {20,0,"RU_ROUTE114_FOSSIL_MANIACS_HOUSE","Route114_FossilManiacsHouse","MAP_ROUTE114_FOSSIL_MANIACS_HOUSE"}, + {20,1,"RU_ROUTE114_FOSSIL_MANIACS_TUNNEL","Route114_FossilManiacsTunnel","MAP_ROUTE114_FOSSIL_MANIACS_TUNNEL"}, + {20,2,"RU_ROUTE114_LANETTES_HOUSE","Route114_LanettesHouse","MAP_ROUTE114_LANETTES_HOUSE"}, + {21,0,"RU_ROUTE116_TUNNELERS_REST_HOUSE","Route116_TunnelersRestHouse","MAP_ROUTE116_TUNNELERS_REST_HOUSE"}, + {22,0,"RU_ROUTE117_POKEMON_DAY_CARE","Route117_PokemonDayCare","MAP_ROUTE117_POKEMON_DAY_CARE"}, + {23,0,"RU_ROUTE121_SAFARI_ZONE_ENTRANCE","Route121_SafariZoneEntrance","MAP_ROUTE121_SAFARI_ZONE_ENTRANCE"}, + {24,0,"RU_METEOR_FALLS_1F_1R","MeteorFalls_1F_1R","MAP_METEOR_FALLS_1F_1R"}, + {24,1,"RU_METEOR_FALLS_1F_2R","MeteorFalls_1F_2R","MAP_METEOR_FALLS_1F_2R"}, + {24,2,"RU_METEOR_FALLS_B1F_1R","MeteorFalls_B1F_1R","MAP_METEOR_FALLS_B1F_1R"}, + {24,3,"RU_METEOR_FALLS_B1F_2R","MeteorFalls_B1F_2R","MAP_METEOR_FALLS_B1F_2R"}, + {24,4,"RU_RUSTURF_TUNNEL","RusturfTunnel","MAP_RUSTURF_TUNNEL"}, + {24,5,"RU_UNDERWATER_SOOTOPOLIS_CITY","Underwater_SootopolisCity","MAP_UNDERWATER_SOOTOPOLIS_CITY"}, + {24,6,"RU_DESERT_RUINS","DesertRuins","MAP_DESERT_RUINS"}, + {24,7,"RU_GRANITE_CAVE_1F","GraniteCave_1F","MAP_GRANITE_CAVE_1F"}, + {24,8,"RU_GRANITE_CAVE_B1F","GraniteCave_B1F","MAP_GRANITE_CAVE_B1F"}, + {24,9,"RU_GRANITE_CAVE_B2F","GraniteCave_B2F","MAP_GRANITE_CAVE_B2F"}, + {24,10,"RU_GRANITE_CAVE_STEVENS_ROOM","GraniteCave_StevensRoom","MAP_GRANITE_CAVE_STEVENS_ROOM"}, + {24,11,"RU_PETALBURG_WOODS","PetalburgWoods","MAP_PETALBURG_WOODS"}, + {24,12,"RU_MT_CHIMNEY","MtChimney","MAP_MT_CHIMNEY"}, + {24,13,"RU_JAGGED_PASS","JaggedPass","MAP_JAGGED_PASS"}, + {24,14,"RU_FIERY_PATH","FieryPath","MAP_FIERY_PATH"}, + {24,15,"RU_MT_PYRE_1F","MtPyre_1F","MAP_MT_PYRE_1F"}, + {24,16,"RU_MT_PYRE_2F","MtPyre_2F","MAP_MT_PYRE_2F"}, + {24,17,"RU_MT_PYRE_3F","MtPyre_3F","MAP_MT_PYRE_3F"}, + {24,18,"RU_MT_PYRE_4F","MtPyre_4F","MAP_MT_PYRE_4F"}, + {24,19,"RU_MT_PYRE_5F","MtPyre_5F","MAP_MT_PYRE_5F"}, + {24,20,"RU_MT_PYRE_6F","MtPyre_6F","MAP_MT_PYRE_6F"}, + {24,21,"RU_MT_PYRE_EXTERIOR","MtPyre_Exterior","MAP_MT_PYRE_EXTERIOR"}, + {24,22,"RU_MT_PYRE_SUMMIT","MtPyre_Summit","MAP_MT_PYRE_SUMMIT"}, + {24,23,"RU_AQUA_HIDEOUT_1F","AquaHideout_1F","MAP_AQUA_HIDEOUT_1F"}, + {24,24,"RU_AQUA_HIDEOUT_B1F","AquaHideout_B1F","MAP_AQUA_HIDEOUT_B1F"}, + {24,25,"RU_AQUA_HIDEOUT_B2F","AquaHideout_B2F","MAP_AQUA_HIDEOUT_B2F"}, + {24,26,"RU_UNDERWATER_SEAFLOOR_CAVERN","Underwater_SeafloorCavern","MAP_UNDERWATER_SEAFLOOR_CAVERN"}, + {24,27,"RU_SEAFLOOR_CAVERN_ENTRANCE","SeafloorCavern_Entrance","MAP_SEAFLOOR_CAVERN_ENTRANCE"}, + {24,28,"RU_SEAFLOOR_CAVERN_ROOM1","SeafloorCavern_Room1","MAP_SEAFLOOR_CAVERN_ROOM1"}, + {24,29,"RU_SEAFLOOR_CAVERN_ROOM2","SeafloorCavern_Room2","MAP_SEAFLOOR_CAVERN_ROOM2"}, + {24,30,"RU_SEAFLOOR_CAVERN_ROOM3","SeafloorCavern_Room3","MAP_SEAFLOOR_CAVERN_ROOM3"}, + {24,31,"RU_SEAFLOOR_CAVERN_ROOM4","SeafloorCavern_Room4","MAP_SEAFLOOR_CAVERN_ROOM4"}, + {24,32,"RU_SEAFLOOR_CAVERN_ROOM5","SeafloorCavern_Room5","MAP_SEAFLOOR_CAVERN_ROOM5"}, + {24,33,"RU_SEAFLOOR_CAVERN_ROOM6","SeafloorCavern_Room6","MAP_SEAFLOOR_CAVERN_ROOM6"}, + {24,34,"RU_SEAFLOOR_CAVERN_ROOM7","SeafloorCavern_Room7","MAP_SEAFLOOR_CAVERN_ROOM7"}, + {24,35,"RU_SEAFLOOR_CAVERN_ROOM8","SeafloorCavern_Room8","MAP_SEAFLOOR_CAVERN_ROOM8"}, + {24,36,"RU_SEAFLOOR_CAVERN_ROOM9","SeafloorCavern_Room9","MAP_SEAFLOOR_CAVERN_ROOM9"}, + {24,37,"RU_CAVE_OF_ORIGIN_ENTRANCE","CaveOfOrigin_Entrance","MAP_CAVE_OF_ORIGIN_ENTRANCE"}, + {24,38,"RU_CAVE_OF_ORIGIN_1F","CaveOfOrigin_1F","MAP_CAVE_OF_ORIGIN_1F"}, + {24,39,"RU_CAVE_OF_ORIGIN_B1F","CaveOfOrigin_B1F","MAP_CAVE_OF_ORIGIN_B1F"}, + {24,40,"RU_CAVE_OF_ORIGIN_B2F","CaveOfOrigin_B2F","MAP_CAVE_OF_ORIGIN_B2F"}, + {24,41,"RU_CAVE_OF_ORIGIN_B3F","CaveOfOrigin_B3F","MAP_CAVE_OF_ORIGIN_B3F"}, + {24,42,"RU_CAVE_OF_ORIGIN_B4F","CaveOfOrigin_B4F","MAP_CAVE_OF_ORIGIN_B4F"}, + {24,43,"RU_VICTORY_ROAD_1F","VictoryRoad_1F","MAP_VICTORY_ROAD_1F"}, + {24,44,"RU_VICTORY_ROAD_B1F","VictoryRoad_B1F","MAP_VICTORY_ROAD_B1F"}, + {24,45,"RU_VICTORY_ROAD_B2F","VictoryRoad_B2F","MAP_VICTORY_ROAD_B2F"}, + {24,46,"RU_SHOAL_CAVE_LOW_TIDE_ENTRANCE_ROOM","ShoalCave_LowTideEntranceRoom","MAP_SHOAL_CAVE_LOW_TIDE_ENTRANCE_ROOM"}, + {24,47,"RU_SHOAL_CAVE_LOW_TIDE_INNER_ROOM","ShoalCave_LowTideInnerRoom","MAP_SHOAL_CAVE_LOW_TIDE_INNER_ROOM"}, + {24,48,"RU_SHOAL_CAVE_LOW_TIDE_STAIRS_ROOM","ShoalCave_LowTideStairsRoom","MAP_SHOAL_CAVE_LOW_TIDE_STAIRS_ROOM"}, + {24,49,"RU_SHOAL_CAVE_LOW_TIDE_LOWER_ROOM","ShoalCave_LowTideLowerRoom","MAP_SHOAL_CAVE_LOW_TIDE_LOWER_ROOM"}, + {24,50,"RU_SHOAL_CAVE_HIGH_TIDE_ENTRANCE_ROOM","ShoalCave_HighTideEntranceRoom","MAP_SHOAL_CAVE_HIGH_TIDE_ENTRANCE_ROOM"}, + {24,51,"RU_SHOAL_CAVE_HIGH_TIDE_INNER_ROOM","ShoalCave_HighTideInnerRoom","MAP_SHOAL_CAVE_HIGH_TIDE_INNER_ROOM"}, + {24,52,"RU_NEW_MAUVILLE_ENTRANCE","NewMauville_Entrance","MAP_NEW_MAUVILLE_ENTRANCE"}, + {24,53,"RU_NEW_MAUVILLE_INSIDE","NewMauville_Inside","MAP_NEW_MAUVILLE_INSIDE"}, + {24,54,"RU_ABANDONED_SHIP_DECK","AbandonedShip_Deck","MAP_ABANDONED_SHIP_DECK"}, + {24,55,"RU_ABANDONED_SHIP_CORRIDORS_1F","AbandonedShip_Corridors_1F","MAP_ABANDONED_SHIP_CORRIDORS_1F"}, + {24,56,"RU_ABANDONED_SHIP_ROOMS_1F","AbandonedShip_Rooms_1F","MAP_ABANDONED_SHIP_ROOMS_1F"}, + {24,57,"RU_ABANDONED_SHIP_CORRIDORS_B1F","AbandonedShip_Corridors_B1F","MAP_ABANDONED_SHIP_CORRIDORS_B1F"}, + {24,58,"RU_ABANDONED_SHIP_ROOMS_B1F","AbandonedShip_Rooms_B1F","MAP_ABANDONED_SHIP_ROOMS_B1F"}, + {24,59,"RU_ABANDONED_SHIP_ROOMS2_B1F","AbandonedShip_Rooms2_B1F","MAP_ABANDONED_SHIP_ROOMS2_B1F"}, + {24,60,"RU_ABANDONED_SHIP_UNDERWATER1","AbandonedShip_Underwater1","MAP_ABANDONED_SHIP_UNDERWATER1"}, + {24,61,"RU_ABANDONED_SHIP_ROOM_B1F","AbandonedShip_Room_B1F","MAP_ABANDONED_SHIP_ROOM_B1F"}, + {24,62,"RU_ABANDONED_SHIP_ROOMS2_1F","AbandonedShip_Rooms2_1F","MAP_ABANDONED_SHIP_ROOMS2_1F"}, + {24,63,"RU_ABANDONED_SHIP_CAPTAINS_OFFICE","AbandonedShip_CaptainsOffice","MAP_ABANDONED_SHIP_CAPTAINS_OFFICE"}, + {24,64,"RU_ABANDONED_SHIP_UNDERWATER2","AbandonedShip_Underwater2","MAP_ABANDONED_SHIP_UNDERWATER2"}, + {24,65,"RU_ABANDONED_SHIP_HIDDEN_FLOOR_CORRIDORS","AbandonedShip_HiddenFloorCorridors","MAP_ABANDONED_SHIP_HIDDEN_FLOOR_CORRIDORS"}, + {24,66,"RU_ABANDONED_SHIP_HIDDEN_FLOOR_ROOMS","AbandonedShip_HiddenFloorRooms","MAP_ABANDONED_SHIP_HIDDEN_FLOOR_ROOMS"}, + {24,67,"RU_ISLAND_CAVE","IslandCave","MAP_ISLAND_CAVE"}, + {24,68,"RU_ANCIENT_TOMB","AncientTomb","MAP_ANCIENT_TOMB"}, + {24,69,"RU_UNDERWATER_ROUTE134","Underwater_Route134","MAP_UNDERWATER_ROUTE134"}, + {24,70,"RU_UNDERWATER_SEALED_CHAMBER","Underwater_SealedChamber","MAP_UNDERWATER_SEALED_CHAMBER"}, + {24,71,"RU_SEALED_CHAMBER_OUTER_ROOM","SealedChamber_OuterRoom","MAP_SEALED_CHAMBER_OUTER_ROOM"}, + {24,72,"RU_SEALED_CHAMBER_INNER_ROOM","SealedChamber_InnerRoom","MAP_SEALED_CHAMBER_INNER_ROOM"}, + {24,73,"RU_SCORCHED_SLAB","ScorchedSlab","MAP_SCORCHED_SLAB"}, + {24,74,"RU_MAGMA_HIDEOUT_1F","MagmaHideout_1F","MAP_MAGMA_HIDEOUT_1F"}, + {24,75,"RU_MAGMA_HIDEOUT_B1F","MagmaHideout_B1F","MAP_MAGMA_HIDEOUT_B1F"}, + {24,76,"RU_MAGMA_HIDEOUT_B2F","MagmaHideout_B2F","MAP_MAGMA_HIDEOUT_B2F"}, + {24,77,"RU_SKY_PILLAR_ENTRANCE","SkyPillar_Entrance","MAP_SKY_PILLAR_ENTRANCE"}, + {24,78,"RU_SKY_PILLAR_OUTSIDE","SkyPillar_Outside","MAP_SKY_PILLAR_OUTSIDE"}, + {24,79,"RU_SKY_PILLAR_1F","SkyPillar_1F","MAP_SKY_PILLAR_1F"}, + {24,80,"RU_SKY_PILLAR_2F","SkyPillar_2F","MAP_SKY_PILLAR_2F"}, + {24,81,"RU_SKY_PILLAR_3F","SkyPillar_3F","MAP_SKY_PILLAR_3F"}, + {24,82,"RU_SKY_PILLAR_4F","SkyPillar_4F","MAP_SKY_PILLAR_4F"}, + {24,83,"RU_SHOAL_CAVE_LOW_TIDE_ICE_ROOM","ShoalCave_LowTideIceRoom","MAP_SHOAL_CAVE_LOW_TIDE_ICE_ROOM"}, + {24,84,"RU_SKY_PILLAR_5F","SkyPillar_5F","MAP_SKY_PILLAR_5F"}, + {24,85,"RU_SKY_PILLAR_TOP","SkyPillar_Top","MAP_SKY_PILLAR_TOP"}, + {25,0,"RU_SECRET_BASE_RED_CAVE1","SecretBase_RedCave1","MAP_SECRET_BASE_RED_CAVE1"}, + {25,1,"RU_SECRET_BASE_BROWN_CAVE1","SecretBase_BrownCave1","MAP_SECRET_BASE_BROWN_CAVE1"}, + {25,2,"RU_SECRET_BASE_BLUE_CAVE1","SecretBase_BlueCave1","MAP_SECRET_BASE_BLUE_CAVE1"}, + {25,3,"RU_SECRET_BASE_YELLOW_CAVE1","SecretBase_YellowCave1","MAP_SECRET_BASE_YELLOW_CAVE1"}, + {25,4,"RU_SECRET_BASE_TREE1","SecretBase_Tree1","MAP_SECRET_BASE_TREE1"}, + {25,5,"RU_SECRET_BASE_SHRUB1","SecretBase_Shrub1","MAP_SECRET_BASE_SHRUB1"}, + {25,6,"RU_SECRET_BASE_RED_CAVE2","SecretBase_RedCave2","MAP_SECRET_BASE_RED_CAVE2"}, + {25,7,"RU_SECRET_BASE_BROWN_CAVE2","SecretBase_BrownCave2","MAP_SECRET_BASE_BROWN_CAVE2"}, + {25,8,"RU_SECRET_BASE_BLUE_CAVE2","SecretBase_BlueCave2","MAP_SECRET_BASE_BLUE_CAVE2"}, + {25,9,"RU_SECRET_BASE_YELLOW_CAVE2","SecretBase_YellowCave2","MAP_SECRET_BASE_YELLOW_CAVE2"}, + {25,10,"RU_SECRET_BASE_TREE2","SecretBase_Tree2","MAP_SECRET_BASE_TREE2"}, + {25,11,"RU_SECRET_BASE_SHRUB2","SecretBase_Shrub2","MAP_SECRET_BASE_SHRUB2"}, + {25,12,"RU_SECRET_BASE_RED_CAVE3","SecretBase_RedCave3","MAP_SECRET_BASE_RED_CAVE3"}, + {25,13,"RU_SECRET_BASE_BROWN_CAVE3","SecretBase_BrownCave3","MAP_SECRET_BASE_BROWN_CAVE3"}, + {25,14,"RU_SECRET_BASE_BLUE_CAVE3","SecretBase_BlueCave3","MAP_SECRET_BASE_BLUE_CAVE3"}, + {25,15,"RU_SECRET_BASE_YELLOW_CAVE3","SecretBase_YellowCave3","MAP_SECRET_BASE_YELLOW_CAVE3"}, + {25,16,"RU_SECRET_BASE_TREE3","SecretBase_Tree3","MAP_SECRET_BASE_TREE3"}, + {25,17,"RU_SECRET_BASE_SHRUB3","SecretBase_Shrub3","MAP_SECRET_BASE_SHRUB3"}, + {25,18,"RU_SECRET_BASE_RED_CAVE4","SecretBase_RedCave4","MAP_SECRET_BASE_RED_CAVE4"}, + {25,19,"RU_SECRET_BASE_BROWN_CAVE4","SecretBase_BrownCave4","MAP_SECRET_BASE_BROWN_CAVE4"}, + {25,20,"RU_SECRET_BASE_BLUE_CAVE4","SecretBase_BlueCave4","MAP_SECRET_BASE_BLUE_CAVE4"}, + {25,21,"RU_SECRET_BASE_YELLOW_CAVE4","SecretBase_YellowCave4","MAP_SECRET_BASE_YELLOW_CAVE4"}, + {25,22,"RU_SECRET_BASE_TREE4","SecretBase_Tree4","MAP_SECRET_BASE_TREE4"}, + {25,23,"RU_SECRET_BASE_SHRUB4","SecretBase_Shrub4","MAP_SECRET_BASE_SHRUB4"}, + {25,24,"RU_SINGLE_BATTLE_COLOSSEUM","SingleBattleColosseum","MAP_SINGLE_BATTLE_COLOSSEUM"}, + {25,25,"RU_TRADE_CENTER","TradeCenter","MAP_TRADE_CENTER"}, + {25,26,"RU_RECORD_CORNER","RecordCorner","MAP_RECORD_CORNER"}, + {25,27,"RU_DOUBLE_BATTLE_COLOSSEUM","DoubleBattleColosseum","MAP_DOUBLE_BATTLE_COLOSSEUM"}, + {25,28,"RU_LINK_CONTEST_ROOM1","LinkContestRoom1","MAP_LINK_CONTEST_ROOM1"}, + {25,29,"RU_UNKNOWN_MAP_25_29","UnknownMap_25_29","MAP_UNKNOWN_MAP_25_29"}, + {25,30,"RU_UNKNOWN_MAP_25_30","UnknownMap_25_30","MAP_UNKNOWN_MAP_25_30"}, + {25,31,"RU_UNKNOWN_MAP_25_31","UnknownMap_25_31","MAP_UNKNOWN_MAP_25_31"}, + {25,32,"RU_UNKNOWN_MAP_25_32","UnknownMap_25_32","MAP_UNKNOWN_MAP_25_32"}, + {25,33,"RU_UNKNOWN_MAP_25_33","UnknownMap_25_33","MAP_UNKNOWN_MAP_25_33"}, + {25,34,"RU_UNKNOWN_MAP_25_34","UnknownMap_25_34","MAP_UNKNOWN_MAP_25_34"}, + {25,35,"RU_LINK_CONTEST_ROOM2","LinkContestRoom2","MAP_LINK_CONTEST_ROOM2"}, + {25,36,"RU_LINK_CONTEST_ROOM3","LinkContestRoom3","MAP_LINK_CONTEST_ROOM3"}, + {25,37,"RU_LINK_CONTEST_ROOM4","LinkContestRoom4","MAP_LINK_CONTEST_ROOM4"}, + {25,38,"RU_LINK_CONTEST_ROOM5","LinkContestRoom5","MAP_LINK_CONTEST_ROOM5"}, + {25,39,"RU_LINK_CONTEST_ROOM6","LinkContestRoom6","MAP_LINK_CONTEST_ROOM6"}, + {25,40,"RU_INSIDE_OF_TRUCK","InsideOfTruck","MAP_INSIDE_OF_TRUCK"}, + {25,41,"RU_SS_TIDAL_CORRIDOR","SSTidalCorridor","MAP_SS_TIDAL_CORRIDOR"}, + {25,42,"RU_SS_TIDAL_LOWER_DECK","SSTidalLowerDeck","MAP_SS_TIDAL_LOWER_DECK"}, + {25,43,"RU_SS_TIDAL_ROOMS","SSTidalRooms","MAP_SS_TIDAL_ROOMS"}, + {26,0,"RU_SAFARI_ZONE_NORTHWEST","SafariZone_Northwest","MAP_SAFARI_ZONE_NORTHWEST"}, + {26,1,"RU_SAFARI_ZONE_NORTHEAST","SafariZone_Northeast","MAP_SAFARI_ZONE_NORTHEAST"}, + {26,2,"RU_SAFARI_ZONE_SOUTHWEST","SafariZone_Southwest","MAP_SAFARI_ZONE_SOUTHWEST"}, + {26,3,"RU_SAFARI_ZONE_SOUTHEAST","SafariZone_Southeast","MAP_SAFARI_ZONE_SOUTHEAST"}, + {26,4,"RU_BATTLE_TOWER_OUTSIDE","BattleTower_Outside","MAP_BATTLE_TOWER_OUTSIDE"}, + {26,5,"RU_BATTLE_TOWER_LOBBY","BattleTower_Lobby","MAP_BATTLE_TOWER_LOBBY"}, + {26,6,"RU_BATTLE_TOWER_ELEVATOR","BattleTower_Elevator","MAP_BATTLE_TOWER_ELEVATOR"}, + {26,7,"RU_BATTLE_TOWER_CORRIDOR","BattleTower_Corridor","MAP_BATTLE_TOWER_CORRIDOR"}, + {26,8,"RU_BATTLE_TOWER_BATTLE_ROOM","BattleTower_BattleRoom","MAP_BATTLE_TOWER_BATTLE_ROOM"}, + {26,9,"RU_SOUTHERN_ISLAND_EXTERIOR","SouthernIsland_Exterior","MAP_SOUTHERN_ISLAND_EXTERIOR"}, + {26,10,"RU_SOUTHERN_ISLAND_INTERIOR","SouthernIsland_Interior","MAP_SOUTHERN_ISLAND_INTERIOR"}, + {26,11,"RU_SAFARI_ZONE_REST_HOUSE","SafariZone_RestHouse","MAP_SAFARI_ZONE_REST_HOUSE"}, + {27,0,"RU_ROUTE104_PROTOTYPE","Route104_Prototype","MAP_ROUTE104_PROTOTYPE"}, + {27,1,"RU_ROUTE104_PROTOTYPE_PRETTY_PETAL_FLOWER_SHOP","Route104_PrototypePrettyPetalFlowerShop","MAP_ROUTE104_PROTOTYPE_PRETTY_PETAL_FLOWER_SHOP"}, + {28,0,"RU_ROUTE109_SEASHORE_HOUSE","Route109_SeashoreHouse","MAP_ROUTE109_SEASHORE_HOUSE"}, + {29,0,"RU_ROUTE110_TRICK_HOUSE_ENTRANCE","Route110_TrickHouseEntrance","MAP_ROUTE110_TRICK_HOUSE_ENTRANCE"}, + {29,1,"RU_ROUTE110_TRICK_HOUSE_END","Route110_TrickHouseEnd","MAP_ROUTE110_TRICK_HOUSE_END"}, + {29,2,"RU_ROUTE110_TRICK_HOUSE_CORRIDOR","Route110_TrickHouseCorridor","MAP_ROUTE110_TRICK_HOUSE_CORRIDOR"}, + {29,3,"RU_ROUTE110_TRICK_HOUSE_PUZZLE1","Route110_TrickHousePuzzle1","MAP_ROUTE110_TRICK_HOUSE_PUZZLE1"}, + {29,4,"RU_ROUTE110_TRICK_HOUSE_PUZZLE2","Route110_TrickHousePuzzle2","MAP_ROUTE110_TRICK_HOUSE_PUZZLE2"}, + {29,5,"RU_ROUTE110_TRICK_HOUSE_PUZZLE3","Route110_TrickHousePuzzle3","MAP_ROUTE110_TRICK_HOUSE_PUZZLE3"}, + {29,6,"RU_ROUTE110_TRICK_HOUSE_PUZZLE4","Route110_TrickHousePuzzle4","MAP_ROUTE110_TRICK_HOUSE_PUZZLE4"}, + {29,7,"RU_ROUTE110_TRICK_HOUSE_PUZZLE5","Route110_TrickHousePuzzle5","MAP_ROUTE110_TRICK_HOUSE_PUZZLE5"}, + {29,8,"RU_ROUTE110_TRICK_HOUSE_PUZZLE6","Route110_TrickHousePuzzle6","MAP_ROUTE110_TRICK_HOUSE_PUZZLE6"}, + {29,9,"RU_ROUTE110_TRICK_HOUSE_PUZZLE7","Route110_TrickHousePuzzle7","MAP_ROUTE110_TRICK_HOUSE_PUZZLE7"}, + {29,10,"RU_ROUTE110_TRICK_HOUSE_PUZZLE8","Route110_TrickHousePuzzle8","MAP_ROUTE110_TRICK_HOUSE_PUZZLE8"}, + {29,11,"RU_ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE","Route110_SeasideCyclingRoadSouthEntrance","MAP_ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE"}, + {29,12,"RU_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE","Route110_SeasideCyclingRoadNorthEntrance","MAP_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE"}, + {30,0,"RU_ROUTE113_GLASS_WORKSHOP","Route113_GlassWorkshop","MAP_ROUTE113_GLASS_WORKSHOP"}, + {31,0,"RU_ROUTE123_BERRY_MASTERS_HOUSE","Route123_BerryMastersHouse","MAP_ROUTE123_BERRY_MASTERS_HOUSE"}, + {32,0,"RU_ROUTE119_WEATHER_INSTITUTE_1F","Route119_WeatherInstitute_1F","MAP_ROUTE119_WEATHER_INSTITUTE_1F"}, + {32,1,"RU_ROUTE119_WEATHER_INSTITUTE_2F","Route119_WeatherInstitute_2F","MAP_ROUTE119_WEATHER_INSTITUTE_2F"}, + {32,2,"RU_ROUTE119_HOUSE","Route119_House","MAP_ROUTE119_HOUSE"}, + {33,0,"RU_ROUTE124_DIVING_TREASURE_HUNTERS_HOUSE","Route124_DivingTreasureHuntersHouse","MAP_ROUTE124_DIVING_TREASURE_HUNTERS_HOUSE"}, + }, + ["sapphire"]={ + {0,0,"SA_PETALBURG_CITY","PetalburgCity","MAP_PETALBURG_CITY"}, + {0,1,"SA_SLATEPORT_CITY","SlateportCity","MAP_SLATEPORT_CITY"}, + {0,2,"SA_MAUVILLE_CITY","MauvilleCity","MAP_MAUVILLE_CITY"}, + {0,3,"SA_RUSTBORO_CITY","RustboroCity","MAP_RUSTBORO_CITY"}, + {0,4,"SA_FORTREE_CITY","FortreeCity","MAP_FORTREE_CITY"}, + {0,5,"SA_LILYCOVE_CITY","LilycoveCity","MAP_LILYCOVE_CITY"}, + {0,6,"SA_MOSSDEEP_CITY","MossdeepCity","MAP_MOSSDEEP_CITY"}, + {0,7,"SA_SOOTOPOLIS_CITY","SootopolisCity","MAP_SOOTOPOLIS_CITY"}, + {0,8,"SA_EVER_GRANDE_CITY","EverGrandeCity","MAP_EVER_GRANDE_CITY"}, + {0,9,"SA_LITTLEROOT_TOWN","LittlerootTown","MAP_LITTLEROOT_TOWN"}, + {0,10,"SA_OLDALE_TOWN","OldaleTown","MAP_OLDALE_TOWN"}, + {0,11,"SA_DEWFORD_TOWN","DewfordTown","MAP_DEWFORD_TOWN"}, + {0,12,"SA_LAVARIDGE_TOWN","LavaridgeTown","MAP_LAVARIDGE_TOWN"}, + {0,13,"SA_FALLARBOR_TOWN","FallarborTown","MAP_FALLARBOR_TOWN"}, + {0,14,"SA_VERDANTURF_TOWN","VerdanturfTown","MAP_VERDANTURF_TOWN"}, + {0,15,"SA_PACIFIDLOG_TOWN","PacifidlogTown","MAP_PACIFIDLOG_TOWN"}, + {0,16,"SA_ROUTE101","Route101","MAP_ROUTE101"}, + {0,17,"SA_ROUTE102","Route102","MAP_ROUTE102"}, + {0,18,"SA_ROUTE103","Route103","MAP_ROUTE103"}, + {0,19,"SA_ROUTE104","Route104","MAP_ROUTE104"}, + {0,20,"SA_ROUTE105","Route105","MAP_ROUTE105"}, + {0,21,"SA_ROUTE106","Route106","MAP_ROUTE106"}, + {0,22,"SA_ROUTE107","Route107","MAP_ROUTE107"}, + {0,23,"SA_ROUTE108","Route108","MAP_ROUTE108"}, + {0,24,"SA_ROUTE109","Route109","MAP_ROUTE109"}, + {0,25,"SA_ROUTE110","Route110","MAP_ROUTE110"}, + {0,26,"SA_ROUTE111","Route111","MAP_ROUTE111"}, + {0,27,"SA_ROUTE112","Route112","MAP_ROUTE112"}, + {0,28,"SA_ROUTE113","Route113","MAP_ROUTE113"}, + {0,29,"SA_ROUTE114","Route114","MAP_ROUTE114"}, + {0,30,"SA_ROUTE115","Route115","MAP_ROUTE115"}, + {0,31,"SA_ROUTE116","Route116","MAP_ROUTE116"}, + {0,32,"SA_ROUTE117","Route117","MAP_ROUTE117"}, + {0,33,"SA_ROUTE118","Route118","MAP_ROUTE118"}, + {0,34,"SA_ROUTE119","Route119","MAP_ROUTE119"}, + {0,35,"SA_ROUTE120","Route120","MAP_ROUTE120"}, + {0,36,"SA_ROUTE121","Route121","MAP_ROUTE121"}, + {0,37,"SA_ROUTE122","Route122","MAP_ROUTE122"}, + {0,38,"SA_ROUTE123","Route123","MAP_ROUTE123"}, + {0,39,"SA_ROUTE124","Route124","MAP_ROUTE124"}, + {0,40,"SA_ROUTE125","Route125","MAP_ROUTE125"}, + {0,41,"SA_ROUTE126","Route126","MAP_ROUTE126"}, + {0,42,"SA_ROUTE127","Route127","MAP_ROUTE127"}, + {0,43,"SA_ROUTE128","Route128","MAP_ROUTE128"}, + {0,44,"SA_ROUTE129","Route129","MAP_ROUTE129"}, + {0,45,"SA_ROUTE130","Route130","MAP_ROUTE130"}, + {0,46,"SA_ROUTE131","Route131","MAP_ROUTE131"}, + {0,47,"SA_ROUTE132","Route132","MAP_ROUTE132"}, + {0,48,"SA_ROUTE133","Route133","MAP_ROUTE133"}, + {0,49,"SA_ROUTE134","Route134","MAP_ROUTE134"}, + {0,50,"SA_UNDERWATER1","Underwater1","MAP_UNDERWATER1"}, + {0,51,"SA_UNDERWATER2","Underwater2","MAP_UNDERWATER2"}, + {0,52,"SA_UNDERWATER3","Underwater3","MAP_UNDERWATER3"}, + {0,53,"SA_UNDERWATER4","Underwater4","MAP_UNDERWATER4"}, + {1,0,"SA_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F","LittlerootTown_BrendansHouse_1F","MAP_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F"}, + {1,1,"SA_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F","LittlerootTown_BrendansHouse_2F","MAP_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F"}, + {1,2,"SA_LITTLEROOT_TOWN_MAYS_HOUSE_1F","LittlerootTown_MaysHouse_1F","MAP_LITTLEROOT_TOWN_MAYS_HOUSE_1F"}, + {1,3,"SA_LITTLEROOT_TOWN_MAYS_HOUSE_2F","LittlerootTown_MaysHouse_2F","MAP_LITTLEROOT_TOWN_MAYS_HOUSE_2F"}, + {1,4,"SA_LITTLEROOT_TOWN_PROFESSOR_BIRCHS_LAB","LittlerootTown_ProfessorBirchsLab","MAP_LITTLEROOT_TOWN_PROFESSOR_BIRCHS_LAB"}, + {2,0,"SA_OLDALE_TOWN_HOUSE1","OldaleTown_House1","MAP_OLDALE_TOWN_HOUSE1"}, + {2,1,"SA_OLDALE_TOWN_HOUSE2","OldaleTown_House2","MAP_OLDALE_TOWN_HOUSE2"}, + {2,2,"SA_OLDALE_TOWN_POKEMON_CENTER_1F","OldaleTown_PokemonCenter_1F","MAP_OLDALE_TOWN_POKEMON_CENTER_1F"}, + {2,3,"SA_OLDALE_TOWN_POKEMON_CENTER_2F","OldaleTown_PokemonCenter_2F","MAP_OLDALE_TOWN_POKEMON_CENTER_2F"}, + {2,4,"SA_OLDALE_TOWN_MART","OldaleTown_Mart","MAP_OLDALE_TOWN_MART"}, + {3,0,"SA_DEWFORD_TOWN_HOUSE1","DewfordTown_House1","MAP_DEWFORD_TOWN_HOUSE1"}, + {3,1,"SA_DEWFORD_TOWN_POKEMON_CENTER_1F","DewfordTown_PokemonCenter_1F","MAP_DEWFORD_TOWN_POKEMON_CENTER_1F"}, + {3,2,"SA_DEWFORD_TOWN_POKEMON_CENTER_2F","DewfordTown_PokemonCenter_2F","MAP_DEWFORD_TOWN_POKEMON_CENTER_2F"}, + {3,3,"SA_DEWFORD_TOWN_GYM","DewfordTown_Gym","MAP_DEWFORD_TOWN_GYM"}, + {3,4,"SA_DEWFORD_TOWN_HALL","DewfordTown_Hall","MAP_DEWFORD_TOWN_HALL"}, + {3,5,"SA_DEWFORD_TOWN_HOUSE2","DewfordTown_House2","MAP_DEWFORD_TOWN_HOUSE2"}, + {4,0,"SA_LAVARIDGE_TOWN_HERB_SHOP","LavaridgeTown_HerbShop","MAP_LAVARIDGE_TOWN_HERB_SHOP"}, + {4,1,"SA_LAVARIDGE_TOWN_GYM_1F","LavaridgeTown_Gym_1F","MAP_LAVARIDGE_TOWN_GYM_1F"}, + {4,2,"SA_LAVARIDGE_TOWN_GYM_B1F","LavaridgeTown_Gym_B1F","MAP_LAVARIDGE_TOWN_GYM_B1F"}, + {4,3,"SA_LAVARIDGE_TOWN_HOUSE","LavaridgeTown_House","MAP_LAVARIDGE_TOWN_HOUSE"}, + {4,4,"SA_LAVARIDGE_TOWN_MART","LavaridgeTown_Mart","MAP_LAVARIDGE_TOWN_MART"}, + {4,5,"SA_LAVARIDGE_TOWN_POKEMON_CENTER_1F","LavaridgeTown_PokemonCenter_1F","MAP_LAVARIDGE_TOWN_POKEMON_CENTER_1F"}, + {4,6,"SA_LAVARIDGE_TOWN_POKEMON_CENTER_2F","LavaridgeTown_PokemonCenter_2F","MAP_LAVARIDGE_TOWN_POKEMON_CENTER_2F"}, + {5,0,"SA_FALLARBOR_TOWN_MART","FallarborTown_Mart","MAP_FALLARBOR_TOWN_MART"}, + {5,1,"SA_FALLARBOR_TOWN_CONTEST_LOBBY","FallarborTown_ContestLobby","MAP_FALLARBOR_TOWN_CONTEST_LOBBY"}, + {5,2,"SA_FALLARBOR_TOWN_CONTEST_HALL","FallarborTown_ContestHall","MAP_FALLARBOR_TOWN_CONTEST_HALL"}, + {5,3,"SA_FALLARBOR_TOWN_POKEMON_CENTER_1F","FallarborTown_PokemonCenter_1F","MAP_FALLARBOR_TOWN_POKEMON_CENTER_1F"}, + {5,4,"SA_FALLARBOR_TOWN_POKEMON_CENTER_2F","FallarborTown_PokemonCenter_2F","MAP_FALLARBOR_TOWN_POKEMON_CENTER_2F"}, + {5,5,"SA_FALLARBOR_TOWN_HOUSE1","FallarborTown_House1","MAP_FALLARBOR_TOWN_HOUSE1"}, + {5,6,"SA_FALLARBOR_TOWN_HOUSE2","FallarborTown_House2","MAP_FALLARBOR_TOWN_HOUSE2"}, + {6,0,"SA_VERDANTURF_TOWN_CONTEST_LOBBY","VerdanturfTown_ContestLobby","MAP_VERDANTURF_TOWN_CONTEST_LOBBY"}, + {6,1,"SA_VERDANTURF_TOWN_CONTEST_HALL","VerdanturfTown_ContestHall","MAP_VERDANTURF_TOWN_CONTEST_HALL"}, + {6,2,"SA_VERDANTURF_TOWN_MART","VerdanturfTown_Mart","MAP_VERDANTURF_TOWN_MART"}, + {6,3,"SA_VERDANTURF_TOWN_POKEMON_CENTER_1F","VerdanturfTown_PokemonCenter_1F","MAP_VERDANTURF_TOWN_POKEMON_CENTER_1F"}, + {6,4,"SA_VERDANTURF_TOWN_POKEMON_CENTER_2F","VerdanturfTown_PokemonCenter_2F","MAP_VERDANTURF_TOWN_POKEMON_CENTER_2F"}, + {6,5,"SA_VERDANTURF_TOWN_WANDAS_HOUSE","VerdanturfTown_WandasHouse","MAP_VERDANTURF_TOWN_WANDAS_HOUSE"}, + {6,6,"SA_VERDANTURF_TOWN_FRIENDSHIP_RATERS_HOUSE","VerdanturfTown_FriendshipRatersHouse","MAP_VERDANTURF_TOWN_FRIENDSHIP_RATERS_HOUSE"}, + {6,7,"SA_VERDANTURF_TOWN_HOUSE","VerdanturfTown_House","MAP_VERDANTURF_TOWN_HOUSE"}, + {7,0,"SA_PACIFIDLOG_TOWN_POKEMON_CENTER_1F","PacifidlogTown_PokemonCenter_1F","MAP_PACIFIDLOG_TOWN_POKEMON_CENTER_1F"}, + {7,1,"SA_PACIFIDLOG_TOWN_POKEMON_CENTER_2F","PacifidlogTown_PokemonCenter_2F","MAP_PACIFIDLOG_TOWN_POKEMON_CENTER_2F"}, + {7,2,"SA_PACIFIDLOG_TOWN_HOUSE1","PacifidlogTown_House1","MAP_PACIFIDLOG_TOWN_HOUSE1"}, + {7,3,"SA_PACIFIDLOG_TOWN_HOUSE2","PacifidlogTown_House2","MAP_PACIFIDLOG_TOWN_HOUSE2"}, + {7,4,"SA_PACIFIDLOG_TOWN_HOUSE3","PacifidlogTown_House3","MAP_PACIFIDLOG_TOWN_HOUSE3"}, + {7,5,"SA_PACIFIDLOG_TOWN_HOUSE4","PacifidlogTown_House4","MAP_PACIFIDLOG_TOWN_HOUSE4"}, + {7,6,"SA_PACIFIDLOG_TOWN_HOUSE5","PacifidlogTown_House5","MAP_PACIFIDLOG_TOWN_HOUSE5"}, + {8,0,"SA_PETALBURG_CITY_WALLYS_HOUSE","PetalburgCity_WallysHouse","MAP_PETALBURG_CITY_WALLYS_HOUSE"}, + {8,1,"SA_PETALBURG_CITY_GYM","PetalburgCity_Gym","MAP_PETALBURG_CITY_GYM"}, + {8,2,"SA_PETALBURG_CITY_HOUSE1","PetalburgCity_House1","MAP_PETALBURG_CITY_HOUSE1"}, + {8,3,"SA_PETALBURG_CITY_HOUSE2","PetalburgCity_House2","MAP_PETALBURG_CITY_HOUSE2"}, + {8,4,"SA_PETALBURG_CITY_POKEMON_CENTER_1F","PetalburgCity_PokemonCenter_1F","MAP_PETALBURG_CITY_POKEMON_CENTER_1F"}, + {8,5,"SA_PETALBURG_CITY_POKEMON_CENTER_2F","PetalburgCity_PokemonCenter_2F","MAP_PETALBURG_CITY_POKEMON_CENTER_2F"}, + {8,6,"SA_PETALBURG_CITY_MART","PetalburgCity_Mart","MAP_PETALBURG_CITY_MART"}, + {9,0,"SA_SLATEPORT_CITY_STERNS_SHIPYARD_1F","SlateportCity_SternsShipyard_1F","MAP_SLATEPORT_CITY_STERNS_SHIPYARD_1F"}, + {9,1,"SA_SLATEPORT_CITY_STERNS_SHIPYARD_2F","SlateportCity_SternsShipyard_2F","MAP_SLATEPORT_CITY_STERNS_SHIPYARD_2F"}, + {9,2,"SA_SLATEPORT_CITY_CONTEST_LOBBY","SlateportCity_ContestLobby","MAP_SLATEPORT_CITY_CONTEST_LOBBY"}, + {9,3,"SA_SLATEPORT_CITY_CONTEST_HALL","SlateportCity_ContestHall","MAP_SLATEPORT_CITY_CONTEST_HALL"}, + {9,4,"SA_SLATEPORT_CITY_HOUSE1","SlateportCity_House1","MAP_SLATEPORT_CITY_HOUSE1"}, + {9,5,"SA_SLATEPORT_CITY_POKEMON_FAN_CLUB","SlateportCity_PokemonFanClub","MAP_SLATEPORT_CITY_POKEMON_FAN_CLUB"}, + {9,6,"SA_SLATEPORT_CITY_OCEANIC_MUSEUM_1F","SlateportCity_OceanicMuseum_1F","MAP_SLATEPORT_CITY_OCEANIC_MUSEUM_1F"}, + {9,7,"SA_SLATEPORT_CITY_OCEANIC_MUSEUM_2F","SlateportCity_OceanicMuseum_2F","MAP_SLATEPORT_CITY_OCEANIC_MUSEUM_2F"}, + {9,8,"SA_SLATEPORT_CITY_HARBOR","SlateportCity_Harbor","MAP_SLATEPORT_CITY_HARBOR"}, + {9,9,"SA_SLATEPORT_CITY_HOUSE2","SlateportCity_House2","MAP_SLATEPORT_CITY_HOUSE2"}, + {9,10,"SA_SLATEPORT_CITY_POKEMON_CENTER_1F","SlateportCity_PokemonCenter_1F","MAP_SLATEPORT_CITY_POKEMON_CENTER_1F"}, + {9,11,"SA_SLATEPORT_CITY_POKEMON_CENTER_2F","SlateportCity_PokemonCenter_2F","MAP_SLATEPORT_CITY_POKEMON_CENTER_2F"}, + {9,12,"SA_SLATEPORT_CITY_MART","SlateportCity_Mart","MAP_SLATEPORT_CITY_MART"}, + {10,0,"SA_MAUVILLE_CITY_GYM","MauvilleCity_Gym","MAP_MAUVILLE_CITY_GYM"}, + {10,1,"SA_MAUVILLE_CITY_BIKE_SHOP","MauvilleCity_BikeShop","MAP_MAUVILLE_CITY_BIKE_SHOP"}, + {10,2,"SA_MAUVILLE_CITY_HOUSE1","MauvilleCity_House1","MAP_MAUVILLE_CITY_HOUSE1"}, + {10,3,"SA_MAUVILLE_CITY_GAME_CORNER","MauvilleCity_GameCorner","MAP_MAUVILLE_CITY_GAME_CORNER"}, + {10,4,"SA_MAUVILLE_CITY_HOUSE2","MauvilleCity_House2","MAP_MAUVILLE_CITY_HOUSE2"}, + {10,5,"SA_MAUVILLE_CITY_POKEMON_CENTER_1F","MauvilleCity_PokemonCenter_1F","MAP_MAUVILLE_CITY_POKEMON_CENTER_1F"}, + {10,6,"SA_MAUVILLE_CITY_POKEMON_CENTER_2F","MauvilleCity_PokemonCenter_2F","MAP_MAUVILLE_CITY_POKEMON_CENTER_2F"}, + {10,7,"SA_MAUVILLE_CITY_MART","MauvilleCity_Mart","MAP_MAUVILLE_CITY_MART"}, + {11,0,"SA_RUSTBORO_CITY_DEVON_CORP_1F","RustboroCity_DevonCorp_1F","MAP_RUSTBORO_CITY_DEVON_CORP_1F"}, + {11,1,"SA_RUSTBORO_CITY_DEVON_CORP_2F","RustboroCity_DevonCorp_2F","MAP_RUSTBORO_CITY_DEVON_CORP_2F"}, + {11,2,"SA_RUSTBORO_CITY_DEVON_CORP_3F","RustboroCity_DevonCorp_3F","MAP_RUSTBORO_CITY_DEVON_CORP_3F"}, + {11,3,"SA_RUSTBORO_CITY_GYM","RustboroCity_Gym","MAP_RUSTBORO_CITY_GYM"}, + {11,4,"SA_RUSTBORO_CITY_POKEMON_SCHOOL","RustboroCity_PokemonSchool","MAP_RUSTBORO_CITY_POKEMON_SCHOOL"}, + {11,5,"SA_RUSTBORO_CITY_POKEMON_CENTER_1F","RustboroCity_PokemonCenter_1F","MAP_RUSTBORO_CITY_POKEMON_CENTER_1F"}, + {11,6,"SA_RUSTBORO_CITY_POKEMON_CENTER_2F","RustboroCity_PokemonCenter_2F","MAP_RUSTBORO_CITY_POKEMON_CENTER_2F"}, + {11,7,"SA_RUSTBORO_CITY_MART","RustboroCity_Mart","MAP_RUSTBORO_CITY_MART"}, + {11,8,"SA_RUSTBORO_CITY_FLAT1_1F","RustboroCity_Flat1_1F","MAP_RUSTBORO_CITY_FLAT1_1F"}, + {11,9,"SA_RUSTBORO_CITY_FLAT1_2F","RustboroCity_Flat1_2F","MAP_RUSTBORO_CITY_FLAT1_2F"}, + {11,10,"SA_RUSTBORO_CITY_HOUSE1","RustboroCity_House1","MAP_RUSTBORO_CITY_HOUSE1"}, + {11,11,"SA_RUSTBORO_CITY_CUTTERS_HOUSE","RustboroCity_CuttersHouse","MAP_RUSTBORO_CITY_CUTTERS_HOUSE"}, + {11,12,"SA_RUSTBORO_CITY_HOUSE2","RustboroCity_House2","MAP_RUSTBORO_CITY_HOUSE2"}, + {11,13,"SA_RUSTBORO_CITY_FLAT2_1F","RustboroCity_Flat2_1F","MAP_RUSTBORO_CITY_FLAT2_1F"}, + {11,14,"SA_RUSTBORO_CITY_FLAT2_2F","RustboroCity_Flat2_2F","MAP_RUSTBORO_CITY_FLAT2_2F"}, + {11,15,"SA_RUSTBORO_CITY_FLAT2_3F","RustboroCity_Flat2_3F","MAP_RUSTBORO_CITY_FLAT2_3F"}, + {11,16,"SA_RUSTBORO_CITY_HOUSE3","RustboroCity_House3","MAP_RUSTBORO_CITY_HOUSE3"}, + {12,0,"SA_FORTREE_CITY_HOUSE1","FortreeCity_House1","MAP_FORTREE_CITY_HOUSE1"}, + {12,1,"SA_FORTREE_CITY_GYM","FortreeCity_Gym","MAP_FORTREE_CITY_GYM"}, + {12,2,"SA_FORTREE_CITY_POKEMON_CENTER_1F","FortreeCity_PokemonCenter_1F","MAP_FORTREE_CITY_POKEMON_CENTER_1F"}, + {12,3,"SA_FORTREE_CITY_POKEMON_CENTER_2F","FortreeCity_PokemonCenter_2F","MAP_FORTREE_CITY_POKEMON_CENTER_2F"}, + {12,4,"SA_FORTREE_CITY_MART","FortreeCity_Mart","MAP_FORTREE_CITY_MART"}, + {12,5,"SA_FORTREE_CITY_HOUSE2","FortreeCity_House2","MAP_FORTREE_CITY_HOUSE2"}, + {12,6,"SA_FORTREE_CITY_HOUSE3","FortreeCity_House3","MAP_FORTREE_CITY_HOUSE3"}, + {12,7,"SA_FORTREE_CITY_HOUSE4","FortreeCity_House4","MAP_FORTREE_CITY_HOUSE4"}, + {12,8,"SA_FORTREE_CITY_HOUSE5","FortreeCity_House5","MAP_FORTREE_CITY_HOUSE5"}, + {12,9,"SA_FORTREE_CITY_DECORATION_SHOP","FortreeCity_DecorationShop","MAP_FORTREE_CITY_DECORATION_SHOP"}, + {13,0,"SA_LILYCOVE_CITY_COVE_LILY_MOTEL_1F","LilycoveCity_CoveLilyMotel_1F","MAP_LILYCOVE_CITY_COVE_LILY_MOTEL_1F"}, + {13,1,"SA_LILYCOVE_CITY_COVE_LILY_MOTEL_2F","LilycoveCity_CoveLilyMotel_2F","MAP_LILYCOVE_CITY_COVE_LILY_MOTEL_2F"}, + {13,2,"SA_LILYCOVE_CITY_LILYCOVE_MUSEUM_1F","LilycoveCity_LilycoveMuseum_1F","MAP_LILYCOVE_CITY_LILYCOVE_MUSEUM_1F"}, + {13,3,"SA_LILYCOVE_CITY_LILYCOVE_MUSEUM_2F","LilycoveCity_LilycoveMuseum_2F","MAP_LILYCOVE_CITY_LILYCOVE_MUSEUM_2F"}, + {13,4,"SA_LILYCOVE_CITY_CONTEST_LOBBY","LilycoveCity_ContestLobby","MAP_LILYCOVE_CITY_CONTEST_LOBBY"}, + {13,5,"SA_LILYCOVE_CITY_CONTEST_HALL","LilycoveCity_ContestHall","MAP_LILYCOVE_CITY_CONTEST_HALL"}, + {13,6,"SA_LILYCOVE_CITY_POKEMON_CENTER_1F","LilycoveCity_PokemonCenter_1F","MAP_LILYCOVE_CITY_POKEMON_CENTER_1F"}, + {13,7,"SA_LILYCOVE_CITY_POKEMON_CENTER_2F","LilycoveCity_PokemonCenter_2F","MAP_LILYCOVE_CITY_POKEMON_CENTER_2F"}, + {13,8,"SA_LILYCOVE_CITY_UNUSED_MART","LilycoveCity_UnusedMart","MAP_LILYCOVE_CITY_UNUSED_MART"}, + {13,9,"SA_LILYCOVE_CITY_POKEMON_TRAINER_FAN_CLUB","LilycoveCity_PokemonTrainerFanClub","MAP_LILYCOVE_CITY_POKEMON_TRAINER_FAN_CLUB"}, + {13,10,"SA_LILYCOVE_CITY_HARBOR","LilycoveCity_Harbor","MAP_LILYCOVE_CITY_HARBOR"}, + {13,11,"SA_LILYCOVE_CITY_EMPTY_MAP","LilycoveCity_EmptyMap","MAP_LILYCOVE_CITY_EMPTY_MAP"}, + {13,12,"SA_LILYCOVE_CITY_MOVE_DELETERS_HOUSE","LilycoveCity_MoveDeletersHouse","MAP_LILYCOVE_CITY_MOVE_DELETERS_HOUSE"}, + {13,13,"SA_LILYCOVE_CITY_HOUSE1","LilycoveCity_House1","MAP_LILYCOVE_CITY_HOUSE1"}, + {13,14,"SA_LILYCOVE_CITY_HOUSE2","LilycoveCity_House2","MAP_LILYCOVE_CITY_HOUSE2"}, + {13,15,"SA_LILYCOVE_CITY_HOUSE3","LilycoveCity_House3","MAP_LILYCOVE_CITY_HOUSE3"}, + {13,16,"SA_LILYCOVE_CITY_HOUSE4","LilycoveCity_House4","MAP_LILYCOVE_CITY_HOUSE4"}, + {13,17,"SA_LILYCOVE_CITY_DEPARTMENT_STORE_1F","LilycoveCity_DepartmentStore_1F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_1F"}, + {13,18,"SA_LILYCOVE_CITY_DEPARTMENT_STORE_2F","LilycoveCity_DepartmentStore_2F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_2F"}, + {13,19,"SA_LILYCOVE_CITY_DEPARTMENT_STORE_3F","LilycoveCity_DepartmentStore_3F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_3F"}, + {13,20,"SA_LILYCOVE_CITY_DEPARTMENT_STORE_4F","LilycoveCity_DepartmentStore_4F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_4F"}, + {13,21,"SA_LILYCOVE_CITY_DEPARTMENT_STORE_5F","LilycoveCity_DepartmentStore_5F","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_5F"}, + {13,22,"SA_LILYCOVE_CITY_DEPARTMENT_STORE_ROOFTOP","LilycoveCity_DepartmentStoreRooftop","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_ROOFTOP"}, + {13,23,"SA_LILYCOVE_CITY_DEPARTMENT_STORE_ELEVATOR","LilycoveCity_DepartmentStoreElevator","MAP_LILYCOVE_CITY_DEPARTMENT_STORE_ELEVATOR"}, + {14,0,"SA_MOSSDEEP_CITY_GYM","MossdeepCity_Gym","MAP_MOSSDEEP_CITY_GYM"}, + {14,1,"SA_MOSSDEEP_CITY_HOUSE1","MossdeepCity_House1","MAP_MOSSDEEP_CITY_HOUSE1"}, + {14,2,"SA_MOSSDEEP_CITY_HOUSE2","MossdeepCity_House2","MAP_MOSSDEEP_CITY_HOUSE2"}, + {14,3,"SA_MOSSDEEP_CITY_POKEMON_CENTER_1F","MossdeepCity_PokemonCenter_1F","MAP_MOSSDEEP_CITY_POKEMON_CENTER_1F"}, + {14,4,"SA_MOSSDEEP_CITY_POKEMON_CENTER_2F","MossdeepCity_PokemonCenter_2F","MAP_MOSSDEEP_CITY_POKEMON_CENTER_2F"}, + {14,5,"SA_MOSSDEEP_CITY_MART","MossdeepCity_Mart","MAP_MOSSDEEP_CITY_MART"}, + {14,6,"SA_MOSSDEEP_CITY_HOUSE3","MossdeepCity_House3","MAP_MOSSDEEP_CITY_HOUSE3"}, + {14,7,"SA_MOSSDEEP_CITY_STEVENS_HOUSE","MossdeepCity_StevensHouse","MAP_MOSSDEEP_CITY_STEVENS_HOUSE"}, + {14,8,"SA_MOSSDEEP_CITY_HOUSE4","MossdeepCity_House4","MAP_MOSSDEEP_CITY_HOUSE4"}, + {14,9,"SA_MOSSDEEP_CITY_SPACE_CENTER_1F","MossdeepCity_SpaceCenter_1F","MAP_MOSSDEEP_CITY_SPACE_CENTER_1F"}, + {14,10,"SA_MOSSDEEP_CITY_SPACE_CENTER_2F","MossdeepCity_SpaceCenter_2F","MAP_MOSSDEEP_CITY_SPACE_CENTER_2F"}, + {14,11,"SA_MOSSDEEP_CITY_GAME_CORNER_1F","MossdeepCity_GameCorner_1F","MAP_MOSSDEEP_CITY_GAME_CORNER_1F"}, + {14,12,"SA_MOSSDEEP_CITY_GAME_CORNER_B1F","MossdeepCity_GameCorner_B1F","MAP_MOSSDEEP_CITY_GAME_CORNER_B1F"}, + {15,0,"SA_SOOTOPOLIS_CITY_GYM_1F","SootopolisCity_Gym_1F","MAP_SOOTOPOLIS_CITY_GYM_1F"}, + {15,1,"SA_SOOTOPOLIS_CITY_GYM_B1F","SootopolisCity_Gym_B1F","MAP_SOOTOPOLIS_CITY_GYM_B1F"}, + {15,2,"SA_SOOTOPOLIS_CITY_POKEMON_CENTER_1F","SootopolisCity_PokemonCenter_1F","MAP_SOOTOPOLIS_CITY_POKEMON_CENTER_1F"}, + {15,3,"SA_SOOTOPOLIS_CITY_POKEMON_CENTER_2F","SootopolisCity_PokemonCenter_2F","MAP_SOOTOPOLIS_CITY_POKEMON_CENTER_2F"}, + {15,4,"SA_SOOTOPOLIS_CITY_MART","SootopolisCity_Mart","MAP_SOOTOPOLIS_CITY_MART"}, + {15,5,"SA_SOOTOPOLIS_CITY_HOUSE1","SootopolisCity_House1","MAP_SOOTOPOLIS_CITY_HOUSE1"}, + {15,6,"SA_SOOTOPOLIS_CITY_HOUSE2","SootopolisCity_House2","MAP_SOOTOPOLIS_CITY_HOUSE2"}, + {15,7,"SA_SOOTOPOLIS_CITY_HOUSE3","SootopolisCity_House3","MAP_SOOTOPOLIS_CITY_HOUSE3"}, + {15,8,"SA_SOOTOPOLIS_CITY_HOUSE4","SootopolisCity_House4","MAP_SOOTOPOLIS_CITY_HOUSE4"}, + {15,9,"SA_SOOTOPOLIS_CITY_HOUSE5","SootopolisCity_House5","MAP_SOOTOPOLIS_CITY_HOUSE5"}, + {15,10,"SA_SOOTOPOLIS_CITY_HOUSE6","SootopolisCity_House6","MAP_SOOTOPOLIS_CITY_HOUSE6"}, + {15,11,"SA_SOOTOPOLIS_CITY_HOUSE7","SootopolisCity_House7","MAP_SOOTOPOLIS_CITY_HOUSE7"}, + {15,12,"SA_SOOTOPOLIS_CITY_HOUSE8","SootopolisCity_House8","MAP_SOOTOPOLIS_CITY_HOUSE8"}, + {16,0,"SA_EVER_GRANDE_CITY_SIDNEYS_ROOM","EverGrandeCity_SidneysRoom","MAP_EVER_GRANDE_CITY_SIDNEYS_ROOM"}, + {16,1,"SA_EVER_GRANDE_CITY_PHOEBES_ROOM","EverGrandeCity_PhoebesRoom","MAP_EVER_GRANDE_CITY_PHOEBES_ROOM"}, + {16,2,"SA_EVER_GRANDE_CITY_GLACIAS_ROOM","EverGrandeCity_GlaciasRoom","MAP_EVER_GRANDE_CITY_GLACIAS_ROOM"}, + {16,3,"SA_EVER_GRANDE_CITY_DRAKES_ROOM","EverGrandeCity_DrakesRoom","MAP_EVER_GRANDE_CITY_DRAKES_ROOM"}, + {16,4,"SA_EVER_GRANDE_CITY_CHAMPIONS_ROOM","EverGrandeCity_ChampionsRoom","MAP_EVER_GRANDE_CITY_CHAMPIONS_ROOM"}, + {16,5,"SA_EVER_GRANDE_CITY_CORRIDOR1","EverGrandeCity_Corridor1","MAP_EVER_GRANDE_CITY_CORRIDOR1"}, + {16,6,"SA_EVER_GRANDE_CITY_CORRIDOR2","EverGrandeCity_Corridor2","MAP_EVER_GRANDE_CITY_CORRIDOR2"}, + {16,7,"SA_EVER_GRANDE_CITY_CORRIDOR3","EverGrandeCity_Corridor3","MAP_EVER_GRANDE_CITY_CORRIDOR3"}, + {16,8,"SA_EVER_GRANDE_CITY_CORRIDOR4","EverGrandeCity_Corridor4","MAP_EVER_GRANDE_CITY_CORRIDOR4"}, + {16,9,"SA_EVER_GRANDE_CITY_CORRIDOR5","EverGrandeCity_Corridor5","MAP_EVER_GRANDE_CITY_CORRIDOR5"}, + {16,10,"SA_EVER_GRANDE_CITY_POKEMON_LEAGUE","EverGrandeCity_PokemonLeague","MAP_EVER_GRANDE_CITY_POKEMON_LEAGUE"}, + {16,11,"SA_EVER_GRANDE_CITY_HALL_OF_FAME","EverGrandeCity_HallOfFame","MAP_EVER_GRANDE_CITY_HALL_OF_FAME"}, + {16,12,"SA_EVER_GRANDE_CITY_POKEMON_CENTER_1F","EverGrandeCity_PokemonCenter_1F","MAP_EVER_GRANDE_CITY_POKEMON_CENTER_1F"}, + {16,13,"SA_EVER_GRANDE_CITY_POKEMON_CENTER_2F","EverGrandeCity_PokemonCenter_2F","MAP_EVER_GRANDE_CITY_POKEMON_CENTER_2F"}, + {17,0,"SA_ROUTE104_MR_BRINEYS_HOUSE","Route104_MrBrineysHouse","MAP_ROUTE104_MR_BRINEYS_HOUSE"}, + {17,1,"SA_ROUTE104_PRETTY_PETAL_FLOWER_SHOP","Route104_PrettyPetalFlowerShop","MAP_ROUTE104_PRETTY_PETAL_FLOWER_SHOP"}, + {18,0,"SA_ROUTE111_WINSTRATE_FAMILYS_HOUSE","Route111_WinstrateFamilysHouse","MAP_ROUTE111_WINSTRATE_FAMILYS_HOUSE"}, + {18,1,"SA_ROUTE111_OLD_LADYS_REST_STOP","Route111_OldLadysRestStop","MAP_ROUTE111_OLD_LADYS_REST_STOP"}, + {19,0,"SA_ROUTE112_CABLE_CAR_STATION","Route112_CableCarStation","MAP_ROUTE112_CABLE_CAR_STATION"}, + {19,1,"SA_MT_CHIMNEY_CABLE_CAR_STATION","MtChimney_CableCarStation","MAP_MT_CHIMNEY_CABLE_CAR_STATION"}, + {20,0,"SA_ROUTE114_FOSSIL_MANIACS_HOUSE","Route114_FossilManiacsHouse","MAP_ROUTE114_FOSSIL_MANIACS_HOUSE"}, + {20,1,"SA_ROUTE114_FOSSIL_MANIACS_TUNNEL","Route114_FossilManiacsTunnel","MAP_ROUTE114_FOSSIL_MANIACS_TUNNEL"}, + {20,2,"SA_ROUTE114_LANETTES_HOUSE","Route114_LanettesHouse","MAP_ROUTE114_LANETTES_HOUSE"}, + {21,0,"SA_ROUTE116_TUNNELERS_REST_HOUSE","Route116_TunnelersRestHouse","MAP_ROUTE116_TUNNELERS_REST_HOUSE"}, + {22,0,"SA_ROUTE117_POKEMON_DAY_CARE","Route117_PokemonDayCare","MAP_ROUTE117_POKEMON_DAY_CARE"}, + {23,0,"SA_ROUTE121_SAFARI_ZONE_ENTRANCE","Route121_SafariZoneEntrance","MAP_ROUTE121_SAFARI_ZONE_ENTRANCE"}, + {24,0,"SA_METEOR_FALLS_1F_1R","MeteorFalls_1F_1R","MAP_METEOR_FALLS_1F_1R"}, + {24,1,"SA_METEOR_FALLS_1F_2R","MeteorFalls_1F_2R","MAP_METEOR_FALLS_1F_2R"}, + {24,2,"SA_METEOR_FALLS_B1F_1R","MeteorFalls_B1F_1R","MAP_METEOR_FALLS_B1F_1R"}, + {24,3,"SA_METEOR_FALLS_B1F_2R","MeteorFalls_B1F_2R","MAP_METEOR_FALLS_B1F_2R"}, + {24,4,"SA_RUSTURF_TUNNEL","RusturfTunnel","MAP_RUSTURF_TUNNEL"}, + {24,5,"SA_UNDERWATER_SOOTOPOLIS_CITY","Underwater_SootopolisCity","MAP_UNDERWATER_SOOTOPOLIS_CITY"}, + {24,6,"SA_DESERT_RUINS","DesertRuins","MAP_DESERT_RUINS"}, + {24,7,"SA_GRANITE_CAVE_1F","GraniteCave_1F","MAP_GRANITE_CAVE_1F"}, + {24,8,"SA_GRANITE_CAVE_B1F","GraniteCave_B1F","MAP_GRANITE_CAVE_B1F"}, + {24,9,"SA_GRANITE_CAVE_B2F","GraniteCave_B2F","MAP_GRANITE_CAVE_B2F"}, + {24,10,"SA_GRANITE_CAVE_STEVENS_ROOM","GraniteCave_StevensRoom","MAP_GRANITE_CAVE_STEVENS_ROOM"}, + {24,11,"SA_PETALBURG_WOODS","PetalburgWoods","MAP_PETALBURG_WOODS"}, + {24,12,"SA_MT_CHIMNEY","MtChimney","MAP_MT_CHIMNEY"}, + {24,13,"SA_JAGGED_PASS","JaggedPass","MAP_JAGGED_PASS"}, + {24,14,"SA_FIERY_PATH","FieryPath","MAP_FIERY_PATH"}, + {24,15,"SA_MT_PYRE_1F","MtPyre_1F","MAP_MT_PYRE_1F"}, + {24,16,"SA_MT_PYRE_2F","MtPyre_2F","MAP_MT_PYRE_2F"}, + {24,17,"SA_MT_PYRE_3F","MtPyre_3F","MAP_MT_PYRE_3F"}, + {24,18,"SA_MT_PYRE_4F","MtPyre_4F","MAP_MT_PYRE_4F"}, + {24,19,"SA_MT_PYRE_5F","MtPyre_5F","MAP_MT_PYRE_5F"}, + {24,20,"SA_MT_PYRE_6F","MtPyre_6F","MAP_MT_PYRE_6F"}, + {24,21,"SA_MT_PYRE_EXTERIOR","MtPyre_Exterior","MAP_MT_PYRE_EXTERIOR"}, + {24,22,"SA_MT_PYRE_SUMMIT","MtPyre_Summit","MAP_MT_PYRE_SUMMIT"}, + {24,23,"SA_AQUA_HIDEOUT_1F","AquaHideout_1F","MAP_AQUA_HIDEOUT_1F"}, + {24,24,"SA_AQUA_HIDEOUT_B1F","AquaHideout_B1F","MAP_AQUA_HIDEOUT_B1F"}, + {24,25,"SA_AQUA_HIDEOUT_B2F","AquaHideout_B2F","MAP_AQUA_HIDEOUT_B2F"}, + {24,26,"SA_UNDERWATER_SEAFLOOR_CAVERN","Underwater_SeafloorCavern","MAP_UNDERWATER_SEAFLOOR_CAVERN"}, + {24,27,"SA_SEAFLOOR_CAVERN_ENTRANCE","SeafloorCavern_Entrance","MAP_SEAFLOOR_CAVERN_ENTRANCE"}, + {24,28,"SA_SEAFLOOR_CAVERN_ROOM1","SeafloorCavern_Room1","MAP_SEAFLOOR_CAVERN_ROOM1"}, + {24,29,"SA_SEAFLOOR_CAVERN_ROOM2","SeafloorCavern_Room2","MAP_SEAFLOOR_CAVERN_ROOM2"}, + {24,30,"SA_SEAFLOOR_CAVERN_ROOM3","SeafloorCavern_Room3","MAP_SEAFLOOR_CAVERN_ROOM3"}, + {24,31,"SA_SEAFLOOR_CAVERN_ROOM4","SeafloorCavern_Room4","MAP_SEAFLOOR_CAVERN_ROOM4"}, + {24,32,"SA_SEAFLOOR_CAVERN_ROOM5","SeafloorCavern_Room5","MAP_SEAFLOOR_CAVERN_ROOM5"}, + {24,33,"SA_SEAFLOOR_CAVERN_ROOM6","SeafloorCavern_Room6","MAP_SEAFLOOR_CAVERN_ROOM6"}, + {24,34,"SA_SEAFLOOR_CAVERN_ROOM7","SeafloorCavern_Room7","MAP_SEAFLOOR_CAVERN_ROOM7"}, + {24,35,"SA_SEAFLOOR_CAVERN_ROOM8","SeafloorCavern_Room8","MAP_SEAFLOOR_CAVERN_ROOM8"}, + {24,36,"SA_SEAFLOOR_CAVERN_ROOM9","SeafloorCavern_Room9","MAP_SEAFLOOR_CAVERN_ROOM9"}, + {24,37,"SA_CAVE_OF_ORIGIN_ENTRANCE","CaveOfOrigin_Entrance","MAP_CAVE_OF_ORIGIN_ENTRANCE"}, + {24,38,"SA_CAVE_OF_ORIGIN_1F","CaveOfOrigin_1F","MAP_CAVE_OF_ORIGIN_1F"}, + {24,39,"SA_CAVE_OF_ORIGIN_B1F","CaveOfOrigin_B1F","MAP_CAVE_OF_ORIGIN_B1F"}, + {24,40,"SA_CAVE_OF_ORIGIN_B2F","CaveOfOrigin_B2F","MAP_CAVE_OF_ORIGIN_B2F"}, + {24,41,"SA_CAVE_OF_ORIGIN_B3F","CaveOfOrigin_B3F","MAP_CAVE_OF_ORIGIN_B3F"}, + {24,42,"SA_CAVE_OF_ORIGIN_B4F","CaveOfOrigin_B4F","MAP_CAVE_OF_ORIGIN_B4F"}, + {24,43,"SA_VICTORY_ROAD_1F","VictoryRoad_1F","MAP_VICTORY_ROAD_1F"}, + {24,44,"SA_VICTORY_ROAD_B1F","VictoryRoad_B1F","MAP_VICTORY_ROAD_B1F"}, + {24,45,"SA_VICTORY_ROAD_B2F","VictoryRoad_B2F","MAP_VICTORY_ROAD_B2F"}, + {24,46,"SA_SHOAL_CAVE_LOW_TIDE_ENTRANCE_ROOM","ShoalCave_LowTideEntranceRoom","MAP_SHOAL_CAVE_LOW_TIDE_ENTRANCE_ROOM"}, + {24,47,"SA_SHOAL_CAVE_LOW_TIDE_INNER_ROOM","ShoalCave_LowTideInnerRoom","MAP_SHOAL_CAVE_LOW_TIDE_INNER_ROOM"}, + {24,48,"SA_SHOAL_CAVE_LOW_TIDE_STAIRS_ROOM","ShoalCave_LowTideStairsRoom","MAP_SHOAL_CAVE_LOW_TIDE_STAIRS_ROOM"}, + {24,49,"SA_SHOAL_CAVE_LOW_TIDE_LOWER_ROOM","ShoalCave_LowTideLowerRoom","MAP_SHOAL_CAVE_LOW_TIDE_LOWER_ROOM"}, + {24,50,"SA_SHOAL_CAVE_HIGH_TIDE_ENTRANCE_ROOM","ShoalCave_HighTideEntranceRoom","MAP_SHOAL_CAVE_HIGH_TIDE_ENTRANCE_ROOM"}, + {24,51,"SA_SHOAL_CAVE_HIGH_TIDE_INNER_ROOM","ShoalCave_HighTideInnerRoom","MAP_SHOAL_CAVE_HIGH_TIDE_INNER_ROOM"}, + {24,52,"SA_NEW_MAUVILLE_ENTRANCE","NewMauville_Entrance","MAP_NEW_MAUVILLE_ENTRANCE"}, + {24,53,"SA_NEW_MAUVILLE_INSIDE","NewMauville_Inside","MAP_NEW_MAUVILLE_INSIDE"}, + {24,54,"SA_ABANDONED_SHIP_DECK","AbandonedShip_Deck","MAP_ABANDONED_SHIP_DECK"}, + {24,55,"SA_ABANDONED_SHIP_CORRIDORS_1F","AbandonedShip_Corridors_1F","MAP_ABANDONED_SHIP_CORRIDORS_1F"}, + {24,56,"SA_ABANDONED_SHIP_ROOMS_1F","AbandonedShip_Rooms_1F","MAP_ABANDONED_SHIP_ROOMS_1F"}, + {24,57,"SA_ABANDONED_SHIP_CORRIDORS_B1F","AbandonedShip_Corridors_B1F","MAP_ABANDONED_SHIP_CORRIDORS_B1F"}, + {24,58,"SA_ABANDONED_SHIP_ROOMS_B1F","AbandonedShip_Rooms_B1F","MAP_ABANDONED_SHIP_ROOMS_B1F"}, + {24,59,"SA_ABANDONED_SHIP_ROOMS2_B1F","AbandonedShip_Rooms2_B1F","MAP_ABANDONED_SHIP_ROOMS2_B1F"}, + {24,60,"SA_ABANDONED_SHIP_UNDERWATER1","AbandonedShip_Underwater1","MAP_ABANDONED_SHIP_UNDERWATER1"}, + {24,61,"SA_ABANDONED_SHIP_ROOM_B1F","AbandonedShip_Room_B1F","MAP_ABANDONED_SHIP_ROOM_B1F"}, + {24,62,"SA_ABANDONED_SHIP_ROOMS2_1F","AbandonedShip_Rooms2_1F","MAP_ABANDONED_SHIP_ROOMS2_1F"}, + {24,63,"SA_ABANDONED_SHIP_CAPTAINS_OFFICE","AbandonedShip_CaptainsOffice","MAP_ABANDONED_SHIP_CAPTAINS_OFFICE"}, + {24,64,"SA_ABANDONED_SHIP_UNDERWATER2","AbandonedShip_Underwater2","MAP_ABANDONED_SHIP_UNDERWATER2"}, + {24,65,"SA_ABANDONED_SHIP_HIDDEN_FLOOR_CORRIDORS","AbandonedShip_HiddenFloorCorridors","MAP_ABANDONED_SHIP_HIDDEN_FLOOR_CORRIDORS"}, + {24,66,"SA_ABANDONED_SHIP_HIDDEN_FLOOR_ROOMS","AbandonedShip_HiddenFloorRooms","MAP_ABANDONED_SHIP_HIDDEN_FLOOR_ROOMS"}, + {24,67,"SA_ISLAND_CAVE","IslandCave","MAP_ISLAND_CAVE"}, + {24,68,"SA_ANCIENT_TOMB","AncientTomb","MAP_ANCIENT_TOMB"}, + {24,69,"SA_UNDERWATER_ROUTE134","Underwater_Route134","MAP_UNDERWATER_ROUTE134"}, + {24,70,"SA_UNDERWATER_SEALED_CHAMBER","Underwater_SealedChamber","MAP_UNDERWATER_SEALED_CHAMBER"}, + {24,71,"SA_SEALED_CHAMBER_OUTER_ROOM","SealedChamber_OuterRoom","MAP_SEALED_CHAMBER_OUTER_ROOM"}, + {24,72,"SA_SEALED_CHAMBER_INNER_ROOM","SealedChamber_InnerRoom","MAP_SEALED_CHAMBER_INNER_ROOM"}, + {24,73,"SA_SCORCHED_SLAB","ScorchedSlab","MAP_SCORCHED_SLAB"}, + {24,74,"SA_MAGMA_HIDEOUT_1F","MagmaHideout_1F","MAP_MAGMA_HIDEOUT_1F"}, + {24,75,"SA_MAGMA_HIDEOUT_B1F","MagmaHideout_B1F","MAP_MAGMA_HIDEOUT_B1F"}, + {24,76,"SA_MAGMA_HIDEOUT_B2F","MagmaHideout_B2F","MAP_MAGMA_HIDEOUT_B2F"}, + {24,77,"SA_SKY_PILLAR_ENTRANCE","SkyPillar_Entrance","MAP_SKY_PILLAR_ENTRANCE"}, + {24,78,"SA_SKY_PILLAR_OUTSIDE","SkyPillar_Outside","MAP_SKY_PILLAR_OUTSIDE"}, + {24,79,"SA_SKY_PILLAR_1F","SkyPillar_1F","MAP_SKY_PILLAR_1F"}, + {24,80,"SA_SKY_PILLAR_2F","SkyPillar_2F","MAP_SKY_PILLAR_2F"}, + {24,81,"SA_SKY_PILLAR_3F","SkyPillar_3F","MAP_SKY_PILLAR_3F"}, + {24,82,"SA_SKY_PILLAR_4F","SkyPillar_4F","MAP_SKY_PILLAR_4F"}, + {24,83,"SA_SHOAL_CAVE_LOW_TIDE_ICE_ROOM","ShoalCave_LowTideIceRoom","MAP_SHOAL_CAVE_LOW_TIDE_ICE_ROOM"}, + {24,84,"SA_SKY_PILLAR_5F","SkyPillar_5F","MAP_SKY_PILLAR_5F"}, + {24,85,"SA_SKY_PILLAR_TOP","SkyPillar_Top","MAP_SKY_PILLAR_TOP"}, + {25,0,"SA_SECRET_BASE_RED_CAVE1","SecretBase_RedCave1","MAP_SECRET_BASE_RED_CAVE1"}, + {25,1,"SA_SECRET_BASE_BROWN_CAVE1","SecretBase_BrownCave1","MAP_SECRET_BASE_BROWN_CAVE1"}, + {25,2,"SA_SECRET_BASE_BLUE_CAVE1","SecretBase_BlueCave1","MAP_SECRET_BASE_BLUE_CAVE1"}, + {25,3,"SA_SECRET_BASE_YELLOW_CAVE1","SecretBase_YellowCave1","MAP_SECRET_BASE_YELLOW_CAVE1"}, + {25,4,"SA_SECRET_BASE_TREE1","SecretBase_Tree1","MAP_SECRET_BASE_TREE1"}, + {25,5,"SA_SECRET_BASE_SHRUB1","SecretBase_Shrub1","MAP_SECRET_BASE_SHRUB1"}, + {25,6,"SA_SECRET_BASE_RED_CAVE2","SecretBase_RedCave2","MAP_SECRET_BASE_RED_CAVE2"}, + {25,7,"SA_SECRET_BASE_BROWN_CAVE2","SecretBase_BrownCave2","MAP_SECRET_BASE_BROWN_CAVE2"}, + {25,8,"SA_SECRET_BASE_BLUE_CAVE2","SecretBase_BlueCave2","MAP_SECRET_BASE_BLUE_CAVE2"}, + {25,9,"SA_SECRET_BASE_YELLOW_CAVE2","SecretBase_YellowCave2","MAP_SECRET_BASE_YELLOW_CAVE2"}, + {25,10,"SA_SECRET_BASE_TREE2","SecretBase_Tree2","MAP_SECRET_BASE_TREE2"}, + {25,11,"SA_SECRET_BASE_SHRUB2","SecretBase_Shrub2","MAP_SECRET_BASE_SHRUB2"}, + {25,12,"SA_SECRET_BASE_RED_CAVE3","SecretBase_RedCave3","MAP_SECRET_BASE_RED_CAVE3"}, + {25,13,"SA_SECRET_BASE_BROWN_CAVE3","SecretBase_BrownCave3","MAP_SECRET_BASE_BROWN_CAVE3"}, + {25,14,"SA_SECRET_BASE_BLUE_CAVE3","SecretBase_BlueCave3","MAP_SECRET_BASE_BLUE_CAVE3"}, + {25,15,"SA_SECRET_BASE_YELLOW_CAVE3","SecretBase_YellowCave3","MAP_SECRET_BASE_YELLOW_CAVE3"}, + {25,16,"SA_SECRET_BASE_TREE3","SecretBase_Tree3","MAP_SECRET_BASE_TREE3"}, + {25,17,"SA_SECRET_BASE_SHRUB3","SecretBase_Shrub3","MAP_SECRET_BASE_SHRUB3"}, + {25,18,"SA_SECRET_BASE_RED_CAVE4","SecretBase_RedCave4","MAP_SECRET_BASE_RED_CAVE4"}, + {25,19,"SA_SECRET_BASE_BROWN_CAVE4","SecretBase_BrownCave4","MAP_SECRET_BASE_BROWN_CAVE4"}, + {25,20,"SA_SECRET_BASE_BLUE_CAVE4","SecretBase_BlueCave4","MAP_SECRET_BASE_BLUE_CAVE4"}, + {25,21,"SA_SECRET_BASE_YELLOW_CAVE4","SecretBase_YellowCave4","MAP_SECRET_BASE_YELLOW_CAVE4"}, + {25,22,"SA_SECRET_BASE_TREE4","SecretBase_Tree4","MAP_SECRET_BASE_TREE4"}, + {25,23,"SA_SECRET_BASE_SHRUB4","SecretBase_Shrub4","MAP_SECRET_BASE_SHRUB4"}, + {25,24,"SA_SINGLE_BATTLE_COLOSSEUM","SingleBattleColosseum","MAP_SINGLE_BATTLE_COLOSSEUM"}, + {25,25,"SA_TRADE_CENTER","TradeCenter","MAP_TRADE_CENTER"}, + {25,26,"SA_RECORD_CORNER","RecordCorner","MAP_RECORD_CORNER"}, + {25,27,"SA_DOUBLE_BATTLE_COLOSSEUM","DoubleBattleColosseum","MAP_DOUBLE_BATTLE_COLOSSEUM"}, + {25,28,"SA_LINK_CONTEST_ROOM1","LinkContestRoom1","MAP_LINK_CONTEST_ROOM1"}, + {25,29,"SA_UNKNOWN_MAP_25_29","UnknownMap_25_29","MAP_UNKNOWN_MAP_25_29"}, + {25,30,"SA_UNKNOWN_MAP_25_30","UnknownMap_25_30","MAP_UNKNOWN_MAP_25_30"}, + {25,31,"SA_UNKNOWN_MAP_25_31","UnknownMap_25_31","MAP_UNKNOWN_MAP_25_31"}, + {25,32,"SA_UNKNOWN_MAP_25_32","UnknownMap_25_32","MAP_UNKNOWN_MAP_25_32"}, + {25,33,"SA_UNKNOWN_MAP_25_33","UnknownMap_25_33","MAP_UNKNOWN_MAP_25_33"}, + {25,34,"SA_UNKNOWN_MAP_25_34","UnknownMap_25_34","MAP_UNKNOWN_MAP_25_34"}, + {25,35,"SA_LINK_CONTEST_ROOM2","LinkContestRoom2","MAP_LINK_CONTEST_ROOM2"}, + {25,36,"SA_LINK_CONTEST_ROOM3","LinkContestRoom3","MAP_LINK_CONTEST_ROOM3"}, + {25,37,"SA_LINK_CONTEST_ROOM4","LinkContestRoom4","MAP_LINK_CONTEST_ROOM4"}, + {25,38,"SA_LINK_CONTEST_ROOM5","LinkContestRoom5","MAP_LINK_CONTEST_ROOM5"}, + {25,39,"SA_LINK_CONTEST_ROOM6","LinkContestRoom6","MAP_LINK_CONTEST_ROOM6"}, + {25,40,"SA_INSIDE_OF_TRUCK","InsideOfTruck","MAP_INSIDE_OF_TRUCK"}, + {25,41,"SA_SS_TIDAL_CORRIDOR","SSTidalCorridor","MAP_SS_TIDAL_CORRIDOR"}, + {25,42,"SA_SS_TIDAL_LOWER_DECK","SSTidalLowerDeck","MAP_SS_TIDAL_LOWER_DECK"}, + {25,43,"SA_SS_TIDAL_ROOMS","SSTidalRooms","MAP_SS_TIDAL_ROOMS"}, + {26,0,"SA_SAFARI_ZONE_NORTHWEST","SafariZone_Northwest","MAP_SAFARI_ZONE_NORTHWEST"}, + {26,1,"SA_SAFARI_ZONE_NORTHEAST","SafariZone_Northeast","MAP_SAFARI_ZONE_NORTHEAST"}, + {26,2,"SA_SAFARI_ZONE_SOUTHWEST","SafariZone_Southwest","MAP_SAFARI_ZONE_SOUTHWEST"}, + {26,3,"SA_SAFARI_ZONE_SOUTHEAST","SafariZone_Southeast","MAP_SAFARI_ZONE_SOUTHEAST"}, + {26,4,"SA_BATTLE_TOWER_OUTSIDE","BattleTower_Outside","MAP_BATTLE_TOWER_OUTSIDE"}, + {26,5,"SA_BATTLE_TOWER_LOBBY","BattleTower_Lobby","MAP_BATTLE_TOWER_LOBBY"}, + {26,6,"SA_BATTLE_TOWER_ELEVATOR","BattleTower_Elevator","MAP_BATTLE_TOWER_ELEVATOR"}, + {26,7,"SA_BATTLE_TOWER_CORRIDOR","BattleTower_Corridor","MAP_BATTLE_TOWER_CORRIDOR"}, + {26,8,"SA_BATTLE_TOWER_BATTLE_ROOM","BattleTower_BattleRoom","MAP_BATTLE_TOWER_BATTLE_ROOM"}, + {26,9,"SA_SOUTHERN_ISLAND_EXTERIOR","SouthernIsland_Exterior","MAP_SOUTHERN_ISLAND_EXTERIOR"}, + {26,10,"SA_SOUTHERN_ISLAND_INTERIOR","SouthernIsland_Interior","MAP_SOUTHERN_ISLAND_INTERIOR"}, + {26,11,"SA_SAFARI_ZONE_REST_HOUSE","SafariZone_RestHouse","MAP_SAFARI_ZONE_REST_HOUSE"}, + {27,0,"SA_ROUTE104_PROTOTYPE","Route104_Prototype","MAP_ROUTE104_PROTOTYPE"}, + {27,1,"SA_ROUTE104_PROTOTYPE_PRETTY_PETAL_FLOWER_SHOP","Route104_PrototypePrettyPetalFlowerShop","MAP_ROUTE104_PROTOTYPE_PRETTY_PETAL_FLOWER_SHOP"}, + {28,0,"SA_ROUTE109_SEASHORE_HOUSE","Route109_SeashoreHouse","MAP_ROUTE109_SEASHORE_HOUSE"}, + {29,0,"SA_ROUTE110_TRICK_HOUSE_ENTRANCE","Route110_TrickHouseEntrance","MAP_ROUTE110_TRICK_HOUSE_ENTRANCE"}, + {29,1,"SA_ROUTE110_TRICK_HOUSE_END","Route110_TrickHouseEnd","MAP_ROUTE110_TRICK_HOUSE_END"}, + {29,2,"SA_ROUTE110_TRICK_HOUSE_CORRIDOR","Route110_TrickHouseCorridor","MAP_ROUTE110_TRICK_HOUSE_CORRIDOR"}, + {29,3,"SA_ROUTE110_TRICK_HOUSE_PUZZLE1","Route110_TrickHousePuzzle1","MAP_ROUTE110_TRICK_HOUSE_PUZZLE1"}, + {29,4,"SA_ROUTE110_TRICK_HOUSE_PUZZLE2","Route110_TrickHousePuzzle2","MAP_ROUTE110_TRICK_HOUSE_PUZZLE2"}, + {29,5,"SA_ROUTE110_TRICK_HOUSE_PUZZLE3","Route110_TrickHousePuzzle3","MAP_ROUTE110_TRICK_HOUSE_PUZZLE3"}, + {29,6,"SA_ROUTE110_TRICK_HOUSE_PUZZLE4","Route110_TrickHousePuzzle4","MAP_ROUTE110_TRICK_HOUSE_PUZZLE4"}, + {29,7,"SA_ROUTE110_TRICK_HOUSE_PUZZLE5","Route110_TrickHousePuzzle5","MAP_ROUTE110_TRICK_HOUSE_PUZZLE5"}, + {29,8,"SA_ROUTE110_TRICK_HOUSE_PUZZLE6","Route110_TrickHousePuzzle6","MAP_ROUTE110_TRICK_HOUSE_PUZZLE6"}, + {29,9,"SA_ROUTE110_TRICK_HOUSE_PUZZLE7","Route110_TrickHousePuzzle7","MAP_ROUTE110_TRICK_HOUSE_PUZZLE7"}, + {29,10,"SA_ROUTE110_TRICK_HOUSE_PUZZLE8","Route110_TrickHousePuzzle8","MAP_ROUTE110_TRICK_HOUSE_PUZZLE8"}, + {29,11,"SA_ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE","Route110_SeasideCyclingRoadSouthEntrance","MAP_ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE"}, + {29,12,"SA_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE","Route110_SeasideCyclingRoadNorthEntrance","MAP_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE"}, + {30,0,"SA_ROUTE113_GLASS_WORKSHOP","Route113_GlassWorkshop","MAP_ROUTE113_GLASS_WORKSHOP"}, + {31,0,"SA_ROUTE123_BERRY_MASTERS_HOUSE","Route123_BerryMastersHouse","MAP_ROUTE123_BERRY_MASTERS_HOUSE"}, + {32,0,"SA_ROUTE119_WEATHER_INSTITUTE_1F","Route119_WeatherInstitute_1F","MAP_ROUTE119_WEATHER_INSTITUTE_1F"}, + {32,1,"SA_ROUTE119_WEATHER_INSTITUTE_2F","Route119_WeatherInstitute_2F","MAP_ROUTE119_WEATHER_INSTITUTE_2F"}, + {32,2,"SA_ROUTE119_HOUSE","Route119_House","MAP_ROUTE119_HOUSE"}, + {33,0,"SA_ROUTE124_DIVING_TREASURE_HUNTERS_HOUSE","Route124_DivingTreasureHuntersHouse","MAP_ROUTE124_DIVING_TREASURE_HUNTERS_HOUSE"}, + }, +} diff --git a/src/compat/gen1/MapRuntime.lua b/src/compat/gen1/MapRuntime.lua new file mode 100644 index 000000000..1d28a9588 --- /dev/null +++ b/src/compat/gen1/MapRuntime.lua @@ -0,0 +1,203 @@ +-- Compatibility map observations over one native session. Never loads a map, +-- runs a script/player, or lends native definitions to compatibility writers. +local Identity = require('src.compat.gen1.MapIdentity') +local PairIdentity = require('src.compat.gen1.PairIdentity') +local Views = require('src.compat.gen1.MapViews') +local Version = require('src.core.GameVersion') +local Connections = require('src.core.game3.connections') +local M = {} +local mapTypes = {NONE=0,TOWN=1,CITY=2,ROUTE=3,UNDERGROUND=4,UNDERWATER=5,OCEAN_ROUTE=6,UNKNOWN=7,INDOOR=8,SECRET_BASE=9} +local function numericBoolean(v) if v == nil then return nil end; return (v == true or v == 1) and 1 or 0 end +local function copy(v) + if type(v) ~= 'table' then return v end + local out = {}; for k, value in pairs(v) do out[k] = copy(value) end; return out +end +local overworld=require('src.compat.gen1.NativeSession').world +function M.new(getGame, getEdition) + assert(type(getGame)=='function' and type(getEdition)=='function','native session resolvers required') + local handle = {} + local views = Views.new(getGame,getEdition) + local observing=false + local owner,data,nativeMaps,edition,binding,pairBinding,epoch + local maps,known,published,loaded = {},setmetatable({},{__mode='k'}),{},{} + local ended,allIndexed = false,false + local function clear() + if observing or ended then + views:release();views=not ended and Views.new(getGame,getEdition)or nil;observing=false + end + if pairBinding then pairBinding:release() end + owner,data,nativeMaps,edition,binding,pairBinding,epoch=nil,nil,nil,nil,nil,nil,nil + maps,known,published,loaded={},setmetatable({},{__mode='k'}),{},{} + allIndexed=false + end + local function session() + if ended then return nil,'map runtime released' end + local game,id=getGame(),getEdition() + local info=Version.info(id) + if type(game)~='table' or not info or info.generation~=3 or not Identity.lookup(id,0,0) + or type(game.data)~='table' or type(game.data.maps)~='table' then + clear();return nil,'no native GBA session' + end + if game~=owner or data~=game.data or nativeMaps~=game.data.maps or edition~=id then + clear();owner,data,nativeMaps,edition=game,game.data,game.data.maps,id + binding=Identity.bind(nativeMaps,id);epoch={} + if type(data.tilesets)=='table' and type(data.map_tilesets)=='table' then + pairBinding=PairIdentity.bind(data.tilesets,data.map_tilesets,id) + end + end + return game + end + local function resolve(id) + local native,def=binding:nativeId(id) + if not native then + local canonical=binding:legacyId(id) + if canonical then native,def=binding:nativeId(canonical) end + end + if native then return native,def,binding:legacyId(native) end + return nil,'map identity unavailable or retired' + end + local function destination(id) + local canonical=binding:legacyId(id) + if canonical then return canonical end + if type(id)=='string' then + local g,n=id:match('^MAP_G(%d+)_N(%d+)$') + if g then return Identity.lookup(edition,tonumber(g),tonumber(n)) end + return Identity.lookup(edition,id) + end + end + function handle:definition(id) + local game,why=session();if not game then return nil,why end + local native,def,canonical=resolve(id);if not native then return nil,def end + local prior=maps[canonical] + if prior then return prior end + local pair=pairBinding and pairBinding:descriptor(def.tileset) + if not pair or type(pair.tileset)~='string'then return nil,'source tileset pair identity unavailable' end + local kind=mapTypes[def.mapType] or tonumber(def.mapType) or 0 + local indoor=kind==4 or kind==8 or kind==9 + local rows={} + for _,row in ipairs(def.sourceConnections or {})do + local target=destination(row.map) + if not target then return nil,'connection identity unavailable' end + rows[#rows+1]={dir=row.dir,map=target,offset=row.offset} + end + if def.connections and next(def.connections) and not def.sourceConnections then + return nil,'original connection order unavailable' + end + local warps={} + for _,row in ipairs(def.warps or {})do + local converted=copy(row);converted.nativeDestination=row.destMap;converted.destMap=destination(row.destMap) + if not converted.destMap and type(row.destMap)=='string' then + local g,n=row.destMap:match('^MAP_G(%d+)_N(%d+)$') + converted.mapGroup,converted.mapNum=tonumber(g),tonumber(n) + end + warps[#warps+1]=converted + end + local cycling,escaping,running=numericBoolean(def.allowCycling),numericBoolean(def.allowEscaping),numericBoolean(def.allowRunning) + local show=numericBoolean(def.showMapName)or 0 + local flags=tonumber(def.flags) + if edition=='ruby'or edition=='sapphire'then + -- RS policies are type-based; byte 26 is the source name-popup value, + -- not Emerald's permission bit field. Preserve native state separately. + cycling=(kind~=8 and kind~=9 and kind~=5)and 1 or 0 + local name=canonical:sub(4) + if name=='ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE'or name=='ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE'then cycling=1 end + if name=='SEAFLOOR_CAVERN_ROOM9'or name=='CAVE_OF_ORIGIN_B4F'then cycling=0 end + escaping=kind==4 and 1 or 0;running=kind==8 and 0 or 1 + if flags then show=flags==1 and 1 or 0 end + elseif flags then + local shift=(edition=='firered'or edition=='leafgreen')and 4 or 8 + show=math.floor(flags/shift)==1 and 1 or 0 + end + local view={id=canonical,name=canonical,nativeId=native,nativePair=def.tileset, + width=def.width,height=def.height,kind=indoor and 'indoor' or (kind==3 and 'route' or 'town'), + environment=indoor and 'INDOOR' or (kind==3 and 'ROUTE' or 'TOWN'),pair=pair.id,tileset=pair.tileset, + connections=rows,warps=warps,regionMapSectionId=def.regionMapSection, + showMapName=show,floorNum=def.floorNum or 0, + weather=def.weather or 0,mapType=kind,cave=numericBoolean(def.cave), + allowEscaping=escaping,allowRunning=running, + bikingAllowed=cycling,battleType=def.battleScene,music=def.music, + borderWidth=def.sourceBorderWidth,borderHeight=def.sourceBorderHeight,native=true} + for _,row in ipairs(rows)do + if row.dir=='dive'or row.dir=='emerge'then view[row.dir]={map=row.map,offset=tonumber(row.offset)or 0}end + end + local weak=setmetatable({handle},{__mode='v'});local generation=epoch + setmetatable(view,{__index=function(_,key) + local runtime=weak[1] + if key=='midLayout' and runtime then return runtime:layout(canonical,view,generation) end + end}) + known[view]=generation;maps[canonical]=view + return view + end + function handle:layout(id,def,generation) + local game,why=session();if not game then return nil,why end + if def and (known[def]~=epoch or (generation and generation~=epoch))then + return nil,'owned map definition retired' + end + if not def then def,why=self:definition(id);if not def then return nil,why end end + observing=true + local view;view,why=views:map(id);if not view then return nil,why end + local prior=published[def.id] + if prior~=view then + local previous=prior and prior.midLayout + view.midLayout.pair=def.pair + view.midLayout._revision=math.max(view.revision,(previous and previous._revision or 0)+1) + published[def.id]=view + end + loaded[def.id]=true + return view.midLayout + end + function handle:definitions() + local game,why=session();if not game then return nil,why end + if allIndexed then return maps end + for key in pairs(nativeMaps)do + local canonical=binding:legacyId(key) + if canonical then + local def,err=self:definition(canonical);if not def then return nil,err end + end + end + allIndexed=true + return maps + end + function handle:current() + local game,why=session();if not game then return nil,why end + local ow=overworld(game) + if not (ow and ow.map and nativeMaps[ow.map.id]==ow.map.def)then + allIndexed=false;views:current();return nil,'no active native map' + end + local def;def,why=self:definition(ow.map.id);if not def then return nil,why end + local layout;layout,why=self:layout(def.id,def);if not layout then return nil,why end + return def + end + function handle:neighbors(def) + def=def or self:current() + if not def or known[def]~=epoch then return {},{} end + local list,byDir={},{} + for _,row in ipairs(def.connections or {})do + local target=self:definition(row.map) + if target and Connections.cardinal(row.dir) then + local layout=self:layout(row.map,target) + if layout then + local record={dir=row.dir,map=row.map,mapId=row.map,offset=row.offset,def=target} + list[#list+1]=record;byDir[row.dir]=byDir[row.dir]or record + end + end + end + return byDir,list + end + function handle:loadedCount() + local n=0;for _ in pairs(loaded)do n=n+1 end;return n + end + function handle:invalidate(id) + local game=session();if not game then return end + views:invalidate(id) + if id==nil then clear() end + end + function handle:isOwned(def) session();return epoch~=nil and known[def]==epoch end + function handle:loadedLayouts() session();return loaded end + function handle:release() + if ended then return end + ended=true;clear();getGame,getEdition=nil,nil + end + return handle +end +return M diff --git a/src/compat/gen1/MapViews.lua b/src/compat/gen1/MapViews.lua new file mode 100644 index 000000000..cae5d712b --- /dev/null +++ b/src/compat/gen1/MapViews.lua @@ -0,0 +1,215 @@ +-- Owned compatibility snapshots over the native map owner. No map loads, +-- renderer construction, shared-definition writes or save access. +local Views = {} +local editions = { firered = true, leafgreen = true, emerald = true, ruby = true, sapphire = true } +local directions = {north=true,south=true,east=true,west=true,dive=true,emerge=true} +local function uint(n, max) + return type(n) == "number" and n >= 0 and n <= max and n % 1 == 0 +end +local function same(a, b) + if not a or #a ~= #b then return false end + for i = 1, #a do if a[i] ~= b[i] then return false end end + return true +end +local world=require('src.compat.gen1.NativeSession').world +local function kind(mapType) + local numbers = {NONE=0,TOWN=1,CITY=2,ROUTE=3,UNDERGROUND=4,UNDERWATER=5,OCEAN_ROUTE=6,UNKNOWN=7,INDOOR=8,SECRET_BASE=9} + mapType = numbers[mapType] or tonumber(mapType) or 0 + if mapType == 4 or mapType == 8 or mapType == 9 then return "indoor", "INDOOR" end + -- Match the public census registration, including its ocean/underwater + -- classification, rather than silently changing imported mod semantics. + if mapType == 3 then return "route", "ROUTE" end + return "town", "TOWN" +end + +function Views.new(getGame, getEdition) + assert(type(getGame) == "function" and type(getEdition) == "function", "native map view resolvers required") + -- Companion contracts are loaded on construction, never by a native boot + -- that does not request compatibility observations. + local Identity = require("src.compat.gen1.MapIdentity") + local Behavior = require("src.compat.gen1.MetatileBehavior") + local Classes = require("src.compat.gen1.CollisionClass") + local Layout = require("src.compat.gen1.DecodedLayout") + local Tiles = require("src.render.Gen3Tiles") + local Versions = require("src.core.GameVersion") + local function weakCache() return setmetatable({}, {__mode = "v"}) end + local owner, data, maps, edition, binding, cache, ended = nil, nil, nil, nil, nil, weakCache(), false + local serial, published = 0, setmetatable({}, {__mode = "k"}) + local ids = setmetatable({}, {__mode = "k"}) + local function token(value) + if type(value) ~= "table" then return value or false end + if not ids[value] then ids[value] = {} end + return ids[value] + end + local handle = {} + local function reset() cache = weakCache(); binding = nil; owner, data, maps, edition = nil, nil, nil, nil end + local function currentOwner() + if ended then return nil, "map view owner released" end + local game, id = getGame(), getEdition() + if type(game) ~= "table" or not editions[id] or not Versions.info(id) + or not game.data or type(game.data.maps) ~= "table" then + reset(); return nil, "no native GBA session" + end + local ow = world(game) + if not (ow and ow.map and game.data.maps[ow.map.id] == ow.map.def) then + reset(); return nil, "no active native map" + end + if game ~= owner or game.data ~= data or game.data.maps ~= maps or id ~= edition then + reset(); owner, data, maps, edition = game, game.data, game.data.maps, id + binding = Identity.bind(maps, edition) + end + return ow + end + local function loaded(ow, id, def) + if ow.map.id == id or ow.map.def == def then return ow.map end + for _, row in ipairs(ow.neighbors or {}) do + if row.map and (row.map.id == id or row.map.def == def) then return row.map end + end + end + local function build(id) + local ow, why = currentOwner(); if not ow then return nil, why end + local nativeId, def = binding:nativeId(id) + if not nativeId and maps[id] then + local alias = binding:legacyId(id) + if alias then nativeId, def = binding:nativeId(alias) end + end + if not nativeId then return nil, "map identity unavailable" end + local canonical = binding:legacyId(nativeId) + local live = loaded(ow, nativeId, def) + if live and live.def ~= def then return nil, "resident map definition changed" end + local w, h = def.width, def.height + if not uint(w, 1024) or w == 0 or not uint(h, 1024) or h == 0 or def.blockPx ~= 16 then + return nil, "native layout dimensions unavailable" + end + local sets, constants = data.map_tilesets, data.constants + local pair = data.tilesets and data.tilesets[def.tileset] + local a = pair and sets and sets[pair.primaryKey] + local b = pair and sets and sets[pair.secondaryKey] + local geometry = constants and constants.gen3Layout + if not (a and b and geometry and Tiles.sourceBehavior + and uint(geometry.metatilesInPrimary,1023) and geometry.metatilesInPrimary > 0 + and uint(geometry.tilesInPrimary,1023) and geometry.tilesInPrimary > 0 + and uint(geometry.palettesInPrimary,15) and geometry.palettesInPrimary > 0 + and geometry.metatileLayers == 2) then return nil, "original tileset attributes unavailable" end + local rawFlags = constants.gen3SourceBehaviorFlags + local frlg = edition == "firered" or edition == "leafgreen" + if not frlg and not (rawFlags and rawFlags.version == 1 and type(rawFlags.raw) == "string" + and #rawFlags.raw == 240 and rawFlags.romSha1 == Versions.info(edition).sha1) then + return nil, "original terrain flags unavailable" + end + local bw, bh, border = def.sourceBorderWidth, def.sourceBorderHeight, def.sourceBorder + if not uint(bw, 255) or bw == 0 or not uint(bh, 255) or bh == 0 + or type(border) ~= "string" or #border ~= bw * bh * 2 then return nil, "original border unavailable" end + if def.connections and next(def.connections) and not def.sourceConnections then + return nil, "original connection order unavailable" + end + local decodedBlocks = def._blockArraySource == def.blocks and def._blockArray + local stamp = {token(owner),token(maps),edition,token(def), token(live), live and live.gridRevision or 0, + token(def.blocks), token(def.collisionCells), token(def.elevationCells), token(def.sourceConnections), token(def.warps), w, h, + token(decodedBlocks), + token(a), token(b), a.attributes or false, b.attributes or false, a.sourceAttributes or false, b.sourceAttributes or false, + a.sourceAttributeBytes or false, b.sourceAttributeBytes or false, + a.sourceBehaviorMask or false, b.sourceBehaviorMask or false, + rawFlags and rawFlags.raw or false, border, bw, bh, token(geometry), geometry.metatilesInPrimary, + geometry.tilesInPrimary,geometry.palettesInPrimary,geometry.metatileLayers,def.mapType or 0} + local cached = cache[nativeId] + if cached and same(published[cached].stamp, stamp) then return cached end + local terrain = Tiles.new({primary = a, secondary = b}, geometry) + local flags = {} + if not frlg then + for raw = 0, 239 do + local bits = rawFlags.raw:byte(raw + 1) + if bits > 7 then return nil, "invalid original terrain flags" end + flags[Behavior.fromRaw(edition, raw)] = bits + end + end + local classify = Classes.new(frlg and "frlg" or "rse", flags) + local mapKind, environment = kind(def.mapType) + local warps = {} + for _, row in ipairs(def.warps or {}) do + if uint(row.x, w - 1) and uint(row.y, h - 1) then warps[row.y * w + row.x + 1] = true end + end + local paddedW, paddedH = w + w % 2, h + h % 2 + local cells = {} + for y = 0, paddedH - 1 do for x = 0, paddedW - 1 do + local sx, sy = math.min(x, w - 1), math.min(y, h - 1) + local index = sy * w + sx + 1 + local mid = live and live.blockPatch and live.blockPatch[index] + if mid == nil then + local blocks = decodedBlocks or def.blocks + if type(blocks) == "table" then mid = blocks[index] + elseif type(blocks) == "string" and #blocks >= index * 2 then + mid = (blocks:byte(index * 2 - 1) + blocks:byte(index * 2) * 256) % 1024 + end + end + local collision = live and live.collisionPatch and live.collisionPatch[index] + if collision == nil then collision = def.collisionCells and def.collisionCells[index] end + local elevation = live and live.elevationPatch and live.elevationPatch[index] + if elevation == nil then elevation = def.elevationCells and def.elevationCells[index] end + if not uint(mid, 1023) or not uint(collision, 3) or not uint(elevation, 15) then return nil, "native cell source unavailable" end + if mapKind == "indoor" and (x >= w or y >= h) then mid, collision, elevation = 0, 1, 0 end + local raw = terrain:sourceBehavior(mid) + if raw == nil then return nil, "original cell behavior unavailable" end + local behavior = Behavior.fromRaw(edition, raw) + local coll = classify.fromCell(mid, collision, behavior, mapKind) + -- Resolve the native pack's final blocking guard after classification. + -- These are the land classes emitted by our classifier; water, walls + -- and directional ledges retain their own categories. + if collision ~= 0 and (coll == 0 or coll == 0x18 or coll == 0x2b + or ((coll == 0x71 or coll == 0x72) and not (x < w and y < h and warps[index]))) then coll = 7 end + cells[#cells + 1] = {mid = mid, coll = coll, elev = elevation} + end end + local borders = {}; for i = 1, bw * bh do borders[i] = (border:byte(i * 2 - 1) + border:byte(i * 2) * 256) % 1024 end + local converted = {} + for _, row in ipairs(def.sourceConnections or {}) do + if not directions[row.dir] + or type(row.offset) ~= "number" or row.offset % 1 ~= 0 then return nil,"invalid source connection" end + local destination = binding:legacyId(row.map) + if not destination then return nil, "connection map identity unavailable" end + converted[#converted + 1] = {dir = row.dir, map = destination, offset = row.offset} + end + local convertedWarps = {} + for _, row in ipairs(def.warps or {}) do + local destination = row.destMap + local canonicalDestination = binding:legacyId(destination) + local group, number + if not canonicalDestination and type(destination) == "string" then + group, number = destination:match("^MAP_G(%d+)_N(%d+)$") + group, number = tonumber(group), tonumber(number) + canonicalDestination = group and Identity.lookup(edition, group, number) or Identity.lookup(edition, destination) + end + convertedWarps[#convertedWarps+1]={x=row.x,y=row.y,elevation=row.elevation,destWarp=row.destWarp, + destMap=canonicalDestination,nativeDestination=destination, + mapGroup=not canonicalDestination and group or nil,mapNum=not canonicalDestination and number or nil} + end + local snapshot = {id = canonical, nativeId = nativeId, nativePair = def.tileset, + width = w, height = h, kind = mapKind, environment = environment, + connections = converted, warps = convertedWarps} + -- The native pair key is explicit. Atlas-name compatibility is a separate + -- provider and this observation is not installed as the old map module. + snapshot.midLayout = Layout.fromDecoded({width = paddedW, height = paddedH, trueWidth = w, trueHeight = h, + cells = cells, borderWidth = bw, borderHeight = bh, borderMids = borders}, canonical, def.tileset) + serial = serial + 1; snapshot.revision = serial + cache[nativeId], published[snapshot] = snapshot, {stamp = stamp,id = nativeId} + return snapshot + end + function handle:current() + local ow, why = currentOwner(); if not ow then return nil, why end + return build(ow.map.id) + end + function handle:map(id) return build(id) end + function handle:isCurrent(view) + local record = published[view]; if not record then return false end + local fresh = build(record.id) + return fresh == view + end + function handle:invalidate(id) + if id == nil then cache = weakCache(); return end + local _, why = currentOwner(); if why then return end + local key = binding:nativeId(id) + cache[key or id] = nil + end + function handle:release() ended = true; reset(); getGame, getEdition = nil, nil end + return handle +end +return Views diff --git a/src/compat/gen1/MetatileBehavior.lua b/src/compat/gen1/MetatileBehavior.lua new file mode 100644 index 000000000..5793cfc67 --- /dev/null +++ b/src/compat/gen1/MetatileBehavior.lua @@ -0,0 +1,52 @@ +-- Semantic behavior IDs for compatibility views. Native behavior bytes stay +-- native; callers explicitly select the cartridge before translating a byte. +local data = require("src.compat.gen1.MetatileNames") +local Behavior = { RSE_BASE = 256 } +Behavior.RENAMES = { NO_RUNNING = "RUNNING_DISALLOWED", NO_SURFACING = "UNDERWATER_BLOCKED_ABOVE", + NON_ANIMATED_DOOR = "CAVE_DOOR", ANIMATED_DOOR = "WARP_DOOR" } +for name, id in pairs(data.ids) do Behavior[name] = id end +local function nameKey(name) + return type(name) == "string" and (name:gsub("^MB_", "")) or nil +end +function Behavior.id(name) return data.ids[nameKey(name)] end +function Behavior.require(name) + local id = Behavior.id(name) + if id == nil then error("mb: unknown metatile behavior '" .. tostring(name) .. "'", 2) end + return id +end +function Behavior.nameOf(id) return data.names[tonumber(id) or -1] end +function Behavior.isRseOnly(id) id = tonumber(id); return id ~= nil and id >= Behavior.RSE_BASE end +function Behavior.all() + local out = {}; for name, id in pairs(data.ids) do out[name] = id end; return out +end +local converters = {} +function Behavior.translator(game) + local values = data.translations[game] + if not values then return nil end + if converters[game] then return converters[game] end + local reverse = {} + for raw = 0, 255 do + local id = values[raw + 1] + if reverse[id] == nil then reverse[id] = raw end + end + local t = { game = game } + function t.canon(raw) return values[(tonumber(raw) or 0) % 256 + 1] end + function t.raw(id) return reverse[tonumber(id) or -1] end + function t.table() + local out = {}; for raw = 0, 255 do out[raw] = values[raw + 1] end; return out + end + if data.sourceNames[game] then + function t.sourceName(id) + local raw = t.raw(id) + return raw and data.sourceNames[game][raw] or nil + end + end + converters[game] = t + return t +end +function Behavior.fromRaw(game, raw) + local converter = Behavior.translator(game) + if converter then return converter.canon(raw) end + return raw +end +return Behavior diff --git a/src/compat/gen1/MetatileNames.lua b/src/compat/gen1/MetatileNames.lua new file mode 100644 index 000000000..355f78496 --- /dev/null +++ b/src/compat/gen1/MetatileNames.lua @@ -0,0 +1,1140 @@ +-- Public behavior identifiers and conversion metadata, reference 37a49b31f458d51ea9e00b90f2a247272ad09cb8. +-- Regenerate with tools/metatile-compat/export.lua; no ROM bytes or runtime implementation. +return { + ids = { + ["ADVERTISING_POSTER"] = 152, + ["ANIMATED_DOOR"] = 105, + ["AQUA_HIDEOUT_WARP"] = 359, + ["ASHGRASS"] = 292, + ["BATTLE_PYRAMID_WARP"] = 269, + ["BATTLE_RECORDS"] = 142, + ["BERRY_TREE_SOIL"] = 416, + ["BIKE_BRIDGE_OVER_BARRIER"] = 383, + ["BLINKING_LIGHTS"] = 158, + ["BLUEPRINT"] = 486, + ["BLUEPRINTS"] = 147, + ["BOOKSHELF"] = 129, + ["BRIDGE_OVER_OCEAN"] = 368, + ["BRIDGE_OVER_POND_HIGH"] = 371, + ["BRIDGE_OVER_POND_HIGH_EDGE_1"] = 380, + ["BRIDGE_OVER_POND_HIGH_EDGE_2"] = 381, + ["BRIDGE_OVER_POND_LOW"] = 369, + ["BRIDGE_OVER_POND_MED"] = 370, + ["BRIDGE_OVER_POND_MED_EDGE_1"] = 378, + ["BRIDGE_OVER_POND_MED_EDGE_2"] = 379, + ["BUMPY_SLOPE"] = 465, + ["BURGLARY"] = 162, + ["CABINET"] = 137, + ["CABLE_BOX_RESULTS_1"] = 388, + ["CABLE_BOX_RESULTS_2"] = 487, + ["CABLE_CLUB_WIRELESS_MONITOR"] = 141, + ["CAVE"] = 8, + ["CAVE_DOOR"] = 96, + ["CLOSED_SOOTOPOLIS_DOOR"] = 395, + ["COMPUTER"] = 151, + ["COUNTER"] = 128, + ["CRACKED_FLOOR"] = 466, + ["CRACKED_FLOOR_HOLE"] = 358, + ["CRACKED_ICE"] = 39, + ["CUP"] = 155, + ["CYCLING_ROAD_PULL_DOWN"] = 208, + ["CYCLING_ROAD_PULL_DOWN_GRASS"] = 209, + ["CYCLING_ROAD_WATER"] = 27, + ["DEEP_SAND"] = 262, + ["DEEP_SOUTH_WARP"] = 366, + ["DEEP_WATER"] = 18, + ["DOWN_ESCALATOR"] = 107, + ["DOWN_LEFT_STAIR_WARP"] = 111, + ["DOWN_RIGHT_STAIR_WARP"] = 110, + ["DRESSER"] = 139, + ["EASTWARD_CURRENT"] = 80, + ["EAST_ARROW_WARP"] = 98, + ["FALL_WARP"] = 102, + ["FAST_WATER"] = 17, + ["FOOD"] = 144, + ["FOOD_SMELLS_TASTY"] = 153, + ["FOOTPRINTS"] = 293, + ["FORTREE_BRIDGE"] = 376, + ["HOLDS_LARGE_DECORATION"] = 451, + ["HOLDS_SMALL_DECORATION"] = 437, + ["HORIZONTAL_RAIL"] = 470, + ["HOT_SPRINGS"] = 40, + ["ICE"] = 35, + ["IMPASSABLE_EAST"] = 48, + ["IMPASSABLE_NORTH"] = 50, + ["IMPASSABLE_NORTHEAST"] = 52, + ["IMPASSABLE_NORTHWEST"] = 53, + ["IMPASSABLE_SOUTH"] = 51, + ["IMPASSABLE_SOUTHEAST"] = 54, + ["IMPASSABLE_SOUTHWEST"] = 55, + ["IMPASSABLE_SOUTH_AND_NORTH"] = 448, + ["IMPASSABLE_WEST"] = 49, + ["IMPASSABLE_WEST_AND_EAST"] = 449, + ["IMPRESSIVE_MACHINE"] = 160, + ["INDIGO_PLATEAU_SIGN_1"] = 145, + ["INDIGO_PLATEAU_SIGN_2"] = 146, + ["INDOOR_ENCOUNTER"] = 11, + ["INTERIOR_DEEP_WATER"] = 273, + ["ISOLATED_HORIZONTAL_RAIL"] = 468, + ["ISOLATED_VERTICAL_RAIL"] = 467, + ["JUMP_EAST"] = 56, + ["JUMP_NORTH"] = 58, + ["JUMP_NORTHEAST"] = 316, + ["JUMP_NORTHWEST"] = 317, + ["JUMP_SOUTH"] = 59, + ["JUMP_SOUTHEAST"] = 318, + ["JUMP_SOUTHWEST"] = 319, + ["JUMP_WEST"] = 57, + ["KITCHEN"] = 138, + ["LADDER"] = 97, + ["LAVARIDGE_1F_WARP"] = 104, + ["LAVARIDGE_GYM_1F_WARP"] = 360, + ["LAVARIDGE_GYM_B1F_WARP"] = 297, + ["LONG_GRASS"] = 259, + ["LONG_GRASS_SOUTH_EDGE"] = 265, + ["MOSSDEEP_GYM_WARP"] = 270, + ["MOUNTAIN_TOP"] = 12, + ["MT_PYRE_HOLE"] = 271, + ["MUDDY_SLOPE"] = 464, + ["NEATLY_LINED_UP_TOOLS"] = 159, + ["NON_ANIMATED_DOOR"] = 96, + ["NORMAL"] = 0, + ["NORTHWARD_CURRENT"] = 82, + ["NORTH_ARROW_WARP"] = 100, + ["NO_RUNNING"] = 10, + ["NO_SURFACING"] = 25, + ["OCEAN_WATER"] = 21, + ["PACIFIDLOG_HORIZONTAL_LOG_LEFT"] = 374, + ["PACIFIDLOG_HORIZONTAL_LOG_RIGHT"] = 375, + ["PACIFIDLOG_VERTICAL_LOG_BOTTOM"] = 373, + ["PACIFIDLOG_VERTICAL_LOG_TOP"] = 372, + ["PAINTING"] = 148, + ["PC"] = 131, + ["PETALBURG_GYM_DOOR"] = 397, + ["PICTURE_BOOK_SHELF"] = 480, + ["PLAYER_ROOM_PC_ON"] = 453, + ["POKEBLOCK_FEEDER"] = 391, + ["POKEMART_SHELF"] = 130, + ["POKEMART_SIGN"] = 136, + ["POKEMON_CENTER_BOOKSHELF"] = 482, + ["POKEMON_CENTER_SIGN"] = 135, + ["POND_WATER"] = 16, + ["PORTHOLE"] = 156, + ["POWER_PLANT_MACHINE"] = 149, + ["PUDDLE"] = 22, + ["QUESTIONNAIRE"] = 143, + ["REFLECTION_UNDER_BRIDGE"] = 299, + ["REGION_MAP"] = 133, + ["REGULAR_WARP"] = 103, + ["ROCK_STAIRS"] = 42, + ["ROULETTE"] = 394, + ["RUNNING_DISALLOWED"] = 10, + ["RUNNING_SHOES_INSTRUCTION"] = 398, + ["SAND"] = 33, + ["SAND_CAVE"] = 43, + ["SEAWEED"] = 34, + ["SEAWEED_NO_SURFACING"] = 298, + ["SECRET_BASE_BALLOON"] = 440, + ["SECRET_BASE_BREAKABLE_DOOR"] = 446, + ["SECRET_BASE_DECORATION"] = 436, + ["SECRET_BASE_DECORATION_BASE"] = 454, + ["SECRET_BASE_GLITTER_MAT"] = 442, + ["SECRET_BASE_HOLE"] = 450, + ["SECRET_BASE_IMPASSABLE"] = 441, + ["SECRET_BASE_JUMP_MAT"] = 443, + ["SECRET_BASE_NORTH_WALL"] = 439, + ["SECRET_BASE_PC"] = 432, + ["SECRET_BASE_POSTER"] = 455, + ["SECRET_BASE_REGISTER_PC"] = 433, + ["SECRET_BASE_SAND_ORNAMENT"] = 447, + ["SECRET_BASE_SCENERY"] = 434, + ["SECRET_BASE_SOUND_MAT"] = 445, + ["SECRET_BASE_SPIN_MAT"] = 444, + ["SECRET_BASE_SPOT_BLUE_CAVE"] = 410, + ["SECRET_BASE_SPOT_BLUE_CAVE_OPEN"] = 411, + ["SECRET_BASE_SPOT_BROWN_CAVE"] = 402, + ["SECRET_BASE_SPOT_BROWN_CAVE_OPEN"] = 403, + ["SECRET_BASE_SPOT_RED_CAVE"] = 400, + ["SECRET_BASE_SPOT_RED_CAVE_OPEN"] = 401, + ["SECRET_BASE_SPOT_SHRUB"] = 408, + ["SECRET_BASE_SPOT_SHRUB_OPEN"] = 409, + ["SECRET_BASE_SPOT_TREE_LEFT"] = 406, + ["SECRET_BASE_SPOT_TREE_LEFT_OPEN"] = 407, + ["SECRET_BASE_SPOT_TREE_RIGHT"] = 412, + ["SECRET_BASE_SPOT_TREE_RIGHT_OPEN"] = 413, + ["SECRET_BASE_SPOT_YELLOW_CAVE"] = 404, + ["SECRET_BASE_SPOT_YELLOW_CAVE_OPEN"] = 405, + ["SECRET_BASE_TRAINER_SPOT"] = 435, + ["SECRET_BASE_TV_SHIELD"] = 452, + ["SECRET_BASE_WALL"] = 257, + ["SHALLOW_WATER"] = 23, + ["SHOAL_CAVE_ENTRANCE"] = 284, + ["SHOP_SHELF"] = 485, + ["SHORT_GRASS"] = 263, + ["SIGNPOST"] = 132, + ["SKY_PILLAR_CLOSED_DOOR"] = 490, + ["SLIDE_EAST"] = 68, + ["SLIDE_NORTH"] = 70, + ["SLIDE_SOUTH"] = 71, + ["SLIDE_WEST"] = 69, + ["SLOT_MACHINE"] = 393, + ["SNACKS"] = 140, + ["SOOTOPOLIS_DEEP_WATER"] = 276, + ["SOUTHWARD_CURRENT"] = 83, + ["SOUTH_ARROW_WARP"] = 101, + ["SPIN_DOWN"] = 87, + ["SPIN_LEFT"] = 85, + ["SPIN_RIGHT"] = 84, + ["SPIN_UP"] = 86, + ["STAIRS_OUTSIDE_ABANDONED_SHIP"] = 283, + ["STOP_SPINNING"] = 88, + ["STRENGTH_BUTTON"] = 32, + ["TALL_GRASS"] = 2, + ["TELEPHONE"] = 150, + ["TELEVISION"] = 134, + ["THIN_ICE"] = 38, + ["TRAINER_HILL_TIMER"] = 489, + ["TRAINER_TOWER_MONITOR"] = 163, + ["TRASH_BIN"] = 154, + ["TRASH_CAN"] = 484, + ["TRICK_HOUSE_PUZZLE_8_FLOOR"] = 72, + ["TRICK_HOUSE_PUZZLE_DOOR"] = 396, + ["UNDERWATER_BLOCKED_ABOVE"] = 25, + ["UNION_ROOM_WARP"] = 113, + ["UNUSED_01"] = 1, + ["UNUSED_04"] = 260, + ["UNUSED_05"] = 261, + ["UNUSED_1D"] = 285, + ["UNUSED_1E"] = 286, + ["UNUSED_1F"] = 287, + ["UNUSED_23"] = 291, + ["UNUSED_2C"] = 300, + ["UNUSED_2D"] = 301, + ["UNUSED_2E"] = 302, + ["UNUSED_2F"] = 303, + ["UNUSED_49"] = 329, + ["UNUSED_4A"] = 330, + ["UNUSED_4B"] = 331, + ["UNUSED_4C"] = 332, + ["UNUSED_4D"] = 333, + ["UNUSED_4E"] = 334, + ["UNUSED_4F"] = 335, + ["UNUSED_54"] = 340, + ["UNUSED_55"] = 341, + ["UNUSED_56"] = 342, + ["UNUSED_57"] = 343, + ["UNUSED_58"] = 344, + ["UNUSED_59"] = 345, + ["UNUSED_5A"] = 346, + ["UNUSED_5B"] = 347, + ["UNUSED_5C"] = 348, + ["UNUSED_5D"] = 349, + ["UNUSED_5E"] = 350, + ["UNUSED_5F"] = 351, + ["UNUSED_6F"] = 367, + ["UNUSED_79"] = 377, + ["UNUSED_81"] = 385, + ["UNUSED_82"] = 386, + ["UNUSED_88"] = 392, + ["UNUSED_9E"] = 414, + ["UNUSED_9F"] = 415, + ["UNUSED_A1"] = 417, + ["UNUSED_A2"] = 418, + ["UNUSED_A3"] = 419, + ["UNUSED_A4"] = 420, + ["UNUSED_A5"] = 421, + ["UNUSED_A6"] = 422, + ["UNUSED_A7"] = 423, + ["UNUSED_A8"] = 424, + ["UNUSED_A9"] = 425, + ["UNUSED_AA"] = 426, + ["UNUSED_AB"] = 427, + ["UNUSED_AC"] = 428, + ["UNUSED_AD"] = 429, + ["UNUSED_AE"] = 430, + ["UNUSED_AF"] = 431, + ["UNUSED_B6"] = 438, + ["UNUSED_BRIDGE"] = 382, + ["UNUSED_C8"] = 456, + ["UNUSED_C9"] = 457, + ["UNUSED_CA"] = 458, + ["UNUSED_CB"] = 459, + ["UNUSED_CC"] = 460, + ["UNUSED_CD"] = 461, + ["UNUSED_CE"] = 462, + ["UNUSED_CF"] = 463, + ["UNUSED_D7"] = 471, + ["UNUSED_D8"] = 472, + ["UNUSED_D9"] = 473, + ["UNUSED_DA"] = 474, + ["UNUSED_DB"] = 475, + ["UNUSED_DC"] = 476, + ["UNUSED_DD"] = 477, + ["UNUSED_DE"] = 478, + ["UNUSED_DF"] = 479, + ["UNUSED_EB"] = 491, + ["UNUSED_EC"] = 492, + ["UNUSED_ED"] = 493, + ["UNUSED_EE"] = 494, + ["UNUSED_EF"] = 495, + ["UNUSED_SOOTOPOLIS_DEEP_WATER"] = 280, + ["UNUSED_SOOTOPOLIS_DEEP_WATER_2"] = 282, + ["UNUSED_WATER"] = 26, + ["UP_ESCALATOR"] = 106, + ["UP_LEFT_STAIR_WARP"] = 109, + ["UP_RIGHT_STAIR_WARP"] = 108, + ["VASE"] = 483, + ["VERTICAL_RAIL"] = 469, + ["VIDEO_GAME"] = 161, + ["WALK_EAST"] = 64, + ["WALK_NORTH"] = 66, + ["WALK_SOUTH"] = 67, + ["WALK_WEST"] = 65, + ["WARP_DOOR"] = 105, + ["WATERFALL"] = 19, + ["WATER_DOOR"] = 364, + ["WATER_SOUTH_ARROW_WARP"] = 365, + ["WESTWARD_CURRENT"] = 81, + ["WEST_ARROW_WARP"] = 99, + ["WINDOW"] = 157, + ["WIRELESS_BOX_RESULTS"] = 488, + }, + names = { + [0] = "NORMAL", + [1] = "UNUSED_01", + [2] = "TALL_GRASS", + [8] = "CAVE", + [10] = "RUNNING_DISALLOWED", + [11] = "INDOOR_ENCOUNTER", + [12] = "MOUNTAIN_TOP", + [16] = "POND_WATER", + [17] = "FAST_WATER", + [18] = "DEEP_WATER", + [19] = "WATERFALL", + [21] = "OCEAN_WATER", + [22] = "PUDDLE", + [23] = "SHALLOW_WATER", + [25] = "UNDERWATER_BLOCKED_ABOVE", + [26] = "UNUSED_WATER", + [27] = "CYCLING_ROAD_WATER", + [32] = "STRENGTH_BUTTON", + [33] = "SAND", + [34] = "SEAWEED", + [35] = "ICE", + [38] = "THIN_ICE", + [39] = "CRACKED_ICE", + [40] = "HOT_SPRINGS", + [42] = "ROCK_STAIRS", + [43] = "SAND_CAVE", + [48] = "IMPASSABLE_EAST", + [49] = "IMPASSABLE_WEST", + [50] = "IMPASSABLE_NORTH", + [51] = "IMPASSABLE_SOUTH", + [52] = "IMPASSABLE_NORTHEAST", + [53] = "IMPASSABLE_NORTHWEST", + [54] = "IMPASSABLE_SOUTHEAST", + [55] = "IMPASSABLE_SOUTHWEST", + [56] = "JUMP_EAST", + [57] = "JUMP_WEST", + [58] = "JUMP_NORTH", + [59] = "JUMP_SOUTH", + [64] = "WALK_EAST", + [65] = "WALK_WEST", + [66] = "WALK_NORTH", + [67] = "WALK_SOUTH", + [68] = "SLIDE_EAST", + [69] = "SLIDE_WEST", + [70] = "SLIDE_NORTH", + [71] = "SLIDE_SOUTH", + [72] = "TRICK_HOUSE_PUZZLE_8_FLOOR", + [80] = "EASTWARD_CURRENT", + [81] = "WESTWARD_CURRENT", + [82] = "NORTHWARD_CURRENT", + [83] = "SOUTHWARD_CURRENT", + [84] = "SPIN_RIGHT", + [85] = "SPIN_LEFT", + [86] = "SPIN_UP", + [87] = "SPIN_DOWN", + [88] = "STOP_SPINNING", + [96] = "CAVE_DOOR", + [97] = "LADDER", + [98] = "EAST_ARROW_WARP", + [99] = "WEST_ARROW_WARP", + [100] = "NORTH_ARROW_WARP", + [101] = "SOUTH_ARROW_WARP", + [102] = "FALL_WARP", + [103] = "REGULAR_WARP", + [104] = "LAVARIDGE_1F_WARP", + [105] = "WARP_DOOR", + [106] = "UP_ESCALATOR", + [107] = "DOWN_ESCALATOR", + [108] = "UP_RIGHT_STAIR_WARP", + [109] = "UP_LEFT_STAIR_WARP", + [110] = "DOWN_RIGHT_STAIR_WARP", + [111] = "DOWN_LEFT_STAIR_WARP", + [113] = "UNION_ROOM_WARP", + [128] = "COUNTER", + [129] = "BOOKSHELF", + [130] = "POKEMART_SHELF", + [131] = "PC", + [132] = "SIGNPOST", + [133] = "REGION_MAP", + [134] = "TELEVISION", + [135] = "POKEMON_CENTER_SIGN", + [136] = "POKEMART_SIGN", + [137] = "CABINET", + [138] = "KITCHEN", + [139] = "DRESSER", + [140] = "SNACKS", + [141] = "CABLE_CLUB_WIRELESS_MONITOR", + [142] = "BATTLE_RECORDS", + [143] = "QUESTIONNAIRE", + [144] = "FOOD", + [145] = "INDIGO_PLATEAU_SIGN_1", + [146] = "INDIGO_PLATEAU_SIGN_2", + [147] = "BLUEPRINTS", + [148] = "PAINTING", + [149] = "POWER_PLANT_MACHINE", + [150] = "TELEPHONE", + [151] = "COMPUTER", + [152] = "ADVERTISING_POSTER", + [153] = "FOOD_SMELLS_TASTY", + [154] = "TRASH_BIN", + [155] = "CUP", + [156] = "PORTHOLE", + [157] = "WINDOW", + [158] = "BLINKING_LIGHTS", + [159] = "NEATLY_LINED_UP_TOOLS", + [160] = "IMPRESSIVE_MACHINE", + [161] = "VIDEO_GAME", + [162] = "BURGLARY", + [163] = "TRAINER_TOWER_MONITOR", + [208] = "CYCLING_ROAD_PULL_DOWN", + [209] = "CYCLING_ROAD_PULL_DOWN_GRASS", + [257] = "SECRET_BASE_WALL", + [259] = "LONG_GRASS", + [260] = "UNUSED_04", + [261] = "UNUSED_05", + [262] = "DEEP_SAND", + [263] = "SHORT_GRASS", + [265] = "LONG_GRASS_SOUTH_EDGE", + [269] = "BATTLE_PYRAMID_WARP", + [270] = "MOSSDEEP_GYM_WARP", + [271] = "MT_PYRE_HOLE", + [273] = "INTERIOR_DEEP_WATER", + [276] = "SOOTOPOLIS_DEEP_WATER", + [280] = "UNUSED_SOOTOPOLIS_DEEP_WATER", + [282] = "UNUSED_SOOTOPOLIS_DEEP_WATER_2", + [283] = "STAIRS_OUTSIDE_ABANDONED_SHIP", + [284] = "SHOAL_CAVE_ENTRANCE", + [285] = "UNUSED_1D", + [286] = "UNUSED_1E", + [287] = "UNUSED_1F", + [291] = "UNUSED_23", + [292] = "ASHGRASS", + [293] = "FOOTPRINTS", + [297] = "LAVARIDGE_GYM_B1F_WARP", + [298] = "SEAWEED_NO_SURFACING", + [299] = "REFLECTION_UNDER_BRIDGE", + [300] = "UNUSED_2C", + [301] = "UNUSED_2D", + [302] = "UNUSED_2E", + [303] = "UNUSED_2F", + [316] = "JUMP_NORTHEAST", + [317] = "JUMP_NORTHWEST", + [318] = "JUMP_SOUTHEAST", + [319] = "JUMP_SOUTHWEST", + [329] = "UNUSED_49", + [330] = "UNUSED_4A", + [331] = "UNUSED_4B", + [332] = "UNUSED_4C", + [333] = "UNUSED_4D", + [334] = "UNUSED_4E", + [335] = "UNUSED_4F", + [340] = "UNUSED_54", + [341] = "UNUSED_55", + [342] = "UNUSED_56", + [343] = "UNUSED_57", + [344] = "UNUSED_58", + [345] = "UNUSED_59", + [346] = "UNUSED_5A", + [347] = "UNUSED_5B", + [348] = "UNUSED_5C", + [349] = "UNUSED_5D", + [350] = "UNUSED_5E", + [351] = "UNUSED_5F", + [358] = "CRACKED_FLOOR_HOLE", + [359] = "AQUA_HIDEOUT_WARP", + [360] = "LAVARIDGE_GYM_1F_WARP", + [364] = "WATER_DOOR", + [365] = "WATER_SOUTH_ARROW_WARP", + [366] = "DEEP_SOUTH_WARP", + [367] = "UNUSED_6F", + [368] = "BRIDGE_OVER_OCEAN", + [369] = "BRIDGE_OVER_POND_LOW", + [370] = "BRIDGE_OVER_POND_MED", + [371] = "BRIDGE_OVER_POND_HIGH", + [372] = "PACIFIDLOG_VERTICAL_LOG_TOP", + [373] = "PACIFIDLOG_VERTICAL_LOG_BOTTOM", + [374] = "PACIFIDLOG_HORIZONTAL_LOG_LEFT", + [375] = "PACIFIDLOG_HORIZONTAL_LOG_RIGHT", + [376] = "FORTREE_BRIDGE", + [377] = "UNUSED_79", + [378] = "BRIDGE_OVER_POND_MED_EDGE_1", + [379] = "BRIDGE_OVER_POND_MED_EDGE_2", + [380] = "BRIDGE_OVER_POND_HIGH_EDGE_1", + [381] = "BRIDGE_OVER_POND_HIGH_EDGE_2", + [382] = "UNUSED_BRIDGE", + [383] = "BIKE_BRIDGE_OVER_BARRIER", + [385] = "UNUSED_81", + [386] = "UNUSED_82", + [388] = "CABLE_BOX_RESULTS_1", + [391] = "POKEBLOCK_FEEDER", + [392] = "UNUSED_88", + [393] = "SLOT_MACHINE", + [394] = "ROULETTE", + [395] = "CLOSED_SOOTOPOLIS_DOOR", + [396] = "TRICK_HOUSE_PUZZLE_DOOR", + [397] = "PETALBURG_GYM_DOOR", + [398] = "RUNNING_SHOES_INSTRUCTION", + [400] = "SECRET_BASE_SPOT_RED_CAVE", + [401] = "SECRET_BASE_SPOT_RED_CAVE_OPEN", + [402] = "SECRET_BASE_SPOT_BROWN_CAVE", + [403] = "SECRET_BASE_SPOT_BROWN_CAVE_OPEN", + [404] = "SECRET_BASE_SPOT_YELLOW_CAVE", + [405] = "SECRET_BASE_SPOT_YELLOW_CAVE_OPEN", + [406] = "SECRET_BASE_SPOT_TREE_LEFT", + [407] = "SECRET_BASE_SPOT_TREE_LEFT_OPEN", + [408] = "SECRET_BASE_SPOT_SHRUB", + [409] = "SECRET_BASE_SPOT_SHRUB_OPEN", + [410] = "SECRET_BASE_SPOT_BLUE_CAVE", + [411] = "SECRET_BASE_SPOT_BLUE_CAVE_OPEN", + [412] = "SECRET_BASE_SPOT_TREE_RIGHT", + [413] = "SECRET_BASE_SPOT_TREE_RIGHT_OPEN", + [414] = "UNUSED_9E", + [415] = "UNUSED_9F", + [416] = "BERRY_TREE_SOIL", + [417] = "UNUSED_A1", + [418] = "UNUSED_A2", + [419] = "UNUSED_A3", + [420] = "UNUSED_A4", + [421] = "UNUSED_A5", + [422] = "UNUSED_A6", + [423] = "UNUSED_A7", + [424] = "UNUSED_A8", + [425] = "UNUSED_A9", + [426] = "UNUSED_AA", + [427] = "UNUSED_AB", + [428] = "UNUSED_AC", + [429] = "UNUSED_AD", + [430] = "UNUSED_AE", + [431] = "UNUSED_AF", + [432] = "SECRET_BASE_PC", + [433] = "SECRET_BASE_REGISTER_PC", + [434] = "SECRET_BASE_SCENERY", + [435] = "SECRET_BASE_TRAINER_SPOT", + [436] = "SECRET_BASE_DECORATION", + [437] = "HOLDS_SMALL_DECORATION", + [438] = "UNUSED_B6", + [439] = "SECRET_BASE_NORTH_WALL", + [440] = "SECRET_BASE_BALLOON", + [441] = "SECRET_BASE_IMPASSABLE", + [442] = "SECRET_BASE_GLITTER_MAT", + [443] = "SECRET_BASE_JUMP_MAT", + [444] = "SECRET_BASE_SPIN_MAT", + [445] = "SECRET_BASE_SOUND_MAT", + [446] = "SECRET_BASE_BREAKABLE_DOOR", + [447] = "SECRET_BASE_SAND_ORNAMENT", + [448] = "IMPASSABLE_SOUTH_AND_NORTH", + [449] = "IMPASSABLE_WEST_AND_EAST", + [450] = "SECRET_BASE_HOLE", + [451] = "HOLDS_LARGE_DECORATION", + [452] = "SECRET_BASE_TV_SHIELD", + [453] = "PLAYER_ROOM_PC_ON", + [454] = "SECRET_BASE_DECORATION_BASE", + [455] = "SECRET_BASE_POSTER", + [456] = "UNUSED_C8", + [457] = "UNUSED_C9", + [458] = "UNUSED_CA", + [459] = "UNUSED_CB", + [460] = "UNUSED_CC", + [461] = "UNUSED_CD", + [462] = "UNUSED_CE", + [463] = "UNUSED_CF", + [464] = "MUDDY_SLOPE", + [465] = "BUMPY_SLOPE", + [466] = "CRACKED_FLOOR", + [467] = "ISOLATED_VERTICAL_RAIL", + [468] = "ISOLATED_HORIZONTAL_RAIL", + [469] = "VERTICAL_RAIL", + [470] = "HORIZONTAL_RAIL", + [471] = "UNUSED_D7", + [472] = "UNUSED_D8", + [473] = "UNUSED_D9", + [474] = "UNUSED_DA", + [475] = "UNUSED_DB", + [476] = "UNUSED_DC", + [477] = "UNUSED_DD", + [478] = "UNUSED_DE", + [479] = "UNUSED_DF", + [480] = "PICTURE_BOOK_SHELF", + [482] = "POKEMON_CENTER_BOOKSHELF", + [483] = "VASE", + [484] = "TRASH_CAN", + [485] = "SHOP_SHELF", + [486] = "BLUEPRINT", + [487] = "CABLE_BOX_RESULTS_2", + [488] = "WIRELESS_BOX_RESULTS", + [489] = "TRAINER_HILL_TIMER", + [490] = "SKY_PILLAR_CLOSED_DOOR", + [491] = "UNUSED_EB", + [492] = "UNUSED_EC", + [493] = "UNUSED_ED", + [494] = "UNUSED_EE", + [495] = "UNUSED_EF", + }, + translations = { + emerald = { + 0,257,2,259,260,261,262,263,8,265,10,11,12,269,270,271, + 16,273,18,19,276,21,22,23,280,25,282,283,284,285,286,287, + 35,33,34,291,292,293,38,39,40,297,298,299,300,301,302,303, + 48,49,50,51,52,53,54,55,56,57,58,59,316,317,318,319, + 64,65,66,67,68,69,70,71,72,329,330,331,332,333,334,335, + 80,81,82,83,340,341,342,343,344,345,346,347,348,349,350,351, + 96,97,98,99,100,101,358,359,360,105,106,107,364,365,366,367, + 368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383, + 128,385,386,131,388,133,134,391,392,393,394,395,396,397,398,143, + 400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415, + 416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431, + 432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447, + 448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463, + 464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479, + 480,129,482,483,484,485,486,487,488,489,490,491,492,493,494,495, + 496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511, + }, + ruby = { + 0,257,2,259,260,261,262,263,8,265,10,11,12,525,270,271, + 16,273,18,19,276,21,22,23,280,25,282,283,284,285,286,287, + 35,33,34,291,292,293,38,39,40,297,298,299,300,301,302,303, + 48,49,50,51,52,53,54,55,56,57,58,59,316,317,318,319, + 64,65,66,67,68,69,70,71,72,329,330,331,332,333,334,335, + 80,81,82,83,340,341,342,343,344,345,346,347,348,349,350,351, + 96,97,98,99,100,101,358,359,360,105,106,107,364,365,366,367, + 368,369,370,371,372,373,374,375,376,377,378,379,380,381,638,383, + 128,385,386,131,388,133,134,391,392,393,394,395,396,397,398,143, + 400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415, + 416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431, + 432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447, + 448,449,450,451,452,453,710,711,456,457,458,459,460,461,462,463, + 464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479, + 480,129,482,483,484,485,486,743,744,745,746,491,492,493,494,495, + 752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767, + }, + sapphire = { + 0,257,2,259,260,261,262,263,8,265,10,11,12,525,270,271, + 16,273,18,19,276,21,22,23,280,25,282,283,284,285,286,287, + 35,33,34,291,292,293,38,39,40,297,298,299,300,301,302,303, + 48,49,50,51,52,53,54,55,56,57,58,59,316,317,318,319, + 64,65,66,67,68,69,70,71,72,329,330,331,332,333,334,335, + 80,81,82,83,340,341,342,343,344,345,346,347,348,349,350,351, + 96,97,98,99,100,101,358,359,360,105,106,107,364,365,366,367, + 368,369,370,371,372,373,374,375,376,377,378,379,380,381,638,383, + 128,385,386,131,388,133,134,391,392,393,394,395,396,397,398,143, + 400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415, + 416,417,418,419,420,421,422,423,424,425,426,427,428,429,430,431, + 432,433,434,435,436,437,438,439,440,441,442,443,444,445,446,447, + 448,449,450,451,452,453,710,711,456,457,458,459,460,461,462,463, + 464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479, + 480,129,482,483,484,485,486,743,744,745,746,491,492,493,494,495, + 752,753,754,755,756,757,758,759,760,761,762,763,764,765,766,767, + }, + }, + sourceNames = { + ruby = { + [0] = "MB_NORMAL", + [1] = "MB_SECRET_BASE_WALL", + [2] = "MB_TALL_GRASS", + [3] = "MB_LONG_GRASS", + [4] = "MB_UNUSED_04", + [5] = "MB_UNUSED_05", + [6] = "MB_UNUSED_DEEP_SAND", + [7] = "MB_SHORT_GRASS", + [8] = "MB_UNUSED_CAVE", + [9] = "MB_LONG_GRASS_SOUTH_EDGE", + [10] = "MB_NO_RUNNING", + [11] = "MB_INDOOR_ENCOUNTER", + [12] = "MB_MOUNTAIN_TOP", + [13] = "MB_SECRET_BASE_GLITTER_MATTTLE_PYRAMID_WARP", + [14] = "MB_MOSSDEEP_GYM_WARP", + [15] = "MB_MT_PYRE_HOLE", + [16] = "MB_POND_WATER", + [17] = "MB_SEMI_DEEP_WATER", + [18] = "MB_UNUSED_DEEP_WATER", + [19] = "MB_WATERFALL", + [20] = "MB_SOOTOPOLIS_DEEP_WATER", + [21] = "MB_OCEAN_WATER", + [22] = "MB_PUDDLE", + [23] = "MB_SHALLOW_WATER", + [24] = "MB_UNUSED_SOOTOPOLIS_DEEP_WATER", + [25] = "MB_NO_SURFACING", + [26] = "MB_UNUSED_SOOTOPOLIS_DEEP_WATER_2", + [27] = "MB_STAIRS_OUTSIDE_ABANDONED_SHIP", + [28] = "MB_SHOAL_CAVE_ENTRANCE", + [29] = "MB_UNUSED_1D", + [30] = "MB_UNUSED_1E", + [31] = "MB_UNUSED_1F", + [32] = "MB_ICE", + [33] = "MB_SAND", + [34] = "MB_SEAWEED", + [35] = "MB_UNUSED_23", + [36] = "MB_ASHGRASS", + [37] = "MB_FOOTPRINTS", + [38] = "MB_THIN_ICE", + [39] = "MB_CRACKED_ICE", + [40] = "MB_HOT_SPRINGS", + [41] = "MB_LAVARIDGE_GYM_B1F_WARP", + [42] = "MB_SEAWEED_NO_SURFACING", + [43] = "MB_REFLECTION_UNDER_BRIDGE", + [44] = "MB_UNUSED_2C", + [45] = "MB_UNUSED_2D", + [46] = "MB_UNUSED_2E", + [47] = "MB_UNUSED_2F", + [48] = "MB_IMPASSABLE_EAST", + [49] = "MB_IMPASSABLE_WEST", + [50] = "MB_IMPASSABLE_NORTH", + [51] = "MB_IMPASSABLE_SOUTH", + [52] = "MB_IMPASSABLE_NORTHEAST", + [53] = "MB_IMPASSABLE_NORTHWEST", + [54] = "MB_IMPASSABLE_SOUTHEAST", + [55] = "MB_IMPASSABLE_SOUTHWEST", + [56] = "MB_JUMP_EAST", + [57] = "MB_JUMP_WEST", + [58] = "MB_JUMP_NORTH", + [59] = "MB_JUMP_SOUTH", + [60] = "MB_JUMP_NORTHEAST", + [61] = "MB_JUMP_NORTHWEST", + [62] = "MB_JUMP_SOUTHEAST", + [63] = "MB_JUMP_SOUTHWEST", + [64] = "MB_WALK_EAST", + [65] = "MB_WALK_WEST", + [66] = "MB_WALK_NORTH", + [67] = "MB_WALK_SOUTH", + [68] = "MB_SLIDE_EAST", + [69] = "MB_SLIDE_WEST", + [70] = "MB_SLIDE_NORTH", + [71] = "MB_SLIDE_SOUTH", + [72] = "MB_TRICK_HOUSE_PUZZLE_8_FLOOR", + [73] = "MB_UNUSED_49", + [74] = "MB_UNUSED_4A", + [75] = "MB_UNUSED_4B", + [76] = "MB_UNUSED_4C", + [77] = "MB_UNUSED_4D", + [78] = "MB_UNUSED_4E", + [79] = "MB_UNUSED_4F", + [80] = "MB_UNUSED_EASTWARD_CURRENT", + [81] = "MB_WESTWARD_CURRENT", + [82] = "MB_NORTHWARD_CURRENT", + [83] = "MB_SOUTHWARD_CURRENT", + [84] = "MB_UNUSED_54", + [85] = "MB_UNUSED_55", + [86] = "MB_UNUSED_56", + [87] = "MB_UNUSED_57", + [88] = "MB_UNUSED_58", + [89] = "MB_UNUSED_59", + [90] = "MB_UNUSED_5A", + [91] = "MB_UNUSED_5B", + [92] = "MB_UNUSED_5C", + [93] = "MB_UNUSED_5D", + [94] = "MB_UNUSED_5E", + [95] = "MB_UNUSED_5F", + [96] = "MB_NON_ANIMATED_DOOR", + [97] = "MB_LADDER", + [98] = "MB_UNUSED_EAST_ARROW_WARP", + [99] = "MB_WEST_ARROW_WARP", + [100] = "MB_NORTH_ARROW_WARP", + [101] = "MB_SOUTH_ARROW_WARP", + [102] = "MB_CRACKED_FLOOR_HOLE", + [103] = "MB_AQUA_HIDEOUT_WARP", + [104] = "MB_LAVARIDGE_GYM_1F_WARP", + [105] = "MB_ANIMATED_DOOR", + [106] = "MB_UP_ESCALATOR", + [107] = "MB_DOWN_ESCALATOR", + [108] = "MB_WATER_DOOR", + [109] = "MB_WATER_SOUTH_ARROW_WARP", + [110] = "MB_UNUSED_DEEP_SOUTH_WARP", + [111] = "MB_UNUSED_6F", + [112] = "MB_WARP_OR_BRIDGE", + [113] = "MB_UNUSED_71", + [114] = "MB_ROUTE120_NORTH_BRIDGE_1", + [115] = "MB_ROUTE120_NORTH_BRIDGE_2", + [116] = "MB_PACIFIDLOG_VERTICAL_LOG_1", + [117] = "MB_PACIFIDLOG_VERTICAL_LOG_2", + [118] = "MB_PACIFIDLOG_HORIZONTAL_LOG_1", + [119] = "MB_PACIFIDLOG_HORIZONTAL_LOG_2", + [120] = "MB_FORTREE_BRIDGE", + [121] = "MB_UNUSED_79", + [122] = "MB_ROUTE120_SOUTH_BRIDGE_1", + [123] = "MB_ROUTE120_SOUTH_BRIDGE_2", + [124] = "MB_ROUTE120_NORTH_BRIDGE_3", + [125] = "MB_ROUTE120_NORTH_BRIDGE_4", + [126] = "MB_UNUSED_7E", + [127] = "MB_ROUTE110_BRIDGE", + [128] = "MB_COUNTER", + [129] = "MB_UNUSED_81", + [130] = "MB_UNUSED_82", + [131] = "MB_PC", + [132] = "MB_LINK_BATTLE_RECORDS", + [133] = "MB_REGION_MAP", + [134] = "MB_TELEVISION", + [135] = "MB_POKEBLOCK_FEEDER", + [136] = "MB_UNUSED_88", + [137] = "MB_SLOT_MACHINE", + [138] = "MB_ROULETTE", + [139] = "MB_CLOSED_SOOTOPOLIS_DOOR", + [140] = "MB_TRICK_HOUSE_PUZZLE_DOOR", + [141] = "MB_PETALBURG_GYM_DOOR", + [142] = "MB_RUNNING_SHOES_MANUAL", + [143] = "MB_QUESTIONNAIRE", + [144] = "MB_SECRET_BASE_SPOT_RED_CAVE", + [145] = "MB_SECRET_BASE_SPOT_RED_CAVE_OPEN", + [146] = "MB_SECRET_BASE_SPOT_BROWN_CAVE", + [147] = "MB_SECRET_BASE_SPOT_BROWN_CAVE_OPEN", + [148] = "MB_SECRET_BASE_SPOT_YELLOW_CAVE", + [149] = "MB_SECRET_BASE_SPOT_YELLOW_CAVE_OPEN", + [150] = "MB_SECRET_BASE_SPOT_TREE_1", + [151] = "MB_SECRET_BASE_SPOT_TREE_1_OPEN", + [152] = "MB_SECRET_BASE_SPOT_SHRUB", + [153] = "MB_SECRET_BASE_SPOT_SHRUB_OPEN", + [154] = "MB_SECRET_BASE_SPOT_BLUE_CAVE", + [155] = "MB_SECRET_BASE_SPOT_BLUE_CAVE_OPEN", + [156] = "MB_SECRET_BASE_SPOT_TREE_2", + [157] = "MB_SECRET_BASE_SPOT_TREE_2_OPEN", + [158] = "MB_UNUSED_9E", + [159] = "MB_UNUSED_9F", + [160] = "MB_BERRY_TREE_SOIL", + [161] = "MB_UNUSED_A1", + [162] = "MB_UNUSED_A2", + [163] = "MB_UNUSED_A3", + [164] = "MB_UNUSED_A4", + [165] = "MB_UNUSED_A5", + [166] = "MB_UNUSED_A6", + [167] = "MB_UNUSED_A7", + [168] = "MB_UNUSED_A8", + [169] = "MB_UNUSED_A9", + [170] = "MB_UNUSED_AA", + [171] = "MB_UNUSED_AB", + [172] = "MB_UNUSED_AC", + [173] = "MB_UNUSED_AD", + [174] = "MB_UNUSED_AE", + [175] = "MB_UNUSED_AF", + [176] = "MB_SECRET_BASE_PC", + [177] = "MB_RECORD_MIXING_SECRET_BASE_PC", + [178] = "MB_SECRET_BASE_UNUSED", + [179] = "MB_BLOCK_DECORATION", + [180] = "MB_SECRET_BASE_DECORATION", + [181] = "MB_SECRET_BASE_LARGE_MAT_EDGE", + [182] = "MB_UNUSED_B6", + [183] = "MB_SECRET_BASE_NORTH_WALL", + [184] = "MB_SECRET_BASE_BALLOON", + [185] = "MB_SECRET_BASE_IMPASSABLE", + [186] = "MB_SECRET_BASE_GLITTER_MAT", + [187] = "MB_SECRET_BASE_JUMP_MAT", + [188] = "MB_SECRET_BASE_SPIN_MAT", + [189] = "MB_SECRET_BASE_MUSIC_NOTE_MAT", + [190] = "MB_SECRET_BASE_BREAKABLE_DOOR", + [191] = "MB_SECRET_BASE_SAND_ORNAMENT", + [192] = "MB_IMPASSABLE_SOUTH_AND_NORTH", + [193] = "MB_IMPASSABLE_WEST_AND_EAST", + [194] = "MB_SECRET_BASE_HOLE", + [195] = "MB_LARGE_MAT_CENTER", + [196] = "MB_SECRET_BASE_SHIELD_OR_TOY_TV", + [197] = "MB_PLAYER_ROOM_PC_ON", + [198] = "MB_UNUSED_C6", + [199] = "MB_UNUSED_C7", + [200] = "MB_UNUSED_C8", + [201] = "MB_UNUSED_C9", + [202] = "MB_UNUSED_CA", + [203] = "MB_UNUSED_CB", + [204] = "MB_UNUSED_CC", + [205] = "MB_UNUSED_CD", + [206] = "MB_UNUSED_CE", + [207] = "MB_UNUSED_CF", + [208] = "MB_MUDDY_SLOPE", + [209] = "MB_BUMPY_SLOPE", + [210] = "MB_CRACKED_FLOOR", + [211] = "MB_ISOLATED_VERTICAL_RAIL", + [212] = "MB_ISOLATED_HORIZONTAL_RAIL", + [213] = "MB_VERTICAL_RAIL", + [214] = "MB_HORIZONTAL_RAIL", + [215] = "MB_UNUSED_D7", + [216] = "MB_UNUSED_D8", + [217] = "MB_UNUSED_D9", + [218] = "MB_UNUSED_DA", + [219] = "MB_UNUSED_DB", + [220] = "MB_UNUSED_DC", + [221] = "MB_UNUSED_DD", + [222] = "MB_UNUSED_DE", + [223] = "MB_UNUSED_DF", + [224] = "MB_PICTURE_BOOK_SHELF", + [225] = "MB_BOOKSHELF", + [226] = "MB_POKEMON_CENTER_BOOKSHELF", + [227] = "MB_VASE", + [228] = "MB_TRASH_CAN", + [229] = "MB_SHOP_SHELF", + [230] = "MB_BLUEPRINT", + [231] = "MB_UNUSED_E7", + [232] = "MB_UNUSED_E8", + [233] = "MB_UNUSED_E9", + [234] = "MB_UNUSED_EA", + [235] = "MB_UNUSED_EB", + [236] = "MB_UNUSED_EC", + [237] = "MB_UNUSED_ED", + [238] = "MB_UNUSED_EE", + [239] = "MB_UNUSED_EF", + [255] = "MB_INVALID", + }, + sapphire = { + [0] = "MB_NORMAL", + [1] = "MB_SECRET_BASE_WALL", + [2] = "MB_TALL_GRASS", + [3] = "MB_LONG_GRASS", + [4] = "MB_UNUSED_04", + [5] = "MB_UNUSED_05", + [6] = "MB_UNUSED_DEEP_SAND", + [7] = "MB_SHORT_GRASS", + [8] = "MB_UNUSED_CAVE", + [9] = "MB_LONG_GRASS_SOUTH_EDGE", + [10] = "MB_NO_RUNNING", + [11] = "MB_INDOOR_ENCOUNTER", + [12] = "MB_MOUNTAIN_TOP", + [13] = "MB_SECRET_BASE_GLITTER_MATTTLE_PYRAMID_WARP", + [14] = "MB_MOSSDEEP_GYM_WARP", + [15] = "MB_MT_PYRE_HOLE", + [16] = "MB_POND_WATER", + [17] = "MB_SEMI_DEEP_WATER", + [18] = "MB_UNUSED_DEEP_WATER", + [19] = "MB_WATERFALL", + [20] = "MB_SOOTOPOLIS_DEEP_WATER", + [21] = "MB_OCEAN_WATER", + [22] = "MB_PUDDLE", + [23] = "MB_SHALLOW_WATER", + [24] = "MB_UNUSED_SOOTOPOLIS_DEEP_WATER", + [25] = "MB_NO_SURFACING", + [26] = "MB_UNUSED_SOOTOPOLIS_DEEP_WATER_2", + [27] = "MB_STAIRS_OUTSIDE_ABANDONED_SHIP", + [28] = "MB_SHOAL_CAVE_ENTRANCE", + [29] = "MB_UNUSED_1D", + [30] = "MB_UNUSED_1E", + [31] = "MB_UNUSED_1F", + [32] = "MB_ICE", + [33] = "MB_SAND", + [34] = "MB_SEAWEED", + [35] = "MB_UNUSED_23", + [36] = "MB_ASHGRASS", + [37] = "MB_FOOTPRINTS", + [38] = "MB_THIN_ICE", + [39] = "MB_CRACKED_ICE", + [40] = "MB_HOT_SPRINGS", + [41] = "MB_LAVARIDGE_GYM_B1F_WARP", + [42] = "MB_SEAWEED_NO_SURFACING", + [43] = "MB_REFLECTION_UNDER_BRIDGE", + [44] = "MB_UNUSED_2C", + [45] = "MB_UNUSED_2D", + [46] = "MB_UNUSED_2E", + [47] = "MB_UNUSED_2F", + [48] = "MB_IMPASSABLE_EAST", + [49] = "MB_IMPASSABLE_WEST", + [50] = "MB_IMPASSABLE_NORTH", + [51] = "MB_IMPASSABLE_SOUTH", + [52] = "MB_IMPASSABLE_NORTHEAST", + [53] = "MB_IMPASSABLE_NORTHWEST", + [54] = "MB_IMPASSABLE_SOUTHEAST", + [55] = "MB_IMPASSABLE_SOUTHWEST", + [56] = "MB_JUMP_EAST", + [57] = "MB_JUMP_WEST", + [58] = "MB_JUMP_NORTH", + [59] = "MB_JUMP_SOUTH", + [60] = "MB_JUMP_NORTHEAST", + [61] = "MB_JUMP_NORTHWEST", + [62] = "MB_JUMP_SOUTHEAST", + [63] = "MB_JUMP_SOUTHWEST", + [64] = "MB_WALK_EAST", + [65] = "MB_WALK_WEST", + [66] = "MB_WALK_NORTH", + [67] = "MB_WALK_SOUTH", + [68] = "MB_SLIDE_EAST", + [69] = "MB_SLIDE_WEST", + [70] = "MB_SLIDE_NORTH", + [71] = "MB_SLIDE_SOUTH", + [72] = "MB_TRICK_HOUSE_PUZZLE_8_FLOOR", + [73] = "MB_UNUSED_49", + [74] = "MB_UNUSED_4A", + [75] = "MB_UNUSED_4B", + [76] = "MB_UNUSED_4C", + [77] = "MB_UNUSED_4D", + [78] = "MB_UNUSED_4E", + [79] = "MB_UNUSED_4F", + [80] = "MB_UNUSED_EASTWARD_CURRENT", + [81] = "MB_WESTWARD_CURRENT", + [82] = "MB_NORTHWARD_CURRENT", + [83] = "MB_SOUTHWARD_CURRENT", + [84] = "MB_UNUSED_54", + [85] = "MB_UNUSED_55", + [86] = "MB_UNUSED_56", + [87] = "MB_UNUSED_57", + [88] = "MB_UNUSED_58", + [89] = "MB_UNUSED_59", + [90] = "MB_UNUSED_5A", + [91] = "MB_UNUSED_5B", + [92] = "MB_UNUSED_5C", + [93] = "MB_UNUSED_5D", + [94] = "MB_UNUSED_5E", + [95] = "MB_UNUSED_5F", + [96] = "MB_NON_ANIMATED_DOOR", + [97] = "MB_LADDER", + [98] = "MB_UNUSED_EAST_ARROW_WARP", + [99] = "MB_WEST_ARROW_WARP", + [100] = "MB_NORTH_ARROW_WARP", + [101] = "MB_SOUTH_ARROW_WARP", + [102] = "MB_CRACKED_FLOOR_HOLE", + [103] = "MB_AQUA_HIDEOUT_WARP", + [104] = "MB_LAVARIDGE_GYM_1F_WARP", + [105] = "MB_ANIMATED_DOOR", + [106] = "MB_UP_ESCALATOR", + [107] = "MB_DOWN_ESCALATOR", + [108] = "MB_WATER_DOOR", + [109] = "MB_WATER_SOUTH_ARROW_WARP", + [110] = "MB_UNUSED_DEEP_SOUTH_WARP", + [111] = "MB_UNUSED_6F", + [112] = "MB_WARP_OR_BRIDGE", + [113] = "MB_UNUSED_71", + [114] = "MB_ROUTE120_NORTH_BRIDGE_1", + [115] = "MB_ROUTE120_NORTH_BRIDGE_2", + [116] = "MB_PACIFIDLOG_VERTICAL_LOG_1", + [117] = "MB_PACIFIDLOG_VERTICAL_LOG_2", + [118] = "MB_PACIFIDLOG_HORIZONTAL_LOG_1", + [119] = "MB_PACIFIDLOG_HORIZONTAL_LOG_2", + [120] = "MB_FORTREE_BRIDGE", + [121] = "MB_UNUSED_79", + [122] = "MB_ROUTE120_SOUTH_BRIDGE_1", + [123] = "MB_ROUTE120_SOUTH_BRIDGE_2", + [124] = "MB_ROUTE120_NORTH_BRIDGE_3", + [125] = "MB_ROUTE120_NORTH_BRIDGE_4", + [126] = "MB_UNUSED_7E", + [127] = "MB_ROUTE110_BRIDGE", + [128] = "MB_COUNTER", + [129] = "MB_UNUSED_81", + [130] = "MB_UNUSED_82", + [131] = "MB_PC", + [132] = "MB_LINK_BATTLE_RECORDS", + [133] = "MB_REGION_MAP", + [134] = "MB_TELEVISION", + [135] = "MB_POKEBLOCK_FEEDER", + [136] = "MB_UNUSED_88", + [137] = "MB_SLOT_MACHINE", + [138] = "MB_ROULETTE", + [139] = "MB_CLOSED_SOOTOPOLIS_DOOR", + [140] = "MB_TRICK_HOUSE_PUZZLE_DOOR", + [141] = "MB_PETALBURG_GYM_DOOR", + [142] = "MB_RUNNING_SHOES_MANUAL", + [143] = "MB_QUESTIONNAIRE", + [144] = "MB_SECRET_BASE_SPOT_RED_CAVE", + [145] = "MB_SECRET_BASE_SPOT_RED_CAVE_OPEN", + [146] = "MB_SECRET_BASE_SPOT_BROWN_CAVE", + [147] = "MB_SECRET_BASE_SPOT_BROWN_CAVE_OPEN", + [148] = "MB_SECRET_BASE_SPOT_YELLOW_CAVE", + [149] = "MB_SECRET_BASE_SPOT_YELLOW_CAVE_OPEN", + [150] = "MB_SECRET_BASE_SPOT_TREE_1", + [151] = "MB_SECRET_BASE_SPOT_TREE_1_OPEN", + [152] = "MB_SECRET_BASE_SPOT_SHRUB", + [153] = "MB_SECRET_BASE_SPOT_SHRUB_OPEN", + [154] = "MB_SECRET_BASE_SPOT_BLUE_CAVE", + [155] = "MB_SECRET_BASE_SPOT_BLUE_CAVE_OPEN", + [156] = "MB_SECRET_BASE_SPOT_TREE_2", + [157] = "MB_SECRET_BASE_SPOT_TREE_2_OPEN", + [158] = "MB_UNUSED_9E", + [159] = "MB_UNUSED_9F", + [160] = "MB_BERRY_TREE_SOIL", + [161] = "MB_UNUSED_A1", + [162] = "MB_UNUSED_A2", + [163] = "MB_UNUSED_A3", + [164] = "MB_UNUSED_A4", + [165] = "MB_UNUSED_A5", + [166] = "MB_UNUSED_A6", + [167] = "MB_UNUSED_A7", + [168] = "MB_UNUSED_A8", + [169] = "MB_UNUSED_A9", + [170] = "MB_UNUSED_AA", + [171] = "MB_UNUSED_AB", + [172] = "MB_UNUSED_AC", + [173] = "MB_UNUSED_AD", + [174] = "MB_UNUSED_AE", + [175] = "MB_UNUSED_AF", + [176] = "MB_SECRET_BASE_PC", + [177] = "MB_RECORD_MIXING_SECRET_BASE_PC", + [178] = "MB_SECRET_BASE_UNUSED", + [179] = "MB_BLOCK_DECORATION", + [180] = "MB_SECRET_BASE_DECORATION", + [181] = "MB_SECRET_BASE_LARGE_MAT_EDGE", + [182] = "MB_UNUSED_B6", + [183] = "MB_SECRET_BASE_NORTH_WALL", + [184] = "MB_SECRET_BASE_BALLOON", + [185] = "MB_SECRET_BASE_IMPASSABLE", + [186] = "MB_SECRET_BASE_GLITTER_MAT", + [187] = "MB_SECRET_BASE_JUMP_MAT", + [188] = "MB_SECRET_BASE_SPIN_MAT", + [189] = "MB_SECRET_BASE_MUSIC_NOTE_MAT", + [190] = "MB_SECRET_BASE_BREAKABLE_DOOR", + [191] = "MB_SECRET_BASE_SAND_ORNAMENT", + [192] = "MB_IMPASSABLE_SOUTH_AND_NORTH", + [193] = "MB_IMPASSABLE_WEST_AND_EAST", + [194] = "MB_SECRET_BASE_HOLE", + [195] = "MB_LARGE_MAT_CENTER", + [196] = "MB_SECRET_BASE_SHIELD_OR_TOY_TV", + [197] = "MB_PLAYER_ROOM_PC_ON", + [198] = "MB_UNUSED_C6", + [199] = "MB_UNUSED_C7", + [200] = "MB_UNUSED_C8", + [201] = "MB_UNUSED_C9", + [202] = "MB_UNUSED_CA", + [203] = "MB_UNUSED_CB", + [204] = "MB_UNUSED_CC", + [205] = "MB_UNUSED_CD", + [206] = "MB_UNUSED_CE", + [207] = "MB_UNUSED_CF", + [208] = "MB_MUDDY_SLOPE", + [209] = "MB_BUMPY_SLOPE", + [210] = "MB_CRACKED_FLOOR", + [211] = "MB_ISOLATED_VERTICAL_RAIL", + [212] = "MB_ISOLATED_HORIZONTAL_RAIL", + [213] = "MB_VERTICAL_RAIL", + [214] = "MB_HORIZONTAL_RAIL", + [215] = "MB_UNUSED_D7", + [216] = "MB_UNUSED_D8", + [217] = "MB_UNUSED_D9", + [218] = "MB_UNUSED_DA", + [219] = "MB_UNUSED_DB", + [220] = "MB_UNUSED_DC", + [221] = "MB_UNUSED_DD", + [222] = "MB_UNUSED_DE", + [223] = "MB_UNUSED_DF", + [224] = "MB_PICTURE_BOOK_SHELF", + [225] = "MB_BOOKSHELF", + [226] = "MB_POKEMON_CENTER_BOOKSHELF", + [227] = "MB_VASE", + [228] = "MB_TRASH_CAN", + [229] = "MB_SHOP_SHELF", + [230] = "MB_BLUEPRINT", + [231] = "MB_UNUSED_E7", + [232] = "MB_UNUSED_E8", + [233] = "MB_UNUSED_E9", + [234] = "MB_UNUSED_EA", + [235] = "MB_UNUSED_EB", + [236] = "MB_UNUSED_EC", + [237] = "MB_UNUSED_ED", + [238] = "MB_UNUSED_EE", + [239] = "MB_UNUSED_EF", + [255] = "MB_INVALID", + }, + }, +} diff --git a/src/compat/gen1/NativeSession.lua b/src/compat/gen1/NativeSession.lua new file mode 100644 index 000000000..b98db4b08 --- /dev/null +++ b/src/compat/gen1/NativeSession.lua @@ -0,0 +1,79 @@ +-- Resolve the live owner without bootstrapping Game or keeping a retired world. +local Version = require('src.core.GameVersion') +local M = {} +local entering=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +function M.withWorld(game,world,fn,...) + local owner,why=M.game(game);assert(owner,why) + assert(type(world)=='table'and world.isOverworld and world.map and world.player + and owner.data and owner.data.maps and owner.data.maps[world.map.id]==world.map.def,'foreign native world scope') + local previous=entering[owner];entering[owner]=world + local result=pack(pcall(fn,...));entering[owner]=previous + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function M.game(game) + local explicit = game ~= nil + local facade = package.loaded['src.core.Game3'] + if type(facade) == 'table' and type(facade.native) == 'function' then + game = facade.native(game) + if explicit and game == nil then return nil, 'native Game3 facade retired' end + end + game = game or package.loaded['src.core.Game'] + local info = Version.info(Version.get()) + if type(game) ~= 'table' or not info or info.generation ~= 3 then + return nil, 'no native GBA session' + end + return game +end +function M.data(game) + local owner,why=M.game(game);if not owner then return nil,why end + local data=owner.data + if type(data)~='table'or type(data.tilesets)~='table'or type(data.map_tilesets)~='table' + or type(data.constants)~='table'or type(data.constants.gen3Layout)~='table'then + return nil,'native GBA tileset data unavailable' + end + return owner,data +end +-- Admission lookup is also used by map readers that can run before a player +-- is constructed. Edition/data validation stays with each typed consumer. +function M.world(game) + if type(game)~='table'then return nil end + local states = game.stack and game.stack.states + local world=entering[game] + if not world and type(states) == 'table' then + for i = #states, 1, -1 do + if states[i].isOverworld then world = states[i]; break end + end + -- The physical list can contain overlays only. Its persistent world is + -- still a real native admission, independently of the convenience pointer. + if not world then world=game.stack.worldState end + elseif not world then + world = game.overworld + end + return world and world.isOverworld and world or nil +end +function M.resolve(game) + local why;game,why=M.game(game);if not game then return nil,why end + local world=M.world(game) + if not (world and world.isOverworld and world.player and world.map) then + return nil, 'no active native player' + end + if not (game.data and game.data.maps and world.map.def + and game.data.maps[world.map.id] == world.map.def) then + return nil, 'native world belongs to a retired dataset' + end + return game, world, world.player +end +function M.acceptsInput(game, world, player) + local owner, current, actor = M.resolve(game) + if owner ~= game or current ~= world or actor ~= player then return false end + local states = game.stack and game.stack.states + if type(states)=='table' then + local active=type(game.stack.active)=='function' and game.stack:active() or states[#states] + if active~=world then return false end + end + return not world.battleActive and not (world.busy and world:busy()) +end +return M diff --git a/src/compat/gen1/ObjectRuntime.lua b/src/compat/gen1/ObjectRuntime.lua new file mode 100644 index 000000000..b2734daaf --- /dev/null +++ b/src/compat/gen1/ObjectRuntime.lua @@ -0,0 +1,208 @@ +-- Owned, live observations of the native object pool. No second actor loop. +local Session = require('src.compat.gen1.NativeSession') +local Identity = require('src.compat.gen1.MapIdentity') +local Version = require('src.core.GameVersion') +local R = {}; R.__index = R +local directions = {up=true,down=true,left=true,right=true} +local function finite(v) return type(v)=='number' and v==v and math.abs(v)=lo and v<=hi end +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 fields={cellX='cellX',cellY='cellY',px='px',py='py',targetX='targetX',targetY='targetY', + moving='moving',progress='progress',facing='facing',stepFlip='stepFlip',animClock='animClock', + invisible='invisible',hidden='hidden',passable='passable',runtime='runtime', + customFrame='fixedFrame',raiseX='shiftPx',homeX='roamOriginX',homeY='roamOriginY'} +local boolWrite={hidden='hidden',invisible='invisible',passable='passable'} +function R.new() return setmetatable({views=setmetatable({},{__mode='kv'}),records=setmetatable({},{__mode='kv'})},R) end +function R:release() + if self.context then self.context.retired=true end + self.context=nil +end +function R:current(game) + local g,w,p=Session.resolve(game) + local c=self.context + if not g then self:release(); return nil,w end + if c and (c.retired or c.game~=g or c.world~=w or c.player~=p or c.map~=w.map or c.def~=w.map.def or c.data~=g.data + or c.maps~=g.data.maps or c.save~=g.save or c.loader~=g.mods or c.edition~=Version.get() + or c.binding:legacyId(w.map.id)~=c.id) then + self:release(); c=nil + end + if not c then + c={game=g,world=w,player=p,map=w.map,def=w.map.def,data=g.data,maps=g.data.maps, + save=g.save,loader=g.mods,edition=Version.get()} + c.binding=Identity.bind(c.maps,c.edition) + local why; c.id,why=c.binding:legacyId(w.map.id) + if not c.id then return nil,why end + self.context=c + end + return c +end +local function live(c) + if c.retired then return false end + local g,w,p=Session.resolve(c.game) + if g~=c.game or w~=c.world or p~=c.player or w.map~=c.map or w.map.def~=c.def + or g.data~=c.data or g.data.maps~=c.maps or g.save~=c.save or g.mods~=c.loader + or Version.get()~=c.edition or c.binding:legacyId(c.map.id)~=c.id then c.retired=true; return false end + return true +end +local function member(c,e,mapId) + if not live(c) then return false end + if mapId==c.map.id then + for _,a in ipairs(c.world.npcs or c.world.entities or {}) do if a==e then return true end end + else + for _,nb in ipairs(c.world.neighbors or {}) do + if nb.map and nb.map.id==mapId and c.maps[mapId]==nb.map.def then + for _,a in ipairs(nb.peers or {}) do if a==e then return true end end + end + end + for _,ghost in ipairs(c.world.ghosts or {}) do + local map=ghost.nb and ghost.nb.map + if map and map.id==mapId and c.maps[mapId]==map.def and ghost.npc==e then return true end + end + end + return false +end +function R:view(c,e,mapId) + local class=package.loaded['src.world.NPC'] + if type(class)~='table' or getmetatable(e)~=class or type(e.def)~='table' + or not integer(e.def.index,1,65535) then return nil,'unsupported native object identity' end + local id,why=c.binding:legacyId(mapId); if not id then return nil,why end + local record=self.views[e] + if record and record.context==c and record.mapId==mapId and not record.retired then return record.view end + record={context=c,actor=e,mapId=mapId,extras={},definition=copy(e.def)} + record.definition.localId=e.def.index + local function valid() + if record.retired then return false end + if not member(c,e,mapId) or c.binding:legacyId(mapId)~=id then record.retired=true; return false end + return true + end + local function read(_,key) + if not valid() then return nil end + if key=='passable' then return e.passable==true or e.def.passable==true end + if key=='runtime' then return e.def.runtime==true end + if fields[key] then return e[fields[key]] end + if key=='def' then return record.definition end + if key=='localId' or key=='originLocalId' then return e.def.index end + if key=='id' then return e.id end + if key=='originMapId' then return id end + if key=='originMapGroup' then return c.maps[mapId].group end + if key=='originMapNum' then return c.maps[mapId].number end + if key=='graphicsId' then + -- The extractor preserves the cartridge row on the actual sprite. + -- Variable sprites resolve to a different sheet without changing def. + local sprite=e.sprite and e.sprite.def + local key=type(sprite)=='table' and sprite.id + return type(key)=='string' and tonumber(key:match('^SPRITE_G3_(%d+)$')) or e.def.graphicsId + end + if key=='visible' then return not(e.hidden or e.visible==false or e.berryVisualEmpty) end + if key=='currentElevation' or key=='elevation' then return e.elevation or e.gen3Elevation or e.def.elevation or 0 end + if key=='stepFrames' then return e.scriptStepLen or e.stepFrames or 16 end + if key=='raiseY' then + local observed={}; for k,v in pairs(e) do observed[k]=v end + setmetatable(observed,class) + local _,_,py=class.pose(observed) + return e.py-py + end + if key=='movementType' then return e.gen3MovementType or e.def.movementType end + if key=='frozen' then return e.frozen==true or e.gen3ScriptFrozen==true end + if key=='scriptBusy' then + for _,mv in ipairs(c.world.scriptMoves or {}) do if mv.entity==e then return true end end + return false + end + return record.extras[key] + end + local function write(_,key,value) + assert(valid(),'native object view retired') + if key=='sprite'then + assert(value==nil or type(value)=='table'and type(value.draw)=='function','invalid object renderer') + local host=require('src.mods.Runtime');local facade=require('src.core.Game3') + local who=host.currentMod or facade._callbackOwner();assert(who,'object renderer requires a mod owner') + record.extras.sprite=value;record.presentationSet=true;record.presentationOwner=who + require('src.compat.gen1.SourceActors').bind(e,record) + elseif boolWrite[key] then + assert(type(value)=='boolean','invalid object boolean'); e[boolWrite[key]]=value + elseif key=='visible' then + assert(type(value)=='boolean','invalid object visibility'); e.hidden=not value; e.visible=value + elseif key=='facing' then + assert(directions[value],'invalid object facing'); e.facing=value + elseif key=='raiseX' then + assert(finite(value) and math.abs(value)<=16777216,'invalid object offset'); e.shiftPx=value + elseif key=='customFrame' then + local frames=e.sprite and e.sprite.def and math.max(e.sprite.def.frames or 1,e.sprite.def.poseFrames or 1) or 1 + assert(value==nil or integer(value,0,frames-1),'invalid object frame'); e.fixedFrame=value + elseif key=='frozen' then + assert(type(value)=='boolean','invalid object freeze'); e.gen3ScriptFrozen=value or nil + elseif fields[key] or key=='def' or key=='localId' or key=='graphicsId' or key=='id' + or key=='currentElevation' or key=='elevation' or key=='stepFrames' or key=='movementType' + or key=='scriptBusy' or key=='raiseY' or type(key)=='string' and key:match('^origin') then + error('object '..key..' requires a native command owner',2) + else record.extras[key]=value end + end + record.view=setmetatable({},{__index=read,__newindex=write}) + self.views[e]=record + self.records[record.view]=record + return record.view +end +function R:actor(view) + local r=view and self.records[view] + if r then + if not r.retired and r.view.localId~=nil then return r.actor,r.context,r.mapId end + r.retired=true; return nil,'native object view retired' + end + return nil,'foreign object view' +end +function R:pool(mapId,game) + local c,why=self:current(game); if not c then return nil,why end + local nativeId=c.map.id + if mapId then + nativeId=c.binding:nativeId(mapId) + if not nativeId then + -- Registries can acquire authored maps without replacing data.maps. + -- Re-index on a miss; never fall back to the current map's objects. + c.binding=Identity.bind(c.maps,c.edition) + nativeId=c.binding:nativeId(mapId) + end + if not nativeId then return nil,'unknown object map' end + end + local list,seen,ids={},{},{} + local function add(e) + if seen[e] then return true end + local view,err=self:view(c,e,nativeId); if not view then return nil,err end + if ids[view.localId] then return nil,'ambiguous native object local ID' end + seen[e]=true; ids[view.localId]=true; list[#list+1]=view + return true + end + if nativeId==c.map.id then + for _,e in ipairs(c.world.npcs or c.world.entities or {}) do + if e~=c.player then local ok,err=add(e); if not ok then return nil,err end end + end + else + for _,nb in ipairs(c.world.neighbors or {}) do + if nb.map and nb.map.id==nativeId then + for _,e in ipairs(nb.peers or {}) do local ok,err=add(e); if not ok then return nil,err end end + end + end + for _,ghost in ipairs(c.world.ghosts or {}) do + if ghost.nb and ghost.nb.map and ghost.nb.map.id==nativeId then + local ok,err=add(ghost.npc); if not ok then return nil,err end + end + end + end + return list,c +end +function R:walkPhase(view) + local e=self:actor(view) + return e and not e.sliding and e:walkPhase() or 0 +end +R.integer=integer +R.copy=copy +R.directions=directions +return R diff --git a/src/compat/gen1/PairIdentity.lua b/src/compat/gen1/PairIdentity.lua new file mode 100644 index 000000000..d9b7a125c --- /dev/null +++ b/src/compat/gen1/PairIdentity.lua @@ -0,0 +1,82 @@ +-- Semantic texture names resolve from BOTH original source bank identities. +-- No map-name guesses, shared aliases, texture allocation or global edition. +local metadata = require('src.compat.gen1.PairNames') +local M, indexes = {}, {} +local function address(n) + return type(n) == 'number' and n >= 0 and n < 0x2000000 and n % 1 == 0 +end +local function index(edition) + if indexes[edition] then return indexes[edition] end + local rows = metadata[edition]; if not rows then return nil end + local out = {} + for _, row in ipairs(rows) do + local p = out[row[1]] or {}; out[row[1]] = p + assert(not p[row[2]], 'ambiguous source pair metadata'); p[row[2]] = row + end + indexes[edition] = out; return out +end +function M.lookup(edition, primary, secondary) + local rows = index(edition) + local row = rows and address(primary) and address(secondary) and rows[primary] and rows[primary][secondary] + if row then return row[3], row[4], row[5], row[6] end +end +function M.bind(pairDefs, banks, edition) + assert(type(pairDefs) == 'table' and type(banks) == 'table', 'native pair and bank tables required') + assert(index(edition), 'unsupported pair identity edition') + local byNative, byName, ambiguous, released = {}, {}, {}, false + for key, def in pairs(pairDefs) do + if type(key) == 'string' and type(def) == 'table' then + local primary, secondary = banks[def.primaryKey], banks[def.secondaryKey] + if type(primary) == 'table' and type(secondary) == 'table' + and primary.secondary == false and secondary.secondary == true then + local name, pn, sn, tileset = M.lookup(edition, primary.address, secondary.address) + if name then + local row = {id=name,nativeId=key,primary=pn,secondary=sn, + primaryKey=def.primaryKey,secondaryKey=def.secondaryKey,tileset=tileset, + pa=primary.address,sa=secondary.address,def=def,p=primary,s=secondary} + byNative[key] = row + local prior = byName[name] + if prior and prior.def ~= def then ambiguous[name] = true + elseif not prior or key == def.id then byName[name] = row end + end + end + end + end + local function valid(row) + return not released and row and pairDefs[row.nativeId] == row.def + and row.def.primaryKey == row.primaryKey and row.def.secondaryKey == row.secondaryKey + and banks[row.primaryKey] == row.p and banks[row.secondaryKey] == row.s + and row.p.address == row.pa and row.s.address == row.sa + and row.p.secondary == false and row.s.secondary == true + end + local binding = {} + function binding:nativeId(name) + if released then return nil, 'pair binding released' end + if ambiguous[name] then return nil, 'ambiguous native pair identity' end + local row = byName[name] + if not valid(row) then return nil, 'native pair unavailable or retired' end + return row.nativeId + end + function binding:canonicalId(key) + if released then return nil, 'pair binding released' end + local row = byNative[key] + if not valid(row) then return nil, 'native pair unavailable or retired' end + return row.id + end + function binding:descriptor(key) + if released then return nil, 'pair binding released' end + local row = byNative[key] + if not row then + local native, why = self:nativeId(key); if not native then return nil, why end + row = byNative[native] + end + if not valid(row) then return nil, 'native pair unavailable or retired' end + return {id=row.id,nativeId=row.nativeId,primary=row.primary,secondary=row.secondary, + primaryKey=row.primaryKey,secondaryKey=row.secondaryKey,tileset=row.tileset} + end + function binding:release() + released = true; pairDefs, banks, byNative, byName, ambiguous = nil, nil, {}, {}, {} + end + return binding +end +return M diff --git a/src/compat/gen1/PairNames.lua b/src/compat/gen1/PairNames.lua new file mode 100644 index 000000000..526198348 --- /dev/null +++ b/src/compat/gen1/PairNames.lua @@ -0,0 +1,318 @@ +-- Public semantic names and source addresses; no cartridge content. +-- Reference: 37a49b31f458d51ea9e00b90f2a247272ad09cb8 +-- Regenerate with tools/pair-identities/export.lua and assemble.py. +return { + firered = { + {2968212, 2968236, "pallet_outdoor", "general", "pallet_town", "FR_PALLET_OUTDOOR"}, + {2968212, 2968260, "viridian_outdoor", "general", "viridian_city", "FR_VIRIDIAN_OUTDOOR"}, + {2968212, 2968284, "pewter_outdoor", "general", "pewter_city", "FR_PEWTER_OUTDOOR"}, + {2968212, 2968308, "general__rom_082d4af4", "general", "rom_082d4af4", "FR_GENERAL_ROM_082D4AF4"}, + {2968212, 2968332, "general__rom_082d4b0c", "general", "rom_082d4b0c", "FR_GENERAL_ROM_082D4B0C"}, + {2968212, 2968356, "general__rom_082d4b24", "general", "rom_082d4b24", "FR_GENERAL_ROM_082D4B24"}, + {2968212, 2968380, "general__rom_082d4b3c", "general", "rom_082d4b3c", "FR_GENERAL_ROM_082D4B3C"}, + {2968212, 2968404, "general__rom_082d4b54", "general", "rom_082d4b54", "FR_GENERAL_ROM_082D4B54"}, + {2968212, 2968428, "general__rom_082d4b6c", "general", "rom_082d4b6c", "FR_GENERAL_ROM_082D4B6C"}, + {2968212, 2968452, "general__rom_082d4b84", "general", "rom_082d4b84", "FR_GENERAL_ROM_082D4B84"}, + {2968212, 2968476, "general__rom_082d4b9c", "general", "rom_082d4b9c", "FR_GENERAL_ROM_082D4B9C"}, + {2968212, 2968572, "general__rom_082d4bfc", "general", "rom_082d4bfc", "FR_GENERAL_ROM_082D4BFC"}, + {2968212, 2968980, "general__rom_082d4d94", "general", "rom_082d4d94", "FR_GENERAL_ROM_082D4D94"}, + {2968212, 2969028, "general__rom_082d4dc4", "general", "rom_082d4dc4", "FR_GENERAL_ROM_082D4DC4"}, + {2968212, 2969076, "general__rom_082d4df4", "general", "rom_082d4df4", "FR_GENERAL_ROM_082D4DF4"}, + {2968212, 2969100, "general__rom_082d4e0c", "general", "rom_082d4e0c", "FR_GENERAL_ROM_082D4E0C"}, + {2968212, 2969124, "general__rom_082d4e24", "general", "rom_082d4e24", "FR_GENERAL_ROM_082D4E24"}, + {2968212, 2969172, "general__rom_082d4e54", "general", "rom_082d4e54", "FR_GENERAL_ROM_082D4E54"}, + {2968212, 2969580, "general__rom_082d4fec", "general", "rom_082d4fec", "FR_GENERAL_ROM_082D4FEC"}, + {2968212, 2969604, "general__rom_082d5004", "general", "rom_082d5004", "FR_GENERAL_ROM_082D5004"}, + {2968212, 2969628, "general__rom_082d501c", "general", "rom_082d501c", "FR_GENERAL_ROM_082D501C"}, + {2968212, 2969676, "sevii_outdoor", "general", "sevii_islands_123", "SEVII_OUTDOOR"}, + {2968212, 2969700, "general__rom_082d5064", "general", "rom_082d5064", "FR_GENERAL_ROM_082D5064"}, + {2968212, 2969724, "general__rom_082d507c", "general", "rom_082d507c", "FR_GENERAL_ROM_082D507C"}, + {2968212, 2969772, "harbor", "general", "island_harbor", "SEVII_HARBOR"}, + {2968500, 2968524, "building__rom_082d4bcc", "building", "rom_082d4bcc", "FR_BUILDING_ROM_082D4BCC"}, + {2968500, 2968548, "network", "building", "pokemon_center", "SEVII_NETWORK"}, + {2968500, 2968620, "building__rom_082d4c2c", "building", "rom_082d4c2c", "FR_BUILDING_ROM_082D4C2C"}, + {2968500, 2968644, "building__rom_082d4c44", "building", "rom_082d4c44", "FR_BUILDING_ROM_082D4C44"}, + {2968500, 2968668, "building__rom_082d4c5c", "building", "rom_082d4c5c", "FR_BUILDING_ROM_082D4C5C"}, + {2968500, 2968692, "player_house", "building", "pretty_petals_flower_shop", "FR_PLAYER_HOUSE"}, + {2968500, 2968716, "oak_lab", "building", "lab", "FR_OAK_LAB"}, + {2968500, 2968740, "building__rom_082d4ca4", "building", "rom_082d4ca4", "FR_BUILDING_ROM_082D4CA4"}, + {2968500, 2968764, "building__rom_082d4cbc", "building", "rom_082d4cbc", "FR_BUILDING_ROM_082D4CBC"}, + {2968500, 2968812, "building__rom_082d4cec", "building", "rom_082d4cec", "FR_BUILDING_ROM_082D4CEC"}, + {2968500, 2968836, "pewter_gym", "building", "pewter_gym", "FR_PEWTER_GYM"}, + {2968500, 2968860, "building__rom_082d4d1c", "building", "rom_082d4d1c", "FR_BUILDING_ROM_082D4D1C"}, + {2968500, 2968884, "building__rom_082d4d34", "building", "rom_082d4d34", "FR_BUILDING_ROM_082D4D34"}, + {2968500, 2968908, "building__rom_082d4d4c", "building", "rom_082d4d4c", "FR_BUILDING_ROM_082D4D4C"}, + {2968500, 2968932, "building__rom_082d4d64", "building", "rom_082d4d64", "FR_BUILDING_ROM_082D4D64"}, + {2968500, 2968956, "building__rom_082d4d7c", "building", "rom_082d4d7c", "FR_BUILDING_ROM_082D4D7C"}, + {2968500, 2969124, "building__rom_082d4e24", "building", "rom_082d4e24", "FR_BUILDING_ROM_082D4E24"}, + {2968500, 2969196, "building__rom_082d4e6c", "building", "rom_082d4e6c", "FR_BUILDING_ROM_082D4E6C"}, + {2968500, 2969220, "house", "building", "generic_building_2", "SEVII_HOUSE"}, + {2968500, 2969244, "building__rom_082d4e9c", "building", "rom_082d4e9c", "FR_BUILDING_ROM_082D4E9C"}, + {2968500, 2969268, "building__rom_082d4eb4", "building", "rom_082d4eb4", "FR_BUILDING_ROM_082D4EB4"}, + {2968500, 2969292, "building__rom_082d4ecc", "building", "rom_082d4ecc", "FR_BUILDING_ROM_082D4ECC"}, + {2968500, 2969316, "building__rom_082d4ee4", "building", "rom_082d4ee4", "FR_BUILDING_ROM_082D4EE4"}, + {2968500, 2969340, "building__rom_082d4efc", "building", "rom_082d4efc", "FR_BUILDING_ROM_082D4EFC"}, + {2968500, 2969364, "building__rom_082d4f14", "building", "rom_082d4f14", "FR_BUILDING_ROM_082D4F14"}, + {2968500, 2969388, "building__rom_082d4f2c", "building", "rom_082d4f2c", "FR_BUILDING_ROM_082D4F2C"}, + {2968500, 2969412, "building__rom_082d4f44", "building", "rom_082d4f44", "FR_BUILDING_ROM_082D4F44"}, + {2968500, 2969436, "building__rom_082d4f5c", "building", "rom_082d4f5c", "FR_BUILDING_ROM_082D4F5C"}, + {2968500, 2969460, "building__rom_082d4f74", "building", "rom_082d4f74", "FR_BUILDING_ROM_082D4F74"}, + {2968500, 2969484, "building__rom_082d4f8c", "building", "rom_082d4f8c", "FR_BUILDING_ROM_082D4F8C"}, + {2968500, 2969508, "building__rom_082d4fa4", "building", "rom_082d4fa4", "FR_BUILDING_ROM_082D4FA4"}, + {2968500, 2969652, "building__rom_082d5034", "building", "rom_082d5034", "FR_BUILDING_ROM_082D5034"}, + {2968500, 2969748, "building__rom_082d5094", "building", "rom_082d5094", "FR_BUILDING_ROM_082D5094"}, + {2968500, 2969796, "building__rom_082d50c4", "building", "rom_082d50c4", "FR_BUILDING_ROM_082D50C4"}, + {2968500, 2969820, "building__rom_082d50dc", "building", "rom_082d50dc", "FR_BUILDING_ROM_082D50DC"}, + }, + leafgreen = { + {2968180, 2968204, "pallet_outdoor", "general", "pallet_town", "FR_PALLET_OUTDOOR"}, + {2968180, 2968228, "viridian_outdoor", "general", "viridian_city", "FR_VIRIDIAN_OUTDOOR"}, + {2968180, 2968252, "pewter_outdoor", "general", "pewter_city", "FR_PEWTER_OUTDOOR"}, + {2968180, 2968276, "general__rom_082d4ad4", "general", "rom_082d4ad4", "FR_GENERAL_ROM_082D4AD4"}, + {2968180, 2968300, "general__rom_082d4aec", "general", "rom_082d4aec", "FR_GENERAL_ROM_082D4AEC"}, + {2968180, 2968324, "general__rom_082d4b04", "general", "rom_082d4b04", "FR_GENERAL_ROM_082D4B04"}, + {2968180, 2968348, "general__rom_082d4b1c", "general", "rom_082d4b1c", "FR_GENERAL_ROM_082D4B1C"}, + {2968180, 2968372, "general__rom_082d4b34", "general", "rom_082d4b34", "FR_GENERAL_ROM_082D4B34"}, + {2968180, 2968396, "general__rom_082d4b4c", "general", "rom_082d4b4c", "FR_GENERAL_ROM_082D4B4C"}, + {2968180, 2968420, "general__rom_082d4b64", "general", "rom_082d4b64", "FR_GENERAL_ROM_082D4B64"}, + {2968180, 2968444, "general__rom_082d4b7c", "general", "rom_082d4b7c", "FR_GENERAL_ROM_082D4B7C"}, + {2968180, 2968540, "general__rom_082d4bdc", "general", "rom_082d4bdc", "FR_GENERAL_ROM_082D4BDC"}, + {2968180, 2968948, "general__rom_082d4d74", "general", "rom_082d4d74", "FR_GENERAL_ROM_082D4D74"}, + {2968180, 2968996, "general__rom_082d4da4", "general", "rom_082d4da4", "FR_GENERAL_ROM_082D4DA4"}, + {2968180, 2969044, "general__rom_082d4dd4", "general", "rom_082d4dd4", "FR_GENERAL_ROM_082D4DD4"}, + {2968180, 2969068, "general__rom_082d4dec", "general", "rom_082d4dec", "FR_GENERAL_ROM_082D4DEC"}, + {2968180, 2969092, "general__rom_082d4e04", "general", "rom_082d4e04", "FR_GENERAL_ROM_082D4E04"}, + {2968180, 2969140, "general__rom_082d4e34", "general", "rom_082d4e34", "FR_GENERAL_ROM_082D4E34"}, + {2968180, 2969548, "general__rom_082d4fcc", "general", "rom_082d4fcc", "FR_GENERAL_ROM_082D4FCC"}, + {2968180, 2969572, "general__rom_082d4fe4", "general", "rom_082d4fe4", "FR_GENERAL_ROM_082D4FE4"}, + {2968180, 2969596, "general__rom_082d4ffc", "general", "rom_082d4ffc", "FR_GENERAL_ROM_082D4FFC"}, + {2968180, 2969644, "sevii_outdoor", "general", "sevii_islands_123", "SEVII_OUTDOOR"}, + {2968180, 2969668, "general__rom_082d5044", "general", "rom_082d5044", "FR_GENERAL_ROM_082D5044"}, + {2968180, 2969692, "general__rom_082d505c", "general", "rom_082d505c", "FR_GENERAL_ROM_082D505C"}, + {2968180, 2969740, "harbor", "general", "island_harbor", "SEVII_HARBOR"}, + {2968468, 2968492, "building__rom_082d4bac", "building", "rom_082d4bac", "FR_BUILDING_ROM_082D4BAC"}, + {2968468, 2968516, "network", "building", "pokemon_center", "SEVII_NETWORK"}, + {2968468, 2968588, "building__rom_082d4c0c", "building", "rom_082d4c0c", "FR_BUILDING_ROM_082D4C0C"}, + {2968468, 2968612, "building__rom_082d4c24", "building", "rom_082d4c24", "FR_BUILDING_ROM_082D4C24"}, + {2968468, 2968636, "building__rom_082d4c3c", "building", "rom_082d4c3c", "FR_BUILDING_ROM_082D4C3C"}, + {2968468, 2968660, "player_house", "building", "pretty_petals_flower_shop", "FR_PLAYER_HOUSE"}, + {2968468, 2968684, "oak_lab", "building", "lab", "FR_OAK_LAB"}, + {2968468, 2968708, "building__rom_082d4c84", "building", "rom_082d4c84", "FR_BUILDING_ROM_082D4C84"}, + {2968468, 2968732, "building__rom_082d4c9c", "building", "rom_082d4c9c", "FR_BUILDING_ROM_082D4C9C"}, + {2968468, 2968780, "building__rom_082d4ccc", "building", "rom_082d4ccc", "FR_BUILDING_ROM_082D4CCC"}, + {2968468, 2968804, "pewter_gym", "building", "pewter_gym", "FR_PEWTER_GYM"}, + {2968468, 2968828, "building__rom_082d4cfc", "building", "rom_082d4cfc", "FR_BUILDING_ROM_082D4CFC"}, + {2968468, 2968852, "building__rom_082d4d14", "building", "rom_082d4d14", "FR_BUILDING_ROM_082D4D14"}, + {2968468, 2968876, "building__rom_082d4d2c", "building", "rom_082d4d2c", "FR_BUILDING_ROM_082D4D2C"}, + {2968468, 2968900, "building__rom_082d4d44", "building", "rom_082d4d44", "FR_BUILDING_ROM_082D4D44"}, + {2968468, 2968924, "building__rom_082d4d5c", "building", "rom_082d4d5c", "FR_BUILDING_ROM_082D4D5C"}, + {2968468, 2969092, "building__rom_082d4e04", "building", "rom_082d4e04", "FR_BUILDING_ROM_082D4E04"}, + {2968468, 2969164, "building__rom_082d4e4c", "building", "rom_082d4e4c", "FR_BUILDING_ROM_082D4E4C"}, + {2968468, 2969188, "house", "building", "generic_building_2", "SEVII_HOUSE"}, + {2968468, 2969212, "building__rom_082d4e7c", "building", "rom_082d4e7c", "FR_BUILDING_ROM_082D4E7C"}, + {2968468, 2969236, "building__rom_082d4e94", "building", "rom_082d4e94", "FR_BUILDING_ROM_082D4E94"}, + {2968468, 2969260, "building__rom_082d4eac", "building", "rom_082d4eac", "FR_BUILDING_ROM_082D4EAC"}, + {2968468, 2969284, "building__rom_082d4ec4", "building", "rom_082d4ec4", "FR_BUILDING_ROM_082D4EC4"}, + {2968468, 2969308, "building__rom_082d4edc", "building", "rom_082d4edc", "FR_BUILDING_ROM_082D4EDC"}, + {2968468, 2969332, "building__rom_082d4ef4", "building", "rom_082d4ef4", "FR_BUILDING_ROM_082D4EF4"}, + {2968468, 2969356, "building__rom_082d4f0c", "building", "rom_082d4f0c", "FR_BUILDING_ROM_082D4F0C"}, + {2968468, 2969380, "building__rom_082d4f24", "building", "rom_082d4f24", "FR_BUILDING_ROM_082D4F24"}, + {2968468, 2969404, "building__rom_082d4f3c", "building", "rom_082d4f3c", "FR_BUILDING_ROM_082D4F3C"}, + {2968468, 2969428, "building__rom_082d4f54", "building", "rom_082d4f54", "FR_BUILDING_ROM_082D4F54"}, + {2968468, 2969452, "building__rom_082d4f6c", "building", "rom_082d4f6c", "FR_BUILDING_ROM_082D4F6C"}, + {2968468, 2969476, "building__rom_082d4f84", "building", "rom_082d4f84", "FR_BUILDING_ROM_082D4F84"}, + {2968468, 2969620, "building__rom_082d5014", "building", "rom_082d5014", "FR_BUILDING_ROM_082D5014"}, + {2968468, 2969716, "building__rom_082d5074", "building", "rom_082d5074", "FR_BUILDING_ROM_082D5074"}, + {2968468, 2969764, "building__rom_082d50a4", "building", "rom_082d50a4", "FR_BUILDING_ROM_082D50A4"}, + {2968468, 2969788, "building__rom_082d50bc", "building", "rom_082d50bc", "FR_BUILDING_ROM_082D50BC"}, + }, + emerald = { + {4060932, 4060956, "general__petalburg", "general", "petalburg", "EM_GENERAL_PETALBURG"}, + {4060932, 4060980, "general__rustboro", "general", "rustboro", "EM_GENERAL_RUSTBORO"}, + {4060932, 4061004, "general__dewford", "general", "dewford", "EM_GENERAL_DEWFORD"}, + {4060932, 4061028, "general__slateport", "general", "slateport", "EM_GENERAL_SLATEPORT"}, + {4060932, 4061052, "general__mauville", "general", "mauville", "EM_GENERAL_MAUVILLE"}, + {4060932, 4061076, "general__lavaridge", "general", "lavaridge", "EM_GENERAL_LAVARIDGE"}, + {4060932, 4061100, "general__fallarbor", "general", "fallarbor", "EM_GENERAL_FALLARBOR"}, + {4060932, 4061124, "general__fortree", "general", "fortree", "EM_GENERAL_FORTREE"}, + {4060932, 4061148, "general__lilycove", "general", "lilycove", "EM_GENERAL_LILYCOVE"}, + {4060932, 4061172, "general__mossdeep", "general", "mossdeep", "EM_GENERAL_MOSSDEEP"}, + {4060932, 4061196, "general__ever_grande", "general", "ever_grande", "EM_GENERAL_EVER_GRANDE"}, + {4060932, 4061220, "general__pacifidlog", "general", "pacifidlog", "EM_GENERAL_PACIFIDLOG"}, + {4060932, 4061244, "general__sootopolis", "general", "sootopolis", "EM_GENERAL_SOOTOPOLIS"}, + {4060932, 4061268, "general__battle_frontier_outside_west", "general", "battle_frontier_outside_west", "EM_GENERAL_BATTLE_FRONTIER_OUTSIDE_WEST"}, + {4060932, 4061292, "general__battle_frontier_outside_east", "general", "battle_frontier_outside_east", "EM_GENERAL_BATTLE_FRONTIER_OUTSIDE_EAST"}, + {4060932, 4061388, "general__cave", "general", "cave", "EM_GENERAL_CAVE"}, + {4060932, 4061484, "general__meteor_falls", "general", "meteor_falls", "EM_GENERAL_METEOR_FALLS"}, + {4060932, 4061628, "general__facility", "general", "facility", "EM_GENERAL_FACILITY"}, + {4060932, 4061652, "general__bike_shop", "general", "bike_shop", "EM_GENERAL_BIKE_SHOP"}, + {4060932, 4061676, "general__rusturf_tunnel", "general", "rusturf_tunnel", "EM_GENERAL_RUSTURF_TUNNEL"}, + {4060932, 4061844, "general__inside_of_truck", "general", "inside_of_truck", "EM_GENERAL_INSIDE_OF_TRUCK"}, + {4060932, 4061892, "general__contest", "general", "contest", "EM_GENERAL_CONTEST"}, + {4060932, 4061988, "general__underwater", "general", "underwater", "EM_GENERAL_UNDERWATER"}, + {4060932, 4062276, "general__inside_ship", "general", "inside_ship", "EM_GENERAL_INSIDE_SHIP"}, + {4060932, 4062356, "general__battle_frontier", "general", "battle_frontier", "EM_GENERAL_BATTLE_FRONTIER"}, + {4060932, 4062380, "general__battle_palace", "general", "battle_palace", "EM_GENERAL_BATTLE_PALACE"}, + {4060932, 4062524, "general__mirage_tower", "general", "mirage_tower", "EM_GENERAL_MIRAGE_TOWER"}, + {4060932, 4062572, "general__island_harbor", "general", "island_harbor", "EM_GENERAL_ISLAND_HARBOR"}, + {4060932, 4062620, "general__navel_rock", "general", "navel_rock", "EM_GENERAL_NAVEL_ROCK"}, + {4060932, 4062668, "general__battle_tent", "general", "battle_tent", "EM_GENERAL_BATTLE_TENT"}, + {4061316, 4061340, "building__shop", "building", "shop", "EM_BUILDING_SHOP"}, + {4061316, 4061364, "building__pokemon_center", "building", "pokemon_center", "EM_BUILDING_POKEMON_CENTER"}, + {4061316, 4061412, "building__pokemon_school", "building", "pokemon_school", "EM_BUILDING_POKEMON_SCHOOL"}, + {4061316, 4061436, "building__pokemon_fan_club", "building", "pokemon_fan_club", "EM_BUILDING_POKEMON_FAN_CLUB"}, + {4061316, 4061508, "building__oceanic_museum", "building", "oceanic_museum", "EM_BUILDING_OCEANIC_MUSEUM"}, + {4061316, 4061532, "building__cable_club", "building", "cable_club", "EM_BUILDING_CABLE_CLUB"}, + {4061316, 4061556, "building__seashore_house", "building", "seashore_house", "EM_BUILDING_SEASHORE_HOUSE"}, + {4061316, 4061580, "building__pretty_petal_flower_shop", "building", "pretty_petal_flower_shop", "EM_BUILDING_PRETTY_PETAL_FLOWER_SHOP"}, + {4061316, 4061604, "building__pokemon_day_care", "building", "pokemon_day_care", "EM_BUILDING_POKEMON_DAY_CARE"}, + {4061316, 4061628, "building__facility", "building", "facility", "EM_BUILDING_FACILITY"}, + {4061316, 4061892, "building__contest", "building", "contest", "EM_BUILDING_CONTEST"}, + {4061316, 4061916, "building__lilycove_museum", "building", "lilycove_museum", "EM_BUILDING_LILYCOVE_MUSEUM"}, + {4061316, 4061940, "building__brendans_mays_house", "building", "brendans_mays_house", "EM_BUILDING_BRENDANS_MAYS_HOUSE"}, + {4061316, 4061964, "building__lab", "building", "lab", "EM_BUILDING_LAB"}, + {4061316, 4062012, "building__petalburg_gym", "building", "petalburg_gym", "EM_BUILDING_PETALBURG_GYM"}, + {4061316, 4062036, "building__sootopolis_gym", "building", "sootopolis_gym", "EM_BUILDING_SOOTOPOLIS_GYM"}, + {4061316, 4062060, "building__generic_building", "building", "generic_building", "EM_BUILDING_GENERIC_BUILDING"}, + {4061316, 4062084, "building__mauville_game_corner", "building", "mauville_game_corner", "EM_BUILDING_MAUVILLE_GAME_CORNER"}, + {4061316, 4062108, "building__rustboro_gym", "building", "rustboro_gym", "EM_BUILDING_RUSTBORO_GYM"}, + {4061316, 4062132, "building__dewford_gym", "building", "dewford_gym", "EM_BUILDING_DEWFORD_GYM"}, + {4061316, 4062156, "building__mauville_gym", "building", "mauville_gym", "EM_BUILDING_MAUVILLE_GYM"}, + {4061316, 4062180, "building__lavaridge_gym", "building", "lavaridge_gym", "EM_BUILDING_LAVARIDGE_GYM"}, + {4061316, 4062204, "building__trick_house_puzzle", "building", "trick_house_puzzle", "EM_BUILDING_TRICK_HOUSE_PUZZLE"}, + {4061316, 4062228, "building__fortree_gym", "building", "fortree_gym", "EM_BUILDING_FORTREE_GYM"}, + {4061316, 4062252, "building__mossdeep_gym", "building", "mossdeep_gym", "EM_BUILDING_MOSSDEEP_GYM"}, + {4061316, 4062332, "building__elite_four", "building", "elite_four", "EM_BUILDING_ELITE_FOUR"}, + {4061316, 4062356, "building__battle_frontier", "building", "battle_frontier", "EM_BUILDING_BATTLE_FRONTIER"}, + {4061316, 4062380, "building__battle_palace", "building", "battle_palace", "EM_BUILDING_BATTLE_PALACE"}, + {4061316, 4062404, "building__battle_dome", "building", "battle_dome", "EM_BUILDING_BATTLE_DOME"}, + {4061316, 4062428, "building__battle_factory", "building", "battle_factory", "EM_BUILDING_BATTLE_FACTORY"}, + {4061316, 4062452, "building__battle_pike", "building", "battle_pike", "EM_BUILDING_BATTLE_PIKE"}, + {4061316, 4062476, "building__battle_arena", "building", "battle_arena", "EM_BUILDING_BATTLE_ARENA"}, + {4061316, 4062500, "building__battle_pyramid", "building", "battle_pyramid", "EM_BUILDING_BATTLE_PYRAMID"}, + {4061316, 4062548, "building__mossdeep_game_corner", "building", "mossdeep_game_corner", "EM_BUILDING_MOSSDEEP_GAME_CORNER"}, + {4061316, 4062596, "building__trainer_hill", "building", "trainer_hill", "EM_BUILDING_TRAINER_HILL"}, + {4061316, 4062644, "building__battle_frontier_ranking_hall", "building", "battle_frontier_ranking_hall", "EM_BUILDING_BATTLE_FRONTIER_RANKING_HALL"}, + {4061316, 4062668, "building__battle_tent", "building", "battle_tent", "EM_BUILDING_BATTLE_TENT"}, + {4061316, 4062692, "building__mystery_events_house", "building", "mystery_events_house", "EM_BUILDING_MYSTERY_EVENTS_HOUSE"}, + {4061316, 4062716, "building__union_room", "building", "union_room", "EM_BUILDING_UNION_ROOM"}, + {4062300, 4061700, "secret_base__secret_base_brown_cave", "secret_base", "secret_base_brown_cave", "EM_SECRET_BASE_SECRET_BASE_BROWN_CAVE"}, + {4062300, 4061724, "secret_base__secret_base_tree", "secret_base", "secret_base_tree", "EM_SECRET_BASE_SECRET_BASE_TREE"}, + {4062300, 4061748, "secret_base__secret_base_shrub", "secret_base", "secret_base_shrub", "EM_SECRET_BASE_SECRET_BASE_SHRUB"}, + {4062300, 4061772, "secret_base__secret_base_blue_cave", "secret_base", "secret_base_blue_cave", "EM_SECRET_BASE_SECRET_BASE_BLUE_CAVE"}, + {4062300, 4061796, "secret_base__secret_base_yellow_cave", "secret_base", "secret_base_yellow_cave", "EM_SECRET_BASE_SECRET_BASE_YELLOW_CAVE"}, + {4062300, 4061820, "secret_base__secret_base_red_cave", "secret_base", "secret_base_red_cave", "EM_SECRET_BASE_SECRET_BASE_RED_CAVE"}, + }, + ruby = { + {2649332, 2649356, "general__petalburg", "general", "petalburg", "RU_GENERAL_PETALBURG"}, + {2649332, 2649380, "general__rustboro", "general", "rustboro", "RU_GENERAL_RUSTBORO"}, + {2649332, 2649404, "general__dewford", "general", "dewford", "RU_GENERAL_DEWFORD"}, + {2649332, 2649428, "general__slateport", "general", "slateport", "RU_GENERAL_SLATEPORT"}, + {2649332, 2649452, "general__mauville", "general", "mauville", "RU_GENERAL_MAUVILLE"}, + {2649332, 2649476, "general__lavaridge", "general", "lavaridge", "RU_GENERAL_LAVARIDGE"}, + {2649332, 2649500, "general__fallarbor", "general", "fallarbor", "RU_GENERAL_FALLARBOR"}, + {2649332, 2649524, "general__fortree", "general", "fortree", "RU_GENERAL_FORTREE"}, + {2649332, 2649548, "general__lilycove", "general", "lilycove", "RU_GENERAL_LILYCOVE"}, + {2649332, 2649572, "general__mossdeep", "general", "mossdeep", "RU_GENERAL_MOSSDEEP"}, + {2649332, 2649596, "general__ever_grande", "general", "ever_grande", "RU_GENERAL_EVER_GRANDE"}, + {2649332, 2649620, "general__pacifidlog", "general", "pacifidlog", "RU_GENERAL_PACIFIDLOG"}, + {2649332, 2649644, "general__sootopolis", "general", "sootopolis", "RU_GENERAL_SOOTOPOLIS"}, + {2649332, 2649740, "general__cave", "general", "cave", "RU_GENERAL_CAVE"}, + {2649332, 2649836, "general__meteor_falls", "general", "meteor_falls", "RU_GENERAL_METEOR_FALLS"}, + {2649332, 2649980, "general__facility", "general", "facility", "RU_GENERAL_FACILITY"}, + {2649332, 2650004, "general__bike_shop", "general", "bike_shop", "RU_GENERAL_BIKE_SHOP"}, + {2649332, 2650028, "general__rusturf_tunnel", "general", "rusturf_tunnel", "RU_GENERAL_RUSTURF_TUNNEL"}, + {2649332, 2650196, "general__inside_of_truck", "general", "inside_of_truck", "RU_GENERAL_INSIDE_OF_TRUCK"}, + {2649332, 2650244, "general__contest", "general", "contest", "RU_GENERAL_CONTEST"}, + {2649332, 2650340, "general__underwater", "general", "underwater", "RU_GENERAL_UNDERWATER"}, + {2649332, 2650628, "general__ship", "general", "ship", "RU_GENERAL_SHIP"}, + {2649668, 2649692, "building__shop", "building", "shop", "RU_BUILDING_SHOP"}, + {2649668, 2649716, "building__pokemon_center", "building", "pokemon_center", "RU_BUILDING_POKEMON_CENTER"}, + {2649668, 2649764, "building__pokemon_school", "building", "pokemon_school", "RU_BUILDING_POKEMON_SCHOOL"}, + {2649668, 2649788, "building__pokemon_fan_club", "building", "pokemon_fan_club", "RU_BUILDING_POKEMON_FAN_CLUB"}, + {2649668, 2649860, "building__oceanic_museum", "building", "oceanic_museum", "RU_BUILDING_OCEANIC_MUSEUM"}, + {2649668, 2649884, "building__cable_club", "building", "cable_club", "RU_BUILDING_CABLE_CLUB"}, + {2649668, 2649908, "building__seashore_house", "building", "seashore_house", "RU_BUILDING_SEASHORE_HOUSE"}, + {2649668, 2649932, "building__pretty_petal_flower_shop", "building", "pretty_petal_flower_shop", "RU_BUILDING_PRETTY_PETAL_FLOWER_SHOP"}, + {2649668, 2649956, "building__pokemon_day_care", "building", "pokemon_day_care", "RU_BUILDING_POKEMON_DAY_CARE"}, + {2649668, 2650244, "building__contest", "building", "contest", "RU_BUILDING_CONTEST"}, + {2649668, 2650268, "building__lilycove_museum", "building", "lilycove_museum", "RU_BUILDING_LILYCOVE_MUSEUM"}, + {2649668, 2650292, "building__brendans_mays_house", "building", "brendans_mays_house", "RU_BUILDING_BRENDANS_MAYS_HOUSE"}, + {2649668, 2650316, "building__lab", "building", "lab", "RU_BUILDING_LAB"}, + {2649668, 2650364, "building__petalburg_gym", "building", "petalburg_gym", "RU_BUILDING_PETALBURG_GYM"}, + {2649668, 2650388, "building__sootopolis_gym", "building", "sootopolis_gym", "RU_BUILDING_SOOTOPOLIS_GYM"}, + {2649668, 2650412, "building__generic_building", "building", "generic_building", "RU_BUILDING_GENERIC_BUILDING"}, + {2649668, 2650436, "building__mauville_game_corner", "building", "mauville_game_corner", "RU_BUILDING_MAUVILLE_GAME_CORNER"}, + {2649668, 2650460, "building__rustboro_gym", "building", "rustboro_gym", "RU_BUILDING_RUSTBORO_GYM"}, + {2649668, 2650484, "building__dewford_gym", "building", "dewford_gym", "RU_BUILDING_DEWFORD_GYM"}, + {2649668, 2650508, "building__mauville_gym", "building", "mauville_gym", "RU_BUILDING_MAUVILLE_GYM"}, + {2649668, 2650532, "building__lavaridge_gym", "building", "lavaridge_gym", "RU_BUILDING_LAVARIDGE_GYM"}, + {2649668, 2650556, "building__trick_house_puzzle", "building", "trick_house_puzzle", "RU_BUILDING_TRICK_HOUSE_PUZZLE"}, + {2649668, 2650580, "building__fortree_gym", "building", "fortree_gym", "RU_BUILDING_FORTREE_GYM"}, + {2649668, 2650604, "building__mossdeep_gym", "building", "mossdeep_gym", "RU_BUILDING_MOSSDEEP_GYM"}, + {2649668, 2650676, "building__elite_four", "building", "elite_four", "RU_BUILDING_ELITE_FOUR"}, + {2649668, 2650700, "building__battle_tower", "building", "battle_tower", "RU_BUILDING_BATTLE_TOWER"}, + {2650652, 2650052, "secret_base__secret_base_brown_cave", "secret_base", "secret_base_brown_cave", "RU_SECRET_BASE_SECRET_BASE_BROWN_CAVE"}, + {2650652, 2650076, "secret_base__secret_base_tree", "secret_base", "secret_base_tree", "RU_SECRET_BASE_SECRET_BASE_TREE"}, + {2650652, 2650100, "secret_base__secret_base_shrub", "secret_base", "secret_base_shrub", "RU_SECRET_BASE_SECRET_BASE_SHRUB"}, + {2650652, 2650124, "secret_base__secret_base_blue_cave", "secret_base", "secret_base_blue_cave", "RU_SECRET_BASE_SECRET_BASE_BLUE_CAVE"}, + {2650652, 2650148, "secret_base__secret_base_yellow_cave", "secret_base", "secret_base_yellow_cave", "RU_SECRET_BASE_SECRET_BASE_YELLOW_CAVE"}, + {2650652, 2650172, "secret_base__secret_base_red_cave", "secret_base", "secret_base_red_cave", "RU_SECRET_BASE_SECRET_BASE_RED_CAVE"}, + }, + sapphire = { + {2649220, 2649244, "general__petalburg", "general", "petalburg", "SA_GENERAL_PETALBURG"}, + {2649220, 2649268, "general__rustboro", "general", "rustboro", "SA_GENERAL_RUSTBORO"}, + {2649220, 2649292, "general__dewford", "general", "dewford", "SA_GENERAL_DEWFORD"}, + {2649220, 2649316, "general__slateport", "general", "slateport", "SA_GENERAL_SLATEPORT"}, + {2649220, 2649340, "general__mauville", "general", "mauville", "SA_GENERAL_MAUVILLE"}, + {2649220, 2649364, "general__lavaridge", "general", "lavaridge", "SA_GENERAL_LAVARIDGE"}, + {2649220, 2649388, "general__fallarbor", "general", "fallarbor", "SA_GENERAL_FALLARBOR"}, + {2649220, 2649412, "general__fortree", "general", "fortree", "SA_GENERAL_FORTREE"}, + {2649220, 2649436, "general__lilycove", "general", "lilycove", "SA_GENERAL_LILYCOVE"}, + {2649220, 2649460, "general__mossdeep", "general", "mossdeep", "SA_GENERAL_MOSSDEEP"}, + {2649220, 2649484, "general__ever_grande", "general", "ever_grande", "SA_GENERAL_EVER_GRANDE"}, + {2649220, 2649508, "general__pacifidlog", "general", "pacifidlog", "SA_GENERAL_PACIFIDLOG"}, + {2649220, 2649532, "general__sootopolis", "general", "sootopolis", "SA_GENERAL_SOOTOPOLIS"}, + {2649220, 2649628, "general__cave", "general", "cave", "SA_GENERAL_CAVE"}, + {2649220, 2649724, "general__meteor_falls", "general", "meteor_falls", "SA_GENERAL_METEOR_FALLS"}, + {2649220, 2649868, "general__facility", "general", "facility", "SA_GENERAL_FACILITY"}, + {2649220, 2649892, "general__bike_shop", "general", "bike_shop", "SA_GENERAL_BIKE_SHOP"}, + {2649220, 2649916, "general__rusturf_tunnel", "general", "rusturf_tunnel", "SA_GENERAL_RUSTURF_TUNNEL"}, + {2649220, 2650084, "general__inside_of_truck", "general", "inside_of_truck", "SA_GENERAL_INSIDE_OF_TRUCK"}, + {2649220, 2650132, "general__contest", "general", "contest", "SA_GENERAL_CONTEST"}, + {2649220, 2650228, "general__underwater", "general", "underwater", "SA_GENERAL_UNDERWATER"}, + {2649220, 2650516, "general__ship", "general", "ship", "SA_GENERAL_SHIP"}, + {2649556, 2649580, "building__shop", "building", "shop", "SA_BUILDING_SHOP"}, + {2649556, 2649604, "building__pokemon_center", "building", "pokemon_center", "SA_BUILDING_POKEMON_CENTER"}, + {2649556, 2649652, "building__pokemon_school", "building", "pokemon_school", "SA_BUILDING_POKEMON_SCHOOL"}, + {2649556, 2649676, "building__pokemon_fan_club", "building", "pokemon_fan_club", "SA_BUILDING_POKEMON_FAN_CLUB"}, + {2649556, 2649748, "building__oceanic_museum", "building", "oceanic_museum", "SA_BUILDING_OCEANIC_MUSEUM"}, + {2649556, 2649772, "building__cable_club", "building", "cable_club", "SA_BUILDING_CABLE_CLUB"}, + {2649556, 2649796, "building__seashore_house", "building", "seashore_house", "SA_BUILDING_SEASHORE_HOUSE"}, + {2649556, 2649820, "building__pretty_petal_flower_shop", "building", "pretty_petal_flower_shop", "SA_BUILDING_PRETTY_PETAL_FLOWER_SHOP"}, + {2649556, 2649844, "building__pokemon_day_care", "building", "pokemon_day_care", "SA_BUILDING_POKEMON_DAY_CARE"}, + {2649556, 2650132, "building__contest", "building", "contest", "SA_BUILDING_CONTEST"}, + {2649556, 2650156, "building__lilycove_museum", "building", "lilycove_museum", "SA_BUILDING_LILYCOVE_MUSEUM"}, + {2649556, 2650180, "building__brendans_mays_house", "building", "brendans_mays_house", "SA_BUILDING_BRENDANS_MAYS_HOUSE"}, + {2649556, 2650204, "building__lab", "building", "lab", "SA_BUILDING_LAB"}, + {2649556, 2650252, "building__petalburg_gym", "building", "petalburg_gym", "SA_BUILDING_PETALBURG_GYM"}, + {2649556, 2650276, "building__sootopolis_gym", "building", "sootopolis_gym", "SA_BUILDING_SOOTOPOLIS_GYM"}, + {2649556, 2650300, "building__generic_building", "building", "generic_building", "SA_BUILDING_GENERIC_BUILDING"}, + {2649556, 2650324, "building__mauville_game_corner", "building", "mauville_game_corner", "SA_BUILDING_MAUVILLE_GAME_CORNER"}, + {2649556, 2650348, "building__rustboro_gym", "building", "rustboro_gym", "SA_BUILDING_RUSTBORO_GYM"}, + {2649556, 2650372, "building__dewford_gym", "building", "dewford_gym", "SA_BUILDING_DEWFORD_GYM"}, + {2649556, 2650396, "building__mauville_gym", "building", "mauville_gym", "SA_BUILDING_MAUVILLE_GYM"}, + {2649556, 2650420, "building__lavaridge_gym", "building", "lavaridge_gym", "SA_BUILDING_LAVARIDGE_GYM"}, + {2649556, 2650444, "building__trick_house_puzzle", "building", "trick_house_puzzle", "SA_BUILDING_TRICK_HOUSE_PUZZLE"}, + {2649556, 2650468, "building__fortree_gym", "building", "fortree_gym", "SA_BUILDING_FORTREE_GYM"}, + {2649556, 2650492, "building__mossdeep_gym", "building", "mossdeep_gym", "SA_BUILDING_MOSSDEEP_GYM"}, + {2649556, 2650564, "building__elite_four", "building", "elite_four", "SA_BUILDING_ELITE_FOUR"}, + {2649556, 2650588, "building__battle_tower", "building", "battle_tower", "SA_BUILDING_BATTLE_TOWER"}, + {2650540, 2649940, "secret_base__secret_base_brown_cave", "secret_base", "secret_base_brown_cave", "SA_SECRET_BASE_SECRET_BASE_BROWN_CAVE"}, + {2650540, 2649964, "secret_base__secret_base_tree", "secret_base", "secret_base_tree", "SA_SECRET_BASE_SECRET_BASE_TREE"}, + {2650540, 2649988, "secret_base__secret_base_shrub", "secret_base", "secret_base_shrub", "SA_SECRET_BASE_SECRET_BASE_SHRUB"}, + {2650540, 2650012, "secret_base__secret_base_blue_cave", "secret_base", "secret_base_blue_cave", "SA_SECRET_BASE_SECRET_BASE_BLUE_CAVE"}, + {2650540, 2650036, "secret_base__secret_base_yellow_cave", "secret_base", "secret_base_yellow_cave", "SA_SECRET_BASE_SECRET_BASE_YELLOW_CAVE"}, + {2650540, 2650060, "secret_base__secret_base_red_cave", "secret_base", "secret_base_red_cave", "SA_SECRET_BASE_SECRET_BASE_RED_CAVE"}, + }, +} diff --git a/src/compat/gen1/PlayerPose.lua b/src/compat/gen1/PlayerPose.lua new file mode 100644 index 000000000..61d258ed1 --- /dev/null +++ b/src/compat/gen1/PlayerPose.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Short field gestures use the native player's fixed step and pose owner. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local M={} +local active +local function release() + local a=active;active=nil + if a and a.player._gen3FieldMovePoseOwner==a then + a.player.fieldMove,a.player.fieldMovePose,a.player.fieldMoveFrames=a.previous,a.pose,a.frames + a.player._gen3FieldMovePoseOwner=a.previousOwner + end +end +local function live(a) + local g,w,p=Native.resolve() + return a and g==a.game and w==a.world and p==a.player and w.map==a.map and g.data==a.data + and g.save==a.save and g.mods==a.loader and Version.get()==a.edition +end +function M.start(duration,kind) + if active and not live(active)then release()end + local g,w,p=Native.resolve();if not g or p.moving or p._gen3FieldMovePoseOwner then return false end + duration=duration or 28 + assert(type(duration)=='number'and duration%1==0 and duration>=1 and duration<=3600,'invalid field gesture duration') + assert(kind==nil or type(kind)=='string','invalid field gesture kind') + local host=require('src.mods.Runtime');local facade=require('src.core.Game3');local who=host.currentMod or facade._callbackOwner() + if not who then return false end + active={game=g,world=w,player=p,map=w.map,data=g.data,save=g.save,loader=g.mods,edition=Version.get(),who=who, + remaining=duration,previous=p.fieldMove,pose=p.fieldMovePose,frames=p.fieldMoveFrames,previousOwner=p._gen3FieldMovePoseOwner} + p._gen3FieldMovePoseOwner=active;p.fieldMove=true;p.fieldMovePose=kind or'field_move';p.fieldMoveFrames=duration + return true +end +function M.tick(player) + local a=active;if not a then return end + if not live(a)or a.player._gen3FieldMovePoseOwner~=a then release();return end + if player~=a.player then return end + a.remaining=a.remaining-1;a.player.fieldMoveFrames=a.remaining + if a.remaining<=0 then release()end +end +M.release=release +M.releaseOwner=function(_,who)if active and active.who==who then release()end end +M.poll=function()if active and not live(active)then release()end end +require('src.core.game3.asset_stream').register(M) +return M diff --git a/src/compat/gen1/PlayerRuntime.lua b/src/compat/gen1/PlayerRuntime.lua new file mode 100644 index 000000000..0a22347c0 --- /dev/null +++ b/src/compat/gen1/PlayerRuntime.lua @@ -0,0 +1,65 @@ +-- One compatibility call scope inside the native overworld player phase. +-- An idle tick is deferred: free-body wrappers may then submit their one +-- cell arrival without advancing animation, bobbing or events a second time. +local Session = require('src.compat.gen1.NativeSession') +local M = {} +function M.new() + local active + local records = setmetatable({},{__mode='k'}) + local runtime = {} + function runtime:scope() return active end + function runtime:record(player) + local record=records[player] + if not record then record={stepSerial=0};records[player]=record end + return record + end + function runtime:tick(game) + local c = active + if not c or (game and game ~= c.game and game ~= c.facade) then + return nil, 'player ticks belong to the native fixed-step phase' + end + if c.advanced then return false end + local _, world, player = Session.resolve(c.game) + if world ~= c.world or player ~= c.player then return false end + if not c.finishing and not player.moving then return false end + c.advanced = true + local x,y=player.cellX,player.cellY + if not player.moving and player.compatWalkInPlace and not (player.bumpFrames and player.bumpFrames>0) then + player.animClock=(player.animClock or 0)+1 + end + local stepped = player:update() + if stepped then + local record=self:record(player) + record.prevCellX,record.prevCellY=x,y;record.stepSerial=record.stepSerial+1 + end + c.world:finishPlayerStep(stepped, false) + return stepped + end + function runtime:input(input) + local c = active + if not c or c.inputPolled or not Session.acceptsInput(c.game,c.world,c.player) then return end + c.inputPolled = true + local previous = c.game.input + c.game.input = input or previous + local ok, result = xpcall(function() return c.world:handleInput() end, debug.traceback) + c.game.input = previous + if not ok then error(result,0) end + return result + end + function runtime:run(game, world, facade, input, update) + assert(not active, 'nested native player phase') + local c = {game=game,world=world,player=world.player,facade=facade} + active = c + local ok, why = xpcall(function() + update(facade,input) + if active ~= c then return end -- teardown during a callback retires the scope + c.finishing = true + self:tick(game) + end, debug.traceback) + active = nil + if not ok then error(why,0) end + end + function runtime:release() active = nil;records=setmetatable({},{__mode='k'})end + return runtime +end +return M diff --git a/src/compat/gen1/PokemonView.lua b/src/compat/gen1/PokemonView.lua new file mode 100644 index 000000000..fc53d6e0e --- /dev/null +++ b/src/compat/gen1/PokemonView.lua @@ -0,0 +1,127 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Typed party observations share native residents, never another party/save. +local Ids=require('src.compat.gen1.BattleIds') +local M={} +local records=setmetatable({},{__mode='k'}) +local environments=setmetatable({},{__mode='k'}) +local Iteration=require('src.compat.gen1.TableIteration') +local stats={maxHp='hp',attack='attack',defense='defense',speed='speed',spAtk='spatk',spDef='spdef'} +local bits={PSN=8,BRN=16,FRZ=32,PAR=64} +local function uint(n,hi)return type(n)=='number'and n==n and n>=0 and n<=hi and n%1==0 end +function M.status(mon) + if mon.status=='SLP'then return math.max(1,math.min(7,mon.sleepTurns or 1))end + return mon.status=='PSN'and mon.toxic and 128 or bits[mon.status]or 0 +end +function M.setStatus(mon,value) + if value==nil then value=0 end + assert(uint(value,255)and(value<=7 or({[8]=true,[16]=true,[32]=true,[64]=true,[128]=true})[value]),'invalid native status') + mon.status=value>0 and(value<=7 and'SLP'or({[8]='PSN',[16]='BRN',[32]='FRZ',[64]='PAR',[128]='PSN'})[value])or nil + mon.sleepTurns=value>0 and value<=7 and value or nil;mon.toxic=value==128 or nil +end +function M.new(game,valid) + local e={game=game,data=game.data,ids=Ids.new(game.data),entries=setmetatable({},{__mode='k'}),cache=setmetatable({},{__mode='kv'}),valid=valid} + environments[e]=true + function e:live() + if not self.game then return false end + if not self.valid()or not self.ids:valid()then self:release();return false end + return true + end + function e:release() + for _,entry in pairs(self.entries)do entry.raw=nil;entry.arrays=nil end + self.game,self.data,self.ids=nil,nil,nil;self.cache={};self.valid=function()return false end + end + local function vectors(d) + if d.arrays then return d.arrays end + local a={moves={},pp={},maxPp={},baseline={},native={}} + for i,row in ipairs(d.raw.moves or{})do + local def=e.data.moves[row.id] + a.moves[i]=e.ids:read('moves',row.id);a.pp[i]=row.pp;a.baseline[i]=row.pp + a.maxPp[i]=(def and def.pp or 0)+math.floor((def and def.pp or 0)/5)*(row.ppUps or 0) + a.native[i]=row + end + d.arrays=a;return a + end + function e:flush() + if not self:live()then return end + local writes,clear={},{} + for _,d in pairs(self.entries)do + local a=d.arrays + if a and d.raw then + for i,row in ipairs(a.native)do + assert(d.raw.moves[i]==row and e.ids:read('moves',row.id)==a.moves[i],'native moves changed during source edit') + local def=e.data.moves[row.id];local maximum=def.pp+math.floor(def.pp/5)*(row.ppUps or 0) + assert(a.maxPp[i]==maximum,'native maximum PP is read-only') + assert(uint(a.pp[i],maximum),'invalid native PP') + assert(a.pp[i]==a.baseline[i]or row.pp==a.baseline[i],'native PP changed during source edit') + if a.pp[i]~=a.baseline[i]then writes[#writes+1]={row,a.pp[i]}end + end + for i in pairs(a.pp)do assert(uint(i,#a.native)and i>0,'invalid native PP slot')end + for i,v in pairs(a.moves)do assert(a.native[i]and e.ids:read('moves',a.native[i].id)==v,'native move list is read-only')end + for i,v in pairs(a.maxPp)do + local row=a.native[i];local def=row and e.data.moves[row.id] + assert(def and v==def.pp+math.floor(def.pp/5)*(row.ppUps or 0),'native maximum PP is read-only') + end + clear[#clear+1]=d + end + end + for _,write in ipairs(writes)do write[1].pp=write[2]end + for _,d in ipairs(clear)do d.arrays=nil end + end + function e:view(mon) + if not self:live()or type(mon)~='table'then return nil end + local old=self.cache[mon];if old then return old end + local view={};local d={raw=mon,env=self};self.cache[mon]=view;self.entries[view]=d;records[view]=d + setmetatable(view,{__index=function(_,key) + if not e:live()or not d.raw then return nil end + local m=d.raw + if key=='species'then return e.ids:read('pokemon',m.species)end + if key=='item'then return e.ids:read('items',m.item)end + if key=='status'then return M.status(m)end + if key=='friendship'or key=='happiness'then return m.happiness end + if key=='isEgg'or key=='egg'then return require('src.pokemon.Party').isEgg(m)end + if key=='moves'or key=='pp'or key=='maxPp'then return vectors(d)[key]end + if stats[key]then return m.stats and m.stats[stats[key]]end + if key=='otId'then return m.otId or m.otID end + if key=='otSecretId'then return m.otSecretId or m.secretId end + if key=='ivs'then + local out={};for a,b in pairs{hp='hp',atk='attack',def='defense',spe='speed',spa='spatk',spd='spdef'}do out[a]=m.ivs and m.ivs[b]end;return out + end + if key=='hp'or key=='level'or key=='exp'or key=='nickname'or key=='personality'or key=='sleep'then + return m[key=='sleep'and'sleepTurns'or key] + end + end,__newindex=function(_,key,value) + assert(e:live()and d.raw,'native party view retired') + local m=d.raw + if key=='hp'then assert(uint(value,m.stats.hp),'invalid native HP');m.hp=value + elseif key=='friendship'or key=='happiness'then assert(uint(value,255),'invalid native friendship');m.happiness=value + elseif key=='nickname'then assert(type(value)=='string'and#value<=32,'invalid native nickname');m.nickname=value + elseif key=='status'then M.setStatus(m,value) + elseif key=='sleep'then assert(uint(value or 0,7),'invalid native sleep');m.sleepTurns=value + elseif key=='item'then + local item=e.ids:native('items',value);assert(value==0 or item,'unknown native item');m.item=item + else error('native party '..tostring(key)..' belongs to its command owner',2)end + end}) + Iteration.register(view,function(spec,source) + assert(spec.environment and spec.environment:live(),'native party view retired') + local out={} + for _,key in ipairs{'species','item','hp','level','exp','nickname','personality','friendship','status','ivs','otId','otSecretId','isEgg','maxHp','attack','defense','speed','spAtk','spDef','moves','pp','maxPp'}do + local value=source[key] + if type(value)=='table'then local a={};for k,v in pairs(value)do a[k]=v end;value=a end + out[key]=value + end + return out + end,setmetatable({environment=e},{__mode='v'})) + return view + end + return e +end +function M.resolve(view) + local d=records[view] + if not d then return nil,false end + if not d.env:live()then return nil,true end + d.env:flush();return d.raw,true,d.env.game +end +function M.flush() + for e in pairs(environments)do e:flush()end +end +return M diff --git a/src/compat/gen1/ScriptBoundary.lua b/src/compat/gen1/ScriptBoundary.lua new file mode 100644 index 000000000..b3a550c96 --- /dev/null +++ b/src/compat/gen1/ScriptBoundary.lua @@ -0,0 +1,30 @@ +-- Native owners open a scope; source base calls can consume its body once. +-- Queries made between native phases cannot advance another script clock. +local M={} +local scopes={} +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +function M.invoke(name) + local s=scopes[name] + if not s then return nil,'script operation belongs to its native phase'end + if s.failed then error(s.failed,0)end + if not s.called then + s.called=true + local r=pack(pcall(s.body)) + if not r[1]then s.failed=r[2];error(r[2],0)end + s.result={n=r.n-1};for i=2,r.n do s.result[i-1]=r[i]end + end + if not s.result then return nil,'native script phase is already executing' end + return unpack(s.result,1,s.result.n) +end +function M.dispatch(name,body,source,...) + local previous=scopes[name] + local s={body=body};scopes[name]=s + local result=pack(pcall(source,...));scopes[name]=previous + -- A wrapper cannot swallow a failing native command and retry it. + if s.failed then error(s.failed,0)end + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function M.active(name)return scopes[name]~=nil end +return M diff --git a/src/compat/gen1/ScriptBundle.lua b/src/compat/gen1/ScriptBundle.lua new file mode 100644 index 000000000..3b0fa178e --- /dev/null +++ b/src/compat/gen1/ScriptBundle.lua @@ -0,0 +1,174 @@ +-- Owned script/event observations; the native compiler remains the executor. +local Identity=require('src.compat.gen1.MapIdentity') +local Header=require('src.import.Gen3ScriptInterface') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local records=setmetatable({},{__mode='k'}) +function M.copy(value,seen) + if type(value)~='table'then return value end + seen=seen or{};if seen[value]then return seen[value]end + local out={};seen[value]=out + for k,v in pairs(value)do out[M.copy(k,seen)]=M.copy(v,seen)end + return out +end +function M.sourceKey(key) + if type(key)=='number'then + if key>=0x08000000 and key<0x0a000000 then return ('g3:%08x'):format(key)end + return key + end + if type(key)~='string'then return key end + local hex=key:match('^[SM](%x+)$') + if hex then return ('g3:%08x'):format(0x08000000+tonumber(hex,16))end + return key +end +function M.nativeKey(key,prefix) + if type(key)=='number'then + if key<0x08000000 or key>=0x0a000000 then return nil end + return (prefix or'S')..('%07X'):format(key-0x08000000) + end + if type(key)~='string'then return nil end + local hex=key:match('^g3:(%x+)$') + return hex and M.nativeKey(tonumber(hex,16),prefix)or key +end +local aliases={loadword={'dest','value'},setvar={'var','value'},compare_var_to_value={'var','value'}, + setflag={'flag'},clearflag={'flag'},checkflag={'flag'},setworldmapflag={'flag'},callstd={'std'},gotostd={'std'}, + ['goto']={'target'},call={'target'},goto_if={'cond','target'},call_if={'cond','target'}, + applymovement={'localId','movement'},applymovementat={'localId','movement'},waitmovement={'localId'}, + removeobject={'localId'},addobject={'localId'},message={'ptr'},special={'id'},specialvar={'var','id'}, + textcolor={'color'},setobjectxyperm={'localId'},setobjectmovementtype={'localId'}, + callnative={'fn'},gotonative={'fn'}} +local targets={['goto']=1,call=1,goto_if=2,call_if=2,vgoto=1,vcall=1,vgoto_if=2,vcall_if=2} +local trainerPointers={ + [0]={'introText','defeatText'},[1]={'introText','defeatText','eventScript'}, + [2]={'introText','defeatText','eventScript'},[3]={'defeatText'}, + [4]={'introText','defeatText','notEnoughText'},[5]={'introText','defeatText'}, + [6]={'introText','defeatText','notEnoughText','eventScript'}, + [7]={'introText','defeatText','notEnoughText'},[8]={'introText','defeatText','notEnoughText','eventScript'}, + [9]={'introText','defeatText'},[10]={'introText','defeatText'},[11]={'introText','defeatText'},[12]={'introText','defeatText'}, +} +function M.row(ir,edition) + local row={op=ir[1],opcode=ir.sourceOpcode} + if ir.sourceSpec=='*'and type(ir.sourceBytes)=='string'then + local bytes=ir.sourceBytes + local function number(at,n) + local value=0;for i=0,n-1 do value=value+assert(bytes:byte(at+i),'truncated trainer operands')*256^i end;return value + end + row.type=number(1,1);row.trainer=number(2,2);row.localId=number(4,2) + row[1],row[2]=row.trainer,row.localId + local fields=trainerPointers[row.type] + if row.type==9 and(edition=='firered'or edition=='leafgreen')then fields={'defeatText','victoryText'};row.flags=row.localId end + assert(fields,'original trainer battle type unavailable') + assert(#bytes==5+#fields*4,'trainer operand size differs') + for i,name in ipairs(fields)do row[name]=M.sourceKey(number(6+(i-1)*4,4))end + elseif ir.sourceSpec and type(ir.sourceBytes)=='string'then + local at=1 + for i=1,#ir.sourceSpec do + local width=({b=1,w=2,d=4})[ir.sourceSpec:sub(i,i)] + assert(width,'invalid original operand specification') + local value=0 + for n=0,width-1 do value=value+assert(ir.sourceBytes:byte(at+n),'truncated original operands')*256^n end + row[i]=value;at=at+width + end + assert(at==#ir.sourceBytes+1,'original operand size differs') + else + assert(ir[1]~='trainerbattle','original trainer-battle projection is not yet available') + for i=2,#ir do row[i-1]=M.copy(ir[i])end + end + if ir[1]=='special'or ir[1]=='specialvar'then + assert(type(ir.sourceSpecial)=='number','original script operands unavailable; cartridge cache needs regeneration') + row[ir[1]=='special'and 1 or 2]=ir.sourceSpecial + end + local target=targets[row.op] + if target then row[target]=M.sourceKey(row[target])end + if row.op=='message'or row.op=='vmessage'or row.op=='braillemessage'or row.op=='getbraillestringwidth' + or row.op=='messageautoscroll'or row.op=='messageinstant'or row.op=='pokenavcall'then + row[1]=M.sourceKey(row[1]);row.ptr=row[1] + end + if row.op=='loadword'then row[2]=M.sourceKey(row[2])end + if row.op=='bufferstring'then row[2]=M.sourceKey(row[2]);row.src=row[2]end + if row.op=='applymovement'or row.op=='applymovementat'then row[2]=M.sourceKey(row[2])end + for i,name in ipairs(aliases[row.op]or{})do row[name]=row[i]end + return row +end +local phaseNames={[1]='onLoad',[2]='onFrame',[3]='onTransition',[4]='onWarpIntoMap', + [5]='onResume',[6]='onDiveWarp',[7]='onReturnToField'} +function M.new(data,edition,valid) + local pool=data and data.map_scripts + if not(pool and pool.source=='RomExtractorGen3'and type(pool.scripts)=='table')then + return nil,'native script bundle unavailable' + end + local binding=Identity.bind(data.maps,edition) + local bundle={scripts={},events={},labels={},fromNative=true} + local cache,expected={},{} + records[bundle]={cache=cache,expected=expected,scripts=bundle.scripts} + -- Materialize a list only when a consumer asks for it. Mutating this owned + -- list never mutates the compiler cache or another mod's native definitions. + local function script(key) + assert(valid(),'native script bundle retired') + local native=M.nativeKey(key) + native=pool.labels and(pool.labels[native]or native)or native + local rows=pool.scripts[native];if not rows then return nil end + local canonical=M.sourceKey(native);local cached=cache[canonical] + if cached then return cached end + local out={};for i,ir in ipairs(rows)do out[i]=M.row(ir,edition)end + cache[canonical]=out;expected[canonical]=M.copy(out);return out + end + setmetatable(bundle.scripts,{__index=function(_,key)return script(key)end}) + Iteration.register(bundle.scripts,function() + assert(valid(),'native script bundle retired') + local out={};for key in pairs(pool.scripts)do local source=M.sourceKey(key);out[source]=script(source)end;return out + end) + for key,label in pairs(pool.labels or{})do bundle.labels[key]=M.sourceKey(label)end + bundle.nativeMovementsAvailable=type(pool.movements)=='table' + -- Native text definitions retain their native keys. The text-IR service + -- owns address/name projection and richer control-code semantics. + bundle.nativeTextAvailable=type(data.text)=='table' + for nativeId,entry in pairs(pool.maps or{})do + local id=binding:legacyId(nativeId) + local def=data.maps[nativeId] + if id and def then + local ev={objects=M.copy(def.objects or{}),bgEvents=M.copy(def.signs or{}),coordEvents=M.copy(def.coordEvents or{}), + mapScripts={onFrame={},onWarpIntoMap={}},music=def.music} + for i,obj in ipairs(ev.objects)do + obj.localId=obj.localId or obj.index;obj.graphics=obj.graphicsId;obj.flag=obj.flagId or obj.flag + obj.scriptKey=M.sourceKey((entry.objects or{})[i]) + end + for i,sign in ipairs(ev.bgEvents)do sign.scriptKey=M.sourceKey((entry.signs or{})[i])end + for _,cb in ipairs(entry.callbacks or{})do + local name=phaseNames[cb.type];if name then ev.mapScripts[name]=M.sourceKey(cb.script)end + end + for _,tbl in ipairs(entry.tables or{})do + local name=phaseNames[tbl.type] + if name then + local rows={};for i,r in ipairs(tbl.rows or{})do rows[i]={var=r.var,value=r.value,script=M.sourceKey(r.script)}end + ev.mapScripts[name]=rows + end + end + for i,coord in ipairs(entry.coords or{})do + ev.coordEvents[i]=M.copy(coord);ev.coordEvents[i].scriptKey=M.sourceKey(coord.script) + end + bundle.events[id]=ev + end + end + local header=pool.sourceInterface + bundle.originalOperandsAvailable=header and header.schema==Header.SCHEMA and header.edition==edition or false + return bundle +end +local function same(a,b) + if type(a)~=type(b)then return false end + if type(a)~='table'then return a==b end + for k,v in pairs(a)do if not same(v,b[k])then return false end end + for k in pairs(b)do if a[k]==nil then return false end end + return true +end +function M.validate(bundle) + local record=records[bundle] + if not record then return false,'replacement script bundles require the native overlay compiler'end + if bundle.scripts~=record.scripts then return false,'replacement script lists require the native overlay compiler'end + if Iteration.rawNext(bundle.scripts)~=nil then return false,'authored script lists require the native overlay compiler'end + for k,v in pairs(record.cache)do + if not same(v,record.expected[k])then return false,'edited script rows require the native overlay compiler'end + end + return true +end +return M diff --git a/src/compat/gen1/ScriptMap.lua b/src/compat/gen1/ScriptMap.lua new file mode 100644 index 000000000..84c14cd3c --- /dev/null +++ b/src/compat/gen1/ScriptMap.lua @@ -0,0 +1,57 @@ +-- Numeric source-facing tables, with synchronous native command writes. +-- Iteration uses owned values; neither the save nor a script context is lent. +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local function key(value) + value=tonumber(value) + if type(value)=='number'and value==value and value%1==0 and value>=0 and value<=65535 then return value end +end +function M.new(backend,kind) + assert(kind=='flags'or kind=='vars','invalid script map kind') + local state={backend=backend,kind=kind};local view={} + local function readable(k) + return k and(kind=='flags'or k>=0x4000 and not backend:isSpecial(k)) + end + setmetatable(view,{ + __index=function(_,k) + k=key(k);if not readable(k)then return nil end + if kind=='flags'then return backend:flagValue(k)end + return backend:varValue(k) + end, + __newindex=function(_,k,value) + k=key(k);assert(readable(k),'invalid persistent script map key') + if kind=='flags'then if value==nil then value=false end;backend:setFlag(k,value) + elseif value==nil then backend:clearVar(k) + else backend:setVar(k,value)end + end, + }) + return Iteration.register(view,function(s)return s.backend:snapshot()[s.kind]end,state,function(s,k) + if s.kind=='flags'then return s.backend:nextFlag(k)end + return s.backend:nextVar(k) + end) +end +function M.replace(backend,kind,value) + assert(kind=='flags'or kind=='vars','invalid script map kind') + assert(type(value)=='table','invalid persistent script map') + -- Validate and normalize the complete replacement before any command can + -- mutate native state. No special variables belong to a persistent map. + local normalized={} + for k,v in pairs(value)do + local n=key(k) + assert(n and(kind=='flags'or n>=0x4000 and not backend:isSpecial(n)),'invalid persistent script map key') + if kind=='flags'then assert(type(v)=='boolean','invalid script flag value') + else assert(type(v)=='number'and v==v and v%1==0 and math.abs(v)<=9007199254740991,'invalid script variable value')end + assert(normalized[n]==nil or normalized[n]==v,'conflicting script map aliases') + normalized[n]=v + end + local current=backend:snapshot()[kind] + for k in pairs(current)do + if normalized[k]==nil then + if kind=='flags'then backend:setFlag(k,false)else backend:clearVar(k)end + end + end + for k,v in pairs(normalized)do + if kind=='flags'then backend:setFlag(k,v)else backend:setVar(k,v)end + end +end +return M diff --git a/src/compat/gen1/ScriptPreview.lua b/src/compat/gen1/ScriptPreview.lua new file mode 100644 index 000000000..633a001ca --- /dev/null +++ b/src/compat/gen1/ScriptPreview.lua @@ -0,0 +1,37 @@ +-- A bounded native command pass over detached state. It never publishes a +-- field, borrows a live runner, dispatches mod hooks or starts UI/audio/IO. +local Bundle=require('src.compat.gen1.ScriptBundle') +local M={} +local admission={maxInstructions=4000,commands={ + label=true,jump=true,g3_call=true,g3_return=true,g3_jump_if=true,g3_jump_unless=true,g3_call_if=true, + g3_setvar=true,g3_addvar=true,g3_subvar=true,g3_copyvar=true,g3_setorcopyvar=true, + g3_compare_value=true,g3_compare_var=true,g3_check_flag=true, + g3_place_perm=true,g3_movement_type=true, +}} +function M.run(c,nativeId) + local snapshot=c.backend:snapshot() + local save={flags={},gen3Vars=Bundle.copy(snapshot.vars)} + for id,value in pairs(snapshot.flags)do save.flags[('FLAG_G3_%04X'):format(id)]=value end + for id=0x4010,0x401f do save.gen3Vars[id]=nil end + local entry=c.pool and c.pool.maps and c.pool.maps[nativeId] + local key + for _,cb in ipairs(entry and entry.callbacks or{})do if cb.type==3 then key=cb.script;break end end + if key then + local rows=require('src.script.Gen3ScriptVM').compile(c.data,key) + if not rows then return nil,'native neighbour transition did not compile'end + local runner=require('src.script.ScriptRunner').new({data=c.data,save=save}) + local ctx=runner:makeContext({mapId=nativeId,source={strict=true}}) + local ok,why=pcall(runner.exec,runner,rows,ctx,admission) + if not ok then return nil,why end + end + local flags,vars={},{} + for name,value in pairs(save.flags)do local hex=name:match('^FLAG_G3_(%x%x%x%x)$');if hex then flags[tonumber(hex,16)]=value end end + for id,value in pairs(save.gen3Vars)do if not c.backend:isSpecial(id)then vars[id]=value end end + local result={store={flags=flags,vars=vars},perm={},movementType={}} + for id,home in pairs(save.gen3ObjectHomes and save.gen3ObjectHomes[nativeId]or{})do + if home.x~=nil and home.y~=nil then result.perm[id]={x=home.x,y=home.y}end + result.movementType[id]=home.movementType + end + return result +end +return M diff --git a/src/compat/gen1/ScriptRuntime.lua b/src/compat/gen1/ScriptRuntime.lua new file mode 100644 index 000000000..463da7a51 --- /dev/null +++ b/src/compat/gen1/ScriptRuntime.lua @@ -0,0 +1,277 @@ +-- Source scripting observations and commands over the existing native runner. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Identity=require('src.compat.gen1.MapIdentity') +local Store=require('src.compat.gen1.ScriptStore') +local Maps=require('src.compat.gen1.ScriptMap') +local Bundle=require('src.compat.gen1.ScriptBundle') +local Iteration=require('src.compat.gen1.TableIteration') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local R={};R.__index=R +local function owner() + local runtime=package.loaded['src.mods.Runtime'];local g=package.loaded['src.core.Game3'] + return runtime and runtime.currentMod or g and g._callbackOwner and g._callbackOwner() +end +function R.new()return setmetatable({},R)end +function R:owner()return owner()end +function R:cancelOwner(who) + local c=self.context + if c then + for _,key in ipairs({'vm','store'})do + if c.preview[key..'Owner']==who then + c.preview[key],c.preview[key..'Set'],c.preview[key..'Owner']=nil,nil,nil + end + end + end + if self.dataset and self.dataset.overrideOwner==who then self.dataset.override,self.dataset.overrideOwner=nil,nil end +end +function R:retireContext() + local c=self.context + if c then c.life.retired=true;c.backend:release()end + self.context=nil +end +function R:release() + self:retireContext() + if self.dataset then self.dataset.life.retired=true end + self.dataset=nil +end +function R:current(game,world) + local g,why=Native.game(game);if not g then self:release();return nil,why end + if not world then local _,w=Native.resolve(g);world=w end + if world and not(type(world)=='table'and world.isOverworld and world.player and world.map + and g.data and g.data.maps and g.data.maps[world.map.id]==world.map.def)then world=nil end + if not(g.data and type(g.data.maps)=='table'and type(g.save)=='table')then + self:release();return nil,'native script dataset unavailable' + end + local c=self.context;local map=world and world.map + if c and(c.game~=g or c.world~=world or c.map~=map or c.data~=g.data or c.maps~=g.data.maps + or c.save~=g.save or c.loader~=g.mods or c.pool~=g.data.map_scripts or c.edition~=Version.get() + or c.life.runner~=(world and world.runner))then self:retireContext();c=nil end + local dataset=self.dataset + if dataset and(dataset.game~=g or dataset.data~=g.data or dataset.maps~=g.data.maps or dataset.save~=g.save + or dataset.loader~=g.mods or dataset.pool~=g.data.map_scripts or dataset.edition~=Version.get())then + dataset.life.retired=true;self.dataset=nil;dataset=nil + end + if not dataset then + dataset={game=g,data=g.data,maps=g.data.maps,save=g.save,loader=g.mods,pool=g.data.map_scripts,edition=Version.get()} + local held={};for k,v in pairs(dataset)do held[k]=v end;dataset.life=held + dataset.bundle,dataset.error=Bundle.new(g.data,dataset.edition,function() + return not held.retired and held.data==g.data and held.maps==g.data.maps and held.save==g.save + and held.loader==g.mods and held.pool==g.data.map_scripts and held.edition==Version.get() + end) + self.dataset=dataset + end + if not c then + c={game=g,world=world,map=map,data=g.data,maps=g.data.maps,save=g.save,loader=g.mods,pool=g.data.map_scripts,edition=Version.get()} + c.binding=Identity.bind(c.maps,c.edition);c.id=map and c.binding:legacyId(map.id) + c.backend=assert(Store.new(g)) + local life={game=g,data=c.data,maps=c.maps,save=c.save,loader=c.loader,pool=c.pool,edition=c.edition, + world=world,map=map,runner=world and world.runner} + c.life=life + local function valid() + local _,observed=Native.resolve(g) + return not life.retired and g.data==life.data and g.save==life.save + and g.mods==life.loader and g.data.maps==life.maps and g.data.map_scripts==life.pool and Version.get()==life.edition + and(not life.world or observed==life.world and life.world.map==life.map and life.world.runner==life.runner) + end + c.valid=valid;c.preview={};self.context=c + local backend,preview=c.backend,c.preview + local guarded=setmetatable({},{__index=function(_,key) + local fn=backend[key] + if type(fn)~='function'then return fn end + return function(_,...) + assert(valid(),'native script view retired') + if key=='setFlag'or key=='setVar'or key=='clearVar'then + assert(not(preview.vmSet or preview.storeSet or preview.disabled),'native commands suspended by source preview lease') + end + return fn(backend,...) + end + end}) + local flags,vars=Maps.new(guarded,'flags'),Maps.new(guarded,'vars') + c.store=setmetatable({},{__index=function(_,key) + assert(valid(),'native script view retired') + if key=='flags'then return flags elseif key=='vars'then return vars end + end,__newindex=function(_,key,value) + assert(key=='flags'or key=='vars','invalid script store field') + Maps.replace(guarded,key,value) + end}) + Iteration.register(c.store,function(s) + assert(s.valid(),'native script view retired');return s.backend:snapshot() + end,{valid=valid,backend=backend}) + c.dataset=dataset;c.bundle,c.bundleError=dataset.bundle,dataset.error + if world and world.runner and c.bundle then c.vm=self:vm(c)end + end + return c +end +function R:bundle() + local c,why=self:current();if not c then return nil,why end + return c.dataset.override or c.bundle,c.bundleError +end +function R:key(c,key) + local native=Bundle.nativeKey(key) + local labels=c.dataset.override and c.dataset.override.labels or c.bundle and c.bundle.labels + if labels and labels[key]then native=Bundle.nativeKey(labels[key])end + if c.pool and c.pool.scripts and c.pool.scripts[native]then return native end +end +function R:start(key,localId,facing) + local c,why=self:current();if not(c and c.world and c.vm)then return false,why or'no native field runner'end + if c.disabled or c.preview.vmSet or c.preview.storeSet then return false,'native commands are suspended by a source preview lease'end + local runner=c.world.runner + if runner:isRunning()then return false,'native field runner is busy'end + local intact,reason=Bundle.validate(c.dataset.override or c.bundle) + if not intact then return false,reason end + local native=self:key(c,key);if not native then return false,'native script label unavailable'end + local rows=require('src.script.Gen3ScriptVM').compile(c.data,native) + if not rows then return false,'native script did not compile'end + for _,row in ipairs(rows)do + if row[1]=='g3_unimplemented'or row[1]=='g3_unimplemented_result'then + return false,'native script command unavailable: '..tostring(row[2]) + end + end + local npc + if localId then + assert(type(localId)=='number'and localId%1==0 and localId>=0 and localId<=255,'invalid script local id') + for _,n in ipairs(c.world.npcs or{})do if n.def and n.def.index==localId then npc=n;break end end + end + if facing then + facing=({down=1,up=2,left=3,right=4})[facing]or facing + assert(type(facing)=='number'and facing%1==0 and facing>=1 and facing<=4,'invalid script facing') + end + if localId then c.backend:setVar(0x800f,localId)end + if facing then c.backend:setVar(0x800c,facing)end + local who=owner();c.started={runner=runner,key=native,owner=who} + runner:run(rows,{npc=npc,mapId=c.map.id,source={modId=who,mapId=c.map.id,hook='source script',strict=true}}) + c.started.context=runner.ctx + return true +end +function R:halt() + local c=self:current();local run=c and c.started + if not run or c.world.runner~=run.runner or run.runner.ctx~=run.context or run.owner~=owner()then return false,'native script belongs to another owner'end + local runner=run.runner + if not runner:isRunning()then return true end + local states=c.game.stack and c.game.stack.states + if states and states[#states]~=c.world then return false,'native script UI must finish before cancellation'end + if #(c.world.scriptMoves or{})>0 then return false,'native script movements must finish before cancellation'end + -- Reuse the native cancellation cleanup, including locks/fades and the + -- script-ended notification; do not replace another runner's coroutine. + return runner:cancelOwned(run.context,runner.co) +end +function R:vm(c) + local runtime=self;local vm={} + local special=setmetatable({},{ + __index=function(_,id) + assert(c.valid(),'native script view retired');id=tonumber(id) + if not id or id%1~=0 or not c.backend:isSpecial(id)then return nil end + return c.backend:varValue(id) + end, + __newindex=function(_,id,value) + assert(c.valid(),'native script view retired');id=tonumber(id) + assert(not(c.preview.vmSet or c.preview.storeSet or c.disabled),'native commands suspended by source preview lease') + assert(id and id%1==0 and c.backend:isSpecial(id),'invalid native special variable') + if value==nil then c.backend:clearVar(id)else c.backend:setVar(id,value)end + end}) + Iteration.register(special,function(s) + assert(s.valid(),'native script view retired');local out={} + local high=(s.edition=='firered'or s.edition=='leafgreen')and 0x8014 or 0x8015 + for id=0x8000,high do out[id]=s.backend:varValue(id)end + return out + end,{valid=c.valid,backend=c.backend,edition=c.edition}) + local ctx=setmetatable({specialVars=special},{__index=function(_,key) + assert(c.valid(),'native script view retired');local native=c.world.runner.ctx + if key=='status'then return c.world.runner:isRunning()and'running'or'stopped'end + local value=native and native[key] + if type(value)~='table'and type(value)~='function'and type(value)~='userdata'then return value end + end,__newindex=function()error('script context writes require their native command',2)end}) + function vm:isRunning()return c.valid()and c.world.runner:isRunning()or false end + function vm:start(key)assert(c.valid(),'native script view retired');return runtime:start(key)end + function vm:startTalk(key,id,facing)assert(c.valid(),'native script view retired');return runtime:start(key,id,facing)end + function vm:tick()assert(c.valid(),'native script view retired');return Boundary.invoke('tick')end + function vm:halt()assert(c.valid(),'native script view retired');return runtime:halt()end + function vm:getText(key) + assert(c.valid(),'native script view retired');local b=runtime:bundle() + if not(b and b.text)then return nil,'source text control-code projection is not available'end + return b.text[key] + end + return setmetatable(vm,{__index=function(_,key) + assert(c.valid(),'native script view retired') + if key=='ctx'then return ctx end + if key=='store'then return c.preview.storeSet and c.preview.store or c.store end + if key=='scripts'or key=='text'or key=='movements'then local b=runtime:bundle();return b and b[key]end + end}) +end +function R:phase(name,game,world,mapId,body,source) + return Native.withWorld(game,world,function() + local c=self:current(game,world) + if not c or c.id==nil or not c.vm then return body()end + if c.disabled or c.preview.vmSet or c.preview.storeSet then return body()end + local intact,reason=Bundle.validate(c.dataset.override or c.bundle) + assert(intact,reason) + local facade=require('src.core.Game3');local observed=assert(facade.forNative(game)) + return Boundary.dispatch(name,function() + if name=='runEnterScripts'then + c.entered=true;c.requests=c.requests or{} + local r=package.loaded['src.core.game3.runtime'];local serial=r and r.tickVblank()or 0 + local entry=c.pool.maps and c.pool.maps[c.map.id] + for _,cb in ipairs(entry and entry.callbacks or{})do c.requests[cb.type]=serial end + end + return body() + end,source,nil,c.id,observed,world) + end) +end +function R:tick(runner,body) + local c=self:current() + if not(c and c.vm and c.world.runner==runner)then return body(runner)end + -- A detached preview does not take ownership of the live game's clock. + if c.preview.vmSet or c.preview.storeSet then return body(runner)end + return Boundary.dispatch('tick',function()return body(runner)end,function()return c.vm:tick()end) +end +function R:request(kind,mapId) + local c,why=self:current() + if not(c and c.world and c.bundle)then return false,why or'no native field script space'end + if mapId and mapId~=c.id then return false,'native map is not the requested source map'end + if c.preview.vmSet or c.preview.storeSet then return false,'source preview lease is active'end + local intact,reason=Bundle.validate(c.dataset.override or c.bundle) + if not intact then return false,reason end + if kind==1 or kind==3 then return c.entered==true,'native map-entry phase owns this callback'end + local rows={} + local entry=c.pool and c.pool.maps and c.pool.maps[c.map.id] + for _,cb in ipairs(entry and entry.callbacks or{})do if cb.type==kind then rows[#rows+1]=cb.script end end + if kind==4 then + for _,tbl in ipairs(entry and entry.tables or{})do if tbl.type==kind then + for _,row in ipairs(tbl.rows or{})do + if c.backend:getVar(row.var)==c.backend:getVar(row.value or 0)then rows[#rows+1]=row.script;break end + end + end end + end + local r=package.loaded['src.core.game3.runtime'];local serial=r and r.tickVblank()or 0 + c.requests=c.requests or{} + if c.requests[kind]==serial then return true,'native callback already requested'end + local compiler=require('src.script.Gen3ScriptVM');local compiled={} + for _,key in ipairs(rows)do + local code=compiler.compile(c.data,key) + if code then + for _,row in ipairs(code)do + if row[1]=='g3_unimplemented'or row[1]=='g3_unimplemented_result'then return false,'native callback command unavailable: '..tostring(row[2])end + end + compiled[#compiled+1]=code + end + end + if #compiled==0 then return false,'native callback is absent'end + assert(type(c.world.queueScript)=='function','native script queue unavailable') + c.requests[kind]=serial + for _,code in ipairs(compiled)do c.world:queueScript(code,{mapId=c.map.id})end + return true +end +function R:neighbor(mapId) + local c,why=self:current();if not(c and c.world and c.bundle)then return nil,why end + local id=c.binding:nativeId(mapId) + if not id or id==c.map.id then return nil,'not a native neighbour map'end + local found=false + for _,row in ipairs(c.world.neighbors or{})do if row.map and row.map.id==id then found=true;break end end + if not found then return nil,'requested map is not in the native connection world'end + if c.preview.vmSet or c.preview.storeSet then return nil,'source preview lease is active'end + -- Recompute from current native variables: transitions may have changed a + -- gating var without changing maps or the identity of its store. + return require('src.compat.gen1.ScriptPreview').run(c,id) +end +return R diff --git a/src/compat/gen1/ScriptStore.lua b/src/compat/gen1/ScriptStore.lua new file mode 100644 index 000000000..f4be99b1a --- /dev/null +++ b/src/compat/gen1/ScriptStore.lua @@ -0,0 +1,125 @@ +-- Numeric script state over one native save. Writes use the native commands +-- so visibility, flag events, league aliases and graphics slots stay coherent. +-- This is a backend for the scripting facade, not a second store or VM. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local M={} +local rawNext=next +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(value,lo) + value=tonumber(value) + assert(integer(value,lo or 0,65535),'invalid numeric script id') + return value +end +local function special(key,edition) + local high=(edition=='firered'or edition=='leafgreen')and 0x8014 or 0x8015 + return key>=0x8000 and key<=high +end +function M.new(game) + local owner,why=Native.game(game);if not owner then return nil,why end + local save,data,mods,edition=owner.save,owner.data,owner.mods,Version.get() + if type(save)~='table'or type(data)~='table'then return nil,'native script save unavailable'end + local maps=data.maps;local released=false;local store={} + local function valid() + return not released and Version.get()==edition and owner.save==save + and owner.data==data and owner.mods==mods and data.maps==maps + end + local function current() + assert(valid(),'native script store retired') + end + local function context() + current() + -- Resolve at the command boundary, not when the adapter was created: + -- a connection or warp may have installed another native world. + local _,world=Native.resolve(owner) + return {game=owner,save=save,overworld=world} + end + function store:valid()return valid()end + function store:release()released=true end + function store:isSpecial(key)current();return special(id(key),edition)end + function store:flagValue(key) + current();key=id(key) + if type(save.flags)~='table'then return nil end + local value=save.flags[('FLAG_G3_%04X'):format(key)] + if type(value)=='boolean'then return value end + end + function store:getFlag(key) + current();key=id(key) + return type(save.flags)=='table'and save.flags[('FLAG_G3_%04X'):format(key)]==true or false + end + function store:setFlag(key,on) + current();key=id(key);assert(type(on)=='boolean','invalid script flag value') + assert(type(save.flags)=='table','native flag table unavailable') + require('src.script.Gen3Commands') + local commands=require('src.script.Commands') + local name=('FLAG_G3_%04X'):format(key) + if on then commands.g3_set_flag(context(),name) + else commands.g3_clear_flag(context(),name)end + return true + end + function store:getVar(key) + current();key=id(key) + if key<0x4000 then return key end + local value=type(save.gen3Vars)=='table'and save.gen3Vars[key] + return integer(value,-9007199254740991,9007199254740991)and value or 0 + end + function store:varValue(key) + current();key=id(key,0x4000) + local value=type(save.gen3Vars)=='table'and save.gen3Vars[key] + if integer(value,-9007199254740991,9007199254740991)then return value end + end + function store:setVar(key,value) + current();key=id(key,0x4000) + assert(integer(value,-9007199254740991,9007199254740991),'invalid script variable value') + require('src.script.Gen3Commands').setVar(save,key,value%65536) + return true + end + function store:clearVar(key) + current();key=id(key,0x4000) + -- Use the native writer even for removal: object-graphics variables own + -- a derived slot table which must not retain their previous value. + require('src.script.Gen3Commands').setVar(save,key,0) + save.gen3Vars[key]=nil + return true + end + function store:nextFlag(previous) + current() + if type(save.flags)~='table'then return nil end + local name=previous~=nil and ('FLAG_G3_%04X'):format(id(previous))or nil + while true do + local value;name,value=rawNext(save.flags,name) + if name==nil then return nil end + local hex=type(name)=='string'and name:match('^FLAG_G3_(%x%x%x%x)$') + local n=hex and tonumber(hex,16) + if n and name==('FLAG_G3_%04X'):format(n)and type(value)=='boolean'then return n,value end + end + end + function store:nextVar(previous) + current() + if type(save.gen3Vars)~='table'then return nil end + local key=previous~=nil and id(previous,0x4000)or nil + while true do + local value;key,value=rawNext(save.gen3Vars,key) + if key==nil then return nil end + if integer(key,0x4000,65535)and not special(key,edition) + and integer(value,-9007199254740991,9007199254740991)then return key,value end + end + end + function store:snapshot() + current();local flags,vars={},{} + for name,value in pairs(save.flags or{})do + local hex=type(name)=='string'and name:match('^FLAG_G3_(%x%x%x%x)$') + local key=hex and tonumber(hex,16) + if key and name==('FLAG_G3_%04X'):format(key)and type(value)=='boolean'then flags[key]=value end + end + for key,value in pairs(save.gen3Vars or{})do + if integer(key,0x4000,65535)and not special(key,edition) + and integer(value,-9007199254740991,9007199254740991)then vars[key]=value end + end + return {flags=flags,vars=vars} + end + return store +end +return M diff --git a/src/compat/gen1/SessionView.lua b/src/compat/gen1/SessionView.lua new file mode 100644 index 000000000..89ebc6b3e --- /dev/null +++ b/src/compat/gen1/SessionView.lua @@ -0,0 +1,163 @@ +-- Typed observations of one native GBA save, never another save/simulation. +-- Collection schemas (party, bag, script stores) have separate adapters; do +-- not lend differently shaped native tables under those source names. +local Native=require('src.compat.gen1.NativeSession') +local Identity=require('src.compat.gen1.MapIdentity') +local Version=require('src.core.GameVersion') +local ScriptStore=require('src.compat.gen1.ScriptStore') +local ScriptMap=require('src.compat.gen1.ScriptMap') +local Iteration=require('src.compat.gen1.TableIteration') +local M={} +local records=setmetatable({},{__mode='k'}) +local stores=setmetatable({},{__mode='k'}) +local function finite(n)return type(n)=='number'and n==n and math.abs(n)=0 and n or 0 end + return number(value.hours)*3600+number(value.minutes,value.mins)*60 + +number(value.seconds,value.secs)+number(value.frames,value.vblanks)/60 + end + local n=tonumber(value);return finite(n)and n>=0 and n or 0 +end +function M.new(game) + local owner,why=Native.game(game);if not owner then return nil,why end + local data,save,edition=owner.data,owner.save,Version.get() + if type(data)~='table'or type(data.maps)~='table'or type(save)~='table'then return nil,'native save data unavailable'end + if not Identity.lookup(edition,0,0)then return nil,'native source map catalogue unavailable'end + local binding=Identity.bind(data.maps,edition) + local record={owner=owner,data=data,save=save,edition=edition,maps=data.maps,clock={}} + local scripts=assert(ScriptStore.new(owner)) + local flags,vars=ScriptMap.new(scripts,'flags'),ScriptMap.new(scripts,'vars') + local view={} + local bag,dex,party={},{},{} + stores[bag]={record=record,kind='bag'};stores[dex]={record=record,kind='dex'} + function record:valid() + if self.retired then return false end + if not scripts:valid()or Version.get()~=edition or owner.data~=data or owner.save~=save or data.maps~=self.maps then + self.retired=true;scripts:release();if self.mons then self.mons:release()end;return false + end + return true + end + record.mons=require('src.compat.gen1.PokemonView').new(owner,function()return record:valid()end) + function record:seconds()return M.seconds(save)end + function record:world() + if not self:valid()then return end + local _,world,player=Native.resolve(owner);return world,player + end + function record:map() + local world=self:world();local id=world and world.map.id or(save.player and save.player.map) + return id and binding:legacyId(id) + end + local function current() + if not record:valid()then return nil end + return save.player or{},record:world() + end + setmetatable(view,{ + __index=function(_,key) + local player,world,actor=current();if not player then return nil end + if key=='version'then return edition end + if key=='generation'then return 3 end + if key=='flags'then return flags end + if key=='vars'then return vars end + if key=='engine'then return 'game3'end + if key=='bag'then return bag end + if key=='dex'then return dex end + if key=='party'then + local count=#(save.party or{}) + for i,m in ipairs(save.party or{})do party[i]=record.mons:view(m)end + for i=#party,count+1,-1 do party[i]=nil end + return party + end + if key=='secretId'then return player.secretId or player.sid or 0 end + if key=='safari'then + local out;if save.safari then out={};for k,v in pairs(save.safari)do if type(v)~='table'then out[k]=v end end end + return out + end + if key=='map'then return record:map()end + if key=='x'or key=='y'then return actor and actor[key=='x'and'cellX'or'cellY']or player[key]end + if key=='facing'then return actor and actor.facing or player.facing end + if key=='name'then return player.name end + if key=='rivalName'then return player.rival end + if key=='trainerId'then return player.id end + if key=='gender'then return player.gender end + if key=='money'or key=='coins'or key=='modData'or key=='meta'or key=='options'then return save[key]end + if key=='biking'then return save.onBike==true end + if key=='bikeType'then return actor and actor.acroBike and'acro'or save.bikeType end + if key=='surfing'then if actor then return actor.surfing==true end;return player.surfing==true end + if key=='underwater'then return world and world.map.def.mapType=='UNDERWATER'or false end + if key=='elevation'then return actor and actor.elevation or player.elevation end + local seconds=record:seconds();local whole=math.floor(seconds) + if key=='playTime'or key=='playtime'then + local clock=record.clock + clock.hours,clock.minutes,clock.seconds=math.floor(whole/3600),math.floor(whole/60)%60,whole%60 + clock.vblanks=math.floor((seconds-whole)*60+.000001) + return clock + end + if key=='hours'or key=='playTimeHours'then return math.floor(whole/3600)end + if key=='minutes'or key=='playTimeMinutes'then return math.floor(whole/60)%60 end + if key=='seconds'or key=='playTimeSeconds'then return whole%60 end + end, + __newindex=function(t,key,value) + assert(record:valid(),'native session view retired') + local player=save.player + if key=='money'or key=='coins'then + assert(finite(value)and value>=0 and value%1==0 and value<4294967296,'invalid session '..key) + save[key]=value + elseif key=='flags'or key=='vars'then + ScriptMap.replace(scripts,key,value) + elseif key=='name'or key=='rivalName'then + assert(player and type(value)=='string'and#value<=10 and not value:find('%c'),'invalid player name') + player[key=='name'and'name'or'rival']=value + elseif key=='gender'then + assert(player and(value==0 or value==1),'invalid player gender');player.gender=value + elseif key=='trainerId'then + assert(player and finite(value)and value>=0 and value<=65535 and value%1==0,'invalid native trainer id');player.id=value + elseif key=='biking'then + assert(type(value)=='boolean','invalid bike state');save.onBike=value + elseif key=='party'or key=='bag'or key=='dex'or key=='secretId'or key=='safari'or key=='map'or key=='x'or key=='y'or key=='facing'or key=='elevation'or key=='surfing' + or key=='version'or key=='generation'or key=='engine'or key=='options'or key=='modData'or key=='meta' + or key=='playTime'or key=='playtime'or key=='hours'or key=='minutes'or key=='seconds' + or key=='playTimeHours'or key=='playTimeMinutes'or key=='playTimeSeconds'then + error('session '..key..' belongs to its native command owner',2) + else + -- Temporary presentation/preview fields stay detached. Persistence + -- belongs to mod.save; this never adds fields to a player's save. + rawset(t,key,value) + end + end, + }) + record.scripts=scripts + local fields={'version','generation','engine','map','x','y','facing','name','rivalName','trainerId','gender', + 'party','bag','dex','secretId','safari','money','coins','modData','meta','options','biking','bikeType','surfing','underwater','elevation', + 'playTime','flags','vars'} + Iteration.register(view,function(state,source) + assert(state:valid(),'native session view retired') + local out={} + -- Include detached presentation fields without recursing through the + -- registered iterator. No registry callback captures its own weak key. + for k,v in Iteration.rawPairs(source)do out[k]=v end + for _,key in ipairs(fields)do + local v=source[key] + if key=='flags'or key=='vars'then v=Iteration.snapshot(v) + elseif key=='playTime'and v then + v={hours=v.hours,minutes=v.minutes,seconds=v.seconds,vblanks=v.vblanks} + end + out[key]=v + end + return out + end,record) + records[view]=record + return view,record +end +function M.record(view)return records[view]end +function M.release(view) + local record=records[view] + if record then record.retired=true;record.scripts:release();if record.mons then record.mons:release()end end +end +function M.store(value,kind) + local entry=stores[value] + if entry and entry.kind==kind and entry.record:valid()then return entry.record.owner,entry.record.save end +end + +return M diff --git a/src/compat/gen1/SourceActors.lua b/src/compat/gen1/SourceActors.lua new file mode 100644 index 000000000..d97065a4d --- /dev/null +++ b/src/compat/gen1/SourceActors.lua @@ -0,0 +1,99 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Draw-only actors join native sorting, never NPC updates or collision pools. +local Native=require('src.compat.gen1.NativeSession') +local Scope=require('src.render.GraphicsScope') +local M={} +-- An installed presentation is owned even when the mod drops its view. +-- Retire it explicitly with the native actor/cohort or its installing owner. +local bindings=setmetatable({},{__mode='k'}) +function M.bind(actor,record)bindings[actor]=record end +local function retire(actor,record) + -- Object views may outlive removal or remain in a VM stack slot. Release + -- the installed artwork explicitly; dropping this weak-key binding alone + -- leaves it owned by the still-reachable view record until a later GC. + record.presentationSet=nil;record.presentationOwner=nil + record.extras.sprite=nil;bindings[actor]=nil +end +function M.poll() + for actor,r in pairs(bindings)do + if r.retired or not r.presentationSet or r.view.localId==nil then retire(actor,r) end + end +end +function M.renderer(actor) + local r=bindings[actor] + if not r then return nil,false end + if r.retired or not r.presentationSet or r.view.localId==nil then retire(actor,r);return nil,false end + return r.extras.sprite,true +end +function M.releaseOwner(_,who) + for actor,r in pairs(bindings)do if r.presentationOwner==who then + r.presentationSet=nil;r.extras.sprite=nil;bindings[actor]=nil + end end +end +function M.release() + for actor,r in pairs(bindings)do r.presentationSet=nil;r.extras.sprite=nil;bindings[actor]=nil end +end +require('src.core.game3.asset_stream').register(M) +local function finite(n)return type(n)=='number'and n==n and math.abs(n)=1 and level<=100,'invalid Pokemon level') + mon={species=key,level=level,personality=value.personality or 0,ivs={},evs={},moves={},hp=value.hp,exp=value.exp, + happiness=value.friendship or value.happiness or def.friendship or 70,nickname=value.nickname,isEgg=value.isEgg, + item=ids:native('items',value.item),otId=value.otId,otSecretId=value.otSecretId} + assert(type(mon.personality)=='number'and mon.personality%1==0 and mon.personality>=0 and mon.personality<4294967296,'invalid personality') + mon.nature=(data.constants.natureOrder or{})[mon.personality%25+1] + mon.abilitySlot=mon.personality%2+1 + for a,b in pairs(names)do + local iv=(value.ivs or{})[a]or(value.ivs or{})[b]or 0 + local ev=(value.evs or{})[a]or(value.evs or{})[b]or 0 + assert(type(iv)=='number'and iv%1==0 and iv>=0 and iv<=31,'invalid IV') + assert(type(ev)=='number'and ev%1==0 and ev>=0 and ev<=255,'invalid EV') + mon.ivs[b],mon.evs[b]=iv,ev + end + mon.stats=Stats.calcGen3(def,level,mon.ivs,mon.evs,mon.nature) + -- Explicit combat stats may include temporary overworld modifiers. + for a,b in pairs(fields)do + local n=value[a] + if type(n)=='number'and n>0 then assert(n==n and n=128 and n-256 or n end +function M.decode(bytes,gid) + if type(bytes)~='string'or#bytes<16 or bytes:sub(1,4)~='SVOW'then return nil,'invalid sprite metadata'end + local version=bytes:byte(5);if version<1 or version>4 then return nil,'unsupported sprite metadata version'end + local r={formatVersion=version,inanimate=bytes:byte(6)==1,graphicsId=u16(bytes,7), + width=u16(bytes,9),height=u16(bytes,11),frameCount=u16(bytes,13),paletteTag=u16(bytes,15),drawOffX=0,drawOffY=0} + if r.graphicsId~=gid then return nil,'sprite metadata identity mismatch'end + if r.width<8 or r.width>128 or r.height<8 or r.height>128 or r.frameCount<1 or r.frameCount>256 + or r.width*r.height*r.frameCount>2097152 then return nil,'sprite metadata geometry limit'end + if version>=2 then + if #bytes<118 then return nil,'truncated sprite palettes'end + r.reflectionPaletteTag=u16(bytes,17);r.paletteSlot=bytes:byte(19);r.reflectionPaletteMappedTag=u16(bytes,20) + local flags=bytes:byte(22) + for index,key in ipairs({'palette','reflectionPalette','mappedReflectionPalette'})do + local p={};for c=0,15 do p[c]=u16(bytes,23+(index-1)*32+c*2)end;r[key]=p + end + if flags%2==0 then r.reflectionPalette=nil end + if math.floor(flags/2)%2==0 then r.mappedReflectionPalette=nil end + if r.reflectionPaletteTag==65535 then r.reflectionPaletteTag=nil end + if r.reflectionPaletteMappedTag==65535 then r.reflectionPaletteMappedTag=nil end + end + if version==4 then + if #bytes<120 then return nil,'truncated sprite anchors'end + r.drawOffX,r.drawOffY=signed(bytes:byte(119)),signed(bytes:byte(120)) + end + return r +end +function M.read(cache,root,gid) + local meta=cache:read(root..'/'..gid..'.meta');if not meta then return nil end + local r,why=M.decode(meta,gid);if not r then return nil,why end + local rgba=cache:read(root..'/'..gid..'.rgba') + if type(rgba)~='string'or#rgba~=r.width*r.height*r.frameCount*4 then return nil,'invalid sprite RGBA payload'end + return r,rgba +end +function M.manifest(cache,root) + local bytes=cache:read(root..'/manifest.lua') + if not bytes then return nil end + if type(bytes)~='string'or#bytes>1048576 then return nil,'sprite manifest size limit'end + local chunk,why + if setfenv then chunk,why=loadstring(bytes,'@sprite-manifest') + else chunk,why=load(bytes,'@sprite-manifest','t',{})end + if not chunk then return nil,why end + if setfenv then setfenv(chunk,{})end + -- Count hooks do not interrupt an already compiled LuaJIT loop. The tiny + -- generated manifest runs once; disable traces for it and its nested data + -- constructors so the instruction guard remains effective. + if jit and jit.off then jit.off(chunk,true)end + local hook,mask,count=debug.gethook() + debug.sethook(function()error('sprite manifest instruction limit')end,'',200000) + local ok,result=pcall(chunk);debug.sethook(hook,mask,count) + if not ok or type(result)~='table'then return nil,'invalid sprite manifest'end + return result +end +return M diff --git a/src/compat/gen1/SpritePose.lua b/src/compat/gen1/SpritePose.lua new file mode 100644 index 000000000..3e8279e1f --- /dev/null +++ b/src/compat/gen1/SpritePose.lua @@ -0,0 +1,79 @@ +-- Pure image-table pose rules. Observing a pose never advances a game clock. +local P={} +local Version=require('src.core.GameVersion') +local axis={down=0,up=1,left=2,right=2} +local function number(v,default) + v=tonumber(v);return v and v==v and math.abs(v)=12 then return P.fishingAbsFrame(facing,math.max(0,math.min(3,number(opts.fishFrame,3)))),mirrored end + if opts.frame~=nil then return math.max(0,math.min(n-1,number(opts.frame,0))),mirrored end + if n<=1 or sprite.inanimate then return 0,false end + if opts.bow and n>9 then return 9,false end + if opts.fieldMove and n>=5 then local f=number(opts.fieldMoveFrame,4);return f>=n and 0 or f,false end + if opts.running~=nil and n>=18 then + local profile=package.loaded['src.core.game3.profile'] + local ok,row + if profile and profile.forSession then ok,row=pcall(profile.forSession)end + local frames=ok and row and row.field and row.field.runFrames + local r=frames and frames[opts.running==1 and 2 or 1];r=r and r[flip and'a'or'b'] + if r and r[facing]then return r[facing],mirrored end + local id=Version.get() + if id=='emerald'or id=='ruby'or id=='sapphire'then + return opts.running==1 and 9+a or 12+a*2+(flip and 0 or 1),mirrored + end + return 9+a*3+(opts.running==1 and(flip and 1 or 2)or 0),mirrored + end + if sprite.nativeStandOrder and sprite.nativeWalker==false then return sprite.nativeStandOrder[a+1]or 0,mirrored end + if (phase==1 or phase==true)and n>=9 then return 3+a*2+(flip and 0 or 1),mirrored end + return math.min(a,n-1),mirrored +end +function P.avatarState(player) + if not player then return 'NORMAL'end + if number(player.fieldMoveAnim,0)>0 then return 'FIELD_MOVE'end + if player.underwater then return 'UNDERWATER'end + if player.fishing then return 'FISHING'end + if player.surfing and not player.dismounting or player.flyRide or player.surfJumpFrame and player.surfJumpFrame()then return 'SURFING'end + if player.biking then return player.bikeType=='acro'and'ACRO_BIKE'or'MACH_BIKE'end + return player.watering and'WATERING'or'NORMAL' +end +function P.avatarGraphicsId(state,female,avatars,who) + local rows=avatars and avatars[who or'player'];if not rows then return nil end + local key=female and'female'or'male';local normal + for _,r in ipairs(rows)do if r.state==state then return r[key]end;if r.state=='NORMAL'then normal=r[key]end end + return normal +end +return P diff --git a/src/compat/gen1/SpriteRuntime.lua b/src/compat/gen1/SpriteRuntime.lua new file mode 100644 index 000000000..b7f7c79a5 --- /dev/null +++ b/src/compat/gen1/SpriteRuntime.lua @@ -0,0 +1,349 @@ +-- Owned sprite resources over native imported sheets or an explicit binary +-- cache provider. The native SpriteRenderer/Assets GPU caches are never lent. +local Session=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Sheets=require('src.import.Gen3ObjectSheets') +local Assets=require('src.render.Assets') +local Cache=require('src.compat.gen1.SpriteCache') +local R={};R.__index=R +local ENGINE={} +local function finite(n)return type(n)=='number'and n==n and math.abs(n)=lo and n<=hi end +local function release(x)if x and x.release then pcall(x.release,x)end end +local function copy(v,seen,depth) + if type(v)~='table'then + if type(v)=='number'or type(v)=='string'or type(v)=='boolean'then return v end + return nil + end + seen=seen or{};if seen[v]then return seen[v]end + assert((depth or 0)<32,'sprite metadata nesting limit') + local out={};seen[v]=out + for k,x in pairs(v)do if type(k)=='number'or type(k)=='string'then out[k]=copy(x,seen,(depth or 0)+1)end end + return out +end +local function owner() + local native=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return native and native.currentMod or facade and facade._callbackOwner and facade._callbackOwner()or ENGINE +end +local function root() + local extract=package.loaded['src.import.gba.extract_island1'] + return ((extract and extract.CACHE_ROOT)or'data/generated/gba')..'/ow' +end +local function dropSprite(sprite) + if not sprite then return end + release(sprite.image);release(sprite.imageData) + for _,q in pairs(sprite.quads or{})do release(q)end + sprite.image,sprite.imageData,sprite.quads=nil,nil,{} + sprite._retired=true +end +function R.new() + return setmetatable({loaded={},reflections={},overrides={},errors={},jobs={},providers={},owned={},serial=0,bytes=0,maxBytes=128*1024*1024},R) +end +function R:cancelJob(job) + if self.jobs[job.id]==job then self.jobs[job.id]=nil end + job.cancelled=true + for _,p in ipairs(job.parts)do release(p)end;job.parts={} +end +function R:invalidate() + local old=self.jobs;self.jobs={};for _,job in pairs(old)do self:cancelJob(job)end + for sprite in pairs(self.owned)do dropSprite(sprite)end + self.owned={};self.loaded,self.reflections,self.overrides={},{},{} + self.errors={};self.context=nil;self.bytes=0 +end +function R:release()self:invalidate();self.providers={}end +function R:install(provider) + if provider~=nil and(type(provider)~='table'or type(provider.read)~='function')then return nil,'invalid sprite cache provider'end + local who=owner() + for i=#self.providers,1,-1 do if self.providers[i].owner==who then table.remove(self.providers,i)end end + self.providers[#self.providers+1]={owner=who,cache=provider,edition=Version.get()} + self:invalidate();return self:ready() +end +function R:current() + local game=Session.game();local edition=Version.get();local info=Version.info(edition) + if not info or info.generation~=3 then self:invalidate();return nil,'no native GBA sprite session'end + local data=game and game.data;local meta=data and data.gen3_object_sheets + local lease=self.providers[#self.providers];local provider=lease and lease.edition==edition and lease.cache + if meta and(meta.schema~=Sheets.SCHEMA or meta.edition~=edition or not Version.acceptsSha1(edition,meta.romSha1))then + meta=nil + end + if not meta and not provider then self:invalidate();return nil,'native original sprite cache unavailable or outdated'end + local c=self.context;local saved=game and game.save;local mods=game and game.mods;local prefix=root() + if c and(c.game~=game or c.data~=data or c.meta~=meta or c.save~=saved or c.mods~=mods + or c.edition~=edition or c.provider~=provider or c.root~=prefix or c.assets~=Assets.loader)then self:invalidate();c=nil end + if not c then + c={game=game,data=data,meta=meta,save=saved,mods=mods,edition=edition,provider=provider,root=prefix,assets=Assets.loader} + self.context=c + end + return c +end +function R:manifest() + local c,why=self:current();if not c then return nil,why end + if not c.manifest then + if c.provider then c.manifest=copy(Cache.manifest(c.provider,c.root))end + if not c.manifest and c.meta then + local manifest={sprites={},avatars=copy(c.meta.avatars),count=0} + for id,r in pairs(c.meta.objects)do + manifest.sprites[id]={width=r.width,height=r.height,frameCount=r.frameCount,paletteTag=r.paletteTag,inanimate=r.inanimate} + manifest.count=manifest.count+1 + end + c.manifest=manifest + end + end + return c.manifest +end +function R:ready()local c=self:current();return c~=nil and(self:manifest()~=nil)end +function R:record(id) + id=tonumber(id);if not integer(id,0,65535)then return nil,'invalid sprite graphics ID'end + local c,why=self:current();if not c then return nil,why end + if c.provider then + c.sourceRecords=c.sourceRecords or{} + if c.sourceRecords[id]==nil then + local ok,record,rgba=pcall(Cache.read,c.provider,c.root,id) + if not ok then c.sourceRecords[id]={error=tostring(record)} + elseif record then c.sourceRecords[id]={record=record,rgba=rgba} + elseif rgba then c.sourceRecords[id]={error=rgba} + else c.sourceRecords[id]=false end + end + local found=c.sourceRecords[id] + if found then + if found.error then return nil,found.error end + return found.record,c,found.rgba + end + end + local r=c.meta and c.meta.objects[id] + if not r then return nil,'original sprite graphics unavailable'end + if not(r.graphicsId==id and integer(r.width,8,128)and integer(r.height,8,128)and integer(r.frameCount,1,256) + and r.width*r.height*r.frameCount<=2097152 and type(r.indexed)=='string' + and#r.indexed==r.width*r.height*r.frameCount)then return nil,'invalid native sprite geometry'end + return r,c +end +local function selection(c,r) + local n=r.native;local out={} + local raw=('assets/generated/overworld/g3_%03d_source.png'):format(r.graphicsId) + local resolved=Assets.resolve(raw) + if resolved~=raw then out.raw=resolved;return out end + for i,pair in ipairs({{n and n.key,n and n.order},{n and n.runKey,n and n.runOrder}})do + local key,order=pair[1],pair[2];local def=key and c.data and c.data.sprites and c.data.sprites[key] + if def and type(def.image)=='string'and order then + local original=i==2 and n.runImage or n.image + local path=Assets.resolve(def.image) + if path~=original or def.frameWidth~=(i==2 and n.runWidth or n.defaultWidth) or def.frameHeight~=(i==2 and n.runHeight or n.defaultHeight) + or math.max(def.frames or 1,def.poseFrames or 1)~=(i==2 and n.runFrames or n.frames) or i==1 and def.walker~=n.walker then + out[i]={key=key,path=path,def=def,order=order,width=def.frameWidth or n.width,height=def.frameHeight or n.height, + frames=math.max(def.frames or 1,def.poseFrames or 1),inanimate=(def.frames or 1)<=1,walker=def.walker} + end + end + end + return out +end +local function same(a,b) + if a.raw~=b.raw then return false end + for i=1,2 do + local x,y=a[i],b[i] + if (x==nil)~=(y==nil)then return false end + if x and(x.path~=y.path or x.def~=y.def or x.width~=y.width or x.height~=y.height or x.frames~=y.frames or x.walker~=y.walker)then return false end + end + return true +end +function R:yield(job) + self:current() + if self.context~=job.context or job.cancelled then error('sprite preparation retired')end + if not job.flushing then coroutine.yield()end +end +function R:build(job) + local r,c=job.record,job.context + local rgba=job.rgba or Sheets.rgba(r,nil,function()self:yield(job)end) + local data=love.image.newImageData(r.width,r.height*r.frameCount,'rgba8',rgba);job.parts[#job.parts+1]=data + local width,height,count=r.width,r.height,r.frameCount + local pick=job.selection + if pick.raw then + local source=love.image.newImageData(pick.raw);job.parts[#job.parts+1]=source + assert(source:getWidth()==width and source:getHeight()==height*count,'original sprite override dimensions differ') + data:paste(source,0,0,0,0,width,height*count) + else + for i=1,2 do + local s=pick[i] + if s then + local source=love.image.newImageData(s.path);job.parts[#job.parts+1]=source + assert(source:getWidth()==s.width and source:getHeight()>=s.height*s.frames,'native sprite override dimensions differ') + if s.inanimate and i==1 then + width,height,count=s.width,s.height,1 + data=love.image.newImageData(width,height,'rgba8');job.parts[#job.parts+1]=data + data:paste(source,0,0,0,0,width,height) + job.inanimate=true;break + end + assert(s.width>=width and s.height>=height,'native override cannot represent original sprite geometry') + for frame,original in ipairs(s.order)do + assert(original=deadline +end +function R:cancelPending()local jobs=self.jobs;self.jobs={};for _,job in pairs(jobs)do self:cancelJob(job)end end +function R:drop(sprite) + local held=self.owned[sprite] + if held then self.bytes=self.bytes-held.size;self.owned[sprite]=nil;dropSprite(sprite)end +end +function R:get(id) + id=tonumber(id);local c,why=self:current();if not c then return nil,why end + local sprite=self.loaded[id] + if sprite and not sprite._retired then + local held=self.owned[sprite] + if not held then return sprite end -- explicit source injection remains caller-owned + local r,context,rgba=self:record(id) + if r==held.record and context==held.context and(rgba or same(selection(c,r),held.selection))then held.owners[owner()]=true;return sprite end + self:drop(sprite);self.loaded[id]=nil;self:clearPalette(id);self:drop(self.reflections[id]);self.reflections[id]=nil + end + local ok,reason=self:prefetch(id,0);local job=self.jobs[id] + if not job then return self.loaded[id],reason end + if job.co==coroutine.running()then return nil,'recursive sprite preparation'end + job.flushing=true + while self.jobs[id]==job do if not self:advance(job)then break end end + return self.loaded[id],self.errors[id] +end +function R:variant(base,from,to) + if type(from)~='table'or type(to)~='table'or#from~=#to or#from==0 then return nil,'invalid sprite palette substitution'end + local colors,lut={},{};for i,a in ipairs(from)do + local b=to[i];assert(type(a)=='table'and type(b)=='table','invalid palette color') + for c=1,3 do assert(integer(a[c],0,255)and integer(b[c],0,255),'invalid palette channel')end + colors[i]={a[1],a[2],a[3],b} + lut[a[1]*65536+a[2]*256+a[3]]=b + end + local data=base.imageData:clone();local image,quads;local matches=0 + local ok,why=pcall(function() + data:mapPixel(function(_,_,r,g,b,a) + if a<=0 then return r,g,b,a end + local rr,gg,bb=math.floor(r*255+.5),math.floor(g*255+.5),math.floor(b*255+.5) + local best,distance=lut[rr*65536+gg*256+bb] + if not best then + for _,c in ipairs(colors)do + local d=(rr-c[1])^2+(gg-c[2])^2+(bb-c[3])^2 + if d<=48 and(not distance or d0,'sprite palette substitution matched no pixels') + assert(self.bytes+base.width*base.height*base.frameCount*8<=self.maxBytes,'owned sprite texture budget exhausted') + image=love.graphics.newImage(data);image:setFilter('nearest','nearest');quads={} + for frame=0,base.frameCount-1 do quads[frame]=love.graphics.newQuad(0,frame*base.height,base.width,base.height,base.width,base.height*base.frameCount)end + end) + if not ok then release(data);release(image);for _,q in pairs(quads or{})do release(q)end;return nil,tostring(why)end + local sprite={};for k,v in pairs(base)do sprite[k]=v end + sprite.image,sprite.imageData,sprite.quads=image,data,quads + local size=base.width*base.height*base.frameCount*8;self.bytes=self.bytes+size + self.owned[sprite]={size=size,owners={[owner()]=true},base=base};return sprite +end +function R:clearPalette(id) + id=tonumber(id);local o=self.overrides[id];if not o then return false end + self:drop(o.spr);self.overrides[id]=nil;return true +end +function R:setPalette(id,key,to,from) + id=tonumber(id);if type(key)~='string'or not id then return false end + local base=self:get(id);if not base then return false end + local previous=self.overrides[id];if previous and previous.key==key and previous.base==base then return true end + if not from then local d=package.loaded['src.core.game3.deoxys'];from=d and d.ROCK_PALS and d.ROCK_PALS[1]end + local ok,sprite=pcall(self.variant,self,base,from,to);if not ok or not sprite then return false end + self:clearPalette(id);self.overrides[id]={key=key,spr=sprite,base=base,colours=copy(to),sourceColours=copy(from),owner=owner()};return true +end +function R:getDraw(id) + local base,why=self:get(id);if not base then return nil,why end + local ov=self.overrides[tonumber(id)];return ov and ov.spr or base +end +local function rgb(p) + if not p then return nil end + local rows={};for i=1,15 do local n=p[i];if n==nil then return nil end + rows[i]={math.floor(n%32*255/31+.5),math.floor(math.floor(n/32)%32*255/31+.5),math.floor(math.floor(n/1024)%32*255/31+.5)}end + return rows +end +function R:reflection(id) + id=tonumber(id);local base=self:get(id);if not base or not base.reflectionPaletteMappedTag then return nil end + local cached=self.reflections[id] + if cached then + local held=self.owned[cached];if held then held.owners[owner()]=true end + return cached + end + local from,to=rgb(base.palette),rgb(base.mappedReflectionPalette);if not from or not to then return nil end + local sprite=self:variant(base,from,to);if sprite then self.reflections[id]=sprite end;return sprite +end +function R:releaseOwner(who) + if type(who)~='string'then return end + for i=#self.providers,1,-1 do if self.providers[i].owner==who then table.remove(self.providers,i)end end + self:current() + for _,job in pairs(self.jobs)do job.owners[who]=nil;if not next(job.owners)then self:cancelJob(job)end end + -- The public cache may replace a resource without releasing it. Retire + -- through our allocation registry so displaced and derived resources are + -- included, while caller-injected images remain outside our ownership. + for sprite,held in pairs(self.owned)do + held.owners[who]=nil + if not next(held.owners)then self:drop(sprite)end + end + for id,sprite in pairs(self.loaded)do + if sprite._retired then self.loaded[id]=nil;self:clearPalette(id)end + end + for id,sprite in pairs(self.reflections)do + if sprite._retired then self.reflections[id]=nil end + end + for id,o in pairs(self.overrides)do if o.owner==who then self:clearPalette(id)end end +end +R.copy=copy +return R diff --git a/src/compat/gen1/TableIteration.lua b/src/compat/gen1/TableIteration.lua new file mode 100644 index 000000000..0eb992296 --- /dev/null +++ b/src/compat/gen1/TableIteration.lua @@ -0,0 +1,34 @@ +-- LuaJIT's Lua-5.1 pairs/next do not consult __pairs. Only registered typed +-- views opt into snapshots; all ordinary tables keep their original C path. +-- Load this infrastructure before opening any mod mutation journal. +local nativePairs,nativeNext=pairs,next +local views=setmetatable({},{__mode='k'}) +local M={rawPairs=nativePairs,rawNext=nativeNext} +function M.register(view,reader,state,nextReader) + assert(type(view)=='table'and type(reader)=='function','invalid typed table view') + views[view]={reader=reader,state=state,nextReader=nextReader} + return view +end +function M.unregister(view)views[view]=nil end +function M.snapshot(view) + local spec=views[view] + if not spec then return nil end + local result=spec.reader(spec.state,view) + assert(type(result)=='table'and result~=view,'invalid typed table snapshot') + return result +end +local function viewPairs(value) + local spec=views[value] + if not spec then return nativePairs(value)end + return nativePairs(M.snapshot(value)) +end +local function viewNext(value,key) + local spec=views[value] + if not spec then return nativeNext(value,key)end + if spec.nextReader then return spec.nextReader(spec.state,key)end + return nativeNext(M.snapshot(value),key) +end +-- Installed once at module load, before Loader's entry/lifecycle snapshots. +-- Later mod wrappers remain free to forward to these just like normal pairs. +pairs,next=viewPairs,viewNext +return M diff --git a/src/compat/gen1/TransitionCatalog.lua b/src/compat/gen1/TransitionCatalog.lua new file mode 100644 index 000000000..2069eb3fe --- /dev/null +++ b/src/compat/gen1/TransitionCatalog.lua @@ -0,0 +1,95 @@ +-- Cartridge enum facts; visual execution remains on native registered styles. +local M={} +local base={'BLUR','SWIRL','SHUFFLE','BIG_POKEBALL','POKEBALLS_TRAIL','CLOCKWISE_WIPE','RIPPLE','WAVE', + 'SLICE','WHITE_BARS_FADE','GRID_SQUARES','ANGLED_WIPES'} +local tails={frlg={'LORELEI','BRUNO','AGATHA','LANCE','BLUE','SPIRAL'}, + rse={'SIDNEY','PHOEBE','GLACIA','DRAKE','CHAMPION','AQUA','MAGMA','REGICE','REGISTEEL','REGIROCK', + 'KYOGRE','GROUDON','RAYQUAZA','SHRED_SPLIT','BLACKHOLE','BLACKHOLE_PULSATE','RECTANGULAR_SPIRAL', + 'FRONTIER_LOGO_WIGGLE','FRONTIER_LOGO_WAVE','FRONTIER_SQUARES','FRONTIER_SQUARES_SCROLL', + 'FRONTIER_SQUARES_SPIRAL','FRONTIER_CIRCLES_MEET','FRONTIER_CIRCLES_CROSS','FRONTIER_CIRCLES_ASYMMETRIC_SPIRAL', + 'FRONTIER_CIRCLES_SYMMETRIC_SPIRAL','FRONTIER_CIRCLES_MEET_IN_SEQ','FRONTIER_CIRCLES_CROSS_IN_SEQ', + 'FRONTIER_CIRCLES_ASYMMETRIC_SPIRAL_IN_SEQ','FRONTIER_CIRCLES_SYMMETRIC_SPIRAL_IN_SEQ'}} +local commonStyles={'g3_blur','g3_swirl','g3_shuffle','g3_bigPokeball','g3_pokeballsTrail','g3_clockwiseBlackfade', + 'g3_ripple','g3_wave','g3_slice','g3_whitefade','g3_grid','g3_angled'} +function M.family(edition) + if edition=='firered'or edition=='leafgreen'then return 'frlg'end + if edition=='emerald'or edition=='ruby'or edition=='sapphire'then return 'rse'end +end +function M.new(edition) + local family=assert(M.family(edition),'no native GBA transition catalogue') + local rs=edition=='ruby'or edition=='sapphire' + local c={ID={},names={},styles={},MUGSHOT_BY_ID={},TERRAIN={NORMAL=0,CAVE=1,FLASH=2,WATER=3},family=family} + for i,name in ipairs(base)do c.ID[name]=i-1;c.names[i-1]=name;c.styles[i-1]=commonStyles[i]end + for i,name in ipairs(tails[family])do + if not rs or i<=5 then c.ID[name]=11+i;c.names[11+i]=name end + end + if family=='frlg'then + c.styles[1],c.styles[4],c.styles[5],c.styles[8],c.styles[9]='frlg_swirl','frlg_balltrail','frlg_clockwise','frlg_slice','frlg_whitebars' + end + local names=family=='frlg'and{'lorelei','bruno','agatha','lance','blue'}or{'sidney','phoebe','glacia','drake','champion'} + for i,name in ipairs(names)do c.MUGSHOT_BY_ID[11+i]=name;c.styles[11+i]='g3_mugshot'end + if family=='rse'and not rs then + for i,name in ipairs({'aqua','magma','regice','registeel','regirock','kyogre','groudon','rayquaza'})do c.styles[16+i]='g3_'..name end + end + if rs then + for alias,name in pairs({CLOCKWISE_BLACKFADE='CLOCKWISE_WIPE',WHITEFADE='WHITE_BARS_FADE',SHARDS='ANGLED_WIPES',SYDNEY='SIDNEY',STEVEN='CHAMPION'})do c.ID[alias]=c.ID[name]end + end + c.TUNE={introFades=family=='frlg'and 2 or 3,blurDelay=family=='frlg'and 2 or 4, + wipeStepX=family=='frlg'and 32 or 16,wipeStepY=family=='frlg'and 16 or 8, + rippleFadeAt=family=='frlg'and 41 or 81,rippleFadeDelay=family=='frlg'and -8 or -2} + return c +end +function M.terrain(edition,opts) + opts=opts or{} + if opts.flash or(tonumber(opts.flashLevel)or 0)>0 then return 2 end + if M.family(edition)=='frlg'then + if opts.surfing or opts.isWater or opts.mapKind=='water'or opts.mapType==4 or opts.mapType==5 then return 3 end + if opts.isCave or opts.mapKind=='cave'or opts.mapKind=='dungeon'or opts.mapType==3 then return 1 end + else + if opts._surfable then return 3 end + if tonumber(opts.mapType)==4 then return 1 end + if tonumber(opts.mapType)==5 then return 3 end + end + return 0 +end +local wild={{8,9},{5,10},{0,10},{7,6}} +local trainer={{4,11},{2,3},{0,10},{1,6}} +function M.pick(edition,opts,isTrainer) + opts=opts or{} + local frlg=M.family(edition)=='frlg';local cls=tonumber(opts.trainerClass);local id=tonumber(opts.trainerId) + if isTrainer then + if frlg then + if opts.trainerTower or opts.eReader then cls=nil end + if cls==87 then + for i,name in ipairs({'Lorelei','Bruno','Agatha','Lance'})do if opts['is'..name]then return 11+i end end + return ({[410]=12,[411]=13,[412]=14,[413]=15,[735]=12,[736]=13,[737]=14,[738]=15})[id]or 16 + end + if cls==90 or opts.isRival or opts.isChampion then return 16 end + for i,name in ipairs({'Lorelei','Bruno','Agatha','Lance','Blue'})do if opts['is'..name]then return 11+i end end + else + local rs=edition=='ruby'or edition=='sapphire' + if id==1024 or cls==(rs and 32 or 38)then return 16 end + if cls==(rs and 24 or 31)then return ({[261]=12,[262]=13,[263]=14,[264]=15})[id]or 16 end + if edition=='emerald'then + if cls==9 or cls==53 or cls==49 then return 18 end + if cls==3 or cls==13 or cls==11 then return 17 end + end + end + elseif opts.pyramid and not frlg then + return (tonumber(opts.enemyLevel)or 3)<(tonumber(opts.playerLevel)or 5)and 0 or 10 + end + local terrain=opts.terrain or M.terrain(edition,opts) + local row=(isTrainer and trainer or wild)[terrain+1]or(isTrainer and trainer or wild)[1] + return row[(tonumber(opts.enemyLevel)or 3)<(tonumber(opts.playerLevel)or 5)and 1 or 2] +end +function M.special(edition,group,opts) + opts=opts or{} + if group=='trainer_hill'or group=='secret_base'or group=='e_reader'or group=='battle_tower'then + return(tonumber(opts.enemyLevel)or 3)<(tonumber(opts.playerLevel)or 5)and 4 or 3 + end + assert(edition=='emerald','transition group is unavailable for this edition') + local list=group=='pyramid'and{31,32,33}or group=='dome'and{29,31,32,33}or{29,30,31,32,34,35,36,37,38,39,40,41} + local random=opts.random or math.random + return list[(random(#list*65536)%#list)+1] +end +return M diff --git a/src/compat/gen1/TransitionRuntime.lua b/src/compat/gen1/TransitionRuntime.lua new file mode 100644 index 000000000..d3b183d6f --- /dev/null +++ b/src/compat/gen1/TransitionRuntime.lua @@ -0,0 +1,211 @@ +-- Observe actual stack admissions; never create a parallel transition clock. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Catalog=require('src.compat.gen1.TransitionCatalog') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local R={};R.__index=R +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +local function copy(t,seen) + if type(t)~='table'then + if type(t)=='number'or type(t)=='string'or type(t)=='boolean'then return t end + return nil + end + seen=seen or{};if seen[t]then return seen[t]end + local out={};seen[t]=out + for k,v in pairs(t)do if type(k)=='string'or type(k)=='number'then out[k]=copy(v,seen)end end + return out +end +local function owner() + local host=package.loaded['src.mods.Runtime'];local game=package.loaded['src.core.Game3'] + return host and host.currentMod or game and game._callbackOwner and game._callbackOwner() +end +local function cohort(c) + return not c.invalidated and c.game.data==c.data and c.game.save==c.save and c.game.mods==c.loader and Version.get()==c.edition +end +local function member(c) + if c.released or not cohort(c)or c.state.finished then return false end + for _,state in ipairs(c.game.stack and c.game.stack.states or{})do if state==c.state then return true end end + return false +end +local function releaseResource(r)if r and r.release then pcall(r.release,r)end end +function R.new()return setmetatable({records=setmetatable({},{__mode='kv'})},R)end +function R:notify(c,completed) + if c.notified then return end;c.notified=true + local ui=package.loaded['src.ui.game3.presentation'] + if ui and ui.notify then + local ok,why=pcall(ui.notify,'battleTransitionEnd',{finished=completed}) + if not ok then + c.presentationError=tostring(why) + require('src.mods.Runtime').reportError(c.owner or 'compatibility-transition',c.presentationError) + end + end +end +function R:drop(c) + if not c or c.released then return end + c.released=true;releaseResource(c.canvas);releaseResource(c.input);c.canvas,c.input=nil,nil +end +function R:release() + for _,c in pairs(self.records)do + if c.state.onDone==c.installedCallback then c.state.onDone=c.original end + c.invalidated=true;self:drop(c) + end + self.records=setmetatable({},{__mode='kv'});self.last=nil +end +function R:bind(state) + local c=self.records[state];if c and cohort(c)then return c end + if c then self:drop(c)end + local game=Native.game(state.game) + if not(game and game.data and game.save and game.stack and Catalog.family(Version.get()))then return nil end + local own=state.cohort + if own and(own.data~=game.data or own.save~=game.save or own.loader~=game.mods or own.edition~=Version.get())then return nil end + c={state=state,game=game,data=game.data,save=game.save,loader=game.mods,edition=Version.get(),owner=owner()} + c.catalog=Catalog.new(c.edition);c.options=copy(state.options or{}) + c.options.wild=not c.options.trainer;c.options.playerLevel=c.options.leadLevel + c.options.terrain=c.options.mapTransitionType + local chosen=Catalog.pick(c.edition,c.options,c.options.trainer) + if c.catalog.styles[chosen]==state.style then c.id=chosen + else for id,style in pairs(c.catalog.styles)do if style==state.style then c.id=id;break end end end + local original=state.onDone + c.original=original + c.continue=function() + if c.callbackError then error(c.callbackError,0)end + if c.callbackResult then return unpack(c.callbackResult,1,c.callbackResult.n)end + if not cohort(c)or not state.finished or state.phase~='done'then return false,'native transition has not completed'end + c.callbackResult={n=0} + local fn=original;original=nil;c.original=nil + local result=pack(pcall(function()if fn then return fn()end end));fn=nil + if not result[1]then c.callbackError=result[2];error(result[2],0)end + c.callbackResult={n=result.n-1};for i=2,result.n do c.callbackResult[i-1]=result[i]end + return unpack(c.callbackResult,1,c.callbackResult.n) + end + state.onDone=c.continue;c.installedCallback=c.continue;self.records[state]=c;self.last=c + return c +end +function R:current() + local game=Native.game();if not game then return nil end + local class=package.loaded['src.render.BattleTransition'] + if not class then return nil end + for i=#(game.stack and game.stack.states or{}),1,-1 do + local state=game.stack.states[i] + if getmetatable(state)==class and not state.finished then + local c=self.records[state] + if c and not cohort(c)then self:drop(c);return nil end + return c or self:bind(state) + end + end +end +function R:observed() + local c=self:current()or self.last + if c and cohort(c)then return c end + if c then self:drop(c);self.last=nil end +end +function R:configure(c,id,opts,callback) + assert(member(c),'native transition admission retired') + opts=opts or{} + assert(type(opts)=='table','invalid transition options') + if id~=nil then + assert(type(id)=='number'and id%1==0 and c.catalog.names[id],'invalid transition id') + if id~=c.id then + local style=assert(c.catalog.styles[id],'native transition effect is not implemented') + local def=c.data.transitions and c.data.transitions[style]or require('src.render.BattleTransition').STYLES[style] + assert(def,'native transition style is not registered') + c.state.style,c.state.def,c.state.wipeLen=style,def,def.frames;c.id=id + end + end + if callback~=nil then + assert(type(callback)=='function','invalid transition completion');c.state.onDone=callback;c.installedCallback=callback + end + if opts.skipIntro then c.state.phase='wipe';c.state.t=0 end + c.options=copy(opts) + if opts.headless then return self:complete(c,true)end + return true +end +function R:phase(c,name,body,fn,...) + if not member(c)then return false,'native transition admission retired'end + local result=pack(Boundary.dispatch('transition.'..name,body,fn,...)) + if c.callbackError then error(c.callbackError,0)end + return unpack(result,1,result.n) +end +function R:complete(c,completed) + if not c or not member(c)then return false,'native transition admission retired'end + local who=owner() + if c.owner and who and c.owner~=who then return false,'native transition belongs to another mod'end + if c.game.stack:top()~=c.state then return false,'native transition is covered by another state'end + self:notify(c,completed) + local result=c.state:complete(completed) + if c.callbackError then error(c.callbackError,0)end + return result +end +function R:start(id,opts,callback) + local game,why=Native.game();if not(game and game.data and game.save and game.stack)then return false,why or'no native transition owner'end + if self:current()then return false,'native transition is busy'end + local catalogue=Catalog.new(Version.get());id=id or catalogue.ID.SLICE + local style=catalogue.styles[id] + if not style then return false,'native transition effect is not implemented'end + local args=copy(opts or{});args.style=style + local state=require('src.render.BattleTransition').new(game,callback,args) + local c=self:bind(state) + if c.catalog.styles[id]==state.style then c.id=id end + c.options=copy(opts or{});c.sourceStarted=true + game.stack:push(state) + return true +end +function R:exit(state) + local c=self.records[state];if not c then return end + self:notify(c,state.finished and state.phase=='done') + self:drop(c) +end +function R:cancelOwner(who) + local c=self:current() + if c and c.sourceStarted and c.owner==who and c.game.stack:top()==c.state then + self:notify(c,false);c.state:complete(false) + end +end +function R:drawWorld(c,canvas,w,h) + if not c or not member(c)then return false end + if c.options.overUi then return false end + if c.state.def.draw then return false,'native foreground transition owns its UI draw'end + assert(canvas and canvas.getDimensions,'invalid transition canvas') + local cw,ch=canvas:getDimensions();w,h=w or cw,h or ch + assert(cw==w and ch==h and w>0 and h>0,'transition view dimensions differ') + local g=love.graphics + if c.input and(c.input:getWidth()~=w or c.input:getHeight()~=h)then releaseResource(c.input);c.input=nil end + c.input=c.input or g.newCanvas(w,h,{dpiscale=canvas:getDPIScale()}) + c.input:setFilter('nearest','nearest') + g.push('all') + local ok,why=pcall(function() + g.origin();g.setCanvas(c.input);g.setShader();g.setScissor();g.setDepthMode();g.setColor(1,1,1,1) + g.setBlendMode('replace','premultiplied');g.draw(canvas,0,0) + end) + g.pop();if not ok then error(why,0)end + local drawn=c.state:drawInto(canvas,w,h,c.input) + if drawn then c.worldDrawn=true end + return drawn +end +function R:composite(renderer,fn) + local c=self:current();if not c or c.options.overUi or not renderer.worldOverride then return false end + if c.state.def.draw then return false end + local source=renderer.worldOverride + local ok,w,h=pcall(source.getDimensions,source);if not ok then return false end + local g=love.graphics + if c.canvas and(c.canvas:getWidth()~=w or c.canvas:getHeight()~=h)then releaseResource(c.canvas);c.canvas=nil end + c.canvas=c.canvas or g.newCanvas(w,h,{dpiscale=source:getDPIScale()});c.canvas:setFilter('nearest','nearest') + g.push('all') + local success,drawn=pcall(function() + g.origin();g.setCanvas(c.canvas);g.setShader();g.setScissor();g.setDepthMode();g.setColor(1,1,1,1) + g.setBlendMode('replace','premultiplied') + if renderer.worldOverrideMirrored then g.draw(source,0,h,0,1,-1)else g.draw(source,0,0)end + return fn(c.canvas,w,h) + end) + g.pop();if not success then error(drawn,0)end + if drawn then + if renderer.worldOverride==source then renderer:setWorldOverride(c.canvas,{worldOrientation='native'})end + if renderer.battleWipe and renderer.battleWipe.transition==c.state then renderer.battleWipe=nil end + local veil=renderer.nativeTransitionVeil + if veil and veil.state==c.state and veil.value==renderer.screenVeil then renderer.screenVeil=nil end + end + return drawn +end +R.copy=copy +return R diff --git a/src/compat/gen1/UiFlow.lua b/src/compat/gen1/UiFlow.lua new file mode 100644 index 000000000..0f7ba2158 --- /dev/null +++ b/src/compat/gen1/UiFlow.lua @@ -0,0 +1,66 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native stack flows keep callbacks and child screens in one live cohort. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local M={} +local flows=setmetatable({},{__mode='k'}) +function M.new(done) + assert(done==nil or type(done)=='function','invalid UI continuation') + local g,w,p=Native.resolve();if not(g and g.stack)then return nil,'no native UI owner'end + local host=require('src.mods.Runtime');local facade=require('src.core.Game3') + local who=host.currentMod or facade._callbackOwner();local loader=g.mods + local mod=who and loader and loader.mods and loader.mods[who] + if not(mod and mod.enabled~=false and not mod.failed)then return nil,'no live mod UI owner'end + local f={game=g,world=w,player=p,map=w.map,save=g.save,data=g.data,stack=g.stack,loader=loader,mod=mod, + edition=Version.get(),who=who,done=done,states={}} + flows[f]=true + function f:valid() + if self.retired then return false end + local current,world,player=Native.resolve() + return current==self.game and world==self.world and player==self.player and world.map==self.map + and current.save==self.save and current.data==self.data and current.stack==self.stack and current.mods==self.loader + and Version.get()==self.edition and self.loader.mods[self.who]==self.mod and self.mod.enabled~=false and not self.mod.failed + end + function f:admit(fn,...) + assert(self:valid(),'native UI flow retired') + local stack=self.stack;local previous=stack._nativeUiAdmission;stack._nativeUiAdmission=self + local function pack(...)return{n=select('#',...),...}end + local result=pack(pcall(fn,...));stack._nativeUiAdmission=previous + if not result[1]then error(result[2],0)end + return (table.unpack or unpack)(result,2,result.n) + end + function f:attach(state) + assert(self:valid(),'native UI flow retired');state.nativeUiFlow=self;self.states[#self.states+1]=state;return state + end + function f:push(state)self:attach(state);self.stack:push(state);return state end + function f:retire() + if self.retired then return end + self.retired=true;self.done=nil + local states=self.states;self.states={} + if self.stack and self.stack.removeAll then self.stack:removeAll(states)end + for _,s in ipairs(states)do if s.nativeUiFlow==self then s.nativeUiFlow=nil end end + flows[self]=nil;self.game,self.world,self.player,self.map,self.save,self.data,self.loader,self.mod,self.stack=nil,nil,nil,nil,nil,nil,nil,nil,nil + end + function f:finish(...) + if not self:valid()then self:retire();return false end + local callback,owner=self.done,self.who;self.done=nil;self:retire() + if callback then facade._withCallbackOwner(owner,callback,...)end + return true + end + return f +end +function M.inherit(stack,state) + local top=stack:top();local flow=stack._nativeUiAdmission or top and top.nativeUiFlow + if flow and rawget(state,"nativeUiFlow")==nil and not state.modOwnedScreen and flow:valid()then flow:attach(state)end +end +function M.poll() + local retire={};for f in pairs(flows)do if not f:valid()then retire[#retire+1]=f end end + for _,f in ipairs(retire)do f:retire()end +end +function M.releaseOwner(_,who) + local retire={};for f in pairs(flows)do if f.who==who then retire[#retire+1]=f end end + for _,f in ipairs(retire)do f:retire()end +end +function M.release()local retire={};for f in pairs(flows)do retire[#retire+1]=f end;for _,f in ipairs(retire)do f:retire()end end +require('src.core.game3.asset_stream').register(M) +return M diff --git a/src/core/Data.lua b/src/core/Data.lua index 02984c906..579fa7286 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -392,13 +392,13 @@ local OPTIONAL = { "audio", "palettes", "icons", "unown_puzzle", "unown_dex", "gen3_frontier_speech", -- the in-game trade sequence's art and tables -- (Emerald/FireRed, src/import/Gen3TradeArt.lua) - "gen3_trade_art" } + "gen3_trade_art", "gen3_object_sheets" } -- Gen 3's own optional tables. On a Gen 4 / Black-White cache nothing writes -- them, and the additive overlay would hand the root cache's copy (another -- cartridge's) to the Sinnoh dataset -- the `icons` bug's shape -- (tools/gen4_party_icon_check.lua section 6) -- so they are not even asked. -local GEN3_ONLY = { "gen3_frontier_speech", "gen3_trade_art" } +local GEN3_ONLY = { "gen3_frontier_speech", "gen3_trade_art", "gen3_object_sheets" } -- Vanilla defaults for rules exposed through the constants registry. A -- value has to exist before a mod can patch it; each one matches the @@ -1120,7 +1120,7 @@ function Data:seedDefaults() -- still gets SOMETHING rather than a screen with no images to draw. local core = self.constants and self.constants.gen3FRLGIntro local images = core and core.images - if V.get() == "firered" and type(images) == "table" + if V.isFRLG() and type(images) == "table" and images.gfBg and images.scene1Grass and images.scene3Bg then boot.screens.splash = "Gen3IntroFRLG" end @@ -1318,7 +1318,9 @@ function Data:seedDefaults() require("src.world.Gen3Weather").setCycles(cycles) end - local win = (self.constants or {}).gen3MessageWindow + -- RS field text and battle chrome use different native contracts. + local win = (self.constants or {}).gen3FieldMessageWindow + or (self.constants or {}).gen3MessageWindow if type(win) == "table" and win.width and win.height then self.field.theme = self.field.theme or {} if self.field.theme.textBox == nil then @@ -1940,7 +1942,7 @@ function Data:seedDefaults() end end end - if require("src.core.GameVersion").get() == "firered" then + if require("src.core.GameVersion").isFRLG() then seedMapAliases(self.maps, FIRERED_STORY_MAP_ALIASES) preferMapAliases(self.maps, FIRERED_STORY_MAP_ALIASES) for mapId, textConst in pairs(FIRERED_STORY_OBJECT_TEXT) do @@ -2200,6 +2202,10 @@ function Data:load() end for _, name in ipairs(OPTIONAL) do if isRequired[name] then goto continue end + if name == "gen3_object_sheets" and not isGen3Cache then + self[name] = nil + goto continue + end if blocked[name] then -- Deliberately not even attempted. It would very likely SUCCEED -- the -- root cache is Red's and the overlay does not hide it -- and the result diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 792df70a5..cc283db20 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -50,6 +50,12 @@ function FixedStep:update(dt) end end +-- End only the current accelerated frame. Unlike discardCatchup, this must +-- not suppress the next real-time frame after a speed lock becomes active. +function FixedStep:endFrame() + self.accum = 0 +end + -- Drop any pending catch-up steps and arm the one-frame clamp above. A -- hitch inside one logic step (map seam setMap / song start) makes the -- next real-time dt huge; without this the while-loop above would advance diff --git a/src/core/FrameCap.lua b/src/core/FrameCap.lua index 2e5b36feb..763e116f2 100644 --- a/src/core/FrameCap.lua +++ b/src/core/FrameCap.lua @@ -25,6 +25,55 @@ FrameCap.DEFAULT = 60 -- save editor are paced before any save applies its stored option. FrameCap.current = FrameCap.DEFAULT +-- P1 rollback switch. Each frame still falls back to software pacing when +-- there was no present, VSync is off, or the display rate is unavailable. +FrameCap.vsyncPacing = true +local _refreshWindow, _refreshVsync, _refreshAt, _refreshRate +local REFRESH_POLL_SECONDS = 1 + +local function presentationPaces(window, now) + if not (window and window.getVSync and window.getMode) then return false end + local vsync = window.getVSync() + if type(vsync) ~= "number" or vsync == 0 then + _refreshVsync = vsync + return false + end + -- getMode returns a new flags table: sample it once a second rather than + -- allocate one each frame. Provider/VSync changes invalidate the sample; + -- display-rate changes are observed on the next periodic sample. + if window ~= _refreshWindow or vsync ~= _refreshVsync or not _refreshAt + or now < _refreshAt or now - _refreshAt >= REFRESH_POLL_SECONDS then + local _, _, flags = window.getMode() + local rate = type(flags) == "table" and tonumber(flags.refreshrate) + _refreshWindow, _refreshVsync, _refreshAt = window, vsync, now + _refreshRate = rate and rate > 0 and rate < math.huge and rate or nil + end + return _refreshRate ~= nil and FrameCap.current >= _refreshRate +end + +-- The run loop owns the deadline and passes back the returned value. Keeping +-- the original fallback here lets tests exercise the real pacing operation. +function FrameCap.pace(timer, window, presented, nextFrame, idleWork) + local now = timer.getTime() + local budget = 1 / FrameCap.current + nextFrame = nextFrame + budget + if FrameCap.vsyncPacing and presented and presentationPaces(window, now) then + if idleWork then + idleWork(timer, nextFrame) + return timer.getTime() + end + return now + end + if now - nextFrame > budget then nextFrame = now end + if idleWork then idleWork(timer, nextFrame) end + while true do + local remaining = nextFrame - timer.getTime() + if remaining <= 0 then break end + timer.sleep(remaining < 0.001 and remaining or 0.001) + end + return nextFrame +end + -- Nearest valid step for an arbitrary value (a hand-edited options.lua or -- an old save with no fpsCap key), so a bad number degrades to something -- sane; nil / non-numbers fall back to the default. A value below MIN or diff --git a/src/core/GCFrameBudget.lua b/src/core/GCFrameBudget.lua new file mode 100644 index 000000000..146d95f48 --- /dev/null +++ b/src/core/GCFrameBudget.lua @@ -0,0 +1,86 @@ +-- Cooperative incremental collection in otherwise unused frame time. +local Profile = require("src.core.FrameProfile") +local Budget = {} +local MAX_SECONDS = 0.0005 +local _collector, _priorPause, _priorMul, _pause, _mul +local _enabled, _seconds, _canQueryRunning, _facadeStepped = false, 0, false, false + +local function nonnegative(value, fallback) + if type(value) ~= "number" or value ~= value or value < 0 or value == math.huge then + return fallback + end + return value +end + +local function restoreTuning() + if _collector and _priorPause ~= nil then + _collector("setpause", _priorPause) + _collector("setstepmul", _priorMul) + end + _collector, _priorPause, _priorMul, _pause, _mul = nil, nil, nil, nil, nil +end + +function Budget.configure(performance, collector) + collector = collector or collectgarbage + if not performance.gcBudget or type(collector) ~= "function" then + restoreTuning() + _enabled = false + return + end + if _collector ~= collector then + restoreTuning() + end + local caps = performance.CAPS[performance.tier] or {} + local pause = math.floor(nonnegative(caps.gcPause, performance.GC_PAUSE)) + local mul = math.floor(nonnegative(caps.gcStepMul, performance.GC_STEP_MUL)) + if not _collector then + _collector = collector + _priorPause = collector("setpause", pause) + _priorMul = collector("setstepmul", mul) + _canQueryRunning = pcall(collector, "isrunning") + else + if pause ~= _pause then + collector("setpause", pause) + end + if mul ~= _mul then + collector("setstepmul", mul) + end + end + _pause, _mul = pause, mul + _seconds = math.min(MAX_SECONDS, nonnegative(caps.gcBudgetSeconds, performance.GC_BUDGET_SECONDS)) + _enabled = true +end + +function Budget.beginFrame(performance, collector) + _facadeStepped = false + Budget.configure(performance, collector) +end + +-- An existing public compatibility method retains ownership of its frame. +-- Its call, arguments and return behavior are not replaced by idle collection. +function Budget._facadeHandled() + _facadeStepped = true +end + +function Budget.idle(timer, deadline) + if not _enabled or _facadeStepped or _seconds <= 0 then + return 0 + end + local start = timer.getTime() + local stop = math.min(deadline, start + _seconds) + if stop <= start or (_canQueryRunning and not _collector("isrunning")) then + return 0 + end + local close = Profile.section(" frame pacing: gc budget") + local steps = 0 + while timer.getTime() < stop do + steps = steps + 1 + if _collector("step") then + break + end + end + close() + return steps +end + +return Budget diff --git a/src/core/Game.lua b/src/core/Game.lua index fc5db6c50..32f6d398b 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -10,6 +10,9 @@ local Renderer = require("src.render.Renderer") local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") local FrameProfile = require("src.core.FrameProfile") +local Performance = require("src.core.Performance") +local GCFrameBudget = require("src.core.GCFrameBudget") +local HotModules = require("src.core.HotModules") local TouchControls = require("src.core.TouchControls") local ModLoader = require("src.mods.Loader") local ModRuntime = require("src.mods.Runtime") @@ -17,6 +20,16 @@ local Screens = require("src.ui.Screens") local Game = {} +-- Legacy/tool stack doubles may not expose the optional visibility method. +local function renderVisible(stack, state) + return state ~= nil and (not stack.renderVisible or stack:renderVisible(state)) +end + +-- The default must follow an explicit next(otherGame), not capture the caller. +local function resolveLogicSpeedVanilla(game) + return game:_resolveLogicSpeed() +end + -- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- module unloaded, so a player boot never touches a byte of dev code local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true @@ -29,6 +42,7 @@ local function bootScreens(game) end function Game:load() + self.nativeStackReady = false self.pointerOwners={} self.secondScreenPointerOwner=nil self.secondScreenOverworldPoketch=nil @@ -107,7 +121,10 @@ function Game:load() -- apply the persisted audio + display options before anything plays self:applyOptions(self.save.options) - FixedStep:init(function(step) self:step(step) end) + FixedStep:init(function(step) + self:step(step) + self:_speedLockEdge() + end) self.fixedStep = FixedStep local OverworldState = require("src.world.OverworldController") @@ -132,6 +149,7 @@ function Game:load() -- its party-follower bridge reads `game.world` the same way. The metatable -- costs nothing, keeps `payload.game` working for everyone who reads it the -- documented way, and adds no keys -- `pairs(payload)` is unchanged. + self.nativeStackReady = true ModRuntime.emit("game.ready", setmetatable({ game = self }, { __index = self })) @@ -233,59 +251,74 @@ end -- the title screen with its NEW GAME / CONTINUE wiring; used at boot -- and by the START-menu QUIT confirmation -function Game:makeTitleState() +function Game:newGame() local OverworldState = require("src.world.OverworldController") + while self.stack:top() do self.stack:pop() end + if self.overworld then self.overworld.fadeOverlay = nil end + -- New Game keeps the standalone options.lua preferences + self.save = SaveData.newGame(self:bootConfig()) + -- no bucket carry-over: mod state from an abandoned session must + -- not leak into a fresh slot; mods seed via save.created instead + self:adoptSave(self.save) + ModRuntime.emit("save.created", { save = self.save }) + self:applyOptions(self.save.options) + self.stack:push(OverworldState, self.save.player.map, + self.save.player.x, self.save.player.y, + self.save.player.facing) + local newGameScreen = bootScreens(self).newGame + if newGameScreen == nil and not require("src.core.GameVersion").isGen2() then + newGameScreen = "OakSpeech" + end + if newGameScreen then + Screens.push(self, newGameScreen, function() end) + else + -- PRISM HAS NO OAK SPEECH, and that is why its character customisation + -- never appeared. Data.lua sets boot.screens.newGame = false for any + -- dataset with no OakText* -- Prism writes its own introduction in + -- engine/intro_menu.asm rather than replaying the professor's -- so + -- OakSpeech does not run, and the `customize_player` step lives INSIDE + -- OakSpeech. The step was correct and simply unreachable. + -- + -- IntroductionSpeech calls PlayerCustomization before it asks the + -- player's name, and the name prompt on this path comes from the + -- intro map's own script, so pushing it here puts it in the same + -- place: after New Game, before the map runs. + -- pcall, not a bare require: a dataset can want this screen without + -- the build shipping it (that is exactly how NEW GAME on Prism went + -- from "no customisation menu" to "module not found" the moment the + -- branch became reachable). A missing screen must cost the player + -- the customisation, not the save file they were starting. + local okCust, Cust = pcall(require, "src.ui.PrismCustomization") + if okCust and type(Cust) == "table" and Cust.available(self) then + Screens.push(self, "PrismCustomization", function() end) + elseif not okCust then + require("src.core.Logger").error( + "player customisation screen unavailable: %s", tostring(Cust)) + end + end +end + +function Game:continueGame(loaded, recovered) + if not loaded then return self:newGame() end + return self:restoreSave(loaded, recovered) +end + +function Game:makeTitleState() local factory = Screens.get(self, bootScreens(self).title or "TitleState") local title = factory.new(self, { onNewGame = function() - while self.stack:top() do self.stack:pop() end - if self.overworld then self.overworld.fadeOverlay = nil end - -- New Game keeps the standalone options.lua preferences - self.save = SaveData.newGame(self:bootConfig()) - -- no bucket carry-over: mod state from an abandoned session must - -- not leak into a fresh slot; mods seed via save.created instead - self:adoptSave(self.save) - ModRuntime.emit("save.created", { save = self.save }) - self:applyOptions(self.save.options) - self.stack:push(OverworldState, self.save.player.map, - self.save.player.x, self.save.player.y, - self.save.player.facing) - local newGameScreen = bootScreens(self).newGame - if newGameScreen == nil and not require("src.core.GameVersion").isGen2() then - newGameScreen = "OakSpeech" - end - if newGameScreen then - Screens.push(self, newGameScreen, function() end) - else - -- PRISM HAS NO OAK SPEECH, and that is why its character customisation - -- never appeared. Data.lua sets boot.screens.newGame = false for any - -- dataset with no OakText* -- Prism writes its own introduction in - -- engine/intro_menu.asm rather than replaying the professor's -- so - -- OakSpeech does not run, and the `customize_player` step lives INSIDE - -- OakSpeech. The step was correct and simply unreachable. - -- - -- IntroductionSpeech calls PlayerCustomization before it asks the - -- player's name, and the name prompt on this path comes from the - -- intro map's own script, so pushing it here puts it in the same - -- place: after New Game, before the map runs. - -- pcall, not a bare require: a dataset can want this screen without - -- the build shipping it (that is exactly how NEW GAME on Prism went - -- from "no customisation menu" to "module not found" the moment the - -- branch became reachable). A missing screen must cost the player - -- the customisation, not the save file they were starting. - local okCust, Cust = pcall(require, "src.ui.PrismCustomization") - if okCust and type(Cust) == "table" and Cust.available(self) then - Screens.push(self, "PrismCustomization", function() end) - elseif not okCust then - require("src.core.Logger").error( - "player customisation screen unavailable: %s", tostring(Cust)) - end - end + local facade=package.loaded['src.core.Game3'] + local run=function()return self:newGame()end + if facade and facade._bootAction then return facade._bootAction(self,{action='new_game'},run)end + return run() end, onContinue = function() local loaded, recovered = SaveData.load() if loaded then - self:restoreSave(loaded, recovered) + local facade=package.loaded['src.core.Game3'] + local run=function()return self:continueGame(loaded,recovered)end + if facade and facade._bootAction then return facade._bootAction(self,{action='continue'},run)end + return run() end end, }) @@ -358,6 +391,11 @@ function Game:_step(dt) end -- AN EMPTY STACK IS NOT A GAME. if self:ensureStack() then return end + -- Source callbacks observe this native phase, after input promotion and + -- stack recovery. A wrapped runtime may hold it; no second loop is run. + local compatibility = package.loaded['src.core.game3.runtime'] + if compatibility and compatibility._fixedBoundary + and compatibility._fixedBoundary(self, dt) == false then return end local closeStep = FrameProfile.section(" logic: stack:update") self.stack:update(dt) closeStep() @@ -377,24 +415,46 @@ function Game:_step(dt) -- it on its own real-time 60Hz accumulator instead. end --- The logic multiplier for this frame. Read live rather than cached so the --- 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:logicSpeed() +-- Keep the native global option; the portable hook wraps resolution, not +-- engine-specific category settings or the speed ladder. +function Game:_resolveLogicSpeed() local GameSpeed = require("src.core.GameSpeed") - -- Link play is always 1X on both machines, and this wins over every other - -- source including POKEPORT_SPEED. Fast-forward multiplies the logic - -- clock, so a peer at 10X burned a tournament shot clock ten times faster - -- 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. + local opts = self.save and self.save.options + return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) +end + +function Game:speedLocked() if self.linkSession or (self.linkNet and not self.linkNet.closed) then - return 1 + return true, "link" + end + -- A menu on top must not hide a fixed-speed state underneath it. + 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 + +-- A link/fixed-speed state may start inside an accelerated logic step. Drop +-- the remaining budget now instead of running that state at the old speed. +function Game:_speedLockEdge() + if (self._frameSpeed or 1) > 1 and self:speedLocked() then + FixedStep:endFrame() + end +end + +-- Read live on every resolution. Hard locks win over --speed/POKEPORT_SPEED, +-- and those explicit overrides win over mods. Clamp the final hook result as +-- well as the native default so invalid output cannot poison the accumulator. +function Game:logicSpeed() + if self:speedLocked() then return 1 end + local GameSpeed = require("src.core.GameSpeed") if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end - local opts = self.save and self.save.options - return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) + if not ModRuntime.wantsHook("core.logic_speed") then return self:_resolveLogicSpeed() end + return GameSpeed.clamp(ModRuntime.call("core.logic_speed", resolveLogicSpeedVanilla, self)) end -- THE FREE CAMERA, when one owns the frame -- otherwise nil. @@ -586,7 +646,7 @@ function Game:updateCameraStick(dt) end function Game:update(dt) - require('src.pokemon.Gen4PoketchState').tick(self, dt) + HotModules.get('src.pokemon.Gen4PoketchState').tick(self, dt) local panel=self.secondScreenPointerOwner local top=self.stack and self.stack.top and self.stack:top() if panel and panel~=top and panel.updatePresentation then @@ -597,6 +657,7 @@ function Game:update(dt) -- or the anti-spiral clamp quietly caps every level above ~15X. self:updateCameraStick(dt) local speed = self:logicSpeed() + self._frameSpeed = speed FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5) do local close = FrameProfile.section(" update: fixed steps") @@ -612,7 +673,7 @@ function Game:update(dt) local close = FrameProfile.section(" update: music") while self.audioAccum >= step do self.audioAccum = self.audioAccum - step - require("src.core.Music").update(Data) + HotModules.get("src.core.Music").update(Data) end close() end @@ -620,34 +681,34 @@ function Game:update(dt) -- frame dt (not the fixed logic step) for a smooth ~0.25s glide. do local close = FrameProfile.section(" update: tilt") - require("src.render.Tilt").update(dt) + HotModules.get("src.render.Tilt").update(dt) close() end -- mod render pipelines tween on the same real-frame clock, for the same -- reason: they are presentational, so fast-forward must not speed them up do local close = FrameProfile.section(" pipelines: update") - require("src.render.Pipelines").update(dt) + HotModules.get("src.render.Pipelines").update(dt) close() end do local close = FrameProfile.section(" update: discord") - pcall(function() require("src.core.DiscordPresence").update(dt) end) + if Performance.hotPathHoist then + local ok, Discord = HotModules.optional("src.core.DiscordPresence") + if ok and type(Discord) == "table" then pcall(Discord.update, dt) end + else + pcall(function() require("src.core.DiscordPresence").update(dt) end) + end close() end - -- Steady-state memory backstop: advance the incremental collector one - -- small step every rendered frame. The heavy GPU objects are now freed - -- explicitly (map eviction, battle exit, canvas/renderer swaps), so this - -- only has to keep ordinary Lua-heap garbage (per-frame tables/closures) - -- from drifting upward over a long session, and to spread collection out - -- so the default lazy schedule never batches it into a visible pause. - -- ONE INCREMENTAL GC STEP PER FRAME. Cheap on a small heap and not - -- obviously cheap under a mod that allocates a world's worth of geometry, - -- which is exactly the case being measured -- so it is named rather than - -- assumed. + -- Keep an already-loaded native GC delegation at its update boundary. + -- Ordinary engine collection moves to spare deadline time in love.run. if collectgarbage then local close = FrameProfile.section(" update: gc step") - collectgarbage("step", 1) + local facade = package.loaded["src.core.Game3"] + local handled = type(facade) == "table" and type(facade._stepGC) == "function" and facade._stepGC(self) + if handled then GCFrameBudget._facadeHandled() end + if not handled and not Performance.gcBudget then collectgarbage("step", 1) end close() end end @@ -825,11 +886,11 @@ function Game.drawBaseInStack(stack, visibleBase) local states = stack and stack.states or {} for i = visibleBase - 1, 1, -1 do local state = states[i] - if state and state.bgMode and state:bgMode() == "world" then + if renderVisible(stack, state) and state.bgMode and state:bgMode() == "world" then -- restart the search from under the battle: the highest opaque state at -- or below it (the overworld), not the menu sitting over it for j = i, 1, -1 do - if states[j].isOpaque then return j end + if renderVisible(stack, states[j]) and states[j].isOpaque then return j end end return 1 end @@ -916,7 +977,7 @@ Game.centerClassicZones = centerClassicZones function Game:draw() local closeDraw = FrameProfile.section("draw: whole frame") - local screenLayout=require('src.ui.SecondScreen') + local screenLayout=HotModules.get('src.ui.SecondScreen') Renderer.splitViewport=nil if screenLayout.combined(self) then local w,h=love.graphics.getDimensions() @@ -934,25 +995,25 @@ function Game:draw() -- Single-screen generations use the physical Android lower panel as a live -- save companion without changing the game's own screen mode. do - local okC, Companion = pcall(require, "src.ui.SaveCompanion") + local okC, Companion = HotModules.optional("src.ui.SaveCompanion") if okC and Companion and Companion.tick then pcall(Companion.tick, self) end end do - local okSS, SS = pcall(require, "src.ui.SecondScreen") + local okSS, SS = HotModules.optional("src.ui.SecondScreen") if okSS and SS.mode and (SS.mode(self) == "display" or SS.combined(self)) and not self.secondScreenDrawnThisFrame then - local version = require("src.core.GameVersion").info() + local version = HotModules.get("src.core.GameVersion").info() local family = version and version.family -- THE WATCH ONLY ONCE IT IS ON THE WRIST: Platinum's lower screen shows -- the Pokétch after `enablepoketch` (Jubilife) and nothing before; Black -- and White's is the C-Gear, not Platinum's watch (not built yet), so -- theirs stays blank too - local poketchOn = require("src.pokemon.Gen4PoketchState").owned(self.save) + local poketchOn = HotModules.get("src.pokemon.Gen4PoketchState").owned(self.save) local panelModule = family == "hgss" and "src.ui.HgssFieldPanel" or (family ~= "bw" and poketchOn and "src.ui.Gen4Poketch") or nil local okP, Poketch = false, nil - if panelModule then okP, Poketch = pcall(require, panelModule) end + if panelModule then okP, Poketch = HotModules.optional(panelModule) end if not panelModule then pcall(SS.draw, self, function() love.graphics.setColor(0, 0, 0, 1) @@ -991,7 +1052,7 @@ function Game:draw() -- has to get out, and because the GPU readback wants the drawing finished. -- A no-op in every other mode and on every machine with one screen. do - local okSS, SS = pcall(require, "src.ui.SecondScreen") + local okSS, SS = HotModules.optional("src.ui.SecondScreen") if okSS and SS.flush then pcall(SS.flush, self) end end FrameProfile.frame() @@ -1004,16 +1065,26 @@ function Game:toggleFrameProfile() return FrameProfile.toggle() end +local function viewportSnapshot(viewport) + return { + width=viewport.width,height=viewport.height,gameX=viewport.gameX,gameY=viewport.gameY, + gameWidth=viewport.gameWidth,gameHeight=viewport.gameHeight,scale=viewport.scale, + dpiX=viewport.dpiX,dpiY=viewport.dpiY, + } +end + function Game:_draw() -- the UI canvas clears transparent when the overworld's world pass -- shows through beneath it; opaque full-screen states get the classic -- white clear local base = self.stack:visibleBase() local worldBelow = self.stack.states[base] == self.overworld + and renderVisible(self.stack, self.overworld) -- a world-bg battle keeps the map drawing under whatever it opened, so the -- world pass can run for a frame whose CLEAR is still an opaque menu's local drawFrom = Game.drawBaseInStack(self.stack, base) local worldDrawn = self.stack.states[drawFrom] == self.overworld + and renderVisible(self.stack, self.overworld) -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred -- in that surface below, so their classic coordinates and hit testing stay @@ -1095,7 +1166,7 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if renderVisible(self.stack,state) and state.draw then -- ...and it is centred by ITS OWN size, not by the Game Boy's (see -- Game.homeOffset). classicOffset still gates it: a surface that is -- the Game Boy's centres nothing, which is every frame outside a wide @@ -1116,8 +1187,8 @@ function Game:_draw() end end local baseState = self.stack.states[drawFrom] - local SuperSample = require("src.render.SuperSample") - if not worldDrawn and baseState and require("src.core.GameVersion").isNds() + local SuperSample = HotModules.get("src.render.SuperSample") + if not worldDrawn and baseState and HotModules.get("src.core.GameVersion").isNds() and SuperSample.singleScreen(self) and not SuperSample.active then SuperSample.draw(self, self.stack:top(), drawStates) else @@ -1129,7 +1200,7 @@ function Game:_draw() local zones, worldZones, zoneOwner for i = #self.stack.states, 1, -1 do local s = self.stack.states[i] - if s.sgbPalettes then + if renderVisible(self.stack, s) and s.sgbPalettes then zones = s:sgbPalettes(self) zoneOwner = s break @@ -1183,11 +1254,18 @@ function Game:_draw() if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end - local viewport = Renderer:endFrame(zones, worldZones) + local viewport, privateViewport + if Performance.hotPathHoist and Renderer.endFrame == Renderer._nativeEndFrame then + viewport = Renderer:_endFrameForEngine(zones, worldZones) + privateViewport = true + else + viewport = Renderer:endFrame(zones, worldZones) + end -- Persistent tool status is screen-space UI: draw it over the completed -- render pipeline with exact playfield/margin geometry, but below mobile -- controls. It never becomes an updating game state. if ModRuntime.wantsHook("render.hud") then + if privateViewport then viewport = viewportSnapshot(viewport) end ModRuntime.call("render.hud", function() end, self, viewport) end -- on-screen mobile controls: pure screen-space, over the finished frame @@ -1207,7 +1285,7 @@ function Game:zoomStep(delta) end end -function Game:wheelmoved(_, dy) +local function wheelmovedBody(self, dy) -- THE WHEEL ZOOMS THE FREE CAMERA when one owns the frame, and the -- window otherwise. -- @@ -1228,6 +1306,17 @@ function Game:wheelmoved(_, dy) end end +function Game:wheelmoved(_, dy) + if not ModRuntime.wantsHook("input.wheel") then + return wheelmovedBody(self, dy) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.wheel", function() + return wheelmovedBody(self, dy) + end, self, dy) +end + -- WHEN THE STACK IS EMPTIED AND NOBODY PUSHES ANYTHING BACK. -- -- Traced from play, straight out of the log: @@ -1359,7 +1448,7 @@ function Game:cycleGen4CameraTilt() return true end -function Game:keypressed(key) +local function keypressedBody(self, key) -- ...ahead of the delegation below, which is unconditional. if key == "f9" then return self:unstick() end if key == "f11" then return self:toggleFrameProfile() end @@ -1492,6 +1581,17 @@ function Game:keypressed(key) Input:keypressed(key) end +function Game:keypressed(key) + if not ModRuntime.wantsHook("input.key") then + return keypressedBody(self, key) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.key", function() + return keypressedBody(self, key) + end, self, { phase = "pressed", key = key }) +end + function Game:textinput(text) local top = self.stack and self.stack:top() if top and top.onTextInput then @@ -1519,19 +1619,31 @@ function Game:returnToLauncher() require("src.core.HostShell").toLauncher() end --- Releases reach Input even while a top state captures raw input: a +-- Unless an input.key wrapper swallows the event, releases reach Input +-- even while a top state captures raw input: a -- swallowed key-up would strand a held-state flag for a key Input saw go -- down before the capture armed (the stuck-flag hazard Input:reset -- exists for). The top state only OBSERVES the release afterwards, -- unlike onKeyPressed above which owns the press, so BindingsMenu can -- commit a capture on the key-up (#589). -function Game:keyreleased(key) +local function keyreleasedBody(self, key) Input:keyreleased(key) local top = self.stack and self.stack:top() if top and top.onKeyReleased then top:onKeyReleased(key) end end -function Game:gamepadpressed(joystick, button) +function Game:keyreleased(key) + if not ModRuntime.wantsHook("input.key") then + return keyreleasedBody(self, key) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.key", function() + return keyreleasedBody(self, key) + end, self, { phase = "released", key = key }) +end + +local function gamepadpressedBody(self, joystick, button) -- a controller is being used: the touch overlay steps aside until the -- next screen touch (mobile only; a no-op elsewhere) TouchControls:noteGamepad() @@ -1544,14 +1656,36 @@ function Game:gamepadpressed(joystick, button) Input:gamepadpressed(joystick, button) end -function Game:gamepadreleased(joystick, button) +function Game:gamepadpressed(joystick, button) + if not ModRuntime.wantsHook("input.gamepad") then + return gamepadpressedBody(self, joystick, button) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.gamepad", function() + return gamepadpressedBody(self, joystick, button) + end, self, { phase = "pressed", joystick = joystick, button = button }) +end + +local function gamepadreleasedBody(self, joystick, button) -- same observe-after-Input contract as Game:keyreleased (#589) Input:gamepadreleased(joystick, button) local top = self.stack and self.stack:top() if top and top.onGamepadReleased then top:onGamepadReleased(button) end end -function Game:gamepadaxis(joystick, axis, value) +function Game:gamepadreleased(joystick, button) + if not ModRuntime.wantsHook("input.gamepad") then + return gamepadreleasedBody(self, joystick, button) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.gamepad", function() + return gamepadreleasedBody(self, joystick, button) + end, self, { phase = "released", joystick = joystick, button = button }) +end + +local function gamepadaxisBody(self, joystick, axis, value) -- past-deadzone only, so resting-stick drift can't hide the overlay if math.abs(value) > 0.5 then TouchControls:noteGamepad() end -- the triggers and the right stick as bindable pseudo-buttons (Input @@ -1574,6 +1708,17 @@ function Game:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value) end +function Game:gamepadaxis(joystick, axis, value) + if not ModRuntime.wantsHook("input.gamepad") then + return gamepadaxisBody(self, joystick, axis, value) + end + -- Keep the native callback arguments even if a wrapper rewrites next(...), + -- matching Gen1. Omitting next suppresses the complete native body. + return ModRuntime.call("input.gamepad", function() + return gamepadaxisBody(self, joystick, axis, value) + end, self, { phase = "axis", joystick = joystick, axis = axis, value = value }) +end + -- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the -- generic joystick path anyway: any sensor-style device that still reaches us -- has gravity pinning an axis past the deadzone, which would hide the touch @@ -1929,7 +2074,7 @@ function Game:restoreSave(loaded, recovered) -- already partway through the Elite Four predate that bridge and may have -- only one side, leaving the next room sealed after an update. Reconcile -- those run-scoped flags before the room's ON_LOAD script evaluates them. - if require("src.core.GameVersion").get() == "firered" then + if require("src.core.GameVersion").isFRLG() then local okG3, G3 = pcall(require, "src.script.Gen3Commands") if okG3 and G3 and G3.repairFireRedLeagueFlags and G3.repairFireRedLeagueFlags(loaded) then @@ -2046,4 +2191,23 @@ function Game:restoreSave(loaded, recovered) end end +-- Require nothing on an ordinary boot. Source Game3 wrappers receive their +-- distinct facade while the captured native method retains its own owner. +for _, name in ipairs({ 'update', 'draw', '_step', 'keypressed', 'keyreleased', + 'textinput', 'wheelmoved', 'touchpressed', 'touchmoved', 'touchreleased', + 'gamepadpressed', 'gamepadreleased', 'gamepadaxis', 'joystickpressed', + 'joystickreleased', 'joystickaxis', 'joystickhat', 'joystickremoved', + 'focus', 'visible', 'returnToTitle', 'returnToLauncher', 'restartWithMods', + 'writeOptions', 'applyOptions', 'writeSave', '_resolveLogicSpeed', + 'logicSpeed', 'speedLocked', 'zoomStep' }) do + local key, native = name, Game[name] + if type(native) == 'function' then + Game[key] = function(self, ...) + local facade = package.loaded['src.core.Game3'] + if facade and facade._dispatch then return facade._dispatch(key, self, native, ...) end + return native(self, ...) + end + end +end + return Game diff --git a/src/core/Game3.lua b/src/core/Game3.lua new file mode 100644 index 000000000..fe8d3677b --- /dev/null +++ b/src/core/Game3.lua @@ -0,0 +1,281 @@ +-- A distinct facade for source wrappers. The existing Game still owns every +-- simulation, frame and IO operation; requiring this cannot bootstrap it. +local Native=require('src.compat.gen1.NativeSession') +local Views=require('src.compat.gen1.SessionView') +local Version=require('src.core.GameVersion') +local G={} +local entries=setmetatable({},{__mode='k'}) +local owners=setmetatable({},{__mode='k'}) +local scopes={} +local bases={} +local bootLeases=setmetatable({},{__mode='k'}) +local mouseBodies=setmetatable({},{__mode='k'}) +local callbackOwners={} +local actorCallbackOwners={} +local callbackOwner +local unpack=table.unpack or unpack +local function pack(...)return {n=select('#',...),...}end +function G._callbackOwner()return callbackOwner end +function G._withCallbackOwner(owner,fn,...) + local previous=callbackOwner;callbackOwner=owner + local result=pack(pcall(fn,...)) + local views=package.loaded['src.compat.gen1.PokemonView'] + if result[1]and views then + local ok,why=pcall(views.flush) + if not ok then result={n=2,false,why}end + end + callbackOwner=previous + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +local function methodOwner(name) + local seams=package.loaded['src.mods.ModSeams'];local journal=seams and seams._journal or{} + for i=#journal,1,-1 do + local change=journal[i] + if change.tbl==G and change.key==name and change.after==G[name]then return change.owner end + end +end +local function entry(view) + local e=entries[view] + assert(e and not e.retired,'native Game3 facade retired or unbound') + return e +end +function G.native(value) + if value==nil then return package.loaded['src.core.Game']end + local e=entries[value] + if e then return not e.retired and e.owner or nil end + if type(value)=='table'and type(rawget(value,'game'))=='table'then return G.native(rawget(value,'game'))end + return value +end +local function session(e) + if e.session then + local record=Views.record(e.session) + if record and record:valid()then return e.session end + Views.release(e.session);e.session=nil + end + e.session=Views.new(e.owner) + return e.session +end +local function forward(view,name,...) + local e=entry(view);local c=scopes[name] + if c and c.view==view then + if c.called then + if c.failed then error(c.failure,0)end + if c.result then return unpack(c.result,1,c.result.n)end + return + end + c.called=true + local result=pack(pcall(c.native,e.owner,...)) + if not result[1]then c.failed=true;c.failure=result[2];error(result[2],0)end + c.result={n=result.n-1};for i=2,result.n do c.result[i-1]=result[i]end + return unpack(c.result,1,c.result.n) + end + local method=e.owner[name] + if type(method)~='function'then return nil,'native method unavailable: '..name end + return method(e.owner,...) +end +for _,name in ipairs({'update','draw','keypressed','keyreleased','textinput','wheelmoved', + 'touchpressed','touchmoved','touchreleased','gamepadpressed','gamepadreleased','gamepadaxis', + 'joystickpressed','joystickreleased','joystickaxis','joystickhat','joystickremoved','focus','visible', + 'mousepressed','mousemoved','mousereleased','writeOptions','applyOptions','writeSave','returnToTitle', + 'logicSpeed','speedLocked','_resolveLogicSpeed','zoomStep', + 'returnToLauncher','restartWithMods'})do + local key=name;G[key]=function(view,...)return forward(view,key,...)end;bases[key]=G[key] +end +function G.fixedUpdate(view,...)return forward(view,'_step',...)end +bases.fixedUpdate=G.fixedUpdate +function G.reset(view) + local e=entry(view) + local runtime=package.loaded['src.core.game3.runtime'];if runtime and runtime.release then runtime.release()end + if e.session then Views.release(e.session);e.session=nil end + e.overrides={};e.overridden={};e.bootEpoch={} +end +bases.reset=G.reset +function G._handleBootAction(view,action) + local e=entry(view);local lease=type(action)=='table'and bootLeases[action] + if not lease or lease.view~=view or lease.epoch~=e.bootEpoch or action.action~=lease.kind then + return nil,'boot action belongs to a retired or absent native title callback' + end + if lease.failed then error(lease.failed,0)end + if lease.done then return unpack(lease.result,1,lease.result.n)end + if lease.owner.data~=lease.data or lease.owner.save~=lease.save or lease.owner.mods~=lease.loader then + return nil,'native title callback retired' + end + if not lease.done then + lease.done=true + local ok,why=xpcall(function()lease.result=pack(lease.callback())end,debug.traceback) + if not ok then lease.failed=why;error(why,0)end + end + return unpack(lease.result,1,lease.result.n) +end +bases._handleBootAction=G._handleBootAction +function G._bootAction(owner,action,callback) + if Version.generation()~=3 or G._handleBootAction==bases._handleBootAction then return callback()end + local view=G.forNative(owner);if not view then return callback()end + local e=entries[view] + bootLeases[action]={view=view,owner=owner,data=owner.data,save=owner.save,loader=owner.mods, + epoch=e.bootEpoch,kind=action.action,callback=callback} + return G._dispatch('_handleBootAction',owner,function()return bases._handleBootAction(view,action)end,action) +end +function G.stepGC()if collectgarbage then return collectgarbage('step',1)end end +function G._stepGC() + if Version.generation()~=3 then return false end + G.stepGC();return true +end +function G.forNative(value) + local native=G.native(value) + if value~=nil and native==nil then return nil,'native Game3 facade retired'end + local owner,why=Native.game(native);if not owner then return nil,why end + local existing=owners[owner] + if existing and not entries[existing].retired then + local e=entries[existing] + if e.loader==owner.mods or e.resetting then return existing end + G._notifyReset(owner);e.retired=true;if e.session then Views.release(e.session)end + end + local view={};local e={owner=owner,overrides={},overridden={},loader=owner.mods,bootEpoch={}};entries[view]=e;owners[owner]=view + setmetatable(view,{ + __index=function(_,key) + if G[key]~=nil then return G[key]end + if e.retired then return nil end + if e.overridden[key]then return e.overrides[key]end + if key=='mods'then return e.loader end + if key=='generation'then return 3 end + if key=='session'or key=='save'then return session(e)end + if key=='phase'then return Native.resolve(owner)and'field'or'boot'end + if key=='options'then return owner.save and owner.save.options end + if key=='data'then + if e.dataset~=owner.data then + e.dataset=owner.data + local dataset=e.dataset + e.dataView=setmetatable({}, {__index=function(_,field) + if owner.data~=dataset then return nil end + if field=='maps'then return require('src.core.game3.map').definitions(owner)end + return dataset and dataset[field] + end}) + end + return e.dataView + end + local value=owner[key] + if type(value)~='function'then return value end + end, + __newindex=function(t,key,value) + assert(not e.retired,'native Game3 facade retired') + if key=='data'or key=='generation'or key=='phase'then error('Game3 '..key..' belongs to its native owner',2)end + if key=='speedOverride'then + assert(value==nil or value==false or(type(value)=='number'and value>0 and value 1 + and ownsSlot(target[id], id, group, number) then + value.id, value.group, value.number = id, group, number + end + for _, alias in ipairs(families[id] or {}) do target[alias] = value end + target[id] = value + end +end + +function Names.registryId(maps, id) + if not Version.isGen3() or type(maps) ~= "table" or type(id) ~= "string" then return id end + if owners[maps] and owners[maps] ~= Version.get() then return id end + local def = maps[id] + if type(def) ~= "table" then return id end + local group, number = sourceSlot(def.id) + if not group or not Catalog.mapIdFor(group, number) then return id end + local source = string.format("MAP_G%02d_N%02d", group, number) + if maps[source] == def and ownsSlot(def, source, group, number) then return source end + return id +end + +return Names diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index e7e54b0d2..d7ff5a008 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -392,4 +392,101 @@ function HostShell.httpGet(url, userAgent, accept) return body end + +-- Worker-only GET. Curl arguments containing remote input live in a config +-- file, never in shell syntax (also preserves percent-encoded Windows URLs). +-- errorPath belongs to the engine's private per-job workspace, not the mod. +local function httpBounded(url, opts, method, postBody) + opts = opts or {} + if not HostShell.canSpawnProcess() then return nil, "bounded HTTP requires desktop curl" end + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") + or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end + if opts.httpsOnly and not url:lower():match("^https://") then return nil, "HTTPS required" end + local function bounded(n, default, cap) + n = tonumber(n) + if not n or n ~= n then n = default end + return math.max(0.1, math.min(cap, n)) + end + local seconds = bounded(opts.maxSeconds, 20, 30) + local bytes = math.floor(bounded(opts.maxBytes, 4 * 1024 * 1024, 16 * 1024 * 1024)) + if bytes < 1 then return nil, "invalid byte limit" end + local path = opts.errorPath + if type(path) ~= "string" or path == "" or path:find("[%z%c]") then return nil, "missing worker path" end + local windows = love.system.getOS() == "Windows" + -- cmd expands these even inside quotes. Only the local config pathname is + -- shell-visible; refuse an unsupported local pathname rather than expand it. + if windows and path:find('[%%!"^]') then return nil, "unsupported characters in worker path" end + local configPath = path .. ".cfg" + local bodyPath = path .. ".body" + local function cleanup() os.remove(configPath);if postBody then os.remove(bodyPath) end end + local function quoted(value) + value = tostring(value) + if value:find("[%z%c]") then return nil end + return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' + end + local ua, accept = quoted(opts.userAgent or "gen2recomp"), quoted(opts.accept or "*/*") + if not ua or not accept then return nil, "invalid HTTP header" end + local lines = {"silent", "show-error", "fail", "globoff", + "connect-timeout = " .. math.min(10, seconds), "max-time = " .. seconds, + "max-filesize = " .. bytes, "max-redirs = 5", + 'proto = "=' .. (opts.httpsOnly and "https" or "http,https") .. '"', + 'proto-redir = "=' .. (opts.httpsOnly and "https" or "http,https") .. '"', + "user-agent = " .. ua, "header = " .. quoted("Accept: " .. (opts.accept or "*/*")), + "stderr = " .. quoted(path), 'write-out = "\\n%{http_code}"', "url = " .. quoted(url)} + -- Schannel does not consistently consume this curl environment setting. + -- Put the caller's trusted CA bundle in the private config on every desktop. + local ca=os.getenv('CURL_CA_BUNDLE') + if ca and ca~='' then + local value=quoted(ca);if not value then return nil,'invalid CA bundle path' end + lines[#lines+1]='cacert = '..value + end + if method == "POST" then + if type(postBody) ~= "string" or #postBody > 512 * 1024 then return nil, "POST body exceeds byte limit" end + local contentType=opts.contentType or "text/plain; charset=utf-8" + if type(contentType)~="string" or #contentType>128 or not quoted(contentType) then return nil,"invalid Content-Type" end + local data,why=io.open(bodyPath,"wb");if not data then return nil,tostring(why) end + local wrote,err=data:write(postBody);local closed=data:close() + if not wrote or not closed then cleanup();return nil,tostring(err or "POST staging failed") end + lines[#lines+1]='request = "POST"' + lines[#lines+1]="header = "..quoted("Content-Type: "..contentType) + lines[#lines+1]="data-binary = "..quoted("@"..bodyPath) + else lines[#lines+1]="location" end + lines[#lines+1]="" + local config = table.concat(lines,"\n") + local f, err = io.open(configPath, "wb") + if not f then cleanup();return nil, tostring(err) end + local wrote, why = f:write(config); local closed = f:close() + if not wrote or not closed then cleanup(); return nil, tostring(why or "config write failed") end + local pipe = HostShell.popen("curl --disable --config " .. HostShell.quote(configPath), windows and "rb" or "r") + if not pipe then cleanup(); return nil, "could not start curl" end + local ok, body = pcall(pipe.read, pipe, bytes + 5) + local closeOk, done, _, code = pcall(pipe.close, pipe) + cleanup() + local ef = io.open(path, "rb") + local complaint + if ef then complaint = HostShell.curlComplaint(ef:read(4096)); ef:close() end + os.remove(path) + if not ok then return nil, "response read failed" end + body = body or "" + local status = tonumber(body:match("\n(%d%d%d)$")) + if status then body = body:sub(1, -5) end + if #body > bytes then return nil, "response exceeds byte limit" end + if complaint then + return nil, complaint, tonumber(complaint:match("returned error: (%d%d%d)")) or status + end + if not closeOk or done == nil or (type(code) == "number" and code ~= 0) then + return nil, "curl failed" + end + if not status or status < 200 or status >= 300 then return nil, "incomplete HTTP response", status end + return body, nil, status +end + +function HostShell.httpGetBounded(url,opts)return httpBounded(url,opts,"GET")end +function HostShell.httpPostBounded(url,body,opts) + opts=opts or {} + if type(url)~="string" or not url:lower():match("^https://") then return nil,"HTTPS required" end + local bounded={};for key,value in pairs(opts)do bounded[key]=value end + bounded.httpsOnly=true + return httpBounded(url,bounded,"POST",body) +end return HostShell diff --git a/src/core/HotModules.lua b/src/core/HotModules.lua new file mode 100644 index 000000000..316773c7b --- /dev/null +++ b/src/core/HotModules.lua @@ -0,0 +1,34 @@ +-- Lazy dependency upvalues shared by native update/draw sites. Public require +-- paths stay intact; replacing/clearing package.loaded remains observable. +local Performance = require("src.core.Performance") +local Hot = {} +local _modules = {} + +function Hot.get(name) + if Performance.hotPathHoist then + local module = _modules[name] + if module and package.loaded[name] == module then + return module + end + module = require(name) + _modules[name] = module + return module + end + return require(name) +end + +function Hot.optional(name) + if Performance.hotPathHoist then + local module = _modules[name] + if module and package.loaded[name] == module then + return true, module + end + end + local ok, module = pcall(require, name) + if ok and Performance.hotPathHoist then + _modules[name] = module + end + return ok, module +end + +return Hot diff --git a/src/core/Input.lua b/src/core/Input.lua index e55bf0cbf..f808bbdd8 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -8,6 +8,7 @@ -- table at call time keeps that entirely inside GamepadMap. local GamepadMap = require("src.core.GamepadMap") local GameVersion = require("src.core.GameVersion") +local InputControl = require("src.mods.InputControl") local Input = {} @@ -144,6 +145,7 @@ end -- that would have cleared a held direction. Called from Game on those -- transitions so a stuck flag can't outlive them. function Input:reset() + InputControl.forget(self) self.state = {} self.pressQueue = {} self.pressed = {} @@ -237,6 +239,16 @@ function Input:overlayReleased(btn) release(self, btn, "touch:" .. btn) end +-- Internal source-aware entry points for mod.input. Each token owns a +-- separate source, so releasing it cannot release physical/other mod input. +function Input:sourcePress(button, source) + press(self, button, source) +end + +function Input:sourceRelease(button, source) + release(self, button, source) +end + function Input:gamepadpressed(joystick, button) local btn = self.padBindings[button] if btn then diff --git a/src/core/MapIdentity.lua b/src/core/MapIdentity.lua new file mode 100644 index 000000000..406ceb6c8 --- /dev/null +++ b/src/core/MapIdentity.lua @@ -0,0 +1,119 @@ +-- Verified Gen2 map aliases keep native storage keys while renderers use +-- the published map name. No reverse spelling guesses or save migration. +local Version = require("src.core.GameVersion") +local Identity = {} + +function Identity.normalize(mapId) + if type(mapId) ~= "string" then return mapId end + local id = mapId:gsub("([0-9]+)_([FB])$", "%1%2") + local out, i, len = {}, 1, #id + while i <= len do + local ch = id:sub(i, i) + if ch:match("%d") then + local j = i + 1 + while j <= len and id:sub(j, j):match("%d") do + j = j + 1 + end + local prev = i > 1 and id:sub(i - 1, i - 1) or "" + if prev:match("%a") and prev ~= "B" and out[#out] ~= "_" then + out[#out + 1] = "_" + end + out[#out + 1] = id:sub(i, j - 1) + i = j + else + out[#out + 1] = ch + i = i + 1 + end + end + return table.concat(out) +end + + +function Identity.sourceId(data, id) + if Version.isGen3() then return require("src.core.Gen3MapNames").sourceId(data, id) end + if not Version.isGen2() or type(id) ~= "string" then return id end + local maps = data and data.maps + if type(maps) ~= "table" then return id end + local original, seen = id, {} + while true do + if seen[id] then return original end + seen[id] = true + local def = maps[id] + local source = type(def) == "table" and def.sourceId + if type(source) ~= "string" or source == id or type(maps[source]) ~= "table" then + return id + end + id = source + end +end + +-- Only a shared registry record proves that two names address one live map. +-- Declared source provenance alone may also describe an authored variant. +function Identity.runtimeId(data, id) + if Version.isGen3() then return require("src.core.Gen3MapNames").sourceId(data, id) end + if not Version.isGen2() or type(id) ~= "string" then return id end + local maps = data and data.maps + local def = type(maps) == "table" and maps[id] + if type(def) == "table" and type(def.id) == "string" + and type(def.sourceId) == "string" and maps[def.sourceId] == def + and maps[def.id] == def then return def.id end + return id +end + +function Identity.same(data, a, b) + return a == b or (a ~= nil and b ~= nil + and Identity.runtimeId(data, a) == Identity.runtimeId(data, b)) +end + +function Identity.aliases(data, id) + local out, seen = {}, {} + local function add(key) + if key ~= nil and not seen[key] then out[#out+1] = key; seen[key] = true end + end + local source = Identity.sourceId(data, id) + add(source); add(id) + add(Identity.runtimeId(data, id)) + if Version.isGen3() then add(require("src.core.Gen3MapNames").canonicalId(data, id)) end + if Version.isGen2() and type(source) == "string" then + local name = Identity.normalize(source) + local maps = data and data.maps + -- A generated spelling alone is insufficient: the registry must prove + -- this is an alias of this exact native map. + if name ~= source and type(maps) == "table" and type(maps[name]) == "table" + and Identity.sourceId(data, name) == source then add(name) end + end + return out +end + +-- Save keys remain native. Older public-only values stay readable per leaf, +-- including when the native row already contains a different object's state. +function Identity.savedValue(data, records, id, entry) + if type(records) ~= "table" then return nil end + for _, name in ipairs(Identity.aliases(data, id)) do + local value = records[name] + if entry ~= nil then + if type(value) == "table" then value = value[entry] else value = nil end + end + if value ~= nil then return value end + end +end + +function Identity.savedFlag(data, records, id, suffix) + if type(records) ~= "table" then return nil end + for _, name in ipairs(Identity.aliases(data, id)) do + local value = records[tostring(name) .. suffix] + if value ~= nil then return value end + end +end + +function Identity.lookup(data, records, id) + if type(records) ~= "table" or id == nil then return nil end + local exact = records[id] + if exact ~= nil then return exact end -- includes explicit false overrides + for _, name in ipairs(Identity.aliases(data, id)) do + local value = records[name] + if value ~= nil then return value end + end +end + +return Identity diff --git a/src/core/Music.lua b/src/core/Music.lua index e5b95baf6..fca19f123 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -136,7 +136,14 @@ end -- the jingle ends (see update()). -- Called by Sound.playCry: unlike a fanfare, the song keeps playing and is -- merely attenuated for as long as the cry sounds. -function Music.duckForCry(data, src) +function Music.duckForCry(data, src, noDuck) + if noDuck then + if cryDuck and cryDuck.src==src then + cryDuck=nil + if not state.fade then applyVolume(state.source);applyVolume(state.loopSource)end + end + return + end local c = data and data.constants and data.constants.gen3Cry local duck = c and tonumber(c.duckBgm) local scale = c and tonumber(c.scale) diff --git a/src/core/Performance.lua b/src/core/Performance.lua index 861121ddd..c7bd61b1a 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -42,6 +42,17 @@ local Performance = {} +-- P2 is independently reversible. Tier rows can reduce the idle budget or +-- tune the collector without changing the persisted presentation options. +Performance.gcBudget = true +-- P3: cache hot-path dependencies and reuse engine-private presentation data. +Performance.hotPathHoist = true +-- P4: independent world-draw closure, list and protection optimization. +Performance.worldDrawHoist = true +Performance.GC_PAUSE = 120 +Performance.GC_STEP_MUL = 300 +Performance.GC_BUDGET_SECONDS = 0.0005 + -- Option-row order (auto first, then most to least capable). Performance.TIERS = { "auto", "high", "balanced", "low" } @@ -60,6 +71,11 @@ Performance.CAPS = { balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false }, low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 }, } +for _, caps in pairs(Performance.CAPS) do + caps.gcPause = Performance.GC_PAUSE + caps.gcStepMul = Performance.GC_STEP_MUL + caps.gcBudgetSeconds = Performance.GC_BUDGET_SECONDS +end -- Live resolved tier (never "auto"); Game:applyOptions sets it and the -- renderer / options row read it back. Defaults to the unclamped tier so diff --git a/src/core/PlatformHooks.lua b/src/core/PlatformHooks.lua new file mode 100644 index 000000000..42a9018b3 --- /dev/null +++ b/src/core/PlatformHooks.lua @@ -0,0 +1,16 @@ +-- Shared frame boundary for mods that poll host state or pause simulation. +-- Match Gen1Recomp's main.lua -> PlatformHooks.update contract. +local Runtime = require("src.mods.Runtime") +local PlatformHooks = {} + +-- Hoisted: forwarding needs no per-frame closure or payload allocation. +-- Native Game:update results are deliberately discarded, as in Gen1. +local function vanillaUpdate(game, dt) + game:update(dt) +end + +function PlatformHooks.update(game, dt) + return Runtime.call("core.update", vanillaUpdate, game, dt) +end + +return PlatformHooks diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 7093a32f5..e0ec01196 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -264,14 +264,69 @@ 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 + +-- A source consumer's environmental voice must not share the cached Source +-- of a world script, weather controller or another mod. +function Sound.playOwnedEffect(data, name, owner) + assert(type(owner)=="string" and owner~="", "effect owner required") + 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 source=playPath(data,"source:"..#owner..":"..owner..":"..tostring(id),def) + if source then played("sfx",name) end + return source +end +function Sound.isSuspended()return sessionSuspended end + +-- Weather owns a separate native voice: an ambient stop must not cut off a +-- mod or scripted one-shot of the same cartridge sequence. +function Sound.playWeatherEffect(data, name, looping) + return Runtime.call("field.weather.sound", function(value) + local id, song = Sound.sequenceKey(data, value) + 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, "weather:" .. tostring(id), def) + if src then + safeAudio("Source:setLooping", src.setLooping, src, looping == true) + played("sfx", value) + end + return src + end, name) end function Sound.play(data, name) @@ -630,7 +685,12 @@ function Sound.playCry(data, species, opts) end -- set every time rather than at build: the cached source outlives a change -- of game, and the scale is the loaded cartridge's - pcall(src.setVolume, src, volumeFor(key) * cryScale(data)) + local scale=cryScale(data) + if opts and type(opts.volume)=='number'and opts.volume==opts.volume and opts.volume>=0 and opts.volume<=256 then + local unit=data.constants and data.constants.gen3Cry and tonumber(data.constants.gen3Cry.scale)or 256 + if unit and unit>0 then scale=opts.volume/unit end + end + pcall(src.setVolume, src, volumeFor(key) * scale) -- Interactive Dex playback owns its source; loop/effect settings must never -- leak onto the cached cry used by battles, NPC scripts or other menus. if opts and opts.isolated and src.clone then @@ -638,7 +698,7 @@ function Sound.playCry(data, species, opts) if ok and copy then src=copy end end if not restart(src) then return nil end - require("src.core.Music").duckForCry(data, src) + require("src.core.Music").duckForCry(data, src,opts and opts.noDuck==true) played("cry", species, species) return src end diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index baf622c4c..80a742dfd 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -81,13 +81,16 @@ local deepSaid = {} function StateStack:init() self.states = {} + self._modalTracker = nil end -- screen.pushed/popped fire after enter/exit so listeners observe the -- settled state; the wants guard keeps the no-listener path allocation-free function StateStack:push(state, ...) + local flows=package.loaded["src.compat.gen1.UiFlow"];if flows then flows.inherit(self,state)end table.insert(self.states, state) + if self._modalTracker then self._modalTracker:added(state) end if state.enter then state:enter(...) end local depth = #self.states Logger.debug("state stack: push %s (depth %d)", @@ -105,15 +108,14 @@ function StateStack:push(state, ...) end end -function StateStack:pop() - local state = table.remove(self.states) +local function finishRemoval(self, state) if state and state.exit then state:exit() end -- A POP THAT EMPTIES THE STACK is not debug-level news. The engine does it -- on purpose in exactly two places and pushes the replacement in the same -- breath; everywhere else it is one pop too many, and the thing it leaves -- behind -- a screen that never changes, with no error -- gives the reader -- nothing to search for. So it is said in words, once per culprit. - if state ~= nil and #self.states == 0 then + if state ~= nil and #self.states == 0 and not self.worldState then Logger.warn("state stack: popping %s emptied the stack -- nothing will " .. "update or draw until something is pushed back", StateStack.describe(state)) @@ -127,6 +129,35 @@ function StateStack:pop() return state end +function StateStack:pop() + local state = table.remove(self.states) + if state and self._modalTracker then self._modalTracker:removed(state) end + return finishRemoval(self, state) +end + +-- Remove actual native states, including a named modal beneath another one. +-- Detach the whole selection before callbacks: a new screen opened by exit +-- must survive and must never receive another removed screen's exit event. +function StateStack:removeAll(selected) + local wanted, removed = {}, {} + for _, state in ipairs(selected) do wanted[state] = true end + for i = #self.states, 1, -1 do + local state = self.states[i] + if wanted[state] then + table.remove(self.states, i) + if self._modalTracker then self._modalTracker:removed(state) end + removed[#removed + 1] = state + end + end + local firstError + for _, state in ipairs(removed) do + local ok, err = pcall(finishRemoval, self, state) + if not ok and not firstError then firstError = err end + end + if firstError then error(firstError, 0) end + return #removed +end + function StateStack:top() return self.states[#self.states] end @@ -140,6 +171,7 @@ end function StateStack:restore(state) if type(state) ~= "table" then return false end table.insert(self.states, state) + if self._modalTracker then self._modalTracker:added(state) end Logger.warn("state stack: restored %s (depth %d)", StateStack.describe(state), #self.states) return true @@ -172,26 +204,81 @@ end -- update opens the menu on A, and running that under the menu would open a -- second one on the same press) and it must not push, pop or finish. A state -- that wants both puts the motion in animate and calls it from update. +-- Update/covered animation follows native opacity, regardless of rendering. +-- Hiding a mirrored menu must not animate newly exposed states during logic. +local function opaqueBase(states, modal) + for i = #states, 1, -1 do + if modal then + if modal:allows(states[i]) and modal:opaque(states[i]) then return i end + elseif states[i].isOpaque then return i end + end + return 1 +end + function StateStack:update(dt) local top = self:top() - if top and top.update then top:update(dt) end - for i = self:visibleBase(), #self.states - 1 do + if top and top.update then + local flow=top.nativeUiFlow + if flow then + if flow:valid()then flow:admit(top.update,top,dt)else flow:retire()end + else top:update(dt)end + end + local modal = self._modalTracker + if modal and not modal:isCurrent() then modal = nil end + for i = opaqueBase(self.states, modal), #self.states - 1 do local state = self.states[i] - if state.animate then state:animate(dt) end + if state.animate and (not modal or modal:allows(state)) then state:animate(dt) end + end +end + +local function visibleByDefault() return true end + +-- A pure main-screen predicate: only literal false hides the native state. +-- Update/input ownership and the stored stack remain unchanged. +local function modalRenderPlan(self) + local modal = self._modalTracker + if not modal or not modal:isCurrent() then return end + local visible, hooked = {}, Runtime.wantsHook("screen.render_visible") + for _, state in ipairs(self.states) do + visible[state] = not hooked or Runtime.call("screen.render_visible", visibleByDefault, state) ~= false end + return modal:renderPlan(visible) +end + +function StateStack:renderVisible(state, plan) + if not state then return false end + plan = plan or modalRenderPlan(self) + if plan then return plan.visible[state] == true end + if not Runtime.wantsHook("screen.render_visible") then return true end + return Runtime.call("screen.render_visible", visibleByDefault, state) ~= false end --- index of the lowest state drawn this frame (highest opaque, else 1) -function StateStack:visibleBase() +-- index of the lowest state drawn this frame (highest visible opaque, else 1) +function StateStack:visibleBase(plan) + plan = plan or modalRenderPlan(self) + if plan then + for i = #self.states, 1, -1 do + local state = self.states[i] + if plan.visible[state] and (state == plan.opaque + or not self._modalTracker.records[state] and state.isOpaque) then return i end + end + return 1 + end + local modal = self._modalTracker + if modal and not modal:isCurrent() then modal = nil end + if not Runtime.wantsHook("screen.render_visible") then return opaqueBase(self.states, modal) end for i = #self.states, 1, -1 do - if self.states[i].isOpaque then return i end + local state = self.states[i] + if self:renderVisible(state) and (modal and modal:opaque(state) or not modal and state.isOpaque) then return i end end return 1 end function StateStack:draw() - for i = self:visibleBase(), #self.states do - if self.states[i].draw then self.states[i]:draw() end + local plan = modalRenderPlan(self) + for i = self:visibleBase(plan), #self.states do + local state = self.states[i] + if self:renderVisible(state, plan) and state.draw then state:draw() end end end 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/core/game3/asset_stream.lua b/src/core/game3/asset_stream.lua new file mode 100644 index 000000000..26d813430 --- /dev/null +++ b/src/core/game3/asset_stream.lua @@ -0,0 +1,32 @@ +-- Main-thread resource services share a wall-clock slice. Providers retain +-- their own typed jobs and cancellation; no private worker module is copied. +local S={BUDGET=.002,_uploadedThisFrame=false} +local services=setmetatable({},{__mode='v'}) +function S.register(service)services[#services+1]=service;return service end +function S.poll() + for _,service in pairs(services)do if service.poll then service:poll()end end +end +function S.update() + local clock=love and love.timer and love.timer.getTime or os.clock + local deadline=clock()+S.BUDGET + for _,service in pairs(services)do + if service.step then service:step(math.max(0,deadline-clock()))end + if service.update then service:update()end + if clock()>=deadline then break end + end +end +function S.cancelPending() + for _,service in pairs(services)do if service.cancelPending then service:cancelPending()end end +end +function S.cancelOwner(owner) + for _,service in pairs(services)do if service.releaseOwner then + local ok,why=pcall(service.releaseOwner,service,owner) + if not ok then print('[compat/stream] owner cleanup failed: '..tostring(why))end + end end +end +function S.frameComplete()S._uploadedThisFrame=false end +function S.shutdown() + for _,service in pairs(services)do if service.release then service:release()end end +end +S.release=S.shutdown +return S diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua new file mode 100644 index 000000000..a0c116d06 --- /dev/null +++ b/src/core/game3/audio.lua @@ -0,0 +1,65 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Sound=require('src.core.Sound') +local A={_seSources={},_seMeta=setmetatable({},{__mode='k'})} +local function owner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner() +end +local function stop(who,id) + for i=#A._seSources,1,-1 do + local source=A._seSources[i];local meta=A._seMeta[source] + if meta and(not who or meta.owner==who)and(id==nil or meta.id==id)then + pcall(source.stop,source);A._seMeta[source]=nil;table.remove(A._seSources,i) + end + end +end +function A.playSe(id,opts) + local volume=opts and opts.sourceVolume + assert(volume==nil or type(volume)=='number'and volume==volume and volume>=0 and volume<=1,'invalid source volume') + local g=Native.game();if not g then return false end + local value=require('src.core.game3.se_ids').resolve(id) + local who=owner() + local source=who and Sound.playOwnedEffect(g.data,value or id,who)or Sound.playNamedEffect(g.data,value or id) + if not source then source=Sound.playId(g.data,value)end + if source and who then + if not A._seMeta[source]then A._seSources[#A._seSources+1]=source end + A._seMeta[source]={owner=who,id=value or id,game=g,data=g.data,save=g.save,loader=g.mods} + if volume~=nil then + source:setVolume(volume) + end + A._lastSe=source + end + return source~=nil +end +function A.stopSe(id) + local who=owner();if not who then return false end + local value=id~=nil and require('src.core.game3.se_ids').resolve(id)or nil + if id~=nil and value==nil then return false end + stop(who,value);return true +end +function A.isReady()return love and love.audio~=nil and not Sound.isSuspended()end +function A.playCry(species,mode,pan) + local g=Native.game();if not g then return false end + local key=Ids.new(g.data):native('pokemon',species);if not key then return false end + return Sound.playCry(g.data,key,{noDuck=type(mode)=='table'and mode.noDuck or mode==1, + volume=type(mode)=='table'and mode.volume or nil})~=nil +end +function A.isCryFinished()return not Sound.anyPlaying('cry')end +A._service={release=function()stop();A._lastSe=nil end,releaseOwner=function(_,who)stop(who)end, + poll=function() + local g=Native.game() + for _,meta in pairs(A._seMeta)do + if g~=meta.game or g.data~=meta.data or g.save~=meta.save or g.mods~=meta.loader then stop(meta.owner)end + end + end} +require('src.core.game3.asset_stream').register(A._service) +setmetatable(A,{__index=function(_,key) + if key=='_suspended'then return Sound.isSuspended()end + if key=='_sfxVolume'then + local g=Native.game();local n=g and g.save and g.save.options and g.save.options.sfxVol + return type(n)=='number'and math.max(0,math.min(7,n))/7 or 1 + end +end}) +return A diff --git a/src/core/game3/bag.lua b/src/core/game3/bag.lua new file mode 100644 index 000000000..233e3479d --- /dev/null +++ b/src/core/game3/bag.lua @@ -0,0 +1,31 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Views=require('src.compat.gen1.SessionView') +local Ids=require('src.compat.gen1.BattleIds') +local Native=require('src.inventory.Bag') +local B={} +local function context(bag,id) + local game,save=Views.store(bag,'bag');if not game then return end + local key=Ids.new(game.data):native('items',id) + if key then return game,save,key end +end +function B.canAdd(bag,id,qty) + local g,s,key=context(bag,id) + return g~=nil and Native.canAdd(s,key,qty,g.data)or false +end +function B.add(bag,id,qty) + local g,s,key=context(bag,id);qty=qty or 1 + if not(g and Native.canAdd(s,key,qty,g.data))then return false,0 end + local ok=Native.add(s,key,qty,g.data);return ok,ok and qty or 0 +end +function B.has(bag,id,qty) + local g,s,key=context(bag,id) + return g~=nil and(s.inventory[key]or 0)>=(qty or 1)or false +end +function B.count(bag,id)local g,s,key=context(bag,id);return g and(s.inventory[key]or 0)or 0 end +function B.remove(bag,id,qty) + local g,s,key=context(bag,id) + qty=qty or 1 + if type(qty)~='number'or qty~=qty or qty%1~=0 or qty<1 or qty>999 or not g or not B.has(bag,id,qty)then return false end + Native.remove(s,key,qty or 1);return true +end +return B diff --git a/src/core/game3/battle.lua b/src/core/game3/battle.lua new file mode 100644 index 000000000..0ab8099f4 --- /dev/null +++ b/src/core/game3/battle.lua @@ -0,0 +1,190 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source battle presentation and observations, driven by the native owner. +local Runtime=require('src.compat.gen1.BattleRuntime') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Scope=require('src.render.GraphicsScope') +local M={} +local runtime=Runtime.new() +local function admitted(c) + if Runtime.member(c)then return true end + runtime:retire(c,false);return false +end +local captures=setmetatable({},{__mode='k'}) +local function serial()local renderer=package.loaded['src.render.Renderer'];return renderer and renderer.frameSerial end +local function sameTarget(a,b,depth) + if a==b then return true end + if type(a)~='table'or type(b)~='table'or(depth or 0)>=4 then return false end + -- LOVE canvases are userdata; preserve identity for table-based test or + -- external wrappers too. Only the MRT binding description is structural. + if a.typeOf or b.typeOf then return false end + for k,v in pairs(a)do if not sameTarget(v,b[k],(depth or 0)+1)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 target(value,depth) + if depth==nil then value=love.graphics.getCanvas();depth=0 end + if type(value)~='table'or value.typeOf or depth>=4 then return value end + local out={};for k,v in pairs(value)do out[k]=target(v,depth+1)end;return out +end +local function call(name,...) + local facade=require('src.core.Game3');local owner=facade._callbackOwner() + local seams=package.loaded['src.mods.ModSeams'] + for i=#(seams and seams._journal or{}),1,-1 do + local change=seams._journal[i] + if change.tbl==M and change.key==name and change.after==M[name]then owner=change.owner;break end + end + return facade._withCallbackOwner(owner,M[name],...) +end +function M.isActive()return runtime:active()~=nil end +function M.getState()local c=runtime:active();return c and c.views.view or runtime.last end +function M.getResult()local view=M.getState();return view and view.result end +function M.start(opts) + if Boundary.active('battle.start')then return Boundary.invoke('battle.start')end + return require('src.compat.gen1.BattleRequests').start(opts) +end +function M.songInfo(st) + st=st or M.getState();local kinds=st and st.kinds or{} + return{wild=st and st.wild or false,link=st and st.link or false, + trainerClass=st and st.trainerClass,trainerName=st and st.trainerName,frontier=kinds.frontier, + kind=kinds.kyogreGroudon and'kyogreGroudon'or kinds.regi and'regi'or nil} +end +function M.statWindowPhase() + local c=runtime:active();if not c then return false end + local class=c.screen.StatBox;if not class then return false end + for _,state in ipairs(c.stack.states)do + if getmetatable(state)==class then return true end + end + return false +end +function M.update()return Boundary.invoke('battle.update')end +local function draw(value,w,h) + local c=runtime:active(value);if not c then return false,'no admitted native battle'end + local bw,bh=c.screen:uiSize();w,h=w or bw,h or bh + if w~=bw or h~=bh then return false,'battle capture must use the native surface size'end + if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end + -- A capture can draw the native screen but cannot re-enter source wrappers. + return M._captureNative(c.screen) +end +M.draw=draw +function M._captureNative(screen) + local c=runtime:active();if not c or c.screen~=screen then return false,'native battle admission retired'end + local ok,result=Scope.call(function() + return require('src.core.Game3')._withCallbackOwner(nil,screen._drawNative,screen) + end) + if not ok then error(result,0)end + if admitted(c)then captures[screen]={serial=serial(),target=target()}end + return true +end +function M.abort(result) + result=result or'run' + local c=runtime:active();if not c then return false,'no admitted native battle'end + if c.stack:top()~=c.screen then return false,'native battle is covered by another state'end + if c.aborting then return false,'native battle completion already requested'end + if result~='run'and result~='lose'and result~='win'and result~='caught'then + return false,'a native battle outcome is required' + end + -- Aborting cannot award a victory or catch that the native rules never made. + if result~='run'and c.screen.result~=result then return false,'outcome has not been decided by the native battle'end + if c.screen.kind=='link'then return false,'link termination belongs to the native peer protocol'end + c.views:flush();c.aborting=true;c.screen.result=result;c.screen:finish();return true +end +function M._nativeEnter(screen) + local c=runtime:bind(screen);if not c then return end + if c.notified then return end;c.notified=true + local st=c.views.view + return Boundary.dispatch('battle.start',function()return true end,function() + return call('start',{wild=st.wild,playerParty=st.playerParty,foe=st.enemy and st.enemy.mon, + trainerId=screen.trainer and screen.trainer.id}) + end) +end +function M.reset() + if runtime:active()then return false,'cannot reset an admitted native battle'end + runtime:release();return true +end +function M._nativeUpdate(screen,dt,body) + captures[screen]=nil + local c=runtime:bind(screen);if not c then return body(screen,dt)end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + if Boundary.active('battle.update')then return Boundary.invoke('battle.update')end + return Boundary.dispatch('battle.update',function() + if not admitted(c)then return false,'native battle admission retired'end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + local function native(owner,step) + if not admitted(c)then return false,'native battle admission retired'end + if c.stack:top()~=screen then return false,'native battle is covered by another state'end + c.views:flush() + return require('src.core.Game3')._withCallbackOwner(nil,body,owner,step) + end + local ui=package.loaded['src.core.game3.battle.ui'] + if ui then return ui._nativeUpdate(screen,dt,native)end + return native(screen,dt) + end,function() + local anim=package.loaded['src.core.game3.battle.anim'] + if anim then anim._nativeStep(screen,dt)end + if not admitted(c)or c.stack:top()~=screen then return false,'native battle admission retired'end + local result=call('update',dt,require('src.core.Game3').forNative(c.game)) + if admitted(c)and c.stack:top()==screen then c.views:flush()end + return result + end) +end +function M._nativeDraw(screen,body) + local c=runtime:bind(screen);if not c then return body(screen)end + if Boundary.active('battle.draw')then return Boundary.invoke('battle.draw')end + local capture=captures[screen];local frame=serial() + if capture and sameTarget(capture.target,target())and(capture.serial~=nil and capture.serial==frame or capture.serial==nil)then + if capture.serial==nil then captures[screen]=nil end;return true + end + local ui=package.loaded['src.core.game3.battle.ui'] + local function guarded(owner) + if not admitted(c)then return false,'native battle admission retired'end + return require('src.core.Game3')._withCallbackOwner(nil,body,owner) + end + local function native() + if not admitted(c)then return false,'native battle admission retired'end + if ui then return ui._nativeDraw(screen,guarded)end + return guarded(screen) + end + if M.draw==draw then return native()end + local w,h=screen:uiSize() + local ok,result=Scope.call(function() + return Boundary.dispatch('battle.draw',native,function() + return call('draw',require('src.core.Game3').forNative(c.game),w,h) + end) + end) + if not ok then error(result,0)end + return result +end +function M._nativeField(screen,body) + local bg=package.loaded['src.core.game3.battle.bg'] + if not bg then return body(screen)end + local c=runtime:bind(screen);if not c then return body(screen)end + return bg._nativeDraw(screen,function(owner) + if not admitted(c)then return false,'native battle admission retired'end + return require('src.core.Game3')._withCallbackOwner(nil,body,owner) + end) +end +function M._nativeExit(screen) + captures[screen]=nil + local anim=package.loaded['src.core.game3.battle.anim'] + if anim then anim._nativeExit(screen)end + runtime:exit(screen) +end +-- Private typed consumers can resolve a source observation to the same native +-- admission; callers cannot submit another state or a stale completed view. +function M._screen(view) + local c=runtime:active() + if c and(view==nil or view==c.views.view)then return c.screen end +end +local service={poll=function()runtime:poll()end,release=function()captures=setmetatable({},{__mode='k'});runtime:release()end, + releaseOwner=function(_,who)runtime:releaseOwner(who)end} +M._service=service +require('src.core.game3.asset_stream').register(service) +setmetatable(M,{__index=function(_,key) + local c=runtime:active() + if key=='_active'then return c~=nil end + if key=='_st'then return c and c.views.view or runtime.last end + if not c then return nil end + if key=='_pendingEnd'then return c.screen.result end + if key=='_phase'then return c.screen.result and'ending'or c.screen.phase end +end}) +return M diff --git a/src/core/game3/battle/anim.lua b/src/core/game3/battle/anim.lua new file mode 100644 index 000000000..fd8ac2808 --- /dev/null +++ b/src/core/game3/battle/anim.lua @@ -0,0 +1,274 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- One admitted native presentation owner. This is not a second battle VM; +-- authored command/particle APIs are advertised only as they are integrated. +local Battle=require('src.core.game3.battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Clock=require('src.battle.PresentationClock') +local Scope=require('src.render.GraphicsScope') +local Ids=require('src.compat.gen1.BattleIds') +local A={Coords=Coords,ENEMY_MON={x=176,y=40},PLAYER_MON={x=72,y=80}} +local current +local numeric={ox=true,oy=true,alpha=true,scale=true,sx=true,sy=true,rotation=true, + darken=true,flash=true,displayHp=true,displayMaxHp=true,displayLevel=true,displayExp=true} +local function retire() + if current then + local screen=current.screen[1] + if screen and current.clip and screen.gen3Anim==current.clip.player + and current.clip.serial==screen.gen3Anim.presentationSerial then + screen.gen3Anim:release();screen.gen3AnimPlaying=false + end + current.clip=nil + if current.effect then current.effect:release();current.effect=nil end + current.tasks={};current.onEnd=nil;current.users={};current.overrides={} + current.view=nil;current.screen[1]=nil + end + current=nil +end +local function context() + local screen=Battle._screen();local view=Battle.getState() + if not screen then retire();return end + if not current or current.screen[1]~=screen or current.view~=view then + retire() + current={screen=setmetatable({screen},{__mode='v'}),view=view,present={},overrides={}, + tasks={},nextTask=0,users={},ids=Ids.new(screen.data)} + end + local facade=require('src.core.Game3');local owner=facade._callbackOwner() + local host=package.loaded['src.mods.Runtime'];owner=host and host.currentMod or owner + if owner then current.users[owner]=true end + return current,screen +end +local function battler(screen,id) + if id==0 then return screen.player elseif id==1 then return screen.enemy end + return screen:battlerAt(id) +end +local function number(v)return type(v)=='number'and v==v and v~=math.huge and v~=-math.huge end +local function callback(fn,...) + if fn then return require('src.core.Game3')._withCallbackOwner(fn.owner,fn.call,...)end +end +local function owned(fn) + if fn==nil then return end + assert(type(fn)=='function','presentation callback must be a function') + local host=package.loaded['src.mods.Runtime'] + return{call=fn,owner=host and host.currentMod or require('src.core.Game3')._callbackOwner()} +end +local function read(d,id,key) + local active,screen=context();if active~=d then return nil end + local b=battler(screen,id);if not b then return nil end + local p=d.overrides[id] + if p[key]~=nil then return p[key]end + if key=='id'then return id end + if key=='side'then return Coords.sideOf(id)end + if key=='visible'then return not b.fainted and not screen:fxHidden(b)and not screen:gen3AnimMonBgHidden(b)end + if key=='invisible'or key=='battlerInvisible'then return screen:gen3AnimMonBgHidden(b)end + if key=='ox'or key=='oy'then + local x,y=screen:gen3AnimShake(b);local fx=screen.picFx and screen.picFx[b] + return(key=='ox'and x+(fx and fx.ox or 0)or y+(fx and fx.oy or 0)) + end + if key=='scale'then return screen:gen3AnimScale(b)or 1 end + if key=='sx'or key=='sy'then + local x,y=screen:gen3AnimSquash(b);return(key=='sx'and x or y)or 1 + end + if key=='rotation'then return screen:gen3AnimRotate(b)or 0 end + if key=='alpha'then local fx=screen:gen3AnimSpecialMonState(b);return fx and fx.alpha or 1 end + if key=='hFlip'then return false end + if key=='darken'or key=='flash'then return 0 end + if key=='displayHp'then return b.shownHP or b.mon.hp end + if key=='displayMaxHp'then return b.mon.stats.hp end + if key=='displayLevel'then return b.mon.level end + if key=='displayStatus'then + -- Logical status may already describe a queued future event. The native + -- HUD owns the shown ailment; false is its explicit healthy sentinel. + local status=b.shownStatus + if not status then return false end + if status=='SLP'then return 1 end + return({PSN=8,BRN=16,FRZ=32,PAR=64,TOX=128})[status]or false + end + if key=='displayExp'then return screen:expFraction(b.mon)end +end +function A.idOf(key) + if key=='attacker_side'then + local _,screen=context();local anim=screen and screen.gen3Anim + return anim and anim.attackerPosition or 0 + end + return Coords.idOf(key) +end +A.sideOf,A.isDouble,A.setDouble=Coords.sideOf,Coords.isDouble,Coords.setDouble +A.coords,A.subpriority,A.bgPriorityRank=Coords.coords,Coords.subpriority,Coords.bgPriorityRank +A.monDrawOrder,A.particleBand,A.battlerIds=Coords.monDrawOrder,Coords.particleBand,Coords.ids +function A.x(value) + local _,screen=context();local anim=screen and screen.gen3Anim + value=tonumber(value)or 0 + return anim and anim.attackerIsPlayer==false and -value or value +end +function A.present(key) + local d,screen=context();local id=A.idOf(key) + if not d or id==nil or id>=2 and not Coords.isDouble(d.view)or not battler(screen,id)then return nil end + if not d.present[id]then + d.overrides[id]={} + d.present[id]=setmetatable({},{__index=function(_,k)return read(d,id,k)end, + __newindex=function(_,k,v) + assert(context()==d,'native battle presentation retired') + assert(k~='id'and k~='side','native battler identity is read-only') + if v~=nil and(numeric[k]or type(v)=='number')then assert(number(v),'invalid presentation number')end + d.overrides[id][k]=v + end}) + end + return d.present[id] +end +function A.battlerCenter(key) + local p=A.present(key);local base=Coords.coords(nil,A.idOf(key)) + if not p or not base then return nil end + return base.x+(p.ox or 0),base.y+(p.oy or 0) +end +function A.shownBattler(key,fallback)local p=A.present(key);return p and p.shown or fallback end +function A.setShown(key,value)local p=A.present(key);if p then p.shown=value;return true end;return false end +function A.ballIdOf(key) + local d,screen=context();local id=A.idOf(key);local b=d and id and battler(screen,id) + if not b then return nil end + return require('src.battle.Gen3BallAnim').ballIndex(screen.data,b.mon.ball or'POKE_BALL',screen.data.items) +end +function A.displayHpRatio(key,fallback) + local p=A.present(key);local mon=fallback and fallback.mon + local hp=p and p.displayHp or mon and mon.hp or 0 + local maximum=math.max(1,p and p.displayMaxHp or mon and mon.maxHp or 1) + return math.max(0,math.min(1,hp/maximum)),hp,maximum +end +function A.displayExpRatio(key,fallback) + local p=A.present(key or'player') + return p and p.displayExp or 0,p and p.displayLevel or fallback and fallback.mon and fallback.mon.level or 1 +end +function A.hpTweening() + local _,screen=context();if not screen then return false end + for _,id in ipairs(Coords.ids())do + local b=battler(screen,id) + if b and b.shownHP and(b.shownHP~=b.mon.hp or b.draining or(b.drainHold or 0)>0)then return true end + end + return false +end +local function effect() + local d=context();if not d then return end + if not d.effect then d.effect=require('src.render.BattleScreenEffect').new()end + return d.effect +end +function A.setScreenEffect(opts)local e=effect();return e and e:set(opts)or false end +function A.screenEffect()local e=effect();return e and e.value end +function A.beginScreenEffect()local e=effect();return e and e:begin()or false end +function A.endScreenEffect()local e=effect();return e and e:finish()or false end +function A.busy() + local d,screen=context() + return d~=nil and(screen.gen3AnimPlaying==true or screen.gen3BallPlaying==true + or A.hpTweening()or screen.expFilling==true or next(d.tasks)~=nil)or false +end +function A.setSeqBusy(value)local d=context();if d then d.seqBusy=value==true end end +function A.seqBusy()local d=context();return d and d.seqBusy==true or false end +function A.tweenStage(frames,onStep,onComplete) + local d=context();if not d then return nil,'no admitted native battle'end + assert(number(frames)and frames>=0,'invalid presentation duration') + d.nextTask=d.nextTask+1 + local task={id=d.nextTask,frames=0,total=math.max(1,math.ceil(frames)),step=owned(onStep),done=owned(onComplete)} + d.tasks[task.id]=task + return task +end +function A._nativeStep(screen,dt) + if not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + local d,actual=context();if actual~=screen or screen.game.stack:top()~=screen then return false end + return Clock.once(screen,'source.presentation',function() + local clip=d.clip + if clip then + if clip.player~=screen.gen3Anim or clip.serial~=screen.gen3Anim.presentationSerial then + d.clip,d.onEnd=nil,nil + elseif screen.gen3AnimPlaying then + require('src.core.Game3')._withCallbackOwner(nil,screen.updateMovePresentation,screen) + end + end + -- Snapshot the IDs: a callback may schedule another tween, which starts + -- next frame, or retire the admission, which cancels the remaining work. + local ids={};for id in pairs(d.tasks)do ids[#ids+1]=id end;table.sort(ids) + for _,id in ipairs(ids)do + if context()~=d or screen.game.stack:top()~=screen then return false end + local task=d.tasks[id] + if task then + task.frames=task.frames+1 + callback(task.step,math.min(1,task.frames/task.total),task) + if context()~=d then return false end + if task.frames>=task.total then d.tasks[id]=nil;callback(task.done)end + end + end + if context()==d and screen.game.stack:top()==screen and d.onEnd and not screen.gen3AnimPlaying then + local done=d.onEnd;d.onEnd,d.clip=nil,nil;callback(done) + elseif context()==d and not screen.gen3AnimPlaying then d.clip=nil + end + return true + end) +end +function A._holdsNativeQueue(screen) + local d,actual=context();local clip=d and d.clip + return actual==screen and clip~=nil and clip.player==screen.gen3Anim + and clip.serial==screen.gen3Anim.presentationSerial and screen.gen3AnimPlaying==true +end +function A.update(dt) + local _,screen=context() + if not screen or not Clock.active(screen)then return false,'animation update belongs to its native battle phase'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + A._nativeStep(screen,dt) + if Battle._screen()~=screen or screen.game.stack:top()~=screen then return false,'native battle admission retired'end + -- Use the owner's dt, not a second caller-provided clock. The original + -- native update and this hold consume the same once-only display steps. + require('src.core.Game3')._withCallbackOwner(nil,screen.updatePresentation,screen,Clock.delta(screen)) + return true +end +function A.launchMove(moveId,opts) + opts=opts or{};local d,screen=context() + if not d then return false,'no admitted native battle'end + if screen.game.stack:top()~=screen then return false,'native battle is covered by another state'end + if screen.gen3AnimPlaying then return false,'native animation is already active'end + local native=d.ids:native('moves',moveId);local anim=screen.gen3Anim + if not native or not anim or not anim:has(native,opts)then return false,'no native animation record'end + if opts.miss then return false,'native miss presentation requires its authored command'end + local attacker=A.idOf(opts.attackerId or opts.attackerSide or'player') + local target=A.idOf(opts.targetId or opts.targetSide or'enemy') + if attacker==nil or target==nil or not battler(screen,attacker)or not battler(screen,target)then + return false,'animation requires admitted native battlers' + end + local done=owned(opts.onEnd) + if not require('src.core.Game3')._withCallbackOwner(nil,anim.start,anim,native,attacker%2==0,attacker,target,opts,screen)then return false end + screen.gen3AnimPlaying=true;d.onEnd=done;d.clip={player=anim,serial=anim.presentationSerial} + return true +end +function A.reset() + retire();Coords._service.release();return true +end +function A._nativeExit(screen) + if current and current.screen[1]==screen then A.reset()end +end +-- Applied around the existing native sprite draw, preserving its scanlines, +-- tints, substitutes and affine effects. Only source overrides compose here; +-- querying present() never feeds native effects back into themselves. +function A._drawBattler(screen,b,body,...) + local d,actual=context();local id=b.position or(b.isPlayer and 0 or 1) + local p=d and actual==screen and d.overrides[id] + if not p or next(p)==nil then return body(screen,b,...)end + if p.visible==false or p.invisible or p.battlerInvisible or p.blinkHidden then return end + if not(p.hFlip or(p.ox or 0)~=0 or(p.oy or 0)~=0 or(p.rotation or 0)~=0 + or(p.scale or 1)~=1 or(p.sx or 1)~=1 or(p.sy or 1)~=1 or(p.alpha or 1)~=1)then + return body(screen,b,...) + end + local args={...};local x,y,scale=args[1],args[2],args[3]or 1 + local img=screen:battlerPic(b);local w,h=img:getDimensions() + local cx,cy=x+w*scale/2,y+h*scale/2 + local ok,result=Scope.call(function() + local g=love.graphics + g.translate(cx+(p.ox or 0),cy+(p.oy or 0));g.rotate(p.rotation or 0) + g.scale((p.scale or 1)*(p.sx or 1)*(p.hFlip and -1 or 1),(p.scale or 1)*(p.sy or 1)) + g.translate(-cx,-cy) + local r,green,blue,a=g.getColor();g.setColor(r,green,blue,a*(p.alpha or 1)) + return body(screen,b,x,y,scale) + end) + if not ok then error(result,0)end + return result +end +A._service={release=A.reset,poll=function()context()end,releaseOwner=function(_,owner) + if current and current.users[owner]then A.reset()end +end} +require('src.core.game3.asset_stream').register(A._service) +return A diff --git a/src/core/game3/battle/anim_coords.lua b/src/core/game3/battle/anim_coords.lua new file mode 100644 index 000000000..6f8611cf7 --- /dev/null +++ b/src/core/game3/battle/anim_coords.lua @@ -0,0 +1,131 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Authored 240x160 sprite anchors, separate from opaque image bounds. Imported +-- cartridge coordinates override the standard anchors on the native owner. +local Battle=require('src.core.game3.battle') +local C={SINGLES={},DOUBLES={}} +C.SUBPRIORITY={[0]=30,40,20,50} +C.BG_PRIORITY_RANK={[0]=2,1,1,2} +C.DRAW_ORDER_SINGLES={1,0} +C.DRAW_ORDER_DOUBLES={3,1,0,2} +C.battleState=Battle.getState +local function point(x,y)return{x=x,y=y,[1]=x,[2]=y}end +for id,p in pairs{[0]={72,80},{176,40},{48,40},{112,80}}do C.SINGLES[id]=point(p[1],p[2])end +for id,p in pairs{[0]={32,80},{200,40},{90,88},{152,32}}do C.DOUBLES[id]=point(p[1],p[2])end +local double,overrides,binding +local users={} +local function recordOwner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local owner=host and host.currentMod or facade and facade._callbackOwner() + if owner then users[owner]=true end +end +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +function C.sideOf(id)return type(id)=='number'and(id%2==0 and'player'or'enemy')or nil end +function C.partner(id)return type(id)=='number'and(id+2)%4 or nil end +function C.fixedId(key) + if type(key)=='number'and key==key and key>=0 and key<=3 then return math.floor(key)end + return({player=0,enemy=1})[key] +end +function C.idOf(key) + if type(key)=='table'then key=key.id or key.side end + if binding and binding[key]~=nil then return binding[key]end + return C.fixedId(key) +end +function C.bind(attacker,target) + recordOwner() + if attacker==nil then binding=nil;return end + attacker=C.fixedId(attacker);target=C.fixedId(target) + binding={} + if target then binding[C.sideOf(target)]=target end + if attacker then binding[C.sideOf(attacker)]=attacker end +end +function C.withBattler(key,fn,...) + local previous=binding;local id=C.idOf(key) + binding={player=previous and previous.player or 0,enemy=previous and previous.enemy or 1} + if id then binding[C.sideOf(id)]=id end + local result=pack(pcall(fn,C.sideOf(id),...));binding=previous + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function C.sideArg(fn,index) + index=index or 1 + return function(...) + local args=pack(...);local id=C.idOf(args[index]) + if type(args[index])~='number'or not id then return fn(...)end + return C.withBattler(id,function(side) + args[index]=side;return fn(unpack(args,1,args.n)) + end) + end +end +function C.setDouble(value)recordOwner();if value==nil then double=nil else double=not not value end end +function C.isDouble(st) + if double~=nil then return double end + st=st or Battle.getState();return st and st.double==true or false +end +function C.setCoordinateOverrides(value)recordOwner();local previous=overrides;overrides=value;return previous end +function C.coords(st,key) + local id=C.idOf(key);if id==nil then return nil end + local p=overrides and overrides[id] + if p then return p end + local screen=Battle._screen(st) + local imported=screen and screen.data.constants.gen3BattlerCoords + local row=imported and(C.isDouble(st)and imported.doubles or imported.singles) + p=row and row[id+1]or(C.isDouble(st)and C.DOUBLES or C.SINGLES)[id] + return point(p.x or p[1],p.y or p[2]) +end +function C.ids(st)return C.isDouble(st)and{0,1,2,3}or{0,1}end +function C.battler(st,key) + st=st or Battle.getState();local id=C.idOf(key) + if not st or id==nil then return nil end + return st.battlers and st.battlers[id]or(id==0 and st.player or id==1 and st.enemy or nil) +end +function C.spritePresent(st,key) + local id=C.idOf(key);if id==nil or id>=2 and not C.isDouble(st)then return false end + local b=C.battler(st,id) + if not st then st=Battle.getState()end + if st and st.absent and st.absent[id]then return false end + return b==nil and id<2 or b~=nil and not b.absent and not b.fainted and(b.mon==nil or(b.mon.hp or 0)>0) +end +function C.subpriority(key)return C.SUBPRIORITY[C.idOf(key)]or 40 end +function C.bgPriorityRank(key)return C.BG_PRIORITY_RANK[C.idOf(key)]or 1 end +function C.monDrawOrder(st)return C.isDouble(st)and{3,1,0,2}or{1,0}end +function C.monBehindZ(key,st) + local id=C.idOf(key) + for i,slot in ipairs(C.monDrawOrder(st))do if slot==id then return(i-1)*100+95 end end + return 95 +end +function C.particleBand(k,st) + k=tonumber(k)or 0 + if k<=0 then return 0,99 end + return k*100+1,k>=#C.monDrawOrder(st)and 999 or k*100+99 +end +function C.layerZ(sub,monbg,bgPriority,st) + sub=tonumber(sub)or 0 + local order=C.monDrawOrder(st);local band=0 + for i=#order,1,-1 do + local id=order[i] + local front + if monbg and rawget(monbg,id)then front=((bgPriority and bgPriority[C.bgPriorityRank(id)])or 2)>=2 + else front=sub=3 then return math.max(1,math.min(98,98-sub))end + return C.layerZ(sub,vm and vm._monbg,vm and vm._bgPrio,st) +end +function C.idTable(values) + local result=setmetatable({},{__index=function(t,k)local id=C.idOf(k);if id~=nil then return rawget(t,id)end end, + __newindex=function(t,k,v)local id=C.idOf(k);rawset(t,id~=nil and id or k,v)end}) + for k,v in pairs(values or{})do result[k]=v end + return result +end +C._service={release=function()double,overrides,binding=nil,nil,nil;users={}end} +C._service.poll=function()if not Battle.isActive()then C._service.release()end end +C._service.releaseOwner=function(_,owner)if users[owner]then C._service.release()end end +require('src.core.game3.asset_stream').register(C._service) +return C diff --git a/src/core/game3/battle/anim_pal.lua b/src/core/game3/battle/anim_pal.lua new file mode 100644 index 000000000..9483639cd --- /dev/null +++ b/src/core/game3/battle/anim_pal.lua @@ -0,0 +1,45 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Decode source artwork with LOVE; palette indices must be explicit imported +-- data. RGBA colours cannot recover duplicate cartridge palette entries. +local P={} +local blend +function P.shader() + if not blend then + blend=love.graphics.newShader([[ + extern vec3 blendColor; + extern number blendCoeff; + vec4 effect(vec4 colour, Image image, vec2 uv, vec2 screen) { + vec4 pixel=Texel(image,uv); + return vec4(mix(pixel.rgb,blendColor,blendCoeff),pixel.a)*colour; + } + ]]) + blend:send('blendColor',{0,0,0});blend:send('blendCoeff',0) + end + return blend +end +function P.loadImageData(bytes,name) + if type(bytes)~='string'then return nil,'animation image bytes unavailable'end + local file=love.filesystem.newFileData(bytes,name or'animation.png') + local ok,data=pcall(love.image.newImageData,file);file:release() + if not ok then return nil,data end + return data +end +function P.hydrateIndex(info,tag,image,reader) + assert(type(info)=='table'and type(reader)=='function','animation artwork and reader required') + if info.idxImage then return true end + local path=info.indexFile or info.idxFile + if not path then return false,'animation artwork has no explicit palette indices'end + local data,why=P.loadImageData(reader(path),path) + if not data then return false,why end + local w,h=image:getDimensions();local iw,ih=data:getDimensions() + if iw~=w or ih~=h then data:release();return false,'animation palette index dimensions differ'end + local ok,texture=pcall(love.graphics.newImage,data) + if not ok then data:release();return false,texture end + texture:setFilter('nearest','nearest');info.idxImage,info.idxData=texture,data + return true +end +P._service={release=function() + if blend then blend:release();blend=nil end +end} +require('src.core.game3.asset_stream').register(P._service) +return P diff --git a/src/core/game3/battle/anim_sprites.lua b/src/core/game3/battle/anim_sprites.lua new file mode 100644 index 000000000..93a679341 --- /dev/null +++ b/src/core/game3/battle/anim_sprites.lua @@ -0,0 +1,86 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native event observations and reversible drawing transforms. There is no +-- sprite scheduler here: event life, OAM order and animation clocks stay native. +local Battle=require('src.core.game3.battle') +local Scope=require('src.render.GraphicsScope') +local Native=require('src.battle.Gen3Battle') +local S={} +local current +local interested=false +local function retire() + if current then for _,row in ipairs(current.pool)do row.active=false;row.customDraw=nil end end + current=nil +end +local function context() + local screen=Battle._screen();local anim=screen and screen.gen3AnimPlaying and screen.gen3Anim + if not(anim and anim.events)then retire();return end + if not current or current.screen~=screen or current.anim~=anim or current.serial~=anim.presentationSerial then + retire();current={screen=screen,anim=anim,serial=anim.presentationSerial,pool={},entries={},users={}} + end + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local who=host and host.currentMod or facade and facade._callbackOwner();if who then current.users[who]=true end + return current +end +local function sample(c) + if c.sampled and c.frame==c.anim.frame then return c.pool end + c.frame=c.anim.frame;c.sampled=true + local seen,out={},{} + for _,event in ipairs(c.anim.events)do + local pose=c.anim:eventPose(event,c.screen,Native) + if pose then + local row=c.entries[event]or{};c.entries[event]=row;seen[event]=true + local key=c.anim:eventPriorityKey(event,c.screen) + local x,y=pose.centreX+(pose.dx or 0),pose.centreY+(pose.dy or 0) + row.active,row.visible,row.invisible=true,true,pose.callbackState and pose.callbackState.invisible or false + row.x,row.y,row.ox,row.oy=x,y,0,0;row.scaleX,row.scaleY,row.rotation=1,1,0 + row.alpha,row.age,row.tag,row.template=1,pose.age,event.sheet,event.template + row.subpriority=key and key%256;row.priority=key and math.floor(key/256) + row.hostId=event.target and c.anim.targetPosition or c.anim.attackerPosition + row.image=pose.sheet.image;row.customDraw=nil + row._nativeX,row._nativeY=x,y + out[#out+1]=row + end + end + for event,row in pairs(c.entries)do if not seen[event]then row.active=false;row.customDraw=nil;c.entries[event]=nil end end + -- Keep the pool identity stable for the native clip; removed rows retire. + for i,row in ipairs(out)do c.pool[i]=row end + for i=#out+1,#c.pool do c.pool[i]=nil end + return c.pool +end +function S.pool() + interested=true + local c=context();if not c then return{}end + return sample(c) +end +function S.activeCount()return#S.pool()end +function S.forEachActive(fn) + assert(type(fn)=='function','particle observer required') + for _,row in ipairs(S.pool())do fn(row)end +end +local function finite(n)return type(n)=='number'and n==n and math.abs(n)=0,'invalid move power');move.power=opts.power end + if opts.moveType~=nil then move.type=assert(ids:native('types',opts.moveType),'unknown cartridge move type')end + local Defaults=require('src.battle.RulesetDefaults');local rulesets=g.data.rulesets or require('src.battle.Rulesets') + local selected=g.save.options and g.save.options.ruleset + local rules=rulesets[selected]or rulesets[Defaults.defaultFor(g)] + assert(rules,'native ruleset unavailable') + local critical=opts.forceCrit;if opts.noCrit then critical=false end + local user,target=battler(attacker,g,ids),battler(defender,g,ids) + user.isPlayer,user.position,target.isPlayer,target.position=true,0,false,1 + local messages={} + local battle={data=g.data,ruleset=rules,rng=opts.rng or love.math.random, + player=user,enemy=target,field={},lastDamage=opts.lastDamage or 0} + function battle:sayNext(text)messages[#messages+1]=text end + function battle:sideOf(who)return who==user and'player'or'enemy'end + function battle:computeDamage(a,b,row,extra) + local arguments={rng=self.rng,forceCrit=critical,weather=opts.weather,data=g.data} + for k,v in pairs(extra or{})do arguments[k]=v end + if opts.noCrit then arguments.forceCrit=false end + return Native.compute(rules,a,b,row,arguments) + end + local record=require('src.battle.BattleState').effectRecord(battle,move.effect) + local ctx=require('src.battle.EffectRegistry').makeCtx(battle,user,target,move,{id=key,pp=1},false) + local damage,info,failed + if move.power==0 or move.category=='status'or move.class=='status'then + damage,info=0,{crit=false,typeMult=10} + elseif record and record.chooseDamage and opts.power==nil then + damage,info=record.chooseDamage(ctx) + if damage==nil then + if type(info)=='string'then messages[#messages+1]=info end + damage,info,failed=0,{crit=false,typeMult=10},true + end + else + damage,info=battle:computeDamage(user,target,move,{explode=record and record.explode, + highCrit=record and record.highCrit, + powerMultiplier=record and record.powerMultiplier and record.powerMultiplier(ctx)or nil}) + end + info=info or{crit=false,typeMult=10} + if opts.multiplier then assert(type(opts.multiplier)=='number'and opts.multiplier>=0,'invalid damage multiplier');damage=math.floor(damage*opts.multiplier)end + return damage,{move=require('src.core.game3.battle.moves').get(moveId),effectiveness=info.typeMult/10, + critical=info.crit,status=move.power==0 or move.category=='status'or move.class=='status', + physical=(move.category or move.class or require('src.battle.TypeChart').category(move.type))=='physical', + ability=info.ability,missed=info.missed,failed=failed or false,messages=messages} +end +return D diff --git a/src/core/game3/battle/experience.lua b/src/core/game3/battle/experience.lua new file mode 100644 index 000000000..3f1ac1911 --- /dev/null +++ b/src/core/game3/battle/experience.lua @@ -0,0 +1,57 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Source=require('src.compat.gen1.SourcePokemon') +local Growth=require('src.pokemon.Growth') +local Awards=require('src.battle.Experience') +local E={MAX_LEVEL=100} +local function definition(value) + local g=Native.game();if not g then return end + local id=type(value)=='table'and value.species or value + local key=Ids.new(g.data):native('pokemon',id) + return key and g.data.pokemon[key],g +end +function E.expYield(value)local d=definition(value);return d and d.baseExp or 0 end +function E.growthRate(value)local d=definition(value);return d and d.growthRate end +function E.expForLevel(value,level) + local d,g=definition(value);local rate=d and d.growthRate or value + return Growth.expForLevel(rate,level,g and g.data.growth_rates) +end +function E.levelForExp(value,exp) + local d,g=definition(value);return Growth.levelForExp(d and d.growthRate or value,exp,100,g and g.data.growth_rates) +end +function E.gainFor(species,level,opts) + opts=opts or{};local d,g=definition(species);if not d then return 0 end + local n=Awards.gainFor(d,level,false,opts.participants,false,g.data.constants) + local tune=g.data.constants.exp or{} + for _,mult in ipairs{opts.luckyEgg and 1.5 or 1,opts.trainer and(tune.trainerMult or 1.5)or 1,opts.traded and(tune.tradedMult or 1.5)or 1}do n=math.floor(n*mult)end + return math.max(1,n) +end +function E.apply(value,amount) + local mon,g,commit=Source.resolve(value) + assert(type(amount)=='number'and amount==amount and math.abs(amount)=nextLevel then mon.exp=start end + local result={gained=0,rawGained=amount,fromLevel=mon.level,toLevel=mon.level,fromExp=mon.exp,toExp=mon.exp,levels={},steps={}} + local target=mon.level>=cap and mon.exp or math.min(threshold(cap),mon.exp+amount) + while mon.exp0 then + step.grewTo=mon.level;step.hp=mon.hp;step.maxHp=mon.stats.hp + step.oldStats={maxHp=old.hp,atk=old.attack,def=old.defense,spa=old.spatk,spd=old.spdef,spe=old.speed} + local s=mon.stats;step.newStats={maxHp=s.hp,atk=s.attack,def=s.defense,spa=s.spatk,spd=s.spdef,spe=s.speed} + for _,n in ipairs(levels)do result.levels[#result.levels+1]=n end + end + result.steps[#result.steps+1]=step + end + result.toExp,result.toLevel=mon.exp,mon.level;result.gained=mon.exp-result.fromExp + commit();return result +end +return E diff --git a/src/core/game3/battle/healthbox.lua b/src/core/game3/battle/healthbox.lua new file mode 100644 index 000000000..ec07c51a4 --- /dev/null +++ b/src/core/game3/battle/healthbox.lua @@ -0,0 +1,66 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source healthboxes use native shown state and the existing imported painter. +local Battle=require('src.core.game3.battle') +local Coords=require('src.core.game3.battle.anim_coords') +local Native=require('src.battle.Gen3Battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Scope=require('src.render.GraphicsScope') +local H={} +local scopes={} +local function resident(st,key,view) + local screen=Battle._screen(st);if not screen then return end + local id=Coords.idOf(key);if id==nil then return end + local observed=Coords.battler(Battle.getState(),id) + if view~=nil and view~=observed then return end + local native;if id==0 then native=screen.player elseif id==1 then native=screen.enemy else native=screen:battlerAt(id)end + return screen,native,id,observed +end +function H.center(st,id) + local screen,b=resident(st,id);if not b then return nil end + local x,y,h=Native.healthboxGeometry(screen,Coords.idOf(id));if not x then return nil end + return{x=x+32,y=y+math.floor(h/2),x+32,y+math.floor(h/2)} +end +local function paint(screen,b,id,opts) + local x,y=Native.healthboxGeometry(screen,id);if not x then return false end + opts=opts or{} + for _,key in ipairs{'ox','oy'}do + local n=opts[key];assert(n==nil or type(n)=='number'and n==n and math.abs(n)=layout.width or y>=layout.height then return nil end + return layout:elevAt(x,y) +end +function Collision.elevationAt(x,y) + local g,w=Session.resolve() + if g and integer(x)and integer(y)and w.map:inBounds(x,y)then return w.map:cellElevation(x,y)end +end +function Collision.canEnter(game,x,y,opts)return runtime:canEnter(game,x,y,opts)end +function Collision.isWalkable(x,y) + local g,w=Session.resolve() + return g~=nil and integer(x)and integer(y)and w.map:inBounds(x,y)and w.map:isWalkableCell(x,y)or false +end +function Collision.isWater(x,y) + local g,w=Session.resolve() + return g~=nil and integer(x)and integer(y)and w.map:inBounds(x,y)and w.map:isWaterCell(x,y)or false +end +function Collision.isGrass(x,y) + local g,w=Session.resolve() + return g~=nil and integer(x)and integer(y)and w.map:inBounds(x,y)and w.map:isGrassCell(x,y)or false +end +function Collision.nextElevation(def,at,x,y,px,py) + local g=Session.resolve();local maps=g and Map._context(g) + if not(maps and maps:isOwned(def)and maps:definition(def.id)==def and integer(x)and integer(y))then + return at,nil + end + return require('src.world.Map').elevationTransition(at, + Collision.elevationOn(def,x,y),Collision.elevationOn(def,px,py)) +end +function Collision.ledgeLanding(game,x,y,dir) + local g,w=Session.resolve(game) + if not(g and integer(x)and integer(y)and w.map:inBounds(x,y))then return nil end + local _,fx,fy,distance=require('src.world.Ledge').approach(w.map,g.data,x,y,dir) + if not fx then return nil end + local delta=require('src.world.Collision').DELTA[dir] + local lx,ly=x+delta[1]*(distance or 2),y+delta[2]*(distance or 2) + if w.map:inBounds(lx,ly)then return lx,ly end +end +function Collision.isWarpMetatileBehavior(behavior) + return type(behavior)=='number'and((behavior>=0x60 and behavior<=0x6f)or behavior==0x71 or warpBehaviors[behavior]==true)or false +end +function Collision.warpAt(x,y) + local def=current();if not(def and Collision.inBounds(x,y))then return nil end + local cell=Collision.cell(x,y);local behavior=Collision.behaviorOn(def,x,y) + if not((cell>=0x60 and cell<=0x7f)or warpBehaviors[behavior])then return nil end + for _,warp in ipairs(def.warps or{})do if warp.x==x and warp.y==y then return warp end end +end +function Collision.resolveWarpDestination(game,warp) + local g,w=Session.resolve(game) + if not g or type(warp)~='table'then return nil,'no admitted native warp observation'end + local maps=Map._context(g);local found + local definitions,why=maps:definitions() + if not definitions then return nil,why end + for _,def in pairs(definitions)do + for _,row in ipairs(def.warps or{})do if row==warp then found=true;break end end + if found then break end + end + if not found then return nil,'foreign or retired warp observation'end + local native={};for k,v in pairs(warp)do native[k]=v end + native.destMap=warp.nativeDestination or warp.destMap + if not g.data.maps[native.destMap]then + local id=require('src.compat.gen1.MapIdentity').bind(g.data.maps,require('src.core.GameVersion').get()):nativeId(native.destMap) + if id then native.destMap=id end + end + local id,x,y=require('src.world.Warp').observeDestination(g.data,native,w.lastOutdoor,w.backupWarp,g.save) + if not id then return nil end + local def=maps:definition(id) + if not def then return nil,'warp destination is not an admitted source map'end + return def.id,x,y +end +function Collision.clear()runtime:release()end +Collision.release=Collision.clear +setmetatable(Collision,{__index=function(_,key) + if key=='_grid'then return runtime:grid()end + local def=current();if not def then return nil end + if key=='_mapDef'then return def end + if key=='_mapId'then return def.id end + local layout=def.midLayout + if key=='_widthCells'then return layout and layout.width or 0 end + if key=='_heightCells'then return layout and layout.height or 0 end + if key=='_warps'then + local out={};for _,warp in ipairs(def.warps or{})do + local active=Collision.warpAt(warp.x,warp.y);if active then out[warp.y*1024+warp.x]=active end + end + return out + end +end}) +return Collision diff --git a/src/core/game3/connections.lua b/src/core/game3/connections.lua new file mode 100644 index 000000000..aa74b8750 --- /dev/null +++ b/src/core/game3/connections.lua @@ -0,0 +1,77 @@ +-- Legacy map-view coordinates. Native connection loading remains independent; +-- its records must be converted at the map-view boundary, not aliased here. +local C = {} +local directions = { north = "north", south = "south", west = "west", east = "east", + up = "north", down = "south", left = "west", right = "east" } +function C.cardinal(direction) return directions[direction] end +local function connection(direction, value) + local dir = directions[direction] + local map = type(value) == "table" and (value.map or value.mapId) or value + if not dir or type(map) ~= "string" then return nil end + return { dir = dir, map = map, offset = type(value) == "table" and tonumber(value.offset) or 0 } +end +function C.each(def) + local source = def and def.connections + local out = {} + if type(source) ~= "table" then return out end + for _, row in ipairs(source) do + local converted = type(row) == "table" and connection(row.dir or row.direction, row) + if converted then out[#out + 1] = converted end + end + local keys = {} + for key in pairs(source) do if type(key) == "string" then keys[#keys + 1] = key end end + table.sort(keys) + for _, key in ipairs(keys) do + local converted = connection(key, source[key]) + if converted then out[#out + 1] = converted end + end + return out +end +function C.sizeOf(def) + local layout = def and def.midLayout + if layout and layout.width and layout.height then return layout.width, layout.height end + return (tonumber(def and def.width) or 0) * 2, (tonumber(def and def.height) or 0) * 2 +end +local function vertical(direction) return direction == "north" or direction == "south" end +function C.incoming(def, direction, x, y, destination) + direction = directions[direction] + if not direction then return nil end + local sw, sh = C.sizeOf(def) + for _, row in ipairs(C.each(def)) do + if row.dir == direction then + local target = destination(row.map) + if target then + local w, h = C.sizeOf(target) + local along, span, limit + if vertical(direction) then along, span, limit = x, w, sw else along, span, limit = y, h, sh end + local low = math.max(row.offset, 0) + local high = math.min(limit, span + row.offset) + if along >= low and along <= high then return row, target end + end + end + end +end +function C.localPos(row, x, y, sw, sh) + local w, h = C.sizeOf(row.def) + local offset = tonumber(row.offset) or 0 + local lx, ly + if row.dir == "north" then lx, ly = x - offset, h + y + elseif row.dir == "south" then lx, ly = x - offset, y - sh + elseif row.dir == "west" then lx, ly = w + x, y - offset + elseif row.dir == "east" then lx, ly = x - sw, y - offset + else return nil end + local along = vertical(row.dir) and lx or ly + local span = vertical(row.dir) and w or h + if along >= 0 and along < span then return lx, ly end +end +function C.atPos(rows, x, y, sw, sh) + for _, row in ipairs(rows or {}) do + local skip = row.dir == "north" and y >= 0 or row.dir == "south" and y < sh + or row.dir == "west" and x >= 0 or row.dir == "east" and x < sw + if not skip and row.def then + local lx, ly = C.localPos(row, x, y, sw, sh) + if lx then return row, lx, ly end + end + end +end +return C diff --git a/src/core/game3/constants.lua b/src/core/game3/constants.lua new file mode 100644 index 000000000..c5d113d2a --- /dev/null +++ b/src/core/game3/constants.lua @@ -0,0 +1,45 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Detached names/indices from the current native import, never another ROM. +local Version=require('src.core.GameVersion') +local Native=require('src.compat.gen1.NativeSession') +local C={} +local function enum(rows,prefix) + local names,ids={},{} + for name,id in pairs(rows)do + if type(name)=='string'and type(id)=='number'then names[(prefix or'')..name]=id;ids[id]=ids[id]or((prefix or'')..name)end + end + return{byName=names,byId=ids} +end +function C.of(edition) + edition=edition or Version.get();assert(edition==Version.get(),'foreign native constant dataset') + local g=Native.game();assert(g,'no native GBA constant dataset') + local data=g.data;local rows={} + local forms=data.field and data.field.playerForms or{} + for who,r in pairs(forms)do + if r.picIndex then + local frlg=edition=='firered'or edition=='leafgreen' + local name=who=='boy'and(frlg and'TRAINER_PIC_RED'or'TRAINER_PIC_BRENDAN')or who=='girl'and(frlg and'TRAINER_PIC_LEAF'or'TRAINER_PIC_MAY') + if name then rows[name]=r.picIndex end + end + end + -- Named special partners retain the picture identity on native trainer rows. + for _,r in pairs(data.trainers or{})do + if(r.name=='WALLY'or r.name=='STEVEN')and type(r.picIndex)=='number'then + local key='TRAINER_PIC_'..r.name + if rows[key]and rows[key]~=r.picIndex then error('ambiguous native trainer identity: '..r.name)end + rows[key]=r.picIndex + end + end + local sections={} + for id,name in pairs(data.constants.gen3MapSections or{})do + if type(name)=='table'then name=name.name end + if type(name)=='string'then sections['MAPSEC_'..name:upper():gsub('[^%w]+','_')]=id end + end + local out={game=edition,trainer_classes=enum(rows),region_map_sections=enum(sections)} + function out:raw(kind)local row=self[kind];return row and require('src.mods.Merge').deepCopy(row)end + function out:id(kind,name)local row=self[kind];return row and row.byName[name]end + function out:name(kind,id)local row=self[kind];return row and row.byId[id]end + return out +end +C.active=function()return C.of()end +return C diff --git a/src/core/game3/dataset.lua b/src/core/game3/dataset.lua new file mode 100644 index 000000000..2835a4df7 --- /dev/null +++ b/src/core/game3/dataset.lua @@ -0,0 +1,52 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Read imported artifacts through the native cache filesystem. Hydration, +-- importing and save writes remain with the native data/session owners. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local CacheFs=require('src.import.CacheFs') +local D={} +local records=setmetatable({},{__mode='k'}) +local current,currentHandle +local function retire(c)c.retired=true;c.game,c.data,c.root=nil,nil,nil end +local function valid(c) + local game=Native.game() + if c.retired then return false end + if game~=c.game or not game or game.data~=c.data or Version.get()~=c.edition or CacheFs.root()~=c.root then + retire(c);return false + end + return true +end +local function path(value) + if type(value)~='string'or#value>512 or value:find('[%c\\:]')or value:find('..',1,true) + or value:find('//',1,true)then return nil end + if not(value:match('^data/generated/[%w_./%-]+$')or value:match('^assets/generated/[%w_./%-]+$'))then return nil end + return value +end +function D.cache() + local game=Native.game();local data=game and game.data;local edition=Version.get();local root=CacheFs.root() + if current and current.game==game and current.data==data and current.edition==edition and current.root==root and not current.retired then + return currentHandle + end + if current then retire(current)end + local c={game=game,data=data,edition=edition,root=root,prefix=Version.cachePrefix()} + local h={};records[h]=c;current,currentHandle=c,h + function h:read(name) + local relative=path(name) + if not relative then return nil,'invalid imported artifact path'end + if not valid(c)then return nil,'native imported artifact cache retired'end + local bytes,why=CacheFs.dataFs().read(c.prefix..relative) + return type(bytes)=='string'and bytes or nil,why + end + function h:exists(name) + local relative=path(name) + if not relative or not valid(c)then return false end + local info=CacheFs.dataFs().getInfo(c.prefix..relative,'file') + return info~=nil and info.type=='file' + end + return h +end +D._service={release=function() + for _,c in pairs(records)do retire(c)end;current,currentHandle=nil,nil +end,poll=function()for _,c in pairs(records)do valid(c)end end} +require('src.core.game3.asset_stream').register(D._service) +return D diff --git a/src/core/game3/dex.lua b/src/core/game3/dex.lua new file mode 100644 index 000000000..8debe1ef5 --- /dev/null +++ b/src/core/game3/dex.lua @@ -0,0 +1,15 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Views=require('src.compat.gen1.SessionView') +local Ids=require('src.compat.gen1.BattleIds') +local D={} +local function query(dex,species,seen) + local g,s=Views.store(dex,'dex');if not g then return false end + local key=Ids.new(g.data):native('pokemon',species) + if not key then return false end + if seen then return not not(s.pokedex and s.pokedex.seen and s.pokedex.seen[key])end + return require('src.battle.CaughtMarker').caught(g,key) +end +function D.isCaught(dex,species)return query(dex,species,false)end +D.isOwned=D.isCaught +function D.isSeen(dex,species)return query(dex,species,true)end +return D diff --git a/src/core/game3/doors.lua b/src/core/game3/doors.lua new file mode 100644 index 000000000..b84e8b187 --- /dev/null +++ b/src/core/game3/doors.lua @@ -0,0 +1,46 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Door observations belong to the native world. Missing native artwork is +-- reported explicitly; a presentation consumer cannot create door timing. +local Native=require('src.compat.gen1.NativeSession') +local Map=require('src.core.game3.map') +local D={} +local function context(id) + local game,world=Native.resolve() + if not game then return nil,'no native door owner'end + local def=Map.currentDef() + if id~=nil and(not def or def.id~=id)then return nil,'door map is not current'end + return world,game +end +function D.getActiveAnim(id) + local world=context(id) + if not world then return nil end + -- Only a producer attached to this native world can expose a live pose. + local pose=world.gen3DoorAnimation + return pose and not pose.done and pose or nil +end +function D.getDoorEntryAt(id,x,y) + local world,game=context(id) + if not world then return nil,game end + local records=game.data.constants and game.data.constants.gen3Doors + if not records then return nil,'native door artwork has not been imported'end + local layout=Map.currentDef().midLayout + local mid=layout and layout:midAt(x,y) + return records.by_mid and records.by_mid[mid] +end +function D.open(id,x,y,opts,done) + local world,why=context(id) + if not world then return false,why end + if type(world.openDoorAt)~='function'then return false,'native door animation unavailable'end + return world:openDoorAt(x,y,opts,done) +end +function D.draw(x,y) + local world=context();local pose=world and world.gen3DoorAnimation + if not(pose and not pose.done and type(pose.draw)=='function')then return false,'no active native door artwork'end + return pose:draw(x,y) +end +return setmetatable(D,{__index=function(_,key) + local _,game=context() + if type(game)~='table'then return nil end + if key=='_manifest'then return game.data.constants and game.data.constants.gen3Doors end + if key=='SOUND_SLIDING'then return require('src.core.game3.se_ids').resolve('SE_DOOR')end +end}) diff --git a/src/core/game3/encounters.lua b/src/core/game3/encounters.lua new file mode 100644 index 000000000..f3869c8e7 --- /dev/null +++ b/src/core/game3/encounters.lua @@ -0,0 +1,69 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Detached, numeric-ID observations of native encounter tables. No random +-- draws, map loading, private cache installation or independent step rolls. +local Native=require('src.compat.gen1.NativeSession') +local Map=require('src.core.game3.map') +local Ids=require('src.compat.gen1.BattleIds') +local Encounter=require('src.world.Encounter') +local M={} +local function context() + local g=Native.game() + if g and type(g.data)=='table'and type(g.data.encounters)=='table'then return g end +end +function M.ensureLoaded() + local g=context() + return g~=nil and next(g.data.encounters)~=nil or false +end +function M.terrainAt(x,y) + local g,w=Native.resolve() + if not(g and type(x)=='number'and type(y)=='number'and x%1==0 and y%1==0 + and w.map:inBounds(x,y))then return nil end + if require('src.world.Gen3FieldRules').isUnderwaterEncounter(w.map,x,y) + or w.map:isWaterCell(x,y)then return 'water'end + if w.map:isEncounterCell(x,y)then return 'land'end +end +local function area(value,ids) + if value==nil then return nil end + if type(value)~='table'or type(value.slots)~='table'then return nil,'invalid native encounter area'end + local out={rate=value.rate,slots={}} + for i,slot in ipairs(value.slots)do + local species=type(slot)=='table'and ids:read('pokemon',slot.species) + if not species then return nil,'native encounter species has no cartridge index'end + out.slots[i]={species=species,level=slot.level,minLevel=slot.min or slot.minLevel, + maxLevel=slot.max or slot.maxLevel} + end + return out +end +function M.tableFor(id) + local g=context();if not(g and type(id)=='string')then return nil end + local maps=Map._context(g);local def=maps and maps:definition(id) + local raw=def and g.data.maps[def.nativeId] + if not raw then return nil end + local native=Encounter.forMap(g.data,raw,def.nativeId,nil,g.save) + if not native then return nil end + local ids=Ids.new(g.data);local out={} + for _,names in ipairs{{'land','grass'},{'water','water'},{'rocks','rock'},{'fishing','fish'}}do + local row,why=area(native[names[2]],ids) + if why then return nil,why end + out[names[1]]=row + end + return out +end +function M.rollSweetScent(id,terrain) + local g,w=Native.resolve();if not g or(terrain~='land'and terrain~='water')then return nil end + local maps=Map._context(g);local def=maps and maps:definition(id) + if not def or def.nativeId~=w.map.id then return nil end + local enc,roaming=Encounter.sweetScent(g.data,g.save,w.map.def,w.map.id,terrain=='water',true) + if not enc then return nil end + local ids=Ids.new(g.data);local out={species=ids:read('pokemon',enc.species),level=enc.level,roamer=roaming~=nil} + if roaming then + local individual=require('src.world.Gen3Roamers').active(g.save) + out.personality=individual.personality + if individual.ivs then out.ivs={};for a,b in pairs{hp='hp',atk='attack',def='defense',spe='speed',spa='spatk',spd='spdef'}do out.ivs[a]=individual.ivs[b]end end + if(individual.hp or 0)>0 then out.hp=individual.hp end + out.status=require('src.compat.gen1.PokemonView').status(individual) + end + return out +end +M.table_for=M.tableFor +return M diff --git a/src/core/game3/enhancements.lua b/src/core/game3/enhancements.lua new file mode 100644 index 000000000..519374d61 --- /dev/null +++ b/src/core/game3/enhancements.lua @@ -0,0 +1,21 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Features=require('src.mods.FeatureOptions') +local E={attach=Features.attach,enabled=Features.enabled} +function E.text(en,de) + local p=Features.provider('quickBall')or Features.provider('repelPrompt')or Features.provider('trainingDetails')or Features.provider('timedEncounters') + return p and Features.call(p,'language')=='de'and de or en +end +function E.encounterTable(mapId,source) + local g=require('src.compat.gen1.NativeSession').game();if not g then return source end + local maps=require('src.core.game3.map')._context(g);local def=maps and maps:definition(mapId) + if not def then return source end + local native=g.data.maps[def.nativeId] + if source and source.grass then return require('src.world.EncounterActivity').forMap(g.data,g.save,native,def.nativeId,source)end + local p=Features.provider('timedEncounters') + if not(p and source and source.land and Features.call(p,'outdoors',mapId)==true)then return source end + local out={};for k,v in pairs(source)do out[k]=v end + out.land=require('src.world.EncounterActivity').reweight(source.land,require('src.world.EncounterActivity').phase(Features.call(p,'time')), + require('src.core.game3.pokemon').national,g.data.constants.encounterActivity) + return out +end +return E diff --git a/src/core/game3/evolution.lua b/src/core/game3/evolution.lua new file mode 100644 index 000000000..94a2cd1f9 --- /dev/null +++ b/src/core/game3/evolution.lua @@ -0,0 +1,12 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local E={} +function E.levelTarget(value,session) + local mon,g=require('src.compat.gen1.SourcePokemon').resolve(value) + if session then + local r=require('src.compat.gen1.SessionView').record(session) + assert(r and r:valid()and r.owner==g,'foreign evolution session') + end + local key=require('src.pokemon.Evolution').pendingFor(g,mon,{kind='levelup'}) + return key and require('src.compat.gen1.BattleIds').new(g.data):read('pokemon',key) +end +return E diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua new file mode 100644 index 000000000..e4afdd6c6 --- /dev/null +++ b/src/core/game3/field.lua @@ -0,0 +1,50 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Per-mod field leases share native input admission; no global lock is erased. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local F={} +local cohort,leases +local function context() + local g,w,p=Native.resolve();if not g then cohort,leases=nil,nil;return end + if not cohort or cohort.game~=g or cohort.world~=w or cohort.player~=p or cohort.map~=w.map + or cohort.save~=g.save or cohort.data~=g.data or cohort.loader~=g.mods or cohort.edition~=Version.get()then + cohort={game=g,world=w,player=p,map=w.map,save=g.save,data=g.data,loader=g.mods,edition=Version.get()};leases={} + end + return cohort +end +local function owner() + local runtime=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return runtime and runtime.currentMod or facade and facade._callbackOwner() +end +function F.lock(tag) + local c=context();local who=owner();if not(c and who)then return false end + tag=tag or'default';assert(type(tag)=='string'and#tag<=128,'invalid field lock tag') + leases[who]=leases[who]or{};leases[who][tag]=true;return true +end +function F.unlock(tag) + local c=context();local who=owner();if not(c and who)then return false end + local held=leases[who];if held then held[tag or'default']=nil;if not next(held)then leases[who]=nil end end + return true +end +function F._locked(game,world) + local c=context();return c~=nil and(not game or game==c.game)and(not world or world==c.world)and next(leases)~=nil +end +function F.interact()return Boundary.invoke('field.interact')end +function F._dispatch(game,world,body) + local c=context();if not c or c.game~=game or c.world~=world then return body()end + return Boundary.dispatch('field.interact',body,function(...)return require('src.compat.gen1.SourceCall').call(F,'interact',...)end,require('src.core.Game3').forNative(game)) +end +local service={release=function()cohort,leases=nil,nil end, + releaseOwner=function(_,who)if leases then leases[who]=nil end end, + poll=function()if cohort then context()end end} +require('src.core.game3.asset_stream').register(service) +F._service=service +setmetatable(F,{__index=function(_,k)if k=='locked'then return F._locked()end end, + __newindex=function(t,key,value) + if key=='locked'then + assert(type(value)=='boolean','invalid field lock') + if value then F.lock()else local c=context();local who=owner();if c and who then leases[who]=nil end end + else rawset(t,key,value)end + end}) +return F diff --git a/src/core/game3/field_effects.lua b/src/core/game3/field_effects.lua new file mode 100644 index 000000000..d9552301d --- /dev/null +++ b/src/core/game3/field_effects.lua @@ -0,0 +1,40 @@ +-- Field artwork resources. Native effect tasks and phase observations are +-- separate contracts; no empty animation lists or substitute clocks here. +local Runtime=require('src.compat.gen1.FieldEffectRuntime') +local Assets=require('src.render.Assets') +local Stream=require('src.core.game3.asset_stream') +local service=Runtime.new() +local F={} +Stream.register(service) +Assets.register(function()service:invalidate()end) +function F.collectActors(actors,camX,camY) + local boundary=require('src.compat.gen1.ScriptBoundary') + if boundary.active('field.collectActors')then return boundary.invoke('field.collectActors')end + return require('src.compat.gen1.SourceActors').collect(actors) +end +local baseCollect=F.collectActors +function F._hasActorExtensions()return F.collectActors~=baseCollect end +function F.install(cache)return service:install(cache)end +function F.invalidate()service:invalidate()end +function F.release()service:release()end +function F.cancelOwner(who)service:releaseOwner(who)end +function F.manifest()return service:manifest()end +function F.manifestObject(name) + local manifest,why=F.manifest();if not manifest then return nil,why end + return manifest._byName[name] +end +function F.loadSheet(name)return service:get(name)end +F.getSpriteSheet=F.loadSheet +setmetatable(F,{__index=function(_,key) + if key=='_cache'then local c=service:current();return c and c.provider and c.provider.cache end + if key=='_sheets'then service:current();return service.loaded end + if key=='_manifest'then return F.manifest()end +end,__newindex=function(t,key,value) + if key=='_cache'then return F.install(value)end + if key=='_sheets'then + assert(type(value)=='table'and next(value)==nil,'field sheet cache replacement must be empty') + return F.invalidate() + end + rawset(t,key,value) +end}) +return F diff --git a/src/core/game3/field_move_show_mon.lua b/src/core/game3/field_move_show_mon.lua new file mode 100644 index 000000000..03306c53b --- /dev/null +++ b/src/core/game3/field_move_show_mon.lua @@ -0,0 +1,76 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source phases over the engine's actual field-move StateStack admission. +local Runtime=require('src.compat.gen1.FieldMoveRuntime') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local runtime=Runtime.new() +local S={} +local observers={} +local function call(name,...) + local facade=package.loaded['src.core.Game3'];local seams=package.loaded['src.mods.ModSeams'];local who + for i=#(seams and seams._journal or{}),1,-1 do + local v=seams._journal[i];if v.tbl==S and v.key==name and v.after==S[name]then who=v.owner;break end + end + if facade and facade._withCallbackOwner then return facade._withCallbackOwner(who or facade._callbackOwner(),S[name],...)end + return S[name](...) +end +function S.start(mon,opts,callback) + if Boundary.active('field_move.start')then + local c=runtime.starting;local ok,why=runtime:configure(c,mon,opts,callback) + if ok then Boundary.invoke('field_move.start')end + return ok,why + end + return runtime:start(mon,opts,callback) +end +function S.isActive()return runtime:current()~=nil end +function S.step()return Boundary.invoke('field_move.step')end +function S.draw() + if Boundary.active('field_move.draw')then return Boundary.invoke('field_move.draw')end + local c=runtime:current();if not c or not c.state:valid(false)then return false end + local result=c.state:drawExternal();c.manualDrawn=not c.state.externalDrawSerial;return result +end +function S.reset()return runtime:reset()end +function S.invalidate() + local c=runtime:current();if not c then return true end + return c.state:reloadArtwork() +end +function S._observeMod(who)if type(who)=='string'then observers[who]=true end end +function S.release()runtime:release();observers={}end +function S.cancelOwner(who)observers[who]=nil;return runtime:cancelOwner(who)end +function S._nativeOptions(game) + local host=package.loaded['src.mods.Runtime'];local exports=game.mods and game.mods.exports or{} + local active=false + for who in pairs(observers)do if exports[who]or host and host.currentMod==who then active=true;break end end + if not active then return end + local f=package.loaded['src.core.game3.field_effects'] + if require('src.compat.gen1.NativeSession').game()==game and f and f._cache + or game.data.constants and game.data.constants.gen3FieldMovePresentation then return{presentation='cartridge',pose=true}end +end +function S._nativeStart(state) + local c=runtime:bind(state);if not c then return end + if c.sourceStarted then return true end + local previous=runtime.starting;runtime.starting=c + local ok,why=pcall(runtime.phase,runtime,c,'start',function()return true end,function() + return call('start',state.mon,{pose=state.poseInstalled==true,noDuck=state.options.noDuck},c.continue) + end) + runtime.starting=previous + if not ok then error(why,0)end + return why +end +function S._nativeStep(state,body) + local c=runtime:bind(state);if not c then return body()end + c.manualDrawn=false + return runtime:phase(c,'step',body,function()return call('step')end) +end +function S._nativeDraw(state,body) + local c=runtime:bind(state);if not c then return body()end + if c.manualDrawn then c.manualDrawn=false;return true end + return runtime:phase(c,'draw',body,function()return call('draw')end) +end +local service={release=S.release,releaseOwner=function(_,who)return S.cancelOwner(who)end,poll=function() + local stale={};for state in pairs(runtime.records)do if not state:cohortCurrent()then stale[#stale+1]=state end end + for _,state in ipairs(stale)do state:retire()end +end} +require('src.core.game3.asset_stream').register(service);S._service=service +function S._nativeExit(state)runtime:exit(state)end +setmetatable(S,{__index=function(_,key)if key=='_fx'then return runtime:snapshot()end end}) +return S diff --git a/src/core/game3/field_moves.lua b/src/core/game3/field_moves.lua new file mode 100644 index 000000000..e77316054 --- /dev/null +++ b/src/core/game3/field_moves.lua @@ -0,0 +1,34 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Source=require('src.compat.gen1.SourcePokemon') +local Actions=require('src.world.WorldActions') +local F={} +local function moveKey(game,value) + local key=Ids.new(game.data):native('moves',value) + return key or type(value)=='string'and value:upper():gsub(' ','_')or nil +end +function F.hasBadge(ctx,badge) + local g=Native.game();if not(g and g.save)then return false end + local key=moveKey(g,badge);local gate=(g.data.constants.gen3FieldMoves or{})[key] + if not(gate and gate.badgeFlag)then return true end + return g.save.flags and g.save.flags[('FLAG_G3_%04X'):format(gate.badgeFlag)]==true or false +end +function F.partyMoveUser(party,move,ctx) + local g,w=Native.resolve();if not g then return nil,6 end + local key=moveKey(g,move);if not key then return nil,6 end + local mon=w.partyKnows and w:partyKnows(key) + if not mon then return nil,6 end + for i,value in ipairs(party or{})do + local raw,known=Source.resident(value) + if known and raw==mon then return value,i-1 end + end + return nil,6 +end +function F.fromMenu(move,ctx) + local g,w=Native.resolve();if not g then return{ok=false,text='No active field.'}end + local key=moveKey(g,move);if not key then return{ok=false,text='Unknown field move.'}end + local ok,why=Actions.useFieldAction(g,w,key:lower()) + return{ok=ok==true,text=why} +end +return F diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua new file mode 100644 index 000000000..f77e0dd8d --- /dev/null +++ b/src/core/game3/field_view.lua @@ -0,0 +1,236 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source field presentation over the admitted native world. No second map, +-- actor simulation, effect clock or save is constructed by this interface. +local Session=require('src.compat.gen1.NativeSession') +local Flash=require('src.world.Gen3Flash') +local Scope=require('src.render.GraphicsScope') +local Map=require('src.core.game3.map') +local Native=require('src.core.game3.tileset_native') +local M={cameraPanX=0,cameraPanY=0,_nativeDirty=true} +local tiles=require('src.compat.gen1.FieldTiles').new(M) +local cohort,active,manualRadius,palette,spanCache +local function currentOwner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner and facade._callbackOwner() +end +local function release() + tiles:release() + if palette then Native.resetSlotPalette(palette.ts,palette.slot)end + cohort,manualRadius,palette,spanCache=nil,nil,nil,nil + M.cameraPanX,M.cameraPanY=0,0 + M._nativeDirty=true;M._nativeLayout=nil;M._viewW,M._viewH=nil,nil +end +local function bind(value) + local game,world,player=Session.resolve(value) + if not game then release();return nil,world end + if game~=package.loaded['src.core.Game']then return nil,'field drawing belongs to the current native Game'end + if not cohort or cohort.game~=game or cohort.data~=game.data or cohort.save~=game.save + or cohort.loader~=game.mods or cohort.world~=world then + release();cohort={game=game,data=game.data,save=game.save,loader=game.mods,world=world,map=world.map,users={}} + elseif cohort.map~=world.map then + tiles:release();manualRadius=nil;cohort.map=world.map + if palette then Native.resetSlotPalette(palette.ts,palette.slot);palette=nil end + end + local who=currentOwner();if who then cohort.users[who]=true end + return game,world,player +end +local function record() + local game,why=bind();if not game then return nil,why end + local r=Flash.record(game.data);if not r then return nil,'imported Flash radii unavailable'end + return r +end +function M.flashRadii() + local r,why=record();if not r then return nil,why end + local out={};for i,value in ipairs(r.radii)do out[i-1]=value end;return out +end +function M.maxFlashLevel()local r,why=record();return r and r.maxLevel or nil,why end +function M.radiusForLevel(level) + local r,why=record();if not r then return nil,why end + level=tonumber(level)or 0 + return r.radii[level+1]or r.radii[1] +end +function M.setFlashLevel(level) + local game,why=bind();if not game then return false,why end + manualRadius=nil;return Flash.setLevel(game,level) +end +function M.getFlashLevel()local game=bind();return game and Flash.level(game)or 0 end +function M.setFlashRadius(radius) + local game,why=bind();if not game then return false,why end + radius=tonumber(radius) + if radius and(radius~=radius or math.abs(radius)==math.huge)then return false,'invalid Flash radius'end + manualRadius=radius;return true +end +M.setPyramidLightRadius=M.setFlashRadius +function M.animateFlashLevel(fromLevel,toLevel) + local game,why=bind();if not game then return false,why end + manualRadius=nil;Flash.setLevel(game,fromLevel);Flash.animateTo(game,toLevel) + -- The native update owns the one-pixel steps and its existing script wait. +end +function M.flashRadius() + local game=bind();if not game then return nil end + if manualRadius~=nil then return manualRadius end + if require('src.core.GameVersion').get()=='emerald'then + local pyramid=package.loaded['src.script.Gen3BattlePyramid'] + local radius=pyramid and pyramid.lightRadius(game) + if radius then return radius end + end + if Flash.level(game)==0 then return nil end + return tonumber(game.save.gen3FlashRadius)or Flash.radiusOf(game.data,Flash.level(game)) +end +function M.setCameraPanning(x,y) + local game,why=bind();if not game then return false,why end + x,y=tonumber(x)or 0,tonumber(y)or 0 + if x~=x or y~=y or math.abs(x)==math.huge or math.abs(y)==math.huge then return false,'invalid camera pan'end + M.cameraPanX,M.cameraPanY=x,y;return true +end +function M.defaultFlashLevel(value,mapId) + local game,why=bind(value);if not game then return nil,why end + local maps=Map.definitions(game);local def=maps and maps[mapId or Map.current] + local r=Flash.record(game.data);if not r then return nil,'imported Flash radii unavailable'end + if not def or tonumber(def.cave)==0 or def.cave==nil then return 0 end + return Flash.lit(game)and r.lit or r.dark +end +function M.setDefaultFlashLevel(value,mapId) + local level,why=M.defaultFlashLevel(value,mapId) + if level==nil then return false,why end + return M.setFlashLevel(level) +end +function M.setBgPaletteOverride(slot,words) + local game,why=bind();if not game then return false,why end + if palette and(words==nil or palette.slot~=slot)then + Native.resetSlotPalette(palette.ts,palette.slot);palette=nil + end + if words==nil then return true end + local def=Map.currentDef();local pair=def and(def.pair or def.midLayout.pair) + local ts=pair and Native.get(pair) + if not ts or not Native.setSlotPalette(ts,slot,words)then return false,'native palette unavailable'end + if palette and palette.ts~=ts then Native.resetSlotPalette(palette.ts,palette.slot)end + local copy={};for i=1,16 do copy[i]=words[i]or 0 end + palette={ts=ts,pair=pair,slot=slot,words=copy};return true +end +function M.flashSpans(radius,w,h,cx,cy) + return Flash.spans(radius,tonumber(w)or 240,tonumber(h)or 160,cx,cy) +end +function M.flashSpansFor(radius,w,h,cx,cy) + if not spanCache or spanCache.r~=radius or spanCache.w~=w or spanCache.h~=h or spanCache.cx~=cx or spanCache.cy~=cy then + spanCache={r=radius,w=w,h=h,cx=cx,cy=cy,spans=M.flashSpans(radius,w,h,cx,cy)} + end + return spanCache.spans +end +function M.invalidateCell()M._nativeDirty=true end +function M.invalidateLayoutCell()M._nativeDirty=true end +function M.trimNative(atlas,map,pair,visible) + local keep,busy={},{} + if pair then keep[pair]=true end + for key in pairs(visible or{})do keep[key]=true end + for key in pairs(tiles.atlases)do keep[key]=true end + for _,store in ipairs{tiles.under,tiles.over}do + for _,batch in pairs(store)do busy[batch:getTexture()]=true end + end + local x0,y0,x1,y1=map.warmRect() + for _,edge in ipairs(map.world or{})do + if not map.warmNear or map.warmNear(edge,x0,y0,x1,y1)then + local p=edge.def and(edge.def.pair or edge.def.midLayout.pair);if p then keep[p]=true end + end + end + return atlas.trim(keep,atlas.RESIDENT_MAX,busy) +end +function M.invalidate() + tiles:release();M._nativeDirty=true;M._nativeLayout=nil;spanCache=nil + Native.invalidate() + if palette then palette.ts=nil end +end +M.release=release +local function dimensions(value,default) + value=tonumber(value)or default + assert(value>0 and value= (layout.trueWidth or layout.width) + or y >= (layout.trueHeight or layout.height) +end +local function borderMid(layout, x, y) + return layout.borderMids[(y % layout.borderHeight) * layout.borderWidth + x % layout.borderWidth + 1] or 0 +end +function Layout:cellAt(x, y) + local row = self.overrides[y * 1024 + x] + if row then return row end + if outside(self, x, y) then return { mid = borderMid(self, x, y), coll = 255, elev = 0 } end + return self.cells[y * self.width + x + 1] or { mid = 0, coll = 255, elev = 0 } +end +local baseCellAt = Layout.cellAt +local function field(layout, x, y, key, missing, border) + if layout.cellAt ~= baseCellAt then return layout:cellAt(x, y)[key] end + local row = layout.overrides[y * 1024 + x] + if row then return row[key] end + if outside(layout, x, y) then + if border ~= nil then return border end + return borderMid(layout, x, y) + end + row = layout.cells[y * layout.width + x + 1] + if row then return row[key] end + return missing +end +function Layout:midAt(x, y) return field(self, x, y, "mid", 0) end +function Layout:collAt(x, y) return field(self, x, y, "coll", 255, 255) end +function Layout:elevAt(x, y) return field(self, x, y, "elev", 0, 0) end + +function Layout:collArray() + if self.width * self.height <= 0 then return nil end + local out = {} + for y = 0, self.height - 1 do + for x = 0, self.width - 1 do + local i = y * self.width + x + 1 + local override, row = self.overrides[y * 1024 + x], self.cells[i] + -- Padding stays blocked even if a caller stored an outside override. + out[i] = not outside(self, x, y) and (override and override.coll or row and row.coll or 255) or 255 + end + end + return out +end + +local function invalidate(layout, coordinates) + local view = package.loaded["src.core.game3.field_view"] + if not view then return end + if coordinates and #coordinates <= 128 and view.invalidateLayoutCell then + for i = 1, #coordinates, 2 do view.invalidateLayoutCell(layout, coordinates[i], coordinates[i + 1]) end + else view._nativeDirty = true end +end +function Layout:applyOverride(x, y, mid, coll, elev) + self._revision = (self._revision or 0) + 1 + x, y = tonumber(x) or 0, tonumber(y) or 0 + self.overrides[y * 1024 + x] = { mid = tonumber(mid) or 0, coll = coll ~= nil and coll or 255, elev = elev or 0 } + invalidate(self, { x, y }) +end +function Layout:setMetatiles(rows) + local coordinates = {} + for _, row in ipairs(rows or {}) do + local x, y = tonumber(row.x) or 0, tonumber(row.y) or 0 + local before = self:cellAt(x, y) + self.overrides[y * 1024 + x] = { + mid = tonumber(row.mid) or before.mid or 0, + coll = row.coll ~= nil and row.coll or before.coll, + elev = row.elev ~= nil and row.elev or before.elev or 0, + } + coordinates[#coordinates + 1] = x; coordinates[#coordinates + 1] = y + end + if #coordinates > 0 then self._revision = (self._revision or 0) + 1; invalidate(self, coordinates) end + return #coordinates / 2 +end +function Layout:stamp(source, ox, oy) + if type(source) ~= "table" or type(source.cells) ~= "table" then return 0 end + ox, oy = tonumber(ox) or 0, tonumber(oy) or 0 + local count = 0 + for y = 0, (source.trueHeight or source.height or 0) - 1 do + for x = 0, (source.trueWidth or source.width or 0) - 1 do + local row = source.cells[y * source.width + x + 1] + if row then + self.overrides[(oy + y) * 1024 + ox + x] = { mid = row.mid or 0, coll = row.coll ~= nil and row.coll or 255, elev = row.elev or 0 } + count = count + 1 + end + end + end + invalidate(self) + if count > 0 then self._revision = (self._revision or 0) + 1 end + return count +end +function Layout:clearOverrides() + if next(self.overrides) then self.overrides = {}; self._revision = (self._revision or 0) + 1 end + invalidate(self) +end + +function Layout:workerPacked() + local source = self._workerSource + if not source or self.cells ~= source.cells or self.width ~= source.width or self.height ~= source.height + or self.trueWidth ~= source.trueWidth or self.trueHeight ~= source.trueHeight + or self.cellAt ~= baseCellAt or self.midAt ~= Layout.midAt then return nil end + local assets = package.loaded["src.render.Assets"] + if assets and assets.loader and #assets.loader:overrideOrder() > 0 then return nil end + local overrides, borders = {}, {} + for key, row in pairs(self.overrides) do overrides[key] = { mid = row.mid } end + for i = 1, self.borderWidth * self.borderHeight do borders[i] = self.borderMids[i] end + return { blob = source.blob, off = 17 + source.blob:byte(15) * source.blob:byte(16) * 2, + width = self.width, trueWidth = self.trueWidth, trueHeight = self.trueHeight, + borderWidth = self.borderWidth, borderHeight = self.borderHeight, borderMids = borders, overrides = overrides } +end +return Layout diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua new file mode 100644 index 000000000..7754c337f --- /dev/null +++ b/src/core/game3/map.lua @@ -0,0 +1,129 @@ +-- Read-side compatibility entry point over the real native GBA map owner. +-- Simulation loads, player/script ownership and resource warmers are not +-- implemented by aliases to differently shaped native functions. +local Runtime=require('src.compat.gen1.MapRuntime') +local Graph=require('src.compat.gen1.MapGraph') +local Version=require('src.core.GameVersion') +local Map={WORLD_HOPS=2,WARM_MARGIN_SCREENS=1} +local context,gameOwner,edition,cache +local function rawGame(game) + local facade=package.loaded['src.core.Game3'] + return type(facade)=='table'and facade.native and facade.native(game)or game +end +local function currentGame() + local game=package.loaded['src.core.Game'] + return type(game)=='table'and game or nil +end +local function getContext(game) + game=rawGame(game or currentGame() or gameOwner) + local id=Version.get();local info=Version.info(id) + if not game or not info or info.generation~=3 then + if context then context:release()end + context,gameOwner,edition,cache=nil,nil,nil,nil + return nil,'no native GBA session' + end + if game~=gameOwner or id~=edition then + if context then context:release()end + gameOwner,edition,cache=game,id,nil + context=Runtime.new(function()return gameOwner end,function()return edition end) + end + return context +end +function Map.currentDef() + local runtime,why=getContext();if not runtime then return nil,why end + return runtime:current() +end +function Map.ensureMidLayout(game,id,def) + local runtime,why=getContext(game);if not runtime then return nil,why end + if def and not runtime:isOwned(def)then return nil,'native or foreign map definitions are not writable compatibility views' end + return runtime:layout(id,def) +end +function Map.definitions(game) + local runtime,why=getContext(game);if not runtime then return nil,why end + return runtime:definitions() +end +function Map.loadNeighborsDepth1(game,def) + local runtime,why=getContext(game);if not runtime then return {},{},why end + return runtime:neighbors(def) +end +function Map.computeWorld(maps,root,hops,reachW,reachH,ensure) + -- Custom definitions follow the established public dimensions. Native + -- source grids retain authored padding but connect at their true extent. + local runtime=context + local C=require('src.core.game3.connections') + local function size(def) + if runtime and runtime:isOwned(def)then return def.width,def.height end + return C.sizeOf(def) + end + return Graph.compute(maps,root,hops,reachW,reachH,ensure,size) +end +function Map.refreshWorld(game,reachW,reachH,root) + local runtime=getContext(game);if not runtime then cache=nil;return {}end + local def=runtime:current();root=root or(def and def.id) + local maps=runtime:definitions() + if not(root and maps and maps[root])then cache=nil;return {}end + reachW,reachH=math.floor(tonumber(reachW)or 0),math.floor(tonumber(reachH)or 0) + if cache and cache.root==root and cache.reachW==reachW and cache.reachH==reachH + and cache.def==maps[root] and cache.hops==Map.WORLD_HOPS then return cache.world end + local world=Map.computeWorld(maps,root,Map.WORLD_HOPS,reachW,reachH,function(id,owned) + runtime:layout(id,owned) + end) + cache={root=root,reachW=reachW,reachH=reachH,def=maps[root],hops=Map.WORLD_HOPS,world=world} + return world +end +function Map.worldMidAt(x,y,def) + def=def or Map.currentDef() + local runtime=getContext() + local function extent(target) + if runtime and runtime:isOwned(target)then return target.width,target.height end + local l=target and target.midLayout;return l and l.width or 0,l and l.height or 0 + end + return Graph.midAt(x,y,def,Map.neighborList,Map.world,extent) +end +function Map.loadedLayoutCount() + local runtime=getContext();return runtime and runtime:loadedCount()or 0 +end +function Map.overscanSlices() + local out={} + for _,row in ipairs(Map.neighborList)do out[#out+1]={dir=row.dir,mapId=row.map or row.mapId,offset=row.offset}end + return out +end +function Map.warmRect(margin) + local game=gameOwner or currentGame();local states=game and game.stack and game.stack.states + local ow + if states then for i=#states,1,-1 do if states[i].isOverworld then ow=states[i];break end end + elseif game then ow=game.overworld end + local p=ow and ow.player;if not(p and tonumber(p.cellX)and tonumber(p.cellY))then return nil end + local field=package.loaded['src.core.game3.field_view'];local display=package.loaded['src.core.game3.display'] + local cols=math.ceil(((field and field._viewW)or(display and display.W)or 240)/16) + local rows=math.ceil(((field and field._viewH)or(display and display.H)or 160)/16) + margin=margin or Map.WARM_MARGIN_SCREENS + local mx,my=math.ceil(cols/2)+1+cols*margin,math.ceil(rows/2)+1+rows*margin + return p.cellX-mx,p.cellY-my,p.cellX+mx,p.cellY+my +end +Map.warmNear=Graph.near +function Map.release() + if context then context:release()end + context,gameOwner,edition,cache=nil,nil,nil,nil +end +-- Internal typed boundary used by the distinct Game3 facade. +function Map._context(game)return getContext(game)end +setmetatable(Map,{__index=function(_,key) + if key=='current'then local def=Map.currentDef();return def and def.id end + if key=='_def'or key=='_currentDef'then return Map.currentDef()end + if key=='neighbors'or key=='neighborList'then + local runtime=getContext();if not runtime then return {}end + local dirs,list=runtime:neighbors();return key=='neighbors'and dirs or list + end + if key=='world'or key=='_worldRoot'or key=='_worldReachW'or key=='_worldReachH'then + local def=Map.currentDef() + if not def then cache=nil;return key=='world'and{}or nil end + if not cache or cache.def~=def then Map.refreshWorld(nil,0,0)end + if key=='world'then return cache and cache.world or{}end + if key=='_worldRoot'then return cache and cache.root end + if key=='_worldReachW'then return cache and cache.reachW end + return cache and cache.reachH + end + if key=='_loadedLayouts'then local runtime=getContext();return runtime and runtime:loadedLayouts()or{}end +end}) +return Map diff --git a/src/core/game3/mon_anim.lua b/src/core/game3/mon_anim.lua new file mode 100644 index 000000000..cf18d74d6 --- /dev/null +++ b/src/core/game3/mon_anim.lua @@ -0,0 +1,85 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Mod-owned presentation players reuse the native procedural motions and +-- imported frame strips. They never tick a battler or consume a command. +local Session=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Motion=require('src.pokemon.MonAnim') +local Pictures=require('src.pokemon.PicAnim') +local Version=require('src.core.GameVersion') +local M={nativeMotion=true} +local records=setmetatable({},{__mode='k'}) +local function owner() + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + return host and host.currentMod or facade and facade._callbackOwner() +end +local function retire(c) + c.game,c.data,c.loader,c.motion,c.pictures,c.ids=nil,nil,nil,nil,nil,nil;c.stopped=true +end +local function valid(c) + if not c or c.stopped then return false end + if Session.game()~=c.game or c.game.data~=c.data or c.game.mods~=c.loader or Version.get()~=c.edition or not c.ids:valid()then retire(c);return false end + return true +end +function M.enabled() + local game=Session.game();local record=game and game.data and game.data.constants and game.data.constants.gen3MonAnim + return type(record)=='table'and type(record.anims)=='table'and next(record.anims)~=nil or false +end +function M.newSprite(species) + local game=Session.game();if not(game and game.data)then return nil,'no native Pokemon data'end + local ids=Ids.new(game.data);local key=ids:native('pokemon',species) + if not key then return nil,'unknown cartridge species'end + local sprite={frames=0} + records[sprite]={game=game,data=game.data,loader=game.mods,edition=Version.get(),key=key,owner=owner(),ids=ids} + return sprite +end +function M.summary(sprite,species,oneFrame) + local c=records[sprite];if not valid(c)then return nil,'native presentation player retired'end + if species~=nil and Ids.new(c.data):native('pokemon',species)~=c.key then return nil,'presentation species changed'end + c.motion=Motion.new(c.data,c.key) + if c.motion then c.motion:restart()end + c.pictures=not oneFrame and Pictures.new(c.data,c.key)or nil + if c.pictures then c.pictures:start()end + sprite.frames=0;return sprite +end +function M.step(sprite) + local c=records[sprite];if not valid(c)then return false,'native presentation player retired'end + if c.motion then c.motion:update(1/60)end + if c.pictures then c.pictures:update(1/60)end + sprite.frames=sprite.frames+1;return true +end +function M.done(sprite) + local c=records[sprite] + return not valid(c)or(not c.motion or not c.motion:active()) + and(not c.pictures or c.pictures.paused or c.pictures.steps==nil) +end +function M.busy(sprite)return not M.done(sprite)end +function M.stop(sprite)local c=records[sprite];if c then retire(c)end end +function M.transform(sprite) + local c=records[sprite];local t=valid(c)and c.motion and c.motion:transform()or Motion.IDENTITY + local tint=t.tint;local color,coeff=0,0 + if tint then + color=math.floor(tint[1]*31+.5)+math.floor(tint[2]*31+.5)*32+math.floor(tint[3]*31+.5)*1024 + coeff=(tint[4]or 0)*16 + end + return{x2=t.dx,y2=t.dy,sx=t.sx,sy=t.sy,rotation=t.rot,alpha=t.alpha, + invisible=t.alpha==0,frame=valid(c)and c.pictures and c.pictures:frame()or 0, + blendColor=color,blendCoeff=coeff} +end +function M.framePic(species,frame,shiny) + if type(frame)~='number'or frame%1~=0 or frame<=0 then return nil end + local game=Session.game();if not(game and game.data)then return nil end + local key=Ids.new(game.data):native('pokemon',species);if not key then return nil end + local mon={species=key,isShiny=shiny==true} + local record=Pictures.record(game.data,key,mon) + if shiny and record and not record.shinySheet then return nil,'native shiny frame strip unavailable'end + -- Borrow the native frame cache. Only its native owner may retire it. + local image=Pictures.frameImage(record,frame,mon) + if not image then return nil end + local w,h=image:getDimensions();return{image=image,w=w,h=h} +end +local service={release=function()for _,c in pairs(records)do retire(c)end end, + poll=function()for _,c in pairs(records)do valid(c)end end, + releaseOwner=function(_,who)for _,c in pairs(records)do if c.owner==who then retire(c)end end end} +M._service=service +require('src.core.game3.asset_stream').register(service) +return M diff --git a/src/core/game3/move_learn.lua b/src/core/game3/move_learn.lua new file mode 100644 index 000000000..3d5a94657 --- /dev/null +++ b/src/core/game3/move_learn.lua @@ -0,0 +1,17 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Source=require('src.compat.gen1.SourcePokemon') +local Ids=require('src.compat.gen1.BattleIds') +local M={} +function M.relearnableMoves(value) + local mon,g=Source.resolve(value);local d=g.data.pokemon[mon.species];local ids=Ids.new(g.data) + local known,seen,out={},{},{} + for _,m in ipairs(mon.moves or{})do known[m.id]=true end + local function add(key) + local n=ids:read('moves',key) + if n and not known[key]and not seen[key]then out[#out+1]=n;seen[key]=true end + end + for _,key in ipairs(d.level1Moves or{})do add(key)end + for _,row in ipairs(d.learnset or{})do if row.level<=mon.level then add(row.move)end end + return out +end +return M diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua new file mode 100644 index 000000000..688fae650 --- /dev/null +++ b/src/core/game3/objects.lua @@ -0,0 +1,126 @@ +-- Compatibility facade over native NPCs; all motion stays on the native queue. +local Runtime=require('src.compat.gen1.ObjectRuntime') +local Identity=require('src.compat.gen1.MapIdentity') +local Player=require('src.core.game3.player') +local runtime=Runtime.new() +local Objects={PLAYER_LOCAL_ID=0xFF} +function Objects.isPlayer(id) id=tonumber(id); return id==0xFF or id==0x800F end +function Objects.find(id) + if Objects.isPlayer(id) then return runtime:current() and Player or nil end + for _,view in ipairs(runtime:pool() or {}) do if view.localId==tonumber(id) then return view end end +end +function Objects.findObjectByLocalIdAndMap(id,group,number) + if group==nil and number==nil or Objects.isPlayer(id) then return Objects.find(id) end + local c=runtime:current(); if not c then return nil end + local mapId=Identity.lookup(c.edition,tonumber(group),tonumber(number)) + if not mapId then return nil end + for _,view in ipairs(runtime:pool(mapId) or {}) do if view.localId==tonumber(id) then return view end end +end +function Objects.layoutBounds(def) + local l=def and def.midLayout + if l and (l.width or 0)>0 and (l.height or 0)>0 then return {w=l.width,h=l.height} end +end +function Objects.offMap(bounds,eo) + return bounds~=nil and (eo.cellX<0 or eo.cellY<0 or eo.cellX>=bounds.w or eo.cellY>=bounds.h) or false +end +function Objects.forDraw() + local out={}; local list,c=runtime:pool(); if not list then return out end + for _,v in ipairs(list) do + if v.visible and not(v.hidden or v.invisible) and (v.moving or v.scriptBusy or c.map:inBounds(v.cellX,v.cellY)) then out[#out+1]=v end + end + return out +end +function Objects.listActive(_mod,game,mapId) + local ids={} + for _,v in ipairs(runtime:pool(mapId,game) or {}) do if v.visible and not v.hidden then ids[#ids+1]=v.localId end end + return ids +end +function Objects.at(x,y) + if not(Runtime.integer(x,-16777216,16777216) and Runtime.integer(y,-16777216,16777216)) then return nil end + for _,v in ipairs(runtime:pool() or {}) do + if v.visible and not v.hidden and (v.moving and v.targetX or v.cellX)==x + and (v.moving and v.targetY or v.cellY)==y then return v end + end +end +function Objects.elevationsCompatible(a,b) a,b=tonumber(a) or 0,tonumber(b) or 0; return a==0 or b==0 or a==b end +function Objects.blocks(x,y,except,elevation) + for _,v in ipairs(runtime:pool() or {}) do + if v.localId~=tonumber(except) and v.visible and not(v.hidden or v.passable) + and Objects.elevationsCompatible(elevation,v.currentElevation) then + if v.cellX==x and v.cellY==y or v.moving and v.targetX==x and v.targetY==y then return true end + end + end + return false +end +function Objects.playerBlocks(x,y,elevation) + local c=runtime:current(); local p=c and c.player + return p~=nil and Objects.elevationsCompatible(elevation,p.elevation) + and (p.cellX==x and p.cellY==y or p.moving==true and p.targetX==x and p.targetY==y) or false +end +function Objects.walkPhase(view) return view==Player and Player.walkPhase() or runtime:walkPhase(view) end +function Objects.scriptFace(view,dir) + if not Runtime.directions[dir] then return false end + if view==Player then if not runtime:current() then return false end; Player.facing=dir; return true end + local e=runtime:actor(view); if not e or e.facingLocked then return false end + e.facing=dir; return true +end +function Objects.turnObject(id,dir) + dir=({[1]='down',[2]='up',[3]='left',[4]='right'})[dir] or dir + return Objects.scriptFace(Objects.find(id),dir) +end +function Objects.hideObjectAt(id,g,n) + local v=Objects.findObjectByLocalIdAndMap(id,g,n); if not v then return false end + v.visible=false; return true +end +function Objects.showObjectAt(id,g,n) + local v=Objects.findObjectByLocalIdAndMap(id,g,n); if not v then return false end + v.visible=true; return true +end +function Objects.hideObject(id) return Objects.hideObjectAt(id) end +function Objects.showObject(id) return Objects.showObjectAt(id) end +function Objects.freeze(id) + local v=Objects.find(id); if not v or v==Player then return false end; v.frozen=true; return true +end +function Objects.unfreeze(id) + local v=Objects.find(id); if not v or v==Player then return false end; v.frozen=false; return true +end +function Objects.setMovementType(id,mt) + local v=Objects.find(id); local e,c=runtime:actor(v) + if not e or not Runtime.integer(mt,0,255) or not(c.data.constants.gen3MovementTypes or {})[mt] then return false end + return e:setMovementType(c.data,mt) +end +function Objects.facePlayer(id) + local e,c=runtime:actor(Objects.find(id)); if not e then return false end + e:facePlayer(c.player); return true +end +function Objects.updateElevation(view) + local e,c,mapId=runtime:actor(view); if not e then return false end + local map=mapId==c.map.id and c.map + if not map then for _,nb in ipairs(c.world.neighbors or {}) do if nb.map.id==mapId then map=nb.map; break end end end + if not map then return false end; e:updateElevation(map); return true +end +function Objects.loadMap(game,mapId) + local c,why=runtime:current(game); if not c then return nil,why end + if mapId~=c.id then return nil,'object loading belongs to the native map transition' end + local list,why=runtime:pool(mapId,game) + if not list then return nil,why end + return list +end +function Objects.clear() runtime:release() end +Objects.reset=Objects.clear +Objects.release=Objects.clear +-- Internal shared observer for the separate, map-local neighbour facade. +function Objects._pool(mapId) + local list,why=runtime:pool(mapId) + if not list then return nil,why end + return list +end +setmetatable(Objects,{__index=function(_,key) + if key=='_mapId' then local c=runtime:current(); return c and c.id end + if key=='_byId' or key=='_order' then + local byId,order={},{} + for _,v in ipairs(runtime:pool() or {}) do byId[v.localId]=v; order[#order+1]=v.localId end + return key=='_byId' and byId or order + end +end}) +return Objects diff --git a/src/core/game3/ow_sprites.lua b/src/core/game3/ow_sprites.lua new file mode 100644 index 000000000..b64e3ae97 --- /dev/null +++ b/src/core/game3/ow_sprites.lua @@ -0,0 +1,112 @@ +-- Original-order sprite interface backed by the native dataset. This facade +-- owns its resources and has no private actor, frame or extraction loop. +local Runtime=require('src.compat.gen1.SpriteRuntime') +local Pose=require('src.compat.gen1.SpritePose') +local Session=require('src.compat.gen1.NativeSession') +local Stream=require('src.core.game3.asset_stream') +local Assets=require('src.render.Assets') +local runtime=Runtime.new() +local S={} +Stream.register(runtime) +Assets.register(function()runtime:invalidate()end) +function S.install(cache)return runtime:install(cache)end +function S.invalidate()runtime:invalidate()end +function S.release()runtime:release()end +function S.ready()return runtime:ready()end +function S.get(id)return runtime:get(id)end +function S.getDraw(id)return runtime:getDraw(id)end +function S.getReflectionDraw(id)return runtime:reflection(id)end +function S.prefetch(id,priority)return runtime:prefetch(id,priority)end +function S.peekDraw(id) + runtime:current();id=tonumber(id) + if runtime.loaded[id]or runtime.overrides[id]then return runtime:getDraw(id)end + runtime:prefetch(id,0);return nil +end +function S.setObjectPalette(id,key,colors,from)return runtime:setPalette(id,key,colors,from)end +function S.clearObjectPalette(id)return runtime:clearPalette(id)end +function S.objectPaletteKey(id) + runtime:current();local o=runtime.overrides[tonumber(id)];return o and o.key +end +S.pose=Pose.pose +S.fieldMoveFrame=Pose.fieldMoveFrame +S.fishingFrame=Pose.fishingFrame +S.fishingAbsFrame=Pose.fishingAbsFrame +S.fishingOffset=Pose.fishingOffset +S.avatarState=Pose.avatarState +function S.avatars()local manifest=runtime:manifest();return manifest and manifest.avatars end +function S.avatarGraphicsId(state,female,avatars,who)return Pose.avatarGraphicsId(state,female,avatars or S.avatars(),who)end +function S.playerGraphicsId(game,provided) + local native,why=Session.game(game);if game~=nil and not native then return nil,why end + local _,world,player=Session.resolve(native) + local observed=player and{ + fieldMoveAnim=player.fieldMove and 1 or 0,underwater=world.map.def.mapType=='UNDERWATER', + fishing=player.fishing,surfing=player.surfing,dismounting=player.dismounting,flyRide=player.flyRide, + biking=native.save and native.save.onBike,bikeType=player.acroBike and'acro'or native.save and native.save.bikeType, + watering=player.watering, + }or{} + local source=provided or package.loaded['src.core.game3.player'] + local p=source and setmetatable({},{__index=function(_,key) + local value=source[key];if value~=nil then return value end;return observed[key] + end})or observed + local session=type(game)=='table'and game.session + local save=type(game)=='table'and game.save or native and native.save + local gender=session and session.gender or save and(save.gender or save.player and save.player.gender) + local female=gender==1 or gender=='female'or gender=='F' + local avatars=S.avatars() + if avatars then return Pose.avatarGraphicsId(Pose.avatarState(p),female,avatars)end + -- FRLG's published interface uses these cartridge graphics constants when + -- no avatar-state table exists. Hoenn always uses its imported state table. + local c=runtime:current();if not c or(c.edition~='firered'and c.edition~='leafgreen')then return nil end + local base=female and 7 or 0;local state=Pose.avatarState(p) + if state=='FIELD_MOVE'then return base+(p.fieldMoveKind=='vs_seeker_bike'and 6 or 3)end + return base+(({FISHING=4,SURFING=2,MACH_BIKE=1,ACRO_BIKE=1})[state]or 0) +end +function S.draw(id,px,py,camX,camY,facing,phase,flip,opts) + local sprite=S.getDraw(id);if not sprite then return false end + local frame,mirror=S.pose(sprite,facing,phase,flip,opts);local quad=sprite.quads[frame] + if not quad then return false end + local offX=sprite.drawOffX or 0;if mirror then offX=-offX end + local x=px-camX+(16-sprite.width)/2+offX + local y=py-camY+16-sprite.height+(sprite.drawOffY or 0) + love.graphics.setColor(1,1,1,opts and opts.alpha or 1) + love.graphics.draw(sprite.image,quad,x+(mirror and sprite.width or 0),y,0,mirror and -1 or 1,1) + if opts and opts.alpha then love.graphics.setColor(1,1,1,1)end + return true +end +-- Only the provider protocol is public. Its closure never exposes the native +-- game/dataset, owner sets or partially constructed GPU resources. +local stream={} +function stream:prefetch(id,priority)return S.prefetch(id,priority)end +function stream:get(id)return S.get(id)end +function stream:poll()runtime:poll()end +function stream:step(budget)runtime:step(budget)end +function stream:cancel(id) + if id==nil then runtime:cancelPending();return true end + local job=runtime.jobs[tonumber(id)];if not job then return false end;runtime:cancelJob(job);return true +end +function stream:retain(wanted) + for id,job in pairs(runtime.jobs)do if not wanted[id]then runtime:cancelJob(job)end end +end +function stream:shutdown()S.release()end +setmetatable(stream,{__index=function(_,key) + if key=='pending'then local out={};for id in pairs(runtime.jobs)do out[id]=true end;return out end +end}) +setmetatable(S,{ + __index=function(_,key) + runtime:current() + if key=='_loaded'then return runtime.loaded end + if key=='_overrides'then return runtime.overrides end + if key=='_reflectionLoaded'then return runtime.reflections end + if key=='_stream'then return stream end + if key=='_manifest'then return runtime:manifest()end + if key=='errors'then return runtime.errors end + if key=='_cache'then local lease=runtime.providers[#runtime.providers];return lease and lease.cache end + if key=='_root'then local c=runtime:current();return c and c.root end + end, + __newindex=function(t,key,value) + if key=='_cache'then runtime:install(value) + elseif key=='_loaded'then assert(value==nil or type(value)=='table','invalid sprite cache table');runtime:invalidate();runtime.loaded=value or{} + else rawset(t,key,value)end + end, +}) +return S diff --git a/src/core/game3/player.lua b/src/core/game3/player.lua new file mode 100644 index 000000000..7dff04370 --- /dev/null +++ b/src/core/game3/player.lua @@ -0,0 +1,142 @@ +-- Compatibility avatar operations on the live native actor. No second player, +-- collision simulator, event runner or on-disk save writer lives here. +local Session = require('src.compat.gen1.NativeSession') +local runtime = require('src.compat.gen1.PlayerRuntime').new() +local Player = {} +local directions = {up={0,-1},down={0,1},left={-1,0},right={1,0}} +local direct = {} +for name in ('cellX cellY px py facing moving progress targetX targetY turnTimer turnArmed animClock running surfing fishing spinning moveDir facingLocked'):gmatch('%S+') do direct[name]=name end +direct.currentElevation='elevation'; direct.elevation='elevation' +direct.walkInPlace='compatWalkInPlace';direct.walkInPlaceFast='compatWalkInPlaceFast' +local function owner(game) + local c=runtime:scope() + if not game and c then game=c.game end + return Session.resolve(game) +end +local function inputOwner(game) + local g,w,p=owner(game) + if not g or not Session.acceptsInput(g,w,p) then return nil,'native field input is locked' end + return g,w,p +end +local function finite(n)return type(n)=='number' and n==n and math.abs(n)0 then return nil end + return p:walkPhase() +end +function Player.drawFlip() + local g,_,p=owner();return g and math.floor((p.animClock or 0)/16)%2==1 or false +end +function Player.startFieldMove(duration,kind)return require("src.compat.gen1.PlayerPose").start(duration,kind)end +function Player.release()runtime:release()end +setmetatable(Player,{ + __index=function(_,key) + local g,w,p=owner();if key=="running"then return g~=nil and p.running==true end + if not g then return nil end + if direct[key]then return p[direct[key]]end + if key=='stepFrames'then return p.stepFramesCur or p.stepFrames end + if key=='biking'then return g.save and g.save.onBike==true end + if key=='bikeType'then return p.acroBike and 'acro' or(g.save and g.save.bikeType)end + if key=='underwater'then return w.map.def.mapType=='UNDERWATER'end + if key=='jumping'then return (p.hopFrames or 0)>0 end + if key=='visible'then return Player.isVisible()end + if key=='fieldMoveAnim'then return p.fieldMove and(p.fieldMoveFrames or 1)or 0 end + if key=='stepSerial'or key=='prevCellX'or key=='prevCellY'then return runtime:record(p)[key]end + end, + __newindex=function(t,key,value) + if not(direct[key]or key=='stepFrames'or key=='biking'or key=='visible')then rawset(t,key,value);return end + local g,_,p=owner();if not g then error('no active native player for '..key,2)end + if type(value)=='number' and not finite(value)then error('non-finite player '..key,2)end + if direct[key]then p[direct[key]]=value + elseif key=='stepFrames'then + assert(finite(value)and value>=1 and value%1==0,'invalid player step frames');p.stepFramesCur=value + elseif key=='biking'then g.save.onBike=value==true + elseif key=='visible'then Player.setVisible(value)end + end, +}) +return Player diff --git a/src/core/game3/pokecenter_heal.lua b/src/core/game3/pokecenter_heal.lua new file mode 100644 index 000000000..1e57e1952 --- /dev/null +++ b/src/core/game3/pokecenter_heal.lua @@ -0,0 +1,16 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native healing keeps its task, sound and continuation; these are sprite reads. +local Native=require('src.compat.gen1.NativeSession') +local Presentation=require('src.world.Gen3HealingPresentation') +local M={} +function M.isActive()local _,world=Native.resolve();return world~=nil and world.healAnim~=nil end +function M.spriteSnapshot() + local game,world,player=Native.resolve() + if not(game and world.healAnim)then return{}end + local sprites=Presentation.snapshot(game,world,world.healAnim)or{} + -- The published source snapshot is anchored at the native 240x160 player's + -- (112,72), independently of the window or a source supersampling texture. + for _,sprite in ipairs(sprites)do sprite.x=sprite.x-player.px+112;sprite.y=sprite.y-player.py+72 end + return sprites +end +return M diff --git a/src/core/game3/pokemon.lua b/src/core/game3/pokemon.lua new file mode 100644 index 000000000..00a554e6e --- /dev/null +++ b/src/core/game3/pokemon.lua @@ -0,0 +1,205 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Cartridge-numbered observations over native imported Pokemon data and art. +-- This module does not create another party, save, extractor or battle engine. +local Native=require('src.compat.gen1.NativeSession') +local Ids=require('src.compat.gen1.BattleIds') +local Pokemon=require('src.pokemon.Pokemon') +local Sprites=require('src.pokemon.Sprites') +local Assets=require('src.render.Assets') +local Version=require('src.core.GameVersion') +local P={} +local current,cache,users=nil,{},{} +local function release() + for _,entry in pairs(cache)do if entry.image and entry.image.release then entry.image:release()end end + current,cache,users=nil,{},{} +end +local function context() + local game=Native.game() + if not(game and game.data and type(game.data.pokemon)=='table')then release();return end + if not current or current.game~=game or current.data~=game.data or current.loader~=game.mods or current.edition~=Version.get()or not current.ids:valid()then + release();current={game=game,data=game.data,loader=game.mods,edition=Version.get(),ids=Ids.new(game.data)} + end + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local who=host and host.currentMod or facade and facade._callbackOwner() + if who then users[who]=true end + return current +end +local function copy(value,seen) + if type(value)~='table'then return type(value)~='function'and value or nil end + seen=seen or{};if seen[value]then return seen[value]end + local out={};seen[value]=out;for k,v in pairs(value)do out[k]=copy(v,seen)end;return out +end +local function species(value) + local c=context();if not c then return end + local key=c.ids:native('pokemon',value) + return key and c.data.pokemon[key],key,c +end +function P.unownLetter(personality) + local n=tonumber(personality)or 0 + assert(n==n and n>=0 and n<=4294967295 and n%1==0,'invalid personality') + local value=0 + for shift=24,0,-8 do value=value*4+math.floor(n/2^shift)%4 end + return value%28 +end +function P.ready()return context()~=nil end +function P.speciesOf(mon) + if type(mon)~='table'then return nil end + local _,key,c=species(mon.species) + return key and c.ids:read('pokemon',key) +end +function P.friendshipOf(mon) + if type(mon)~='table'then return 0 end + local raw,known=require('src.compat.gen1.SourcePokemon').resident(mon) + if known then return raw and(raw.happiness or 0)or 0 end + local value=tonumber(mon.friendship or mon.happiness) + if value then return value end + local row=species(mon.species);return row and(row.friendship or 70)or 0 +end +function P.setFriendship(mon,value,event,reason) + if type(mon)~='table'then return 0 end + value=tonumber(value)or 0 + assert(value==value and math.abs(value)0 and R.Random()%n or 0 end +function R.SeedRng(seed) + assert(type(seed)=='number'and seed==seed and math.abs(seed)=240 and graphics<=255 then slot=graphics-240 end + if slot then + local value=store.vars[0x4010+slot] + if value==nil then return nil end + graphics=value%256 + end + local meta=c.data.gen3_object_sheets + if graphics and meta and graphics>=meta.count then + return nil,'object graphics id outside the native cartridge catalogue' + end + return graphics +end +function Space.resolveObjectSprite(obj) + if obj and type(obj.sprite)=='string'and obj.sprite~=''then return obj.sprite end + local id=Space.resolveObjectGraphicsId(obj) + return id and('SPRITE_G3_%03d'):format(id) +end +function Space.neighborObjectState(mapId)return runtime:neighbor(mapId)end +function Space.refreshObjectGraphics() + local c=current();if not(c and c.world)then return false end + if c.preview.vmSet or c.preview.storeSet then return false,'source preview lease is active'end + if c.world.syncObjectVisibility then c.world:syncObjectVisibility()end + local seen={} + local function refresh(npc) + if npc and not seen[npc]and npc.refreshSprite then seen[npc]=true;npc:refreshSprite(c.data)end + end + for _,npc in pairs(c.world.npcPool or{})do refresh(npc)end + for _,npc in ipairs(c.world.npcs or{})do refresh(npc)end + return true +end +function Space.startScript(key,localId,facing) + local c=current();if not(c and c.world)then return false,'no native field runner'end + facing=facing or c.world.player.facing + return runtime:start(key,localId or 0,facing) +end +function Space.install(mod) + local bundle,why=Space.ensureBundle();if not bundle then return nil,why end + Space._mod=mod;return bundle +end +function Space.release()runtime:release()end +function Space.cancelOwner(who)runtime:cancelOwner(who)end +function Space._nativePhase(name,game,world,mapId,body) + return runtime:phase(name,game,world,mapId,body,function(...) + local c=current(game,world) + if name=='runOnFrame'and c then c.pendingOnFrame=nil end + return attributed(name,Space[name],...) + end) +end +function Space._nativeTick(runner,body)return runtime:tick(runner,body)end +setmetatable(Space,{ + __index=function(_,key) + local c=current();if not c then return key=='active'and false or nil end + if key=='active'then return c.world~=nil and c.vm~=nil and not c.disabled end + if key=='mapId'then return c.id end + if key=='vm'then if c.disabled then return nil end;if c.preview.vmSet then return c.preview.vm end;return c.vm end + if key=='store'then if c.preview.storeSet then return c.preview.store end;return c.store end + if key=='bundle'then return c.dataset.override or c.bundle end + if key=='_pendingOnFrame'then return c.pendingOnFrame==true end + end, + __newindex=function(t,key,value) + if key=='vm'or key=='store'or key=='bundle'then + local c,why=current();assert(c,why) + assert(value==nil or type(value)=='table','invalid source script replacement') + if key=='bundle'then + assert(c.dataset.overrideOwner==nil or c.dataset.overrideOwner==runtime:owner(),'script bundle replacement belongs to another mod') + c.dataset.override=value~=c.bundle and value or nil + c.dataset.overrideOwner=c.dataset.override and runtime:owner()or nil + else + local who=runtime:owner() + assert(c.preview[key..'Owner']==nil or c.preview[key..'Owner']==who,'script preview lease belongs to another mod') + local original=c[key];c.preview[key]=value;c.preview[key..'Set']=value~=original + c.preview[key..'Owner']=c.preview[key..'Set']and who or nil + end + elseif key=='_pendingOnFrame'then local c=current();if c then c.pendingOnFrame=value==true end + elseif key=='active'or key=='mapId'then error('script field belongs to its native owner: '..key,2) + else rawset(t,key,value)end + end, +}) +return Space diff --git a/src/core/game3/se_ids.lua b/src/core/game3/se_ids.lua new file mode 100644 index 000000000..6fb632593 --- /dev/null +++ b/src/core/game3/se_ids.lua @@ -0,0 +1,20 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Named imported sequences win; the shared cartridge UI constants retain +-- their edition-specific numbering even before an audio cache is mounted. +local S={} +local Version=require('src.core.GameVersion') +function S.resolve(id) + if type(id)=='number'then return id end + local Native=require('src.compat.gen1.NativeSession');local g=Native.game() + if g then + for _,song in pairs(g.data and g.data.audio and g.data.audio.songs or{})do + if type(song)=='table'and song.sfxName==id then return song.index end + end + end + return tonumber(id)or S[id] +end +setmetatable(S,{__index=function(_,key) + if key=='SE_SUCCESS'then return Version.isFRLG()and 25 or 31 end + if key=='SE_SELECT'then return 5 end +end}) +return S diff --git a/src/core/game3/tileset_anim.lua b/src/core/game3/tileset_anim.lua new file mode 100644 index 000000000..2d5959598 --- /dev/null +++ b/src/core/game3/tileset_anim.lua @@ -0,0 +1,91 @@ +-- Animation observes the existing native clock. It never advances the game, +-- source scripts, RNG or a second terrain timer. +local A={_visible={}} +local function native()return require('src.core.game3.tileset_native')end +-- Metadata is derived from the admitted atlas, never from a second animation +-- scheduler. Detached decoders let renderers inspect every water frame without +-- rewinding the live sheet or changing the native clock. +local observed=setmetatable({},{__mode='k'}) +local function waterBank(model,edition) + local B=require('src.compat.gen1.MetatileBehavior') + local decoder=model.decoder + local water={[B.POND_WATER]=true,[B.OCEAN_WATER]=true,[B.DEEP_WATER]=true, + [B.FAST_WATER]=true,[B.SHALLOW_WATER]=true} + local relevant={} + for _,mid in ipairs(decoder:animatedMetatiles())do + local raw=decoder:sourceBehavior(mid) + if raw~=nil and water[B.fromRaw(edition,raw)]then + for _,part in ipairs(decoder:entries(mid))do + for i,anim in ipairs(decoder:animations())do + if part.tile>=anim.tile and part.tile=anim.tile and part.tile65536 then return nil,'native water animation period exceeds observation budget'end + end + local times={[0]=true} + for i in pairs(relevant)do local step=math.max(1,math.floor(decoder:animations()[i].step)) + for tick=step,period-1,step do times[tick]=true end + end + local ticks={};for tick in pairs(times)do ticks[#ticks+1]=tick end;table.sort(ticks) + if #ticks>256 or #ticks*#mids*1024>16*1024*1024 then return nil,'native water observation exceeds byte budget'end + local snapshot=require('src.compat.gen1.AtlasPixels').new( + {primary=decoder.primary,secondary=decoder.secondary},{tilesInPrimary=decoder.tilesInPrimary, + metatilesInPrimary=decoder.metatilesInPrimary,palettesInPrimary=decoder.palettesInPrimary,metatileLayers=decoder.layers}) + local frames={} + for _,tick in ipairs(ticks)do + snapshot:update(tick) + for _,mid in ipairs(mids)do frames[#frames+1]=snapshot.under[snapshot.midToSlot[mid]]end + end + return {mids=mids,frames=#ticks,rgba=table.concat(frames),ticks=ticks,period=period} +end +local function entry(name) + local ts=native().get(name) + if not(ts and ts._model and not ts._retired)then return nil end + local prior=observed[ts] + if not prior then + local edition=require('src.core.GameVersion').get() + -- The weak-key cache must not point strongly back to its key. Observing + -- metadata cannot keep a trimmed native atlas (or its RGBA samples) alive. + local reference=setmetatable({ts},{__mode='v'}) + prior=setmetatable({rse=edition~='firered'and edition~='leafgreen',banks={}}, + {__index=function(_,key)if key=='atlas'then return reference[1]end end}) + local water,why=waterBank(ts._model,edition) + prior.banks.water,prior.unavailableReason=water,why + observed[ts]=prior + end + return prior +end +A._pairs=setmetatable({},{__index=function(_,name)if type(name)=='string'then return entry(name)end end}) +function A.install()A.invalidate()end +function A.invalidate()A._visible={};A._current=nil;A._pair=nil;observed=setmetatable({},{__mode='k'})end +function A.enterMap(pair)return A.bindPair(pair)end +function A.bindPair(pair,atlas) + if type(pair)~='string'then return false end + local owned=native().get(pair) + if not owned or owned._retired or atlas~=nil and atlas~=owned then return false end + A._current=pair;A._pair=pair;A._visible[pair]=true;return true +end +function A.unbindPair(pair)A._visible[pair]=nil;if A._pair==pair then A._pair=nil;A._current=nil end end +function A.setVisiblePairs(pairs)A._visible=pairs or{}end +function A.step()native().update()end +A.stepRse=A.step +A.release=A.invalidate +return A diff --git a/src/core/game3/tileset_native.lua b/src/core/game3/tileset_native.lua new file mode 100644 index 000000000..07ad3f0a1 --- /dev/null +++ b/src/core/game3/tileset_native.lua @@ -0,0 +1,129 @@ +-- Source atlas contract backed directly by native imported banks. Projected +-- images are owned here, so palette/animation uploads cannot mutate a native +-- renderer's borrowed sheet or be evicted by its separate LRU. +local Upload=require('src.compat.gen1.AtlasUpload') +local Native={_pairs={},_use={},_tick=0,_gen=0,RESIDENT_MAX=4,MAX_UPLOAD_BUFFERS=3,COALESCED_ROW_UPLOADS=true} +local runtime=require('src.compat.gen1.AtlasRuntime').new(Native) +require('src.core.game3.asset_stream').register(runtime) +local function integer(n,hi)return type(n)=='number'and n>=0 and n<=hi and n%1==0 end +function Native.install(cache) + runtime:release();Native._cache=cache +end +local function resetAnimation() + local animation=package.loaded['src.core.game3.tileset_anim'] + if animation and animation.invalidate then animation.invalidate()end +end +function Native.invalidate()runtime:release();resetAnimation()end +function Native.release()runtime:release();Native._cache=nil;resetAnimation()end +function Native.ready(pair)return type(pair)=='string'and runtime:ready(pair)or false end +function Native.prefetch(pair,priority) + if type(pair)~='string'then return false end + return runtime:prefetch(pair,priority) +end +function Native.get(pair) + if type(pair)~='string'then return nil,'atlas pair must be a string'end + return runtime:get(pair) +end +local function atlas(pairOrTs) + if type(pairOrTs)=='table'then return pairOrTs.image and not pairOrTs._retired and pairOrTs or nil end + return Native.get(pairOrTs) +end +function Native.slotFor(pairOrTs,mid) + local ts=atlas(pairOrTs) + return ts and(ts.midToSlot[mid]or ts.midToSlot[0])or 0 +end +function Native.hasMid(pairOrTs,mid) + local ts=atlas(pairOrTs);return ts~=nil and ts.midToSlot[mid]~=nil +end +local function quad(pairOrTs,slot,over) + local ts=atlas(pairOrTs);if not ts then return nil end + slot=tonumber(slot)or 0 + if not integer(slot,(ts.cols*ts.rows)-1)then return nil end + local image=ts[over and'overImage'or'image'];if not image then return nil end + local list=ts[over and'overQuads'or'quads'] + if not list[slot]then list[slot]=love.graphics.newQuad(slot%ts.cols*16,math.floor(slot/ts.cols)*16,16,16,image:getDimensions())end + return list[slot] +end +function Native.quad(pairOrTs,slot)return quad(pairOrTs,slot,false)end +function Native.overQuad(pairOrTs,slot)return quad(pairOrTs,slot,true)end +function Native.markDirty(ts,over,slot) + if not integer(slot,ts.cols*ts.rows-1)then return false end + local key=over and'_dirtyOver'or'_dirtyUnder';ts[key]=ts[key]or{};ts[key][slot]=true;return true +end +function Native.flush(ts,under,over,count,coalesced) + if not(ts and ts.image and not ts._retired)then return false end + count=count==3 and 3 or 2 + if under then Upload.flushPlane(ts,false,count,coalesced~=false)end + if over then Upload.flushPlane(ts,true,count,coalesced~=false)end + if under or over then ts.revision=(ts.revision or 0)+1 end + return under or over or false +end +function Native.ensureAlt(ts)if ts and not ts._retired then Upload.ensureAlt(ts,2)end end +function Native.prepareSlot(ts,slot) + if not(ts and ts._model and integer(slot,15))then return {under={},over={}}end + local entry=ts.slotPix[slot] + if not entry then entry={under=ts._model:palettePixels(slot,false),over=ts._model:palettePixels(slot,true)};ts.slotPix[slot]=entry end + return entry +end +function Native.setSlotPalette(pairOrTs,slot,words) + local ts=atlas(pairOrTs) + if not(ts and ts._model and integer(slot,15)and type(words)=='table')then return false end + local under,over=ts._model:setPalette(slot,words) + ts.patchedSlots=ts.patchedSlots or{};ts.patchedSlots[slot]=true + runtime:apply(ts,under,over);return true +end +function Native.resetSlotPalette(pairOrTs,slot) + local ts=type(pairOrTs)=='table'and pairOrTs or Native._pairs[pairOrTs] + if not(ts and ts._model and ts.patchedSlots and ts.patchedSlots[slot])then return false end + local under,over=ts._model:setPalette(slot,nil);ts.patchedSlots[slot]=nil + runtime:apply(ts,under,over);return true +end +function Native.resident() + local seen,n={},0 + for _,ts in pairs(Native._pairs)do if ts.image and not seen[ts]then n=n+1;seen[ts]=true end end + return n +end +function Native.acquire(pair) + local ts,why=Native.get(pair);if not ts then return nil,why end + ts._leases=(ts._leases or 0)+1 + local released=false + return {atlas=ts,release=function() + if not released then ts._leases=math.max(0,(ts._leases or 1)-1);released=true;runtime:sweepRetired()end + end} +end +function Native.trim(keep,max,busy) + keep,max,busy=keep or{},max or Native.RESIDENT_MAX,busy or{} + local held,order={},{} + for key,ts in pairs(Native._pairs)do + if keep[key]or(ts._leases or 0)>0 then held[ts]=true end + for _,name in ipairs({'image','overImage','imageAlt','overImageAlt'})do if ts[name]and busy[ts[name]]then held[ts]=true end end + for _,state in pairs(ts._uploadBuffers or{})do + for _,entry in ipairs(state.entries)do if busy[entry.image]then held[ts]=true end end + end + order[#order+1]=key + end + table.sort(order,function(a,b)return(Native._use[a]or 0)<(Native._use[b]or 0)end) + local out={};local n=Native.resident() + for _,key in ipairs(order)do + if n<=max then break end + local ts=Native._pairs[key] + if ts and not held[ts]and runtime:drop(key)then out[#out+1]=key;n=n-1 end + end + return out +end +function Native.update()runtime:update()end +-- Preserve the public pending/prefetch/get/cancel/retain observer surface. +local stream={} +function stream:prefetch(pair,priority)return Native.prefetch(pair,priority)end +function stream:get(pair)return Native.get(pair)end +function stream:cancel()runtime:cancelPending()end +function stream:decoded(pair) + local job=runtime:pending()[pair];return job~=nil and job.ts~=nil +end +function stream:retain(wanted) + local keys={};for key in pairs(runtime:pending())do if not wanted[key]then keys[#keys+1]=key end end + for _,key in ipairs(keys)do runtime:cancel(key)end +end +setmetatable(stream,{__index=function(_,key)if key=='pending'then return runtime:pending()end end}) +Native._stream=stream +return Native diff --git a/src/core/game3/trainer_pic.lua b/src/core/game3/trainer_pic.lua new file mode 100644 index 000000000..3e212fbb5 --- /dev/null +++ b/src/core/game3/trainer_pic.lua @@ -0,0 +1,60 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Trainer artwork is resolved from native imported records, with owned images. +local Native=require('src.compat.gen1.NativeSession') +local Assets=require('src.render.Assets') +local Version=require('src.core.GameVersion') +local P={} +local current,cache,users +local function release() + for _,row in pairs(cache or{})do if row.image and row.image.release then row.image:release()end end + current,cache,users=nil,{},{} +end +release() +local function context() + local g=Native.game();if not g then release();return end + if not current or current.game~=g or current.data~=g.data or current.loader~=g.mods or current.edition~=Version.get()then + release();current={game=g,data=g.data,loader=g.mods,edition=Version.get()} + end + local host=package.loaded['src.mods.Runtime'];local facade=package.loaded['src.core.Game3'] + local who=host and host.currentMod or facade and facade._callbackOwner();if who then users[who]=true end + return g +end +local function image(path) + if type(path)~='string'then return nil,'native trainer picture unavailable'end + if cache[path]then return cache[path]end + local ok,img=pcall(love.graphics.newImage,Assets.resolve(path)) + if not ok then return nil,tostring(img)end + local w,h=img:getDimensions();local row={image=img,w=w,h=h,frames=math.max(1,math.floor(h/64))} + cache[path]=row;return row +end +function P.front(id) + local g=context();if not g then return nil,'no native GBA owner'end + assert(type(id)=='number'and id%1==0 and id>=0,'invalid trainer picture id') + local c=g.data.constants;local path=c.gen3TrainerFront and c.gen3TrainerFront[id] + if not path then + for _,r in pairs(g.data.trainers or{})do if r.picIndex==id and type(r.pic)=='string'then + assert(path==nil or path==r.pic,'ambiguous native trainer picture');path=r.pic + end end + for _,r in pairs(g.data.field and g.data.field.playerForms or{})do if r.picIndex==id then path=r.intro or r.card or path end end + end + return image(path) +end +function P.back(gender) + local g=context();if not g then return nil,'no native GBA owner'end + assert(gender==0 or gender==1,'invalid player gender') + local record=g.data.constants.gen3TrainerBack;local who=gender==1 and'girl'or'boy' + local index=record and record.player and record.player[who] + return image(record and record.images and record.images[index]) +end +function P.backAnims(gender) + local g=context();if not g then return nil,'no native GBA owner'end + assert(gender==0 or gender==1,'invalid player gender') + local r=g.data.constants.gen3TrainerBack;local id=r and r.player and r.player[gender==1 and'girl'or'boy'] + local row=r and r.anims and r.anims[id] + return row and require('src.mods.Merge').deepCopy(row) +end +local service={release=release,poll=function()if current then context()end end, + releaseOwner=function(_,who)if users[who]then users[who]=nil;if not next(users)then release()end end end} +P._service=service +require('src.core.game3.asset_stream').register(service);Assets.register(release) +return P diff --git a/src/core/game3/warm.lua b/src/core/game3/warm.lua new file mode 100644 index 000000000..4d3158868 --- /dev/null +++ b/src/core/game3/warm.lua @@ -0,0 +1,55 @@ +-- Cooperative source warm requests. Work retains its full result/geometry; +-- yielding spends another slice rather than lowering quality or dropping it. +local W={BUDGET=.002,errors={}} +local jobs,serial,active={},0,nil +local function clock()return love and love.timer and love.timer.getTime and love.timer.getTime()or os.clock()end +local function nextJob() + local selected + for _,job in pairs(jobs)do + if not selected or job.priority=deadline +end +function W.flush(key) + local job=jobs[key] + if not job or job.co==coroutine.running()then return false end + job.flushing=true + while jobs[key]==job do advance(job)end + return true +end +function W.yield() + if active and not active.flushing and coroutine.running()==active.co then coroutine.yield()end +end +function W.require(names,priority) + for _,name in ipairs(names)do + if not package.loaded[name]then W.add('mod:'..name,function()require(name)end,priority or 8)end + end +end +function W.reset() + local retired=jobs;jobs={};active=nil;W.errors={} + for _,job in pairs(retired)do if job.cancel then pcall(job.cancel)end end +end +W.release=W.reset +return W diff --git a/src/core/game3/warp.lua b/src/core/game3/warp.lua new file mode 100644 index 000000000..eba78fbf5 --- /dev/null +++ b/src/core/game3/warp.lua @@ -0,0 +1,59 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source warps enter the same guarded native admission as public world warps. +local Native=require('src.compat.gen1.NativeSession') +local Version=require('src.core.GameVersion') +local Admission=require('src.mods.Api3Warp') +local Identity=require('src.compat.gen1.MapIdentity') +local W={} +local leases={} +local function owner() + local host=require('src.mods.Runtime') + return host.currentMod or require('src.core.Game3')._callbackOwner() +end +local function valid(c) + return Native.game()==c.game and c.game.mods==c.loader and c.game.save==c.save + and c.game.data==c.data and Version.get()==c.edition and c.loader.hooks==c.hooks + and c.loader.mods[c.who]==c.mod and c.mod.enabled~=false and not c.mod.failed +end +local function cancel(who) + for key,c in pairs(leases)do + if not who or key==who then Admission.cancelAll(c.loader,key);leases[key]=nil end + end +end +function W.isBusy() + local _,w,p=Native.resolve() + return w~=nil and(not not(w.transitioning or w.flyAnim or w.teleportOut + or w.whirlSpin or w.gen4Waterfall or p.stairExit))or false +end +function W.isEscalatorActive() + local _,w=Native.resolve() + return w~=nil and w.transitioning==true and w.warpArrivalKind=='escalator' +end +function W.request(mod,value,mapId,x,y,facing,opts) + local game,why=Native.resolve(value) + if not game or game~=Native.game()then return nil,why or'foreign native game'end + local who=owner();local loader=game.mods;local record=loader and loader.mods and loader.mods[who] + if not(record and loader.hooks and loader.events and record.enabled~=false and not record.failed)then + return nil,'warp request has no live mod owner' + end + local c=leases[who] + if c and not valid(c)then cancel(who);c=nil end + if not c then + c={game=game,loader=loader,mod=record,who=who,save=game.save,data=game.data,edition=Version.get(),hooks=loader.hooks} + leases[who]=c + c.request=Admission.new(loader,who,3,function()return valid(c)and Native.game()or nil end, + function(g)return{overworld=function()return Native.world(g)end}end) + end + if type(mapId)=='string'and not game.data.maps[mapId]then + local mapped=Identity.bind(game.data.maps,Version.get()):nativeId(mapId) + if mapped then mapId=mapped end + end + return c.request(mapId,x,y,facing,opts) +end +-- Revocation cancels only this owner's continuation. It cannot rewind an +-- already admitted native map transition or clear another owner's input. +function W.clear()local who=owner();if not who or not leases[who]then return false end;cancel(who);return true end +W._service={release=function()cancel()end,releaseOwner=function(_,who)cancel(who)end, + poll=function()for who,c in pairs(leases)do if not valid(c)then cancel(who)end end end} +require('src.core.game3.asset_stream').register(W._service) +return W diff --git a/src/core/game3/weather.lua b/src/core/game3/weather.lua new file mode 100644 index 000000000..83d5c9411 --- /dev/null +++ b/src/core/game3/weather.lua @@ -0,0 +1,9 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native GBA weather identifiers; presentation reads the world's current byte. +local W={SUNNY=2,RAIN=3,get=require('src.core.game3.field_weather').getWeather} +return setmetatable(W,{__index=function(_,key) + local game=require('src.compat.gen1.NativeSession').game() + for id,name in pairs(game and game.data.constants and game.data.constants.gen3WeatherNames or{})do + if name==key or name=='WEATHER_'..key then return id end + end +end}) diff --git a/src/dev/HotReload.lua b/src/dev/HotReload.lua index 0a36309fb..37aee8ea7 100644 --- a/src/dev/HotReload.lua +++ b/src/dev/HotReload.lua @@ -29,6 +29,7 @@ function HotReload.run(game, opts) local Assets = require("src.render.Assets") local Runtime = require("src.mods.Runtime") local Logger = require("src.core.Logger") + if game.mods and game.mods._closeModInput then game.mods:_closeModInput() end local data = game.data if data and data.reloadGenerated then data:reloadGenerated() end local loader = Loader.new(opts and { fs = opts.fs, dev = opts.dev } or nil) diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 814ced9e5..b5f2ccf31 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -716,6 +716,19 @@ function CacheFs.rawRemoveDir(rel) return true end +-- Publish a completed, validated import within the current writable data root. +-- Reuse the platform's atomic replacement used by native downloads, including +-- Windows replacement without deleting the previously installed file first. +function CacheFs.rawRename(from, to) + local root = rawRoot() + if not root then + local lfs = love and love.filesystem + root = lfs and lfs.getSaveDirectory and lfs.getSaveDirectory() + end + if not root then return nil,"no writable data root" end + return require("src.net.DownloadPublish")(realPath(root,from),realPath(root,to)) +end + -- A love.filesystem-shaped File for a path under the live root. -- -- Only WRITING needs this. A read goes through love.filesystem, which can @@ -844,6 +857,8 @@ function CacheFs.dataFs() dataFsCache = { -- reads and enumeration: every home at once getInfo = mounted(function(...) return love.filesystem.getInfo(...) end), + getRealDirectory = mounted(function(...) return love.filesystem.getRealDirectory(...) end), + getWriteRoot = function() return CacheFs.rootReport().path end, getDirectoryItems = mounted(function(...) return love.filesystem.getDirectoryItems(...) end), read = mounted(function(...) return love.filesystem.read(...) end), load = mounted(function(...) return love.filesystem.load(...) end), @@ -853,6 +868,7 @@ function CacheFs.dataFs() write = function(rel, data) return CacheFs.rawWrite(rel, data) end, createDirectory = function(rel) return CacheFs.rawCreateDirectory(rel) end, remove = function(rel) return CacheFs.rawRemove(rel) end, + rename = function(from,to) return CacheFs.rawRename(from,to) end, removeDir = function(rel) return CacheFs.rawRemoveDir(rel) end, -- `mode` given means ALREADY OPEN, exactly as love.filesystem does it -- -- see realFile. Returning an unopened handle here reads as an empty file diff --git a/src/import/Gen3FieldMoveArt.lua b/src/import/Gen3FieldMoveArt.lua new file mode 100644 index 000000000..397efedbe --- /dev/null +++ b/src/import/Gen3FieldMoveArt.lua @@ -0,0 +1,72 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Digest-bound GBA tilemap artwork. Store compact indices and palette words; +-- compose RGBA only for a consumer, without changing existing native art. +local M={SCHEMA=1,WIDTH=256,HEIGHT=80} +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 range(rom,at,n)return integer(at,0,rom.size-n)end +local function strip(rom,spec,checkpoint) + assert(type(spec)=='table'and integer(spec.tileCount,1,1024),'invalid field streak geometry') + assert(range(rom,spec.tiles,spec.tileCount*32)and range(rom,spec.palette,32)and range(rom,spec.tilemap,640),'field streak ROM bounds') + local palette={};for i=1,16 do palette[i]=rom:u16(spec.palette+(i-1)*2)end + local tiles={} + for t=0,spec.tileCount-1 do + local variants={} + for flip=0,3 do + local rows={} + for y=0,7 do + local row={};local sy=flip>=2 and 7-y or y + for x=0,7 do + local sx=flip%2==1 and 7-x or x + local b=rom:u8(spec.tiles+t*32+sy*4+math.floor(sx/2)) + row[x+1]=string.char(sx%2==0 and b%16 or math.floor(b/16)) + end + rows[y+1]=table.concat(row) + end + variants[flip]=rows + end + tiles[t]=variants + end + local rows={} + for ty=0,9 do + local cells={} + for tx=0,31 do + local e=rom:u16(spec.tilemap+(ty*32+tx)*2) + assert(e%1024=2^(bits-1) and n-2^bits or n end +local function range(rom,at,n) return type(at)=='number' and at>=0 and at%1==0 and at+n<=rom.size end +local function pointer(rom,at) + if not range(rom,at,4) then return nil end + return rom:pointer(at) +end +local function bytes(rom,at,n) + if not range(rom,at,n) then return nil end + if rom.sub then return rom:sub(at,n) end + local out={}; for i=0,n-1 do out[i+1]=string.char(rom:u8(at+i)) end + return table.concat(out) +end +local function palettes(rom,spec) + local out={} + for i=0,math.min(spec.paletteCount or 64,64)-1 do + local at=spec.paletteTable+i*8 + if not range(rom,at,8) or rom:u32(at)==0 then break end + local data=pointer(rom,at); local tag=rom:u16(at+4) + if data and tag~=0 and range(rom,data,32) then + local p={}; for c=0,15 do p[c]=rom:u16(data+c*2) end + out[tag]=p + end + end + return out +end +local function imageStarts(rom,spec) + local starts,infos={},{} + local function add(at) + if not at or infos[at] or not range(rom,at,36) or rom:u16(at)~=65535 then return false end + local images,anims=pointer(rom,at+28),pointer(rom,at+24) + if not(images and anims) then return false end + infos[at]=true; starts[images]=true; return true + end + for id=0,spec.count-1 do add(pointer(rom,spec.gfxTable+id*4)) end + -- Contiguous graphics-info records also name image lists not addressed by + -- the public graphics table. Their boundaries still end the preceding list. + local seeds={}; for at in pairs(infos) do seeds[#seeds+1]=at end + for _,at in ipairs(seeds) do at=at+36; while add(at) do at=at+36 end end + return starts +end +local function layout(rom,at,w,h,wide) + if not range(rom,at,8) then return nil end + local count,subs=rom:u8(at),pointer(rom,at+4) + local stride=wide and 8 or 4 + if count<1 or count>64 or not subs or not range(rom,subs,count*stride) then return nil end + local pieces={}; local minX,minY,maxX,maxY=math.huge,math.huge,-math.huge,-math.huge + for i=0,count-1 do + local off=subs+i*stride + local x=wide and signed(rom:u16(off),16) or signed(rom:u8(off),8) + local y=wide and signed(rom:u16(off+2),16) or signed(rom:u8(off+1),8) + local bits=rom:u16(off+(wide and 4 or 2)) + local row=dimensions[bits%4]; local dim=row and row[math.floor(bits/4)%4+1] + if not dim then return nil end + local p={x=x,y=y,w=dim[1],h=dim[2],tile=math.floor(bits/16)%1024} + pieces[#pieces+1]=p + minX,minY=math.min(minX,x),math.min(minY,y) + maxX,maxY=math.max(maxX,x+p.w),math.max(maxY,y+p.h) + end + if maxX-minX~=w or maxY-minY~=h then return nil end + for _,p in ipairs(pieces) do p.x,p.y=p.x-minX,p.y-minY end + return {pieces=pieces,x=minX+math.floor(w/2),y=minY+math.floor(h/2)} +end +local function tiles(w,h,arrangement) + local rows={} + for ty=0,h/8-1 do + local row={}; rows[ty+1]=row + for tx=0,w/8-1 do + local tile=ty*(w/8)+tx + if arrangement then + tile=false + for _,p in ipairs(arrangement.pieces) do + if tx*8>=p.x and ty*8>=p.y and tx*8=1 and spec.count<=4096,'invalid object graphics count') + assert(range(rom,spec.paletteTable,8),'invalid object palette table') + local starts,pals=imageStarts(rom,spec),palettes(rom,spec) + local result={schema=M.SCHEMA,count=spec.count,edition=spec.edition,romSha1=spec.romSha1, + reflectionProfile=spec.reflectionProfile or 'paired',avatars=avatars(rom,spec.avatarTables),objects={},unavailable={}} + for id=0,spec.count-1 do + local ok,record=pcall(function() + local info=pointer(rom,spec.gfxTable+id*4) + assert(range(rom,info,36),'object info unavailable') + local w,h=math.abs(rom:s16(info+8)),math.abs(rom:s16(info+10)) + assert(w>=8 and h>=8 and w<=128 and h<=128 and w%8==0 and h%8==0,'invalid object dimensions') + local images=pointer(rom,info+28); assert(images,'object image table unavailable') + local rawSize=w*h/2; local n=0 + while n<32 do + local at=images+n*8 + if n>0 and starts[at] or not range(rom,at,8) then break end + local size=rom:u16(at+4) + if not pointer(rom,at) or size<32 or size>32768 or size%32~=0 then break end + n=n+1 + end + n=math.max(1,n) + local flags=rom:u8(info+12); local inanimate=math.floor(flags/64)%2==1 + if inanimate and w==32 and h==16 then w=16 end + local arrangement=layout(rom,pointer(rom,info+20),w,h,spec.wideSubsprites) + local tileRows=tiles(w,h,arrangement); local expected=w*h/2; local frames={} + for i=0,n-1 do + local at=images+i*8; local data=pointer(rom,at); local size=rom:u16(at+4) + if size<1 or size>expected*4 then size=expected end + -- Shared picture tables may contain differently sized stages. They + -- keep their table slots, but a stage belonging to another graphics + -- row must not read past its own allocation as this row's image. + local raw=size==rawSize and data and bytes(rom,data,math.max(size,expected)) + frames[i+1]=raw and decode(raw,w,h,tileRows) or string.rep('\0',w*h) + end + local tag,reflectTag=rom:u16(info+2),rom:u16(info+4) + local palette=pals[tag] or {}; for c=0,15 do palette[c]=palette[c] or 0 end + local nativeReflect=spec.reflectionFor and spec.reflectionFor(info) + -- A raw reflection profile publishes the flag/palette but leaves the + -- optional paired-palette projection absent. Keep the independently + -- derived native palette under its own name in both profiles. + local mapped=spec.reflectionProfile~='raw' and pals[reflectTag] and nativeReflect or nil + return {graphicsId=id,width=w,height=h,frameCount=n,indexed=table.concat(frames), + paletteTag=tag,palette=palette,paletteSlot=flags%16,inanimate=inanimate, + reflectionPaletteTag=reflectTag,reflectionPalette=pals[reflectTag], + reflectionPaletteMappedTag=mapped,mappedReflectionPalette=mapped and pals[mapped] or nil, + nativeReflectionPaletteTag=nativeReflect, + drawOffX=arrangement and arrangement.x or 0,drawOffY=arrangement and arrangement.y or 0} + end) + if ok then result.objects[id]=record else result.unavailable[id]=tostring(record) end + if spec.progress and id%16==15 then spec.progress(id+1,spec.count) end + end + return result +end +local function color(word) + return string.char(math.floor((word%32)*255/31+.5),math.floor((math.floor(word/32)%32)*255/31+.5),math.floor((math.floor(word/1024)%32)*255/31+.5),255) +end +function M.rgba(record,palette,checkpoint) + palette=palette or record.palette + assert(type(record.indexed)=='string' and #record.indexed==record.width*record.height*record.frameCount,'invalid indexed object sheet') + local colors={[0]='\0\0\0\0'}; for i=1,15 do colors[i]=color(palette[i] or 0) end + local rows={}; local stride=record.width + for at=1,#record.indexed,stride do + local row={}; for x=0,stride-1 do row[x+1]=assert(colors[record.indexed:byte(at+x)],'invalid sprite palette index') end + rows[#rows+1]=table.concat(row) + if checkpoint and #rows%32==0 then checkpoint() end + end + return table.concat(rows) +end +return M diff --git a/src/import/Gen3PalettePrograms.lua b/src/import/Gen3PalettePrograms.lua new file mode 100644 index 000000000..7ebe45574 --- /dev/null +++ b/src/import/Gen3PalettePrograms.lua @@ -0,0 +1,140 @@ +-- Read-only, bounded analysis of Gen3 tileset initializers. Runtime program +-- recipes are source-pinned in the manifest and require an exact ROM digest. +-- This is not an ARM emulator: RAM is never read/written and no code runs. +local Rom = require("src.import.RomGba") +local M = {} +local BASE = 0x08000000 +local function integer(n,lo,hi) + return type(n)=="number" and n==math.floor(n) and n>=lo and n<=hi +end +local function ram(n,size) + return type(n)=="number" and ((n>=0x02000000 and n+size<=0x02040000) + or (n>=0x03000000 and n+size<=0x03008000)) and n%size==0 +end +local function offset(rom,address,size) + if not integer(address,BASE,BASE+rom.size-size) then error("ROM pointer out of bounds") end + return address-BASE +end + +-- These leaf initializers only assign counters and a callback pointer. +-- Unknown halfword loads remain unknown; their counter values are irrelevant +-- to classifying the installed callback. Unknown callback stores decline. +local function initializer(rom,address) + if not integer(address,BASE+1,BASE+rom.size-2) or address%2~=1 then error("invalid initializer pointer") end + local start=address-BASE-1 + local reg,stores={},{} + for index=0,63 do + local pc=start+index*2 + local h=rom:u16(pc) + local d=h%8 + if h==0x4770 then + if #stores~=1 then error("initializer must install exactly one callback") end + return stores[1] + elseif h>=0x4800 and h<0x5000 then + local at=pc+4;at=at-at%4+(h%256)*4 + reg[math.floor(h/256)%8]=rom:u32(at) + elseif h>=0x2000 and h<0x2800 then + reg[math.floor(h/256)%8]=h%256 + elseif h<0x1000 then + local src=reg[math.floor(h/8)%8] + local shift=math.floor(h/64)%32 + if h<0x0800 then reg[d]=src and (src*2^shift)%4294967296 + else reg[d]=src and (shift==0 and 0 or math.floor(src/2^shift)) end + elseif h>=0x1800 and h<0x2000 then + local a=reg[math.floor(h/8)%8] + local b=math.floor(h/64)%8 + if math.floor(h/1024)%2==0 then b=reg[b] end + local subtract=math.floor(h/512)%2==1 + reg[d]=a and b and (a+(subtract and -b or b))%4294967296 + elseif h>=0x3000 and h<0x4000 then + local rd=math.floor(h/256)%8 + reg[rd]=reg[rd] and (reg[rd]+(h<0x3800 and h%256 or -(h%256)))%4294967296 + elseif h>=0x4600 and h<0x4700 then + local rd=d+math.floor(h/128)%2*8 + if rd>=13 then error("unsupported initializer register") end + reg[rd]=reg[math.floor(h/8)%16] + elseif h>=0x6000 and h<0x6800 then + local base=reg[math.floor(h/8)%8] + local dest=base and base+math.floor(h/64)%32*4 + if not ram(dest,4) or reg[d]==nil then error("unproven callback store") end + stores[#stores+1]=reg[d] + elseif h>=0x8000 and h<0x9000 then + local base=reg[math.floor(h/8)%8] + local dest=base and base+math.floor(h/64)%32*2 + if not ram(dest,2) then error("unproven counter access") end + if h>=0x8800 then reg[d]=nil end + else + error(("unsupported initializer instruction %04x"):format(h)) + end + end + error("initializer instruction limit exceeded") +end + +local function recipe(rom,record,profile,layout) + if type(record.secondary)~="boolean" then error("tileset bank unavailable") end + if record.callback==0 then return {},"no-callback" end + local target=initializer(rom,record.callback) + if target==0 then return {},"null-callback" end + if not integer(target,BASE+1,BASE+rom.size-2) or target%2~=1 then error("invalid runtime callback") end + local program=profile.programs[tostring(target-BASE-1)] + if type(program)~="table" or program.secondary~=record.secondary then error("unknown runtime palette program") end + if program.kind=="static" then return {},program.id end + local split=type(layout)=="table" and layout.palettesInPrimary + if not integer(split,1,15) or program.kind~="frames" + or not integer(program.frames,2,256) or not integer(program.count,1,256) + or not integer(program.first,0,255) or program.first+program.count>256 + or not integer(program.step,1,65535) or (program.phase~=nil and not integer(program.phase,0,65535)) + or not integer(program.table,0,rom.size-program.frames*4) + or program.table%4~=0 or program.frames*program.count*3>262144 then error("invalid palette recipe") end + local lo,hi=record.secondary and split*16 or 0,record.secondary and 256 or split*16 + if program.firsthi then error("palette recipe crosses bank") end + local frames,sourceFrames={},{} + for frame=0,program.frames-1 do + local at=offset(rom,rom:u32(program.table+frame*4),program.count*2) + if at%2~=0 then error("unaligned palette frame") end + local rgb,raw={},{} + for color=0,program.count-1 do + local word=rom:u16(at+color*2) + if word>=0x8000 then error("invalid BGR555 palette frame") end + rgb[#rgb+1]=string.char(Rom.bgr555(word)) + raw[#raw+1]=string.char(word%256,math.floor(word/256)) + end + frames[#frames+1]=table.concat(rgb) + sourceFrames[#sourceFrames+1]=table.concat(raw) + end + return {{kind="frames",first=program.first,count=program.count,step=program.step, + phase=program.phase or 0,frames=frames,sourceFrames=sourceFrames}},program.id +end + +function M.extract(rom,sets,manifest,actualSha1) + local profile=manifest and manifest.terrainPalettePrograms + local identity=type(profile)=="table" and getmetatable(profile)==nil and profile.version==1 + and type(actualSha1)=="string" and #actualSha1==40 + and actualSha1==manifest.romSha1 and actualSha1==profile.romSha1 + and type(profile.programs)=="table" and getmetatable(profile.programs)==nil + local report={version=1,static=0,animated=0,unavailable=0} + local keys={} + for key,value in pairs(sets) do + if key~="_romInfo" and type(value)=="table" then keys[#keys+1]=key end + end + table.sort(keys) + for _,key in ipairs(keys) do + local record=sets[key] + -- A rerun after a source/profile change must never retain old success. + record.paletteCycles=nil + local ok,cycles,id + if identity then ok,cycles,id=pcall(recipe,rom,record,profile,manifest.layout or {}) end + if ok then + record.paletteCycles=cycles + local status=#cycles>0 and "animated" or "static" + record.paletteProgram={version=1,status=status,id=id,romSha1=actualSha1} + report[status]=report[status]+1 + else + record.paletteProgram={version=1,status="unavailable", + reason=identity and tostring(cycles) or "ROM palette program identity unavailable"} + report.unavailable=report.unavailable+1 + end + end + return report +end +return M diff --git a/src/import/Gen3ScriptInterface.lua b/src/import/Gen3ScriptInterface.lua new file mode 100644 index 000000000..92d58afb5 --- /dev/null +++ b/src/import/Gen3ScriptInterface.lua @@ -0,0 +1,15 @@ +-- Additive information needed to observe original script operands after the +-- native importer has normalized cross-edition command ids. +local M={SCHEMA=1} +function M.row(line,row) + row.sourceOpcode=line.op + row.sourceAddress=line.address and line.address+0x08000000 + row.sourceSpecial=line.sourceSpecial + row.sourceBytes=line.sourceBytes + row.sourceSpec=line.sourceSpec + return row +end +function M.header(edition,sha1) + return {schema=M.SCHEMA,edition=edition,romSha1=sha1} +end +return M diff --git a/src/import/Gen3ScriptOps.lua b/src/import/Gen3ScriptOps.lua index 37f73f4ca..cedda25e1 100644 --- a/src/import/Gen3ScriptOps.lua +++ b/src/import/Gen3ScriptOps.lua @@ -70,7 +70,15 @@ -- (usually a pointer) -- * variable length; see TRAINER_BATTLE_LENGTH -local Gen3ScriptOps = {} +local Gen3ScriptOps = { CACHE_REVISION = 1 } + +-- The RS handler reads x/y; the later games removed those reads. Keep +-- edition semantics here instead of mutating the shared command table. +function Gen3ScriptOps.operandSpec(command, version) + local edition = type(version) == 'table' and version.id or version + if command[1] == 'hidemoneybox' and (edition == 'ruby' or edition == 'sapphire') then return 'bb' end + return command[2] +end Gen3ScriptOps.COMMANDS = { { "nop", "" }, { "nop1", "" }, { "end", "" }, { "return", "" }, { "call", "d" }, @@ -119,7 +127,7 @@ Gen3ScriptOps.COMMANDS = { { "choosecontestmon", "" }, { "startcontest", "" }, { "showcontestresults", "" }, { "contestlinktransfer", "" }, { "random", "w" }, { "addmoney", "db" }, { "removemoney", "db" }, { "checkmoney", "db" }, { "showmoneybox", "bbb" }, - { "hidemoneybox", "" }, { "updatemoneybox", "" }, { "getpokenewsactive", "w" }, + { "hidemoneybox", "" }, { "updatemoneybox", "bbb" }, { "getpokenewsactive", "w" }, { "fadescreen", "b" }, { "fadescreenspeed", "bb" }, { "setflashlevel", "w" }, { "animateflash", "b" }, { "messageautoscroll", "d" }, { "dofieldeffect", "w" }, { "setfieldeffectargument", "bw" }, { "waitfieldeffect", "w" }, { "setrespawn", "w" }, diff --git a/src/import/Gen3SourceFlags.lua b/src/import/Gen3SourceFlags.lua new file mode 100644 index 000000000..4a750f5b9 --- /dev/null +++ b/src/import/Gen3SourceFlags.lua @@ -0,0 +1,20 @@ +-- Original byte-indexed terrain flags; native behavior numbering is separate. +local Flags = {} +function Flags.read(rom, spec) + if type(spec) ~= "table" then return nil, "source flag metadata unavailable" end + local offset, count, digest = spec.offset, spec.count, spec.sha256 + if type(offset) ~= "number" or offset < 0 or offset % 1 ~= 0 + or count ~= 240 or type(digest) ~= "string" or #digest ~= 64 + or digest:find("[^0-9a-f]") then return nil, "invalid source flag metadata" end + if not (rom and type(rom.data) == "string") or offset + count > #rom.data then + return nil, "source flag table outside cartridge" + end + if not (love and love.data and love.data.hash) then return nil, "source flag checksum unavailable" end + local raw = rom.data:sub(offset + 1, offset + count) + local hash = love.data.hash("sha256", raw) + local hex = hash:gsub(".", function(c) return ("%02x"):format(c:byte()) end) + if hex ~= digest then return nil, "source flag checksum mismatch" end + for i = 1, #raw do if raw:byte(i) > 7 then return nil, "invalid source flag bits" end end + return raw +end +return Flags diff --git a/src/import/Gen3TradeArt.lua b/src/import/Gen3TradeArt.lua index ec1c0737a..a610d1f92 100644 --- a/src/import/Gen3TradeArt.lua +++ b/src/import/Gen3TradeArt.lua @@ -348,7 +348,8 @@ end function Gen3TradeArt.extract(x) local rom = x.rom local key = Gen3TradeArt.romKey(rom) - local T = key and Gen3TradeArt.ROMS[key] + local mapped = x.manifest and x.manifest.tradeArt + local T = mapped and mapped.romKey == key and mapped or key and Gen3TradeArt.ROMS[key] if not T then Logger.warn("gen3 trade art: no address table for cartridge %s -- in-game " .. "trades keep the bare swap", tostring(key)) diff --git a/src/import/Gen3TransitionProfile.lua b/src/import/Gen3TransitionProfile.lua new file mode 100644 index 000000000..5777730a6 --- /dev/null +++ b/src/import/Gen3TransitionProfile.lua @@ -0,0 +1,12 @@ +-- RS class ordinals differ from Emerald. Older relocated caches retained +-- Emerald's default pair; resolve only that known inherited profile, leaving +-- explicitly authored class profiles alone. +local M={} +function M.classes(edition,classes) + classes=classes or{} + if(edition=='ruby'or edition=='sapphire')and classes.eliteFour==31 and classes.champion==38 then + return{eliteFour=24,champion=32,magma={},aqua={}} + end + return classes +end +return M diff --git a/src/import/Importers.lua b/src/import/Importers.lua new file mode 100644 index 000000000..72ec0f1b6 --- /dev/null +++ b/src/import/Importers.lua @@ -0,0 +1,240 @@ +-- Shared, cartridge-derived asset packs. Providers publish data; consumers +-- only see packs explicitly declared by their mod. No game assets ship here. +local Data = require("src.import.PackData") +local Semver = require("src.mods.Semver") +local Writer = require("src.import.LuaWriter") +local MD5 = require("src.mods.StreamMD5") +local Importers = { ROOT="asset_packs", PACK_FILE="pack.lua", PACK_FORMAT=1, + MAX_ENTRY_BYTES=Data.MAX_BYTES } +Importers.KINDS = { + sprite={ext={png=true}}, tileset={ext={png=true}}, palette={ext={png=true,lua=true}}, + font={ext={png=true}}, sfx={ext={wav=true,ogg=true}}, music={ext={wav=true,ogg=true}}, + sample={ext={wav=true,ogg=true}}, +} +Importers.STATUS = { planned=true, beta=true, ready=true } +-- This catalogue describes the portable pack identities. ROM extraction +-- services are tracked separately; installing an existing pack is available. +local catalog = { + {id="gen5_bw",name="Black / White",status="beta", + source={name="Black / White cartridge dump",formats={"nds"},sizes={268435456}}, + packs={{id="battle_sprites",name="Battle sprites",kind="sprite"}}}, + {id="pmd_red",name="Red Rescue Team",status="beta", + source={name="Red Rescue Team cartridge dump",formats={"gba"},sizes={33554432}}, + packs={{id="sprites",name="Sprite sheets",kind="sprite"}}}, + {id="lttp",name="A Link to the Past",status="beta",module="src.import.lttp.LttpImport", + source={name="A Link to the Past cartridge dump",formats={"sfc","smc"},sizes={1048576,2097152}}, + packs={{id="sprites",name="Sprites",kind="sprite"},{id="tiles",name="Tiles",kind="tileset"}, + {id="palettes",name="Palettes",kind="palette"}, + {id="sfx",name="Sound effects",kind="sfx",planned=true}, + {id="music",name="Music",kind="music",planned=true}}}, +} +local byId = {}; for _, desc in ipairs(catalog) do byId[desc.id] = desc end +local cache = setmetatable({}, {__mode="k"}) +local function validId(id) return type(id)=="string" and #id<=128 and id:match("^[a-z0-9_%-]+$")~=nil end +Importers.validId = validId +local function pathInside(file) + if type(file)~="string" or file=="" or #file>1024 or file:find("[%z\1-\31\\:]") + or file:sub(1,1)=="/" then return nil end + local parts={} + for p in file:gmatch("[^/]+") do + if p==".." then return nil end + if p~="." then parts[#parts+1]=p end + end + return #parts>0 and table.concat(parts,"/") or nil +end +local function integer(n) return type(n)=="number" and n>=0 and nImporters.MAX_ENTRY_BYTES then return false end + if files[rel] and files[rel]~=size then return false end + files[rel]=size; return true + end + for key,entry in pairs(m.entries) do + if type(key)~="string" or not key:match("^[a-z0-9_%-]+[a-z0-9_/%-]*$") or #key>512 + or type(entry)~="table" or not fileRecord(entry.file,entry.size) + or not kind.ext[ext(entry.file)] then return false,"invalid pack entry: "..tostring(key) end + if entry.metadata~=nil then + local meta=entry.metadata + if type(meta)~="table" or not fileRecord(meta.file,meta.size) or ext(meta.file)~="lua" then + return false,"invalid entry metadata: "..key + end + end + count=count+1; if count>20000 then return false,"pack entry count limit" end + end + return true,nil,count +end +function Importers.invalidate(fs,id,pack) + -- Providers may use a scoped wrapper over the same writable filesystem. + -- Invalidate readers of that relative identity as well as the writer. + local path=Importers.manifestPath(id,pack) + if path then for _,c in pairs(cache) do c[path]=nil end end +end +function Importers.readPack(id,pack,fs) + fs=Importers.fs(fs) + local path,err=Importers.manifestPath(id,pack); if not path then return nil,err end + local info=fs and fs.getInfo and fs.getInfo(path,"file") + if not info then return nil,"pack is not installed" end + local c=cache[fs]; local old=c and c[path] + local origin=fs.getRealDirectory and fs.getRealDirectory(path) + -- Timeless injected filesystems are deliberately never memoized. + if old and type(info.modtime)=="number" and info.modtime>=0 and info.modtime Data.MAX_BYTES then return nil, "pack data exceeds the read limit" end + local at, nodes, depth = 1, 0, 0 + local function bad(why) error("pack data at byte " .. at .. ": " .. why, 0) end + local function space() + while true do + local _, last = bytes:find("^%s*", at); at = last + 1 + if bytes:sub(at, at + 1) ~= "--" then return end + -- Only line comments; block comments are unnecessary for generated data. + at = (bytes:find("\n", at + 2, true) or #bytes) + 1 + end + end + local function stringValue() + local quote, out = bytes:sub(at, at), {}; at = at + 1 + while true do + local c = bytes:sub(at, at); at = at + 1 + if c == "" then bad("unterminated string") end + if c == quote then return table.concat(out) end + if c == "\\" then + c = bytes:sub(at, at); at = at + 1 + local escapes = { n="\n", r="\r", t="\t", a="\a", b="\b", f="\f", v="\v", + ["\\"]="\\", ['"']='"', ["'"]="'", ["\n"]="\n" } + if c:match("%d") then + local digits = c .. (bytes:match("^%d?%d?", at) or "") + if tonumber(digits) > 255 then bad("invalid decimal escape") end + at = at + #digits - 1; c = string.char(tonumber(digits)) + elseif escapes[c] then c = escapes[c] + else bad("invalid string escape") end + end + out[#out + 1] = c + end + end + local value + local function tableValue() + depth = depth + 1; if depth > Data.MAX_DEPTH then bad("table nesting limit") end + at = at + 1 + local out, seen, index = {}, {}, 1 + space() + while bytes:sub(at, at) ~= "}" do + if at > #bytes then bad("unterminated table") end + local key, item + if bytes:sub(at, at) == "[" then + at = at + 1; key = value(); space() + if bytes:sub(at, at) ~= "]" then bad("expected ]") end + at = at + 1; space() + if bytes:sub(at, at) ~= "=" then bad("expected =") end + at = at + 1; item = value() + else + local name = bytes:match("^[%a_][%w_]*", at) + local saved = at + if name then at = at + #name; space() end + if name and bytes:sub(at, at) == "=" then + key = name; at = at + 1; item = value() + else + at = saved; key = index; index = index + 1; item = value() + end + end + if type(key) ~= "string" and type(key) ~= "number" then bad("invalid table key") end + if seen[key] then bad("duplicate table key") end + seen[key], out[key] = true, item + space() + local c = bytes:sub(at, at) + if c == "," or c == ";" then at = at + 1; space() + elseif c ~= "}" then bad("expected table separator") end + end + at = at + 1; depth = depth - 1; return out + end + value = function() + nodes = nodes + 1; if nodes > Data.MAX_NODES then bad("node limit") end + space(); local c = bytes:sub(at, at) + if c == '"' or c == "'" then return stringValue() end + if c == "{" then return tableValue() end + local name = bytes:match("^[%a_][%w_]*", at) + if name == "true" or name == "false" then at = at + #name; return name == "true" end + if c:match("[%d%+%-%.]") then + local token = bytes:match("^[^%s,%]}%;]+", at) + local n = token and tonumber(token) + if not n or n ~= n or n == math.huge or n == -math.huge then bad("invalid finite number") end + at = at + #token; return n + end + bad("expected a literal") + end + local ok, result = pcall(function() + space() + if bytes:match("^[%a_][%w_]*", at) ~= "return" then bad("expected return") end + at = at + 6; local out = value(); space() + if at <= #bytes then bad("trailing content") end + if type(out) ~= "table" then bad("root must be a table") end + return out + end) + if not ok then return nil, result end + return result +end + +function Data.copy(value) + if type(value) ~= "table" then return value end + local out = {}; for k, v in pairs(value) do out[k] = Data.copy(v) end; return out +end + +-- Stat before reading, and give native handles an explicit maximum length. +function Data.read(fs, path, expected) + if not (fs and fs.getInfo) then return nil, "pack file information is unavailable" end + local info = fs.getInfo(path, "file") + local size = info and info.size + if not (info and info.type == "file" and type(size) == "number" and size >= 0 + and size % 1 == 0 and size <= Data.MAX_BYTES) then return nil, "pack file is missing or oversized" end + if expected and size ~= expected then return nil, "pack file size does not match" end + local bytes, err + if fs.newFile then + local file; file, err = fs.newFile(path) + if not file then return nil, err or "could not open pack file" end + local ok, result, why = pcall(function() + local opened, openErr = file:open("r"); if not opened then return nil, openErr end + if size == 0 then return "" end + return file:read(size) + end) + pcall(file.close, file) + if not ok then return nil, tostring(result) end + bytes, err = result, why + elseif fs.readRange then bytes, err = fs.readRange(path, 0, size) + elseif fs.read then bytes, err = fs.read(path, size) end + if type(bytes) ~= "string" or #bytes ~= size then return nil, err or "short pack read" end + return bytes +end + +return Data diff --git a/src/import/PackInbox.lua b/src/import/PackInbox.lua new file mode 100644 index 000000000..da4c2fd3e --- /dev/null +++ b/src/import/PackInbox.lua @@ -0,0 +1,69 @@ +-- Files/iTunes sharing, Android storage and console MTP all reach this same +-- relative inbox. Never move or delete the user's source pack directories. +local Importers=require("src.import.Importers") +local Inbox={ROOT="imports/asset_packs",SOURCES="imports/asset_sources"} +function Inbox.start(fs) + fs=Importers.fs(fs) + local queue={} + -- Source cartridges run first. Explicit extracted packs in the inbox may + -- then override individual packs, preserving their established precedence. + for _,desc in ipairs(Importers.all()) do + if desc.module then + for _,extension in ipairs(desc.source.formats or {}) do + local path=Inbox.SOURCES.."/"..desc.id.."."..extension + if fs.getInfo(path,"file") then + queue[#queue+1]={importer=desc.id,pack="graphics",source=path,module=desc.module} + break + end + end + end + end + for _,desc in ipairs(Importers.all()) do + for _,pack in ipairs(desc.packs) do + local root=Inbox.ROOT.."/"..desc.id.."/"..pack.id + if fs.getInfo(root.."/"..Importers.PACK_FILE,"file") then + queue[#queue+1]={importer=desc.id,pack=pack.id,root=root} + end + end + end + return coroutine.create(function() + local installed,failed,positions={},{},{} + local function record(result) + local key=result.importer.."/"..result.pack + local at=positions[key] or #installed+1 + positions[key],installed[at]=at,result + end + for _,item in ipairs(queue) do + local co + local prepared,prepareError=pcall(function() + if item.source then + local data,err=require("src.import.PackData").read(fs,item.source) + assert(data,err) + co=require(item.module).job(data,fs) + else co=Importers.install(item.importer,item.pack,fs,item.root,fs) end + end) + if not prepared then + failed[#failed+1]={importer=item.importer,pack=item.pack,error=tostring(prepareError)} + end + while co do + local ok,value=coroutine.resume(co) + if not ok then + failed[#failed+1]={importer=item.importer,pack=item.pack,error=tostring(value)}; break + end + if coroutine.status(co)=="dead" then + if item.source then + for _,desc in ipairs(Importers.get(item.importer).packs) do + local count=value.packs and value.packs[desc.id] + if count then record({importer=item.importer,pack=desc.id,entries=count,reused=value.reused}) end + end + else record(value) end + break + end + value.importer,value.pack=item.importer,item.pack + coroutine.yield(value) + end + end + return {installed=installed,failed=failed,found=#queue} + end) +end +return Inbox diff --git a/src/import/PackProducer.lua b/src/import/PackProducer.lua new file mode 100644 index 000000000..20434be58 --- /dev/null +++ b/src/import/PackProducer.lua @@ -0,0 +1,118 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native extractors publish immutable objects and switch the descriptor only +-- once their complete result is ready. Cancellation never replaces a pack. +local Importers=require("src.import.Importers") +local Data=require("src.import.PackData") +local Writer=require("src.import.LuaWriter") +local MD5=require("src.mods.StreamMD5") +local Producer={} +local publish=require("src.import.PackPublish") + +local function literal(value) + local ok,bytes=pcall(Writer.encode,value) + if not ok then return nil,tostring(bytes) end + if #bytes>Data.MAX_BYTES then return nil,"pack data exceeds the read limit" end + local decoded,err=Data.decode(bytes) + if not decoded then return nil,err end + return decoded,bytes +end + +function Producer.new(id,pack,version,source,fs) + local descriptor=Importers.pack(id,pack) + if not descriptor then return nil,"unknown asset pack" end + local normalized,err=literal(source) + if not normalized then return nil,err end + local manifest={format=Importers.PACK_FORMAT,importer=id,pack=pack, + kind=descriptor.kind,version=version,source=normalized,entries={}} + local valid; valid,err=Importers.validatePack(manifest,id,pack) + if not valid then return nil,err end + fs=Importers.fs(fs) + if not (fs and fs.write and fs.rename and fs.remove) then return nil,"atomic pack publication is unavailable" end + local state,count="open",0 + local scope=Importers.pinScope(fs) + local root=assert(Importers.packRoot(id,pack)) + local api={} + local function object(bytes,extension) + if type(bytes)~="string" or #bytes>Importers.MAX_ENTRY_BYTES then return nil,"asset exceeds the pack read limit" end + local file="objects/"..MD5.new():update(bytes):final().."."..extension + local path=root.."/"..file + if Data.read(fs,path,#bytes)~=bytes then + local ok,why=publish(fs,path,bytes);if not ok then return nil,why end + end + return {file=file,size=#bytes} + end + local function refuse(reason) + state="failed";return nil,reason + end + function api:put(key,bytes,attributes,metadata,extension) + if state~="open" then return nil,"pack producer is "..state end + local inScope,scopeError=scope();if not inScope then return refuse(scopeError) end + if count>=20000 then return refuse("pack entry count limit") end + if type(key)~="string" or not key:match("^[a-z0-9_%-]+[a-z0-9_/%-]*$") or #key>512 then + return refuse("invalid native pack entry identity") + end + if manifest.entries[key] then return refuse("duplicate native pack entry") end + local attrs,why=literal(attributes or {}) + if not attrs then return refuse(why) end + extension=extension or (manifest.kind=="palette" and "lua" or "png") + if not (Importers.KINDS[manifest.kind].ext[extension]) then return refuse("asset extension does not match pack kind") end + attrs.file,attrs.size="entry."..extension,type(bytes)=="string" and #bytes or -1 + local metadataBytes + if metadata~=nil then + local decoded;decoded,metadataBytes=literal(metadata) + if not decoded then return refuse(metadataBytes) end + attrs.metadata={file="metadata.lua",size=#metadataBytes} + else attrs.metadata=nil end + local probe={format=manifest.format,importer=id,pack=pack,kind=manifest.kind, + version=version,source=manifest.source,entries={[key]=attrs}} + local checked;checked,why=Importers.validatePack(probe,id,pack) + if not checked then return refuse(why) end + local stored;stored,why=object(bytes,extension) + if not stored then return refuse(why) end + attrs.file,attrs.size=stored.file,stored.size + if metadataBytes then + attrs.metadata,why=object(metadataBytes,"lua") + if not attrs.metadata then return refuse(why) end + end + manifest.entries[key]=attrs;count=count+1 + return Data.copy(attrs) + end + function api:alias(key,existing) + if state~="open" then return nil,"pack producer is "..state end + local inScope,scopeError=scope();if not inScope then return refuse(scopeError) end + if count>=20000 then return refuse("pack entry count limit") end + if manifest.entries[key] or not manifest.entries[existing] then return refuse("invalid native pack alias") end + if type(key)~="string" or not key:match("^[a-z0-9_%-]+[a-z0-9_/%-]*$") or #key>512 then + return refuse("invalid native pack entry identity") + end + manifest.entries[key]=Data.copy(manifest.entries[existing]);count=count+1 + return Data.copy(manifest.entries[key]) + end + function api:finish() + if state~="open" then return nil,"pack producer is "..state end + local inScope,scopeError=scope();if not inScope then return refuse(scopeError) end + if count==0 then return refuse("native extraction produced no entries") end + local valid,why=Importers.validatePack(manifest,id,pack) + if not valid then return refuse(why) end + -- Recheck files at the publication boundary; an interrupted or externally + -- removed object must not turn a required pack into a ready one. + for _,entry in pairs(manifest.entries) do + for _,rec in ipairs({entry,entry.metadata or false}) do + if rec then + local info=fs.getInfo and fs.getInfo(root.."/"..rec.file,"file") + if not info or info.type~="file" or info.size~=rec.size then return refuse("native pack object is missing or changed") end + end + end + end + local normalized,encoded=literal(manifest) + if not normalized then return refuse(encoded) end + local ok;ok,why=publish(fs,assert(Importers.manifestPath(id,pack)),encoded) + if not ok then return refuse(why) end + state="published";Importers.invalidate(fs,id,pack) + return {importer=id,pack=pack,version=version,entries=count} + end + function api:cancel() if state=="open" then state="cancelled" end end + function api:state() return state end + return api +end +return Producer diff --git a/src/import/PackPublish.lua b/src/import/PackPublish.lua new file mode 100644 index 000000000..c50727e04 --- /dev/null +++ b/src/import/PackPublish.lua @@ -0,0 +1,27 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- A private staging name per Lua state plus a monotonic call sequence. Native +-- extractors and inbox installs share this publication path, including workers. +local Data=require("src.import.PackData") +local MD5=require("src.mods.StreamMD5") +local clock=love and love.timer and love.timer.getTime or os.clock +local owner={sequence=0} -- retained by the publisher; no gameplay RNG draws +local identity=table.concat({tostring(owner),tostring(os.time()),string.format("%.9f",clock())},":") +local nonce=MD5.new():update(identity):final() +return function(fs,path,bytes) + if not (fs and fs.write and fs.rename and fs.remove) then return nil,"atomic pack publication is unavailable" end + owner.sequence=owner.sequence+1 + local pending=path..".pending-"..nonce.."-"..owner.sequence + local called,ok,err=pcall(function() + local dir=path:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then + local made,why=fs.createDirectory(dir);if not made then return nil,why end + end + local wrote,why=fs.write(pending,bytes);if not wrote then return nil,why end + local staged;staged,why=Data.read(fs,pending,#bytes) + if staged~=bytes then return nil,why or "pack staging write did not match" end + return fs.rename(pending,path) + end) + if not called then err,ok=tostring(ok),nil end + if not ok then pcall(fs.remove,pending);return nil,err or "pack publication failed" end + return true +end diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..e80811703 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -22,6 +22,8 @@ -- Nothing here hardcodes an address; if the manifest lacks a symbol, the -- stage that needs it says so and is skipped rather than reading whatever -- happens to be at offset zero. +-- Terrain palette callback classifications are a separately identified, +-- source-pinned manifest profile, gated again on the actual full ROM digest. -- -- The CONTRACT is the same as Gen 2's, because the runtime's is: `new` takes -- (romData, version, manifest, progress), `run` walks DATA_STAGES then @@ -53,7 +55,7 @@ RomExtractorGen3.__index = RomExtractorGen3 -- The progress denominator. Kept honest with the two stage lists below: a -- mismatch does not break anything, it just makes the bar lie, which is the -- kind of small wrongness that survives for months. -local STAGE_COUNT = 94 +local STAGE_COUNT = 95 -- --------------------------------------------------------------------------- -- The stage tables below are keyed to Emerald's ROM. A sibling cartridge @@ -111,13 +113,25 @@ end -- rather than merely moved. Applied after the address map, restored with it. function RomExtractorGen3.applyStageOverrides(overrides) if type(overrides) ~= "table" then return 0 end + -- JSON objects preserve mixed Lua records (numeric pose entries plus a + -- named species field), but their numeric keys arrive as strings. + local function decoded(value) + if type(value) ~= "table" then return value end + local out = {} + for k, v in pairs(value) do + local key = type(k) == "string" and k:match("^%d+$") and tonumber(k) or k + out[key] = decoded(v) + end + return out + end local n = 0 for tableName, fields in pairs(overrides) do local t = RomExtractorGen3[tableName] if type(t) == "table" and type(fields) == "table" then for k, v in pairs(fields) do + k = type(k) == "string" and k:match("^%d+$") and tonumber(k) or k RomExtractorGen3._pristine[#RomExtractorGen3._pristine + 1] = { t, k, t[k] } - t[k] = v + t[k] = decoded(v) n = n + 1 end end @@ -2612,11 +2626,18 @@ function RomExtractorGen3:statFlourish() -- immediates independent of the compiler-selected low register. local drift = self:thumbImmCount(step, F.SCAN, 0x3000, 4) > 0 and 4 or nil local lap = self:thumbImmCount(step, F.SCAN, 0x2800, 128) > 0 and 128 or nil - local wrap = self:thumbImmCount(step, F.SCAN, 0x3000, 124) > 0 and 124 or nil - local laps = self:thumbImmCount(step, F.SCAN, 0x2800, 3) > 0 and 3 or nil + -- Ruby/Sapphire's sub_80E0CD0 finishes after TWO laps; the later task + -- runs THREE. Both compiled forms fuse the -4 scroll and +128 wrap as + -- ADD 124. Keep every existing motion/blob gate and select only the lap + -- count from the cartridge header, never the active application's version. + local gameCode = rom:u32(0xAC) + local rs = gameCode == 0x45565841 or gameCode == 0x45505841 -- AXVE / AXPE + local wrapValue, lapCount = 124, rs and 2 or 3 + local wrap = self:thumbImmCount(step, F.SCAN, 0x3000, wrapValue) > 0 and wrapValue or nil + local laps = self:thumbImmCount(step, F.SCAN, 0x2800, lapCount) > 0 and lapCount or nil local cx, cy = 96, 32 -- source-defined placement after this exact task is proved if not (drift and lap and wrap and laps) then - return fail("the stat-up task's installed step does not carry its 4/128/124/3 motion") + return fail("the stat-up task's installed step does not carry its cartridge-specific scroll/lap motion") end -- ...and the wrap has to bring the lap back where it started, less one -- step: 128 out, 124 back, and the step itself is the 4 that is missing @@ -3798,15 +3819,15 @@ function RomExtractorGen3:animCallbackFamily(callback) elseif hasArc and from == "attacker" then family = "arc_candidate" end - local isFireRed = self.version == "firered" - or (type(self.version) == "table" - and self.version.id == "firered") + local version = type(self.version) == "table" and self.version.id or self.version + local isFireRed = require("src.core.GameVersion").isFRLG(version) if isFireRed then + local canonical = (self.manifest.callbackAddressMap or {})[("%06X"):format(callback)] or callback if from == "target" then - local exact = RomExtractorGen3.FRLG_TARGET_LOCAL_LINEAR[callback] + local exact = RomExtractorGen3.FRLG_TARGET_LOCAL_LINEAR[canonical] if exact then family = exact end end - local exact = RomExtractorGen3.FRLG_CALLBACK_PATH[callback] + local exact = RomExtractorGen3.FRLG_CALLBACK_PATH[canonical] if exact then family = exact end end local rec = { family = family, @@ -3902,6 +3923,7 @@ function RomExtractorGen3:extractConstants() self:layoutValue("abilityNameLength", 13), numAbilities, "constants") local constants = { source = "ROM:Emerald" } + constants.gen3MauvilleGym = self.manifest.mauvilleGym -- WHERE A NEW GAME STARTS, and it is not a constant anywhere on the -- cartridge -- it is the argument list of WarpToTruck, the only call to @@ -4658,6 +4680,22 @@ function RomExtractorGen3:extractConstants() window.width, window.height, window.left, window.top) end + -- RS positions the field printer in task code, not in Emerald's BG + -- WindowTemplate records. Keep it separate from the battle-window anchor. + local rsWindow = self.manifest.rsFieldMessageWindow + if type(rsWindow) == "table" and rsWindow.kind == "rs-field-code" then + assert(rsWindow.left == 2 and rsWindow.top == 15 + and rsWindow.width == 26 and rsWindow.height == 4 + and type(rsWindow.source) == "string", "Invalid RS field-window metadata") + constants.gen3FieldMessageWindow = { + left = rsWindow.left, top = rsWindow.top, + width = rsWindow.width, height = rsWindow.height, + source = rsWindow.source, + } + Logger.info("Gen3 RS field window: %d columns by %d rows at (%d,%d)", + rsWindow.width, rsWindow.height, rsWindow.left, rsWindow.top) + end + -- THE ATTRACT SEQUENCE'S CAST. -- -- Its backdrops come out of the scene pass; its choreography is task code @@ -5171,7 +5209,8 @@ function RomExtractorGen3:extractConstants() underwater = trans.underwater, secretBaseOpponent = trans.secretBaseOpponent, secretBaseTransition = trans.championTransition, - trainerClasses = trans.trainerClasses, + trainerClasses = require('src.import.Gen3TransitionProfile').classes( + type(self.version)=='table' and self.version.id or self.version, trans.trainerClasses), eliteFour = trans.eliteFour, championTransition = trans.championTransition, aquaTransition = trans.aquaTransition, @@ -5829,7 +5868,7 @@ function RomExtractorGen3:extractConstants() constants.types = {} constants.typeOrder = {} for i = 0, numTypes - 1 do - local id = slug(typeNames[i]) + local id = (self.manifest.typeIds or {})[i + 1] or slug(typeNames[i]) if id == "" then id = ("TYPE_%02d"):format(i) end -- ONE-BASED, unlike speciesOrder / moveOrder / itemOrder, which are -- keyed by the cartridge's own number. It is not an accident and it @@ -7368,6 +7407,17 @@ function RomExtractorGen3:extractFont() local srcRow = top + row local half = srcRow < 8 and 0 or 2 -- the top pair of tiles, or the bottom local line = {} + if font.bits == 4 then + -- RS's shadowed 8x16 Latin font is two raw 4bpp tiles. Its + -- ApplyColors_ShadowedFont maps nibble F to ink and E to shadow. + local word = self.rom:u32(base + srcRow * 4) + for px = 0, cellW - 1 do + local nibble = math.floor(word / 2 ^ (px * 4)) % 16 + local role = nibble == 15 and 1 or (nibble == 14 and 2 or 0) + line[px + 1] = role + if role ~= 0 then any = true end + end + else for tile = 0, 1 do local word = self.rom:u16(base + (half + tile) * 16 + (srcRow % 8) * 2) for x = 0, 7 do @@ -7380,6 +7430,7 @@ function RomExtractorGen3:extractFont() end end end + end cell[row + 1] = line end roles[code + 1] = cell @@ -7654,12 +7705,21 @@ function RomExtractorGen3:extractFont() local across = math.floor(tonumber(face.tilesAcross) or 2) local stride = math.floor(tonumber(face.glyphBytes) or 0x40) for code = 0, count - 1 do - local base = face.glyphs + code * stride + local glyphCode = face.glyphMap and self.rom:u8(face.glyphMap + + code * (face.glyphMapStride or 1) + (face.glyphMapOffset or 0)) or code + local base = face.glyphs + glyphCode * stride local cell = {} for row = 0, fh - 1 do local srcRow = ftop + row local half = srcRow < 8 and 0 or across local line = {} + if face.bits == 4 then + local word = self.rom:u32(base + srcRow * 4) + for px = 0, fw - 1 do + local nibble = math.floor(word / 2 ^ (px * 4)) % 16 + line[px + 1] = nibble == 15 and 1 or (nibble == 14 and 2 or 0) + end + else for tile = 0, across - 1 do local word = self.rom:u16(base + (half + tile) * 16 + (srcRow % 8) * 2) @@ -7670,10 +7730,11 @@ function RomExtractorGen3:extractFont() end end end + end cell[row + 1] = line end cells[code + 1] = cell - fadv[code + 1] = self.rom:u8(face.widths + code) + fadv[code + 1] = self.rom:u8(face.widths + glyphCode) end local okFace = pcall(function() local image = paintPage(cells, fw, fh) @@ -7868,6 +7929,30 @@ RomExtractorGen3.BRAILLE_ALPHABET = { -- One 16x16 cell out of the sheet, as roles: 1 raised, 2 unraised, 3 paper. function RomExtractorGen3:brailleGlyph(base, value) local B = RomExtractorGen3.BRAILLE + local profile = self.manifest and self.manifest.braille + if profile and profile.bits == 1 then + local cell = {} + for row = 0, B.CELL - 1 do + local line = {} + for half = 0, 1 do + local code = value + half * profile.halfCodeOffset + local tile = self.rom:u8(profile.glyphMap + code * 2 + math.floor(row / 8)) + local blank = self.rom:u8(profile.glyphMap + + half * profile.halfCodeOffset * 2 + math.floor(row / 8)) + local word = self.rom:u8(base + tile * 8 + row % 8) + local paper = self.rom:u8(base + blank * 8 + row % 8) + for x = 0, 7 do + local ink = math.floor(word / 2 ^ (7 - x)) % 2 == 1 + local unraised = math.floor(paper / 2 ^ (7 - x)) % 2 == 1 + -- RS draws small dots even in the blank cell. Keep those as a + -- separate role so the six raised-dot masks can still be proved. + line[half * 8 + x + 1] = ink and (unraised and 2 or 1) or 3 + end + end + cell[row + 1] = line + end + return cell + end local at = base + math.floor(value / B.PER_ROW) * B.ROW + (value % B.PER_ROW) * B.STRIDE local cell = {} @@ -7921,6 +8006,7 @@ end function RomExtractorGen3:extractBraille() self:beginStage("Gen3 braille") local B = RomExtractorGen3.BRAILLE + local profile = self.manifest and self.manifest.braille local base = self:symbol("gFontBrailleLatinGlyphs") local pool = self._scriptPool if not base then @@ -8021,7 +8107,7 @@ function RomExtractorGen3:extractBraille() local height = math.min(B.WINDOW.maxHeight, B.WINDOW.baseHeight + B.WINDOW.perLine * (#lines - 1)) - return { + local wall = { at = at, format = format, lines = lines, @@ -8031,6 +8117,15 @@ function RomExtractorGen3:extractBraille() x = math.floor((B.WINDOW.screenWidth - width) / 2), y = math.floor((B.WINDOW.screenHeight - height) / 2), } + if profile and profile.frameFromHeader then + -- RS's frame coordinates include the border, and its text origin is + -- independent of that frame. Preserve both from ScrCmd_braillemessage. + wall.x, wall.y = format[1] + 1, format[2] + 1 + wall.width, wall.height = format[3] - format[1] - 1, + format[4] - format[2] - 1 + wall.textX, wall.textY = format[5] * 8, format[6] * 8 + end + return wall end local messages, count, unreadable = {}, 0, 0 @@ -8064,7 +8159,7 @@ function RomExtractorGen3:extractBraille() perRow = B.PER_ROW, count = B.COUNT, advance = B.CELL, - lineHeight = B.CELL + B.LINE_SPACING, + lineHeight = profile and profile.lineHeight or (B.CELL + B.LINE_SPACING), window = B.WINDOW, messages = messages, source = ("ROM:gFontBrailleLatinGlyphs %07X (%d cells, %dx%d, %d per " @@ -8074,7 +8169,8 @@ function RomExtractorGen3:extractBraille() } local okPage, pageErr = pcall(function() - local paint = { [1] = FONT_TEXT, [2] = FONT_SHADOW } + local paint = { [1] = FONT_TEXT, + [2] = profile and profile.bits == 1 and FONT_TEXT or FONT_SHADOW } local rows = math.ceil(B.COUNT / B.PER_ROW) local image = self:fontSheet(B.PER_ROW * B.CELL, rows * B.CELL) if not image then return end @@ -9305,7 +9401,7 @@ function RomExtractorGen3:extractOverworldSprites() pixels[id] = { cells = cells, colors = colors, width = width, height = frameHeight, - runCells = runCells } + runCells = runCells, originalOrder = order, originalRunOrder = runOrder } local key = ("SPRITE_G3_%03d"):format(id) out[key] = { @@ -9828,6 +9924,35 @@ function RomExtractorGen3:extractOverworldSprites() walkers = walkers, still = still, failed = failed, sheets = drawn, substituted = substituted } self:write("sprites", out) + -- Keep original picture-table order and palette words as an additive + -- compact cache. Existing actor sheets and their frame numbers stay intact. + local edition = type(self.version) == "table" and self.version.id or self.version + local originalSheets = require("src.import.Gen3ObjectSheets").extract(self.rom, { + edition = edition, romSha1 = self.manifest.romSha1, + gfxTable = table_, count = count, paletteTable = palTable, paletteCount = palCount, + wideSubsprites = edition == "ruby" or edition == "sapphire", + reflectionProfile = edition == "emerald" and "raw" or "paired", + avatarTables = self.manifest.objectAvatars or require("src.import.gba.object_avatar_tables")[edition], + reflectionFor = function(info) return self:reflectionPaletteFor(info) end, + progress = function(current,total) + if self.checkpoint then self.checkpoint(("Decoding original object sheets %d/%d"):format(current,total)) end + end, + }) + for id,cell in pairs(pixels) do + local record = originalSheets.objects[id] + if record then + local key = ("SPRITE_G3_%03d"):format(id) + record.native = { key=key, width=cell.width, height=cell.height, order=cell.originalOrder, walker=out[key] and out[key].walker, + image=out[key] and out[key].image, frames=out[key] and math.max(out[key].frames or 1,out[key].poseFrames or 1), + defaultWidth=out[key] and out[key].frameWidth, defaultHeight=out[key] and out[key].frameHeight, + runKey=out[key.."_RUN"] and key.."_RUN" or nil, runOrder=cell.originalRunOrder, + runImage=out[key.."_RUN"] and out[key.."_RUN"].image, + runFrames=out[key.."_RUN"] and math.max(out[key.."_RUN"].frames or 1,out[key.."_RUN"].poseFrames or 1), + runWidth=out[key.."_RUN"] and out[key.."_RUN"].frameWidth,runHeight=out[key.."_RUN"] and out[key.."_RUN"].frameHeight, + reflectionKey=out[key.."_REFLECT"] and key.."_REFLECT" or nil } + end + end + self:write("gen3_object_sheets", originalSheets) Logger.info("Gen3 overworld sprites: %d rows, %d that turn and walk, %d " .. "non-walkers, %d unreadable, %d sheets composed, %d " .. "run-time slots standing in", @@ -9946,6 +10071,12 @@ function RomExtractorGen3:extractTrainerSprites() if not ok then Logger.warn("gen3 trainer pic %d failed", i) end end + -- Retain the native numeric portrait catalogue for presentation consumers. + local constants=self._constants or{} + constants.gen3TrainerFront=pathFor + self._constants=constants + self:write("constants",constants) + -- ...AND THE PATH ONTO EVERY TRAINER ROW. -- -- `pic` is a PATH in this engine: BattleState.trainerPicPath hands it @@ -12034,6 +12165,14 @@ end function RomExtractorGen3:extractFieldEffects() self:beginStage("Gen3 field effects") + local ok,digest=pcall(require('src.import.RomVerification').sha1,self.rom.data) + local edition=type(self.version)=='table'and self.version.id or self.version + local artwork,why=require('src.import.Gen3FieldMoveArt').extract(self.rom,self.manifest,ok and digest or nil,edition,self.checkpoint) + local constants=self._constants or{} + constants.gen3FieldMovePresentation=artwork + self._constants=constants + if not artwork then Logger.warn('gen3 field move artwork unavailable: %s',tostring(why))end + self:write('constants',constants) local heal, howMany, distinct = self:fieldEffectRoles() if not heal or howMany ~= 1 then Logger.warn("gen3 field effects: %d of the region's %d effect ids sit in " @@ -12359,7 +12498,11 @@ function RomExtractorGen3:extractDexEntries() frontCoords = coordTable(front) or frontCoords backCoords = coordTable(back) or backCoords end - local stride, poseAt = frlg and 36 or 32, frlg and 26 or 22 + -- Ruby/Sapphire also carry two description pointers. Their manifest + -- declares the same 36-byte structure without selecting FRLG UI rules. + local stride = (self.manifest or {}).pokedexEntrySize or (frlg and 36 or 32) + assert(stride == 32 or stride == 36, "Unsupported Gen3 Pokedex entry size") + local poseAt = stride == 36 and 26 or 22 for _, def in pairs(self._pokemon) do local dex = def.dex if dex and dex > 0 and dex <= 386 then @@ -12751,6 +12894,19 @@ function RomExtractorGen3:extractMapLayouts() if secondary then tilesets[secondary] = true end local blockPtr = self.rom:pointer(a + 12) local borderPtr = self.rom:pointer(a + 8) + local borderW, borderH = 2, 2 + if (self.manifest.metatileAttributes or {}).bytes == 4 then + borderW, borderH = self.rom:u8(a + 24), self.rom:u8(a + 25) + end + local nativeBorder = borderPtr and self:layoutBlocks(borderPtr, 4) or nil + -- Unused layout records can declare a zero-sized source border. Keep + -- native extraction unchanged and report unavailable source metadata; + -- do not turn a compatibility observation into a native import failure. + local sourceBorder + if borderW > 0 and borderH > 0 and borderPtr and borderPtr + borderW * borderH * 2 <= self.rom.size then + sourceBorder = borderW * borderH == 4 and nativeBorder + or self:layoutBlocks(borderPtr, borderW * borderH) + end cells = cells + width * height -- COLLISION LIVES ON THE MAP CELL, not on the tileset. -- @@ -12784,7 +12940,10 @@ function RomExtractorGen3:extractMapLayouts() primaryTileset = primary, secondaryTileset = secondary, -- the border is always 2x2 metatiles in Emerald - border = borderPtr and self:layoutBlocks(borderPtr, 4) or nil, + border = nativeBorder, + sourceBorder = sourceBorder, + sourceBorderWidth = borderW, + sourceBorderHeight = borderH, blocks = blockPtr and self:layoutBlocks(blockPtr, width * height) or nil, source = ("ROM:gMapLayouts[%d]"):format(i), } @@ -13413,7 +13572,7 @@ function RomExtractorGen3:extractMaps() local count = rom:u32(connAt) local at = rom:pointer(connAt + 4) if at and count > 0 and count <= 8 then - local byDir = {} + local byDir, sourceConnections = {}, {} local byDirRows, order = {}, {} local any = false for i = 0, count - 1 do @@ -13441,6 +13600,7 @@ function RomExtractorGen3:extractMaps() -- way. `list` carries all of them in ROM order, which is the -- order the cartridge searches. if dir and to then + sourceConnections[#sourceConnections + 1] = { dir = dir, map = to, offset = rom:s32(o + 4) } local rows = byDirRows[dir] if not rows then rows = {} @@ -13471,6 +13631,7 @@ function RomExtractorGen3:extractMaps() end if any then def.connections = byDir + def.sourceConnections = sourceConnections connected = connected + 1 end end @@ -13489,6 +13650,8 @@ function RomExtractorGen3:extractMaps() def.collisionCells = layout.collisionCells def.elevationCells = layout.elevationCells def.border = layout.border + def.sourceBorder, def.sourceBorderWidth, def.sourceBorderHeight = + layout.sourceBorder, layout.sourceBorderWidth, layout.sourceBorderHeight -- Gen 3 has no single border BLOCK -- it has a 2x2 border patch -- so -- the def carries its top-left corner for the engine's border fill and -- the full patch beside it. @@ -13550,7 +13713,7 @@ function RomExtractorGen3:decodeScriptAt(start, queue) if not command then return lines, ("unknown opcode $%02X at %07X"):format(op, o) end - local name, spec = command[1], command[2] + local name, spec = command[1], Gen3ScriptOps.operandSpec(command, self.version) local at, args = o + 1, {} if spec == "*" then local kind = rom:u8(at) @@ -13655,6 +13818,10 @@ function RomExtractorGen3:decodeScriptAt(start, queue) -- `specialRemap`, 0-based, -1 for a FireRed-only one, which becomes -- 0x1000 + its own index so it can never land on an Emerald handler). local remap = self.manifest.specialRemap + local sourceSpecial + if name == "special" or name == "specialvar" then + sourceSpecial = args[name == "special" and 1 or 2] + end if remap and (name == "special" or name == "specialvar") then local slot = name == "special" and 1 or 2 local raw = args[slot] @@ -13663,7 +13830,8 @@ function RomExtractorGen3:decodeScriptAt(start, queue) args[slot] = (to >= 0) and to or (0x1000 + raw) end end - lines[#lines + 1] = { address = o, op = op, name = name, args = args } + lines[#lines + 1] = { address = o, op = op, name = name, args = args, sourceSpecial = sourceSpecial, + sourceBytes = rom:sub(o + 1, at - o - 1), sourceSpec = spec } o = at if Gen3ScriptOps.TERMINATORS[op] then return lines, nil end if op == 0x27 and previous and Gen3ScriptOps.WARP_YIELD[previous] then @@ -13797,6 +13965,14 @@ RomExtractorGen3.FIELD_CALLS = { -- is what lets a stage read what a special DOES rather than take its name on -- trust. function RomExtractorGen3:specialAddress(index) + -- Stage readers name engine/Emerald specials; the RS cartridge numbers + -- the same functions differently. Unknown identities must not probe an + -- unrelated native function at the engine's numeric slot. + local indices = self.manifest.specialIndexMap + if indices then + index = indices[tostring(index)] + if index == nil then return nil end + end local base = self:symbol("gSpecials") if not base then return nil end local ptr = self.rom:u32(base + (tonumber(index) or 0) * 4) @@ -18016,7 +18192,7 @@ function RomExtractorGen3:extractMapScripts() for i, line in ipairs(lines) do local row = { line.name } for k = 1, #line.args do row[k + 1] = line.args[k] end - rows[i] = row + rows[i] = require("src.import.Gen3ScriptInterface").row(line, row) end scripts[("S%07X"):format(start)] = rows if err then failures[#failures + 1] = err end @@ -18175,6 +18351,8 @@ function RomExtractorGen3:extractMapScripts() source = "RomExtractorGen3", scripts = scripts, movements = movements, -- filled by extractMovementScripts + sourceInterface = require("src.import.Gen3ScriptInterface").header( + type(self.version) == "table" and self.version.id or self.version, self.manifest.romSha1), -- local ids the map's own scripts spawn; see the note above spawned = spawned, -- ...and the ones an arrival callback puts BACK, which start on the map @@ -27068,13 +27246,17 @@ function RomExtractorGen3:extractTilesets() local ok, err = pcall(function() local raw = compressed and rom:lz77(tilesAt) or rom:bytes(tilesAt, 512 * 32) - local palettes = {} + local palettes, sourcePalettes = {}, {} for p = 0, 15 do - palettes[p + 1] = RomGba.palette(rom:bytes(palAt + p * 32, 32)) + local rawPalette = rom:bytes(palAt + p * 32, 32) + palettes[p + 1] = RomGba.palette(rawPalette) + for _, value in ipairs(rawPalette) do sourcePalettes[#sourcePalettes + 1] = string.char(value) end end -- metatile composition and attributes, as byte strings for the same -- reason the blockdata is: 73 tilesets come to about 26,000 metatiles - local comp, attrs = {}, {} + local comp, attrs, sourceAttrs = {}, {}, {} + local A = self.manifest.metatileAttributes + local sourceBytes = A and A.bytes == 4 and 4 or 2 for m = 0, metatiles - 1 do for t = 0, 7 do local v = rom:u16(metaAt + m * 16 + t * 2) @@ -27085,10 +27267,12 @@ function RomExtractorGen3:extractTilesets() -- downstream reads Emerald's u16 (behaviour low byte, layer type -- bits 12-15) with Emerald's numbering, so the record is packed -- into that shape here, once (manifest `metatileAttributes`). - local A = self.manifest.metatileAttributes local av if A and A.bytes == 4 then local raw32 = rom:u32(attrAt + m * 4) + sourceAttrs[#sourceAttrs + 1] = string.char(raw32 % 256, + math.floor(raw32 / 256) % 256, math.floor(raw32 / 65536) % 256, + math.floor(raw32 / 16777216) % 256) local beh = raw32 % ((A.behaviorMask or 0x1FF) + 1) local own = RomExtractorGen3.FRLG_BEHAVIOUR_OVERRIDES[beh] local mapped = A.behaviorRemap and A.behaviorRemap[tostring(beh)] @@ -27122,9 +27306,16 @@ function RomExtractorGen3:extractTilesets() -- owns its own bank. Which half to read is the pair's business, not -- the tileset's -- see palettesInPrimary in the manifest. palettes = palettes, + sourcePaletteBytes = table.concat(sourcePalettes), tiles = table.concat(pixels), metatiles = table.concat(comp), attributes = table.concat(attrs), + -- Compatibility readers need the cartridge's own behavior IDs. + -- Native FRLG normalization is many-to-one and cannot be reversed. + -- A 16-bit source is already byte-exact in attributes: do not copy it. + sourceAttributes = sourceBytes == 4 and table.concat(sourceAttrs) or nil, + sourceAttributeBytes = sourceBytes, + sourceBehaviorMask = A and A.behaviorMask or (sourceBytes == 4 and 511 or 255), callback = rom:u32(a + (attrAt == rom:pointer(a + 16) and 20 or 16)), image = "assets/generated/tilesets/" .. key:lower() .. ".png", source = ("ROM:Tileset@%07X"):format(a), @@ -27469,6 +27660,11 @@ function RomExtractorGen3:extractTilesets() palettesInPrimary = self:layoutValue("palettesInPrimary", 6), metatileLayers = self:layoutValue("metatileLayers", 2), } + if self.manifest.metatileBehaviorFlags then + local raw, why = require("src.import.Gen3SourceFlags").read(self.rom, self.manifest.metatileBehaviorFlags) + assert(raw, why) + constants.gen3SourceBehaviorFlags = { version = 1, raw = raw, romSha1 = self.manifest.romSha1 } + end self._constants = constants self:write("constants", constants) Logger.info("Gen3 tileset pairs: %d (the unit a metatile actually draws from)", @@ -29019,6 +29215,8 @@ end function RomExtractorGen3:extractPlayerBackPic() self:beginStage("Gen3 player back pic") local rom = self.rom + local profile = self.manifest.trainerBack + local backCount = profile and profile.count or GEN3_BACK_COUNT if (self.manifest or {}).frlgItemMenu ~= nil then return self:extractPlayerBackPicFireRed() end @@ -29033,7 +29231,7 @@ function RomExtractorGen3:extractPlayerBackPic() local function runAt(at) local recs = {} - for k = 0, GEN3_BACK_COUNT - 1 do + for k = 0, backCount - 1 do local r = recordAt(at + k * 8) if not r then return nil end if r.tag ~= k then return nil end @@ -29049,13 +29247,28 @@ function RomExtractorGen3:extractPlayerBackPic() end local found, at = nil, 0 - while at < rom.size - GEN3_BACK_COUNT * 8 do + if profile then + assert(backCount >= 1 and backCount <= 8, "invalid trainer back count") + local recs = {} + for k = 0, backCount - 1 do + local row = recordAt(profile.table + k * 8) + if not (row and row.tag == k and row.size > 0 + and row.size % GEN3_BACK_FRAME_BYTES == 0 + and row.size <= GEN3_BACK_FRAME_BYTES * GEN3_BACK_MAX_FRAMES) then + Logger.warn("gen3 player back pic: invalid native compressed row %d", k) + return + end + recs[k + 1] = row + end + found = { at = profile.table, recs = recs } + end + while not profile and at < rom.size - backCount * 8 do local recs = runAt(at) if recs then -- ...and the run has to STOP at eight, or it is some other table - local ninth = recordAt(at + GEN3_BACK_COUNT * 8) + local ninth = recordAt(at + backCount * 8) local last = recs[#recs] - if not (ninth and ninth.tag == GEN3_BACK_COUNT + if not (ninth and ninth.tag == backCount and ninth.at == last.at + last.size) then if found then Logger.warn("gen3 player back pic: %07X and %07X both look like the " @@ -29071,14 +29284,14 @@ function RomExtractorGen3:extractPlayerBackPic() end if not found then Logger.warn("gen3 player back pic: no run of %d sheets is laid out end to " - .. "end, so no back pic is written", GEN3_BACK_COUNT) + .. "end, so no back pic is written", backCount) return end -- the palettes sit immediately after, one per sheet, in the same order - local palBase = found.at + GEN3_BACK_COUNT * 8 + local palBase = profile and profile.palettes or (found.at + backCount * 8) local palettes = {} - for k = 0, GEN3_BACK_COUNT - 1 do + for k = 0, backCount - 1 do local ptr = rom:pointer(palBase + k * 8) local tag = rom:u16(palBase + k * 8 + 4) local ok, raw = RomExtractorGen3.lz77ok(rom, ptr or 0) @@ -29086,7 +29299,7 @@ function RomExtractorGen3:extractPlayerBackPic() and #raw >= GEN3_BACK_PAL_BYTES) then Logger.warn("gen3 player back pic: the palette table after %07X does " .. "not run 0..%d, so no back pic is written", - found.at, GEN3_BACK_COUNT - 1) + found.at, backCount - 1) return end palettes[k] = RomGba.palette(raw) @@ -29177,7 +29390,7 @@ function RomExtractorGen3:extractPlayerBackPic() local mine = {} if wanted.boy and wanted.girl then local best = {} - for k = 0, GEN3_BACK_COUNT - 1 do + for k = 0, backCount - 1 do local b = shared(palettes[k], wanted.boy) local g = shared(palettes[k], wanted.girl) if b >= GEN3_BACK_PAL_MIN and b - g >= GEN3_BACK_PAL_MARGIN then @@ -29194,7 +29407,7 @@ function RomExtractorGen3:extractPlayerBackPic() if not (mine.boy and mine.girl) then Logger.warn("gen3 player back pic: no two of the %d backs are drawn in " .. "the same palettes as the player's own two front pics, " - .. "so none is claimed", GEN3_BACK_COUNT) + .. "so none is claimed", backCount) end -- ...and the same match for the two who borrow the player's side of the @@ -29205,7 +29418,7 @@ function RomExtractorGen3:extractPlayerBackPic() local want = wanted[role] if want then local best, runnerUp - for k = 0, GEN3_BACK_COUNT - 1 do + for k = 0, backCount - 1 do local n = shared(palettes[k], want) if not best or n > best.score then runnerUp = best @@ -29228,9 +29441,17 @@ function RomExtractorGen3:extractPlayerBackPic() -- ---- and write them ----------------------------------------------------- local images, written = {}, 0 - for k = 0, GEN3_BACK_COUNT - 1 do + for k = 0, backCount - 1 do local rec = found.recs[k + 1] - local raw = rom:bytes(rec.at, rec.size) + local raw + if profile and profile.compressed then + local ok, bytes = RomExtractorGen3.lz77ok(rom, rec.at) + if not ok or #bytes ~= rec.size then + Logger.warn("gen3 player back pic: compressed sheet %d has the wrong size", k) + return + end + raw = bytes + else raw = rom:bytes(rec.at, rec.size) end local frames = math.floor(rec.size / GEN3_BACK_FRAME_BYTES) local ok = pcall(function() -- frame 1 is the one a battle opens on; the rest are the throw @@ -29346,7 +29567,7 @@ function RomExtractorGen3:extractPlayerBackPic() end local function arrayAt(a) local rows = {} - for k = 0, GEN3_BACK_COUNT - 1 do + for k = 0, backCount - 1 do local ptr = rom:pointer(a + k * 4) if not ptr then return nil end local rec = found.recs[k + 1] @@ -29364,7 +29585,7 @@ function RomExtractorGen3:extractPlayerBackPic() throwsAt, anims = a, rows -- on the image rows too, where everything else about a back already -- is -- but the record below is what survives a headless import - for k = 0, GEN3_BACK_COUNT - 1 do + for k = 0, backCount - 1 do if images[k] then images[k].rest = rows[k].rest images[k].throw = rows[k].throw @@ -29401,7 +29622,7 @@ function RomExtractorGen3:extractPlayerBackPic() local constants = self._constants or {} constants.gen3TrainerBack = { images = images, - count = GEN3_BACK_COUNT, + count = backCount, anims = anims, throwAnim = throwsAt and RomExtractorGen3.BACK_INTRO.THROW_ANIM or nil, intro = intro, @@ -32119,8 +32340,46 @@ function RomExtractorGen3:itemMenuActionsFireRed(constants, M) table.concat(rows[3], ","), martFound) end +-- RS has ten actions, fixed six-byte pocket rows and a column-major grid. +-- The Emerald scanner expects a different action count/layout and must not +-- infer this table from its repeated English labels. +function RomExtractorGen3:itemMenuActionsRS(constants, M) + assert(M.actionCount == 10 and M.choiceStride == 6 and M.columns == 2 + and M.sourceOrder == "column-major", "Invalid verified RS action layout") + local rom, labels, kinds, pockets = self.rom, {}, {}, {} + for i = 1, M.actionCount do + local offset = M.actions + (i - 1) * 8 + local pointer = assert(rom:pointer(offset), "Missing RS item-action label") + labels[i] = self:readText(pointer, 20) + kinds[i] = assert(M.kinds[i], "Missing RS item-action handler kind") + assert((kinds[i] == "blank") == (rom:u32(offset + 4) == 0), "RS blank action handler mismatch") + end + assert(labels[M.blank] == "" and kinds[M.cancel] == "cancel" + and kinds[M.register] == "register", "RS item-action sentinels differ") + for key, spec in pairs(M.pockets) do + assert((spec.count == 4 or spec.count == 6) and spec.row >= 0 and spec.row < 5) + local raw = {} + for i = 1, spec.count do + raw[i] = rom:u8(M.choices + spec.row * M.choiceStride + i - 1) + 1 + assert(kinds[raw[i]], "RS pocket references an unknown action") + end + assert(raw[#raw] == M.cancel, "RS pocket does not close on CANCEL") + local rows, list = spec.count / 2, {} + for row = 1, rows do + for col = 0, 1 do list[#list + 1] = raw[col * rows + row] end + end + pockets[key] = list + end + constants.gen3ItemMenu = {labels=labels,kinds=kinds,pockets=pockets,columns=2, + cancel=M.cancel,blank=M.blank,register=M.register, + source=("ROM:sItemPopupMenuActions %07X, sItemPopupMenuChoicesTable %07X; RS column-major to native row-major") + :format(M.actions,M.choices)} +end + function RomExtractorGen3:itemMenuActions(constants) local rom = self.rom + local rsMenu = (self.manifest or {}).rsItemMenu + if type(rsMenu) == "table" then return self:itemMenuActionsRS(constants, rsMenu) end local frlgMenu = (self.manifest or {}).frlgItemMenu if type(frlgMenu) == "table" then return self:itemMenuActionsFireRed(constants, frlgMenu) @@ -32657,7 +32916,17 @@ function RomExtractorGen3:extractBattleWindows() end local constants = self._constants or {} + -- Keep additional genuine templates from the identified array. A terminator + -- or an invalid row stops the scan; absent windows are never fabricated. + local templates={} + for id=0,14 do + local r=rec(found[0].at+id*8) + if not r or r.bg>3 or r.pal>15 or r.left>31 or r.top>63 + or r.w>30 or r.h>20 then break end + templates[id+1]=win(r) + end constants.gen3BattleWindows = { + templates=templates, message = win(found[0]), prompt = win(found[1]), action = win(found[2]), @@ -40984,7 +41253,7 @@ RomExtractorGen3.DATA_STAGES = { "extractMoveAnimations", "extractStarterSelect", "extractNamingKeyboard", "extractBattleMenu", "extractSummaryText", - "extractHoennDex", "extractTilesetAnimations", "extractSafari", + "extractHoennDex", "extractTilesetAnimations", "extractTilesetPalettePrograms", "extractSafari", "extractTrainerMemo", "extractAbilityDescriptions", "extractMonAnims", "extractCries", "extractReflections", "extractElevation", "extractBattleWindows", @@ -42519,6 +42788,29 @@ function RomExtractorGen3:thumbLength(fn, limit) return nil end +function RomExtractorGen3:extractTilesetPalettePrograms() + self:beginStage("Gen3 tileset palette programs") + if type(self._tilesets)~="table" then + Logger.warn("gen3 palette programs: no tilesets -- run extractTilesets first") + return + end + -- Authenticate the actual bytes, not just the manifest/selected game label. + -- The application has verified these too, but a standalone stage must not + -- reinterpret another revision through a known program-address catalog. + local ok,digest=pcall(require("src.import.RomVerification").sha1,self.rom.data) + local report=require("src.import.Gen3PalettePrograms").extract( + self.rom,self._tilesets,self.manifest,ok and digest or nil) + self._tilesets._romInfo=self._tilesets._romInfo or {} + self._tilesets._romInfo.palettePrograms=report + self:write("map_tilesets",self._tilesets) + Logger.info("Gen3 palette programs: %d static, %d animated, %d unavailable", + report.static,report.animated,report.unavailable) + if report.unavailable>0 then + Logger.warn("gen3 palette programs: incomplete sources retain native base palettes; " + .. "check map_tilesets.paletteProgram diagnostics and the ROM revision") + end +end + function RomExtractorGen3:extractTilesetAnimations() self:beginStage("Gen3 tileset animations") local A = RomExtractorGen3.ANIM @@ -45303,6 +45595,7 @@ RomExtractorGen3.FRLG_TITLE = { BORDER = { PAL = 0xEAE094, TILES = 0x3BF58C, MAP = 0x3BF5A8 }, FLAMES = { GFX = 0x3BF79C, PAL = 0x3BF77C, COLS = 2, ROWS = 2 }, FLAME_X = 0x3BFBD4, -- sFlameXPositions, 15 u8 + EFFECT = "flames", CRY = "CHARIZARD", STREAK = false, STREAK_Y = false, } -- A 30x20 screen from a 32-column tilemap, honouring flips. `blink` swaps @@ -45381,12 +45674,23 @@ function RomExtractorGen3:extractFireRedTitle() size = 16 } end local flameX = {} - for i = 0, 14 do - flameX[#flameX + 1] = self.rom:u8(T.FLAME_X + i) or 0 + if T.FLAME_X then + for i = 0, 14 do + flameX[#flameX + 1] = self.rom:u8(T.FLAME_X + i) or 0 + end + end + local streakY = {} + if T.STREAK then + local img, frames = self:frlgIntroSpriteSheet(T.STREAK, "streak") + if img then + self:saveImage(img, "title_frlg/streak.png") + images.streak = { path = "assets/generated/title_frlg/streak.png", frames = frames } + end + for i = 0, 7 do streakY[#streakY + 1] = self.rom:u16(T.STREAK_Y + i * 2) end end local constants = self._constants or {} constants.gen3FRLGTitle = { - images = images, flameX = flameX, + images = images, flameX = flameX, effect = T.EFFECT, cry = T.CRY, streakY = streakY, backdrop = backdrop and { backdrop[1], backdrop[2], backdrop[3] } or nil, source = "ROM:gGraphics_TitleScreen_* and title_screen.c sBorderBg/sFlames", } @@ -54807,7 +55111,7 @@ function RomExtractorGen3:extractBallAnims() sounds = B.SOUNDS, cases = B.CASES, particleFrames = B.PARTICLE_FRAMES, - source = ("ROM:gBallSpriteSheets %07X, palettes %07X, open frame %07X, " + source = B.SOURCE or ("ROM:gBallSpriteSheets %07X, palettes %07X, open frame %07X, " .. "sBallColors %07X, particles %07X; the send-out off " .. "SpriteCB_PlayerMonSendOut_1 %07X and " .. "SpriteCB_ReleaseMonFromBall %07X") @@ -55332,15 +55636,17 @@ function RomExtractorGen3:slotPaylines() } end -function RomExtractorGen3:extractFireRedSlotMachine() - local S = { +RomExtractorGen3.FRLG_SLOTS = { REELS = 0x464926, PAYOUTS = 0x464966, REEL_PAL = 0x464974, REEL_TILES = 0x464A14, REEL_PAL_TAGS = 0x465608, DIGIT_PAL = 0x465524, DIGIT_TILES = 0x465544, BG_PAL = 0x465930, BG_TILES = 0x4659D0, BG_MAP = 0x4661D4, COUNT = 3, TAGS = 21, SYMBOLS = 7, - } +} + +function RomExtractorGen3:extractFireRedSlotMachine() + local S = RomExtractorGen3.FRLG_SLOTS local reels = {} for reel = 0, S.COUNT - 1 do local row, seen = {}, {} diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bbd959cfd..f7f57e344 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1289,7 +1289,7 @@ local function markerFor(version) -- sequence's art (extractTradeScene, gen3_trade_art) -- without it special -- 257 falls back to the bare swap, so both Gen 3 caches re-import once if version == "emerald" then revision = "emerald-trade-scene-v3:" end - if version == "firered" then revision = "firered-trade-scene-v1:" end + if GameVersion.isFRLG(version) then revision = version .. "-trade-scene-v1:" end if version == "polishedcrystal" then revision = "polished-stored-unown-forms-v7:" end if version == "prism" then revision = "prism-events-art-v2:" end -- hgss-phone-call-v1: gen4_hgss_phone_call (Gen4HgssPhoneCall), the @@ -1323,6 +1323,27 @@ local function markerFor(version) if version == "red" or version == "blue" or version == "yellow" then revision = "gen1-trade-art-v1:" end + if GameVersion.generation(version)==3 then + revision=revision..'gba-field-move-art-v1:' + revision=revision.."gba-script-operands-v"..require("src.import.Gen3ScriptOps").CACHE_REVISION..":" + revision=revision.."original-script-interface-v"..require("src.import.Gen3ScriptInterface").SCHEMA..":" + revision=revision.."original-object-sheets-v"..require("src.import.Gen3ObjectSheets").SCHEMA..":" + end + if version=='ruby' or version=='sapphire' then + revision=revision..'rs-transition-classes-v1:' + end + -- RS now carries its own action tables as well as field geometry. Rebuild these + -- caches; retain the earlier 36-byte dex and item-effect corrections. + -- v6 adds the cartridge's two-lap MetallicShine task to HARDEN/IRON DEFENSE. + -- Keep the v5 ball-art corrections; only fresh native extraction supplies + -- the RS-owned flourish image and 64-frame record. + if version == "ruby" or version == "sapphire" then revision = revision .. "rs-item-menu-v4:rs-stat-flourish-v6:" end + -- Keep original behavior IDs alongside the existing native normalization. + -- Only GBA caches lack this additional source contract; saves are unchanged. + if GameVersion.isGen3(version) then revision = revision .. "source-metatile-attrs-v1:source-map-views-v1:" end + if version == "emerald" or version == "ruby" or version == "sapphire" then + revision = revision .. "source-behavior-flags-v1:" + end return CACHE_FORMAT .. revision .. GameVersion.info(version).sha1 end @@ -1412,6 +1433,7 @@ local CREDITS = { }, } RomImporter.CREDITS = CREDITS +CREDITS.leafgreen = CREDITS.firered function RomImporter.creditFor(tab) return CREDITS[tab] or CREDITS.default @@ -1523,6 +1545,10 @@ local REQUIRED_FILES_GEN3 = { -- Absent, NPC.resolveSpriteDef falls through to SPRITE_RED and the whole -- region is populated by Kanto -- which is what it was. "data/generated/sprites.lua", + -- Original object picture order, independent of normalized native poses. + -- Older GBA caches re-import automatically through the existing readiness + -- check; no mod package or player save needs conversion. + "data/generated/gen3_object_sheets.lua", -- THE PARTY MENU'S OWN ART, and required for the same reason as the font: -- Data no longer blocks `icons` on a Gen 3 cache, so a cache that does not -- carry its own resolves to RED'S and every Hoenn species wears a Kanto @@ -1604,6 +1630,10 @@ local VERSION_REQUIRED_FILES = { "assets/generated/ui/item_pc_frlg.png", "assets/generated/ui/bag_male_item_pc.png", }, + leafgreen = { + "assets/generated/ui/item_pc_frlg.png", + "assets/generated/ui/bag_male_item_pc.png", + }, } -- WHAT A GEN 4 IMPORT MUST HAVE PRODUCED. @@ -4906,6 +4936,7 @@ function RomImporter:_pollPickedFiles(dt) end function RomImporter:update(dt) + self:_stepAssetPacks() local translation=require("src.translation.Service") translation.update() translation.setLauncher(self:_settings()) @@ -5452,6 +5483,7 @@ function RomImporter:_resetFrameRects() self.modRects = nil self.modDeleteRects = nil self.modImportRect = nil + self.modAssetPacksRect = nil self.modImportFileRects = nil self.modImportGameRects = nil -- Enable all / Disable all share that header and the same rule (#647): they @@ -7155,6 +7187,9 @@ function RomImporter:mousepressed(x, y, button) -- conflict); Delete fires immediately; a toggle switch, which lives in the -- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from -- a drag-scroll (Android, with no pointer polling, toggles on press). + if inside(self.modAssetPacksRect, x, y) then + self:installAssetPacks(); return + end if inside(self.modImportRect, x, y) then self:chooseMod(); return end @@ -7764,6 +7799,9 @@ function RomImporter:_drawTabBar(x, y, w, h, chip) { id = "firered", letter = "FR", top = PAL.chipFireRedTop, bot = PAL.chipFireRedBot, under = PAL.chipFireRedTop, label = Strings("FIRERED"), ink = PAL.chipInkSilver }, + { id = "leafgreen", letter = "LG", top = PAL.chipEmeraldTop, + bot = PAL.chipEmeraldBot, under = PAL.chipEmeraldTop, + label = Strings("LEAFGREEN"), ink = PAL.chipInkSilver }, -- FireRed and Emerald sit after Crystal because they are cartridges, and -- the row runs cartridges in generation order and then the hacks -- the same rule -- GameVersion.ORDER follows, and the one polished_crystal_registration_test @@ -7773,6 +7811,12 @@ function RomImporter:_drawTabBar(x, y, w, h, chip) -- what that costs: the game was hashed, recognised by setup, counted in -- "N of X ready" -- and had no way in. The tab row showed eight tabs -- beside a counter that said nine. + { id = "ruby", letter = "R", top = PAL.chipFireRedTop, + bot = PAL.chipFireRedBot, under = PAL.chipFireRedTop, + label = Strings("RUBY"), ink = PAL.chipInkSilver }, + { id = "sapphire", letter = "S", top = PAL.chipEmeraldTop, + bot = PAL.chipEmeraldBot, under = PAL.chipEmeraldTop, + label = Strings("SAPPHIRE"), ink = PAL.chipInkSilver }, { id = "emerald", letter = "E", top = PAL.chipEmeraldTop, bot = PAL.chipEmeraldBot, under = PAL.chipEmeraldTop, label = Strings("EMERALD"), ink = PAL.chipInkSilver }, @@ -8091,6 +8135,7 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged) elseif version == "silver" then accent = PAL.chipSilverTop elseif version == "crystal" then accent = PAL.chipCrystalTop elseif version == "firered" then accent = PAL.chipFireRedTop + elseif version == "leafgreen" then accent = PAL.chipEmeraldTop elseif version == "emerald" then accent = PAL.chipEmeraldTop elseif version == "platinum" then accent = PAL.chipPlatinumTop elseif version == "heartgold" then accent = PAL.chipHeartGoldTop @@ -9880,6 +9925,36 @@ end -- same options.mods enable-state the loader persists). Cheap enough to call on -- any toggle / install; the per-frame draw calls it lazily through _ensureMods -- so a still list costs nothing after the first paint. +function RomImporter:installAssetPacks() + if self.workState=="working" or self._assetPackInstallJob then return end + self._assetPackInstallJob=require("src.import.PackInbox").start() + self.modNotice={ok=true,text=Strings("Reading asset pack inbox...")} +end + +function RomImporter:_stepAssetPacks() + local co=self._assetPackInstallJob + if not co then return end + -- One bounded entry per frame, on phones and consoles as on desktop. + local ok,value=coroutine.resume(co) + if not ok then + self._assetPackInstallJob=nil + self.modNotice={ok=false,text=Strings("Asset pack installation failed: %s",tostring(value))} + elseif coroutine.status(co)=="dead" then + self._assetPackInstallJob=nil + if value.found==0 then + self.modNotice={ok=false,text=Strings("Copy extracted packs into %s/imports/asset_packs///, or supported cartridges into imports/asset_sources/.", + require("src.import.CacheFs").rootReport().path or love.filesystem.getSaveDirectory())} + elseif #value.failed>0 then + local first=value.failed[1] + self.modNotice={ok=false,text=Strings("Installed %d pack(s); %s/%s failed: %s",#value.installed, + first.importer,first.pack,first.error)} + else self.modNotice={ok=true,text=Strings("Installed %d asset pack(s).",#value.installed)} end + self:_refreshMods() + else + self.modNotice={ok=true,text=Strings("Installing %s/%s: %d/%d",value.importer,value.pack,value.done,value.total)} + end +end + function RomImporter:_refreshMods() local LauncherMods = require("src.mods.LauncherMods") -- Once per session, ahead of the first listing: pull in any mod the player @@ -10402,6 +10477,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) end local top = y + headerH + 14 * s + self.modAssetPacksRect = self:_glassyButton(x, top, w, btnH, + self._assetPackInstallJob and Strings("Installing asset packs...") or Strings("Import asset packs from inbox"), + self.saveBtnFont, not self._assetPackInstallJob and self.workState~="working") + top = top + btnH + 8 * s -- Notice line: the last install/delete result, else the platform hint. -- diff --git a/src/import/RomRevisions.lua b/src/import/RomRevisions.lua index 4eb0ab03d..f4a15965e 100644 --- a/src/import/RomRevisions.lua +++ b/src/import/RomRevisions.lua @@ -83,6 +83,16 @@ RomRevisions.ACCEPTED = { { sha1 = "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc", code = "BPRE", revision = 0, label = "Pokemon FireRed (USA, Europe)" }, }, + leafgreen = { + { sha1 = "574fa542ffebb14be69902d1d36f1ec0a4afd71e", code = "BPGE", revision = 0, + label = "Pokemon LeafGreen (USA, Europe)" }, + }, + ruby = { + { sha1 = "f28b6ffc97847e94a6c21a63cacf633ee5c8df1e", code = "AXVE", revision = 0, label = "Pokemon Ruby (USA)" }, + }, + sapphire = { + { sha1 = "3ccbbd45f8553c36463f13b938e833f652b793e4", code = "AXPE", revision = 0, label = "Pokemon Sapphire (USA)" }, + }, emerald = { { sha1 = "f3ae088181bf583e55daf962a92bb46f4f1d07b7", code = "BPEE", revision = 0, label = "Pokemon Emerald (USA, Europe)" }, @@ -132,10 +142,6 @@ RomRevisions.KNOWN = { .. "with a separate linker script) and the extractor is mapped to Rev 0", }, -- pret/pokefirered leafgreen*.sha1 - ["574fa542ffebb14be69902d1d36f1ec0a4afd71e"] = { - game = "leafgreen", code = "BPGE", revision = 0, - label = "Pokemon LeafGreen (USA, Europe)", - }, ["7862c67bdecbe21d1d69ce082ce34327e1c6ed5e"] = { game = "leafgreen", code = "BPGE", revision = 1, label = "Pokemon LeafGreen (USA, Europe) (Rev 1)", diff --git a/src/import/gba/map_catalog.lua b/src/import/gba/map_catalog.lua new file mode 100644 index 000000000..529ab0ba4 --- /dev/null +++ b/src/import/gba/map_catalog.lua @@ -0,0 +1,88 @@ +-- Public GBA map identities. This catalogue is metadata, not a map loader. +-- Slot/name observations come from the pinned public native reference; ROM +-- grids, pointers and tilesets are supplied by this engine's own importer. +local Version = require("src.core.GameVersion") +local Facts = require("src.import.gba.map_catalog_data") +local Catalog = {} +local indexes = {} + +local function profile(version) + version = version or Version.get() + return Facts.profiles[version], version +end + +function Catalog.rebuildIndex(version) + local spec, id = profile(version) + if not spec then return nil end + local index = { slots = {}, names = {}, reverse = {}, pret = {} } + for _, row in ipairs(Facts[spec.rows]) do + local group, number, canonical, pret, constant = unpack(row) + if spec.prefix then canonical = spec.prefix .. canonical:sub(4) end + local slot = group .. ":" .. number + index.slots[slot] = canonical + index.reverse[canonical] = { group, number } + index.pret[pret] = canonical + index.names[canonical], index.names[pret], index.names[slot] = canonical, canonical, canonical + if constant then index.names[constant] = canonical end + end + indexes[id] = index + return index.slots +end + +local function indexFor(version) + local spec, id = profile(version) + if not spec then return nil end + if not indexes[id] then Catalog.rebuildIndex(id) end + return indexes[id] +end + +local function slotNumber(n) + n = tonumber(n) + if not n or n < 0 or n > 255 or n % 1 ~= 0 then return nil end + return n +end + +function Catalog.mapIdFor(group, number, version) + group, number = slotNumber(group), slotNumber(number) + local index = indexFor(version) + return index and group and number and index.slots[group .. ":" .. number] or nil +end + +function Catalog.groupNumFor(name, version) + local index = indexFor(version) + local canonical = index and index.names[name] + local row = canonical and index.reverse[canonical] + if row then return row[1], row[2] end +end + +function Catalog.slotKeyFor(name, version) + local group, number = Catalog.groupNumFor(name, version) + if group then return group .. "_" .. number end +end + +function Catalog.resolve(name, number, version) + if number ~= nil then return Catalog.mapIdFor(name, number, version) end + if type(name) ~= "string" then return nil end + local index = indexFor(version) + return index and index.names[name] or name +end + +function Catalog.isKnown(name, version) + local index = indexFor(version) + return type(name) == "string" and index ~= nil and index.names[name] ~= nil +end + +function Catalog.pretToEngine(name, version) + if type(name) ~= "string" or name == "" then return nil end + local index = indexFor(version) + if not index then return nil end + if index.pret[name] then return index.pret[name] end + -- A spelling conversion is not proof that a physical map exists. + local spec = Facts.profiles[version or Version.get()] + local prefix = spec.prefix or spec.enginePrefix + name = name:gsub("Route(%d+)", "Route_%1") + name = name:gsub("%l%u", function(pair) return pair:sub(1, 1) .. "_" .. pair:sub(2) end) + return prefix .. name:gsub("-", "_"):upper():gsub("_+", "_") +end + +return Catalog diff --git a/src/import/gba/map_catalog_data.lua b/src/import/gba/map_catalog_data.lua new file mode 100644 index 000000000..0e1fb5a65 --- /dev/null +++ b/src/import/gba/map_catalog_data.lua @@ -0,0 +1,1355 @@ +-- Factual public map-slot/name observations, not ROM bytes or implementation. +-- Reference: bryanthaboi/gen1recomp 535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91. +-- Reproduce with tools/gba-map-check; FireRed/LeafGreen share slots. +local D = {} +D.firered = { + { 0, 0, "FR_BATTLE_COLOSSEUM_2P", "BattleColosseum_2P" }, + { 0, 1, "FR_TRADE_CENTER", "TradeCenter" }, + { 0, 2, "FR_RECORD_CORNER", "RecordCorner" }, + { 0, 3, "FR_BATTLE_COLOSSEUM_4P", "BattleColosseum_4P" }, + { 0, 4, "FR_UNION_ROOM", "UnionRoom" }, + { 1, 0, "FR_VIRIDIAN_FOREST", "ViridianForest" }, + { 1, 1, "FR_MT_MOON_1F", "MtMoon_1F" }, + { 1, 2, "FR_MT_MOON_B1F", "MtMoon_B1F" }, + { 1, 3, "FR_MT_MOON_B2F", "MtMoon_B2F" }, + { 1, 4, "FR_SSANNE_EXTERIOR", "SSAnne_Exterior" }, + { 1, 5, "FR_SSANNE_1F_CORRIDOR", "SSAnne_1F_Corridor" }, + { 1, 6, "FR_SSANNE_2F_CORRIDOR", "SSAnne_2F_Corridor" }, + { 1, 7, "FR_SSANNE_3F_CORRIDOR", "SSAnne_3F_Corridor" }, + { 1, 8, "FR_SSANNE_B1F_CORRIDOR", "SSAnne_B1F_Corridor" }, + { 1, 9, "FR_SSANNE_DECK", "SSAnne_Deck" }, + { 1, 10, "FR_SSANNE_KITCHEN", "SSAnne_Kitchen" }, + { 1, 11, "FR_SSANNE_CAPTAINS_OFFICE", "SSAnne_CaptainsOffice" }, + { 1, 12, "FR_SSANNE_1F_ROOM1", "SSAnne_1F_Room1" }, + { 1, 13, "FR_SSANNE_1F_ROOM2", "SSAnne_1F_Room2" }, + { 1, 14, "FR_SSANNE_1F_ROOM3", "SSAnne_1F_Room3" }, + { 1, 15, "FR_SSANNE_1F_ROOM4", "SSAnne_1F_Room4" }, + { 1, 16, "FR_SSANNE_1F_ROOM5", "SSAnne_1F_Room5" }, + { 1, 17, "FR_SSANNE_1F_ROOM7", "SSAnne_1F_Room7" }, + { 1, 18, "FR_SSANNE_2F_ROOM1", "SSAnne_2F_Room1" }, + { 1, 19, "FR_SSANNE_2F_ROOM2", "SSAnne_2F_Room2" }, + { 1, 20, "FR_SSANNE_2F_ROOM3", "SSAnne_2F_Room3" }, + { 1, 21, "FR_SSANNE_2F_ROOM4", "SSAnne_2F_Room4" }, + { 1, 22, "FR_SSANNE_2F_ROOM5", "SSAnne_2F_Room5" }, + { 1, 23, "FR_SSANNE_2F_ROOM6", "SSAnne_2F_Room6" }, + { 1, 24, "FR_SSANNE_B1F_ROOM1", "SSAnne_B1F_Room1" }, + { 1, 25, "FR_SSANNE_B1F_ROOM2", "SSAnne_B1F_Room2" }, + { 1, 26, "FR_SSANNE_B1F_ROOM3", "SSAnne_B1F_Room3" }, + { 1, 27, "FR_SSANNE_B1F_ROOM4", "SSAnne_B1F_Room4" }, + { 1, 28, "FR_SSANNE_B1F_ROOM5", "SSAnne_B1F_Room5" }, + { 1, 29, "FR_SSANNE_1F_ROOM6", "SSAnne_1F_Room6" }, + { 1, 30, "FR_UNDERGROUND_PATH_NORTH_ENTRANCE", "UndergroundPath_NorthEntrance" }, + { 1, 31, "FR_UNDERGROUND_PATH_NORTH_SOUTH_TUNNEL", "UndergroundPath_NorthSouthTunnel" }, + { 1, 32, "FR_UNDERGROUND_PATH_SOUTH_ENTRANCE", "UndergroundPath_SouthEntrance" }, + { 1, 33, "FR_UNDERGROUND_PATH_WEST_ENTRANCE", "UndergroundPath_WestEntrance" }, + { 1, 34, "FR_UNDERGROUND_PATH_EAST_WEST_TUNNEL", "UndergroundPath_EastWestTunnel" }, + { 1, 35, "FR_UNDERGROUND_PATH_EAST_ENTRANCE", "UndergroundPath_EastEntrance" }, + { 1, 36, "FR_DIGLETTS_CAVE_NORTH_ENTRANCE", "DiglettsCave_NorthEntrance" }, + { 1, 37, "FR_DIGLETTS_CAVE_B1F", "DiglettsCave_B1F" }, + { 1, 38, "FR_DIGLETTS_CAVE_SOUTH_ENTRANCE", "DiglettsCave_SouthEntrance" }, + { 1, 39, "FR_VICTORY_ROAD_1F", "VictoryRoad_1F" }, + { 1, 40, "FR_VICTORY_ROAD_2F", "VictoryRoad_2F" }, + { 1, 41, "FR_VICTORY_ROAD_3F", "VictoryRoad_3F" }, + { 1, 42, "FR_ROCKET_HIDEOUT_B1F", "RocketHideout_B1F" }, + { 1, 43, "FR_ROCKET_HIDEOUT_B2F", "RocketHideout_B2F" }, + { 1, 44, "FR_ROCKET_HIDEOUT_B3F", "RocketHideout_B3F" }, + { 1, 45, "FR_ROCKET_HIDEOUT_B4F", "RocketHideout_B4F" }, + { 1, 46, "FR_ROCKET_HIDEOUT_ELEVATOR", "RocketHideout_Elevator" }, + { 1, 47, "FR_SILPH_CO_1F", "SilphCo_1F" }, + { 1, 48, "FR_SILPH_CO_2F", "SilphCo_2F" }, + { 1, 49, "FR_SILPH_CO_3F", "SilphCo_3F" }, + { 1, 50, "FR_SILPH_CO_4F", "SilphCo_4F" }, + { 1, 51, "FR_SILPH_CO_5F", "SilphCo_5F" }, + { 1, 52, "FR_SILPH_CO_6F", "SilphCo_6F" }, + { 1, 53, "FR_SILPH_CO_7F", "SilphCo_7F" }, + { 1, 54, "FR_SILPH_CO_8F", "SilphCo_8F" }, + { 1, 55, "FR_SILPH_CO_9F", "SilphCo_9F" }, + { 1, 56, "FR_SILPH_CO_10F", "SilphCo_10F" }, + { 1, 57, "FR_SILPH_CO_11F", "SilphCo_11F" }, + { 1, 58, "FR_SILPH_CO_ELEVATOR", "SilphCo_Elevator" }, + { 1, 59, "FR_POKEMON_MANSION_1F", "PokemonMansion_1F" }, + { 1, 60, "FR_POKEMON_MANSION_2F", "PokemonMansion_2F" }, + { 1, 61, "FR_POKEMON_MANSION_3F", "PokemonMansion_3F" }, + { 1, 62, "FR_POKEMON_MANSION_B1F", "PokemonMansion_B1F" }, + { 1, 63, "FR_SAFARI_ZONE_CENTER", "SafariZone_Center" }, + { 1, 64, "FR_SAFARI_ZONE_EAST", "SafariZone_East" }, + { 1, 65, "FR_SAFARI_ZONE_NORTH", "SafariZone_North" }, + { 1, 66, "FR_SAFARI_ZONE_WEST", "SafariZone_West" }, + { 1, 67, "FR_SAFARI_ZONE_CENTER_REST_HOUSE", "SafariZone_Center_RestHouse" }, + { 1, 68, "FR_SAFARI_ZONE_EAST_REST_HOUSE", "SafariZone_East_RestHouse" }, + { 1, 69, "FR_SAFARI_ZONE_NORTH_REST_HOUSE", "SafariZone_North_RestHouse" }, + { 1, 70, "FR_SAFARI_ZONE_WEST_REST_HOUSE", "SafariZone_West_RestHouse" }, + { 1, 71, "FR_SAFARI_ZONE_SECRET_HOUSE", "SafariZone_SecretHouse" }, + { 1, 72, "FR_CERULEAN_CAVE_1F", "CeruleanCave_1F" }, + { 1, 73, "FR_CERULEAN_CAVE_2F", "CeruleanCave_2F" }, + { 1, 74, "FR_CERULEAN_CAVE_B1F", "CeruleanCave_B1F" }, + { 1, 75, "FR_POKEMON_LEAGUE_LORELEIS_ROOM", "PokemonLeague_LoreleisRoom" }, + { 1, 76, "FR_POKEMON_LEAGUE_BRUNOS_ROOM", "PokemonLeague_BrunosRoom" }, + { 1, 77, "FR_POKEMON_LEAGUE_AGATHAS_ROOM", "PokemonLeague_AgathasRoom" }, + { 1, 78, "FR_POKEMON_LEAGUE_LANCES_ROOM", "PokemonLeague_LancesRoom" }, + { 1, 79, "FR_POKEMON_LEAGUE_CHAMPIONS_ROOM", "PokemonLeague_ChampionsRoom" }, + { 1, 80, "FR_POKEMON_LEAGUE_HALL_OF_FAME", "PokemonLeague_HallOfFame" }, + { 1, 81, "FR_ROCK_TUNNEL_1F", "RockTunnel_1F" }, + { 1, 82, "FR_ROCK_TUNNEL_B1F", "RockTunnel_B1F" }, + { 1, 83, "FR_SEAFOAM_ISLANDS_1F", "SeafoamIslands_1F" }, + { 1, 84, "FR_SEAFOAM_ISLANDS_B1F", "SeafoamIslands_B1F" }, + { 1, 85, "FR_SEAFOAM_ISLANDS_B2F", "SeafoamIslands_B2F" }, + { 1, 86, "FR_SEAFOAM_ISLANDS_B3F", "SeafoamIslands_B3F" }, + { 1, 87, "FR_SEAFOAM_ISLANDS_B4F", "SeafoamIslands_B4F" }, + { 1, 88, "FR_POKEMON_TOWER_1F", "PokemonTower_1F" }, + { 1, 89, "FR_POKEMON_TOWER_2F", "PokemonTower_2F" }, + { 1, 90, "FR_POKEMON_TOWER_3F", "PokemonTower_3F" }, + { 1, 91, "FR_POKEMON_TOWER_4F", "PokemonTower_4F" }, + { 1, 92, "FR_POKEMON_TOWER_5F", "PokemonTower_5F" }, + { 1, 93, "FR_POKEMON_TOWER_6F", "PokemonTower_6F" }, + { 1, 94, "FR_POKEMON_TOWER_7F", "PokemonTower_7F" }, + { 1, 95, "FR_POWER_PLANT", "PowerPlant" }, + { 1, 96, "FR_MT_EMBER_RUBY_PATH_B4F", "MtEmber_RubyPath_B4F" }, + { 1, 97, "FR_MT_EMBER_EXTERIOR", "MtEmber_Exterior" }, + { 1, 98, "FR_MT_EMBER_SUMMIT_PATH_1F", "MtEmber_SummitPath_1F" }, + { 1, 99, "FR_MT_EMBER_SUMMIT_PATH_2F", "MtEmber_SummitPath_2F" }, + { 1, 100, "FR_MT_EMBER_SUMMIT_PATH_3F", "MtEmber_SummitPath_3F" }, + { 1, 101, "FR_MT_EMBER_SUMMIT", "MtEmber_Summit" }, + { 1, 102, "FR_MT_EMBER_RUBY_PATH_B5F", "MtEmber_RubyPath_B5F" }, + { 1, 103, "FR_MT_EMBER_RUBY_PATH_1F", "MtEmber_RubyPath_1F" }, + { 1, 104, "FR_MT_EMBER_RUBY_PATH_B1F", "MtEmber_RubyPath_B1F" }, + { 1, 105, "FR_MT_EMBER_RUBY_PATH_B2F", "MtEmber_RubyPath_B2F" }, + { 1, 106, "FR_MT_EMBER_RUBY_PATH_B3F", "MtEmber_RubyPath_B3F" }, + { 1, 107, "FR_MT_EMBER_RUBY_PATH_B1F_STAIRS", "MtEmber_RubyPath_B1F_Stairs" }, + { 1, 108, "FR_MT_EMBER_RUBY_PATH_B2F_STAIRS", "MtEmber_RubyPath_B2F_Stairs" }, + { 1, 109, "FR_THREE_ISLAND_BERRY_FOREST", "ThreeIsland_BerryForest" }, + { 1, 110, "FR_FOUR_ISLAND_ICEFALL_CAVE_ENTRANCE", "FourIsland_IcefallCave_Entrance" }, + { 1, 111, "FR_FOUR_ISLAND_ICEFALL_CAVE_1F", "FourIsland_IcefallCave_1F" }, + { 1, 112, "FR_FOUR_ISLAND_ICEFALL_CAVE_B1F", "FourIsland_IcefallCave_B1F" }, + { 1, 113, "FR_FOUR_ISLAND_ICEFALL_CAVE_BACK", "FourIsland_IcefallCave_Back" }, + { 1, 114, "FR_FIVE_ISLAND_ROCKET_WAREHOUSE", "FiveIsland_RocketWarehouse" }, + { 1, 115, "FR_SIX_ISLAND_DOTTED_HOLE_1F", "SixIsland_DottedHole_1F" }, + { 1, 116, "FR_SIX_ISLAND_DOTTED_HOLE_B1F", "SixIsland_DottedHole_B1F" }, + { 1, 117, "FR_SIX_ISLAND_DOTTED_HOLE_B2F", "SixIsland_DottedHole_B2F" }, + { 1, 118, "FR_SIX_ISLAND_DOTTED_HOLE_B3F", "SixIsland_DottedHole_B3F" }, + { 1, 119, "FR_SIX_ISLAND_DOTTED_HOLE_B4F", "SixIsland_DottedHole_B4F" }, + { 1, 120, "FR_SIX_ISLAND_DOTTED_HOLE_SAPPHIRE_ROOM", "SixIsland_DottedHole_SapphireRoom" }, + { 1, 121, "FR_SIX_ISLAND_PATTERN_BUSH", "SixIsland_PatternBush" }, + { 1, 122, "FR_SIX_ISLAND_ALTERING_CAVE", "SixIsland_AlteringCave" }, + { 2, 0, "FR_NAVEL_ROCK_EXTERIOR", "NavelRock_Exterior" }, + { 2, 1, "FR_TRAINER_TOWER_1F", "TrainerTower_1F" }, + { 2, 2, "FR_TRAINER_TOWER_2F", "TrainerTower_2F" }, + { 2, 3, "FR_TRAINER_TOWER_3F", "TrainerTower_3F" }, + { 2, 4, "FR_TRAINER_TOWER_4F", "TrainerTower_4F" }, + { 2, 5, "FR_TRAINER_TOWER_5F", "TrainerTower_5F" }, + { 2, 6, "FR_TRAINER_TOWER_6F", "TrainerTower_6F" }, + { 2, 7, "FR_TRAINER_TOWER_7F", "TrainerTower_7F" }, + { 2, 8, "FR_TRAINER_TOWER_8F", "TrainerTower_8F" }, + { 2, 9, "FR_TRAINER_TOWER_ROOF", "TrainerTower_Roof" }, + { 2, 10, "FR_TRAINER_TOWER_LOBBY", "TrainerTower_Lobby" }, + { 2, 11, "FR_TRAINER_TOWER_ELEVATOR", "TrainerTower_Elevator" }, + { 2, 12, "FR_FIVE_ISLAND_LOST_CAVE_ENTRANCE", "FiveIsland_LostCave_Entrance" }, + { 2, 13, "FR_FIVE_ISLAND_LOST_CAVE_ROOM1", "FiveIsland_LostCave_Room1" }, + { 2, 14, "FR_FIVE_ISLAND_LOST_CAVE_ROOM2", "FiveIsland_LostCave_Room2" }, + { 2, 15, "FR_FIVE_ISLAND_LOST_CAVE_ROOM3", "FiveIsland_LostCave_Room3" }, + { 2, 16, "FR_FIVE_ISLAND_LOST_CAVE_ROOM4", "FiveIsland_LostCave_Room4" }, + { 2, 17, "FR_FIVE_ISLAND_LOST_CAVE_ROOM5", "FiveIsland_LostCave_Room5" }, + { 2, 18, "FR_FIVE_ISLAND_LOST_CAVE_ROOM6", "FiveIsland_LostCave_Room6" }, + { 2, 19, "FR_FIVE_ISLAND_LOST_CAVE_ROOM7", "FiveIsland_LostCave_Room7" }, + { 2, 20, "FR_FIVE_ISLAND_LOST_CAVE_ROOM8", "FiveIsland_LostCave_Room8" }, + { 2, 21, "FR_FIVE_ISLAND_LOST_CAVE_ROOM9", "FiveIsland_LostCave_Room9" }, + { 2, 22, "FR_FIVE_ISLAND_LOST_CAVE_ROOM10", "FiveIsland_LostCave_Room10" }, + { 2, 23, "FR_FIVE_ISLAND_LOST_CAVE_ROOM11", "FiveIsland_LostCave_Room11" }, + { 2, 24, "FR_FIVE_ISLAND_LOST_CAVE_ROOM12", "FiveIsland_LostCave_Room12" }, + { 2, 25, "FR_FIVE_ISLAND_LOST_CAVE_ROOM13", "FiveIsland_LostCave_Room13" }, + { 2, 26, "FR_FIVE_ISLAND_LOST_CAVE_ROOM14", "FiveIsland_LostCave_Room14" }, + { 2, 27, "FR_SEVEN_ISLAND_TANOBY_RUINS_MONEAN_CHAMBER", "SevenIsland_TanobyRuins_MoneanChamber" }, + { 2, 28, "FR_SEVEN_ISLAND_TANOBY_RUINS_LIPTOO_CHAMBER", "SevenIsland_TanobyRuins_LiptooChamber" }, + { 2, 29, "FR_SEVEN_ISLAND_TANOBY_RUINS_WEEPTH_CHAMBER", "SevenIsland_TanobyRuins_WeepthChamber" }, + { 2, 30, "FR_SEVEN_ISLAND_TANOBY_RUINS_DILFORD_CHAMBER", "SevenIsland_TanobyRuins_DilfordChamber" }, + { 2, 31, "FR_SEVEN_ISLAND_TANOBY_RUINS_SCUFIB_CHAMBER", "SevenIsland_TanobyRuins_ScufibChamber" }, + { 2, 32, "FR_SEVEN_ISLAND_TANOBY_RUINS_RIXY_CHAMBER", "SevenIsland_TanobyRuins_RixyChamber" }, + { 2, 33, "FR_SEVEN_ISLAND_TANOBY_RUINS_VIAPOIS_CHAMBER", "SevenIsland_TanobyRuins_ViapoisChamber" }, + { 2, 34, "FR_THREE_ISLAND_DUNSPARCE_TUNNEL", "ThreeIsland_DunsparceTunnel" }, + { 2, 35, "FR_SEVEN_ISLAND_SEVAULT_CANYON_TANOBY_KEY", "SevenIsland_SevaultCanyon_TanobyKey" }, + { 2, 36, "FR_NAVEL_ROCK_1F", "NavelRock_1F" }, + { 2, 37, "FR_NAVEL_ROCK_SUMMIT", "NavelRock_Summit" }, + { 2, 38, "FR_NAVEL_ROCK_BASE", "NavelRock_Base" }, + { 2, 39, "FR_NAVEL_ROCK_SUMMIT_PATH_2F", "NavelRock_SummitPath_2F" }, + { 2, 40, "FR_NAVEL_ROCK_SUMMIT_PATH_3F", "NavelRock_SummitPath_3F" }, + { 2, 41, "FR_NAVEL_ROCK_SUMMIT_PATH_4F", "NavelRock_SummitPath_4F" }, + { 2, 42, "FR_NAVEL_ROCK_SUMMIT_PATH_5F", "NavelRock_SummitPath_5F" }, + { 2, 43, "FR_NAVEL_ROCK_BASE_PATH_B1F", "NavelRock_BasePath_B1F" }, + { 2, 44, "FR_NAVEL_ROCK_BASE_PATH_B2F", "NavelRock_BasePath_B2F" }, + { 2, 45, "FR_NAVEL_ROCK_BASE_PATH_B3F", "NavelRock_BasePath_B3F" }, + { 2, 46, "FR_NAVEL_ROCK_BASE_PATH_B4F", "NavelRock_BasePath_B4F" }, + { 2, 47, "FR_NAVEL_ROCK_BASE_PATH_B5F", "NavelRock_BasePath_B5F" }, + { 2, 48, "FR_NAVEL_ROCK_BASE_PATH_B6F", "NavelRock_BasePath_B6F" }, + { 2, 49, "FR_NAVEL_ROCK_BASE_PATH_B7F", "NavelRock_BasePath_B7F" }, + { 2, 50, "FR_NAVEL_ROCK_BASE_PATH_B8F", "NavelRock_BasePath_B8F" }, + { 2, 51, "FR_NAVEL_ROCK_BASE_PATH_B9F", "NavelRock_BasePath_B9F" }, + { 2, 52, "FR_NAVEL_ROCK_BASE_PATH_B10F", "NavelRock_BasePath_B10F" }, + { 2, 53, "FR_NAVEL_ROCK_BASE_PATH_B11F", "NavelRock_BasePath_B11F" }, + { 2, 54, "FR_NAVEL_ROCK_B1F", "NavelRock_B1F" }, + { 2, 55, "FR_NAVEL_ROCK_FORK", "NavelRock_Fork" }, + { 2, 56, "FR_BIRTH_ISLAND_EXTERIOR", "BirthIsland_Exterior" }, + { 2, 57, "FR_ONE_ISLAND_KINDLE_ROAD_EMBER_SPA", "OneIsland_KindleRoad_EmberSpa" }, + { 2, 58, "FR_BIRTH_ISLAND_HARBOR", "BirthIsland_Harbor" }, + { 2, 59, "FR_NAVEL_ROCK_HARBOR", "NavelRock_Harbor" }, + { 3, 0, "FR_PALLET_TOWN", "PalletTown" }, + { 3, 1, "FR_VIRIDIAN_CITY", "ViridianCity" }, + { 3, 2, "FR_PEWTER_CITY", "PewterCity" }, + { 3, 3, "FR_CERULEAN_CITY", "CeruleanCity" }, + { 3, 4, "FR_LAVENDER_TOWN", "LavenderTown" }, + { 3, 5, "FR_VERMILION_CITY", "VermilionCity" }, + { 3, 6, "FR_CELADON_CITY", "CeladonCity" }, + { 3, 7, "FR_FUCHSIA_CITY", "FuchsiaCity" }, + { 3, 8, "FR_CINNABAR_ISLAND", "CinnabarIsland" }, + { 3, 9, "FR_INDIGO_PLATEAU_EXTERIOR", "IndigoPlateau_Exterior" }, + { 3, 10, "FR_SAFFRON_CITY", "SaffronCity" }, + { 3, 11, "FR_SAFFRON_CITY_CONNECTION", "SaffronCity_Connection" }, + { 3, 12, "SEVII_ONE_ISLAND", "OneIsland" }, + { 3, 13, "FR_TWO_ISLAND", "TwoIsland" }, + { 3, 14, "FR_THREE_ISLAND", "ThreeIsland" }, + { 3, 15, "FR_FOUR_ISLAND", "FourIsland" }, + { 3, 16, "FR_FIVE_ISLAND", "FiveIsland" }, + { 3, 17, "FR_SEVEN_ISLAND", "SevenIsland" }, + { 3, 18, "FR_SIX_ISLAND", "SixIsland" }, + { 3, 19, "FR_ROUTE_1", "Route1" }, + { 3, 20, "FR_ROUTE_2", "Route2" }, + { 3, 21, "FR_ROUTE_3", "Route3" }, + { 3, 22, "FR_ROUTE_4", "Route4" }, + { 3, 23, "FR_ROUTE_5", "Route5" }, + { 3, 24, "FR_ROUTE_6", "Route6" }, + { 3, 25, "FR_ROUTE_7", "Route7" }, + { 3, 26, "FR_ROUTE_8", "Route8" }, + { 3, 27, "FR_ROUTE_9", "Route9" }, + { 3, 28, "FR_ROUTE_10", "Route10" }, + { 3, 29, "FR_ROUTE_11", "Route11" }, + { 3, 30, "FR_ROUTE_12", "Route12" }, + { 3, 31, "FR_ROUTE_13", "Route13" }, + { 3, 32, "FR_ROUTE_14", "Route14" }, + { 3, 33, "FR_ROUTE_15", "Route15" }, + { 3, 34, "FR_ROUTE_16", "Route16" }, + { 3, 35, "FR_ROUTE_17", "Route17" }, + { 3, 36, "FR_ROUTE_18", "Route18" }, + { 3, 37, "FR_ROUTE_19", "Route19" }, + { 3, 38, "FR_ROUTE_20", "Route20" }, + { 3, 39, "FR_ROUTE_21_NORTH", "Route21_North" }, + { 3, 40, "FR_ROUTE_21_SOUTH", "Route21_South" }, + { 3, 41, "FR_ROUTE_22", "Route22" }, + { 3, 42, "FR_ROUTE_23", "Route23" }, + { 3, 43, "FR_ROUTE_24", "Route24" }, + { 3, 44, "FR_ROUTE_25", "Route25" }, + { 3, 45, "SEVII_ONE_ISLAND_KINDLE_ROAD", "OneIsland_KindleRoad" }, + { 3, 46, "SEVII_ONE_ISLAND_TREASURE_BEACH", "OneIsland_TreasureBeach" }, + { 3, 47, "FR_TWO_ISLAND_CAPE_BRINK", "TwoIsland_CapeBrink" }, + { 3, 48, "FR_THREE_ISLAND_BOND_BRIDGE", "ThreeIsland_BondBridge" }, + { 3, 49, "FR_THREE_ISLAND_PORT", "ThreeIsland_Port" }, + { 3, 50, "FR_PROTOTYPE_SEVII_ISLE_6", "Prototype_SeviiIsle_6" }, + { 3, 51, "FR_PROTOTYPE_SEVII_ISLE_7", "Prototype_SeviiIsle_7" }, + { 3, 52, "FR_PROTOTYPE_SEVII_ISLE_8", "Prototype_SeviiIsle_8" }, + { 3, 53, "FR_PROTOTYPE_SEVII_ISLE_9", "Prototype_SeviiIsle_9" }, + { 3, 54, "FR_FIVE_ISLAND_RESORT_GORGEOUS", "FiveIsland_ResortGorgeous" }, + { 3, 55, "FR_FIVE_ISLAND_WATER_LABYRINTH", "FiveIsland_WaterLabyrinth" }, + { 3, 56, "FR_FIVE_ISLAND_MEADOW", "FiveIsland_Meadow" }, + { 3, 57, "FR_FIVE_ISLAND_MEMORIAL_PILLAR", "FiveIsland_MemorialPillar" }, + { 3, 58, "FR_SIX_ISLAND_OUTCAST_ISLAND", "SixIsland_OutcastIsland" }, + { 3, 59, "FR_SIX_ISLAND_GREEN_PATH", "SixIsland_GreenPath" }, + { 3, 60, "FR_SIX_ISLAND_WATER_PATH", "SixIsland_WaterPath" }, + { 3, 61, "FR_SIX_ISLAND_RUIN_VALLEY", "SixIsland_RuinValley" }, + { 3, 62, "FR_SEVEN_ISLAND_TRAINER_TOWER", "SevenIsland_TrainerTower" }, + { 3, 63, "FR_SEVEN_ISLAND_SEVAULT_CANYON_ENTRANCE", "SevenIsland_SevaultCanyon_Entrance" }, + { 3, 64, "FR_SEVEN_ISLAND_SEVAULT_CANYON", "SevenIsland_SevaultCanyon" }, + { 3, 65, "FR_SEVEN_ISLAND_TANOBY_RUINS", "SevenIsland_TanobyRuins" }, + { 4, 0, "FR_PLAYERS_HOUSE_1F", "PalletTown_PlayersHouse_1F" }, + { 4, 1, "FR_PLAYERS_HOUSE_2F", "PalletTown_PlayersHouse_2F" }, + { 4, 2, "FR_RIVALS_HOUSE", "PalletTown_RivalsHouse" }, + { 4, 3, "FR_OAKS_LAB", "PalletTown_ProfessorOaksLab" }, + { 5, 0, "FR_VIRIDIAN_CITY_HOUSE", "ViridianCity_House" }, + { 5, 1, "FR_VIRIDIAN_CITY_GYM", "ViridianCity_Gym" }, + { 5, 2, "FR_VIRIDIAN_CITY_SCHOOL", "ViridianCity_School" }, + { 5, 3, "FR_VIRIDIAN_CITY_MART", "ViridianCity_Mart" }, + { 5, 4, "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F", "ViridianCity_PokemonCenter_1F" }, + { 5, 5, "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F", "ViridianCity_PokemonCenter_2F" }, + { 6, 0, "FR_PEWTER_CITY_MUSEUM_1F", "PewterCity_Museum_1F" }, + { 6, 1, "FR_PEWTER_CITY_MUSEUM_2F", "PewterCity_Museum_2F" }, + { 6, 2, "FR_PEWTER_CITY_GYM", "PewterCity_Gym" }, + { 6, 3, "FR_PEWTER_CITY_MART", "PewterCity_Mart" }, + { 6, 4, "FR_PEWTER_CITY_HOUSE1", "PewterCity_House1" }, + { 6, 5, "FR_PEWTER_CITY_POKEMON_CENTER_1F", "PewterCity_PokemonCenter_1F" }, + { 6, 6, "FR_PEWTER_CITY_POKEMON_CENTER_2F", "PewterCity_PokemonCenter_2F" }, + { 6, 7, "FR_PEWTER_CITY_HOUSE2", "PewterCity_House2" }, + { 7, 0, "FR_CERULEAN_CITY_HOUSE1", "CeruleanCity_House1" }, + { 7, 1, "FR_CERULEAN_CITY_HOUSE2", "CeruleanCity_House2" }, + { 7, 2, "FR_CERULEAN_CITY_HOUSE3", "CeruleanCity_House3" }, + { 7, 3, "FR_CERULEAN_CITY_POKEMON_CENTER_1F", "CeruleanCity_PokemonCenter_1F" }, + { 7, 4, "FR_CERULEAN_CITY_POKEMON_CENTER_2F", "CeruleanCity_PokemonCenter_2F" }, + { 7, 5, "FR_CERULEAN_CITY_GYM", "CeruleanCity_Gym" }, + { 7, 6, "FR_CERULEAN_CITY_BIKE_SHOP", "CeruleanCity_BikeShop" }, + { 7, 7, "FR_CERULEAN_CITY_MART", "CeruleanCity_Mart" }, + { 7, 8, "FR_CERULEAN_CITY_HOUSE4", "CeruleanCity_House4" }, + { 7, 9, "FR_CERULEAN_CITY_HOUSE5", "CeruleanCity_House5" }, + { 8, 0, "FR_LAVENDER_TOWN_POKEMON_CENTER_1F", "LavenderTown_PokemonCenter_1F" }, + { 8, 1, "FR_LAVENDER_TOWN_POKEMON_CENTER_2F", "LavenderTown_PokemonCenter_2F" }, + { 8, 2, "FR_LAVENDER_TOWN_VOLUNTEER_POKEMON_HOUSE", "LavenderTown_VolunteerPokemonHouse" }, + { 8, 3, "FR_LAVENDER_TOWN_HOUSE1", "LavenderTown_House1" }, + { 8, 4, "FR_LAVENDER_TOWN_HOUSE2", "LavenderTown_House2" }, + { 8, 5, "FR_LAVENDER_TOWN_MART", "LavenderTown_Mart" }, + { 9, 0, "FR_VERMILION_CITY_HOUSE1", "VermilionCity_House1" }, + { 9, 1, "FR_VERMILION_CITY_POKEMON_CENTER_1F", "VermilionCity_PokemonCenter_1F" }, + { 9, 2, "FR_VERMILION_CITY_POKEMON_CENTER_2F", "VermilionCity_PokemonCenter_2F" }, + { 9, 3, "FR_VERMILION_CITY_POKEMON_FAN_CLUB", "VermilionCity_PokemonFanClub" }, + { 9, 4, "FR_VERMILION_CITY_HOUSE2", "VermilionCity_House2" }, + { 9, 5, "FR_VERMILION_CITY_MART", "VermilionCity_Mart" }, + { 9, 6, "FR_VERMILION_CITY_GYM", "VermilionCity_Gym" }, + { 9, 7, "FR_VERMILION_CITY_HOUSE3", "VermilionCity_House3" }, + { 10, 0, "FR_CELADON_CITY_DEPARTMENT_STORE_1F", "CeladonCity_DepartmentStore_1F" }, + { 10, 1, "FR_CELADON_CITY_DEPARTMENT_STORE_2F", "CeladonCity_DepartmentStore_2F" }, + { 10, 2, "FR_CELADON_CITY_DEPARTMENT_STORE_3F", "CeladonCity_DepartmentStore_3F" }, + { 10, 3, "FR_CELADON_CITY_DEPARTMENT_STORE_4F", "CeladonCity_DepartmentStore_4F" }, + { 10, 4, "FR_CELADON_CITY_DEPARTMENT_STORE_5F", "CeladonCity_DepartmentStore_5F" }, + { 10, 5, "FR_CELADON_CITY_DEPARTMENT_STORE_ROOF", "CeladonCity_DepartmentStore_Roof" }, + { 10, 6, "FR_CELADON_CITY_DEPARTMENT_STORE_ELEVATOR", "CeladonCity_DepartmentStore_Elevator" }, + { 10, 7, "FR_CELADON_CITY_CONDOMINIUMS_1F", "CeladonCity_Condominiums_1F" }, + { 10, 8, "FR_CELADON_CITY_CONDOMINIUMS_2F", "CeladonCity_Condominiums_2F" }, + { 10, 9, "FR_CELADON_CITY_CONDOMINIUMS_3F", "CeladonCity_Condominiums_3F" }, + { 10, 10, "FR_CELADON_CITY_CONDOMINIUMS_ROOF", "CeladonCity_Condominiums_Roof" }, + { 10, 11, "FR_CELADON_CITY_CONDOMINIUMS_ROOF_ROOM", "CeladonCity_Condominiums_RoofRoom" }, + { 10, 12, "FR_CELADON_CITY_POKEMON_CENTER_1F", "CeladonCity_PokemonCenter_1F" }, + { 10, 13, "FR_CELADON_CITY_POKEMON_CENTER_2F", "CeladonCity_PokemonCenter_2F" }, + { 10, 14, "FR_CELADON_CITY_GAME_CORNER", "CeladonCity_GameCorner" }, + { 10, 15, "FR_CELADON_CITY_GAME_CORNER_PRIZE_ROOM", "CeladonCity_GameCorner_PrizeRoom" }, + { 10, 16, "FR_CELADON_CITY_GYM", "CeladonCity_Gym" }, + { 10, 17, "FR_CELADON_CITY_RESTAURANT", "CeladonCity_Restaurant" }, + { 10, 18, "FR_CELADON_CITY_HOUSE1", "CeladonCity_House1" }, + { 10, 19, "FR_CELADON_CITY_HOTEL", "CeladonCity_Hotel" }, + { 11, 0, "FR_FUCHSIA_CITY_SAFARI_ZONE_ENTRANCE", "FuchsiaCity_SafariZone_Entrance" }, + { 11, 1, "FR_FUCHSIA_CITY_MART", "FuchsiaCity_Mart" }, + { 11, 2, "FR_FUCHSIA_CITY_SAFARI_ZONE_OFFICE", "FuchsiaCity_SafariZone_Office" }, + { 11, 3, "FR_FUCHSIA_CITY_GYM", "FuchsiaCity_Gym" }, + { 11, 4, "FR_FUCHSIA_CITY_HOUSE1", "FuchsiaCity_House1" }, + { 11, 5, "FR_FUCHSIA_CITY_POKEMON_CENTER_1F", "FuchsiaCity_PokemonCenter_1F" }, + { 11, 6, "FR_FUCHSIA_CITY_POKEMON_CENTER_2F", "FuchsiaCity_PokemonCenter_2F" }, + { 11, 7, "FR_FUCHSIA_CITY_WARDENS_HOUSE", "FuchsiaCity_WardensHouse" }, + { 11, 8, "FR_FUCHSIA_CITY_HOUSE2", "FuchsiaCity_House2" }, + { 11, 9, "FR_FUCHSIA_CITY_HOUSE3", "FuchsiaCity_House3" }, + { 12, 0, "FR_CINNABAR_ISLAND_GYM", "CinnabarIsland_Gym" }, + { 12, 1, "FR_CINNABAR_ISLAND_POKEMON_LAB_ENTRANCE", "CinnabarIsland_PokemonLab_Entrance" }, + { 12, 2, "FR_CINNABAR_ISLAND_POKEMON_LAB_LOUNGE", "CinnabarIsland_PokemonLab_Lounge" }, + { 12, 3, "FR_CINNABAR_ISLAND_POKEMON_LAB_RESEARCH_ROOM", "CinnabarIsland_PokemonLab_ResearchRoom" }, + { 12, 4, "FR_CINNABAR_ISLAND_POKEMON_LAB_EXPERIMENT_ROOM", "CinnabarIsland_PokemonLab_ExperimentRoom" }, + { 12, 5, "FR_CINNABAR_ISLAND_POKEMON_CENTER_1F", "CinnabarIsland_PokemonCenter_1F" }, + { 12, 6, "FR_CINNABAR_ISLAND_POKEMON_CENTER_2F", "CinnabarIsland_PokemonCenter_2F" }, + { 12, 7, "FR_CINNABAR_ISLAND_MART", "CinnabarIsland_Mart" }, + { 13, 0, "FR_INDIGO_PLATEAU_POKEMON_CENTER_1F", "IndigoPlateau_PokemonCenter_1F" }, + { 13, 1, "FR_INDIGO_PLATEAU_POKEMON_CENTER_2F", "IndigoPlateau_PokemonCenter_2F" }, + { 14, 0, "FR_SAFFRON_CITY_COPYCATS_HOUSE_1F", "SaffronCity_CopycatsHouse_1F" }, + { 14, 1, "FR_SAFFRON_CITY_COPYCATS_HOUSE_2F", "SaffronCity_CopycatsHouse_2F" }, + { 14, 2, "FR_SAFFRON_CITY_DOJO", "SaffronCity_Dojo" }, + { 14, 3, "FR_SAFFRON_CITY_GYM", "SaffronCity_Gym" }, + { 14, 4, "FR_SAFFRON_CITY_HOUSE", "SaffronCity_House" }, + { 14, 5, "FR_SAFFRON_CITY_MART", "SaffronCity_Mart" }, + { 14, 6, "FR_SAFFRON_CITY_POKEMON_CENTER_1F", "SaffronCity_PokemonCenter_1F" }, + { 14, 7, "FR_SAFFRON_CITY_POKEMON_CENTER_2F", "SaffronCity_PokemonCenter_2F" }, + { 14, 8, "FR_SAFFRON_CITY_MR_PSYCHICS_HOUSE", "SaffronCity_MrPsychicsHouse" }, + { 14, 9, "FR_SAFFRON_CITY_POKEMON_TRAINER_FAN_CLUB", "SaffronCity_PokemonTrainerFanClub" }, + { 15, 0, "FR_ROUTE_2_VIRIDIAN_FOREST_SOUTH_ENTRANCE", "Route2_ViridianForest_SouthEntrance" }, + { 15, 1, "FR_ROUTE_2_HOUSE", "Route2_House" }, + { 15, 2, "FR_ROUTE_2_EAST_BUILDING", "Route2_EastBuilding" }, + { 15, 3, "FR_ROUTE_2_VIRIDIAN_FOREST_NORTH_ENTRANCE", "Route2_ViridianForest_NorthEntrance" }, + { 16, 0, "FR_ROUTE_4_POKEMON_CENTER_1F", "Route4_PokemonCenter_1F" }, + { 16, 1, "FR_ROUTE_4_POKEMON_CENTER_2F", "Route4_PokemonCenter_2F" }, + { 17, 0, "FR_ROUTE_5_POKEMON_DAY_CARE", "Route5_PokemonDayCare" }, + { 17, 1, "FR_ROUTE_5_SOUTH_ENTRANCE", "Route5_SouthEntrance" }, + { 18, 0, "FR_ROUTE_6_NORTH_ENTRANCE", "Route6_NorthEntrance" }, + { 18, 1, "FR_ROUTE_6_UNUSED_HOUSE", "Route6_UnusedHouse" }, + { 19, 0, "FR_ROUTE_7_EAST_ENTRANCE", "Route7_EastEntrance" }, + { 20, 0, "FR_ROUTE_8_WEST_ENTRANCE", "Route8_WestEntrance" }, + { 21, 0, "FR_ROUTE_10_POKEMON_CENTER_1F", "Route10_PokemonCenter_1F" }, + { 21, 1, "FR_ROUTE_10_POKEMON_CENTER_2F", "Route10_PokemonCenter_2F" }, + { 22, 0, "FR_ROUTE_11_EAST_ENTRANCE_1F", "Route11_EastEntrance_1F" }, + { 22, 1, "FR_ROUTE_11_EAST_ENTRANCE_2F", "Route11_EastEntrance_2F" }, + { 23, 0, "FR_ROUTE_12_NORTH_ENTRANCE_1F", "Route12_NorthEntrance_1F" }, + { 23, 1, "FR_ROUTE_12_NORTH_ENTRANCE_2F", "Route12_NorthEntrance_2F" }, + { 23, 2, "FR_ROUTE_12_FISHING_HOUSE", "Route12_FishingHouse" }, + { 24, 0, "FR_ROUTE_15_WEST_ENTRANCE_1F", "Route15_WestEntrance_1F" }, + { 24, 1, "FR_ROUTE_15_WEST_ENTRANCE_2F", "Route15_WestEntrance_2F" }, + { 25, 0, "FR_ROUTE_16_HOUSE", "Route16_House" }, + { 25, 1, "FR_ROUTE_16_NORTH_ENTRANCE_1F", "Route16_NorthEntrance_1F" }, + { 25, 2, "FR_ROUTE_16_NORTH_ENTRANCE_2F", "Route16_NorthEntrance_2F" }, + { 26, 0, "FR_ROUTE_18_EAST_ENTRANCE_1F", "Route18_EastEntrance_1F" }, + { 26, 1, "FR_ROUTE_18_EAST_ENTRANCE_2F", "Route18_EastEntrance_2F" }, + { 27, 0, "FR_ROUTE_19_UNUSED_HOUSE", "Route19_UnusedHouse" }, + { 28, 0, "FR_ROUTE_22_NORTH_ENTRANCE", "Route22_NorthEntrance" }, + { 29, 0, "FR_ROUTE_23_UNUSED_HOUSE", "Route23_UnusedHouse" }, + { 30, 0, "FR_ROUTE_25_SEA_COTTAGE", "Route25_SeaCottage" }, + { 31, 0, "FR_SEVEN_ISLAND_HOUSE_ROOM1", "SevenIsland_House_Room1" }, + { 31, 1, "FR_SEVEN_ISLAND_HOUSE_ROOM2", "SevenIsland_House_Room2" }, + { 31, 2, "FR_SEVEN_ISLAND_MART", "SevenIsland_Mart" }, + { 31, 3, "FR_SEVEN_ISLAND_POKEMON_CENTER_1F", "SevenIsland_PokemonCenter_1F" }, + { 31, 4, "FR_SEVEN_ISLAND_POKEMON_CENTER_2F", "SevenIsland_PokemonCenter_2F" }, + { 31, 5, "FR_SEVEN_ISLAND_UNUSED_HOUSE", "SevenIsland_UnusedHouse" }, + { 31, 6, "FR_SEVEN_ISLAND_HARBOR", "SevenIsland_Harbor" }, + { 32, 0, "SEVII_ONE_ISLAND_POKECENTER", "OneIsland_PokemonCenter_1F" }, + { 32, 1, "SEVII_ONE_ISLAND_POKECENTER_2F", "OneIsland_PokemonCenter_2F" }, + { 32, 2, "SEVII_ONE_ISLAND_HOUSE1", "OneIsland_House1" }, + { 32, 3, "SEVII_ONE_ISLAND_HOUSE2", "OneIsland_House2" }, + { 32, 4, "SEVII_ONE_ISLAND_HARBOR", "OneIsland_Harbor" }, + { 33, 0, "FR_TWO_ISLAND_JOYFUL_GAME_CORNER", "TwoIsland_JoyfulGameCorner" }, + { 33, 1, "FR_TWO_ISLAND_HOUSE", "TwoIsland_House" }, + { 33, 2, "FR_TWO_ISLAND_POKEMON_CENTER_1F", "TwoIsland_PokemonCenter_1F" }, + { 33, 3, "FR_TWO_ISLAND_POKEMON_CENTER_2F", "TwoIsland_PokemonCenter_2F" }, + { 33, 4, "FR_TWO_ISLAND_HARBOR", "TwoIsland_Harbor" }, + { 34, 0, "FR_THREE_ISLAND_HOUSE1", "ThreeIsland_House1" }, + { 34, 1, "FR_THREE_ISLAND_POKEMON_CENTER_1F", "ThreeIsland_PokemonCenter_1F" }, + { 34, 2, "FR_THREE_ISLAND_POKEMON_CENTER_2F", "ThreeIsland_PokemonCenter_2F" }, + { 34, 3, "FR_THREE_ISLAND_MART", "ThreeIsland_Mart" }, + { 34, 4, "FR_THREE_ISLAND_HOUSE2", "ThreeIsland_House2" }, + { 34, 5, "FR_THREE_ISLAND_HOUSE3", "ThreeIsland_House3" }, + { 34, 6, "FR_THREE_ISLAND_HOUSE4", "ThreeIsland_House4" }, + { 34, 7, "FR_THREE_ISLAND_HOUSE5", "ThreeIsland_House5" }, + { 35, 0, "FR_FOUR_ISLAND_POKEMON_DAY_CARE", "FourIsland_PokemonDayCare" }, + { 35, 1, "FR_FOUR_ISLAND_POKEMON_CENTER_1F", "FourIsland_PokemonCenter_1F" }, + { 35, 2, "FR_FOUR_ISLAND_POKEMON_CENTER_2F", "FourIsland_PokemonCenter_2F" }, + { 35, 3, "FR_FOUR_ISLAND_HOUSE1", "FourIsland_House1" }, + { 35, 4, "FR_FOUR_ISLAND_LORELEIS_HOUSE", "FourIsland_LoreleisHouse" }, + { 35, 5, "FR_FOUR_ISLAND_HARBOR", "FourIsland_Harbor" }, + { 35, 6, "FR_FOUR_ISLAND_HOUSE2", "FourIsland_House2" }, + { 35, 7, "FR_FOUR_ISLAND_MART", "FourIsland_Mart" }, + { 36, 0, "FR_FIVE_ISLAND_POKEMON_CENTER_1F", "FiveIsland_PokemonCenter_1F" }, + { 36, 1, "FR_FIVE_ISLAND_POKEMON_CENTER_2F", "FiveIsland_PokemonCenter_2F" }, + { 36, 2, "FR_FIVE_ISLAND_HARBOR", "FiveIsland_Harbor" }, + { 36, 3, "FR_FIVE_ISLAND_HOUSE1", "FiveIsland_House1" }, + { 36, 4, "FR_FIVE_ISLAND_HOUSE2", "FiveIsland_House2" }, + { 37, 0, "FR_SIX_ISLAND_POKEMON_CENTER_1F", "SixIsland_PokemonCenter_1F" }, + { 37, 1, "FR_SIX_ISLAND_POKEMON_CENTER_2F", "SixIsland_PokemonCenter_2F" }, + { 37, 2, "FR_SIX_ISLAND_HARBOR", "SixIsland_Harbor" }, + { 37, 3, "FR_SIX_ISLAND_HOUSE", "SixIsland_House" }, + { 37, 4, "FR_SIX_ISLAND_MART", "SixIsland_Mart" }, + { 38, 0, "FR_THREE_ISLAND_HARBOR", "ThreeIsland_Harbor" }, + { 39, 0, "FR_FIVE_ISLAND_RESORT_GORGEOUS_HOUSE", "FiveIsland_ResortGorgeous_House" }, + { 40, 0, "FR_TWO_ISLAND_CAPE_BRINK_HOUSE", "TwoIsland_CapeBrink_House" }, + { 41, 0, "FR_SIX_ISLAND_WATER_PATH_HOUSE1", "SixIsland_WaterPath_House1" }, + { 41, 1, "FR_SIX_ISLAND_WATER_PATH_HOUSE2", "SixIsland_WaterPath_House2" }, + { 42, 0, "FR_SEVEN_ISLAND_SEVAULT_CANYON_HOUSE", "SevenIsland_SevaultCanyon_House" }, +} +D.emerald = { + { 0, 0, "EM_PETALBURG_CITY", "PetalburgCity", "MAP_PETALBURG_CITY" }, + { 0, 1, "EM_SLATEPORT_CITY", "SlateportCity", "MAP_SLATEPORT_CITY" }, + { 0, 2, "EM_MAUVILLE_CITY", "MauvilleCity", "MAP_MAUVILLE_CITY" }, + { 0, 3, "EM_RUSTBORO_CITY", "RustboroCity", "MAP_RUSTBORO_CITY" }, + { 0, 4, "EM_FORTREE_CITY", "FortreeCity", "MAP_FORTREE_CITY" }, + { 0, 5, "EM_LILYCOVE_CITY", "LilycoveCity", "MAP_LILYCOVE_CITY" }, + { 0, 6, "EM_MOSSDEEP_CITY", "MossdeepCity", "MAP_MOSSDEEP_CITY" }, + { 0, 7, "EM_SOOTOPOLIS_CITY", "SootopolisCity", "MAP_SOOTOPOLIS_CITY" }, + { 0, 8, "EM_EVER_GRANDE_CITY", "EverGrandeCity", "MAP_EVER_GRANDE_CITY" }, + { 0, 9, "EM_LITTLEROOT_TOWN", "LittlerootTown", "MAP_LITTLEROOT_TOWN" }, + { 0, 10, "EM_OLDALE_TOWN", "OldaleTown", "MAP_OLDALE_TOWN" }, + { 0, 11, "EM_DEWFORD_TOWN", "DewfordTown", "MAP_DEWFORD_TOWN" }, + { 0, 12, "EM_LAVARIDGE_TOWN", "LavaridgeTown", "MAP_LAVARIDGE_TOWN" }, + { 0, 13, "EM_FALLARBOR_TOWN", "FallarborTown", "MAP_FALLARBOR_TOWN" }, + { 0, 14, "EM_VERDANTURF_TOWN", "VerdanturfTown", "MAP_VERDANTURF_TOWN" }, + { 0, 15, "EM_PACIFIDLOG_TOWN", "PacifidlogTown", "MAP_PACIFIDLOG_TOWN" }, + { 0, 16, "EM_ROUTE101", "Route101", "MAP_ROUTE101" }, + { 0, 17, "EM_ROUTE102", "Route102", "MAP_ROUTE102" }, + { 0, 18, "EM_ROUTE103", "Route103", "MAP_ROUTE103" }, + { 0, 19, "EM_ROUTE104", "Route104", "MAP_ROUTE104" }, + { 0, 20, "EM_ROUTE105", "Route105", "MAP_ROUTE105" }, + { 0, 21, "EM_ROUTE106", "Route106", "MAP_ROUTE106" }, + { 0, 22, "EM_ROUTE107", "Route107", "MAP_ROUTE107" }, + { 0, 23, "EM_ROUTE108", "Route108", "MAP_ROUTE108" }, + { 0, 24, "EM_ROUTE109", "Route109", "MAP_ROUTE109" }, + { 0, 25, "EM_ROUTE110", "Route110", "MAP_ROUTE110" }, + { 0, 26, "EM_ROUTE111", "Route111", "MAP_ROUTE111" }, + { 0, 27, "EM_ROUTE112", "Route112", "MAP_ROUTE112" }, + { 0, 28, "EM_ROUTE113", "Route113", "MAP_ROUTE113" }, + { 0, 29, "EM_ROUTE114", "Route114", "MAP_ROUTE114" }, + { 0, 30, "EM_ROUTE115", "Route115", "MAP_ROUTE115" }, + { 0, 31, "EM_ROUTE116", "Route116", "MAP_ROUTE116" }, + { 0, 32, "EM_ROUTE117", "Route117", "MAP_ROUTE117" }, + { 0, 33, "EM_ROUTE118", "Route118", "MAP_ROUTE118" }, + { 0, 34, "EM_ROUTE119", "Route119", "MAP_ROUTE119" }, + { 0, 35, "EM_ROUTE120", "Route120", "MAP_ROUTE120" }, + { 0, 36, "EM_ROUTE121", "Route121", "MAP_ROUTE121" }, + { 0, 37, "EM_ROUTE122", "Route122", "MAP_ROUTE122" }, + { 0, 38, "EM_ROUTE123", "Route123", "MAP_ROUTE123" }, + { 0, 39, "EM_ROUTE124", "Route124", "MAP_ROUTE124" }, + { 0, 40, "EM_ROUTE125", "Route125", "MAP_ROUTE125" }, + { 0, 41, "EM_ROUTE126", "Route126", "MAP_ROUTE126" }, + { 0, 42, "EM_ROUTE127", "Route127", "MAP_ROUTE127" }, + { 0, 43, "EM_ROUTE128", "Route128", "MAP_ROUTE128" }, + { 0, 44, "EM_ROUTE129", "Route129", "MAP_ROUTE129" }, + { 0, 45, "EM_ROUTE130", "Route130", "MAP_ROUTE130" }, + { 0, 46, "EM_ROUTE131", "Route131", "MAP_ROUTE131" }, + { 0, 47, "EM_ROUTE132", "Route132", "MAP_ROUTE132" }, + { 0, 48, "EM_ROUTE133", "Route133", "MAP_ROUTE133" }, + { 0, 49, "EM_ROUTE134", "Route134", "MAP_ROUTE134" }, + { 0, 50, "EM_UNDERWATER_ROUTE124", "Underwater_Route124", "MAP_UNDERWATER_ROUTE124" }, + { 0, 51, "EM_UNDERWATER_ROUTE126", "Underwater_Route126", "MAP_UNDERWATER_ROUTE126" }, + { 0, 52, "EM_UNDERWATER_ROUTE127", "Underwater_Route127", "MAP_UNDERWATER_ROUTE127" }, + { 0, 53, "EM_UNDERWATER_ROUTE128", "Underwater_Route128", "MAP_UNDERWATER_ROUTE128" }, + { 0, 54, "EM_UNDERWATER_ROUTE129", "Underwater_Route129", "MAP_UNDERWATER_ROUTE129" }, + { 0, 55, "EM_UNDERWATER_ROUTE105", "Underwater_Route105", "MAP_UNDERWATER_ROUTE105" }, + { 0, 56, "EM_UNDERWATER_ROUTE125", "Underwater_Route125", "MAP_UNDERWATER_ROUTE125" }, + { 1, 0, "EM_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F", "LittlerootTown_BrendansHouse_1F", "MAP_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F" }, + { 1, 1, "EM_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F", "LittlerootTown_BrendansHouse_2F", "MAP_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F" }, + { 1, 2, "EM_LITTLEROOT_TOWN_MAYS_HOUSE_1F", "LittlerootTown_MaysHouse_1F", "MAP_LITTLEROOT_TOWN_MAYS_HOUSE_1F" }, + { 1, 3, "EM_LITTLEROOT_TOWN_MAYS_HOUSE_2F", "LittlerootTown_MaysHouse_2F", "MAP_LITTLEROOT_TOWN_MAYS_HOUSE_2F" }, + { 1, 4, "EM_LITTLEROOT_TOWN_PROFESSOR_BIRCHS_LAB", "LittlerootTown_ProfessorBirchsLab", "MAP_LITTLEROOT_TOWN_PROFESSOR_BIRCHS_LAB" }, + { 2, 0, "EM_OLDALE_TOWN_HOUSE1", "OldaleTown_House1", "MAP_OLDALE_TOWN_HOUSE1" }, + { 2, 1, "EM_OLDALE_TOWN_HOUSE2", "OldaleTown_House2", "MAP_OLDALE_TOWN_HOUSE2" }, + { 2, 2, "EM_OLDALE_TOWN_POKEMON_CENTER_1F", "OldaleTown_PokemonCenter_1F", "MAP_OLDALE_TOWN_POKEMON_CENTER_1F" }, + { 2, 3, "EM_OLDALE_TOWN_POKEMON_CENTER_2F", "OldaleTown_PokemonCenter_2F", "MAP_OLDALE_TOWN_POKEMON_CENTER_2F" }, + { 2, 4, "EM_OLDALE_TOWN_MART", "OldaleTown_Mart", "MAP_OLDALE_TOWN_MART" }, + { 3, 0, "EM_DEWFORD_TOWN_HOUSE1", "DewfordTown_House1", "MAP_DEWFORD_TOWN_HOUSE1" }, + { 3, 1, "EM_DEWFORD_TOWN_POKEMON_CENTER_1F", "DewfordTown_PokemonCenter_1F", "MAP_DEWFORD_TOWN_POKEMON_CENTER_1F" }, + { 3, 2, "EM_DEWFORD_TOWN_POKEMON_CENTER_2F", "DewfordTown_PokemonCenter_2F", "MAP_DEWFORD_TOWN_POKEMON_CENTER_2F" }, + { 3, 3, "EM_DEWFORD_TOWN_GYM", "DewfordTown_Gym", "MAP_DEWFORD_TOWN_GYM" }, + { 3, 4, "EM_DEWFORD_TOWN_HALL", "DewfordTown_Hall", "MAP_DEWFORD_TOWN_HALL" }, + { 3, 5, "EM_DEWFORD_TOWN_HOUSE2", "DewfordTown_House2", "MAP_DEWFORD_TOWN_HOUSE2" }, + { 4, 0, "EM_LAVARIDGE_TOWN_HERB_SHOP", "LavaridgeTown_HerbShop", "MAP_LAVARIDGE_TOWN_HERB_SHOP" }, + { 4, 1, "EM_LAVARIDGE_TOWN_GYM_1F", "LavaridgeTown_Gym_1F", "MAP_LAVARIDGE_TOWN_GYM_1F" }, + { 4, 2, "EM_LAVARIDGE_TOWN_GYM_B1F", "LavaridgeTown_Gym_B1F", "MAP_LAVARIDGE_TOWN_GYM_B1F" }, + { 4, 3, "EM_LAVARIDGE_TOWN_HOUSE", "LavaridgeTown_House", "MAP_LAVARIDGE_TOWN_HOUSE" }, + { 4, 4, "EM_LAVARIDGE_TOWN_MART", "LavaridgeTown_Mart", "MAP_LAVARIDGE_TOWN_MART" }, + { 4, 5, "EM_LAVARIDGE_TOWN_POKEMON_CENTER_1F", "LavaridgeTown_PokemonCenter_1F", "MAP_LAVARIDGE_TOWN_POKEMON_CENTER_1F" }, + { 4, 6, "EM_LAVARIDGE_TOWN_POKEMON_CENTER_2F", "LavaridgeTown_PokemonCenter_2F", "MAP_LAVARIDGE_TOWN_POKEMON_CENTER_2F" }, + { 5, 0, "EM_FALLARBOR_TOWN_MART", "FallarborTown_Mart", "MAP_FALLARBOR_TOWN_MART" }, + { 5, 1, "EM_FALLARBOR_TOWN_BATTLE_TENT_LOBBY", "FallarborTown_BattleTentLobby", "MAP_FALLARBOR_TOWN_BATTLE_TENT_LOBBY" }, + { 5, 2, "EM_FALLARBOR_TOWN_BATTLE_TENT_CORRIDOR", "FallarborTown_BattleTentCorridor", "MAP_FALLARBOR_TOWN_BATTLE_TENT_CORRIDOR" }, + { 5, 3, "EM_FALLARBOR_TOWN_BATTLE_TENT_BATTLE_ROOM", "FallarborTown_BattleTentBattleRoom", "MAP_FALLARBOR_TOWN_BATTLE_TENT_BATTLE_ROOM" }, + { 5, 4, "EM_FALLARBOR_TOWN_POKEMON_CENTER_1F", "FallarborTown_PokemonCenter_1F", "MAP_FALLARBOR_TOWN_POKEMON_CENTER_1F" }, + { 5, 5, "EM_FALLARBOR_TOWN_POKEMON_CENTER_2F", "FallarborTown_PokemonCenter_2F", "MAP_FALLARBOR_TOWN_POKEMON_CENTER_2F" }, + { 5, 6, "EM_FALLARBOR_TOWN_COZMOS_HOUSE", "FallarborTown_CozmosHouse", "MAP_FALLARBOR_TOWN_COZMOS_HOUSE" }, + { 5, 7, "EM_FALLARBOR_TOWN_MOVE_RELEARNERS_HOUSE", "FallarborTown_MoveRelearnersHouse", "MAP_FALLARBOR_TOWN_MOVE_RELEARNERS_HOUSE" }, + { 6, 0, "EM_VERDANTURF_TOWN_BATTLE_TENT_LOBBY", "VerdanturfTown_BattleTentLobby", "MAP_VERDANTURF_TOWN_BATTLE_TENT_LOBBY" }, + { 6, 1, "EM_VERDANTURF_TOWN_BATTLE_TENT_CORRIDOR", "VerdanturfTown_BattleTentCorridor", "MAP_VERDANTURF_TOWN_BATTLE_TENT_CORRIDOR" }, + { 6, 2, "EM_VERDANTURF_TOWN_BATTLE_TENT_BATTLE_ROOM", "VerdanturfTown_BattleTentBattleRoom", "MAP_VERDANTURF_TOWN_BATTLE_TENT_BATTLE_ROOM" }, + { 6, 3, "EM_VERDANTURF_TOWN_MART", "VerdanturfTown_Mart", "MAP_VERDANTURF_TOWN_MART" }, + { 6, 4, "EM_VERDANTURF_TOWN_POKEMON_CENTER_1F", "VerdanturfTown_PokemonCenter_1F", "MAP_VERDANTURF_TOWN_POKEMON_CENTER_1F" }, + { 6, 5, "EM_VERDANTURF_TOWN_POKEMON_CENTER_2F", "VerdanturfTown_PokemonCenter_2F", "MAP_VERDANTURF_TOWN_POKEMON_CENTER_2F" }, + { 6, 6, "EM_VERDANTURF_TOWN_WANDAS_HOUSE", "VerdanturfTown_WandasHouse", "MAP_VERDANTURF_TOWN_WANDAS_HOUSE" }, + { 6, 7, "EM_VERDANTURF_TOWN_FRIENDSHIP_RATERS_HOUSE", "VerdanturfTown_FriendshipRatersHouse", "MAP_VERDANTURF_TOWN_FRIENDSHIP_RATERS_HOUSE" }, + { 6, 8, "EM_VERDANTURF_TOWN_HOUSE", "VerdanturfTown_House", "MAP_VERDANTURF_TOWN_HOUSE" }, + { 7, 0, "EM_PACIFIDLOG_TOWN_POKEMON_CENTER_1F", "PacifidlogTown_PokemonCenter_1F", "MAP_PACIFIDLOG_TOWN_POKEMON_CENTER_1F" }, + { 7, 1, "EM_PACIFIDLOG_TOWN_POKEMON_CENTER_2F", "PacifidlogTown_PokemonCenter_2F", "MAP_PACIFIDLOG_TOWN_POKEMON_CENTER_2F" }, + { 7, 2, "EM_PACIFIDLOG_TOWN_HOUSE1", "PacifidlogTown_House1", "MAP_PACIFIDLOG_TOWN_HOUSE1" }, + { 7, 3, "EM_PACIFIDLOG_TOWN_HOUSE2", "PacifidlogTown_House2", "MAP_PACIFIDLOG_TOWN_HOUSE2" }, + { 7, 4, "EM_PACIFIDLOG_TOWN_HOUSE3", "PacifidlogTown_House3", "MAP_PACIFIDLOG_TOWN_HOUSE3" }, + { 7, 5, "EM_PACIFIDLOG_TOWN_HOUSE4", "PacifidlogTown_House4", "MAP_PACIFIDLOG_TOWN_HOUSE4" }, + { 7, 6, "EM_PACIFIDLOG_TOWN_HOUSE5", "PacifidlogTown_House5", "MAP_PACIFIDLOG_TOWN_HOUSE5" }, + { 8, 0, "EM_PETALBURG_CITY_WALLYS_HOUSE", "PetalburgCity_WallysHouse", "MAP_PETALBURG_CITY_WALLYS_HOUSE" }, + { 8, 1, "EM_PETALBURG_CITY_GYM", "PetalburgCity_Gym", "MAP_PETALBURG_CITY_GYM" }, + { 8, 2, "EM_PETALBURG_CITY_HOUSE1", "PetalburgCity_House1", "MAP_PETALBURG_CITY_HOUSE1" }, + { 8, 3, "EM_PETALBURG_CITY_HOUSE2", "PetalburgCity_House2", "MAP_PETALBURG_CITY_HOUSE2" }, + { 8, 4, "EM_PETALBURG_CITY_POKEMON_CENTER_1F", "PetalburgCity_PokemonCenter_1F", "MAP_PETALBURG_CITY_POKEMON_CENTER_1F" }, + { 8, 5, "EM_PETALBURG_CITY_POKEMON_CENTER_2F", "PetalburgCity_PokemonCenter_2F", "MAP_PETALBURG_CITY_POKEMON_CENTER_2F" }, + { 8, 6, "EM_PETALBURG_CITY_MART", "PetalburgCity_Mart", "MAP_PETALBURG_CITY_MART" }, + { 9, 0, "EM_SLATEPORT_CITY_STERNS_SHIPYARD_1F", "SlateportCity_SternsShipyard_1F", "MAP_SLATEPORT_CITY_STERNS_SHIPYARD_1F" }, + { 9, 1, "EM_SLATEPORT_CITY_STERNS_SHIPYARD_2F", "SlateportCity_SternsShipyard_2F", "MAP_SLATEPORT_CITY_STERNS_SHIPYARD_2F" }, + { 9, 2, "EM_SLATEPORT_CITY_BATTLE_TENT_LOBBY", "SlateportCity_BattleTentLobby", "MAP_SLATEPORT_CITY_BATTLE_TENT_LOBBY" }, + { 9, 3, "EM_SLATEPORT_CITY_BATTLE_TENT_CORRIDOR", "SlateportCity_BattleTentCorridor", "MAP_SLATEPORT_CITY_BATTLE_TENT_CORRIDOR" }, + { 9, 4, "EM_SLATEPORT_CITY_BATTLE_TENT_BATTLE_ROOM", "SlateportCity_BattleTentBattleRoom", "MAP_SLATEPORT_CITY_BATTLE_TENT_BATTLE_ROOM" }, + { 9, 5, "EM_SLATEPORT_CITY_NAME_RATERS_HOUSE", "SlateportCity_NameRatersHouse", "MAP_SLATEPORT_CITY_NAME_RATERS_HOUSE" }, + { 9, 6, "EM_SLATEPORT_CITY_POKEMON_FAN_CLUB", "SlateportCity_PokemonFanClub", "MAP_SLATEPORT_CITY_POKEMON_FAN_CLUB" }, + { 9, 7, "EM_SLATEPORT_CITY_OCEANIC_MUSEUM_1F", "SlateportCity_OceanicMuseum_1F", "MAP_SLATEPORT_CITY_OCEANIC_MUSEUM_1F" }, + { 9, 8, "EM_SLATEPORT_CITY_OCEANIC_MUSEUM_2F", "SlateportCity_OceanicMuseum_2F", "MAP_SLATEPORT_CITY_OCEANIC_MUSEUM_2F" }, + { 9, 9, "EM_SLATEPORT_CITY_HARBOR", "SlateportCity_Harbor", "MAP_SLATEPORT_CITY_HARBOR" }, + { 9, 10, "EM_SLATEPORT_CITY_HOUSE", "SlateportCity_House", "MAP_SLATEPORT_CITY_HOUSE" }, + { 9, 11, "EM_SLATEPORT_CITY_POKEMON_CENTER_1F", "SlateportCity_PokemonCenter_1F", "MAP_SLATEPORT_CITY_POKEMON_CENTER_1F" }, + { 9, 12, "EM_SLATEPORT_CITY_POKEMON_CENTER_2F", "SlateportCity_PokemonCenter_2F", "MAP_SLATEPORT_CITY_POKEMON_CENTER_2F" }, + { 9, 13, "EM_SLATEPORT_CITY_MART", "SlateportCity_Mart", "MAP_SLATEPORT_CITY_MART" }, + { 10, 0, "EM_MAUVILLE_CITY_GYM", "MauvilleCity_Gym", "MAP_MAUVILLE_CITY_GYM" }, + { 10, 1, "EM_MAUVILLE_CITY_BIKE_SHOP", "MauvilleCity_BikeShop", "MAP_MAUVILLE_CITY_BIKE_SHOP" }, + { 10, 2, "EM_MAUVILLE_CITY_HOUSE1", "MauvilleCity_House1", "MAP_MAUVILLE_CITY_HOUSE1" }, + { 10, 3, "EM_MAUVILLE_CITY_GAME_CORNER", "MauvilleCity_GameCorner", "MAP_MAUVILLE_CITY_GAME_CORNER" }, + { 10, 4, "EM_MAUVILLE_CITY_HOUSE2", "MauvilleCity_House2", "MAP_MAUVILLE_CITY_HOUSE2" }, + { 10, 5, "EM_MAUVILLE_CITY_POKEMON_CENTER_1F", "MauvilleCity_PokemonCenter_1F", "MAP_MAUVILLE_CITY_POKEMON_CENTER_1F" }, + { 10, 6, "EM_MAUVILLE_CITY_POKEMON_CENTER_2F", "MauvilleCity_PokemonCenter_2F", "MAP_MAUVILLE_CITY_POKEMON_CENTER_2F" }, + { 10, 7, "EM_MAUVILLE_CITY_MART", "MauvilleCity_Mart", "MAP_MAUVILLE_CITY_MART" }, + { 11, 0, "EM_RUSTBORO_CITY_DEVON_CORP_1F", "RustboroCity_DevonCorp_1F", "MAP_RUSTBORO_CITY_DEVON_CORP_1F" }, + { 11, 1, "EM_RUSTBORO_CITY_DEVON_CORP_2F", "RustboroCity_DevonCorp_2F", "MAP_RUSTBORO_CITY_DEVON_CORP_2F" }, + { 11, 2, "EM_RUSTBORO_CITY_DEVON_CORP_3F", "RustboroCity_DevonCorp_3F", "MAP_RUSTBORO_CITY_DEVON_CORP_3F" }, + { 11, 3, "EM_RUSTBORO_CITY_GYM", "RustboroCity_Gym", "MAP_RUSTBORO_CITY_GYM" }, + { 11, 4, "EM_RUSTBORO_CITY_POKEMON_SCHOOL", "RustboroCity_PokemonSchool", "MAP_RUSTBORO_CITY_POKEMON_SCHOOL" }, + { 11, 5, "EM_RUSTBORO_CITY_POKEMON_CENTER_1F", "RustboroCity_PokemonCenter_1F", "MAP_RUSTBORO_CITY_POKEMON_CENTER_1F" }, + { 11, 6, "EM_RUSTBORO_CITY_POKEMON_CENTER_2F", "RustboroCity_PokemonCenter_2F", "MAP_RUSTBORO_CITY_POKEMON_CENTER_2F" }, + { 11, 7, "EM_RUSTBORO_CITY_MART", "RustboroCity_Mart", "MAP_RUSTBORO_CITY_MART" }, + { 11, 8, "EM_RUSTBORO_CITY_FLAT1_1F", "RustboroCity_Flat1_1F", "MAP_RUSTBORO_CITY_FLAT1_1F" }, + { 11, 9, "EM_RUSTBORO_CITY_FLAT1_2F", "RustboroCity_Flat1_2F", "MAP_RUSTBORO_CITY_FLAT1_2F" }, + { 11, 10, "EM_RUSTBORO_CITY_HOUSE1", "RustboroCity_House1", "MAP_RUSTBORO_CITY_HOUSE1" }, + { 11, 11, "EM_RUSTBORO_CITY_CUTTERS_HOUSE", "RustboroCity_CuttersHouse", "MAP_RUSTBORO_CITY_CUTTERS_HOUSE" }, + { 11, 12, "EM_RUSTBORO_CITY_HOUSE2", "RustboroCity_House2", "MAP_RUSTBORO_CITY_HOUSE2" }, + { 11, 13, "EM_RUSTBORO_CITY_FLAT2_1F", "RustboroCity_Flat2_1F", "MAP_RUSTBORO_CITY_FLAT2_1F" }, + { 11, 14, "EM_RUSTBORO_CITY_FLAT2_2F", "RustboroCity_Flat2_2F", "MAP_RUSTBORO_CITY_FLAT2_2F" }, + { 11, 15, "EM_RUSTBORO_CITY_FLAT2_3F", "RustboroCity_Flat2_3F", "MAP_RUSTBORO_CITY_FLAT2_3F" }, + { 11, 16, "EM_RUSTBORO_CITY_HOUSE3", "RustboroCity_House3", "MAP_RUSTBORO_CITY_HOUSE3" }, + { 12, 0, "EM_FORTREE_CITY_HOUSE1", "FortreeCity_House1", "MAP_FORTREE_CITY_HOUSE1" }, + { 12, 1, "EM_FORTREE_CITY_GYM", "FortreeCity_Gym", "MAP_FORTREE_CITY_GYM" }, + { 12, 2, "EM_FORTREE_CITY_POKEMON_CENTER_1F", "FortreeCity_PokemonCenter_1F", "MAP_FORTREE_CITY_POKEMON_CENTER_1F" }, + { 12, 3, "EM_FORTREE_CITY_POKEMON_CENTER_2F", "FortreeCity_PokemonCenter_2F", "MAP_FORTREE_CITY_POKEMON_CENTER_2F" }, + { 12, 4, "EM_FORTREE_CITY_MART", "FortreeCity_Mart", "MAP_FORTREE_CITY_MART" }, + { 12, 5, "EM_FORTREE_CITY_HOUSE2", "FortreeCity_House2", "MAP_FORTREE_CITY_HOUSE2" }, + { 12, 6, "EM_FORTREE_CITY_HOUSE3", "FortreeCity_House3", "MAP_FORTREE_CITY_HOUSE3" }, + { 12, 7, "EM_FORTREE_CITY_HOUSE4", "FortreeCity_House4", "MAP_FORTREE_CITY_HOUSE4" }, + { 12, 8, "EM_FORTREE_CITY_HOUSE5", "FortreeCity_House5", "MAP_FORTREE_CITY_HOUSE5" }, + { 12, 9, "EM_FORTREE_CITY_DECORATION_SHOP", "FortreeCity_DecorationShop", "MAP_FORTREE_CITY_DECORATION_SHOP" }, + { 13, 0, "EM_LILYCOVE_CITY_COVE_LILY_MOTEL_1F", "LilycoveCity_CoveLilyMotel_1F", "MAP_LILYCOVE_CITY_COVE_LILY_MOTEL_1F" }, + { 13, 1, "EM_LILYCOVE_CITY_COVE_LILY_MOTEL_2F", "LilycoveCity_CoveLilyMotel_2F", "MAP_LILYCOVE_CITY_COVE_LILY_MOTEL_2F" }, + { 13, 2, "EM_LILYCOVE_CITY_LILYCOVE_MUSEUM_1F", "LilycoveCity_LilycoveMuseum_1F", "MAP_LILYCOVE_CITY_LILYCOVE_MUSEUM_1F" }, + { 13, 3, "EM_LILYCOVE_CITY_LILYCOVE_MUSEUM_2F", "LilycoveCity_LilycoveMuseum_2F", "MAP_LILYCOVE_CITY_LILYCOVE_MUSEUM_2F" }, + { 13, 4, "EM_LILYCOVE_CITY_CONTEST_LOBBY", "LilycoveCity_ContestLobby", "MAP_LILYCOVE_CITY_CONTEST_LOBBY" }, + { 13, 5, "EM_LILYCOVE_CITY_CONTEST_HALL", "LilycoveCity_ContestHall", "MAP_LILYCOVE_CITY_CONTEST_HALL" }, + { 13, 6, "EM_LILYCOVE_CITY_POKEMON_CENTER_1F", "LilycoveCity_PokemonCenter_1F", "MAP_LILYCOVE_CITY_POKEMON_CENTER_1F" }, + { 13, 7, "EM_LILYCOVE_CITY_POKEMON_CENTER_2F", "LilycoveCity_PokemonCenter_2F", "MAP_LILYCOVE_CITY_POKEMON_CENTER_2F" }, + { 13, 8, "EM_LILYCOVE_CITY_UNUSED_MART", "LilycoveCity_UnusedMart", "MAP_LILYCOVE_CITY_UNUSED_MART" }, + { 13, 9, "EM_LILYCOVE_CITY_POKEMON_TRAINER_FAN_CLUB", "LilycoveCity_PokemonTrainerFanClub", "MAP_LILYCOVE_CITY_POKEMON_TRAINER_FAN_CLUB" }, + { 13, 10, "EM_LILYCOVE_CITY_HARBOR", "LilycoveCity_Harbor", "MAP_LILYCOVE_CITY_HARBOR" }, + { 13, 11, "EM_LILYCOVE_CITY_MOVE_DELETERS_HOUSE", "LilycoveCity_MoveDeletersHouse", "MAP_LILYCOVE_CITY_MOVE_DELETERS_HOUSE" }, + { 13, 12, "EM_LILYCOVE_CITY_HOUSE1", "LilycoveCity_House1", "MAP_LILYCOVE_CITY_HOUSE1" }, + { 13, 13, "EM_LILYCOVE_CITY_HOUSE2", "LilycoveCity_House2", "MAP_LILYCOVE_CITY_HOUSE2" }, + { 13, 14, "EM_LILYCOVE_CITY_HOUSE3", "LilycoveCity_House3", "MAP_LILYCOVE_CITY_HOUSE3" }, + { 13, 15, "EM_LILYCOVE_CITY_HOUSE4", "LilycoveCity_House4", "MAP_LILYCOVE_CITY_HOUSE4" }, + { 13, 16, "EM_LILYCOVE_CITY_DEPARTMENT_STORE_1F", "LilycoveCity_DepartmentStore_1F", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_1F" }, + { 13, 17, "EM_LILYCOVE_CITY_DEPARTMENT_STORE_2F", "LilycoveCity_DepartmentStore_2F", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_2F" }, + { 13, 18, "EM_LILYCOVE_CITY_DEPARTMENT_STORE_3F", "LilycoveCity_DepartmentStore_3F", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_3F" }, + { 13, 19, "EM_LILYCOVE_CITY_DEPARTMENT_STORE_4F", "LilycoveCity_DepartmentStore_4F", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_4F" }, + { 13, 20, "EM_LILYCOVE_CITY_DEPARTMENT_STORE_5F", "LilycoveCity_DepartmentStore_5F", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_5F" }, + { 13, 21, "EM_LILYCOVE_CITY_DEPARTMENT_STORE_ROOFTOP", "LilycoveCity_DepartmentStoreRooftop", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_ROOFTOP" }, + { 13, 22, "EM_LILYCOVE_CITY_DEPARTMENT_STORE_ELEVATOR", "LilycoveCity_DepartmentStoreElevator", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_ELEVATOR" }, + { 14, 0, "EM_MOSSDEEP_CITY_GYM", "MossdeepCity_Gym", "MAP_MOSSDEEP_CITY_GYM" }, + { 14, 1, "EM_MOSSDEEP_CITY_HOUSE1", "MossdeepCity_House1", "MAP_MOSSDEEP_CITY_HOUSE1" }, + { 14, 2, "EM_MOSSDEEP_CITY_HOUSE2", "MossdeepCity_House2", "MAP_MOSSDEEP_CITY_HOUSE2" }, + { 14, 3, "EM_MOSSDEEP_CITY_POKEMON_CENTER_1F", "MossdeepCity_PokemonCenter_1F", "MAP_MOSSDEEP_CITY_POKEMON_CENTER_1F" }, + { 14, 4, "EM_MOSSDEEP_CITY_POKEMON_CENTER_2F", "MossdeepCity_PokemonCenter_2F", "MAP_MOSSDEEP_CITY_POKEMON_CENTER_2F" }, + { 14, 5, "EM_MOSSDEEP_CITY_MART", "MossdeepCity_Mart", "MAP_MOSSDEEP_CITY_MART" }, + { 14, 6, "EM_MOSSDEEP_CITY_HOUSE3", "MossdeepCity_House3", "MAP_MOSSDEEP_CITY_HOUSE3" }, + { 14, 7, "EM_MOSSDEEP_CITY_STEVENS_HOUSE", "MossdeepCity_StevensHouse", "MAP_MOSSDEEP_CITY_STEVENS_HOUSE" }, + { 14, 8, "EM_MOSSDEEP_CITY_HOUSE4", "MossdeepCity_House4", "MAP_MOSSDEEP_CITY_HOUSE4" }, + { 14, 9, "EM_MOSSDEEP_CITY_SPACE_CENTER_1F", "MossdeepCity_SpaceCenter_1F", "MAP_MOSSDEEP_CITY_SPACE_CENTER_1F" }, + { 14, 10, "EM_MOSSDEEP_CITY_SPACE_CENTER_2F", "MossdeepCity_SpaceCenter_2F", "MAP_MOSSDEEP_CITY_SPACE_CENTER_2F" }, + { 14, 11, "EM_MOSSDEEP_CITY_GAME_CORNER_1F", "MossdeepCity_GameCorner_1F", "MAP_MOSSDEEP_CITY_GAME_CORNER_1F" }, + { 14, 12, "EM_MOSSDEEP_CITY_GAME_CORNER_B1F", "MossdeepCity_GameCorner_B1F", "MAP_MOSSDEEP_CITY_GAME_CORNER_B1F" }, + { 15, 0, "EM_SOOTOPOLIS_CITY_GYM_1F", "SootopolisCity_Gym_1F", "MAP_SOOTOPOLIS_CITY_GYM_1F" }, + { 15, 1, "EM_SOOTOPOLIS_CITY_GYM_B1F", "SootopolisCity_Gym_B1F", "MAP_SOOTOPOLIS_CITY_GYM_B1F" }, + { 15, 2, "EM_SOOTOPOLIS_CITY_POKEMON_CENTER_1F", "SootopolisCity_PokemonCenter_1F", "MAP_SOOTOPOLIS_CITY_POKEMON_CENTER_1F" }, + { 15, 3, "EM_SOOTOPOLIS_CITY_POKEMON_CENTER_2F", "SootopolisCity_PokemonCenter_2F", "MAP_SOOTOPOLIS_CITY_POKEMON_CENTER_2F" }, + { 15, 4, "EM_SOOTOPOLIS_CITY_MART", "SootopolisCity_Mart", "MAP_SOOTOPOLIS_CITY_MART" }, + { 15, 5, "EM_SOOTOPOLIS_CITY_HOUSE1", "SootopolisCity_House1", "MAP_SOOTOPOLIS_CITY_HOUSE1" }, + { 15, 6, "EM_SOOTOPOLIS_CITY_HOUSE2", "SootopolisCity_House2", "MAP_SOOTOPOLIS_CITY_HOUSE2" }, + { 15, 7, "EM_SOOTOPOLIS_CITY_HOUSE3", "SootopolisCity_House3", "MAP_SOOTOPOLIS_CITY_HOUSE3" }, + { 15, 8, "EM_SOOTOPOLIS_CITY_HOUSE4", "SootopolisCity_House4", "MAP_SOOTOPOLIS_CITY_HOUSE4" }, + { 15, 9, "EM_SOOTOPOLIS_CITY_HOUSE5", "SootopolisCity_House5", "MAP_SOOTOPOLIS_CITY_HOUSE5" }, + { 15, 10, "EM_SOOTOPOLIS_CITY_HOUSE6", "SootopolisCity_House6", "MAP_SOOTOPOLIS_CITY_HOUSE6" }, + { 15, 11, "EM_SOOTOPOLIS_CITY_HOUSE7", "SootopolisCity_House7", "MAP_SOOTOPOLIS_CITY_HOUSE7" }, + { 15, 12, "EM_SOOTOPOLIS_CITY_LOTAD_AND_SEEDOT_HOUSE", "SootopolisCity_LotadAndSeedotHouse", "MAP_SOOTOPOLIS_CITY_LOTAD_AND_SEEDOT_HOUSE" }, + { 15, 13, "EM_SOOTOPOLIS_CITY_MYSTERY_EVENTS_HOUSE_1F", "SootopolisCity_MysteryEventsHouse_1F", "MAP_SOOTOPOLIS_CITY_MYSTERY_EVENTS_HOUSE_1F" }, + { 15, 14, "EM_SOOTOPOLIS_CITY_MYSTERY_EVENTS_HOUSE_B1F", "SootopolisCity_MysteryEventsHouse_B1F", "MAP_SOOTOPOLIS_CITY_MYSTERY_EVENTS_HOUSE_B1F" }, + { 16, 0, "EM_EVER_GRANDE_CITY_SIDNEYS_ROOM", "EverGrandeCity_SidneysRoom", "MAP_EVER_GRANDE_CITY_SIDNEYS_ROOM" }, + { 16, 1, "EM_EVER_GRANDE_CITY_PHOEBES_ROOM", "EverGrandeCity_PhoebesRoom", "MAP_EVER_GRANDE_CITY_PHOEBES_ROOM" }, + { 16, 2, "EM_EVER_GRANDE_CITY_GLACIAS_ROOM", "EverGrandeCity_GlaciasRoom", "MAP_EVER_GRANDE_CITY_GLACIAS_ROOM" }, + { 16, 3, "EM_EVER_GRANDE_CITY_DRAKES_ROOM", "EverGrandeCity_DrakesRoom", "MAP_EVER_GRANDE_CITY_DRAKES_ROOM" }, + { 16, 4, "EM_EVER_GRANDE_CITY_CHAMPIONS_ROOM", "EverGrandeCity_ChampionsRoom", "MAP_EVER_GRANDE_CITY_CHAMPIONS_ROOM" }, + { 16, 5, "EM_EVER_GRANDE_CITY_HALL1", "EverGrandeCity_Hall1", "MAP_EVER_GRANDE_CITY_HALL1" }, + { 16, 6, "EM_EVER_GRANDE_CITY_HALL2", "EverGrandeCity_Hall2", "MAP_EVER_GRANDE_CITY_HALL2" }, + { 16, 7, "EM_EVER_GRANDE_CITY_HALL3", "EverGrandeCity_Hall3", "MAP_EVER_GRANDE_CITY_HALL3" }, + { 16, 8, "EM_EVER_GRANDE_CITY_HALL4", "EverGrandeCity_Hall4", "MAP_EVER_GRANDE_CITY_HALL4" }, + { 16, 9, "EM_EVER_GRANDE_CITY_HALL5", "EverGrandeCity_Hall5", "MAP_EVER_GRANDE_CITY_HALL5" }, + { 16, 10, "EM_EVER_GRANDE_CITY_POKEMON_LEAGUE_1F", "EverGrandeCity_PokemonLeague_1F", "MAP_EVER_GRANDE_CITY_POKEMON_LEAGUE_1F" }, + { 16, 11, "EM_EVER_GRANDE_CITY_HALL_OF_FAME", "EverGrandeCity_HallOfFame", "MAP_EVER_GRANDE_CITY_HALL_OF_FAME" }, + { 16, 12, "EM_EVER_GRANDE_CITY_POKEMON_CENTER_1F", "EverGrandeCity_PokemonCenter_1F", "MAP_EVER_GRANDE_CITY_POKEMON_CENTER_1F" }, + { 16, 13, "EM_EVER_GRANDE_CITY_POKEMON_CENTER_2F", "EverGrandeCity_PokemonCenter_2F", "MAP_EVER_GRANDE_CITY_POKEMON_CENTER_2F" }, + { 16, 14, "EM_EVER_GRANDE_CITY_POKEMON_LEAGUE_2F", "EverGrandeCity_PokemonLeague_2F", "MAP_EVER_GRANDE_CITY_POKEMON_LEAGUE_2F" }, + { 17, 0, "EM_ROUTE104_MR_BRINEYS_HOUSE", "Route104_MrBrineysHouse", "MAP_ROUTE104_MR_BRINEYS_HOUSE" }, + { 17, 1, "EM_ROUTE104_PRETTY_PETAL_FLOWER_SHOP", "Route104_PrettyPetalFlowerShop", "MAP_ROUTE104_PRETTY_PETAL_FLOWER_SHOP" }, + { 18, 0, "EM_ROUTE111_WINSTRATE_FAMILYS_HOUSE", "Route111_WinstrateFamilysHouse", "MAP_ROUTE111_WINSTRATE_FAMILYS_HOUSE" }, + { 18, 1, "EM_ROUTE111_OLD_LADYS_REST_STOP", "Route111_OldLadysRestStop", "MAP_ROUTE111_OLD_LADYS_REST_STOP" }, + { 19, 0, "EM_ROUTE112_CABLE_CAR_STATION", "Route112_CableCarStation", "MAP_ROUTE112_CABLE_CAR_STATION" }, + { 19, 1, "EM_MT_CHIMNEY_CABLE_CAR_STATION", "MtChimney_CableCarStation", "MAP_MT_CHIMNEY_CABLE_CAR_STATION" }, + { 20, 0, "EM_ROUTE114_FOSSIL_MANIACS_HOUSE", "Route114_FossilManiacsHouse", "MAP_ROUTE114_FOSSIL_MANIACS_HOUSE" }, + { 20, 1, "EM_ROUTE114_FOSSIL_MANIACS_TUNNEL", "Route114_FossilManiacsTunnel", "MAP_ROUTE114_FOSSIL_MANIACS_TUNNEL" }, + { 20, 2, "EM_ROUTE114_LANETTES_HOUSE", "Route114_LanettesHouse", "MAP_ROUTE114_LANETTES_HOUSE" }, + { 21, 0, "EM_ROUTE116_TUNNELERS_REST_HOUSE", "Route116_TunnelersRestHouse", "MAP_ROUTE116_TUNNELERS_REST_HOUSE" }, + { 22, 0, "EM_ROUTE117_POKEMON_DAY_CARE", "Route117_PokemonDayCare", "MAP_ROUTE117_POKEMON_DAY_CARE" }, + { 23, 0, "EM_ROUTE121_SAFARI_ZONE_ENTRANCE", "Route121_SafariZoneEntrance", "MAP_ROUTE121_SAFARI_ZONE_ENTRANCE" }, + { 24, 0, "EM_METEOR_FALLS_1F_1R", "MeteorFalls_1F_1R", "MAP_METEOR_FALLS_1F_1R" }, + { 24, 1, "EM_METEOR_FALLS_1F_2R", "MeteorFalls_1F_2R", "MAP_METEOR_FALLS_1F_2R" }, + { 24, 2, "EM_METEOR_FALLS_B1F_1R", "MeteorFalls_B1F_1R", "MAP_METEOR_FALLS_B1F_1R" }, + { 24, 3, "EM_METEOR_FALLS_B1F_2R", "MeteorFalls_B1F_2R", "MAP_METEOR_FALLS_B1F_2R" }, + { 24, 4, "EM_RUSTURF_TUNNEL", "RusturfTunnel", "MAP_RUSTURF_TUNNEL" }, + { 24, 5, "EM_UNDERWATER_SOOTOPOLIS_CITY", "Underwater_SootopolisCity", "MAP_UNDERWATER_SOOTOPOLIS_CITY" }, + { 24, 6, "EM_DESERT_RUINS", "DesertRuins", "MAP_DESERT_RUINS" }, + { 24, 7, "EM_GRANITE_CAVE_1F", "GraniteCave_1F", "MAP_GRANITE_CAVE_1F" }, + { 24, 8, "EM_GRANITE_CAVE_B1F", "GraniteCave_B1F", "MAP_GRANITE_CAVE_B1F" }, + { 24, 9, "EM_GRANITE_CAVE_B2F", "GraniteCave_B2F", "MAP_GRANITE_CAVE_B2F" }, + { 24, 10, "EM_GRANITE_CAVE_STEVENS_ROOM", "GraniteCave_StevensRoom", "MAP_GRANITE_CAVE_STEVENS_ROOM" }, + { 24, 11, "EM_PETALBURG_WOODS", "PetalburgWoods", "MAP_PETALBURG_WOODS" }, + { 24, 12, "EM_MT_CHIMNEY", "MtChimney", "MAP_MT_CHIMNEY" }, + { 24, 13, "EM_JAGGED_PASS", "JaggedPass", "MAP_JAGGED_PASS" }, + { 24, 14, "EM_FIERY_PATH", "FieryPath", "MAP_FIERY_PATH" }, + { 24, 15, "EM_MT_PYRE_1F", "MtPyre_1F", "MAP_MT_PYRE_1F" }, + { 24, 16, "EM_MT_PYRE_2F", "MtPyre_2F", "MAP_MT_PYRE_2F" }, + { 24, 17, "EM_MT_PYRE_3F", "MtPyre_3F", "MAP_MT_PYRE_3F" }, + { 24, 18, "EM_MT_PYRE_4F", "MtPyre_4F", "MAP_MT_PYRE_4F" }, + { 24, 19, "EM_MT_PYRE_5F", "MtPyre_5F", "MAP_MT_PYRE_5F" }, + { 24, 20, "EM_MT_PYRE_6F", "MtPyre_6F", "MAP_MT_PYRE_6F" }, + { 24, 21, "EM_MT_PYRE_EXTERIOR", "MtPyre_Exterior", "MAP_MT_PYRE_EXTERIOR" }, + { 24, 22, "EM_MT_PYRE_SUMMIT", "MtPyre_Summit", "MAP_MT_PYRE_SUMMIT" }, + { 24, 23, "EM_AQUA_HIDEOUT_1F", "AquaHideout_1F", "MAP_AQUA_HIDEOUT_1F" }, + { 24, 24, "EM_AQUA_HIDEOUT_B1F", "AquaHideout_B1F", "MAP_AQUA_HIDEOUT_B1F" }, + { 24, 25, "EM_AQUA_HIDEOUT_B2F", "AquaHideout_B2F", "MAP_AQUA_HIDEOUT_B2F" }, + { 24, 26, "EM_UNDERWATER_SEAFLOOR_CAVERN", "Underwater_SeafloorCavern", "MAP_UNDERWATER_SEAFLOOR_CAVERN" }, + { 24, 27, "EM_SEAFLOOR_CAVERN_ENTRANCE", "SeafloorCavern_Entrance", "MAP_SEAFLOOR_CAVERN_ENTRANCE" }, + { 24, 28, "EM_SEAFLOOR_CAVERN_ROOM1", "SeafloorCavern_Room1", "MAP_SEAFLOOR_CAVERN_ROOM1" }, + { 24, 29, "EM_SEAFLOOR_CAVERN_ROOM2", "SeafloorCavern_Room2", "MAP_SEAFLOOR_CAVERN_ROOM2" }, + { 24, 30, "EM_SEAFLOOR_CAVERN_ROOM3", "SeafloorCavern_Room3", "MAP_SEAFLOOR_CAVERN_ROOM3" }, + { 24, 31, "EM_SEAFLOOR_CAVERN_ROOM4", "SeafloorCavern_Room4", "MAP_SEAFLOOR_CAVERN_ROOM4" }, + { 24, 32, "EM_SEAFLOOR_CAVERN_ROOM5", "SeafloorCavern_Room5", "MAP_SEAFLOOR_CAVERN_ROOM5" }, + { 24, 33, "EM_SEAFLOOR_CAVERN_ROOM6", "SeafloorCavern_Room6", "MAP_SEAFLOOR_CAVERN_ROOM6" }, + { 24, 34, "EM_SEAFLOOR_CAVERN_ROOM7", "SeafloorCavern_Room7", "MAP_SEAFLOOR_CAVERN_ROOM7" }, + { 24, 35, "EM_SEAFLOOR_CAVERN_ROOM8", "SeafloorCavern_Room8", "MAP_SEAFLOOR_CAVERN_ROOM8" }, + { 24, 36, "EM_SEAFLOOR_CAVERN_ROOM9", "SeafloorCavern_Room9", "MAP_SEAFLOOR_CAVERN_ROOM9" }, + { 24, 37, "EM_CAVE_OF_ORIGIN_ENTRANCE", "CaveOfOrigin_Entrance", "MAP_CAVE_OF_ORIGIN_ENTRANCE" }, + { 24, 38, "EM_CAVE_OF_ORIGIN_1F", "CaveOfOrigin_1F", "MAP_CAVE_OF_ORIGIN_1F" }, + { 24, 39, "EM_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP1", "CaveOfOrigin_UnusedRubySapphireMap1", "MAP_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP1" }, + { 24, 40, "EM_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP2", "CaveOfOrigin_UnusedRubySapphireMap2", "MAP_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP2" }, + { 24, 41, "EM_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP3", "CaveOfOrigin_UnusedRubySapphireMap3", "MAP_CAVE_OF_ORIGIN_UNUSED_RUBY_SAPPHIRE_MAP3" }, + { 24, 42, "EM_CAVE_OF_ORIGIN_B1F", "CaveOfOrigin_B1F", "MAP_CAVE_OF_ORIGIN_B1F" }, + { 24, 43, "EM_VICTORY_ROAD_1F", "VictoryRoad_1F", "MAP_VICTORY_ROAD_1F" }, + { 24, 44, "EM_VICTORY_ROAD_B1F", "VictoryRoad_B1F", "MAP_VICTORY_ROAD_B1F" }, + { 24, 45, "EM_VICTORY_ROAD_B2F", "VictoryRoad_B2F", "MAP_VICTORY_ROAD_B2F" }, + { 24, 46, "EM_SHOAL_CAVE_LOW_TIDE_ENTRANCE_ROOM", "ShoalCave_LowTideEntranceRoom", "MAP_SHOAL_CAVE_LOW_TIDE_ENTRANCE_ROOM" }, + { 24, 47, "EM_SHOAL_CAVE_LOW_TIDE_INNER_ROOM", "ShoalCave_LowTideInnerRoom", "MAP_SHOAL_CAVE_LOW_TIDE_INNER_ROOM" }, + { 24, 48, "EM_SHOAL_CAVE_LOW_TIDE_STAIRS_ROOM", "ShoalCave_LowTideStairsRoom", "MAP_SHOAL_CAVE_LOW_TIDE_STAIRS_ROOM" }, + { 24, 49, "EM_SHOAL_CAVE_LOW_TIDE_LOWER_ROOM", "ShoalCave_LowTideLowerRoom", "MAP_SHOAL_CAVE_LOW_TIDE_LOWER_ROOM" }, + { 24, 50, "EM_SHOAL_CAVE_HIGH_TIDE_ENTRANCE_ROOM", "ShoalCave_HighTideEntranceRoom", "MAP_SHOAL_CAVE_HIGH_TIDE_ENTRANCE_ROOM" }, + { 24, 51, "EM_SHOAL_CAVE_HIGH_TIDE_INNER_ROOM", "ShoalCave_HighTideInnerRoom", "MAP_SHOAL_CAVE_HIGH_TIDE_INNER_ROOM" }, + { 24, 52, "EM_NEW_MAUVILLE_ENTRANCE", "NewMauville_Entrance", "MAP_NEW_MAUVILLE_ENTRANCE" }, + { 24, 53, "EM_NEW_MAUVILLE_INSIDE", "NewMauville_Inside", "MAP_NEW_MAUVILLE_INSIDE" }, + { 24, 54, "EM_ABANDONED_SHIP_DECK", "AbandonedShip_Deck", "MAP_ABANDONED_SHIP_DECK" }, + { 24, 55, "EM_ABANDONED_SHIP_CORRIDORS_1F", "AbandonedShip_Corridors_1F", "MAP_ABANDONED_SHIP_CORRIDORS_1F" }, + { 24, 56, "EM_ABANDONED_SHIP_ROOMS_1F", "AbandonedShip_Rooms_1F", "MAP_ABANDONED_SHIP_ROOMS_1F" }, + { 24, 57, "EM_ABANDONED_SHIP_CORRIDORS_B1F", "AbandonedShip_Corridors_B1F", "MAP_ABANDONED_SHIP_CORRIDORS_B1F" }, + { 24, 58, "EM_ABANDONED_SHIP_ROOMS_B1F", "AbandonedShip_Rooms_B1F", "MAP_ABANDONED_SHIP_ROOMS_B1F" }, + { 24, 59, "EM_ABANDONED_SHIP_ROOMS2_B1F", "AbandonedShip_Rooms2_B1F", "MAP_ABANDONED_SHIP_ROOMS2_B1F" }, + { 24, 60, "EM_ABANDONED_SHIP_UNDERWATER1", "AbandonedShip_Underwater1", "MAP_ABANDONED_SHIP_UNDERWATER1" }, + { 24, 61, "EM_ABANDONED_SHIP_ROOM_B1F", "AbandonedShip_Room_B1F", "MAP_ABANDONED_SHIP_ROOM_B1F" }, + { 24, 62, "EM_ABANDONED_SHIP_ROOMS2_1F", "AbandonedShip_Rooms2_1F", "MAP_ABANDONED_SHIP_ROOMS2_1F" }, + { 24, 63, "EM_ABANDONED_SHIP_CAPTAINS_OFFICE", "AbandonedShip_CaptainsOffice", "MAP_ABANDONED_SHIP_CAPTAINS_OFFICE" }, + { 24, 64, "EM_ABANDONED_SHIP_UNDERWATER2", "AbandonedShip_Underwater2", "MAP_ABANDONED_SHIP_UNDERWATER2" }, + { 24, 65, "EM_ABANDONED_SHIP_HIDDEN_FLOOR_CORRIDORS", "AbandonedShip_HiddenFloorCorridors", "MAP_ABANDONED_SHIP_HIDDEN_FLOOR_CORRIDORS" }, + { 24, 66, "EM_ABANDONED_SHIP_HIDDEN_FLOOR_ROOMS", "AbandonedShip_HiddenFloorRooms", "MAP_ABANDONED_SHIP_HIDDEN_FLOOR_ROOMS" }, + { 24, 67, "EM_ISLAND_CAVE", "IslandCave", "MAP_ISLAND_CAVE" }, + { 24, 68, "EM_ANCIENT_TOMB", "AncientTomb", "MAP_ANCIENT_TOMB" }, + { 24, 69, "EM_UNDERWATER_ROUTE134", "Underwater_Route134", "MAP_UNDERWATER_ROUTE134" }, + { 24, 70, "EM_UNDERWATER_SEALED_CHAMBER", "Underwater_SealedChamber", "MAP_UNDERWATER_SEALED_CHAMBER" }, + { 24, 71, "EM_SEALED_CHAMBER_OUTER_ROOM", "SealedChamber_OuterRoom", "MAP_SEALED_CHAMBER_OUTER_ROOM" }, + { 24, 72, "EM_SEALED_CHAMBER_INNER_ROOM", "SealedChamber_InnerRoom", "MAP_SEALED_CHAMBER_INNER_ROOM" }, + { 24, 73, "EM_SCORCHED_SLAB", "ScorchedSlab", "MAP_SCORCHED_SLAB" }, + { 24, 74, "EM_AQUA_HIDEOUT_UNUSED_RUBY_MAP1", "AquaHideout_UnusedRubyMap1", "MAP_AQUA_HIDEOUT_UNUSED_RUBY_MAP1" }, + { 24, 75, "EM_AQUA_HIDEOUT_UNUSED_RUBY_MAP2", "AquaHideout_UnusedRubyMap2", "MAP_AQUA_HIDEOUT_UNUSED_RUBY_MAP2" }, + { 24, 76, "EM_AQUA_HIDEOUT_UNUSED_RUBY_MAP3", "AquaHideout_UnusedRubyMap3", "MAP_AQUA_HIDEOUT_UNUSED_RUBY_MAP3" }, + { 24, 77, "EM_SKY_PILLAR_ENTRANCE", "SkyPillar_Entrance", "MAP_SKY_PILLAR_ENTRANCE" }, + { 24, 78, "EM_SKY_PILLAR_OUTSIDE", "SkyPillar_Outside", "MAP_SKY_PILLAR_OUTSIDE" }, + { 24, 79, "EM_SKY_PILLAR_1F", "SkyPillar_1F", "MAP_SKY_PILLAR_1F" }, + { 24, 80, "EM_SKY_PILLAR_2F", "SkyPillar_2F", "MAP_SKY_PILLAR_2F" }, + { 24, 81, "EM_SKY_PILLAR_3F", "SkyPillar_3F", "MAP_SKY_PILLAR_3F" }, + { 24, 82, "EM_SKY_PILLAR_4F", "SkyPillar_4F", "MAP_SKY_PILLAR_4F" }, + { 24, 83, "EM_SHOAL_CAVE_LOW_TIDE_ICE_ROOM", "ShoalCave_LowTideIceRoom", "MAP_SHOAL_CAVE_LOW_TIDE_ICE_ROOM" }, + { 24, 84, "EM_SKY_PILLAR_5F", "SkyPillar_5F", "MAP_SKY_PILLAR_5F" }, + { 24, 85, "EM_SKY_PILLAR_TOP", "SkyPillar_Top", "MAP_SKY_PILLAR_TOP" }, + { 24, 86, "EM_MAGMA_HIDEOUT_1F", "MagmaHideout_1F", "MAP_MAGMA_HIDEOUT_1F" }, + { 24, 87, "EM_MAGMA_HIDEOUT_2F_1R", "MagmaHideout_2F_1R", "MAP_MAGMA_HIDEOUT_2F_1R" }, + { 24, 88, "EM_MAGMA_HIDEOUT_2F_2R", "MagmaHideout_2F_2R", "MAP_MAGMA_HIDEOUT_2F_2R" }, + { 24, 89, "EM_MAGMA_HIDEOUT_3F_1R", "MagmaHideout_3F_1R", "MAP_MAGMA_HIDEOUT_3F_1R" }, + { 24, 90, "EM_MAGMA_HIDEOUT_3F_2R", "MagmaHideout_3F_2R", "MAP_MAGMA_HIDEOUT_3F_2R" }, + { 24, 91, "EM_MAGMA_HIDEOUT_4F", "MagmaHideout_4F", "MAP_MAGMA_HIDEOUT_4F" }, + { 24, 92, "EM_MAGMA_HIDEOUT_3F_3R", "MagmaHideout_3F_3R", "MAP_MAGMA_HIDEOUT_3F_3R" }, + { 24, 93, "EM_MAGMA_HIDEOUT_2F_3R", "MagmaHideout_2F_3R", "MAP_MAGMA_HIDEOUT_2F_3R" }, + { 24, 94, "EM_MIRAGE_TOWER_1F", "MirageTower_1F", "MAP_MIRAGE_TOWER_1F" }, + { 24, 95, "EM_MIRAGE_TOWER_2F", "MirageTower_2F", "MAP_MIRAGE_TOWER_2F" }, + { 24, 96, "EM_MIRAGE_TOWER_3F", "MirageTower_3F", "MAP_MIRAGE_TOWER_3F" }, + { 24, 97, "EM_MIRAGE_TOWER_4F", "MirageTower_4F", "MAP_MIRAGE_TOWER_4F" }, + { 24, 98, "EM_DESERT_UNDERPASS", "DesertUnderpass", "MAP_DESERT_UNDERPASS" }, + { 24, 99, "EM_ARTISAN_CAVE_B1F", "ArtisanCave_B1F", "MAP_ARTISAN_CAVE_B1F" }, + { 24, 100, "EM_ARTISAN_CAVE_1F", "ArtisanCave_1F", "MAP_ARTISAN_CAVE_1F" }, + { 24, 101, "EM_UNDERWATER_MARINE_CAVE", "Underwater_MarineCave", "MAP_UNDERWATER_MARINE_CAVE" }, + { 24, 102, "EM_MARINE_CAVE_ENTRANCE", "MarineCave_Entrance", "MAP_MARINE_CAVE_ENTRANCE" }, + { 24, 103, "EM_MARINE_CAVE_END", "MarineCave_End", "MAP_MARINE_CAVE_END" }, + { 24, 104, "EM_TERRA_CAVE_ENTRANCE", "TerraCave_Entrance", "MAP_TERRA_CAVE_ENTRANCE" }, + { 24, 105, "EM_TERRA_CAVE_END", "TerraCave_End", "MAP_TERRA_CAVE_END" }, + { 24, 106, "EM_ALTERING_CAVE", "AlteringCave", "MAP_ALTERING_CAVE" }, + { 24, 107, "EM_METEOR_FALLS_STEVENS_CAVE", "MeteorFalls_StevensCave", "MAP_METEOR_FALLS_STEVENS_CAVE" }, + { 25, 0, "EM_SECRET_BASE_RED_CAVE1", "SecretBase_RedCave1", "MAP_SECRET_BASE_RED_CAVE1" }, + { 25, 1, "EM_SECRET_BASE_BROWN_CAVE1", "SecretBase_BrownCave1", "MAP_SECRET_BASE_BROWN_CAVE1" }, + { 25, 2, "EM_SECRET_BASE_BLUE_CAVE1", "SecretBase_BlueCave1", "MAP_SECRET_BASE_BLUE_CAVE1" }, + { 25, 3, "EM_SECRET_BASE_YELLOW_CAVE1", "SecretBase_YellowCave1", "MAP_SECRET_BASE_YELLOW_CAVE1" }, + { 25, 4, "EM_SECRET_BASE_TREE1", "SecretBase_Tree1", "MAP_SECRET_BASE_TREE1" }, + { 25, 5, "EM_SECRET_BASE_SHRUB1", "SecretBase_Shrub1", "MAP_SECRET_BASE_SHRUB1" }, + { 25, 6, "EM_SECRET_BASE_RED_CAVE2", "SecretBase_RedCave2", "MAP_SECRET_BASE_RED_CAVE2" }, + { 25, 7, "EM_SECRET_BASE_BROWN_CAVE2", "SecretBase_BrownCave2", "MAP_SECRET_BASE_BROWN_CAVE2" }, + { 25, 8, "EM_SECRET_BASE_BLUE_CAVE2", "SecretBase_BlueCave2", "MAP_SECRET_BASE_BLUE_CAVE2" }, + { 25, 9, "EM_SECRET_BASE_YELLOW_CAVE2", "SecretBase_YellowCave2", "MAP_SECRET_BASE_YELLOW_CAVE2" }, + { 25, 10, "EM_SECRET_BASE_TREE2", "SecretBase_Tree2", "MAP_SECRET_BASE_TREE2" }, + { 25, 11, "EM_SECRET_BASE_SHRUB2", "SecretBase_Shrub2", "MAP_SECRET_BASE_SHRUB2" }, + { 25, 12, "EM_SECRET_BASE_RED_CAVE3", "SecretBase_RedCave3", "MAP_SECRET_BASE_RED_CAVE3" }, + { 25, 13, "EM_SECRET_BASE_BROWN_CAVE3", "SecretBase_BrownCave3", "MAP_SECRET_BASE_BROWN_CAVE3" }, + { 25, 14, "EM_SECRET_BASE_BLUE_CAVE3", "SecretBase_BlueCave3", "MAP_SECRET_BASE_BLUE_CAVE3" }, + { 25, 15, "EM_SECRET_BASE_YELLOW_CAVE3", "SecretBase_YellowCave3", "MAP_SECRET_BASE_YELLOW_CAVE3" }, + { 25, 16, "EM_SECRET_BASE_TREE3", "SecretBase_Tree3", "MAP_SECRET_BASE_TREE3" }, + { 25, 17, "EM_SECRET_BASE_SHRUB3", "SecretBase_Shrub3", "MAP_SECRET_BASE_SHRUB3" }, + { 25, 18, "EM_SECRET_BASE_RED_CAVE4", "SecretBase_RedCave4", "MAP_SECRET_BASE_RED_CAVE4" }, + { 25, 19, "EM_SECRET_BASE_BROWN_CAVE4", "SecretBase_BrownCave4", "MAP_SECRET_BASE_BROWN_CAVE4" }, + { 25, 20, "EM_SECRET_BASE_BLUE_CAVE4", "SecretBase_BlueCave4", "MAP_SECRET_BASE_BLUE_CAVE4" }, + { 25, 21, "EM_SECRET_BASE_YELLOW_CAVE4", "SecretBase_YellowCave4", "MAP_SECRET_BASE_YELLOW_CAVE4" }, + { 25, 22, "EM_SECRET_BASE_TREE4", "SecretBase_Tree4", "MAP_SECRET_BASE_TREE4" }, + { 25, 23, "EM_SECRET_BASE_SHRUB4", "SecretBase_Shrub4", "MAP_SECRET_BASE_SHRUB4" }, + { 25, 24, "EM_BATTLE_COLOSSEUM_2P", "BattleColosseum_2P", "MAP_BATTLE_COLOSSEUM_2P" }, + { 25, 25, "EM_TRADE_CENTER", "TradeCenter", "MAP_TRADE_CENTER" }, + { 25, 26, "EM_RECORD_CORNER", "RecordCorner", "MAP_RECORD_CORNER" }, + { 25, 27, "EM_BATTLE_COLOSSEUM_4P", "BattleColosseum_4P", "MAP_BATTLE_COLOSSEUM_4P" }, + { 25, 28, "EM_CONTEST_HALL", "ContestHall", "MAP_CONTEST_HALL" }, + { 25, 29, "EM_UNUSED_CONTEST_HALL1", "UnusedContestHall1", "MAP_UNUSED_CONTEST_HALL1" }, + { 25, 30, "EM_UNUSED_CONTEST_HALL2", "UnusedContestHall2", "MAP_UNUSED_CONTEST_HALL2" }, + { 25, 31, "EM_UNUSED_CONTEST_HALL3", "UnusedContestHall3", "MAP_UNUSED_CONTEST_HALL3" }, + { 25, 32, "EM_UNUSED_CONTEST_HALL4", "UnusedContestHall4", "MAP_UNUSED_CONTEST_HALL4" }, + { 25, 33, "EM_UNUSED_CONTEST_HALL5", "UnusedContestHall5", "MAP_UNUSED_CONTEST_HALL5" }, + { 25, 34, "EM_UNUSED_CONTEST_HALL6", "UnusedContestHall6", "MAP_UNUSED_CONTEST_HALL6" }, + { 25, 35, "EM_CONTEST_HALL_BEAUTY", "ContestHallBeauty", "MAP_CONTEST_HALL_BEAUTY" }, + { 25, 36, "EM_CONTEST_HALL_TOUGH", "ContestHallTough", "MAP_CONTEST_HALL_TOUGH" }, + { 25, 37, "EM_CONTEST_HALL_COOL", "ContestHallCool", "MAP_CONTEST_HALL_COOL" }, + { 25, 38, "EM_CONTEST_HALL_SMART", "ContestHallSmart", "MAP_CONTEST_HALL_SMART" }, + { 25, 39, "EM_CONTEST_HALL_CUTE", "ContestHallCute", "MAP_CONTEST_HALL_CUTE" }, + { 25, 40, "EM_INSIDE_OF_TRUCK", "InsideOfTruck", "MAP_INSIDE_OF_TRUCK" }, + { 25, 41, "EM_SS_TIDAL_CORRIDOR", "SSTidalCorridor", "MAP_SS_TIDAL_CORRIDOR" }, + { 25, 42, "EM_SS_TIDAL_LOWER_DECK", "SSTidalLowerDeck", "MAP_SS_TIDAL_LOWER_DECK" }, + { 25, 43, "EM_SS_TIDAL_ROOMS", "SSTidalRooms", "MAP_SS_TIDAL_ROOMS" }, + { 25, 44, "EM_BATTLE_PYRAMID_SQUARE01", "BattlePyramidSquare01", "MAP_BATTLE_PYRAMID_SQUARE01" }, + { 25, 45, "EM_BATTLE_PYRAMID_SQUARE02", "BattlePyramidSquare02", "MAP_BATTLE_PYRAMID_SQUARE02" }, + { 25, 46, "EM_BATTLE_PYRAMID_SQUARE03", "BattlePyramidSquare03", "MAP_BATTLE_PYRAMID_SQUARE03" }, + { 25, 47, "EM_BATTLE_PYRAMID_SQUARE04", "BattlePyramidSquare04", "MAP_BATTLE_PYRAMID_SQUARE04" }, + { 25, 48, "EM_BATTLE_PYRAMID_SQUARE05", "BattlePyramidSquare05", "MAP_BATTLE_PYRAMID_SQUARE05" }, + { 25, 49, "EM_BATTLE_PYRAMID_SQUARE06", "BattlePyramidSquare06", "MAP_BATTLE_PYRAMID_SQUARE06" }, + { 25, 50, "EM_BATTLE_PYRAMID_SQUARE07", "BattlePyramidSquare07", "MAP_BATTLE_PYRAMID_SQUARE07" }, + { 25, 51, "EM_BATTLE_PYRAMID_SQUARE08", "BattlePyramidSquare08", "MAP_BATTLE_PYRAMID_SQUARE08" }, + { 25, 52, "EM_BATTLE_PYRAMID_SQUARE09", "BattlePyramidSquare09", "MAP_BATTLE_PYRAMID_SQUARE09" }, + { 25, 53, "EM_BATTLE_PYRAMID_SQUARE10", "BattlePyramidSquare10", "MAP_BATTLE_PYRAMID_SQUARE10" }, + { 25, 54, "EM_BATTLE_PYRAMID_SQUARE11", "BattlePyramidSquare11", "MAP_BATTLE_PYRAMID_SQUARE11" }, + { 25, 55, "EM_BATTLE_PYRAMID_SQUARE12", "BattlePyramidSquare12", "MAP_BATTLE_PYRAMID_SQUARE12" }, + { 25, 56, "EM_BATTLE_PYRAMID_SQUARE13", "BattlePyramidSquare13", "MAP_BATTLE_PYRAMID_SQUARE13" }, + { 25, 57, "EM_BATTLE_PYRAMID_SQUARE14", "BattlePyramidSquare14", "MAP_BATTLE_PYRAMID_SQUARE14" }, + { 25, 58, "EM_BATTLE_PYRAMID_SQUARE15", "BattlePyramidSquare15", "MAP_BATTLE_PYRAMID_SQUARE15" }, + { 25, 59, "EM_BATTLE_PYRAMID_SQUARE16", "BattlePyramidSquare16", "MAP_BATTLE_PYRAMID_SQUARE16" }, + { 25, 60, "EM_UNION_ROOM", "UnionRoom", "MAP_UNION_ROOM" }, + { 26, 0, "EM_SAFARI_ZONE_NORTHWEST", "SafariZone_Northwest", "MAP_SAFARI_ZONE_NORTHWEST" }, + { 26, 1, "EM_SAFARI_ZONE_NORTH", "SafariZone_North", "MAP_SAFARI_ZONE_NORTH" }, + { 26, 2, "EM_SAFARI_ZONE_SOUTHWEST", "SafariZone_Southwest", "MAP_SAFARI_ZONE_SOUTHWEST" }, + { 26, 3, "EM_SAFARI_ZONE_SOUTH", "SafariZone_South", "MAP_SAFARI_ZONE_SOUTH" }, + { 26, 4, "EM_BATTLE_FRONTIER_OUTSIDE_WEST", "BattleFrontier_OutsideWest", "MAP_BATTLE_FRONTIER_OUTSIDE_WEST" }, + { 26, 5, "EM_BATTLE_FRONTIER_BATTLE_TOWER_LOBBY", "BattleFrontier_BattleTowerLobby", "MAP_BATTLE_FRONTIER_BATTLE_TOWER_LOBBY" }, + { 26, 6, "EM_BATTLE_FRONTIER_BATTLE_TOWER_ELEVATOR", "BattleFrontier_BattleTowerElevator", "MAP_BATTLE_FRONTIER_BATTLE_TOWER_ELEVATOR" }, + { 26, 7, "EM_BATTLE_FRONTIER_BATTLE_TOWER_CORRIDOR", "BattleFrontier_BattleTowerCorridor", "MAP_BATTLE_FRONTIER_BATTLE_TOWER_CORRIDOR" }, + { 26, 8, "EM_BATTLE_FRONTIER_BATTLE_TOWER_BATTLE_ROOM", "BattleFrontier_BattleTowerBattleRoom", "MAP_BATTLE_FRONTIER_BATTLE_TOWER_BATTLE_ROOM" }, + { 26, 9, "EM_SOUTHERN_ISLAND_EXTERIOR", "SouthernIsland_Exterior", "MAP_SOUTHERN_ISLAND_EXTERIOR" }, + { 26, 10, "EM_SOUTHERN_ISLAND_INTERIOR", "SouthernIsland_Interior", "MAP_SOUTHERN_ISLAND_INTERIOR" }, + { 26, 11, "EM_SAFARI_ZONE_REST_HOUSE", "SafariZone_RestHouse", "MAP_SAFARI_ZONE_REST_HOUSE" }, + { 26, 12, "EM_SAFARI_ZONE_NORTHEAST", "SafariZone_Northeast", "MAP_SAFARI_ZONE_NORTHEAST" }, + { 26, 13, "EM_SAFARI_ZONE_SOUTHEAST", "SafariZone_Southeast", "MAP_SAFARI_ZONE_SOUTHEAST" }, + { 26, 14, "EM_BATTLE_FRONTIER_OUTSIDE_EAST", "BattleFrontier_OutsideEast", "MAP_BATTLE_FRONTIER_OUTSIDE_EAST" }, + { 26, 15, "EM_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_PARTNER_ROOM", "BattleFrontier_BattleTowerMultiPartnerRoom", "MAP_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_PARTNER_ROOM" }, + { 26, 16, "EM_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_CORRIDOR", "BattleFrontier_BattleTowerMultiCorridor", "MAP_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_CORRIDOR" }, + { 26, 17, "EM_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_BATTLE_ROOM", "BattleFrontier_BattleTowerMultiBattleRoom", "MAP_BATTLE_FRONTIER_BATTLE_TOWER_MULTI_BATTLE_ROOM" }, + { 26, 18, "EM_BATTLE_FRONTIER_BATTLE_DOME_LOBBY", "BattleFrontier_BattleDomeLobby", "MAP_BATTLE_FRONTIER_BATTLE_DOME_LOBBY" }, + { 26, 19, "EM_BATTLE_FRONTIER_BATTLE_DOME_CORRIDOR", "BattleFrontier_BattleDomeCorridor", "MAP_BATTLE_FRONTIER_BATTLE_DOME_CORRIDOR" }, + { 26, 20, "EM_BATTLE_FRONTIER_BATTLE_DOME_PRE_BATTLE_ROOM", "BattleFrontier_BattleDomePreBattleRoom", "MAP_BATTLE_FRONTIER_BATTLE_DOME_PRE_BATTLE_ROOM" }, + { 26, 21, "EM_BATTLE_FRONTIER_BATTLE_DOME_BATTLE_ROOM", "BattleFrontier_BattleDomeBattleRoom", "MAP_BATTLE_FRONTIER_BATTLE_DOME_BATTLE_ROOM" }, + { 26, 22, "EM_BATTLE_FRONTIER_BATTLE_PALACE_LOBBY", "BattleFrontier_BattlePalaceLobby", "MAP_BATTLE_FRONTIER_BATTLE_PALACE_LOBBY" }, + { 26, 23, "EM_BATTLE_FRONTIER_BATTLE_PALACE_CORRIDOR", "BattleFrontier_BattlePalaceCorridor", "MAP_BATTLE_FRONTIER_BATTLE_PALACE_CORRIDOR" }, + { 26, 24, "EM_BATTLE_FRONTIER_BATTLE_PALACE_BATTLE_ROOM", "BattleFrontier_BattlePalaceBattleRoom", "MAP_BATTLE_FRONTIER_BATTLE_PALACE_BATTLE_ROOM" }, + { 26, 25, "EM_BATTLE_FRONTIER_BATTLE_PYRAMID_LOBBY", "BattleFrontier_BattlePyramidLobby", "MAP_BATTLE_FRONTIER_BATTLE_PYRAMID_LOBBY" }, + { 26, 26, "EM_BATTLE_FRONTIER_BATTLE_PYRAMID_FLOOR", "BattleFrontier_BattlePyramidFloor", "MAP_BATTLE_FRONTIER_BATTLE_PYRAMID_FLOOR" }, + { 26, 27, "EM_BATTLE_FRONTIER_BATTLE_PYRAMID_TOP", "BattleFrontier_BattlePyramidTop", "MAP_BATTLE_FRONTIER_BATTLE_PYRAMID_TOP" }, + { 26, 28, "EM_BATTLE_FRONTIER_BATTLE_ARENA_LOBBY", "BattleFrontier_BattleArenaLobby", "MAP_BATTLE_FRONTIER_BATTLE_ARENA_LOBBY" }, + { 26, 29, "EM_BATTLE_FRONTIER_BATTLE_ARENA_CORRIDOR", "BattleFrontier_BattleArenaCorridor", "MAP_BATTLE_FRONTIER_BATTLE_ARENA_CORRIDOR" }, + { 26, 30, "EM_BATTLE_FRONTIER_BATTLE_ARENA_BATTLE_ROOM", "BattleFrontier_BattleArenaBattleRoom", "MAP_BATTLE_FRONTIER_BATTLE_ARENA_BATTLE_ROOM" }, + { 26, 31, "EM_BATTLE_FRONTIER_BATTLE_FACTORY_LOBBY", "BattleFrontier_BattleFactoryLobby", "MAP_BATTLE_FRONTIER_BATTLE_FACTORY_LOBBY" }, + { 26, 32, "EM_BATTLE_FRONTIER_BATTLE_FACTORY_PRE_BATTLE_ROOM", "BattleFrontier_BattleFactoryPreBattleRoom", "MAP_BATTLE_FRONTIER_BATTLE_FACTORY_PRE_BATTLE_ROOM" }, + { 26, 33, "EM_BATTLE_FRONTIER_BATTLE_FACTORY_BATTLE_ROOM", "BattleFrontier_BattleFactoryBattleRoom", "MAP_BATTLE_FRONTIER_BATTLE_FACTORY_BATTLE_ROOM" }, + { 26, 34, "EM_BATTLE_FRONTIER_BATTLE_PIKE_LOBBY", "BattleFrontier_BattlePikeLobby", "MAP_BATTLE_FRONTIER_BATTLE_PIKE_LOBBY" }, + { 26, 35, "EM_BATTLE_FRONTIER_BATTLE_PIKE_CORRIDOR", "BattleFrontier_BattlePikeCorridor", "MAP_BATTLE_FRONTIER_BATTLE_PIKE_CORRIDOR" }, + { 26, 36, "EM_BATTLE_FRONTIER_BATTLE_PIKE_THREE_PATH_ROOM", "BattleFrontier_BattlePikeThreePathRoom", "MAP_BATTLE_FRONTIER_BATTLE_PIKE_THREE_PATH_ROOM" }, + { 26, 37, "EM_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_NORMAL", "BattleFrontier_BattlePikeRoomNormal", "MAP_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_NORMAL" }, + { 26, 38, "EM_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_FINAL", "BattleFrontier_BattlePikeRoomFinal", "MAP_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_FINAL" }, + { 26, 39, "EM_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_WILD_MONS", "BattleFrontier_BattlePikeRoomWildMons", "MAP_BATTLE_FRONTIER_BATTLE_PIKE_ROOM_WILD_MONS" }, + { 26, 40, "EM_BATTLE_FRONTIER_RANKING_HALL", "BattleFrontier_RankingHall", "MAP_BATTLE_FRONTIER_RANKING_HALL" }, + { 26, 41, "EM_BATTLE_FRONTIER_LOUNGE1", "BattleFrontier_Lounge1", "MAP_BATTLE_FRONTIER_LOUNGE1" }, + { 26, 42, "EM_BATTLE_FRONTIER_EXCHANGE_SERVICE_CORNER", "BattleFrontier_ExchangeServiceCorner", "MAP_BATTLE_FRONTIER_EXCHANGE_SERVICE_CORNER" }, + { 26, 43, "EM_BATTLE_FRONTIER_LOUNGE2", "BattleFrontier_Lounge2", "MAP_BATTLE_FRONTIER_LOUNGE2" }, + { 26, 44, "EM_BATTLE_FRONTIER_LOUNGE3", "BattleFrontier_Lounge3", "MAP_BATTLE_FRONTIER_LOUNGE3" }, + { 26, 45, "EM_BATTLE_FRONTIER_LOUNGE4", "BattleFrontier_Lounge4", "MAP_BATTLE_FRONTIER_LOUNGE4" }, + { 26, 46, "EM_BATTLE_FRONTIER_SCOTTS_HOUSE", "BattleFrontier_ScottsHouse", "MAP_BATTLE_FRONTIER_SCOTTS_HOUSE" }, + { 26, 47, "EM_BATTLE_FRONTIER_LOUNGE5", "BattleFrontier_Lounge5", "MAP_BATTLE_FRONTIER_LOUNGE5" }, + { 26, 48, "EM_BATTLE_FRONTIER_LOUNGE6", "BattleFrontier_Lounge6", "MAP_BATTLE_FRONTIER_LOUNGE6" }, + { 26, 49, "EM_BATTLE_FRONTIER_LOUNGE7", "BattleFrontier_Lounge7", "MAP_BATTLE_FRONTIER_LOUNGE7" }, + { 26, 50, "EM_BATTLE_FRONTIER_RECEPTION_GATE", "BattleFrontier_ReceptionGate", "MAP_BATTLE_FRONTIER_RECEPTION_GATE" }, + { 26, 51, "EM_BATTLE_FRONTIER_LOUNGE8", "BattleFrontier_Lounge8", "MAP_BATTLE_FRONTIER_LOUNGE8" }, + { 26, 52, "EM_BATTLE_FRONTIER_LOUNGE9", "BattleFrontier_Lounge9", "MAP_BATTLE_FRONTIER_LOUNGE9" }, + { 26, 53, "EM_BATTLE_FRONTIER_POKEMON_CENTER_1F", "BattleFrontier_PokemonCenter_1F", "MAP_BATTLE_FRONTIER_POKEMON_CENTER_1F" }, + { 26, 54, "EM_BATTLE_FRONTIER_POKEMON_CENTER_2F", "BattleFrontier_PokemonCenter_2F", "MAP_BATTLE_FRONTIER_POKEMON_CENTER_2F" }, + { 26, 55, "EM_BATTLE_FRONTIER_MART", "BattleFrontier_Mart", "MAP_BATTLE_FRONTIER_MART" }, + { 26, 56, "EM_FARAWAY_ISLAND_ENTRANCE", "FarawayIsland_Entrance", "MAP_FARAWAY_ISLAND_ENTRANCE" }, + { 26, 57, "EM_FARAWAY_ISLAND_INTERIOR", "FarawayIsland_Interior", "MAP_FARAWAY_ISLAND_INTERIOR" }, + { 26, 58, "EM_BIRTH_ISLAND_EXTERIOR", "BirthIsland_Exterior", "MAP_BIRTH_ISLAND_EXTERIOR" }, + { 26, 59, "EM_BIRTH_ISLAND_HARBOR", "BirthIsland_Harbor", "MAP_BIRTH_ISLAND_HARBOR" }, + { 26, 60, "EM_TRAINER_HILL_ENTRANCE", "TrainerHill_Entrance", "MAP_TRAINER_HILL_ENTRANCE" }, + { 26, 61, "EM_TRAINER_HILL_1F", "TrainerHill_1F", "MAP_TRAINER_HILL_1F" }, + { 26, 62, "EM_TRAINER_HILL_2F", "TrainerHill_2F", "MAP_TRAINER_HILL_2F" }, + { 26, 63, "EM_TRAINER_HILL_3F", "TrainerHill_3F", "MAP_TRAINER_HILL_3F" }, + { 26, 64, "EM_TRAINER_HILL_4F", "TrainerHill_4F", "MAP_TRAINER_HILL_4F" }, + { 26, 65, "EM_TRAINER_HILL_ROOF", "TrainerHill_Roof", "MAP_TRAINER_HILL_ROOF" }, + { 26, 66, "EM_NAVEL_ROCK_EXTERIOR", "NavelRock_Exterior", "MAP_NAVEL_ROCK_EXTERIOR" }, + { 26, 67, "EM_NAVEL_ROCK_HARBOR", "NavelRock_Harbor", "MAP_NAVEL_ROCK_HARBOR" }, + { 26, 68, "EM_NAVEL_ROCK_ENTRANCE", "NavelRock_Entrance", "MAP_NAVEL_ROCK_ENTRANCE" }, + { 26, 69, "EM_NAVEL_ROCK_B1F", "NavelRock_B1F", "MAP_NAVEL_ROCK_B1F" }, + { 26, 70, "EM_NAVEL_ROCK_FORK", "NavelRock_Fork", "MAP_NAVEL_ROCK_FORK" }, + { 26, 71, "EM_NAVEL_ROCK_UP1", "NavelRock_Up1", "MAP_NAVEL_ROCK_UP1" }, + { 26, 72, "EM_NAVEL_ROCK_UP2", "NavelRock_Up2", "MAP_NAVEL_ROCK_UP2" }, + { 26, 73, "EM_NAVEL_ROCK_UP3", "NavelRock_Up3", "MAP_NAVEL_ROCK_UP3" }, + { 26, 74, "EM_NAVEL_ROCK_UP4", "NavelRock_Up4", "MAP_NAVEL_ROCK_UP4" }, + { 26, 75, "EM_NAVEL_ROCK_TOP", "NavelRock_Top", "MAP_NAVEL_ROCK_TOP" }, + { 26, 76, "EM_NAVEL_ROCK_DOWN01", "NavelRock_Down01", "MAP_NAVEL_ROCK_DOWN01" }, + { 26, 77, "EM_NAVEL_ROCK_DOWN02", "NavelRock_Down02", "MAP_NAVEL_ROCK_DOWN02" }, + { 26, 78, "EM_NAVEL_ROCK_DOWN03", "NavelRock_Down03", "MAP_NAVEL_ROCK_DOWN03" }, + { 26, 79, "EM_NAVEL_ROCK_DOWN04", "NavelRock_Down04", "MAP_NAVEL_ROCK_DOWN04" }, + { 26, 80, "EM_NAVEL_ROCK_DOWN05", "NavelRock_Down05", "MAP_NAVEL_ROCK_DOWN05" }, + { 26, 81, "EM_NAVEL_ROCK_DOWN06", "NavelRock_Down06", "MAP_NAVEL_ROCK_DOWN06" }, + { 26, 82, "EM_NAVEL_ROCK_DOWN07", "NavelRock_Down07", "MAP_NAVEL_ROCK_DOWN07" }, + { 26, 83, "EM_NAVEL_ROCK_DOWN08", "NavelRock_Down08", "MAP_NAVEL_ROCK_DOWN08" }, + { 26, 84, "EM_NAVEL_ROCK_DOWN09", "NavelRock_Down09", "MAP_NAVEL_ROCK_DOWN09" }, + { 26, 85, "EM_NAVEL_ROCK_DOWN10", "NavelRock_Down10", "MAP_NAVEL_ROCK_DOWN10" }, + { 26, 86, "EM_NAVEL_ROCK_DOWN11", "NavelRock_Down11", "MAP_NAVEL_ROCK_DOWN11" }, + { 26, 87, "EM_NAVEL_ROCK_BOTTOM", "NavelRock_Bottom", "MAP_NAVEL_ROCK_BOTTOM" }, + { 26, 88, "EM_TRAINER_HILL_ELEVATOR", "TrainerHill_Elevator", "MAP_TRAINER_HILL_ELEVATOR" }, + { 27, 0, "EM_ROUTE104_PROTOTYPE", "Route104_Prototype", "MAP_ROUTE104_PROTOTYPE" }, + { 27, 1, "EM_ROUTE104_PROTOTYPE_PRETTY_PETAL_FLOWER_SHOP", "Route104_PrototypePrettyPetalFlowerShop", "MAP_ROUTE104_PROTOTYPE_PRETTY_PETAL_FLOWER_SHOP" }, + { 28, 0, "EM_ROUTE109_SEASHORE_HOUSE", "Route109_SeashoreHouse", "MAP_ROUTE109_SEASHORE_HOUSE" }, + { 29, 0, "EM_ROUTE110_TRICK_HOUSE_ENTRANCE", "Route110_TrickHouseEntrance", "MAP_ROUTE110_TRICK_HOUSE_ENTRANCE" }, + { 29, 1, "EM_ROUTE110_TRICK_HOUSE_END", "Route110_TrickHouseEnd", "MAP_ROUTE110_TRICK_HOUSE_END" }, + { 29, 2, "EM_ROUTE110_TRICK_HOUSE_CORRIDOR", "Route110_TrickHouseCorridor", "MAP_ROUTE110_TRICK_HOUSE_CORRIDOR" }, + { 29, 3, "EM_ROUTE110_TRICK_HOUSE_PUZZLE1", "Route110_TrickHousePuzzle1", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE1" }, + { 29, 4, "EM_ROUTE110_TRICK_HOUSE_PUZZLE2", "Route110_TrickHousePuzzle2", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE2" }, + { 29, 5, "EM_ROUTE110_TRICK_HOUSE_PUZZLE3", "Route110_TrickHousePuzzle3", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE3" }, + { 29, 6, "EM_ROUTE110_TRICK_HOUSE_PUZZLE4", "Route110_TrickHousePuzzle4", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE4" }, + { 29, 7, "EM_ROUTE110_TRICK_HOUSE_PUZZLE5", "Route110_TrickHousePuzzle5", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE5" }, + { 29, 8, "EM_ROUTE110_TRICK_HOUSE_PUZZLE6", "Route110_TrickHousePuzzle6", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE6" }, + { 29, 9, "EM_ROUTE110_TRICK_HOUSE_PUZZLE7", "Route110_TrickHousePuzzle7", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE7" }, + { 29, 10, "EM_ROUTE110_TRICK_HOUSE_PUZZLE8", "Route110_TrickHousePuzzle8", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE8" }, + { 29, 11, "EM_ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE", "Route110_SeasideCyclingRoadSouthEntrance", "MAP_ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE" }, + { 29, 12, "EM_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE", "Route110_SeasideCyclingRoadNorthEntrance", "MAP_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE" }, + { 30, 0, "EM_ROUTE113_GLASS_WORKSHOP", "Route113_GlassWorkshop", "MAP_ROUTE113_GLASS_WORKSHOP" }, + { 31, 0, "EM_ROUTE123_BERRY_MASTERS_HOUSE", "Route123_BerryMastersHouse", "MAP_ROUTE123_BERRY_MASTERS_HOUSE" }, + { 32, 0, "EM_ROUTE119_WEATHER_INSTITUTE_1F", "Route119_WeatherInstitute_1F", "MAP_ROUTE119_WEATHER_INSTITUTE_1F" }, + { 32, 1, "EM_ROUTE119_WEATHER_INSTITUTE_2F", "Route119_WeatherInstitute_2F", "MAP_ROUTE119_WEATHER_INSTITUTE_2F" }, + { 32, 2, "EM_ROUTE119_HOUSE", "Route119_House", "MAP_ROUTE119_HOUSE" }, + { 33, 0, "EM_ROUTE124_DIVING_TREASURE_HUNTERS_HOUSE", "Route124_DivingTreasureHuntersHouse", "MAP_ROUTE124_DIVING_TREASURE_HUNTERS_HOUSE" }, +} +D.ruby = { + { 0, 0, "RU_PETALBURG_CITY", "PetalburgCity", "MAP_PETALBURG_CITY" }, + { 0, 1, "RU_SLATEPORT_CITY", "SlateportCity", "MAP_SLATEPORT_CITY" }, + { 0, 2, "RU_MAUVILLE_CITY", "MauvilleCity", "MAP_MAUVILLE_CITY" }, + { 0, 3, "RU_RUSTBORO_CITY", "RustboroCity", "MAP_RUSTBORO_CITY" }, + { 0, 4, "RU_FORTREE_CITY", "FortreeCity", "MAP_FORTREE_CITY" }, + { 0, 5, "RU_LILYCOVE_CITY", "LilycoveCity", "MAP_LILYCOVE_CITY" }, + { 0, 6, "RU_MOSSDEEP_CITY", "MossdeepCity", "MAP_MOSSDEEP_CITY" }, + { 0, 7, "RU_SOOTOPOLIS_CITY", "SootopolisCity", "MAP_SOOTOPOLIS_CITY" }, + { 0, 8, "RU_EVER_GRANDE_CITY", "EverGrandeCity", "MAP_EVER_GRANDE_CITY" }, + { 0, 9, "RU_LITTLEROOT_TOWN", "LittlerootTown", "MAP_LITTLEROOT_TOWN" }, + { 0, 10, "RU_OLDALE_TOWN", "OldaleTown", "MAP_OLDALE_TOWN" }, + { 0, 11, "RU_DEWFORD_TOWN", "DewfordTown", "MAP_DEWFORD_TOWN" }, + { 0, 12, "RU_LAVARIDGE_TOWN", "LavaridgeTown", "MAP_LAVARIDGE_TOWN" }, + { 0, 13, "RU_FALLARBOR_TOWN", "FallarborTown", "MAP_FALLARBOR_TOWN" }, + { 0, 14, "RU_VERDANTURF_TOWN", "VerdanturfTown", "MAP_VERDANTURF_TOWN" }, + { 0, 15, "RU_PACIFIDLOG_TOWN", "PacifidlogTown", "MAP_PACIFIDLOG_TOWN" }, + { 0, 16, "RU_ROUTE101", "Route101", "MAP_ROUTE101" }, + { 0, 17, "RU_ROUTE102", "Route102", "MAP_ROUTE102" }, + { 0, 18, "RU_ROUTE103", "Route103", "MAP_ROUTE103" }, + { 0, 19, "RU_ROUTE104", "Route104", "MAP_ROUTE104" }, + { 0, 20, "RU_ROUTE105", "Route105", "MAP_ROUTE105" }, + { 0, 21, "RU_ROUTE106", "Route106", "MAP_ROUTE106" }, + { 0, 22, "RU_ROUTE107", "Route107", "MAP_ROUTE107" }, + { 0, 23, "RU_ROUTE108", "Route108", "MAP_ROUTE108" }, + { 0, 24, "RU_ROUTE109", "Route109", "MAP_ROUTE109" }, + { 0, 25, "RU_ROUTE110", "Route110", "MAP_ROUTE110" }, + { 0, 26, "RU_ROUTE111", "Route111", "MAP_ROUTE111" }, + { 0, 27, "RU_ROUTE112", "Route112", "MAP_ROUTE112" }, + { 0, 28, "RU_ROUTE113", "Route113", "MAP_ROUTE113" }, + { 0, 29, "RU_ROUTE114", "Route114", "MAP_ROUTE114" }, + { 0, 30, "RU_ROUTE115", "Route115", "MAP_ROUTE115" }, + { 0, 31, "RU_ROUTE116", "Route116", "MAP_ROUTE116" }, + { 0, 32, "RU_ROUTE117", "Route117", "MAP_ROUTE117" }, + { 0, 33, "RU_ROUTE118", "Route118", "MAP_ROUTE118" }, + { 0, 34, "RU_ROUTE119", "Route119", "MAP_ROUTE119" }, + { 0, 35, "RU_ROUTE120", "Route120", "MAP_ROUTE120" }, + { 0, 36, "RU_ROUTE121", "Route121", "MAP_ROUTE121" }, + { 0, 37, "RU_ROUTE122", "Route122", "MAP_ROUTE122" }, + { 0, 38, "RU_ROUTE123", "Route123", "MAP_ROUTE123" }, + { 0, 39, "RU_ROUTE124", "Route124", "MAP_ROUTE124" }, + { 0, 40, "RU_ROUTE125", "Route125", "MAP_ROUTE125" }, + { 0, 41, "RU_ROUTE126", "Route126", "MAP_ROUTE126" }, + { 0, 42, "RU_ROUTE127", "Route127", "MAP_ROUTE127" }, + { 0, 43, "RU_ROUTE128", "Route128", "MAP_ROUTE128" }, + { 0, 44, "RU_ROUTE129", "Route129", "MAP_ROUTE129" }, + { 0, 45, "RU_ROUTE130", "Route130", "MAP_ROUTE130" }, + { 0, 46, "RU_ROUTE131", "Route131", "MAP_ROUTE131" }, + { 0, 47, "RU_ROUTE132", "Route132", "MAP_ROUTE132" }, + { 0, 48, "RU_ROUTE133", "Route133", "MAP_ROUTE133" }, + { 0, 49, "RU_ROUTE134", "Route134", "MAP_ROUTE134" }, + { 0, 50, "RU_UNDERWATER1", "Underwater1", "MAP_UNDERWATER1" }, + { 0, 51, "RU_UNDERWATER2", "Underwater2", "MAP_UNDERWATER2" }, + { 0, 52, "RU_UNDERWATER3", "Underwater3", "MAP_UNDERWATER3" }, + { 0, 53, "RU_UNDERWATER4", "Underwater4", "MAP_UNDERWATER4" }, + { 1, 0, "RU_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F", "LittlerootTown_BrendansHouse_1F", "MAP_LITTLEROOT_TOWN_BRENDANS_HOUSE_1F" }, + { 1, 1, "RU_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F", "LittlerootTown_BrendansHouse_2F", "MAP_LITTLEROOT_TOWN_BRENDANS_HOUSE_2F" }, + { 1, 2, "RU_LITTLEROOT_TOWN_MAYS_HOUSE_1F", "LittlerootTown_MaysHouse_1F", "MAP_LITTLEROOT_TOWN_MAYS_HOUSE_1F" }, + { 1, 3, "RU_LITTLEROOT_TOWN_MAYS_HOUSE_2F", "LittlerootTown_MaysHouse_2F", "MAP_LITTLEROOT_TOWN_MAYS_HOUSE_2F" }, + { 1, 4, "RU_LITTLEROOT_TOWN_PROFESSOR_BIRCHS_LAB", "LittlerootTown_ProfessorBirchsLab", "MAP_LITTLEROOT_TOWN_PROFESSOR_BIRCHS_LAB" }, + { 2, 0, "RU_OLDALE_TOWN_HOUSE1", "OldaleTown_House1", "MAP_OLDALE_TOWN_HOUSE1" }, + { 2, 1, "RU_OLDALE_TOWN_HOUSE2", "OldaleTown_House2", "MAP_OLDALE_TOWN_HOUSE2" }, + { 2, 2, "RU_OLDALE_TOWN_POKEMON_CENTER_1F", "OldaleTown_PokemonCenter_1F", "MAP_OLDALE_TOWN_POKEMON_CENTER_1F" }, + { 2, 3, "RU_OLDALE_TOWN_POKEMON_CENTER_2F", "OldaleTown_PokemonCenter_2F", "MAP_OLDALE_TOWN_POKEMON_CENTER_2F" }, + { 2, 4, "RU_OLDALE_TOWN_MART", "OldaleTown_Mart", "MAP_OLDALE_TOWN_MART" }, + { 3, 0, "RU_DEWFORD_TOWN_HOUSE1", "DewfordTown_House1", "MAP_DEWFORD_TOWN_HOUSE1" }, + { 3, 1, "RU_DEWFORD_TOWN_POKEMON_CENTER_1F", "DewfordTown_PokemonCenter_1F", "MAP_DEWFORD_TOWN_POKEMON_CENTER_1F" }, + { 3, 2, "RU_DEWFORD_TOWN_POKEMON_CENTER_2F", "DewfordTown_PokemonCenter_2F", "MAP_DEWFORD_TOWN_POKEMON_CENTER_2F" }, + { 3, 3, "RU_DEWFORD_TOWN_GYM", "DewfordTown_Gym", "MAP_DEWFORD_TOWN_GYM" }, + { 3, 4, "RU_DEWFORD_TOWN_HALL", "DewfordTown_Hall", "MAP_DEWFORD_TOWN_HALL" }, + { 3, 5, "RU_DEWFORD_TOWN_HOUSE2", "DewfordTown_House2", "MAP_DEWFORD_TOWN_HOUSE2" }, + { 4, 0, "RU_LAVARIDGE_TOWN_HERB_SHOP", "LavaridgeTown_HerbShop", "MAP_LAVARIDGE_TOWN_HERB_SHOP" }, + { 4, 1, "RU_LAVARIDGE_TOWN_GYM_1F", "LavaridgeTown_Gym_1F", "MAP_LAVARIDGE_TOWN_GYM_1F" }, + { 4, 2, "RU_LAVARIDGE_TOWN_GYM_B1F", "LavaridgeTown_Gym_B1F", "MAP_LAVARIDGE_TOWN_GYM_B1F" }, + { 4, 3, "RU_LAVARIDGE_TOWN_HOUSE", "LavaridgeTown_House", "MAP_LAVARIDGE_TOWN_HOUSE" }, + { 4, 4, "RU_LAVARIDGE_TOWN_MART", "LavaridgeTown_Mart", "MAP_LAVARIDGE_TOWN_MART" }, + { 4, 5, "RU_LAVARIDGE_TOWN_POKEMON_CENTER_1F", "LavaridgeTown_PokemonCenter_1F", "MAP_LAVARIDGE_TOWN_POKEMON_CENTER_1F" }, + { 4, 6, "RU_LAVARIDGE_TOWN_POKEMON_CENTER_2F", "LavaridgeTown_PokemonCenter_2F", "MAP_LAVARIDGE_TOWN_POKEMON_CENTER_2F" }, + { 5, 0, "RU_FALLARBOR_TOWN_MART", "FallarborTown_Mart", "MAP_FALLARBOR_TOWN_MART" }, + { 5, 1, "RU_FALLARBOR_TOWN_CONTEST_LOBBY", "FallarborTown_ContestLobby", "MAP_FALLARBOR_TOWN_CONTEST_LOBBY" }, + { 5, 2, "RU_FALLARBOR_TOWN_CONTEST_HALL", "FallarborTown_ContestHall", "MAP_FALLARBOR_TOWN_CONTEST_HALL" }, + { 5, 3, "RU_FALLARBOR_TOWN_POKEMON_CENTER_1F", "FallarborTown_PokemonCenter_1F", "MAP_FALLARBOR_TOWN_POKEMON_CENTER_1F" }, + { 5, 4, "RU_FALLARBOR_TOWN_POKEMON_CENTER_2F", "FallarborTown_PokemonCenter_2F", "MAP_FALLARBOR_TOWN_POKEMON_CENTER_2F" }, + { 5, 5, "RU_FALLARBOR_TOWN_HOUSE1", "FallarborTown_House1", "MAP_FALLARBOR_TOWN_HOUSE1" }, + { 5, 6, "RU_FALLARBOR_TOWN_HOUSE2", "FallarborTown_House2", "MAP_FALLARBOR_TOWN_HOUSE2" }, + { 6, 0, "RU_VERDANTURF_TOWN_CONTEST_LOBBY", "VerdanturfTown_ContestLobby", "MAP_VERDANTURF_TOWN_CONTEST_LOBBY" }, + { 6, 1, "RU_VERDANTURF_TOWN_CONTEST_HALL", "VerdanturfTown_ContestHall", "MAP_VERDANTURF_TOWN_CONTEST_HALL" }, + { 6, 2, "RU_VERDANTURF_TOWN_MART", "VerdanturfTown_Mart", "MAP_VERDANTURF_TOWN_MART" }, + { 6, 3, "RU_VERDANTURF_TOWN_POKEMON_CENTER_1F", "VerdanturfTown_PokemonCenter_1F", "MAP_VERDANTURF_TOWN_POKEMON_CENTER_1F" }, + { 6, 4, "RU_VERDANTURF_TOWN_POKEMON_CENTER_2F", "VerdanturfTown_PokemonCenter_2F", "MAP_VERDANTURF_TOWN_POKEMON_CENTER_2F" }, + { 6, 5, "RU_VERDANTURF_TOWN_WANDAS_HOUSE", "VerdanturfTown_WandasHouse", "MAP_VERDANTURF_TOWN_WANDAS_HOUSE" }, + { 6, 6, "RU_VERDANTURF_TOWN_FRIENDSHIP_RATERS_HOUSE", "VerdanturfTown_FriendshipRatersHouse", "MAP_VERDANTURF_TOWN_FRIENDSHIP_RATERS_HOUSE" }, + { 6, 7, "RU_VERDANTURF_TOWN_HOUSE", "VerdanturfTown_House", "MAP_VERDANTURF_TOWN_HOUSE" }, + { 7, 0, "RU_PACIFIDLOG_TOWN_POKEMON_CENTER_1F", "PacifidlogTown_PokemonCenter_1F", "MAP_PACIFIDLOG_TOWN_POKEMON_CENTER_1F" }, + { 7, 1, "RU_PACIFIDLOG_TOWN_POKEMON_CENTER_2F", "PacifidlogTown_PokemonCenter_2F", "MAP_PACIFIDLOG_TOWN_POKEMON_CENTER_2F" }, + { 7, 2, "RU_PACIFIDLOG_TOWN_HOUSE1", "PacifidlogTown_House1", "MAP_PACIFIDLOG_TOWN_HOUSE1" }, + { 7, 3, "RU_PACIFIDLOG_TOWN_HOUSE2", "PacifidlogTown_House2", "MAP_PACIFIDLOG_TOWN_HOUSE2" }, + { 7, 4, "RU_PACIFIDLOG_TOWN_HOUSE3", "PacifidlogTown_House3", "MAP_PACIFIDLOG_TOWN_HOUSE3" }, + { 7, 5, "RU_PACIFIDLOG_TOWN_HOUSE4", "PacifidlogTown_House4", "MAP_PACIFIDLOG_TOWN_HOUSE4" }, + { 7, 6, "RU_PACIFIDLOG_TOWN_HOUSE5", "PacifidlogTown_House5", "MAP_PACIFIDLOG_TOWN_HOUSE5" }, + { 8, 0, "RU_PETALBURG_CITY_WALLYS_HOUSE", "PetalburgCity_WallysHouse", "MAP_PETALBURG_CITY_WALLYS_HOUSE" }, + { 8, 1, "RU_PETALBURG_CITY_GYM", "PetalburgCity_Gym", "MAP_PETALBURG_CITY_GYM" }, + { 8, 2, "RU_PETALBURG_CITY_HOUSE1", "PetalburgCity_House1", "MAP_PETALBURG_CITY_HOUSE1" }, + { 8, 3, "RU_PETALBURG_CITY_HOUSE2", "PetalburgCity_House2", "MAP_PETALBURG_CITY_HOUSE2" }, + { 8, 4, "RU_PETALBURG_CITY_POKEMON_CENTER_1F", "PetalburgCity_PokemonCenter_1F", "MAP_PETALBURG_CITY_POKEMON_CENTER_1F" }, + { 8, 5, "RU_PETALBURG_CITY_POKEMON_CENTER_2F", "PetalburgCity_PokemonCenter_2F", "MAP_PETALBURG_CITY_POKEMON_CENTER_2F" }, + { 8, 6, "RU_PETALBURG_CITY_MART", "PetalburgCity_Mart", "MAP_PETALBURG_CITY_MART" }, + { 9, 0, "RU_SLATEPORT_CITY_STERNS_SHIPYARD_1F", "SlateportCity_SternsShipyard_1F", "MAP_SLATEPORT_CITY_STERNS_SHIPYARD_1F" }, + { 9, 1, "RU_SLATEPORT_CITY_STERNS_SHIPYARD_2F", "SlateportCity_SternsShipyard_2F", "MAP_SLATEPORT_CITY_STERNS_SHIPYARD_2F" }, + { 9, 2, "RU_SLATEPORT_CITY_CONTEST_LOBBY", "SlateportCity_ContestLobby", "MAP_SLATEPORT_CITY_CONTEST_LOBBY" }, + { 9, 3, "RU_SLATEPORT_CITY_CONTEST_HALL", "SlateportCity_ContestHall", "MAP_SLATEPORT_CITY_CONTEST_HALL" }, + { 9, 4, "RU_SLATEPORT_CITY_HOUSE1", "SlateportCity_House1", "MAP_SLATEPORT_CITY_HOUSE1" }, + { 9, 5, "RU_SLATEPORT_CITY_POKEMON_FAN_CLUB", "SlateportCity_PokemonFanClub", "MAP_SLATEPORT_CITY_POKEMON_FAN_CLUB" }, + { 9, 6, "RU_SLATEPORT_CITY_OCEANIC_MUSEUM_1F", "SlateportCity_OceanicMuseum_1F", "MAP_SLATEPORT_CITY_OCEANIC_MUSEUM_1F" }, + { 9, 7, "RU_SLATEPORT_CITY_OCEANIC_MUSEUM_2F", "SlateportCity_OceanicMuseum_2F", "MAP_SLATEPORT_CITY_OCEANIC_MUSEUM_2F" }, + { 9, 8, "RU_SLATEPORT_CITY_HARBOR", "SlateportCity_Harbor", "MAP_SLATEPORT_CITY_HARBOR" }, + { 9, 9, "RU_SLATEPORT_CITY_HOUSE2", "SlateportCity_House2", "MAP_SLATEPORT_CITY_HOUSE2" }, + { 9, 10, "RU_SLATEPORT_CITY_POKEMON_CENTER_1F", "SlateportCity_PokemonCenter_1F", "MAP_SLATEPORT_CITY_POKEMON_CENTER_1F" }, + { 9, 11, "RU_SLATEPORT_CITY_POKEMON_CENTER_2F", "SlateportCity_PokemonCenter_2F", "MAP_SLATEPORT_CITY_POKEMON_CENTER_2F" }, + { 9, 12, "RU_SLATEPORT_CITY_MART", "SlateportCity_Mart", "MAP_SLATEPORT_CITY_MART" }, + { 10, 0, "RU_MAUVILLE_CITY_GYM", "MauvilleCity_Gym", "MAP_MAUVILLE_CITY_GYM" }, + { 10, 1, "RU_MAUVILLE_CITY_BIKE_SHOP", "MauvilleCity_BikeShop", "MAP_MAUVILLE_CITY_BIKE_SHOP" }, + { 10, 2, "RU_MAUVILLE_CITY_HOUSE1", "MauvilleCity_House1", "MAP_MAUVILLE_CITY_HOUSE1" }, + { 10, 3, "RU_MAUVILLE_CITY_GAME_CORNER", "MauvilleCity_GameCorner", "MAP_MAUVILLE_CITY_GAME_CORNER" }, + { 10, 4, "RU_MAUVILLE_CITY_HOUSE2", "MauvilleCity_House2", "MAP_MAUVILLE_CITY_HOUSE2" }, + { 10, 5, "RU_MAUVILLE_CITY_POKEMON_CENTER_1F", "MauvilleCity_PokemonCenter_1F", "MAP_MAUVILLE_CITY_POKEMON_CENTER_1F" }, + { 10, 6, "RU_MAUVILLE_CITY_POKEMON_CENTER_2F", "MauvilleCity_PokemonCenter_2F", "MAP_MAUVILLE_CITY_POKEMON_CENTER_2F" }, + { 10, 7, "RU_MAUVILLE_CITY_MART", "MauvilleCity_Mart", "MAP_MAUVILLE_CITY_MART" }, + { 11, 0, "RU_RUSTBORO_CITY_DEVON_CORP_1F", "RustboroCity_DevonCorp_1F", "MAP_RUSTBORO_CITY_DEVON_CORP_1F" }, + { 11, 1, "RU_RUSTBORO_CITY_DEVON_CORP_2F", "RustboroCity_DevonCorp_2F", "MAP_RUSTBORO_CITY_DEVON_CORP_2F" }, + { 11, 2, "RU_RUSTBORO_CITY_DEVON_CORP_3F", "RustboroCity_DevonCorp_3F", "MAP_RUSTBORO_CITY_DEVON_CORP_3F" }, + { 11, 3, "RU_RUSTBORO_CITY_GYM", "RustboroCity_Gym", "MAP_RUSTBORO_CITY_GYM" }, + { 11, 4, "RU_RUSTBORO_CITY_POKEMON_SCHOOL", "RustboroCity_PokemonSchool", "MAP_RUSTBORO_CITY_POKEMON_SCHOOL" }, + { 11, 5, "RU_RUSTBORO_CITY_POKEMON_CENTER_1F", "RustboroCity_PokemonCenter_1F", "MAP_RUSTBORO_CITY_POKEMON_CENTER_1F" }, + { 11, 6, "RU_RUSTBORO_CITY_POKEMON_CENTER_2F", "RustboroCity_PokemonCenter_2F", "MAP_RUSTBORO_CITY_POKEMON_CENTER_2F" }, + { 11, 7, "RU_RUSTBORO_CITY_MART", "RustboroCity_Mart", "MAP_RUSTBORO_CITY_MART" }, + { 11, 8, "RU_RUSTBORO_CITY_FLAT1_1F", "RustboroCity_Flat1_1F", "MAP_RUSTBORO_CITY_FLAT1_1F" }, + { 11, 9, "RU_RUSTBORO_CITY_FLAT1_2F", "RustboroCity_Flat1_2F", "MAP_RUSTBORO_CITY_FLAT1_2F" }, + { 11, 10, "RU_RUSTBORO_CITY_HOUSE1", "RustboroCity_House1", "MAP_RUSTBORO_CITY_HOUSE1" }, + { 11, 11, "RU_RUSTBORO_CITY_CUTTERS_HOUSE", "RustboroCity_CuttersHouse", "MAP_RUSTBORO_CITY_CUTTERS_HOUSE" }, + { 11, 12, "RU_RUSTBORO_CITY_HOUSE2", "RustboroCity_House2", "MAP_RUSTBORO_CITY_HOUSE2" }, + { 11, 13, "RU_RUSTBORO_CITY_FLAT2_1F", "RustboroCity_Flat2_1F", "MAP_RUSTBORO_CITY_FLAT2_1F" }, + { 11, 14, "RU_RUSTBORO_CITY_FLAT2_2F", "RustboroCity_Flat2_2F", "MAP_RUSTBORO_CITY_FLAT2_2F" }, + { 11, 15, "RU_RUSTBORO_CITY_FLAT2_3F", "RustboroCity_Flat2_3F", "MAP_RUSTBORO_CITY_FLAT2_3F" }, + { 11, 16, "RU_RUSTBORO_CITY_HOUSE3", "RustboroCity_House3", "MAP_RUSTBORO_CITY_HOUSE3" }, + { 12, 0, "RU_FORTREE_CITY_HOUSE1", "FortreeCity_House1", "MAP_FORTREE_CITY_HOUSE1" }, + { 12, 1, "RU_FORTREE_CITY_GYM", "FortreeCity_Gym", "MAP_FORTREE_CITY_GYM" }, + { 12, 2, "RU_FORTREE_CITY_POKEMON_CENTER_1F", "FortreeCity_PokemonCenter_1F", "MAP_FORTREE_CITY_POKEMON_CENTER_1F" }, + { 12, 3, "RU_FORTREE_CITY_POKEMON_CENTER_2F", "FortreeCity_PokemonCenter_2F", "MAP_FORTREE_CITY_POKEMON_CENTER_2F" }, + { 12, 4, "RU_FORTREE_CITY_MART", "FortreeCity_Mart", "MAP_FORTREE_CITY_MART" }, + { 12, 5, "RU_FORTREE_CITY_HOUSE2", "FortreeCity_House2", "MAP_FORTREE_CITY_HOUSE2" }, + { 12, 6, "RU_FORTREE_CITY_HOUSE3", "FortreeCity_House3", "MAP_FORTREE_CITY_HOUSE3" }, + { 12, 7, "RU_FORTREE_CITY_HOUSE4", "FortreeCity_House4", "MAP_FORTREE_CITY_HOUSE4" }, + { 12, 8, "RU_FORTREE_CITY_HOUSE5", "FortreeCity_House5", "MAP_FORTREE_CITY_HOUSE5" }, + { 12, 9, "RU_FORTREE_CITY_DECORATION_SHOP", "FortreeCity_DecorationShop", "MAP_FORTREE_CITY_DECORATION_SHOP" }, + { 13, 0, "RU_LILYCOVE_CITY_COVE_LILY_MOTEL_1F", "LilycoveCity_CoveLilyMotel_1F", "MAP_LILYCOVE_CITY_COVE_LILY_MOTEL_1F" }, + { 13, 1, "RU_LILYCOVE_CITY_COVE_LILY_MOTEL_2F", "LilycoveCity_CoveLilyMotel_2F", "MAP_LILYCOVE_CITY_COVE_LILY_MOTEL_2F" }, + { 13, 2, "RU_LILYCOVE_CITY_LILYCOVE_MUSEUM_1F", "LilycoveCity_LilycoveMuseum_1F", "MAP_LILYCOVE_CITY_LILYCOVE_MUSEUM_1F" }, + { 13, 3, "RU_LILYCOVE_CITY_LILYCOVE_MUSEUM_2F", "LilycoveCity_LilycoveMuseum_2F", "MAP_LILYCOVE_CITY_LILYCOVE_MUSEUM_2F" }, + { 13, 4, "RU_LILYCOVE_CITY_CONTEST_LOBBY", "LilycoveCity_ContestLobby", "MAP_LILYCOVE_CITY_CONTEST_LOBBY" }, + { 13, 5, "RU_LILYCOVE_CITY_CONTEST_HALL", "LilycoveCity_ContestHall", "MAP_LILYCOVE_CITY_CONTEST_HALL" }, + { 13, 6, "RU_LILYCOVE_CITY_POKEMON_CENTER_1F", "LilycoveCity_PokemonCenter_1F", "MAP_LILYCOVE_CITY_POKEMON_CENTER_1F" }, + { 13, 7, "RU_LILYCOVE_CITY_POKEMON_CENTER_2F", "LilycoveCity_PokemonCenter_2F", "MAP_LILYCOVE_CITY_POKEMON_CENTER_2F" }, + { 13, 8, "RU_LILYCOVE_CITY_UNUSED_MART", "LilycoveCity_UnusedMart", "MAP_LILYCOVE_CITY_UNUSED_MART" }, + { 13, 9, "RU_LILYCOVE_CITY_POKEMON_TRAINER_FAN_CLUB", "LilycoveCity_PokemonTrainerFanClub", "MAP_LILYCOVE_CITY_POKEMON_TRAINER_FAN_CLUB" }, + { 13, 10, "RU_LILYCOVE_CITY_HARBOR", "LilycoveCity_Harbor", "MAP_LILYCOVE_CITY_HARBOR" }, + { 13, 11, "RU_LILYCOVE_CITY_EMPTY_MAP", "LilycoveCity_EmptyMap", "MAP_LILYCOVE_CITY_EMPTY_MAP" }, + { 13, 12, "RU_LILYCOVE_CITY_MOVE_DELETERS_HOUSE", "LilycoveCity_MoveDeletersHouse", "MAP_LILYCOVE_CITY_MOVE_DELETERS_HOUSE" }, + { 13, 13, "RU_LILYCOVE_CITY_HOUSE1", "LilycoveCity_House1", "MAP_LILYCOVE_CITY_HOUSE1" }, + { 13, 14, "RU_LILYCOVE_CITY_HOUSE2", "LilycoveCity_House2", "MAP_LILYCOVE_CITY_HOUSE2" }, + { 13, 15, "RU_LILYCOVE_CITY_HOUSE3", "LilycoveCity_House3", "MAP_LILYCOVE_CITY_HOUSE3" }, + { 13, 16, "RU_LILYCOVE_CITY_HOUSE4", "LilycoveCity_House4", "MAP_LILYCOVE_CITY_HOUSE4" }, + { 13, 17, "RU_LILYCOVE_CITY_DEPARTMENT_STORE_1F", "LilycoveCity_DepartmentStore_1F", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_1F" }, + { 13, 18, "RU_LILYCOVE_CITY_DEPARTMENT_STORE_2F", "LilycoveCity_DepartmentStore_2F", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_2F" }, + { 13, 19, "RU_LILYCOVE_CITY_DEPARTMENT_STORE_3F", "LilycoveCity_DepartmentStore_3F", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_3F" }, + { 13, 20, "RU_LILYCOVE_CITY_DEPARTMENT_STORE_4F", "LilycoveCity_DepartmentStore_4F", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_4F" }, + { 13, 21, "RU_LILYCOVE_CITY_DEPARTMENT_STORE_5F", "LilycoveCity_DepartmentStore_5F", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_5F" }, + { 13, 22, "RU_LILYCOVE_CITY_DEPARTMENT_STORE_ROOFTOP", "LilycoveCity_DepartmentStoreRooftop", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_ROOFTOP" }, + { 13, 23, "RU_LILYCOVE_CITY_DEPARTMENT_STORE_ELEVATOR", "LilycoveCity_DepartmentStoreElevator", "MAP_LILYCOVE_CITY_DEPARTMENT_STORE_ELEVATOR" }, + { 14, 0, "RU_MOSSDEEP_CITY_GYM", "MossdeepCity_Gym", "MAP_MOSSDEEP_CITY_GYM" }, + { 14, 1, "RU_MOSSDEEP_CITY_HOUSE1", "MossdeepCity_House1", "MAP_MOSSDEEP_CITY_HOUSE1" }, + { 14, 2, "RU_MOSSDEEP_CITY_HOUSE2", "MossdeepCity_House2", "MAP_MOSSDEEP_CITY_HOUSE2" }, + { 14, 3, "RU_MOSSDEEP_CITY_POKEMON_CENTER_1F", "MossdeepCity_PokemonCenter_1F", "MAP_MOSSDEEP_CITY_POKEMON_CENTER_1F" }, + { 14, 4, "RU_MOSSDEEP_CITY_POKEMON_CENTER_2F", "MossdeepCity_PokemonCenter_2F", "MAP_MOSSDEEP_CITY_POKEMON_CENTER_2F" }, + { 14, 5, "RU_MOSSDEEP_CITY_MART", "MossdeepCity_Mart", "MAP_MOSSDEEP_CITY_MART" }, + { 14, 6, "RU_MOSSDEEP_CITY_HOUSE3", "MossdeepCity_House3", "MAP_MOSSDEEP_CITY_HOUSE3" }, + { 14, 7, "RU_MOSSDEEP_CITY_STEVENS_HOUSE", "MossdeepCity_StevensHouse", "MAP_MOSSDEEP_CITY_STEVENS_HOUSE" }, + { 14, 8, "RU_MOSSDEEP_CITY_HOUSE4", "MossdeepCity_House4", "MAP_MOSSDEEP_CITY_HOUSE4" }, + { 14, 9, "RU_MOSSDEEP_CITY_SPACE_CENTER_1F", "MossdeepCity_SpaceCenter_1F", "MAP_MOSSDEEP_CITY_SPACE_CENTER_1F" }, + { 14, 10, "RU_MOSSDEEP_CITY_SPACE_CENTER_2F", "MossdeepCity_SpaceCenter_2F", "MAP_MOSSDEEP_CITY_SPACE_CENTER_2F" }, + { 14, 11, "RU_MOSSDEEP_CITY_GAME_CORNER_1F", "MossdeepCity_GameCorner_1F", "MAP_MOSSDEEP_CITY_GAME_CORNER_1F" }, + { 14, 12, "RU_MOSSDEEP_CITY_GAME_CORNER_B1F", "MossdeepCity_GameCorner_B1F", "MAP_MOSSDEEP_CITY_GAME_CORNER_B1F" }, + { 15, 0, "RU_SOOTOPOLIS_CITY_GYM_1F", "SootopolisCity_Gym_1F", "MAP_SOOTOPOLIS_CITY_GYM_1F" }, + { 15, 1, "RU_SOOTOPOLIS_CITY_GYM_B1F", "SootopolisCity_Gym_B1F", "MAP_SOOTOPOLIS_CITY_GYM_B1F" }, + { 15, 2, "RU_SOOTOPOLIS_CITY_POKEMON_CENTER_1F", "SootopolisCity_PokemonCenter_1F", "MAP_SOOTOPOLIS_CITY_POKEMON_CENTER_1F" }, + { 15, 3, "RU_SOOTOPOLIS_CITY_POKEMON_CENTER_2F", "SootopolisCity_PokemonCenter_2F", "MAP_SOOTOPOLIS_CITY_POKEMON_CENTER_2F" }, + { 15, 4, "RU_SOOTOPOLIS_CITY_MART", "SootopolisCity_Mart", "MAP_SOOTOPOLIS_CITY_MART" }, + { 15, 5, "RU_SOOTOPOLIS_CITY_HOUSE1", "SootopolisCity_House1", "MAP_SOOTOPOLIS_CITY_HOUSE1" }, + { 15, 6, "RU_SOOTOPOLIS_CITY_HOUSE2", "SootopolisCity_House2", "MAP_SOOTOPOLIS_CITY_HOUSE2" }, + { 15, 7, "RU_SOOTOPOLIS_CITY_HOUSE3", "SootopolisCity_House3", "MAP_SOOTOPOLIS_CITY_HOUSE3" }, + { 15, 8, "RU_SOOTOPOLIS_CITY_HOUSE4", "SootopolisCity_House4", "MAP_SOOTOPOLIS_CITY_HOUSE4" }, + { 15, 9, "RU_SOOTOPOLIS_CITY_HOUSE5", "SootopolisCity_House5", "MAP_SOOTOPOLIS_CITY_HOUSE5" }, + { 15, 10, "RU_SOOTOPOLIS_CITY_HOUSE6", "SootopolisCity_House6", "MAP_SOOTOPOLIS_CITY_HOUSE6" }, + { 15, 11, "RU_SOOTOPOLIS_CITY_HOUSE7", "SootopolisCity_House7", "MAP_SOOTOPOLIS_CITY_HOUSE7" }, + { 15, 12, "RU_SOOTOPOLIS_CITY_HOUSE8", "SootopolisCity_House8", "MAP_SOOTOPOLIS_CITY_HOUSE8" }, + { 16, 0, "RU_EVER_GRANDE_CITY_SIDNEYS_ROOM", "EverGrandeCity_SidneysRoom", "MAP_EVER_GRANDE_CITY_SIDNEYS_ROOM" }, + { 16, 1, "RU_EVER_GRANDE_CITY_PHOEBES_ROOM", "EverGrandeCity_PhoebesRoom", "MAP_EVER_GRANDE_CITY_PHOEBES_ROOM" }, + { 16, 2, "RU_EVER_GRANDE_CITY_GLACIAS_ROOM", "EverGrandeCity_GlaciasRoom", "MAP_EVER_GRANDE_CITY_GLACIAS_ROOM" }, + { 16, 3, "RU_EVER_GRANDE_CITY_DRAKES_ROOM", "EverGrandeCity_DrakesRoom", "MAP_EVER_GRANDE_CITY_DRAKES_ROOM" }, + { 16, 4, "RU_EVER_GRANDE_CITY_CHAMPIONS_ROOM", "EverGrandeCity_ChampionsRoom", "MAP_EVER_GRANDE_CITY_CHAMPIONS_ROOM" }, + { 16, 5, "RU_EVER_GRANDE_CITY_CORRIDOR1", "EverGrandeCity_Corridor1", "MAP_EVER_GRANDE_CITY_CORRIDOR1" }, + { 16, 6, "RU_EVER_GRANDE_CITY_CORRIDOR2", "EverGrandeCity_Corridor2", "MAP_EVER_GRANDE_CITY_CORRIDOR2" }, + { 16, 7, "RU_EVER_GRANDE_CITY_CORRIDOR3", "EverGrandeCity_Corridor3", "MAP_EVER_GRANDE_CITY_CORRIDOR3" }, + { 16, 8, "RU_EVER_GRANDE_CITY_CORRIDOR4", "EverGrandeCity_Corridor4", "MAP_EVER_GRANDE_CITY_CORRIDOR4" }, + { 16, 9, "RU_EVER_GRANDE_CITY_CORRIDOR5", "EverGrandeCity_Corridor5", "MAP_EVER_GRANDE_CITY_CORRIDOR5" }, + { 16, 10, "RU_EVER_GRANDE_CITY_POKEMON_LEAGUE", "EverGrandeCity_PokemonLeague", "MAP_EVER_GRANDE_CITY_POKEMON_LEAGUE" }, + { 16, 11, "RU_EVER_GRANDE_CITY_HALL_OF_FAME", "EverGrandeCity_HallOfFame", "MAP_EVER_GRANDE_CITY_HALL_OF_FAME" }, + { 16, 12, "RU_EVER_GRANDE_CITY_POKEMON_CENTER_1F", "EverGrandeCity_PokemonCenter_1F", "MAP_EVER_GRANDE_CITY_POKEMON_CENTER_1F" }, + { 16, 13, "RU_EVER_GRANDE_CITY_POKEMON_CENTER_2F", "EverGrandeCity_PokemonCenter_2F", "MAP_EVER_GRANDE_CITY_POKEMON_CENTER_2F" }, + { 17, 0, "RU_ROUTE104_MR_BRINEYS_HOUSE", "Route104_MrBrineysHouse", "MAP_ROUTE104_MR_BRINEYS_HOUSE" }, + { 17, 1, "RU_ROUTE104_PRETTY_PETAL_FLOWER_SHOP", "Route104_PrettyPetalFlowerShop", "MAP_ROUTE104_PRETTY_PETAL_FLOWER_SHOP" }, + { 18, 0, "RU_ROUTE111_WINSTRATE_FAMILYS_HOUSE", "Route111_WinstrateFamilysHouse", "MAP_ROUTE111_WINSTRATE_FAMILYS_HOUSE" }, + { 18, 1, "RU_ROUTE111_OLD_LADYS_REST_STOP", "Route111_OldLadysRestStop", "MAP_ROUTE111_OLD_LADYS_REST_STOP" }, + { 19, 0, "RU_ROUTE112_CABLE_CAR_STATION", "Route112_CableCarStation", "MAP_ROUTE112_CABLE_CAR_STATION" }, + { 19, 1, "RU_MT_CHIMNEY_CABLE_CAR_STATION", "MtChimney_CableCarStation", "MAP_MT_CHIMNEY_CABLE_CAR_STATION" }, + { 20, 0, "RU_ROUTE114_FOSSIL_MANIACS_HOUSE", "Route114_FossilManiacsHouse", "MAP_ROUTE114_FOSSIL_MANIACS_HOUSE" }, + { 20, 1, "RU_ROUTE114_FOSSIL_MANIACS_TUNNEL", "Route114_FossilManiacsTunnel", "MAP_ROUTE114_FOSSIL_MANIACS_TUNNEL" }, + { 20, 2, "RU_ROUTE114_LANETTES_HOUSE", "Route114_LanettesHouse", "MAP_ROUTE114_LANETTES_HOUSE" }, + { 21, 0, "RU_ROUTE116_TUNNELERS_REST_HOUSE", "Route116_TunnelersRestHouse", "MAP_ROUTE116_TUNNELERS_REST_HOUSE" }, + { 22, 0, "RU_ROUTE117_POKEMON_DAY_CARE", "Route117_PokemonDayCare", "MAP_ROUTE117_POKEMON_DAY_CARE" }, + { 23, 0, "RU_ROUTE121_SAFARI_ZONE_ENTRANCE", "Route121_SafariZoneEntrance", "MAP_ROUTE121_SAFARI_ZONE_ENTRANCE" }, + { 24, 0, "RU_METEOR_FALLS_1F_1R", "MeteorFalls_1F_1R", "MAP_METEOR_FALLS_1F_1R" }, + { 24, 1, "RU_METEOR_FALLS_1F_2R", "MeteorFalls_1F_2R", "MAP_METEOR_FALLS_1F_2R" }, + { 24, 2, "RU_METEOR_FALLS_B1F_1R", "MeteorFalls_B1F_1R", "MAP_METEOR_FALLS_B1F_1R" }, + { 24, 3, "RU_METEOR_FALLS_B1F_2R", "MeteorFalls_B1F_2R", "MAP_METEOR_FALLS_B1F_2R" }, + { 24, 4, "RU_RUSTURF_TUNNEL", "RusturfTunnel", "MAP_RUSTURF_TUNNEL" }, + { 24, 5, "RU_UNDERWATER_SOOTOPOLIS_CITY", "Underwater_SootopolisCity", "MAP_UNDERWATER_SOOTOPOLIS_CITY" }, + { 24, 6, "RU_DESERT_RUINS", "DesertRuins", "MAP_DESERT_RUINS" }, + { 24, 7, "RU_GRANITE_CAVE_1F", "GraniteCave_1F", "MAP_GRANITE_CAVE_1F" }, + { 24, 8, "RU_GRANITE_CAVE_B1F", "GraniteCave_B1F", "MAP_GRANITE_CAVE_B1F" }, + { 24, 9, "RU_GRANITE_CAVE_B2F", "GraniteCave_B2F", "MAP_GRANITE_CAVE_B2F" }, + { 24, 10, "RU_GRANITE_CAVE_STEVENS_ROOM", "GraniteCave_StevensRoom", "MAP_GRANITE_CAVE_STEVENS_ROOM" }, + { 24, 11, "RU_PETALBURG_WOODS", "PetalburgWoods", "MAP_PETALBURG_WOODS" }, + { 24, 12, "RU_MT_CHIMNEY", "MtChimney", "MAP_MT_CHIMNEY" }, + { 24, 13, "RU_JAGGED_PASS", "JaggedPass", "MAP_JAGGED_PASS" }, + { 24, 14, "RU_FIERY_PATH", "FieryPath", "MAP_FIERY_PATH" }, + { 24, 15, "RU_MT_PYRE_1F", "MtPyre_1F", "MAP_MT_PYRE_1F" }, + { 24, 16, "RU_MT_PYRE_2F", "MtPyre_2F", "MAP_MT_PYRE_2F" }, + { 24, 17, "RU_MT_PYRE_3F", "MtPyre_3F", "MAP_MT_PYRE_3F" }, + { 24, 18, "RU_MT_PYRE_4F", "MtPyre_4F", "MAP_MT_PYRE_4F" }, + { 24, 19, "RU_MT_PYRE_5F", "MtPyre_5F", "MAP_MT_PYRE_5F" }, + { 24, 20, "RU_MT_PYRE_6F", "MtPyre_6F", "MAP_MT_PYRE_6F" }, + { 24, 21, "RU_MT_PYRE_EXTERIOR", "MtPyre_Exterior", "MAP_MT_PYRE_EXTERIOR" }, + { 24, 22, "RU_MT_PYRE_SUMMIT", "MtPyre_Summit", "MAP_MT_PYRE_SUMMIT" }, + { 24, 23, "RU_AQUA_HIDEOUT_1F", "AquaHideout_1F", "MAP_AQUA_HIDEOUT_1F" }, + { 24, 24, "RU_AQUA_HIDEOUT_B1F", "AquaHideout_B1F", "MAP_AQUA_HIDEOUT_B1F" }, + { 24, 25, "RU_AQUA_HIDEOUT_B2F", "AquaHideout_B2F", "MAP_AQUA_HIDEOUT_B2F" }, + { 24, 26, "RU_UNDERWATER_SEAFLOOR_CAVERN", "Underwater_SeafloorCavern", "MAP_UNDERWATER_SEAFLOOR_CAVERN" }, + { 24, 27, "RU_SEAFLOOR_CAVERN_ENTRANCE", "SeafloorCavern_Entrance", "MAP_SEAFLOOR_CAVERN_ENTRANCE" }, + { 24, 28, "RU_SEAFLOOR_CAVERN_ROOM1", "SeafloorCavern_Room1", "MAP_SEAFLOOR_CAVERN_ROOM1" }, + { 24, 29, "RU_SEAFLOOR_CAVERN_ROOM2", "SeafloorCavern_Room2", "MAP_SEAFLOOR_CAVERN_ROOM2" }, + { 24, 30, "RU_SEAFLOOR_CAVERN_ROOM3", "SeafloorCavern_Room3", "MAP_SEAFLOOR_CAVERN_ROOM3" }, + { 24, 31, "RU_SEAFLOOR_CAVERN_ROOM4", "SeafloorCavern_Room4", "MAP_SEAFLOOR_CAVERN_ROOM4" }, + { 24, 32, "RU_SEAFLOOR_CAVERN_ROOM5", "SeafloorCavern_Room5", "MAP_SEAFLOOR_CAVERN_ROOM5" }, + { 24, 33, "RU_SEAFLOOR_CAVERN_ROOM6", "SeafloorCavern_Room6", "MAP_SEAFLOOR_CAVERN_ROOM6" }, + { 24, 34, "RU_SEAFLOOR_CAVERN_ROOM7", "SeafloorCavern_Room7", "MAP_SEAFLOOR_CAVERN_ROOM7" }, + { 24, 35, "RU_SEAFLOOR_CAVERN_ROOM8", "SeafloorCavern_Room8", "MAP_SEAFLOOR_CAVERN_ROOM8" }, + { 24, 36, "RU_SEAFLOOR_CAVERN_ROOM9", "SeafloorCavern_Room9", "MAP_SEAFLOOR_CAVERN_ROOM9" }, + { 24, 37, "RU_CAVE_OF_ORIGIN_ENTRANCE", "CaveOfOrigin_Entrance", "MAP_CAVE_OF_ORIGIN_ENTRANCE" }, + { 24, 38, "RU_CAVE_OF_ORIGIN_1F", "CaveOfOrigin_1F", "MAP_CAVE_OF_ORIGIN_1F" }, + { 24, 39, "RU_CAVE_OF_ORIGIN_B1F", "CaveOfOrigin_B1F", "MAP_CAVE_OF_ORIGIN_B1F" }, + { 24, 40, "RU_CAVE_OF_ORIGIN_B2F", "CaveOfOrigin_B2F", "MAP_CAVE_OF_ORIGIN_B2F" }, + { 24, 41, "RU_CAVE_OF_ORIGIN_B3F", "CaveOfOrigin_B3F", "MAP_CAVE_OF_ORIGIN_B3F" }, + { 24, 42, "RU_CAVE_OF_ORIGIN_B4F", "CaveOfOrigin_B4F", "MAP_CAVE_OF_ORIGIN_B4F" }, + { 24, 43, "RU_VICTORY_ROAD_1F", "VictoryRoad_1F", "MAP_VICTORY_ROAD_1F" }, + { 24, 44, "RU_VICTORY_ROAD_B1F", "VictoryRoad_B1F", "MAP_VICTORY_ROAD_B1F" }, + { 24, 45, "RU_VICTORY_ROAD_B2F", "VictoryRoad_B2F", "MAP_VICTORY_ROAD_B2F" }, + { 24, 46, "RU_SHOAL_CAVE_LOW_TIDE_ENTRANCE_ROOM", "ShoalCave_LowTideEntranceRoom", "MAP_SHOAL_CAVE_LOW_TIDE_ENTRANCE_ROOM" }, + { 24, 47, "RU_SHOAL_CAVE_LOW_TIDE_INNER_ROOM", "ShoalCave_LowTideInnerRoom", "MAP_SHOAL_CAVE_LOW_TIDE_INNER_ROOM" }, + { 24, 48, "RU_SHOAL_CAVE_LOW_TIDE_STAIRS_ROOM", "ShoalCave_LowTideStairsRoom", "MAP_SHOAL_CAVE_LOW_TIDE_STAIRS_ROOM" }, + { 24, 49, "RU_SHOAL_CAVE_LOW_TIDE_LOWER_ROOM", "ShoalCave_LowTideLowerRoom", "MAP_SHOAL_CAVE_LOW_TIDE_LOWER_ROOM" }, + { 24, 50, "RU_SHOAL_CAVE_HIGH_TIDE_ENTRANCE_ROOM", "ShoalCave_HighTideEntranceRoom", "MAP_SHOAL_CAVE_HIGH_TIDE_ENTRANCE_ROOM" }, + { 24, 51, "RU_SHOAL_CAVE_HIGH_TIDE_INNER_ROOM", "ShoalCave_HighTideInnerRoom", "MAP_SHOAL_CAVE_HIGH_TIDE_INNER_ROOM" }, + { 24, 52, "RU_NEW_MAUVILLE_ENTRANCE", "NewMauville_Entrance", "MAP_NEW_MAUVILLE_ENTRANCE" }, + { 24, 53, "RU_NEW_MAUVILLE_INSIDE", "NewMauville_Inside", "MAP_NEW_MAUVILLE_INSIDE" }, + { 24, 54, "RU_ABANDONED_SHIP_DECK", "AbandonedShip_Deck", "MAP_ABANDONED_SHIP_DECK" }, + { 24, 55, "RU_ABANDONED_SHIP_CORRIDORS_1F", "AbandonedShip_Corridors_1F", "MAP_ABANDONED_SHIP_CORRIDORS_1F" }, + { 24, 56, "RU_ABANDONED_SHIP_ROOMS_1F", "AbandonedShip_Rooms_1F", "MAP_ABANDONED_SHIP_ROOMS_1F" }, + { 24, 57, "RU_ABANDONED_SHIP_CORRIDORS_B1F", "AbandonedShip_Corridors_B1F", "MAP_ABANDONED_SHIP_CORRIDORS_B1F" }, + { 24, 58, "RU_ABANDONED_SHIP_ROOMS_B1F", "AbandonedShip_Rooms_B1F", "MAP_ABANDONED_SHIP_ROOMS_B1F" }, + { 24, 59, "RU_ABANDONED_SHIP_ROOMS2_B1F", "AbandonedShip_Rooms2_B1F", "MAP_ABANDONED_SHIP_ROOMS2_B1F" }, + { 24, 60, "RU_ABANDONED_SHIP_UNDERWATER1", "AbandonedShip_Underwater1", "MAP_ABANDONED_SHIP_UNDERWATER1" }, + { 24, 61, "RU_ABANDONED_SHIP_ROOM_B1F", "AbandonedShip_Room_B1F", "MAP_ABANDONED_SHIP_ROOM_B1F" }, + { 24, 62, "RU_ABANDONED_SHIP_ROOMS2_1F", "AbandonedShip_Rooms2_1F", "MAP_ABANDONED_SHIP_ROOMS2_1F" }, + { 24, 63, "RU_ABANDONED_SHIP_CAPTAINS_OFFICE", "AbandonedShip_CaptainsOffice", "MAP_ABANDONED_SHIP_CAPTAINS_OFFICE" }, + { 24, 64, "RU_ABANDONED_SHIP_UNDERWATER2", "AbandonedShip_Underwater2", "MAP_ABANDONED_SHIP_UNDERWATER2" }, + { 24, 65, "RU_ABANDONED_SHIP_HIDDEN_FLOOR_CORRIDORS", "AbandonedShip_HiddenFloorCorridors", "MAP_ABANDONED_SHIP_HIDDEN_FLOOR_CORRIDORS" }, + { 24, 66, "RU_ABANDONED_SHIP_HIDDEN_FLOOR_ROOMS", "AbandonedShip_HiddenFloorRooms", "MAP_ABANDONED_SHIP_HIDDEN_FLOOR_ROOMS" }, + { 24, 67, "RU_ISLAND_CAVE", "IslandCave", "MAP_ISLAND_CAVE" }, + { 24, 68, "RU_ANCIENT_TOMB", "AncientTomb", "MAP_ANCIENT_TOMB" }, + { 24, 69, "RU_UNDERWATER_ROUTE134", "Underwater_Route134", "MAP_UNDERWATER_ROUTE134" }, + { 24, 70, "RU_UNDERWATER_SEALED_CHAMBER", "Underwater_SealedChamber", "MAP_UNDERWATER_SEALED_CHAMBER" }, + { 24, 71, "RU_SEALED_CHAMBER_OUTER_ROOM", "SealedChamber_OuterRoom", "MAP_SEALED_CHAMBER_OUTER_ROOM" }, + { 24, 72, "RU_SEALED_CHAMBER_INNER_ROOM", "SealedChamber_InnerRoom", "MAP_SEALED_CHAMBER_INNER_ROOM" }, + { 24, 73, "RU_SCORCHED_SLAB", "ScorchedSlab", "MAP_SCORCHED_SLAB" }, + { 24, 74, "RU_MAGMA_HIDEOUT_1F", "MagmaHideout_1F", "MAP_MAGMA_HIDEOUT_1F" }, + { 24, 75, "RU_MAGMA_HIDEOUT_B1F", "MagmaHideout_B1F", "MAP_MAGMA_HIDEOUT_B1F" }, + { 24, 76, "RU_MAGMA_HIDEOUT_B2F", "MagmaHideout_B2F", "MAP_MAGMA_HIDEOUT_B2F" }, + { 24, 77, "RU_SKY_PILLAR_ENTRANCE", "SkyPillar_Entrance", "MAP_SKY_PILLAR_ENTRANCE" }, + { 24, 78, "RU_SKY_PILLAR_OUTSIDE", "SkyPillar_Outside", "MAP_SKY_PILLAR_OUTSIDE" }, + { 24, 79, "RU_SKY_PILLAR_1F", "SkyPillar_1F", "MAP_SKY_PILLAR_1F" }, + { 24, 80, "RU_SKY_PILLAR_2F", "SkyPillar_2F", "MAP_SKY_PILLAR_2F" }, + { 24, 81, "RU_SKY_PILLAR_3F", "SkyPillar_3F", "MAP_SKY_PILLAR_3F" }, + { 24, 82, "RU_SKY_PILLAR_4F", "SkyPillar_4F", "MAP_SKY_PILLAR_4F" }, + { 24, 83, "RU_SHOAL_CAVE_LOW_TIDE_ICE_ROOM", "ShoalCave_LowTideIceRoom", "MAP_SHOAL_CAVE_LOW_TIDE_ICE_ROOM" }, + { 24, 84, "RU_SKY_PILLAR_5F", "SkyPillar_5F", "MAP_SKY_PILLAR_5F" }, + { 24, 85, "RU_SKY_PILLAR_TOP", "SkyPillar_Top", "MAP_SKY_PILLAR_TOP" }, + { 25, 0, "RU_SECRET_BASE_RED_CAVE1", "SecretBase_RedCave1", "MAP_SECRET_BASE_RED_CAVE1" }, + { 25, 1, "RU_SECRET_BASE_BROWN_CAVE1", "SecretBase_BrownCave1", "MAP_SECRET_BASE_BROWN_CAVE1" }, + { 25, 2, "RU_SECRET_BASE_BLUE_CAVE1", "SecretBase_BlueCave1", "MAP_SECRET_BASE_BLUE_CAVE1" }, + { 25, 3, "RU_SECRET_BASE_YELLOW_CAVE1", "SecretBase_YellowCave1", "MAP_SECRET_BASE_YELLOW_CAVE1" }, + { 25, 4, "RU_SECRET_BASE_TREE1", "SecretBase_Tree1", "MAP_SECRET_BASE_TREE1" }, + { 25, 5, "RU_SECRET_BASE_SHRUB1", "SecretBase_Shrub1", "MAP_SECRET_BASE_SHRUB1" }, + { 25, 6, "RU_SECRET_BASE_RED_CAVE2", "SecretBase_RedCave2", "MAP_SECRET_BASE_RED_CAVE2" }, + { 25, 7, "RU_SECRET_BASE_BROWN_CAVE2", "SecretBase_BrownCave2", "MAP_SECRET_BASE_BROWN_CAVE2" }, + { 25, 8, "RU_SECRET_BASE_BLUE_CAVE2", "SecretBase_BlueCave2", "MAP_SECRET_BASE_BLUE_CAVE2" }, + { 25, 9, "RU_SECRET_BASE_YELLOW_CAVE2", "SecretBase_YellowCave2", "MAP_SECRET_BASE_YELLOW_CAVE2" }, + { 25, 10, "RU_SECRET_BASE_TREE2", "SecretBase_Tree2", "MAP_SECRET_BASE_TREE2" }, + { 25, 11, "RU_SECRET_BASE_SHRUB2", "SecretBase_Shrub2", "MAP_SECRET_BASE_SHRUB2" }, + { 25, 12, "RU_SECRET_BASE_RED_CAVE3", "SecretBase_RedCave3", "MAP_SECRET_BASE_RED_CAVE3" }, + { 25, 13, "RU_SECRET_BASE_BROWN_CAVE3", "SecretBase_BrownCave3", "MAP_SECRET_BASE_BROWN_CAVE3" }, + { 25, 14, "RU_SECRET_BASE_BLUE_CAVE3", "SecretBase_BlueCave3", "MAP_SECRET_BASE_BLUE_CAVE3" }, + { 25, 15, "RU_SECRET_BASE_YELLOW_CAVE3", "SecretBase_YellowCave3", "MAP_SECRET_BASE_YELLOW_CAVE3" }, + { 25, 16, "RU_SECRET_BASE_TREE3", "SecretBase_Tree3", "MAP_SECRET_BASE_TREE3" }, + { 25, 17, "RU_SECRET_BASE_SHRUB3", "SecretBase_Shrub3", "MAP_SECRET_BASE_SHRUB3" }, + { 25, 18, "RU_SECRET_BASE_RED_CAVE4", "SecretBase_RedCave4", "MAP_SECRET_BASE_RED_CAVE4" }, + { 25, 19, "RU_SECRET_BASE_BROWN_CAVE4", "SecretBase_BrownCave4", "MAP_SECRET_BASE_BROWN_CAVE4" }, + { 25, 20, "RU_SECRET_BASE_BLUE_CAVE4", "SecretBase_BlueCave4", "MAP_SECRET_BASE_BLUE_CAVE4" }, + { 25, 21, "RU_SECRET_BASE_YELLOW_CAVE4", "SecretBase_YellowCave4", "MAP_SECRET_BASE_YELLOW_CAVE4" }, + { 25, 22, "RU_SECRET_BASE_TREE4", "SecretBase_Tree4", "MAP_SECRET_BASE_TREE4" }, + { 25, 23, "RU_SECRET_BASE_SHRUB4", "SecretBase_Shrub4", "MAP_SECRET_BASE_SHRUB4" }, + { 25, 24, "RU_SINGLE_BATTLE_COLOSSEUM", "SingleBattleColosseum", "MAP_SINGLE_BATTLE_COLOSSEUM" }, + { 25, 25, "RU_TRADE_CENTER", "TradeCenter", "MAP_TRADE_CENTER" }, + { 25, 26, "RU_RECORD_CORNER", "RecordCorner", "MAP_RECORD_CORNER" }, + { 25, 27, "RU_DOUBLE_BATTLE_COLOSSEUM", "DoubleBattleColosseum", "MAP_DOUBLE_BATTLE_COLOSSEUM" }, + { 25, 28, "RU_LINK_CONTEST_ROOM1", "LinkContestRoom1", "MAP_LINK_CONTEST_ROOM1" }, + { 25, 29, "RU_UNKNOWN_MAP_25_29", "UnknownMap_25_29", "MAP_UNKNOWN_MAP_25_29" }, + { 25, 30, "RU_UNKNOWN_MAP_25_30", "UnknownMap_25_30", "MAP_UNKNOWN_MAP_25_30" }, + { 25, 31, "RU_UNKNOWN_MAP_25_31", "UnknownMap_25_31", "MAP_UNKNOWN_MAP_25_31" }, + { 25, 32, "RU_UNKNOWN_MAP_25_32", "UnknownMap_25_32", "MAP_UNKNOWN_MAP_25_32" }, + { 25, 33, "RU_UNKNOWN_MAP_25_33", "UnknownMap_25_33", "MAP_UNKNOWN_MAP_25_33" }, + { 25, 34, "RU_UNKNOWN_MAP_25_34", "UnknownMap_25_34", "MAP_UNKNOWN_MAP_25_34" }, + { 25, 35, "RU_LINK_CONTEST_ROOM2", "LinkContestRoom2", "MAP_LINK_CONTEST_ROOM2" }, + { 25, 36, "RU_LINK_CONTEST_ROOM3", "LinkContestRoom3", "MAP_LINK_CONTEST_ROOM3" }, + { 25, 37, "RU_LINK_CONTEST_ROOM4", "LinkContestRoom4", "MAP_LINK_CONTEST_ROOM4" }, + { 25, 38, "RU_LINK_CONTEST_ROOM5", "LinkContestRoom5", "MAP_LINK_CONTEST_ROOM5" }, + { 25, 39, "RU_LINK_CONTEST_ROOM6", "LinkContestRoom6", "MAP_LINK_CONTEST_ROOM6" }, + { 25, 40, "RU_INSIDE_OF_TRUCK", "InsideOfTruck", "MAP_INSIDE_OF_TRUCK" }, + { 25, 41, "RU_SS_TIDAL_CORRIDOR", "SSTidalCorridor", "MAP_SS_TIDAL_CORRIDOR" }, + { 25, 42, "RU_SS_TIDAL_LOWER_DECK", "SSTidalLowerDeck", "MAP_SS_TIDAL_LOWER_DECK" }, + { 25, 43, "RU_SS_TIDAL_ROOMS", "SSTidalRooms", "MAP_SS_TIDAL_ROOMS" }, + { 26, 0, "RU_SAFARI_ZONE_NORTHWEST", "SafariZone_Northwest", "MAP_SAFARI_ZONE_NORTHWEST" }, + { 26, 1, "RU_SAFARI_ZONE_NORTHEAST", "SafariZone_Northeast", "MAP_SAFARI_ZONE_NORTHEAST" }, + { 26, 2, "RU_SAFARI_ZONE_SOUTHWEST", "SafariZone_Southwest", "MAP_SAFARI_ZONE_SOUTHWEST" }, + { 26, 3, "RU_SAFARI_ZONE_SOUTHEAST", "SafariZone_Southeast", "MAP_SAFARI_ZONE_SOUTHEAST" }, + { 26, 4, "RU_BATTLE_TOWER_OUTSIDE", "BattleTower_Outside", "MAP_BATTLE_TOWER_OUTSIDE" }, + { 26, 5, "RU_BATTLE_TOWER_LOBBY", "BattleTower_Lobby", "MAP_BATTLE_TOWER_LOBBY" }, + { 26, 6, "RU_BATTLE_TOWER_ELEVATOR", "BattleTower_Elevator", "MAP_BATTLE_TOWER_ELEVATOR" }, + { 26, 7, "RU_BATTLE_TOWER_CORRIDOR", "BattleTower_Corridor", "MAP_BATTLE_TOWER_CORRIDOR" }, + { 26, 8, "RU_BATTLE_TOWER_BATTLE_ROOM", "BattleTower_BattleRoom", "MAP_BATTLE_TOWER_BATTLE_ROOM" }, + { 26, 9, "RU_SOUTHERN_ISLAND_EXTERIOR", "SouthernIsland_Exterior", "MAP_SOUTHERN_ISLAND_EXTERIOR" }, + { 26, 10, "RU_SOUTHERN_ISLAND_INTERIOR", "SouthernIsland_Interior", "MAP_SOUTHERN_ISLAND_INTERIOR" }, + { 26, 11, "RU_SAFARI_ZONE_REST_HOUSE", "SafariZone_RestHouse", "MAP_SAFARI_ZONE_REST_HOUSE" }, + { 27, 0, "RU_ROUTE104_PROTOTYPE", "Route104_Prototype", "MAP_ROUTE104_PROTOTYPE" }, + { 27, 1, "RU_ROUTE104_PROTOTYPE_PRETTY_PETAL_FLOWER_SHOP", "Route104_PrototypePrettyPetalFlowerShop", "MAP_ROUTE104_PROTOTYPE_PRETTY_PETAL_FLOWER_SHOP" }, + { 28, 0, "RU_ROUTE109_SEASHORE_HOUSE", "Route109_SeashoreHouse", "MAP_ROUTE109_SEASHORE_HOUSE" }, + { 29, 0, "RU_ROUTE110_TRICK_HOUSE_ENTRANCE", "Route110_TrickHouseEntrance", "MAP_ROUTE110_TRICK_HOUSE_ENTRANCE" }, + { 29, 1, "RU_ROUTE110_TRICK_HOUSE_END", "Route110_TrickHouseEnd", "MAP_ROUTE110_TRICK_HOUSE_END" }, + { 29, 2, "RU_ROUTE110_TRICK_HOUSE_CORRIDOR", "Route110_TrickHouseCorridor", "MAP_ROUTE110_TRICK_HOUSE_CORRIDOR" }, + { 29, 3, "RU_ROUTE110_TRICK_HOUSE_PUZZLE1", "Route110_TrickHousePuzzle1", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE1" }, + { 29, 4, "RU_ROUTE110_TRICK_HOUSE_PUZZLE2", "Route110_TrickHousePuzzle2", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE2" }, + { 29, 5, "RU_ROUTE110_TRICK_HOUSE_PUZZLE3", "Route110_TrickHousePuzzle3", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE3" }, + { 29, 6, "RU_ROUTE110_TRICK_HOUSE_PUZZLE4", "Route110_TrickHousePuzzle4", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE4" }, + { 29, 7, "RU_ROUTE110_TRICK_HOUSE_PUZZLE5", "Route110_TrickHousePuzzle5", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE5" }, + { 29, 8, "RU_ROUTE110_TRICK_HOUSE_PUZZLE6", "Route110_TrickHousePuzzle6", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE6" }, + { 29, 9, "RU_ROUTE110_TRICK_HOUSE_PUZZLE7", "Route110_TrickHousePuzzle7", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE7" }, + { 29, 10, "RU_ROUTE110_TRICK_HOUSE_PUZZLE8", "Route110_TrickHousePuzzle8", "MAP_ROUTE110_TRICK_HOUSE_PUZZLE8" }, + { 29, 11, "RU_ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE", "Route110_SeasideCyclingRoadSouthEntrance", "MAP_ROUTE110_SEASIDE_CYCLING_ROAD_SOUTH_ENTRANCE" }, + { 29, 12, "RU_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE", "Route110_SeasideCyclingRoadNorthEntrance", "MAP_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE" }, + { 30, 0, "RU_ROUTE113_GLASS_WORKSHOP", "Route113_GlassWorkshop", "MAP_ROUTE113_GLASS_WORKSHOP" }, + { 31, 0, "RU_ROUTE123_BERRY_MASTERS_HOUSE", "Route123_BerryMastersHouse", "MAP_ROUTE123_BERRY_MASTERS_HOUSE" }, + { 32, 0, "RU_ROUTE119_WEATHER_INSTITUTE_1F", "Route119_WeatherInstitute_1F", "MAP_ROUTE119_WEATHER_INSTITUTE_1F" }, + { 32, 1, "RU_ROUTE119_WEATHER_INSTITUTE_2F", "Route119_WeatherInstitute_2F", "MAP_ROUTE119_WEATHER_INSTITUTE_2F" }, + { 32, 2, "RU_ROUTE119_HOUSE", "Route119_House", "MAP_ROUTE119_HOUSE" }, + { 33, 0, "RU_ROUTE124_DIVING_TREASURE_HUNTERS_HOUSE", "Route124_DivingTreasureHuntersHouse", "MAP_ROUTE124_DIVING_TREASURE_HUNTERS_HOUSE" }, +} +D.profiles = { + firered = { rows = "firered", enginePrefix = "FR_" }, + leafgreen = { rows = "firered", enginePrefix = "FR_" }, + emerald = { rows = "emerald", enginePrefix = "EM_" }, + ruby = { rows = "ruby", enginePrefix = "RU_" }, + sapphire = { rows = "ruby", prefix = "SA_" }, +} +return D diff --git a/src/import/gba/object_avatar_tables.lua b/src/import/gba/object_avatar_tables.lua new file mode 100644 index 000000000..24dcc762c --- /dev/null +++ b/src/import/gba/object_avatar_tables.lua @@ -0,0 +1,36 @@ +-- Cartridge offsets for the original avatar interface, independently +-- verified against each supported USA Rev 0 input. No runtime simulation. +return { + emerald={ + link_rs=4814090, + genders=2, + stateNames={"NORMAL","MACH_BIKE","ACRO_BIKE","SURFING","UNDERWATER","FIELD_MOVE","FISHING","WATERING"}, + state_flags=4814092, + state_flag_count=5, + player=4814072, + player_states=8, + rival=4814056, + rival_states=8, + link_frlg=4814088, + }, + ruby={ + genders=2, + player=3210324, + player_states=8, + rival=3210308, + rival_states=8, + stateNames={"NORMAL","MACH_BIKE","ACRO_BIKE","SURFING","UNDERWATER","FIELD_MOVE","FISHING","WATERING"}, + state_flags=3210340, + state_flag_count=5, + }, + sapphire={ + genders=2, + player=3210212, + player_states=8, + rival=3210196, + rival_states=8, + stateNames={"NORMAL","MACH_BIKE","ACRO_BIKE","SURFING","UNDERWATER","FIELD_MOVE","FISHING","WATERING"}, + state_flags=3210228, + state_flag_count=5, + }, +} diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua new file mode 100644 index 000000000..2cc3dbf6e --- /dev/null +++ b/src/import/gba/versions.lua @@ -0,0 +1,6 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared retail portrait indices already used by the native FR/LG importer. +local V={TRAINER_PIC_RED=135,TRAINER_PIC_LEAF=136} +return setmetatable(V,{__index=function(_,key) + if key=='BUILD'then return require('src.core.GameVersion').get()end +end}) diff --git a/src/import/lttp/LttpImport.lua b/src/import/lttp/LttpImport.lua new file mode 100644 index 000000000..57a709c1c --- /dev/null +++ b/src/import/lttp/LttpImport.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native SNES graphics extraction. Only explicitly recognized cartridge +-- revisions are accepted; no copyrighted cartridge bytes are shipped. +local G=require("src.import.snes.Graphics") +local Lz=require("src.import.snes.Lz2") +local MD5=require("src.mods.StreamMD5") +local Producer=require("src.import.PackProducer") +local Importers=require("src.import.Importers") +local Writer=require("src.import.LuaWriter") +local T={IMPORTER="lttp",EXPORT_VERSION="1.1.0",SHEET_COUNT=223,SPRITE_FIRST=115, + RAW_FIRST=115,RAW_LAST=126,RAW_SIZE=1536,SHEET_TILES_WIDE=16, + COLORS_3BPP=7,COLORS_4BPP=15} +T.SOURCES={["608c22b8ff930c62dc2de54bcd6eba72"]={name="The Legend of Zelda: A Link to the Past (USA)",size=1048576}} +T.POINTERS={bank=0x4f80,high=0x505f,low=0x513e} +T.PLAYER={offset=0x80000,bytes=0x7000,bits=4,tiles=896} +T.PALETTE_SETS={ + {id="sprites_light",offset=0xdd218,rows=4,perRow=15}, + {id="sprites_dark",offset=0xdd290,rows=4,perRow=15}, + {id="armor",offset=0xdd308,rows=5,perRow=15}, + {id="sprites_aux3",offset=0xdd39e,rows=12,perRow=7}, + {id="bg_dungeon",offset=0xdd734,rows=16,perRow=15}, + {id="bg_overworld_aux3",offset=0xde604,rows=12,perRow=7}, +} +T.DEFAULT_PALETTE={sprites={set="sprites_light",row=1},tiles={set="bg_dungeon",row=1}} +function T.identify(bytes) + if type(bytes)~="string" or (#bytes~=1048576 and #bytes~=1049088) then return nil,"expected a supported 1 MiB SNES cartridge, with optional 512-byte copier header" end + if #bytes==1049088 then bytes=bytes:sub(513) end + local hash=MD5.new():update(bytes):final() + local known=T.SOURCES[hash] + if not known or known.size~=#bytes then return nil,"unsupported SNES cartridge revision ("..hash..")" end + return {name=known.name,md5=hash,size=#bytes},bytes +end +function T.sheetOffset(rom,index) + if type(index)~="number" or index<0 or index>=T.SHEET_COUNT or index%1~=0 then return nil end + local p=T.POINTERS + return G.loRomOffset(rom:byte(p.bank+index+1),rom:byte(p.high+index+1),rom:byte(p.low+index+1)) +end +function T.isRaw(index)return index>=T.RAW_FIRST and index<=T.RAW_LAST end +function T.readSheet(rom,index) + local offset=T.sheetOffset(rom,index) + if not offset or offset>=#rom then return nil,"graphics pointer is outside the cartridge" end + local bytes,err + if T.isRaw(index) then + bytes=rom:sub(offset+1,offset+T.RAW_SIZE) + if #bytes~=T.RAW_SIZE then return nil,"raw graphics block is truncated" end + else bytes,err=Lz.decompress(rom,offset) end + if not bytes then return nil,err end + return G.decodeSheet(bytes) +end +function T.readPlayer(rom) + local p=T.PLAYER + local bytes=rom:sub(p.offset+1,p.offset+p.bytes) + if #bytes~=p.bytes then return nil,"player graphics are truncated" end + return G.decodeTiles(bytes,p.bits) +end +function T.readPalettes(rom) + local all={} + for _,spec in ipairs(T.PALETTE_SETS) do + local colors,err=G.readPalette(rom,spec.offset,spec.rows*spec.perRow) + if not colors then return nil,err end + local rows={} + for row=1,spec.rows do + rows[row]={};for color=1,spec.perRow do rows[row][color]=colors[(row-1)*spec.perRow+color] end + end + all[spec.id]=rows + end + return all +end +local function png(raster) + assert(love and love.image,"native image encoding is unavailable") + local image=love.image.newImageData(raster.width,raster.height,"rgba8",raster.rgba) + local encoded + local ok,result=pcall(function()encoded=image:encode("png");return encoded:getString()end) + if encoded then encoded:release() end + image:release() + if not ok then error(result,0) end + return result +end +local function alreadyInstalled(source,fs) + local expected={tiles=116,sprites=109,palettes=6} + for pack,count in pairs(expected) do + local m=Importers.check({importer=T.IMPORTER,pack=pack,version="="..T.EXPORT_VERSION},fs) + if not m or m.source.md5:lower()~=source.md5 then return false end + local n=0;for _ in pairs(m.entries) do n=n+1 end + if n~=count then return false end + if pack=="tiles" then + if not m.entries.kakariko then return false end + for index=0,T.SPRITE_FIRST-1 do if not m.entries[("sheet_%03d"):format(index)] then return false end end + elseif pack=="sprites" then + if not m.entries.player then return false end + for index=T.SPRITE_FIRST,T.SHEET_COUNT-1 do if not m.entries[("sheet_%03d"):format(index)] then return false end end + else for _,spec in ipairs(T.PALETTE_SETS) do if not m.entries[spec.id] then return false end end end + end + return true +end +function T.job(rom,fs,options) + options=options or {} + return coroutine.create(function() + local source,normalized=T.identify(rom);assert(source,normalized);rom=normalized + fs=Importers.fs(fs) + if not options.force and alreadyInstalled(source,fs) then + return {source=source,packs={tiles=116,sprites=109,palettes=6},skipped={},reused=true} + end + local palettes=assert(T.readPalettes(rom)) + local writers={} + for _,pack in ipairs({"tiles","sprites","palettes"}) do writers[pack]=assert(Producer.new(T.IMPORTER,pack,T.EXPORT_VERSION,source,fs)) end + for index=0,T.SHEET_COUNT-1 do + local tiles,bits=T.readSheet(rom,index);assert(tiles,bits) + local pack=index=3 and slot<=6 then + local override=byte(rom,0x5d97+auxiliary*4+slot-3) + if override~=0 then index=override end + end + out[slot+1]=index + end + return out +end +function O.palettes(rom,area) + local group=byte(rom,0x7d1c+area) + local selectors={byte(rom,0x75504+group*4),byte(rom,0x75505+group*4),byte(rom,0x75506+group*4)} + local result={} + local function install(row,column,address) + local colors=assert(G.readPalette(rom,address,7)) + result[row]=result[row] or {} + for i=1,7 do result[row][column+i-1]=colors[i] end + end + local main=area<64 and 0 or 1 + local mainOffset=word(rom,0xdec3b+main*2) + for row=2,6 do install(row,1,0xde6c8+mainOffset+(row-2)*14) end + local first=word(rom,0xdec13+selectors[1]*2) + local second=word(rom,0xdec13+selectors[2]*2) + for row=2,4 do install(row,9,0xde86c+first+(row-2)*14) end + for row=5,7 do install(row,9,0xde86c+second+(row-5)*14) end + install(7,1,0xde604+byte(rom,0xdebc6+selectors[3])) + return result +end +function O.raster(rom,readSheet,progress) + local area=0x18 + local sheets={} + for slot,index in ipairs(O.sheets(rom,area)) do sheets[slot]=assert(readSheet(rom,index)) end + local palettes=O.palettes(rom,area) + local rgb={G.color(0x2669)} + local background=string.char(math.floor(rgb[1]+0.5),math.floor(rgb[2]+0.5),math.floor(rgb[3]+0.5),255) + local packed={} + for row,colors in pairs(palettes) do + packed[row]={} + for index,c in pairs(colors) do packed[row][index]=string.char(c[1],c[2],c[3],255) end + end + local high={[0]=true,[3]=true,[4]=true,[5]=true} + local pixels={} + for definition=0,4095 do + local tx,ty=definition%64*16,math.floor(definition/64)*16 + for quadrant=0,3 do + local descriptor=word(rom,0x78000+definition*8+quadrant*2) + local tileIndex=descriptor%1024 + local slot=math.floor(tileIndex/64) + local tile=sheets[slot+1] and sheets[slot+1][tileIndex%64+1] + local palette=packed[math.floor(descriptor/1024)%8] + local flipX,flipY=math.floor(descriptor/16384)%2==1,descriptor>=32768 + for y=0,7 do + for x=0,7 do + local sx,sy=flipX and 7-x or x,flipY and 7-y or y + local index=tile and tile[sy*8+sx+1] or 0 + local color=index~=0 and palette and palette[index+(high[slot] and 8 or 0)] or nil + local dx,dy=tx+quadrant%2*8+x,ty+math.floor(quadrant/2)*8+y + pixels[dy*1024+dx+1]=color or background + end + end + end + if progress and definition%128==127 then progress(definition+1,4096) end + end + return {width=1024,height=1024,rgba=table.concat(pixels)} +end +function O.image(rom,readSheet) + local result=O.raster(rom,readSheet) + return love.image.newImageData(result.width,result.height,"rgba8",result.rgba) +end +return O diff --git a/src/import/lttp/SnesGfx.lua b/src/import/lttp/SnesGfx.lua new file mode 100644 index 000000000..b7d73b52e --- /dev/null +++ b/src/import/lttp/SnesGfx.lua @@ -0,0 +1,2 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +return require("src.import.snes.Graphics") diff --git a/src/import/pmd/Sprite.lua b/src/import/pmd/Sprite.lua new file mode 100644 index 000000000..4a10bd255 --- /dev/null +++ b/src/import/pmd/Sprite.lua @@ -0,0 +1,86 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Consume a published sprite pack through the native renderer. No ROM reader. +local Sprite={} +local directions={down=1,right=3,up=5,left=7} +local function positive(value,label) + assert(type(value)=='number'and value%1==0 and value>0 and value<=65536,label) + return value +end +function Sprite.definition(entry) + local meta=assert(entry.sprite,'sprite pack metadata is required') + local count=positive(entry.frames,'sprite pack frame count is required') + local def={image='asset_packs/pmd_red/sprites/'..assert(entry.file), + frames=count,walker=true,trueColor=true,cells={}} + for _,axis in ipairs({'Width','Height','Columns'})do + local value=positive(meta['frame'..axis],'invalid sprite pack frame '..axis) + def['frame'..axis],def['cell'..axis]=value,value + end + def.anchorX,def.anchorY=meta.anchorX,meta.anchorY + for n=0,count-1 do def.cells[n+1]={{tile=n,dx=0,dy=0}}end + return def +end +function Sprite.frame(meta,animation,direction,ticks) + local animations=assert(meta.animations,'sprite animations are required') + local group=assert(animations[(animation or 0)+1]or animations[1],'sprite animation is empty') + local frames=assert(meta.sequences[group[directions[direction]or 1]],'sprite direction is missing') + local duration=0 + for _,frame in ipairs(frames)do duration=duration+positive(frame[2],'invalid sprite frame duration')end + if duration==0 then return {1,1,0,0,0,0,0}end + local cursor=(tonumber(ticks)or 0)%duration + for _,frame in ipairs(frames)do + cursor=cursor-frame[2] + if cursor<0 then return frame end + end +end +local function active(self,facing) + return Sprite.frame(self._packAnimation,0,facing,self.pmdTicks) +end +local function step(self,moving) + self.pmdTicks=moving and self.pmdTicks+1 or 0 +end +local function draw(self,px,py,cx,cy,facing,phase,flip,top) + local frame=active(self,facing) + local native=require('src.render.SpriteRenderer') + self.offsetX,self.offsetY=self.anchorX-8,self.anchorY-16 + -- Fixed-frame rendering keeps the native full-color/depth behavior. Pack + -- anchor offsets replace the native cell-centered defaults on this instance. + return native.drawFixedFrame(self,px+(frame[3]or 0),py+(frame[4]or 0),cx,cy,frame[1]-1,false) +end +local function geometry(self,index) + local def=self.def + return {image=self.image,quad=self.frames[index], + x=index%def.frameColumns*def.frameWidth, + y=math.floor(index/def.frameColumns)*def.frameHeight, + width=def.frameWidth,height=def.frameHeight, + anchorX=self.anchorX,anchorY=self.anchorY} +end +local function pose(self,facing) + local frame=active(self,facing) + local out=geometry(self,frame[1]-1) + out.anchorX=out.anchorX-(frame[3]or 0) + out.anchorY=out.anchorY-(frame[4]or 0) + return out,false +end +function Sprite.new(entry,metadata) + local Native=require('src.render.SpriteRenderer') + local def=Sprite.definition(entry) + -- Avoid creating vertical-strip quads beyond a row-major pack sheet. + local count=def.frames;def.frames=1 + local renderer=Native.new(def);def.frames=count + local w,h=renderer.image:getDimensions() + assert(def.frameColumns*def.frameWidth<=w and math.ceil(count/def.frameColumns)*def.frameHeight<=h, + 'sprite pack frames exceed its image') + for n=0,count-1 do + renderer.frames[n]=love.graphics.newQuad(n%def.frameColumns*def.frameWidth, + math.floor(n/def.frameColumns)*def.frameHeight,def.frameWidth,def.frameHeight,w,h) + end + renderer.anchorX,renderer.anchorY=tonumber(def.anchorX)or 0,tonumber(def.anchorY)or 0 + renderer.offsetX,renderer.offsetY=renderer.anchorX-8,renderer.anchorY-16 + renderer.cellYBias=0 + renderer._packAnimation=metadata or entry.sprite + renderer.pmdTicks=0 + renderer.step,renderer.draw=step,draw + renderer.getFrameGeometry,renderer.getPoseGeometry=geometry,pose + return renderer +end +return Sprite diff --git a/src/import/snes/Graphics.lua b/src/import/snes/Graphics.lua new file mode 100644 index 000000000..82a192439 --- /dev/null +++ b/src/import/snes/Graphics.lua @@ -0,0 +1,84 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local G={TILE_PIXELS=8} +local floor=math.floor +function G.loRomOffset(bank,high,low) + if type(bank)~="number" or type(high)~="number" or type(low)~="number" then return nil end + if bank<0 or bank>255 or high<128 or high>255 or low<0 or low>255 + or bank%1~=0 or high%1~=0 or low%1~=0 then return nil end + return bank%128*32768+(high*256+low)%32768 +end +function G.color(word) + return word%32*255/31,floor(word/32)%32*255/31,floor(word/1024)%32*255/31 +end +function G.readPalette(bytes,offset,count) + if type(bytes)~="string" or type(offset)~="number" or offset<0 or offset%1~=0 + or type(count)~="number" or count<0 or count%1~=0 or offset+count*2>#bytes then return nil,"palette is outside the cartridge" end + local colors={} + for i=0,count-1 do + local a,b=bytes:byte(offset+i*2+1,offset+i*2+2) + local r,g,blue=G.color(a+b*256) + colors[i+1]={floor(r+0.5),floor(g+0.5),floor(blue+0.5)} + end + return colors +end +local function tile(bytes,index,bits) + local stride=bits*8 + if type(bytes)~="string" or type(index)~="number" or index<0 or index%1~=0 + or (index+1)*stride>#bytes then return nil,"tile is outside the graphics block" end + local pixels={} + for y=0,7 do + for x=0,7 do + local color=0 + for plane=0,bits-1 do + local position + if bits==3 and plane==2 then position=16+y + else position=floor(plane/2)*16+y*2+plane%2 end + local byte=bytes:byte(index*stride+position+1) + color=color+floor(byte/2^(7-x))%2*2^plane + end + pixels[y*8+x+1]=color + end + end + return pixels +end +function G.decodeTile2bpp(bytes,index)return tile(bytes,index,2)end +function G.decodeTile3bpp(bytes,index)return tile(bytes,index,3)end +function G.decodeTile4bpp(bytes,index)return tile(bytes,index,4)end +function G.decodeTiles(bytes,bits) + if bits~=2 and bits~=3 and bits~=4 then return nil,"unsupported bit-plane format" end + if type(bytes)~="string" or #bytes==0 or #bytes%(bits*8)~=0 then return nil,"incomplete graphics tiles" end + local out={} + for i=0,#bytes/(bits*8)-1 do out[i+1]=assert(tile(bytes,i,bits)) end + return out +end +function G.sheetFormat(size) + if type(size)~="number" or size<=0 or size>65536 then return nil end + if size%24==0 then return 3,size/24 end + if size%16==0 then return 2,size/16 end +end +function G.decodeSheet(bytes) + local bits=type(bytes)=="string" and G.sheetFormat(#bytes) + if not bits then return nil,"unsupported graphics sheet length" end + local pixels,err=G.decodeTiles(bytes,bits) + if not pixels then return nil,err end + return pixels,bits +end +function G.sheet(tiles,palette,columns) + columns=columns or 16 + local width,height=columns*8,math.ceil(#tiles/columns)*8 + assert(width>0 and height>0 and width*height<=4*1024*1024,"graphics sheet exceeds image bounds") + local colors={[0]=string.char(0,0,0,0)} + for i,rgb in ipairs(palette) do colors[i]=string.char(rgb[1],rgb[2],rgb[3],255) end + local rows={} + for y=0,height-1 do + local pixels={} + for x=0,width-1 do + local source=tiles[floor(y/8)*columns+floor(x/8)+1] + local index=source and source[y%8*8+x%8+1] or 0 + pixels[x+1]=assert(colors[index],"graphics palette is incomplete") + end + rows[y+1]=table.concat(pixels) + end + return {width=width,height=height,rgba=table.concat(rows)} +end +return G diff --git a/src/import/snes/Lz2.lua b/src/import/snes/Lz2.lua new file mode 100644 index 000000000..f78eccca1 --- /dev/null +++ b/src/import/snes/Lz2.lua @@ -0,0 +1,51 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- SNES graphics command stream: literal, byte/word fill, ascending fill and +-- absolute output copies. Graphics copy offsets are little endian. +local Lz={MAX_OUTPUT=65536} +local unpack=table.unpack or unpack +function Lz.decompress(bytes,offset,limit) + if type(bytes)~="string" or type(offset)~="number" or offset<0 or offset%1~=0 then return nil,"invalid compressed graphics source" end + limit=limit or Lz.MAX_OUTPUT + if type(limit)~="number" or limit<0 or limit>Lz.MAX_OUTPUT or limit%1~=0 then return nil,"invalid graphics expansion limit" end + local p=offset+1 + local output={} + local function nextByte() + local value=bytes:byte(p);p=p+1 + if not value then error("truncated compressed graphics",0) end + return value + end + local ok,body=pcall(function() + while true do + local head=nextByte() + if head==255 then break end + local command,length + if head>=224 then + command=math.floor(head/4)%8 + length=(head%4)*256+nextByte()+1 + else command=math.floor(head/32);length=head%32+1 end + if #output+length>limit then error("graphics expansion exceeds its limit",0) end + if command==0 then + for _=1,length do output[#output+1]=nextByte() end + elseif command==1 then + local byte=nextByte();for _=1,length do output[#output+1]=byte end + elseif command==2 then + local a,b=nextByte(),nextByte() + for i=1,length do output[#output+1]=i%2==1 and a or b end + elseif command==3 then + local first=nextByte();for i=0,length-1 do output[#output+1]=(first+i)%256 end + else + local low=nextByte();local start=low+256*nextByte() + if start>=#output then error("graphics copy points outside prior output",0) end + for i=1,length do output[#output+1]=assert(output[start+i],"invalid graphics copy") end + end + end + local chunks={} + for first=1,#output,4096 do + chunks[#chunks+1]=string.char(unpack(output,first,math.min(first+4095,#output))) + end + return table.concat(chunks) + end) + if not ok then return nil,tostring(body) end + return body,p-offset-1 +end +return Lz diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index b09bf37e8..903a2ae84 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -184,7 +184,7 @@ function Bag.add(save, id, qty, data) -- if that key-item slot cannot be made. Without this, Brock could hand -- over TM39 while the UI truthfully had no TM CASE to open afterwards. if pocket == "TM_HM" - and require("src.core.GameVersion").get() == "firered" + and require("src.core.GameVersion").isFRLG() and not save.inventory.TM_CASE then if not Bag.add(save, "TM_CASE", 1, data) then return false end end @@ -212,6 +212,18 @@ function Bag.add(save, id, qty, data) return true end +-- Read-only admission uses the same pocket/container rules on a detached +-- inventory. A query can never grant a TM CASE or reorder the live bag. +function Bag.canAdd(save,id,qty,data) + if not(save and type(save.inventory)=='table')then return false end + qty=qty or 1 + if type(qty)~='number'or qty~=qty or qty%1~=0 or qty<=0 or qty>999 then return false end + local shadow={inventory={},bagOrder={},gen3PyramidBag=save.gen3PyramidBag and true or nil} + for key,value in pairs(save.inventory)do shadow.inventory[key]=value end + for i,value in ipairs(save.bagOrder or{})do shadow.bagOrder[i]=value end + return Bag.add(shadow,id,qty,data) +end + -- Saves made before the FRLG AddBagItem parity fix could contain TMs/HMs but -- no TM CASE. Retail never permits that state: acquiring the first machine -- inserts ITEM_TM_CASE at the same time. Repair such old port saves on load @@ -222,7 +234,7 @@ end -- refusing the repair would strand those machines forever. Future Bag.add -- calls still enforce the cartridge capacity, so the repair cannot compound. function Bag.repairFireRedTMCase(save, data) - if require("src.core.GameVersion").get() ~= "firered" then return false end + if not require("src.core.GameVersion").isFRLG() then return false end if type(save) ~= "table" or type(save.inventory) ~= "table" then return false end if save.inventory.TM_CASE then return false end data = data or require("src.core.Data") diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index c9119139b..310bca8d7 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -1550,6 +1550,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 save.repelSteps = steps + if package.loaded['src.mods.FeatureOptions']then require('src.ui.RepelPrompt').remember(save,itemId)end return "consumed", { Strings("%s used\n%s!", save.player.name, name) } end diff --git a/src/mods/Api3.lua b/src/mods/Api3.lua new file mode 100644 index 000000000..f5de4a039 --- /dev/null +++ b/src/mods/Api3.lua @@ -0,0 +1,199 @@ +-- 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 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]]):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) + 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 function dispatch(name, ...) + if not supportedGeneration or not SUPPORTED[name] then + return nil, "unsupported: API 3 world method " .. name + end + local game = current() + if not game then + return nil, "no game" + end + if name == "setFlag" or name == "getFlag" then + if not game.save then + return nil, "no save" + end + local key, value = ... + if type(key) ~= "string" or key:sub(1, 4) ~= "mod:" then + return nil, "unsupported: API 3 native flag mapping" + end + if name == "setFlag" and type(value) ~= "boolean" then + return nil, "flag value must be boolean" + end + local id = key:sub(5) + if #id == 0 or #id > 256 or id:find("%c") then + return nil, "invalid mod flag" + end + local store = game.save.modData + if store ~= nil and type(store) ~= "table" then + return nil, "invalid mod save store" + end + local bucket = store and store[api.id] + 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 name == "getFlag" then + return flags ~= nil and flags[id] == true + end + local before = flags ~= nil and flags[id] == true + game.save.modData = store or {} + bucket = bucket or {} + game.save.modData[api.id] = bucket + flags = flags or {} + bucket.worldFlags = flags + flags[id] = value or nil + if before ~= value then + Runtime.emit("flag.changed", { name = "mod." .. api.id .. "." .. id, value = value }) + end + return true + 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, + } + -- Version datasets have no native Gen2Recomped adapter yet. Keeping the + -- handle callable allows the same mod to fall back without engine imports. + api.datasets = { + open = function() + return nil, "unsupported: version datasets" + 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/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..ee1db1ca5 --- /dev/null +++ b/src/mods/Api3WildBattle.lua @@ -0,0 +1,203 @@ +-- 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 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 = not ownedNative and require("src.mods.UnifiedIds").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 require("src.mods.Api3WorldEdits").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(require("src.mods.Api3WorldEdits").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/AssetPacks.lua b/src/mods/AssetPacks.lua new file mode 100644 index 000000000..c65bc2e0a --- /dev/null +++ b/src/mods/AssetPacks.lua @@ -0,0 +1,98 @@ +local Importers=require("src.import.Importers") +local Data=require("src.import.PackData") +local Semver=require("src.mods.Semver") +local Packs={MAX_READ_BYTES=Importers.MAX_ENTRY_BYTES} + +function Packs.parse(required,optional) + local seen={} + local function list(value,field,isRequired) + if value==nil then return {} end + assert(type(value)=="table",field.." must be an array") + local count=0 + for key in pairs(value) do + count=count+1 + assert(type(key)=="number" and key%1==0 and key>=1 and key<=#value,field.." must be a dense array") + end + assert(count==#value,field.." must be a dense array") + local out={} + for _,entry in ipairs(value) do + assert(type(entry)=="table" and Importers.validId(entry.importer) and Importers.validId(entry.pack),field.." has an invalid pack identity") + local key=entry.importer.."/"..entry.pack + assert(not seen[key],"duplicate asset requirement: "..key); seen[key]=true + if entry.version~=nil then + assert(type(entry.version)=="string" and entry.version~="" and Semver.validRange(entry.version),field.." has an invalid version range") + end + out[#out+1]={importer=entry.importer,pack=entry.pack,version=entry.version,required=isRequired} + end + return out + end + local a=list(required,"required_assets",true) + return a,list(optional,"optional_assets",false) +end +function Packs.specs(manifest) + local out={} + for _,list in ipairs({manifest.required_assets or {},manifest.optional_assets or {}}) do + for _,spec in ipairs(list) do out[#out+1]=Data.copy(spec) end + end + return out +end +function Packs.missing(manifest,fs) + local out={} + for _,spec in ipairs(manifest.required_assets or {}) do + local pack,err=Importers.check(spec,fs) + if not pack then out[#out+1]={importer=spec.importer,pack=spec.pack,version=spec.version,reason=err} end + end + return #out>0 and out or nil +end +function Packs.new(manifest,fs) + fs=Importers.fs(fs) + local declared={} + for _,spec in ipairs(Packs.specs(manifest)) do declared[spec.importer.."/"..spec.pack]=spec end + local function resolve(id,pack) + local spec=declared[tostring(id).."/"..tostring(pack)] + if not spec then return nil,"undeclared asset pack" end + return Importers.resolve(spec,fs) + end + local function entry(id,pack,key) + local m,err=resolve(id,pack); if not m then return nil,err end + local e=m.entries[key]; if not e then return nil,"no such pack entry: "..tostring(key) end + return e + end + local function view(key,e) + local out=Data.copy(e); out.id=key; return out + end + local api={} + function api:list() + local out={} + for _,spec in ipairs(Packs.specs(manifest)) do + local m=Importers.check(spec,fs) + out[#out+1]={importer=spec.importer,pack=spec.pack,required=spec.required~=false, + installed=m~=nil,version=m and m.version,kind=m and m.kind} + end + return out + end + function api:info(id,pack) + local m,err=resolve(id,pack); if not m then return nil,err end + local n=0; for _ in pairs(m.entries) do n=n+1 end + return {importer=id,pack=pack,kind=m.kind,version=m.version,entries=n,source=m.source.name} + end + function api:entries(id,pack) + local m,err=resolve(id,pack); if not m then return nil,err end + local out={}; for key,e in pairs(m.entries) do out[#out+1]=view(key,e) end + table.sort(out,function(a,b)return a.id= 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..ff4d638ce --- /dev/null +++ b/src/mods/Capabilities.lua @@ -0,0 +1,112 @@ +-- 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 + +add("hooks", "123", "battle.caught_marker_visible world.follower.spawn") +add("hooks", "12345", "battle.menu_auxiliary") + +-- 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 core.logic_speed core.update + encounter.fishing encounter.roll encounter.species evolution.check exp.gain + fieldmove.eligibility input.key input.gamepad input.wheel input.step link.fingerprint map.palette movement.collision + movement.speed music.select music.volume player.sprite pokemon.icon pokemon.sprite + render.compose render.hud render.letterbox render.zones save.new_game save.write screen.render_visible + script.command trainer.party transition.style ui.list_menu ui.options.rows + ui.title_menu.items warp.destination world.tod zoom.range +]]) +-- Screens have distinct implementations: Screens.lua aliases the DS party, +-- naming and start menus; those implementations do not call the GB/GBA hooks. +-- OverworldController:openPC bypasses ui.pc.items on Gen 3/4 (and Gen 5 uses +-- its own script PC). Gen4Battle paints its HUD through a separate seam and +-- does not dispatch battle.overlay, unlike BattleState/Gen3Battle/WideBattle. +add("hooks", "123", "battle.overlay ui.naming.grid ui.party.submenu ui.start_menu.items") +add("hooks", "12", "ui.pc.items") +add("hooks", "45", "battle.status_hud_visible") +add("hooks", "3", "field.weather.sound") +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/Events.lua b/src/mods/Events.lua index 88d27e79b..071173eea 100644 --- a/src/mods/Events.lua +++ b/src/mods/Events.lua @@ -58,7 +58,13 @@ function Events:emit(name, payload) -- lumping them together names the seam instead of the culprit. local close = FrameProfile.section( " mod " .. tostring(entry.owner or "?") .. ": " .. tostring(name)) - local ok, err = pcall(entry.callback, payload) + local facade = package.loaded['src.core.Game3'] + local ok, err + if facade and facade._usesCallbacks and facade._usesCallbacks(entry.owner) then + ok, err = pcall(facade._invokeEvent, entry.owner, name, entry.callback, payload) + else + ok, err = pcall(entry.callback, payload) + end close() if not ok then Logger.error("[%s] event %s: %s", diff --git a/src/mods/FeatureOptions.lua b/src/mods/FeatureOptions.lua new file mode 100644 index 000000000..fe15b8082 --- /dev/null +++ b/src/mods/FeatureOptions.lua @@ -0,0 +1,48 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional native conveniences follow their contributing mod's live options. +local Host=require('src.mods.Runtime') +local M={} +local providers={} +local function release(p) + p.config=nil + for i=#providers,1,-1 do if providers[i]==p then table.remove(providers,i)end end +end +function M.attach(config) + assert(type(config)=='table'and type(config.active)=='function'and type(config.option)=='function','feature options need active/option callbacks') + local facade=require('src.core.Game3');local who=Host.currentMod or facade._callbackOwner() + assert(who,'feature options need a mod owner') + local c={};for _,key in ipairs{'active','option','time','outdoors','language'}do + assert(config[key]==nil or type(config[key])=='function','invalid feature callback');c[key]=config[key] + end + local p={who=who,config=c,hooks=Host.hooks,events=Host.events,errors={}} + providers[#providers+1]=p + return function()release(p)end +end +local function call(p,key,...) + local fn=p.config and p.config[key];if not fn then return nil end + local result={pcall(require('src.core.Game3')._withCallbackOwner,p.who,fn,...)} + if not result[1]then + if not p.errors[key]then Host.reportError(p.who,'feature '..key..': '..tostring(result[2]));p.errors[key]=true end + return nil + end + return result[2] +end +function M.provider(key) + for i=#providers,1,-1 do + local p=providers[i] + if p.hooks~=Host.hooks or p.events~=Host.events then release(p) + else + local game=require('src.compat.gen1.NativeSession').game() + local mod=game and game.mods and game.mods.mods and game.mods.mods[p.who] + if(not game or mod and mod.enabled~=false and not mod.failed)and call(p,'active')==true and call(p,'option',key)==true then return p end + end + end +end +function M.enabled(key)return M.provider(key)~=nil end +M.call=call +function M.releaseOwner(_,who) + for i=#providers,1,-1 do if providers[i].who==who then release(providers[i])end end +end +function M.release()for i=#providers,1,-1 do release(providers[i])end end +require('src.core.game3.asset_stream').register(M) +return M diff --git a/src/mods/Hooks.lua b/src/mods/Hooks.lua index e56b04a83..8f86ca7d4 100644 --- a/src/mods/Hooks.lua +++ b/src/mods/Hooks.lua @@ -100,7 +100,13 @@ function Hooks:call(name, vanilla, ...) return unpack(downstream, 1, downstream.n) end local started = measuring and FrameProfile.now() or nil - local res = pack(pcall(entry.callback, nextFn, unpack(args, 1, args.n))) + local facade = package.loaded['src.core.Game3'] + local res + if facade and facade._usesCallbacks and facade._usesCallbacks(entry.owner) then + res = pack(pcall(facade._invokeHook, entry.owner, entry.callback, nextFn, unpack(args, 1, args.n))) + else + res = pack(pcall(entry.callback, nextFn, unpack(args, 1, args.n))) + end if started then FrameProfile.add(" hook " .. tostring(name) .. " <- " .. siteOf(entry), diff --git a/src/mods/InputControl.lua b/src/mods/InputControl.lua new file mode 100644 index 000000000..03eea1a79 --- /dev/null +++ b/src/mods/InputControl.lua @@ -0,0 +1,140 @@ +-- Per-loader, per-mod ownership for logical GB input. No Game/Input import: +-- handles may be constructed at the title screen or in a headless loader. +local Control = {} +Control.__index = Control +local sessions = setmetatable({}, { __mode = "k" }) +-- Keep sessions with outstanding holds alive until release/reset/teardown. +local holding = {} +local serial = 0 +local BUTTONS = { + up = true, + down = true, + left = true, + right = true, + a = true, + b = true, + start = true, + select = true, +} + +local function idle(session) + for _, bucket in pairs(session.owners) do + if next(bucket.tokens) then + return + end + end + holding[session] = nil +end + +function Control.new() + serial = serial + 1 + local self = setmetatable({ serial = serial, owners = {} }, Control) + sessions[self] = true + return self +end + +function Control:release(owner) + if owner == nil then + for id in pairs(self.owners) do + self:release(id) + end + return + end + local bucket = self.owners[owner] + if not bucket then + return + end + for token, record in pairs(bucket.tokens) do + bucket.tokens[token] = nil + record.input:sourceRelease(record.button, record.source) + end + idle(self) +end + +function Control:retire(owner) + local bucket = self.owners[owner] + if not bucket then + return + end + self:release(owner) + bucket.retired = true + self.owners[owner] = nil +end + +function Control:close() + for owner in pairs(self.owners) do + self:retire(owner) + end + self.closed = true + sessions[self] = nil + holding[self] = nil +end + +function Control:api(owner) + assert(not self.closed, "mod.input is no longer active") + self:retire(owner) + local bucket = { tokens = {}, seq = 0 } + self.owners[owner] = bucket + local function sourceFor(game, button) + assert(not self.closed and not bucket.retired, "mod.input is no longer active") + local input = type(game) == "table" and game.input + assert( + type(input) == "table" + and type(input.sourcePress) == "function" + and type(input.sourceRelease) == "function", + "mod.input needs the live game (see game.ready)" + ) + assert(BUTTONS[button], "unknown GB button: " .. tostring(button)) + bucket.seq = bucket.seq + 1 + -- A retired loader's sources cannot alias those of its replacement. + return input, ("mod:%s:%d:%d"):format(owner, self.serial, bucket.seq) + end + return { + tap = function(_, game, button) + local input, source = sourceFor(game, button) + input:sourcePress(button, source) + input:sourceRelease(button, source) + end, + press = function(_, game, button) + local input, source = sourceFor(game, button) + input:sourcePress(button, source) + local token = {} + bucket.tokens[token] = { input = input, button = button, source = source } + holding[self] = true + return token + end, + release = function(_, token) + local record = bucket.tokens[token] + if not record then + return false + end + bucket.tokens[token] = nil + idle(self) + record.input:sourceRelease(record.button, record.source) + return true + end, + } +end + +-- Input:reset already discards every physical and synthetic source. Retire +-- only tokens bound to that Input, without touching another game's holds. +function Control.forget(input) + for session in pairs(sessions) do + for _, bucket in pairs(session.owners) do + for token, record in pairs(bucket.tokens) do + if record.input == input then + bucket.tokens[token] = nil + end + end + end + idle(session) + end +end + +function Control.closeAll() + for session in pairs(sessions) do + session:close() + end +end + +return Control diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index cadcbd1c7..ccd03256c 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -125,11 +125,19 @@ function LauncherMods.deriveList(manifests, options) -- so a second Stadium 2 mod is simply ready. ModImports.adoptShared(m) local needs = ModImports.missing(m) + local missingAssets = require("src.mods.AssetPacks").missing(m) + if missingAssets then + local first=missingAssets[1] + status,detail="warn", "Needs asset pack "..first.importer.."/"..first.pack + end out[#out + 1] = { id = m.id, name = m.name or m.id, requiredImports = ModImports.of(m), missingImports = needs, + required_assets = m.required_assets, + optional_assets = m.optional_assets, + missingAssets = missingAssets, -- A DIFFERENT KIND OF MISSING. `missingImports` is a FILE the player has -- to hand over; this is a CARTRIDGE THEY HAVE TO HAVE IMPORTED, which -- needs nothing handed over and cannot be satisfied by dropping a file diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..ab168d52d 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -11,18 +11,29 @@ 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 Net = require("src.mods.Net") 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 InputControl = require("src.mods.InputControl") +local BattleSnapshots = require("src.mods.BattleSnapshots") +local UIActions = require("src.mods.UIActions") local ModGens = require("src.mods.ModGens") local ModSeams = require("src.mods.ModSeams") +-- Install typed-view iteration before mod mutation journals open. +require("src.compat.gen1.TableIteration") +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 @@ -32,6 +43,29 @@ require("src.core.ModCompat").install() local Loader = {} Loader.__index = Loader +-- Compatibility battle services also retire on older native hosts whose +-- stream service is available but not yet driven by loader retirement. +local function retireBattlePresentation() + local first + for _,name in ipairs({'src.core.game3.battle','src.core.game3.battle.anim','src.core.game3.battle.anim_coords','src.core.game3.pokemon','src.core.game3.mon_anim'})do + local module=package.loaded[name];local service=type(module)=='table'and module._service + if type(service)=='table'and type(service.release)=='function'then + local ok,why=pcall(service.release,service) + if not ok and not first then first=why end + end + end + if first then error(first,0)end +end + +-- Only an already-loaded field service owns resources. Retire it after +-- wrapper unwind without importing a compatibility module on ordinary boot. +local function retireFieldEffects() + for _,name in ipairs({'src.core.game3.field_effects','src.core.game3.field_move_show_mon'})do + local service=package.loaded[name] + if type(service)=='table' and type(service.release)=='function' then service.release()end + end +end + local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only -- walk a dotted target path without creating anything; the base view a @@ -151,6 +185,7 @@ function Loader.new(opts) mods = {}, loaded = {}, errors = {}, initialized = false, events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, exports = {}, apis = {}, migrations = {}, order = {}, + inputControl = InputControl.new(), modSave = {}, modOptions = {}, optionSchemas = {}, optionStatus = {}, imageCache = {}, fs = (opts and opts.fs) or (love and love.filesystem), @@ -502,7 +537,16 @@ function Loader:_validate() if err then reason = reason .. " (" .. err .. ")" end end end - if reason then self:_fail(mod, "invalid", reason) end + if reason then self:_fail(mod, "invalid", reason) + else + local assets = require("src.mods.AssetPacks").missing(manifest, self.fs) + if assets then + mod.missingAssets = assets + local first = assets[1] + self:_fail(mod, "missing_assets", ("required asset pack not ready: %s/%s (%s)") + :format(first.importer, first.pack, tostring(first.reason))) + end + end end end @@ -803,6 +847,16 @@ function Loader.freshness(fs, root) return (" (newest file %s, %s)"):format(tostring(newestName), when) end +-- Recovery releases holds while keeping the current API usable. Retirement +-- (rollback/reload/teardown) additionally disables the old API's presses. +function Loader:releaseModInput(owner) + self.inputControl:release(owner) +end + +function Loader:_closeModInput() + self.inputControl:close() +end + function Loader:_api(mod) local loader = self local modId = mod.manifest.id @@ -831,7 +885,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 +895,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 +924,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 +941,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, @@ -911,12 +969,14 @@ function Loader:_api(mod) return loader.events:emit(name, payload) end, }, + input = loader.inputControl:api(modId), 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), + fetch = Net.forMod(loader, mod), -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final save = { @@ -999,6 +1059,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 +1083,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 +1186,43 @@ 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 + api.packs = require("src.mods.AssetPacks").new(mod.manifest, loader.fs) + api.postLog = Net.logReporter(loader,mod) -- 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 +1236,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) + 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 +1313,17 @@ 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) + local stream=package.loaded['src.core.game3.asset_stream'] + if stream and stream.cancelOwner then pcall(stream.cancelOwner,modId)end + local move=package.loaded['src.core.game3.field_move_show_mon'] + if move and move.cancelOwner then pcall(move.cancelOwner,modId)end + -- Failed global/module wrappers must unwind before registry cleanup. + ModSeams.unwindOwner(modId) + local facade=package.loaded['src.core.Game3'] + if facade and facade._retireMod then facade._retireMod(modId)end + self.inputControl:retire(modId) + PortableScripts.cancelAll(self, modId, "mod rolled back") + Net.releaseAll(self, modId) for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) end @@ -1206,6 +1334,12 @@ function Loader:_rollback(modId) self.optionStatus[modId] = nil self.migrations[modId] = nil self.modSave[modId] = nil + local stream=package.loaded['src.core.game3.asset_stream'] + if stream and stream.cancelOwner then pcall(stream.cancelOwner,modId)end + local space=package.loaded['src.core.game3.scripting.space'] + if space and space.cancelOwner then pcall(space.cancelOwner,modId)end + local transition=package.loaded['src.core.game3.battle_transition'] + if transition and transition.cancelOwner then pcall(transition.cancelOwner,modId)end end -- a mod that explicitly swears it stays link-compatible while writing into a @@ -1309,12 +1443,81 @@ local function stampAudioOwners(data, name, registry) owners[key] = map end +-- Optional collision observations retire after wrapper unwind, without +-- loading compatibility modules on an ordinary native boot. +local function retireCompatibilityCollision() + local module = package.loaded["src.core.game3.collision"] + if type(module) == "table" and type(module.release) == "function" then module.release() end +end +-- Notify source cleanup while its wrappers still exist, then retire only +-- already-loaded session scopes after unwind. No native boot imports Game3. +local function notifyCompatibilitySession() + local facade=package.loaded['src.core.Game3'] + local game=package.loaded['src.core.Game'] + if facade and facade._notifyReset and game then facade._notifyReset(game)end +end +local function retireCompatibilitySession() + for _,name in ipairs({'src.core.game3.runtime','src.core.Game3'})do + local module=package.loaded[name] + if type(module)=='table'and type(module.release)=='function'then module.release()end + end +end +-- Retire compatibility scopes after wrapper unwind, without loading them +-- on ordinary native boots. Map observations may be supplied by another PR. +local function retireCompatibilityMaps() + for _, name in ipairs({"src.core.game3.map", "src.core.game3.player"}) do + local module=package.loaded[name] + if type(module)=="table" and type(module.release)=="function" then module.release() end + end +end +-- Release only already-loaded compatibility resources, after mod wrappers +-- unwind. These scopes own their buffers and never borrow native sheets. +local function retireCompatibilityAtlases() + for _, name in ipairs({"src.core.game3.warm", "src.core.game3.asset_stream", + "src.core.game3.tileset_native", "src.core.game3.tileset_anim"}) do + local module = package.loaded[name] + if type(module) == "table" and type(module.release) == "function" then module.release() end + end +end +-- Retire owned object views after source wrappers have been unwound. +-- Querying teardown must not import a compatibility world on native boots. +local function retireCompatibilityObjects() + local objects = package.loaded["src.core.game3.objects"] + if type(objects) == "table" and type(objects.release) == "function" then objects.release() end +end +-- Optional sprite resources own no native game state. Do not load a +-- compatibility module or Game just to retire a previous resource cohort. +local function retireSpriteResources() + local sprites=package.loaded['src.core.game3.ow_sprites'] + if sprites and type(sprites.release)=='function' then sprites.release()end + local space=package.loaded['src.core.game3.scripting.space'] + if space and type(space.release)=='function'then space.release()end + local transition=package.loaded['src.core.game3.battle_transition'] + if transition and type(transition.release)=='function'then transition.release()end + local effects=package.loaded['src.core.game3.field_effects'] + if effects and type(effects.release)=='function'then effects.release()end +end + function Loader:load(data) + InputControl.closeAll() + self.inputControl = InputControl.new() + 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 -- wraps and flags the previous load put on engine tables come off first. + notifyCompatibilitySession() ModSeams.unwindAll() + retireCompatibilityCollision() + retireBattlePresentation() + retireFieldEffects() + local stream=package.loaded['src.core.game3.asset_stream'] + if stream and stream.release then stream.release()end + retireCompatibilitySession() + retireCompatibilityMaps() + retireCompatibilityAtlases() + retireCompatibilityObjects() + retireSpriteResources() self.baseData = data -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later @@ -1471,7 +1674,6 @@ function Loader:load(data) Runtime.currentMod = nil if not success then self:_rollback(modId) - ModSeams.unwindOwner(modId) end self.journal = nil if success then @@ -1632,6 +1834,8 @@ function Loader:status() manifest.enabled = mod.enabled ~= false manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled") manifest.error = mod.failure + manifest.waitingAssets = mod.state == "missing_assets" + manifest.missingAssets = mod.missingAssets available[#available + 1] = manifest if manifest.state == "loaded" then loaded[#loaded + 1] = manifest end end @@ -1653,7 +1857,21 @@ 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() + notifyCompatibilitySession() + InputControl.closeAll() + PortableScripts.cancelAll(nil, nil, "mods unloaded") + Net.unloadAll() local undone = select(1, ModSeams.unwindAll()) + retireCompatibilityCollision() + retireBattlePresentation() + retireFieldEffects() + local stream=package.loaded['src.core.game3.asset_stream'] + if stream and stream.release then stream.release()end + retireCompatibilitySession() + retireCompatibilityMaps() + retireCompatibilityAtlases() + retireCompatibilityObjects() + retireSpriteResources() pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) Runtime.currentMod = nil diff --git a/src/mods/LogReport.lua b/src/mods/LogReport.lua new file mode 100644 index 000000000..ec0c7a9a8 --- /dev/null +++ b/src/mods/LogReport.lua @@ -0,0 +1,18 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Json=require('src.link.Json') +local L={MAX_BODY=65536,MAX_WIRE_BODY=512*1024} +function L.prepare(owner,body,opts) + if type(body)~='string' or #body>L.MAX_BODY then return nil,'log body must be a string of at most 65536 bytes' end + if opts~=nil and (type(opts)~='table' or getmetatable(opts)) then return nil,'invalid log options' end + opts=opts or {} + for key in pairs(opts)do if key~='format' then return nil,'unknown log option' end end + local format=opts.format + if format==nil then format='text' end + if format~='text' and format~='json' then return nil,'log format must be text or json' end + if type(owner)~='string' or #owner>256 then return nil,'invalid log owner' end + if format=='text' then return body,'text/plain; charset=utf-8' end + local value=Json.encode({ts=os.time(),mod=owner,format=format,body=body}) + if #value>L.MAX_WIRE_BODY then return nil,'encoded log exceeds byte limit' end + return value,'application/json' +end +return L diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 8509c8fac..cc55c7979 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -209,13 +209,17 @@ function ManagerState:refresh() self.status = (loader and loader.status and loader:status()) or self.game.modStatus or { available = {}, errors = {} } self.byId = {} + self.assetsChanged = false for _, m in ipairs(self.status.available or {}) do self.byId[m.id] = m + if m.waitingAssets and not require("src.mods.AssetPacks").missing(m,loader and loader.fs) then + self.assetsChanged = true + end end if self.currentMod then self.currentMod = self.byId[self.currentMod.id] end - self.restartPending = #self:stagedList() > 0 + self.restartPending = #self:stagedList() > 0 or self.assetsChanged == true -- a live set that drifted off the named profile reverts to ad-hoc local opts = self:optionsTable() if opts.activeProfile then @@ -265,6 +269,7 @@ end function ManagerState:glyphFor(m) if self:isStaged(m) then return GLYPH.staged end if not m.enabled then return GLYPH.disabled end + if m.waitingAssets then return GLYPH.blocked end if m.state == "blocked_dependency" then return GLYPH.blocked end if m.error then return GLYPH.errored end return " " diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..436ac5f02 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -8,7 +8,11 @@ local Version = require("src.core.Version") local Manifest = {} Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true } -Manifest.PERMISSIONS = { network = true, filesystem = true, engine_internals = true } +-- `compute` declares direct LÖVE worker-thread use. It is distinct from the +-- sandboxed mod.job/background service; the native host already exposes the +-- LÖVE thread module and must not reject a mod which declares that use. +Manifest.PERMISSIONS = { network = true, filesystem = true, + engine_internals = true, compute = true } -- link-relevant registries; a mod that writes into one of these while -- declaring affects_link = false gets an attributed warning from the loader @@ -223,6 +227,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 +254,19 @@ 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 logUrl=raw.log_url + if logUrl~=nil then + local bad=type(logUrl)~="string" or #logUrl>8192 or logUrl:find("[%z%c%s]") + or not logUrl:lower():match("^https://[^/?#]+") + if bad or not permissionSet.network then + violation(strict,raw.id,bad and "log_url requires a valid HTTPS URL" or 'log_url requires the "network" permission') + logUrl=nil + end + end + local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version) assert(gameVersionOk, ("malformed game_version %q: %s") :format(tostring(raw.game_version), tostring(gameVersionErr))) @@ -279,7 +306,12 @@ 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) + local requiredAssets, optionalAssets = require("src.mods.AssetPacks").parse(raw.required_assets, raw.optional_assets) -- `support`: where to thank the mod's creator -- a Ko-fi, a Patreon, a -- PayPal page. The launcher's mod card turns it into a "Support the @@ -305,6 +337,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 +349,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,11 +359,15 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + script_format = scriptFormat, + log_url = logUrl, 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 -- the mod out of its own folder (src/mods/ModImports.lua). requiredImports = require("src.mods.ModImports").parse(raw), + required_assets = requiredAssets, + optional_assets = optionalAssets, -- Cartridges that must be IMPORTED for this mod to mean anything -- a -- different thing from requiredImports above, which is a file the player -- hands over and the launcher writes into the mod's folder. diff --git a/src/mods/ModSeams.lua b/src/mods/ModSeams.lua index f4d06a01c..3feee9ab0 100644 --- a/src/mods/ModSeams.lua +++ b/src/mods/ModSeams.lua @@ -44,6 +44,9 @@ -- still the mod's to guard; this takes away the common case, not the need. local ModSeams = {} +-- Mutation journals must remain operable while an entry has temporarily +-- replaced global iteration (including a wrapper that throws). +local pairs,next=pairs,next -- every change, oldest first: { tbl, key, before, after, owner } local journal = {} @@ -105,9 +108,10 @@ end -- open a window. Returns a token for close(). Never throws. function ModSeams.open(owner) local ok, token = pcall(function() - local snap = {} + local snap, seen = {}, {} for _, entry in ipairs(watched()) do snap[#snap + 1] = { t = entry.t, label = entry.label, before = copyOf(entry.t) } + seen[entry.t] = true end -- an engine module required for the FIRST time inside this window is -- snapshotted the moment it loads, before the mod gets it back to wrap @@ -116,6 +120,27 @@ function ModSeams.open(owner) local function require2(name, ...) local had = package.loaded[name] local result = real(name, ...) + if name=='src.core.game3.field_move_show_mon'and type(result)=='table'and result._observeMod then + result._observeMod(owner) + end + -- An imported native follower keeps native callback arguments, but its + -- presentation resources still need the importing mod's owner context. + if name=='src.world.game3.Follower' or name=='src.compat.game3.Follower' then + local facade=package.loaded['src.core.Game3'] or real('src.core.Game3') + if facade and facade._observeActorMod then facade._observeActorMod(owner) end + end + -- Importing the distinct GBA game/runtime namespace declares that + -- this owner's game-bearing callbacks use that facade. No manifest + -- rewrite or mod-id-specific conversion is needed. + if name == 'src.core.Game3' or name == 'src.core.game3.runtime' or name == 'src.core.game3.battle' or name == 'src.core.game3.battle_bridge' + or type(name)=='string' and name:match('^src%.core%.game3%.battle%.') then + local facade = package.loaded['src.core.Game3'] or real('src.core.Game3') + if facade and facade._observeMod then facade._observeMod(owner) end + if type(facade)=='table' and not seen[facade] and not fresh[facade] then + fresh[facade]=true + snap[#snap+1]={t=facade,label='src.core.Game3',before=copyOf(facade)} + end + end if had == nil and type(name) == "string" and name:match("^src%.") and type(result) == "table" and not fresh[result] then fresh[result] = true diff --git a/src/mods/Net.lua b/src/mods/Net.lua new file mode 100644 index 000000000..919cdfa2e --- /dev/null +++ b/src/mods/Net.lua @@ -0,0 +1,108 @@ +-- Per-mod, opaque ownership over the engine's non-blocking GET transport. +local Fetch = require("src.net.Fetch") +local Transport = require('src.net.BoundedHttp') +local Net = { MAX_INFLIGHT = 4 } +local loaders = setmetatable({}, {__mode = "k"}) + +function Net.urlDenial(url) + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") then return "invalid URL" end + if not url:match("^[Hh][Tt][Tt][Pp][Ss]?://[^/?#]+") then return "HTTP or HTTPS URL with a host required" end +end + +local function bucket(loader, owner) + loader.netJobs = loader.netJobs or {} + loader.netJobs[owner] = loader.netJobs[owner] or {} + loaders[loader] = true + return loader.netJobs[owner] +end + +function Net.releaseAll(loader, owner) + if loader.netOneWay then loader.netOneWay[owner]=nil end + local b = loader.netJobs and loader.netJobs[owner] + if not b then return end + for handle, id in pairs(b) do Fetch.cancel(id); Fetch.release(id); b[handle] = nil end + loader.netJobs[owner] = nil +end + +local function submit(loader,owner,method,...) + local b,count=bucket(loader,owner),0 + for _,id in pairs(b)do if Fetch.isPending(id) then count=count+1 end end + if count>=Net.MAX_INFLIGHT then return nil,'too many requests in flight' end + local id,why=Fetch[method](...) + if not id then return nil,why end + local handle={};b[handle]=id + if method=='postLog' then + loader.netOneWay=loader.netOneWay or {};loader.netOneWay[owner]=loader.netOneWay[owner] or {} + loader.netOneWay[owner][handle]=true + end + return handle +end + +function Net.logReporter(loader,mod) + local owner,url,granted=mod.manifest.id,mod.manifest.log_url,mod.manifest.permissionSet.network + return function(_,body,opts) + if not granted then error('mod.postLog for '..owner..' needs the "network" permission',2) end + if not url then return nil,'mod has no log_url' end + local payload,kind=require('src.mods.LogReport').prepare(owner,body,opts) + if not payload then return nil,kind end + if not Transport.available() then return nil,'bounded HTTP transport unavailable' end + return submit(loader,owner,'postLog',url,payload,{userAgent='gen2recomp-mod/'..owner,contentType=kind, + maxSeconds=20,maxBytes=4*1024*1024,boundedHttpsGet=1}) + end +end + +function Net.unloadAll() + for loader in pairs(loaders) do + for owner in pairs(loader.netJobs or {}) do Net.releaseAll(loader, owner) end + end +end + +function Net.forMod(loader, mod) + local owner = mod.manifest.id + if not mod.manifest.permissionSet.network then + local function refuse() error(('mod.fetch for %s needs the "network" permission'):format(owner), 2) end + return { available = function() return false end, get = refuse, + poll = refuse, cancel = refuse, release = refuse, + capabilities = function() return {} end } + end + local api = {} + function api:available() return Transport.available() and Fetch.available() end + function api:capabilities() + return Transport.available() and {boundedHttpsGet = 1, maxResponseBytes = require("src.net.AsyncGet").MAX_BYTES} or {} + end + function api:get(url, opts) + local denial = Net.urlDenial(url) + if denial then return nil, denial end + opts = type(opts) == "table" and opts or {} + if opts.boundedHttpsGet == 1 and not url:lower():match("^https://") then return nil, "HTTPS required" end + return submit(loader,owner,'get',url, { userAgent = "gen2recomp-mod/" .. owner, + accept = type(opts.accept) == "string" and opts.accept or nil, + maxSeconds = opts.maxSeconds, maxBytes = opts.maxBytes, boundedHttpsGet = opts.boundedHttpsGet }) + end + function api:poll(handle) + local id = bucket(loader, owner)[handle] + if not id then return {status = "error", err = "unknown request"} end + local state = Fetch.poll(id); local out = {} + for key, value in pairs(state) do out[key] = value end + if loader.netOneWay and loader.netOneWay[owner] and loader.netOneWay[owner][handle] then + return {status=out.status,progress=out.progress,receivedBytes=0,httpStatus=out.httpStatus, + err=out.status=='error' and 'log upload failed' or nil} + end + return out + end + function api:cancel(handle) + local id = bucket(loader, owner)[handle] + if not id then return false end + Fetch.cancel(id); return true + end + function api:release(handle) + local b = bucket(loader, owner); local id = b[handle] + if not id then return false end + Fetch.cancel(id); Fetch.release(id); b[handle] = nil + if loader.netOneWay and loader.netOneWay[owner] then loader.netOneWay[owner][handle]=nil end + return true + end + return api +end + +return Net diff --git a/src/mods/PortableScripts.lua b/src/mods/PortableScripts.lua new file mode 100644 index 000000000..bfa6c6181 --- /dev/null +++ b/src/mods/PortableScripts.lua @@ -0,0 +1,543 @@ +-- 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 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") + 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 function queue(rows, options) + if not licensed() then + return nil, "script facade is retired" + end + local program, why = Program.compile(rows) + 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 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 = {}, + 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 ow.map ~= token.map + or ow.map.id ~= token.mapId + or ow.player ~= token.player + or ow.player.cellX ~= token.x + or ow.player.cellY ~= token.y + or ow.runner ~= runner + 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 ow.transitioning + then + return nil, "script session changed" + end + return true + end + function token.finish(ok, reason) + if token.done then + return + end + token.done = true + 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 owned(state, step) + 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 ok, err = engine(update, self, ...) + if not ok then + token.finish(false, "native UI failed: " .. message(err)) + end + end + return state + 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 + box = TextBox.new(game, TextBox.fromProse(text), function() + if not asking and not contains(token.stack, box) then + answer(nil) + end + end) + 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 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 + 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 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 ok, err = coroutine.resume(self.co, ...) + 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/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/ScriptProgram.lua b/src/mods/ScriptProgram.lua new file mode 100644 index 000000000..4bfc0a813 --- /dev/null +++ b/src/mods/ScriptProgram.lua @@ -0,0 +1,160 @@ +-- Pure preflight for the bounded portable dialogue/control-flow language. +local Program = {} +Program.MAX_ROWS, Program.MAX_STEPS, Program.SLICE = 1024, 65536, 128 +local ARITY = { + show_text = 2, + ask = 2, + choice = 3, + wait = 2, + label = 2, + jump = 2, + jump_if_true = 2, + jump_if_false = 2, + ["end"] = 1, +} +function Program.supports(verb) + return type(verb) == "string" and (ARITY[verb] ~= nil or verb == "text") +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) + 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], 3) + if not count or count == 0 then + return fail(i, "expected a dense plain command row") + end + local verb = source[1] == "text" and "show_text" or source[1] + if type(verb) ~= "string" or not ARITY[verb] then + return fail(i, "unsupported portable command") + end + if count > ARITY[verb] or count < (verb == "choice" and 2 or ARITY[verb]) then + return fail(i, "invalid command arity") + end + local row = { verb, source[2] } + if 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/StreamMD5.lua b/src/mods/StreamMD5.lua new file mode 100644 index 000000000..cba8f13e6 --- /dev/null +++ b/src/mods/StreamMD5.lua @@ -0,0 +1,117 @@ +local ok, native = pcall(require, "bit") +local bitlib = ok and native or rawget(_G, "bit32") +if not bitlib then + -- Keep the module parseable on Lua 5.1 while supporting the native integer + -- operators of plain Lua 5.3/5.4 test hosts as well. + local make = loadstring or load + local factory = make([[return { + band=function(a,...) for i=1,select('#',...) do a=a & select(i,...) end return a & 0xffffffff end, + bor=function(a,...) for i=1,select('#',...) do a=a | select(i,...) end return a & 0xffffffff end, + bxor=function(a,...) for i=1,select('#',...) do a=a ~ select(i,...) end return a & 0xffffffff end, + bnot=function(a)return (~a) & 0xffffffff end, + lshift=function(a,n)return (a << n) & 0xffffffff end, + rshift=function(a,n)return (a & 0xffffffff) >> n end, + rol=function(a,n)return ((a << n) | ((a & 0xffffffff) >> (32-n))) & 0xffffffff end + }]]) + if factory then bitlib=factory() end +end +if not bitlib then error("StreamMD5 requires bit or bit32") end + +local band, bor, bxor, bnot = bitlib.band, bitlib.bor, bitlib.bxor, bitlib.bnot +local lshift, rshift = bitlib.lshift, bitlib.rshift +local rol = bitlib.rol or bitlib.lrotate + +local K = { + 0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,0xa8304613,0xfd469501, + 0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,0x6b901122,0xfd987193,0xa679438e,0x49b40821, + 0xf61e2562,0xc040b340,0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8, + 0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,0x676f02d9,0x8d2a4c8a, + 0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70, + 0x289b7ec6,0xeaa127fa,0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665, + 0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,0xffeff47d,0x85845dd1, + 0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391, +} +local S = { + 7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22, + 5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20, + 4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23, + 6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21, +} + +local function add32(a,b,c,d) + local n = (a or 0) + (b or 0) + (c or 0) + (d or 0) + return band(n, 0xffffffff) +end + +local function le32_from(s, i) + local b1,b2,b3,b4 = s:byte(i, i+3) + return bor(b1, lshift(b2,8), lshift(b3,16), lshift(b4,24)) +end + +local function le32_bytes(x) + return string.char( + band(x,0xff), band(rshift(x,8),0xff), + band(rshift(x,16),0xff), band(rshift(x,24),0xff)) +end + +local M = {} +local StreamMD5 = {} +StreamMD5.__index = StreamMD5 + +function StreamMD5.new() + return setmetatable({ + a=0x67452301, b=0xefcdab89, c=0x98badcfe, d=0x10325476, + bytes=0, buffer="", done=false, + }, StreamMD5) +end + +function StreamMD5:_block(block) + for j=1,16 do M[j] = le32_from(block, (j-1)*4+1) end + local a,b,c,d = self.a,self.b,self.c,self.d + for i=0,63 do + local f,g + if i < 16 then + f = bor(band(b,c), band(bnot(b),d)); g=i + elseif i < 32 then + f = bor(band(d,b), band(bnot(d),c)); g=(5*i+1)%16 + elseif i < 48 then + f = bxor(b,c,d); g=(3*i+5)%16 + else + f = bxor(c, bor(b,bnot(d))); g=(7*i)%16 + end + local tmp=d + d=c + c=b + b=add32(b, rol(add32(a,f,K[i+1],M[g+1]), S[i+1])) + a=tmp + end + self.a=add32(self.a,a); self.b=add32(self.b,b) + self.c=add32(self.c,c); self.d=add32(self.d,d) +end + +function StreamMD5:update(data) + assert(not self.done, "StreamMD5 context already finalized") + assert(type(data)=="string", "StreamMD5:update expects a string") + self.bytes = self.bytes + #data + local s = self.buffer .. data + local full = #s - (#s % 64) + for i=1,full,64 do self:_block(s:sub(i,i+63)) end + self.buffer = s:sub(full+1) + return self +end + +function StreamMD5:final() + assert(not self.done, "StreamMD5 context already finalized") + local originalBytes = self.bytes + local padLen = (56 - ((originalBytes + 1) % 64)) % 64 + local bits = originalBytes * 8 + local lo = bits % 4294967296 + local hi = math.floor(bits / 4294967296) % 4294967296 + self:update("\128" .. string.rep("\0", padLen) .. le32_bytes(lo) .. le32_bytes(hi)) + assert(#self.buffer == 0, "MD5 finalization left a partial block") + self.done=true + local raw = le32_bytes(self.a)..le32_bytes(self.b)..le32_bytes(self.c)..le32_bytes(self.d) + return (raw:gsub(".", function(ch) return string.format("%02x", ch:byte()) end)) +end + +return StreamMD5 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..37add08db --- /dev/null +++ b/src/mods/UnifiedCallbacks.lua @@ -0,0 +1,227 @@ +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.caught_marker_visible battle.menu_auxiliary world.follower.spawn + 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 core.logic_speed core.update + encounter.fishing encounter.roll encounter.species evolution.check exp.gain fieldmove.eligibility + input.key input.gamepad input.wheel input.step intro.oak_speech.build link.fingerprint map.palette movement.collision movement.speed + music.select music.volume player.sprite pokemon.icon pokemon.sprite render.compose render.hud + render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.party transition.style + field.weather.sound + 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 = { + ["world.follower.spawn"]={false,false}, + ["battle.caught_marker_visible"]={"context"}, + ["battle.menu_auxiliary"]={false,"context"}, + ["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..dde1870a8 --- /dev/null +++ b/src/mods/UnifiedContent.lua @@ -0,0 +1,518 @@ +-- 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 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 + if type(raw.effect) ~= "string" then out.effect = nil end + 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 + if value.effect and value.effect ~= DELETE then + reference(ctx, "move_effects", value.effect) + if ctx.gen > 1 and (not old or value.effect ~= old.effect) then unsupported("changing move effects requires a native effect adapter") 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/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..10d2f6bb2 --- /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), + 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/net/AsyncDownload.lua b/src/net/AsyncDownload.lua new file mode 100644 index 000000000..f6478e13d --- /dev/null +++ b/src/net/AsyncDownload.lua @@ -0,0 +1,188 @@ +-- File downloads have bounded, engine-owned workers. Poll only drains results, +-- reads coalesced progress. All file IO/publication stays on the workers. +local M = { MAX_JOBS = 8, MAX_BYTES = 512 * 1024 * 1024 } +local jobs, workers, destinations, nextId = {}, {}, {}, 0 +local commands, results, quit, directory, ready, failure +local function now() return love.timer.getTime() end + +local function ensure() + if ready ~= nil then return ready end + if not (love and love.thread and love.thread.newThread and love.filesystem and love.timer) + or not require("src.core.HostShell").canSpawnProcess() then + ready, failure = false, "background file downloads unavailable"; return false + end + commands, results, quit = love.thread.newChannel(), love.thread.newChannel(), love.thread.newChannel() + directory = "net-download/" .. os.time() .. "-" .. tostring({}):gsub("[^%w]", "") + local ok, made = pcall(love.filesystem.createDirectory, directory) + if not ok or not made then ready, failure = false, "could not create download workspace"; return false end + for _ = 1, 2 do + local created, thread = pcall(love.thread.newThread, "src/net/download_worker.lua") + if created and thread and pcall(thread.start, thread, commands, results, quit) then workers[#workers + 1] = thread end + end + ready = #workers > 0 + if not ready then failure = "could not start file-download workers" end + return ready +end + +local function retire(job) + if destinations[job.key] == job.id then destinations[job.key] = nil end +end +local function cancelTransfer(job) + if not job.control then return true end + return job.control:performAtomic(function(control) + if control:peek() ~= "open" then return false end + control:clear(); control:push("cancelled"); job.cancel:push(true) + return true + end) +end +local function failed(job, reason) + job.state = {status = "error", err = reason, progress = 0} + retire(job) +end +local function drain() + if not results then return end + while true do + local response = results:pop() + if not response then break end + local job = type(response) == "table" and jobs[response.id] + if job then + job.busy = false + if job.retained and job.state.status == "pending" then + if not response.ok then failed(job, response.err or "download failed") + else + job.state = {status = "ok", progress = 1, receivedBytes = response.bytes, httpStatus = response.httpStatus} + retire(job) + end + if job.state.status == "error" then job.state.httpStatus = response.httpStatus end + end + retire(job) + if not job.retained then jobs[response.id] = nil end + end + end + for _, thread in ipairs(workers) do + local err = thread:getError() + if err then + ready, failure = false, tostring(err) + quit:push(true); commands:clear() + for _ = 1, #workers do commands:push(false) end + for _, job in pairs(jobs) do + if job.state.status == "pending" then cancelTransfer(job); failed(job, failure) end + end + break + end + end + for _, job in pairs(jobs) do + if job.state.status == "pending" and now() >= job.deadline and cancelTransfer(job) then + failed(job, "download timed out") + end + end +end + +local function path(value) + if type(value) ~= "string" or #value == 0 or #value > 2048 or value:find("[%z%c:]") then return nil end + value = value:gsub("\\", "/") + if value:sub(1, 1) == "/" or value:sub(-1) == "/" or value:find("//", 1, true) + or value == "net-download" or value:sub(1, 13) == "net-download/" then return nil end + for part in value:gmatch("[^/]+") do if part == "." or part == ".." then return nil end end + return value +end +local function number(value, default, maximum) + if value == nil then return default end + value = tonumber(value) + if not value or value ~= value or value < 0 or value > maximum then return nil end + return value +end + +function M.download(url, destination, options) + options = type(options) == "table" and options or {} + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") + or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end + if options.boundedHttpsGet == 1 and not url:lower():match("^https://") then return nil, "HTTPS required" end + for _, name in ipairs({"userAgent", "accept"}) do + local value = options[name] + if value ~= nil and (type(value) ~= "string" or #value > 1024 or value:find("[%z%c]")) then + return nil, "invalid HTTP header" + end + end + local dest = path(destination) + if not dest then return nil, "relative download file path required" end + local size = number(options.size, nil, M.MAX_BYTES) + if options.size ~= nil and (size == nil or size % 1 ~= 0) then return nil, "invalid expected size" end + local limit = number(options.maxBytes, math.max(size or 0, 64 * 1024 * 1024), M.MAX_BYTES) + local seconds = number(options.maxSeconds, 60, 300) + if not limit or limit < 1 or limit % 1 ~= 0 or size and size > limit then return nil, "invalid byte limit" end + if not seconds or seconds < .1 then return nil, "invalid download timeout" end + drain() + local busy = 0 + for _, job in pairs(jobs) do if job.busy then busy = busy + 1 end end + if busy >= M.MAX_JOBS then return nil, "file-download worker queue is full" end + local key = love and love.system and love.system.getOS and love.system.getOS() == "Windows" and dest:lower() or dest + if destinations[key] then return nil, "download destination is busy" end + nextId = nextId + 1 + local id = nextId + local job = {id = id, dest = dest, key = key, size = size, retained = true, + state = {status = "pending", progress = 0, receivedBytes = 0}} + jobs[id] = job + if not ensure() then failed(job, failure); return id end + job.stage, job.cancel = directory .. "/" .. id .. ".part", love.thread.newChannel() + job.control, job.progress = love.thread.newChannel(), love.thread.newChannel() + job.control:push("open") + job.deadline, job.busy = now() + seconds, true + destinations[key] = id + commands:push({id = id, url = url, stage = job.stage, dest = dest, cancel = job.cancel, + control = job.control, progress = job.progress, + deadline = job.deadline, maxSeconds = seconds, size = size, maxBytes = limit, + userAgent = options.userAgent, accept = options.accept, httpsOnly = options.boundedHttpsGet == 1}) + return id +end + +function M.poll(id) + drain() + local job = jobs[id] + if not job or not job.retained then return {status = "error", err = "invalid job handle"} end + local state = {} + for k, v in pairs(job.state) do state[k] = v end + if state.status == "pending" and job.progress then + local received = job.progress:peek() + job.receivedBytes = math.max(job.receivedBytes or 0, tonumber(received) or 0) + state.receivedBytes = job.receivedBytes + state.progress = job.size and job.size > 0 and math.min(1, state.receivedBytes / job.size) or 0 + state.cancellable = job.control:peek() == "open" + if not state.cancellable then state.phase = "publishing" end + end + return state +end +function M.cancel(id) + local job = jobs[id] + if not job or not job.retained then return false end + if job.state.status == "pending" then + -- Publication claims and accepted cancellations are ordered by one short + -- channel transaction. No disk IO runs under that lock. Once publication + -- has claimed the completed transfer, cancellation is too late. + if not cancelTransfer(job) then return false end + failed(job, "cancelled") + end + return job.state.status ~= "ok" +end +function M.release(id) + local job = jobs[id] + if not job then return end + M.cancel(id); job.retained = false + if not job.busy then jobs[id] = nil end +end +function M.shutdown() + for id in pairs(jobs) do M.cancel(id) end + if quit then quit:push(true); commands:clear(); for _ = 1, #workers do commands:push(false) end end + for _, thread in ipairs(workers) do pcall(thread.wait, thread) end + drain() + for _, job in pairs(jobs) do + job.busy = false + if job.stage then love.filesystem.remove(job.stage) end + if job.stage then love.filesystem.remove(job.stage .. ".cfg"); love.filesystem.remove(job.stage .. ".stderr") end + end + if directory then love.filesystem.remove(directory) end + workers, destinations = {}, {} + commands, results, quit, directory, ready, failure = nil, nil, nil, nil, nil, nil +end + +return M diff --git a/src/net/AsyncGet.lua b/src/net/AsyncGet.lua new file mode 100644 index 000000000..b01e966d5 --- /dev/null +++ b/src/net/AsyncGet.lua @@ -0,0 +1,160 @@ +-- Bounded engine-owned GET workers. No network or pipe read runs in poll(). +local Get = { MAX_JOBS = 16, MAX_BYTES = 16 * 1024 * 1024 } +local jobs, workers, nextId = {}, {}, 0 +local commands, results, quit, ready, failure, poolDirectory + +local function now() return love.timer.getTime() end + +local function ensure() + if ready ~= nil then return ready end + if not (love and love.thread and love.thread.newThread and love.thread.newChannel) then + ready, failure = false, "background threads unavailable"; return false + end + commands, results, quit = love.thread.newChannel(), love.thread.newChannel(), love.thread.newChannel() + local token = tostring(os.time()) .. "-" .. tostring({}):gsub("[^%w]", "") + poolDirectory = "net-get/" .. token + for workerIndex = 1, 3 do + -- Initialize the writable filesystem on the main thread, before any + -- workers race its first write-directory creation. Each owns its files. + if love.filesystem then + local made, done = pcall(love.filesystem.createDirectory, poolDirectory .. "/" .. workerIndex) + if not made or not done then + ready, failure = false, "could not create GET worker directory" + return false + end + end + end + for workerIndex = 1, 3 do + local ok, thread = pcall(love.thread.newThread, "src/net/get_worker.lua") + if ok and thread then + local started = pcall(thread.start, thread, commands, results, quit, token, workerIndex) + if started then workers[#workers + 1] = thread end + end + end + ready = #workers > 0 + if not ready then failure = "could not start GET workers" end + return ready +end + +local function drain() + if not results then return end + while true do + local msg = results:pop() + if not msg then break end + local job = type(msg) == "table" and jobs[msg.id] + if job then + job.busy = false + if not job.retained then jobs[msg.id] = nil + elseif job.state.status == "pending" then + local body=not job.oneWay and msg.body or nil + local failure=msg.err + if job.oneWay then failure=not msg.ok and 'log upload failed' or nil end + job.state = { status = msg.ok and "ok" or "error", body = body, + err = failure, httpStatus = msg.httpStatus, + receivedBytes = body and #body or 0, progress = msg.ok and 1 or 0 } + end + end + end + for _, thread in ipairs(workers) do + local err = thread:getError() + if err then + ready, failure = false, tostring(err) + quit:push(true); commands:clear() + for _ = 1, #workers do commands:push(false) end + for _, job in pairs(jobs) do + if job.state.status == "pending" then job.state = { status = "error", err = failure } end + end + break + end + end + for _, job in pairs(jobs) do + if job.state.status == "pending" and now() >= job.deadline then + job.cancel:push(true); job.state = { status = "error", err = "request timed out" } + end + end +end + +local function finite(value, fallback, maximum) + value = tonumber(value) + if not value or value ~= value then value = fallback end + return math.max(1, math.min(maximum, value)) +end + +function Get.available() return ensure() end + +local function request(url, opts, method, body) + opts = type(opts) == "table" and opts or {} + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") + or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end + if (opts.boundedHttpsGet == 1 or method=="POST") and not url:lower():match("^https://") then return nil, "HTTPS required" end + if method=="POST" and (type(body)~="string" or #body>512*1024) then return nil,"POST body exceeds byte limit" end + drain() + local busy = 0 + for _, job in pairs(jobs) do if job.busy then busy = busy + 1 end end + if busy >= Get.MAX_JOBS then return nil, "GET worker queue is full" end + nextId = nextId + 1 + local id = nextId + local seconds = finite(opts.maxSeconds, 20, 30) + local state = { status = "pending", progress = 0, receivedBytes = 0 } + if not ensure() then + jobs[id] = { retained = true, state = {status = "error", err = failure} } + return id + end + local cancel = love.thread.newChannel() + local deadline = now() + seconds + jobs[id] = { retained = true, busy = true, cancel = cancel, state = state, deadline = deadline,oneWay=method=="POST" } + commands:push({ id = id, url = url, cancel = cancel, deadline = deadline, + method=method,body=body,discardBody=method=="POST",contentType=opts.contentType, + userAgent = opts.userAgent, accept = opts.accept, + maxSeconds = seconds, maxBytes = math.floor(finite(opts.maxBytes, 4 * 1024 * 1024, Get.MAX_BYTES)), + httpsOnly = opts.boundedHttpsGet == 1 or method=="POST" }) + return id +end + +function Get.get(url,opts)return request(url,opts,"GET")end +function Get.postLog(url,body,opts)return request(url,opts,"POST",body)end + +function Get.poll(id) + drain() + local job = jobs[id] + if not job or not job.retained then return {status = "error", err = "unknown request"} end + local out = {} + for key, value in pairs(job.state) do out[key] = value end + return out +end + +function Get.cancel(id) + local job = jobs[id] + if not job or not job.retained then return false end + if job.state.status == "pending" then + job.cancel:push(true); job.state = {status = "cancelled"} + end + return true +end + +function Get.release(id) + local job = jobs[id] + if not job then return end + Get.cancel(id) + job.retained = false + if not job.busy then jobs[id] = nil end +end + +function Get.shutdown() + if quit then quit:push(true) end + if commands then + commands:clear() + for _ = 1, #workers do commands:push(false) end + end + for _, thread in ipairs(workers) do pcall(thread.wait, thread) end + if poolDirectory and love.filesystem then love.filesystem.remove(poolDirectory) end + poolDirectory = nil + for _, job in pairs(jobs) do + job.busy = false + if job.state.status == "pending" then job.state = {status = "cancelled"} end + end + commands, results, quit, ready, failure = nil, nil, nil, nil, nil + workers = {} +end + +return Get diff --git a/src/net/BoundedHttp.lua b/src/net/BoundedHttp.lua new file mode 100644 index 000000000..66cbf4389 --- /dev/null +++ b/src/net/BoundedHttp.lua @@ -0,0 +1,51 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Engine-only request bridge. The public mod service retains URL ownership. +local Host=require('src.core.HostShell') +local Json=require('src.link.Json') +local H={MAX_BODY=512*1024,MAX_BYTES=16*1024*1024,MAX_SECONDS=30} +function H.nativeAvailable() + local system=love and love.system + if not (system and type(system.boundedHttpVersion)=='function' and type(system.httpRequestBounded)=='function') then return false end + local ok,version=pcall(system.boundedHttpVersion) + return ok and version==1 and love.data and type(love.data.encode)=='function' and type(love.data.decode)=='function' or false +end +function H.available()return Host.canSpawnProcess() or H.nativeAvailable()end +function H.request(url,options) + local o=options or {};local method=o.method or 'GET' + if method~='GET' and method~='POST' then return nil,'unsupported HTTP method' end + if type(url)~='string' or #url>8192 or url:find('[%z%c%s]') or not url:lower():match('^https?://[^/?#]+') then return nil,'invalid HTTP URL' end + if (o.httpsOnly or method=='POST') and not url:lower():match('^https://') then return nil,'HTTPS required' end + local body=o.body + if method=='POST' and (type(body)~='string' or #body>H.MAX_BODY) then return nil,'POST body exceeds byte limit' end + local seconds=tonumber(o.maxSeconds) or 20;local bytes=tonumber(o.maxBytes) or 4*1024*1024 + if seconds~=seconds or bytes~=bytes or seconds<=0 or bytes<1 then return nil,'invalid HTTP bounds' end + seconds=math.min(seconds,H.MAX_SECONDS);bytes=math.floor(math.min(bytes,H.MAX_BYTES)) + if Host.canSpawnProcess() then + local opts={userAgent=o.userAgent,accept=o.accept,maxSeconds=seconds,maxBytes=bytes, + httpsOnly=o.httpsOnly,contentType=o.contentType,errorPath=o.errorPath} + local value,why,status + if method=='POST' then value,why,status=Host.httpPostBounded(url,body,opts) + else value,why,status=Host.httpGetBounded(url,opts) end + if o.discardBody and value~=nil then value='' end + return value,why,status + end + if not H.nativeAvailable() then return nil,'bounded native HTTP transport unavailable' end + local req={boundedVersion=1,method=method,url=url,userAgent=o.userAgent or 'gen2recomp', + accept=o.accept or '*/*',maxSeconds=seconds,maxBytes=bytes, + httpsOnly=o.httpsOnly==true or method=='POST',discardBody=o.discardBody==true, + contentType=o.contentType or 'text/plain; charset=utf-8'} + if body then req.bodyBase64=love.data.encode('string','base64',body) end + local ok,wire=pcall(love.system.httpRequestBounded,Json.encode(req)) + if not ok or type(wire)~='string' or #wire>math.ceil(bytes*4/3)+8192 then return nil,'native HTTP response failed' end + local decoded,response=pcall(Json.decode,wire) + if not decoded or type(response)~='table' then return nil,'invalid native HTTP response' end + local status=response.status + if type(status)~='number' or status%1~=0 or status<100 or status>599 then status=nil end + if response.error or not status or status<200 or status>=300 then return nil,'native HTTP request failed',status end + if o.discardBody then return '',nil,status end + if type(response.bodyBase64)~='string' or #response.bodyBase64>math.ceil(bytes/3)*4 then return nil,'native HTTP response exceeds byte limit',status end + local valid,value=pcall(love.data.decode,'string','base64',response.bodyBase64) + if not valid or #value>bytes then return nil,'invalid native HTTP body',status end + return value,nil,status +end +return H diff --git a/src/net/DownloadPublish.lua b/src/net/DownloadPublish.lua new file mode 100644 index 000000000..bfc95f0e4 --- /dev/null +++ b/src/net/DownloadPublish.lua @@ -0,0 +1,24 @@ +-- Atomic replacement shared by download workers and validated import copies. +-- The caller owns its staging file and supplies the scoped destination. +return function(source, target) + if love.system.getOS() ~= "Windows" then return os.rename(source, target) end + -- Windows rename cannot overwrite an existing file. Never delete the old + -- destination first: MoveFileExW replaces it in one filesystem operation. + local ffi = require("ffi") + ffi.cdef[[ + int MultiByteToWideChar(unsigned int, unsigned long, const char *, int, unsigned short *, int); + int MoveFileExW(const unsigned short *, const unsigned short *, unsigned long); + unsigned long GetLastError(void); + ]] + local function wide(s) + local n = ffi.C.MultiByteToWideChar(65001, 8, s, #s, nil, 0) + if n == 0 then error("invalid UTF-8 download path") end + local out = ffi.new("unsigned short[?]", n + 1) + if ffi.C.MultiByteToWideChar(65001, 8, s, #s, out, n) ~= n then error("download path conversion failed") end + return out + end + if ffi.C.MoveFileExW(wide(source), wide(target), 1) == 0 then + return nil, "download publish failed (Windows " .. tonumber(ffi.C.GetLastError()) .. ")" + end + return true +end diff --git a/src/net/DownloadTransport.lua b/src/net/DownloadTransport.lua new file mode 100644 index 000000000..9f638fbe3 --- /dev/null +++ b/src/net/DownloadTransport.lua @@ -0,0 +1,88 @@ +-- Worker-only streaming HTTP transport. Remote values stay in a curl config; +-- the response is never assembled in the main thread or retained in memory. +local Host = require("src.core.HostShell") +local M = {} + +function M.run(job, output) + if not Host.canSpawnProcess() then return nil, "background downloads require desktop curl" end + local fs = love.filesystem + local errorPath, configPath = job.stage .. ".stderr", job.stage .. ".cfg" + local absolute = fs.getSaveDirectory() .. "/" .. configPath + if love.system.getOS() == "Windows" and absolute:find('[%%!"^]') then + return nil, "unsupported characters in worker path" + end + local function quote(value) + value = tostring(value) + if value:find("[%z%c]") then return nil end + return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' + end + local ua, accept = quote(job.userAgent or "gen2recomp-fetch"), quote(job.accept or "*/*") + if not ua or not accept then return nil, "invalid HTTP header" end + local seconds = math.max(.1, math.min(job.maxSeconds, job.deadline - love.timer.getTime())) + local protocol = job.httpsOnly and "https" or "http,https" + local lines = { "silent", "show-error", "fail", "location", "globoff", + "connect-timeout = " .. math.min(10, seconds), "max-time = " .. seconds, + "max-filesize = " .. job.maxBytes, "max-redirs = 5", + 'proto = "=' .. protocol .. '"', 'proto-redir = "=' .. protocol .. '"', + "user-agent = " .. ua, "header = " .. quote("Accept: " .. (job.accept or "*/*")), + "stderr = " .. quote(fs.getSaveDirectory() .. "/" .. errorPath), + 'write-out = "\\n%{http_code}"', "url = " .. quote(job.url) } + -- Keep explicit trust bundles consistent with the bounded request transport, + -- including Windows curl builds that do not consume CURL_CA_BUNDLE themselves. + local ca = os.getenv("CURL_CA_BUNDLE") + if ca and ca ~= "" then + local value = quote(ca) + if not value then return nil, "invalid CA bundle path" end + lines[#lines + 1] = "cacert = " .. value + end + lines[#lines + 1] = "" + local config = table.concat(lines, "\n") + local wrote, why = fs.write(configPath, config) + if not wrote then return nil, tostring(why or "could not write download config") end + local pipe = Host.popen("curl --disable --config " .. Host.quote(absolute), + love.system.getOS() == "Windows" and "rb" or "r") + if not pipe then fs.remove(configPath); return nil, "could not start download" end + local bytes, tail, failure = 0, "", nil + local ok, err = pcall(function() + while true do + if job.cancel:peek() then failure = "cancelled"; break end + if love.timer.getTime() >= job.deadline then failure = "download timed out"; break end + local chunk = pipe:read(65536) + if not chunk or chunk == "" then break end + -- Retain curl's four-byte HTTP trailer, even across arbitrary reads. + chunk = tail .. chunk + local n = math.max(0, #chunk - 4) + if bytes + n > job.maxBytes then failure = "response exceeds byte limit"; break end + if n > 0 then + local saved, reason = output:write(chunk:sub(1, n)) + if not saved then error(reason or "download write failed") end + local flushed, flushWhy = output:flush() + if not flushed then error(flushWhy or "download flush failed") end + bytes = bytes + n + -- One progress slot per request; a paused menu cannot accumulate an + -- unbounded message queue while a large file keeps streaming. + job.progress:clear(); job.progress:push(bytes) + end + tail = chunk:sub(n + 1) + end + end) + local closed, done, _, code = pcall(pipe.close, pipe) + local readOk, complaint = pcall(fs.read, errorPath, 4096) + fs.remove(configPath); fs.remove(errorPath) + if not ok then return nil, tostring(err) end + if failure then return nil, failure end + local status = tonumber(tail:match("^\n(%d%d%d)$")) + complaint = readOk and Host.curlComplaint(complaint) or nil + if complaint then return nil, complaint, status end + if not closed or done == nil or type(code) == "number" and code ~= 0 then + return nil, "download transport failed", status + end + if not status or status < 200 or status >= 300 then + return nil, "incomplete HTTP response", status + end + if job.cancel:peek() then return nil, "cancelled", status end + if job.size ~= nil and bytes ~= job.size then return nil, "download size mismatch", status end + return bytes, nil, status +end + +return M diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index d1bd1268a..b5eaa0dd2 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -1,155 +1,38 @@ --- Streaming download API for mods to download large files with progress. --- Wraps HostShell's httpDownload in an async job interface for per-frame pumping. --- Required by mods like HD sheet installer that need progress updates during download. - +-- Engine-owned GET and file-download workers. No HTTP/pipe read in poll. local Fetch = {} - --- Active jobs table: jobId -> { url, dest, size, userAgent, startedAt, maxSeconds, state, progress, err, downloaded } -local jobs = {} -local nextJobId = 1 - --- Job states: "pending", "ok", "error" -local function newJob(url, dest, options) - local job = { - id = nextJobId, - url = url, - dest = dest, - size = options and options.size, - userAgent = options and options.userAgent or "gen1recomp-fetch", - maxSeconds = options and options.maxSeconds or 60, - state = "pending", - progress = 0, - err = nil, - startedAt = os.time(), - downloaded = false, - } - jobs[nextJobId] = job - nextJobId = nextJobId + 1 - return job.id +local Get = require("src.net.AsyncGet") +local Download = require("src.net.AsyncDownload") +local jobs, nextId = {}, 0 +local function start(service, method, ...) + local request, err = service[method](...) + if not request then return nil, err end + nextId = nextId + 1 + jobs[nextId] = {service = service, request = request} + return nextId end --- Start a download job. Returns job handle (integer). --- options: { size?, userAgent?, maxSeconds? } +-- Legacy shape: download(url, relativeSavePath, {size?,userAgent?,maxSeconds?}). +-- Additional optional bounds: maxBytes, accept, boundedHttpsGet. function Fetch.download(url, dest, options) - if type(url) ~= "string" or url == "" then - return nil - end - if type(dest) ~= "string" or dest == "" then - return nil - end - local jobId = newJob(url, dest, options) - local job = jobs[jobId] - - -- Start the download in background using HostShell - local HostShell = require("src.core.HostShell") - if not HostShell.canFetch() then - job.state = "error" - job.err = "no network transport on this platform" - return jobId - end - - -- Convert dest to absolute path if relative - local absDest = dest - if love and love.filesystem then - local saveOk, saveDir = pcall(function() - return love.filesystem.getSaveDirectory() - end) - if saveOk and saveDir and saveDir ~= "" then - absDest = saveDir .. "/" .. dest - end - end - - -- Note: HostShell.httpDownload is synchronous, so we mark it as started - -- The actual download will happen on the first poll - job.absDest = absDest - return jobId + return start(Download, "download", url, dest, options) end - --- Poll a job's status. Returns { status, progress?, err? } --- status: "pending", "ok", "error" -function Fetch.poll(jobId) - local job = jobs[jobId] - if not job then - return { status = "error", err = "invalid job handle" } - end - - if job.state ~= "pending" then - return { status = job.state, progress = job.progress, err = job.err } - end - - -- Check timeout - if os.time() - job.startedAt > job.maxSeconds then - job.state = "error" - job.err = "download timed out" - return { status = "error", err = job.err } - end - - -- Perform the actual download on first poll (synchronous but framed) - if not job.downloaded then - job.downloaded = true - local HostShell = require("src.core.HostShell") - local ok, err = pcall(function() - HostShell.httpDownload(job.url, job.absDest, job.userAgent) - end) - - if not ok then - job.state = "error" - job.err = err or "download failed" - return { status = "error", err = job.err } - end - end - - -- Check file size to estimate progress - local currentSize = 0 - if love and love.filesystem then - local infoOk, info = pcall(love.filesystem.getInfo, job.dest) - if infoOk and info and info.size then - currentSize = tonumber(info.size) or 0 - end - end - - -- If we have an expected size, calculate progress - if job.size and job.size > 0 then - job.progress = math.min(1.0, currentSize / job.size) - else - job.progress = 0 - end - - -- Check if download is complete (file exists and non-zero) - if currentSize > 0 then - -- If we have an expected size, verify it matches - if job.size and job.size > 0 then - if currentSize >= job.size then - job.state = "ok" - job.progress = 1.0 - end - else - -- No expected size, assume complete if file exists - job.state = "ok" - job.progress = 1.0 - end - end - - return { status = job.state, progress = job.progress, err = job.err } +function Fetch.get(url, options) return start(Get, "get", url, options) end +function Fetch.postLog(url, body, options) return start(Get, "postLog", url, body, options) end +function Fetch.poll(id) + local job = jobs[id] + if not job then return {status = "error", err = "invalid job handle"} end + return job.service.poll(job.request) end - --- Cancel an active job. -function Fetch.cancel(jobId) - local job = jobs[jobId] - if not job then return end - if job.state == "pending" then - job.state = "error" - job.err = "cancelled" - -- Try to remove partial file - if love and love.filesystem then - pcall(love.filesystem.remove, job.dest) - end - end +function Fetch.cancel(id) + local job = jobs[id] + if not job then return false end + return job.service.cancel(job.request) end - --- Release job resources. Call after job is complete or cancelled. -function Fetch.release(jobId) - jobs[jobId] = nil +function Fetch.release(id) + local job = jobs[id] + if job then job.service.release(job.request); jobs[id] = nil end end - +function Fetch.available() return Get.available() end +function Fetch.isPending(id) return Fetch.poll(id).status == "pending" end +function Fetch.shutdown() Get.shutdown(); Download.shutdown() end return Fetch diff --git a/src/net/download_worker.lua b/src/net/download_worker.lua new file mode 100644 index 000000000..6863f5dfe --- /dev/null +++ b/src/net/download_worker.lua @@ -0,0 +1,57 @@ +require("love.thread") +require("love.filesystem") +require("love.timer") +require("love.system") +local commands, results, quit = ... +local Transport = require("src.net.DownloadTransport") +local publish = require("src.net.DownloadPublish") +while true do + local job = commands:demand() + if quit:peek() or job == false then break end + if type(job) == "table" then + local output + local ok, bytes, err, status = pcall(function() + if job.cancel:peek() then return nil, "cancelled" end + if love.timer.getTime() >= job.deadline then return nil, "download timed out" end + local why + local openFile = love.filesystem.openFile or love.filesystem.newFile + output, why = openFile(job.stage, "w") + if not output then return nil, tostring(why or "could not create download staging file") end + return Transport.run(job, output) + end) + if output then + local closed, result = pcall(output.close, output) + if not closed or not result then bytes, err = nil, "could not close download staging file" end + end + if not ok then err, bytes = tostring(bytes or err), nil end + if bytes ~= nil then + local checked, result, why = pcall(function() + local info = love.filesystem.getInfo(job.stage, "file") + if not info or info.size ~= bytes then return nil, "download size mismatch" end + local parent = job.dest:match("^(.+)/[^/]+$") + if parent and not love.filesystem.createDirectory(parent) then + return nil, "could not create download destination directory" + end + -- The claim is the completion boundary. Cancel either wins before it + -- or reports false after it; it can never race a later file overwrite. + local claimed = job.control:performAtomic(function(control) + if control:peek() ~= "open" or job.cancel:peek() then return false end + if love.timer.getTime() >= job.deadline then return false end + control:clear(); control:push("publishing"); return true + end) + if not claimed then return nil, job.cancel:peek() and "cancelled" or "download timed out" end + local base = love.filesystem.getSaveDirectory() .. "/" + return publish(base .. job.stage, base .. job.dest) + end) + if not checked or not result then err, bytes = tostring(why or result or "could not publish download"), nil end + end + love.filesystem.remove(job.stage .. ".cfg") + love.filesystem.remove(job.stage .. ".stderr") + if bytes == nil then + love.filesystem.remove(job.stage) + results:push({id = job.id, ok = false, err = err or "cancelled", httpStatus = status}) + else + results:push({id = job.id, ok = true, bytes = bytes, httpStatus = status}) + end + end +end diff --git a/src/net/get_worker.lua b/src/net/get_worker.lua new file mode 100644 index 000000000..ccc85a49d --- /dev/null +++ b/src/net/get_worker.lua @@ -0,0 +1,42 @@ +require("love.thread") +require("love.filesystem") +require("love.timer") +require("love.system") +require("love.data") +local commands, results, quit, token, workerIndex = ... +local Transport = require("src.net.BoundedHttp") +local directory = "net-get/" .. token .. "/" .. workerIndex +-- The main thread creates this workspace before starting any worker. +local saveDir = love.filesystem.getSaveDirectory() + +local function run(job) + if job.cancel:peek() or love.timer.getTime() >= job.deadline then + return {id = job.id, ok = false, err = "cancelled"} + end + local path = directory .. "/" .. job.id .. ".stderr" + local body, err, httpStatus = Transport.request(job.url, { + method=job.method,body=job.body,discardBody=job.discardBody,contentType=job.contentType, + userAgent = job.userAgent, accept = job.accept, + maxSeconds = math.max(0.1, math.min(job.maxSeconds, job.deadline - love.timer.getTime())), + maxBytes = job.maxBytes, httpsOnly = job.httpsOnly, + errorPath = saveDir .. "/" .. path, + }) + love.filesystem.remove(path) + if job.cancel:peek() then return {id = job.id, ok = false, err = "cancelled"} end + if job.discardBody then err=body==nil and 'log upload failed' or nil end + return {id = job.id, ok = body ~= nil, body = not job.discardBody and body or nil, + err = err, httpStatus = httpStatus} +end + +while true do + local job = commands:demand() + if quit:peek() or job == false then break end + if type(job) == "table" then + local ok, response = pcall(run, job) + love.filesystem.remove(directory .. "/" .. job.id .. ".stderr") + love.filesystem.remove(directory .. "/" .. job.id .. ".stderr.cfg") + if not ok then response = {id = job.id, ok = false, err = tostring(response)} end + results:push(response) + end +end +love.filesystem.remove(directory) diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 321387210..f901a9d91 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -330,7 +330,7 @@ function Evolution.createShedinja(game, mon, fromSpecies) local def = game.data.pokemon[row.species] if not def then return end local frlg = (game.data.constants or {}).gen3FRLGNaming - or require("src.core.GameVersion").get() == "firered" + or require("src.core.GameVersion").isFRLG() if frlg and ((game.save.inventory or {}).POKE_BALL or 0) < 1 then return end local function copy(value) if type(value) ~= "table" then return value end @@ -413,14 +413,21 @@ function Evolution.learnEvolutionMoves(game, mon, onDone) -- movesLearnedAt uses entry.level == level (exact Gen1 rule); do NOT use -- Pokemon.movesAtLevel (<= level), which would over-grant older moves. local moves = Experience.movesLearnedAt(def, mon.level) + return Evolution.learnMoves(game,mon,moves,function() + local from=shedinjaPending[mon];shedinjaPending[mon]=nil + if from then Evolution.createShedinja(game,mon,from)end + if onDone then onDone()end + end) +end + +-- The same free-slot/forget flow also serves explicit native reminders. +function Evolution.learnMoves(game,mon,moves,onDone) + local def=game.data.pokemon[mon.species] local i = 0 local function nextStep() i = i + 1 local moveId = moves[i] if not moveId then - local from = shedinjaPending[mon] - shedinjaPending[mon] = nil - if from then Evolution.createShedinja(game, mon, from) end if onDone then onDone() end return end diff --git a/src/pokemon/Gen45Hatch.lua b/src/pokemon/Gen45Hatch.lua index d03ff8bf1..edc23cab0 100644 --- a/src/pokemon/Gen45Hatch.lua +++ b/src/pokemon/Gen45Hatch.lua @@ -6,6 +6,7 @@ -- bank (Pt 357, HGSS 187). Black / White keep all three in one bank, 260. -- Each falls back to the engine's English when the cache lacks the line. +local Strings = require("src.core.Strings") local Gen45Hatch = {} Gen45Hatch.LINES = { @@ -37,9 +38,9 @@ end -- The three lines, with `name` (the species) filled in. function Gen45Hatch.lines(game, name) local rows = Gen45Hatch.LINES[family() or "platinum"] - return line(game, rows.oh, name, "Oh?"), - line(game, rows.hatched, name, ("%s hatched from the Egg!"):format(name)), - line(game, rows.ask, name, ("Would you like to nickname the newly\nhatched %s?"):format(name)) + return line(game, rows.oh, name, Strings("Oh?")), + line(game, rows.hatched, name, Strings("%s hatched from the Egg!", name)), + line(game, rows.ask, name, Strings("Would you like to nickname the newly\nhatched %s?", name)) end return Gen45Hatch diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 17c5685c0..4c08a8947 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -224,7 +224,7 @@ function Pokemon.new(data, species, level, rng, form) -- (Black / White too: the same personal byte, the same rules) happiness = ((require("src.core.GameVersion").isGen4() or require("src.core.GameVersion").isGen5()) and (def.baseFriendship or def.friendship or 70)) - or require("src.core.GameVersion").get()=="emerald" and (def.friendship or 70) + or require("src.core.GameVersion").isGen3() and (def.friendship or 70) or (require("src.core.GameVersion").isGen2() and require("src.pokemon.Evolution").BASE_HAPPINESS or nil), -- the Gen1 catch-rate byte freezes at catch time: evolution does NOT diff --git a/src/render/BattleScreenEffect.lua b/src/render/BattleScreenEffect.lua new file mode 100644 index 000000000..ff45819cf --- /dev/null +++ b/src/render/BattleScreenEffect.lua @@ -0,0 +1,62 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Repeated begin calls reapply an effect within one render seam. Nested seams +-- each restore their caller's shader, including error paths guarded by Scope. +local Scope=require('src.render.GraphicsScope') +local M={};M.__index=M +local codes={invert=1,fade_white=2,fade_black=3,darken=3,grayscale=4,custom_blend=5} +local source=[[ +extern number mode; +extern number amount; +extern vec3 blendColour; +vec4 effect(vec4 colour, Image image, vec2 uv, vec2 screen) { + vec4 pixel=Texel(image,uv)*colour; + vec3 target=pixel.rgb; + if(mode<1.5) target=vec3(1.0)-pixel.rgb; + else if(mode<2.5) target=vec3(1.0); + else if(mode<3.5) target=vec3(0.0); + else if(mode<4.5) target=vec3(dot(pixel.rgb,vec3(0.299,0.587,0.114))); + else target=blendColour; + pixel.rgb=mode<1.5 ? target : mix(pixel.rgb,target,amount); + return pixel; +} +]] +function M.new() + return setmetatable({value={type='none',coeff=0,targetColor={1,1,1}}, + bindings=setmetatable({},{__mode='k'}),outside={}},M) +end +function M:set(opts) + if not opts or opts.type==false or opts.type=='none'then + self.value={type='none',coeff=0,targetColor={1,1,1}};return true + end + local kind=opts.type or'invert' + assert(codes[kind],'unsupported battle screen effect') + local amount=opts.coeff==nil and 1 or opts.coeff + assert(type(amount)=='number'and amount==amount and amount>=0 and amount<=1,'invalid battle effect strength') + local colour=opts.targetColor or{1,1,1};local copy={} + for i=1,3 do local v=colour[i];assert(type(v)=='number'and v==v and v>=0 and v<=1,'invalid battle effect colour');copy[i]=v end + self.value={type=kind,coeff=amount,targetColor=copy};return true +end +function M:begin() + local value=self.value;local code=codes[value.type] + if not code then return false end + local g=love.graphics + if not self.shader then self.shader=g.newShader(source)end + local token=Scope.token()or self.outside + local previous=self.bindings[token] + if not previous then previous={shader=g.getShader()};self.bindings[token]=previous end + self.shader:send('mode',code);self.shader:send('amount',value.coeff) + self.shader:send('blendColour',value.targetColor) + g.setShader(self.shader);return true +end +function M:finish() + local token=Scope.token()or self.outside;local previous=self.bindings[token] + if not previous then return false end + self.bindings[token]=nil;love.graphics.setShader(previous.shader);return true +end +function M:release() + -- A surrounding render guard restores a shader still in use on an error + -- path. Do not destroy a borrowed binding during that unwind. + if self.shader and love.graphics.getShader()~=self.shader then self.shader:release();self.shader=nil end + self.bindings=setmetatable({},{__mode='k'}) +end +return M diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua index f3560edd0..ca402541e 100644 --- a/src/render/BattleTransition.lua +++ b/src/render/BattleTransition.lua @@ -966,7 +966,7 @@ local function gen3Style(ctx) -- FireRed's two tables (battle_setup.c sBattleTransitionTable_Wild / -- _Trainer): column 0 when the foe is weaker than the lead. Unlike the old -- shortcut, the ROW matters too: water/flash/cave each have their own pair. - if require("src.core.GameVersion").get() == "firered" then + if require("src.core.GameVersion").isFRLG() then local weaker = ctx.weaker if weaker == nil then weaker = not ctx.stronger end if ctx.trainer then @@ -1103,6 +1103,12 @@ function BattleTransition.new(game, onDone, opts) self.game = game self.onDone = onDone self.t = 0 + self.frame = 0 + self.options = opts or {} + if game and game.data and game.save then + self.cohort = { data=game.data, save=game.save, loader=game.mods, + edition=require('src.core.GameVersion').get() } + end opts = opts or {} -- EVERYTHING EMERALD'S CHOICE ASKS FOR. The three the Game Boy needed are -- still here; the rest are what GetBattleTransitionTypeByMap, @@ -1115,8 +1121,10 @@ function BattleTransition.new(game, onDone, opts) trainerClass = opts.trainerClass, trainerId = opts.trainerId, trainerName = opts.trainerName, legendSpecies = opts.legendSpecies, - legendary = opts.legendary } - local style = Runtime.call("transition.style", vanillaStyle, ctx) + legendary = opts.legendary, requestedStyle = opts.style } + local style = Runtime.call("transition.style", function(value) + return value.requestedStyle or vanillaStyle(value) + end, ctx) local def = styleDef(game, style) -- a hook that names an unregistered style falls back to the vanilla bits if not def then @@ -1125,7 +1133,7 @@ function BattleTransition.new(game, onDone, opts) end self.style = style self.def = def - self.fireRed = require("src.core.GameVersion").get() == "firered" + self.fireRed = require("src.core.GameVersion").isFRLG() -- Every FireRed battle transition first runs CreateIntroTask(0,0,2,2,2): -- two 16-frame gray pulses. It does not use the Gen 1 circle-flash prelude. if self.fireRed then @@ -1140,7 +1148,53 @@ function BattleTransition.new(game, onDone, opts) return self end -function BattleTransition:update(dt) +local function present(self) + local c=self.cohort + if c and (self.game.data~=c.data or self.game.save~=c.save or self.game.mods~=c.loader + or require('src.core.GameVersion').get()~=c.edition) then return false end + local states=self.game.stack and self.game.stack.states + if states then + for _,state in ipairs(states)do if state==self then return true end end + return false + end + return true +end + +function BattleTransition:enter() + local source=package.loaded['src.core.game3.battle_transition'] + if require('src.core.GameVersion').isGen3() and source and source._nativeStart then source._nativeStart(self)end +end + +function BattleTransition:exit() + local source=package.loaded['src.core.game3.battle_transition'] + if source and source._nativeExit then source._nativeExit(self)end +end + +function BattleTransition:complete(completed) + if self.finished or not present(self) then return false end + local stack=self.game.stack + if stack.top and stack:top()~=self then return false,'transition is covered by another native state'end + self.finished=true + self.phase=completed and 'done' or 'idle' + local callback=self.onDone;self.onDone=nil + stack:pop() + if completed and callback then callback()end + return true +end + +function BattleTransition:finish() + if self.finished or not present(self)then return false end + local source=package.loaded['src.core.game3.battle_transition'] + if require('src.core.GameVersion').isGen3() and source and source._nativeFinish then + return source._nativeFinish(self,function()return self:complete(true)end) + end + return self:complete(true) +end + +local function updateBody(self,dt) + if self.finished or not present(self)then return false end + if self.game.stack.top and self.game.stack:top()~=self then return false end + self.frame = self.frame + 1 self.t = self.t + 1 if self.phase == "frlg_intro" then if self.t >= FRLG_INTRO_FRAMES then @@ -1154,20 +1208,20 @@ function BattleTransition:update(dt) end else if self.t >= self.wipeLen + self.blackHold then - self.game.stack:pop() - if self.onDone then self.onDone() end + self:finish() end end end -function BattleTransition:draw() +local function drawBody(self, renderer) if self.phase == "frlg_intro" then local p = (math.max(1, self.t) - 1) % 16 local blend = p < 8 and (p + 1) * 2 or (15 - p) * 2 local a = math.max(0, math.min(1, blend / 16)) - local r = self.game and self.game.renderer + local r = renderer or self.game and self.game.renderer if r then r.screenVeil = { FRLG_INTRO_GRAY, a } + r.nativeTransitionVeil = { state=self, value=r.screenVeil } else love.graphics.setColor(FRLG_INTRO_GRAY, FRLG_INTRO_GRAY, FRLG_INTRO_GRAY, a) @@ -1184,9 +1238,10 @@ function BattleTransition:draw() -- pixel the LCD shows. Hand it to the renderer as a screen-space veil -- so it covers the whole surface at any zoom; only the headless and -- no-renderer paths fall back to filling the 160x144 box. - local r = self.game and self.game.renderer + local r = renderer or self.game and self.game.renderer if r then r.screenVeil = { shade, math.abs(v) } + r.nativeTransitionVeil = { state=self, value=r.screenVeil } return end love.graphics.setColor(shade, shade, shade, math.abs(v)) @@ -1216,7 +1271,7 @@ function BattleTransition:draw() -- alongside a second one outside it. Nothing is stretched -- the pattern is -- continued with more tiles, not scaled-up pixels -- so at 1x the grid works -- out to exactly 20x18 and this is the classic wipe unchanged. - local renderer = self.game and self.game.renderer + renderer = renderer or self.game and self.game.renderer if renderer then renderer.battleWipe = { style = style, prog = prog, t = self.t, game = self.game, @@ -1283,4 +1338,45 @@ function BattleTransition:draw() love.graphics.setColor(1, 1, 1, 1) end +function BattleTransition:update(dt) + local source=package.loaded['src.core.game3.battle_transition'] + if require('src.core.GameVersion').isGen3() and source and source._nativeTick then + return source._nativeTick(self,function()return updateBody(self,dt)end) + end + return updateBody(self,dt) +end + +function BattleTransition:draw() + if self.finished or not present(self)then return false end + local source=package.loaded['src.core.game3.battle_transition'] + if require('src.core.GameVersion').isGen3() and source and source._nativeDraw then + return source._nativeDraw(self,function()return drawBody(self)end) + end + return drawBody(self) +end + +-- Render the same current native effect onto an owned world view. Collect +-- screen-space packets locally; do not replace Game.renderer or sample the +-- canvas being written by scanline effects. +function BattleTransition:drawInto(canvas,w,h,source) + if self.finished or not present(self)then return false end + local g=love.graphics + g.push('all') + local ok,why=pcall(function() + g.origin();g.setCanvas(canvas);g.setShader();g.setScissor();g.setDepthMode() + local packet={};drawBody(self,packet) + if packet.battleWipe then + require('src.render.Renderer').drawBattleWipe(self.game.renderer or {},packet.battleWipe, + w,h,math.floor((w-240)/2),math.floor((h-160)/2),240,160,1,1,source) + end + local veil=packet.screenVeil + if veil and veil[2]>0 then + g.setColor(veil[1],veil[1],veil[1],veil[2]);g.rectangle('fill',0,0,w,h) + end + end) + g.pop() + if not ok then error(why,0)end + return true +end + return BattleTransition diff --git a/src/render/FieldFade.lua b/src/render/FieldFade.lua new file mode 100644 index 000000000..f7d1cfb94 --- /dev/null +++ b/src/render/FieldFade.lua @@ -0,0 +1,137 @@ +-- Copyright (c) 2026 Cedric. All rights reserved. +-- Source-available under the Gen2Recomped License (see LICENSE.md). +-- Shared native script-fade painter and fixed-step controller. Source callers +-- use this same controller; native scripts keep their original ramp and wait. +local Logger=require('src.core.Logger') +local FadeOverlay = {} +FadeOverlay.__index = FadeOverlay + +function FadeOverlay.new(game, ow, color, opts) + return setmetatable({ + game = game, ow = ow, alpha = 0, color = color or "black", + ownedHold = opts and opts.ownedHold or false, + }, FadeOverlay) +end + +-- A FADE IS A PALETTE EFFECT, NOT A MODE -- the world keeps running under it. +-- +-- The state stack only updates the state on TOP, so while this overlay is up +-- it is the only thing being ticked. Ticking nothing but the script runner +-- starves the very thing most scripts do behind a fade: MOVE SOMEBODY. The +-- move queue never steps, `waitmovement` never completes, the map's frame +-- table is never re-asked, and the player is left looking at black with a +-- script that can no longer finish -- which is exactly what Route 101's +-- rescue does (fadescreen, removeobject, setobjectxy, applymovement, +-- waitmovement) three commands after the screen goes dark. +-- +-- So pump the field itself. Only while something is actually driving it: with +-- no script and no queued move there is nothing to advance, and running the +-- field then would hand the player an input frame behind a black screen. +function FadeOverlay:update(dt) + if self.sourceRetired then return end + local ow = self.ow + if ow then + local runner = ow.runner + local busy = (runner and runner.isRunning and runner:isRunning()) + or (ow.scriptMoves and #ow.scriptMoves > 0) + or (ow.pendingScripts and ow.pendingScripts[1] ~= nil) + if busy and ow.update then + ow:update(dt or 1 / 60) + elseif runner then + runner:update() + end + -- THE WATCHDOG, because a screen stuck at black is the one failure a + -- player cannot tell from a crash. + -- + -- A fade is always meant to be undone by something: the script's own + -- fade back, the `waitstate` that ends the scene, or the runner when the + -- script finishes. If all three miss it, the game is still running -- + -- input is even accepted -- behind a fully opaque rectangle, and the only + -- report anybody can make is "it went black". Two seconds of full black + -- with nothing driving the field is not a beat in either cartridge, so + -- bring the picture back and say why once. + if not self.ownedHold and not self.ramp and (self.alpha or 0) >= 1 and not busy then + self.idleFrames = (self.idleFrames or 0) + 1 + if self.idleFrames >= 120 then + Logger.warn("the screen has been faded out for two seconds with no " + .. "script running -- fading back in; whatever darkened " + .. "it never undid it") + self.ramp = { from = self.alpha, to = 0, frames = 12, t = 0 } + self.idleFrames = nil + end + else + self.idleFrames = nil + end + end + local ramp = self.ramp + if not ramp then return end + ramp.t = ramp.t + 1 + local k = math.min(1, ramp.t / ramp.frames) + if ramp.steps then k=math.min(1,math.floor(ramp.t/(ramp.stepFrames or 1))/ramp.steps)end + self.alpha = ramp.from + (ramp.to - ramp.from) * k + if ramp.t >= ramp.frames then + self.ramp = nil + if ramp.to <= 0 then + -- a box may sit above the overlay; remove in place, not pop + local states = self.game.stack.states + for i = #states, 1, -1 do + if states[i] == self then table.remove(states, i) break end + end + self:releaseVeil() + if ow then ow.fadeOverlay = nil end + end + if ramp.onDone then ramp.onDone() end + end +end + +-- ...AND ON THE WHOLE SURFACE'S ORIGIN. A state with no uiSize is centred as +-- Game Boy furniture (Game:draw's classicOffset), which slid the 240x160 +-- rectangle 40 right and 8 down and left an unfaded L down the left and top +-- through Mom's heal. +function FadeOverlay:uiSize() + return require("src.ui.Theme").uiSize() +end + +function FadeOverlay:releaseVeil() + local r = self.game and self.game.renderer + if self.ownsVeil and r then r.screenVeil = nil end + self.ownsVeil = nil +end + +function FadeOverlay:draw() + if self.sourceRetired then return end + -- A PALETTE FADE HAS NO OUTSIDE. When nothing is drawn over the fade it is + -- painted as the renderer's whole-window veil, so the world bleeding past + -- the 240x160 surface darkens with it; with a box above it, it stays a + -- rectangle in the surface so the box remains readable. + local r = self.game and self.game.renderer + if r and self.game.stack:top() == self then + r.screenVeil = { self.color == "white" and 1 or 0, self.alpha or 0 } + self.ownsVeil = true + return + end + self:releaseVeil() + if self.color == "white" then + love.graphics.setColor(1, 1, 1, self.alpha) + else + love.graphics.setColor(0, 0, 0, self.alpha) + end + -- THE WHOLE SCREEN, not the Game Boy's. Fixed at 160x144 this covered the + -- top-left five eighths of a GBA surface and left an unfaded L down the + -- right and along the bottom, which is worse than no fade at all. + local w, h = require("src.ui.Theme").uiSize() + love.graphics.rectangle("fill", 0, 0, w, h) + love.graphics.setColor(1, 1, 1, 1) +end + +function FadeOverlay:exit() + self:releaseVeil() + if self.ow and self.ow.fadeOverlay==self then self.ow.fadeOverlay=nil end +end +function FadeOverlay.describe(state) + if getmetatable(state)~=FadeOverlay then return nil end + local ramp=state.ramp + local direction=ramp and(ramp.to>=ramp.from and 1 or -1)or state.sourceFadeDirection or 0 + return state.alpha or 0,state.color=='white',direction,ramp~=nil,true +end +return FadeOverlay diff --git a/src/render/Font.lua b/src/render/Font.lua index 30b2e4a78..135c41705 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -404,6 +404,7 @@ end -- cartridge's own `{COLOR n}` does: swap the letter's colour and leave the -- shadow where it was. local twoToneShader, twoTonePrev, twoToneDepth = nil, nil, 0 +local twoToneColors={} local function twoTone() if twoToneShader == nil then @@ -432,6 +433,7 @@ function Font.beginTwoTone(ink, shadow) love.graphics.setShader(sh) end twoToneDepth = twoToneDepth + 1 + twoToneColors[twoToneDepth]={ink={ink[1]or 0,ink[2]or 0,ink[3]or 0,ink[4]or 1},shadow={shadow[1]or 0,shadow[2]or 0,shadow[3]or 0,shadow[4]or 1}} pcall(sh.send, sh, "inkColor", { ink[1] or 0, ink[2] or 0, ink[3] or 0, ink[4] or 1 }) pcall(sh.send, sh, "shadowColor", @@ -441,10 +443,15 @@ end function Font.endTwoTone() if not twoTone() or twoToneDepth == 0 then return end + twoToneColors[twoToneDepth]=nil twoToneDepth = twoToneDepth - 1 if twoToneDepth == 0 then love.graphics.setShader(twoTonePrev) twoTonePrev = nil + else + local colors=twoToneColors[twoToneDepth] + pcall(twoToneShader.send,twoToneShader,'inkColor',colors.ink) + pcall(twoToneShader.send,twoToneShader,'shadowColor',colors.shadow) end end diff --git a/src/render/Gen3FieldEffectCatalog.lua b/src/render/Gen3FieldEffectCatalog.lua new file mode 100644 index 000000000..b3dd84993 --- /dev/null +++ b/src/render/Gen3FieldEffectCatalog.lua @@ -0,0 +1,33 @@ +-- Imported field artwork, without allocating images or observing an actor. +-- Names describe identified native records; unavailable effects stay absent. +local M={} +local function integer(n,lo,hi) + return type(n)=='number'and n==n and n%1==0 and n>=lo and n<=hi +end +function M.record(row) + if type(row)~='table'then return nil end + local path=row.image or row.path + local w,h,n=row.frameWidth,row.frameHeight,row.frames + if type(path)~='string'or#path==0 or#path>1024 or not integer(w,8,128) + or not integer(h,8,128)or not integer(n,1,256)or w*h*n>1048576 then return nil end + return {path=path,fw=w,fh=h,frames=n} +end +function M.records(data) + local c=type(data)=='table'and data.constants or{};local out={} + for name,key in pairs({tall_grass='gen3TallGrass',surf_blob='gen3SurfBlob',fly_bird='gen3FRLGFlyBird'})do + out[name]=M.record(c[key]) + end + for name,key in pairs({ripple='gen3Ripple',sparkle='gen3Sparkle'})do + local set=c[key];local row=set and data.sprites and data.sprites[set.key] + if row and row.trueColor==true and not row.big and not row.monIcon then out[name]=M.record(row)end + end + -- Native emote strips have independent geometry/order. Do not concatenate + -- them into an assumed fifteen-frame source strip. + for _,name in ipairs({'exclamation','question','heart'})do + local row=c.gen3Emotes and c.gen3Emotes[name] + if row then out['emote_'..name]=M.record({image=row.image,frameWidth=row.width, + frameHeight=row.height,frames=row.frames})end + end + return out +end +return M diff --git a/src/render/Gen3PaletteCycles.lua b/src/render/Gen3PaletteCycles.lua new file mode 100644 index 000000000..dc7e1574b --- /dev/null +++ b/src/render/Gen3PaletteCycles.lua @@ -0,0 +1,139 @@ +-- Bounded, pure palette animation. Records use absolute BG colour offsets +-- (slot * 16 + colour), even in the secondary bank, just like metatile words. +-- No callback execution, cartridge IO, clock advancement or retained cache. +local M = {} +local names = { "primary", "secondary" } +local function integer(n, lo, hi) + return type(n) == "number" and n == math.floor(n) and n >= lo and n <= hi +end +local function dense(t, max) + if type(t) ~= "table" or getmetatable(t) ~= nil or #t > max then return false end + local n = 0 + for k in pairs(t) do + if not integer(k, 1, #t) then return false end + n = n + 1 + end + return n == #t +end + +-- The native renderer owns its last sample. Static banks need no new colour +-- tables or packed strings every terrain tick, but source tables are mutable: +-- compare their complete shape and RGB bytes before reusing that sample. +-- In particular, empty metadata is static; missing metadata is still unknown. +function M.staticUnchanged(pair, layout, ticks, previous) + if not previous or not previous.complete + or not integer(ticks, 0, 9007199254740991) + or not integer(layout.palettesInPrimary, 1, 15) then return false end + for _, name in ipairs(names) do + local bank = pair[name] + local selected = previous[name .. "Frames"] + if type(bank) ~= "table" or getmetatable(bank) ~= nil + or not dense(bank.paletteCycles, 0) + or type(selected) ~= "table" or #selected ~= 0 then return false end + end + -- Reject either animated bank before scanning any colours. Emerald often + -- combines a static primary with an animated secondary. + for _, name in ipairs(names) do + local bank = pair[name] + local packed = previous[name .. "RGB8"] + if type(packed) ~= "string" or #packed ~= 768 + or not dense(bank.palettes, 16) or #bank.palettes ~= 16 then return false end + local at = 1 + for p = 1, 16 do + local palette = bank.palettes[p] + if not dense(palette, 16) or #palette ~= 16 then return false end + for c = 1, 16 do + local rgb = palette[c] + if not dense(rgb, 3) or #rgb ~= 3 then return false end + local r, g, b = packed:byte(at, at + 2) + if rgb[1] ~= r or rgb[2] ~= g or rgb[3] ~= b then return false end + at = at + 3 + end + end + end + return true +end + +-- Missing metadata is unknown, not evidence that an old cache is static. +function M.sample(pair, layout, ticks) + if not integer(ticks, 0, 9007199254740991) then return nil, "terrain clock unavailable" end + local split = type(layout) == "table" and layout.palettesInPrimary + if not integer(split, 1, 15) then return nil, "palette animation data unavailable" end + local runs, bytes = 0, 0 + for _, name in ipairs(names) do + local bank = pair[name] + local list = type(bank) == "table" and getmetatable(bank) == nil and bank.paletteCycles + if not dense(list, 32) then return nil, "palette animation data unavailable" end + local lo, hi = name == "primary" and 0 or split * 16, name == "primary" and split * 16 or 256 + for _, a in ipairs(list) do + runs = runs + 1 + if runs > 32 or type(a) ~= "table" or getmetatable(a) ~= nil + or not integer(a.first, lo, hi - 1) or not integer(a.count, 1, hi - lo) + or a.first + a.count > hi or not integer(a.step, 1, 65535) + or (a.phase ~= nil and not integer(a.phase, 0, 65535)) then + return nil, "palette animation data unavailable" + end + if a.kind == "frames" then + if not dense(a.frames, 256) or #a.frames < 2 then return nil, "palette animation data unavailable" end + for _, f in ipairs(a.frames) do + if type(f) ~= "string" or #f ~= a.count * 3 then return nil, "palette animation data unavailable" end + bytes = bytes + #f + if bytes > 262144 then return nil, "palette animation data unavailable" end + end + elseif a.kind ~= "rotate" or a.count < 2 or (a.direction ~= 1 and a.direction ~= -1) then + return nil, "palette animation data unavailable" + end + end + end + local out = { ticks = ticks, primary = {}, secondary = {}, complete = true } + local key = { "gen3-palette-v1" } + for _, name in ipairs(names) do + local src, dst = pair[name], out[name] + if not dense(src.palettes, 16) or #src.palettes ~= 16 then return nil, "palette animation data unavailable" end + for p = 1, 16 do + local palette = src.palettes[p] + if not dense(palette, 16) or #palette ~= 16 then return nil, "palette animation data unavailable" end + dst[p] = {} + for c = 1, 16 do + local rgb = palette[c] + if not dense(rgb, 3) or #rgb ~= 3 then return nil, "palette animation data unavailable" end + dst[p][c] = {} + for channel = 1, 3 do + if not integer(rgb[channel], 0, 255) then return nil, "palette animation data unavailable" end + dst[p][c][channel] = rgb[channel] + end + end + end + local selected = {} + for i, a in ipairs(src.paletteCycles) do + -- Reduce before adding phase: ticks + phase could exceed exact doubles. + local length = a.kind == "frames" and #a.frames or a.count + local period = length * a.step + local f = math.floor(((ticks % period) + (a.phase or 0)) % period / a.step) + selected[i] = f + local frame = a.kind == "frames" and a.frames[f + 1] + for c = 0, a.count - 1 do + local at = a.first + c + local rgb + if frame then + rgb = { frame:byte(c * 3 + 1, c * 3 + 3) } + else + local from = a.first + (c - a.direction * f) % a.count + local source = src.palettes[math.floor(from / 16) + 1][from % 16 + 1] + rgb = { source[1], source[2], source[3] } + end + dst[math.floor(at / 16) + 1][at % 16 + 1] = rgb + end + end + out[name .. "Frames"] = selected + local packed = {} + for _, p in ipairs(dst) do + for _, rgb in ipairs(p) do packed[#packed + 1] = string.char(rgb[1], rgb[2], rgb[3]) end + end + out[name .. "RGB8"] = table.concat(packed) + key[#key + 1] = out[name .. "RGB8"] + end + out.contentKey = table.concat(key) + return out +end +return M diff --git a/src/render/Gen3PaletteFrameCache.lua b/src/render/Gen3PaletteFrameCache.lua new file mode 100644 index 000000000..6daf84939 --- /dev/null +++ b/src/render/Gen3PaletteFrameCache.lua @@ -0,0 +1,56 @@ +-- CPU atlas pictures only. Owners keep their existing GPU textures/quads. +-- One global byte budget and a per-owner entry limit bound arbitrary cycles. +local M = {} +function M.release(surfaces) + for _,data in ipairs(surfaces) do if data.release then data:release() end end +end +function M.new(budget,perOwner) + local owners,bytes,count,clock,hits,misses={},0,0,0,0,0,0 + local cache={} + local function remove(owner,key) + local list=owners[owner];local entry=list and list[key] + if not entry then return end + list[key]=nil;bytes=bytes-entry.bytes;count=count-1 + M.release(entry.surfaces) + if next(list)==nil then owners[owner]=nil end + end + local function oldest(only) + local owner,key,at + for candidate,list in pairs(owners) do + if not only or candidate==only then + for k,v in pairs(list) do + if not at or v.usedbudget then return false end + remove(owner,key) + local n=0;for _ in pairs(owners[owner] or {}) do n=n+1 end + if n>=perOwner then local o,k=oldest(owner);remove(o,k) end + while bytes+size>budget do local o,k=oldest();remove(o,k) end + clock=clock+1;owners[owner]=owners[owner] or {} + owners[owner][key]={surfaces=surfaces,bytes=size,used=clock} + bytes=bytes+size;count=count+1 + return true + end + function cache:drop(owner) + while owners[owner] do local key=next(owners[owner]);remove(owner,key) end + end + function cache:clear() + while next(owners) do self:drop(next(owners)) end + end + function cache:stats()return {bytes=bytes,entries=count,hits=hits,misses=misses, + budget=budget,perOwner=perOwner} end + return cache +end +return M diff --git a/src/render/Gen3PaletteSamples.lua b/src/render/Gen3PaletteSamples.lua new file mode 100644 index 000000000..6066e0d17 --- /dev/null +++ b/src/render/Gen3PaletteSamples.lua @@ -0,0 +1,127 @@ +-- Native-only palette samples. The public sampler remains detached and pure. +-- Each decoder retains at most four owned pictures; mutable source tables are +-- checked by value before reuse, never merely by identity or current RGB output. +local Cycles = require("src.render.Gen3PaletteCycles") +local M = {} +M.__index = M +local banks = {"primary", "secondary"} +local fields = {"kind", "first", "count", "step", "phase"} +local function dense(t, size) + if type(t) ~= "table" or getmetatable(t) ~= nil or #t ~= size then return false end + local n = 0 + for k in pairs(t) do + if type(k) ~= "number" or k ~= math.floor(k) or k < 1 or k > size then return false end + n = n + 1 + end + return n == size +end + +-- Called only after the full sampler has validated the complete source. +local function snapshot(pair, layout) + local out = {split = layout.palettesInPrimary} + for _, name in ipairs(banks) do + local src, bytes, programs = pair[name], {}, {} + for _, p in ipairs(src.palettes) do + for _, rgb in ipairs(p) do bytes[#bytes + 1] = string.char(rgb[1], rgb[2], rgb[3]) end + end + for i, rule in ipairs(src.paletteCycles) do + local copy = {} + for _, key in ipairs(fields) do copy[key] = rule[key] end + if rule.kind == "frames" then + copy.frames = {} + for j, frame in ipairs(rule.frames) do copy.frames[j] = frame end + else + copy.direction = rule.direction + end + programs[i] = copy + end + out[name] = {rgb = table.concat(bytes), programs = programs} + end + return out +end + +local function unchanged(pair, layout, source) + if layout.palettesInPrimary ~= source.split then return false end + for _, name in ipairs(banks) do + local bank, old = pair[name], source[name] + if type(bank) ~= "table" or getmetatable(bank) ~= nil + or not dense(bank.paletteCycles, #old.programs) + or not dense(bank.palettes, 16) then return false end + for i, rule in ipairs(bank.paletteCycles) do + local saved = old.programs[i] + if type(rule) ~= "table" or getmetatable(rule) ~= nil then return false end + for _, key in ipairs(fields) do if rule[key] ~= saved[key] then return false end end + if saved.frames then + if not dense(rule.frames, #saved.frames) then return false end + for j, bytes in ipairs(saved.frames) do if rule.frames[j] ~= bytes then return false end end + elseif rule.direction ~= saved.direction then + return false + end + end + local at = 1 + for p = 1, 16 do + local palette = bank.palettes[p] + if not dense(palette, 16) then return false end + for c = 1, 16 do + local rgb = palette[c] + if not dense(rgb, 3) then return false end + local r, g, b = old.rgb:byte(at, at + 2) + if rgb[1] ~= r or rgb[2] ~= g or rgb[3] ~= b then return false end + at = at + 3 + end + end + end + return true +end + +local function selected(sample, source, ticks) + for _, name in ipairs(banks) do + local indices = sample[name .. "Frames"] + for i, rule in ipairs(source[name].programs) do + local length = rule.kind == "frames" and #rule.frames or rule.count + local period = length * rule.step + local index = math.floor(((ticks % period) + (rule.phase or 0)) % period / rule.step) + if indices[i] ~= index then return false end + end + end + return true +end + +function M.new(pair, layout, first) + return setmetatable({source = snapshot(pair, layout), pictures = {first}, + hits = 0, misses = 1, invalidations = 0}, M) +end + +function M:sample(pair, layout, ticks) + if type(ticks) ~= "number" or ticks ~= math.floor(ticks) or ticks < 0 or ticks > 9007199254740991 then + self.source, self.pictures = nil, {} + return nil, "terrain clock unavailable" + end + local same = self.source and unchanged(pair, layout, self.source) + if same then + for i = #self.pictures, 1, -1 do + local hit = self.pictures[i] + if selected(hit, self.source, ticks) then + table.remove(self.pictures, i);self.pictures[#self.pictures + 1] = hit + hit.ticks = ticks;self.hits = self.hits + 1 + return hit + end + end + else + self.source, self.pictures = nil, {} + self.invalidations = self.invalidations + 1 + end + self.misses = self.misses + 1 + local sample, reason = Cycles.sample(pair, layout, ticks) + if not sample then self.source, self.pictures = nil, {};return nil, reason end + if not self.source then self.source = snapshot(pair, layout) end + self.pictures[#self.pictures + 1] = sample + if #self.pictures > 4 then table.remove(self.pictures, 1) end + return sample +end + +function M:stats() + return {entries = #self.pictures, hits = self.hits, misses = self.misses, + invalidations = self.invalidations, limit = 4} +end +return M diff --git a/src/render/Gen3Tiles.lua b/src/render/Gen3Tiles.lua index d1e3a9ec2..b84bb1e54 100644 --- a/src/render/Gen3Tiles.lua +++ b/src/render/Gen3Tiles.lua @@ -119,11 +119,37 @@ function Gen3Tiles:tilePixels(index) end function Gen3Tiles:palette(index) + if self.paletteState then + local name = index < self.palettesInPrimary and "primary" or "secondary" + return self.paletteState[name][index + 1] + end local record = self:paletteBank(index) local pals = record and record.palettes return pals and pals[index + 1] or nil end +-- Independent palette cadence; does not alter the tile animation picture. +-- Missing legacy metadata retains the original base-palette rendering. +function Gen3Tiles:setPaletteTicks(ticks) + local cycles = require("src.render.Gen3PaletteCycles") + if cycles.staticUnchanged(self, self, ticks, self.paletteState) then + self.paletteState.ticks = ticks + return false + end + local state, reason + if self.paletteSampler then + state, reason = self.paletteSampler:sample(self, self, ticks) + else + state, reason = cycles.sample(self, self, ticks) + if state and (#state.primaryFrames > 0 or #state.secondaryFrames > 0) then + self.paletteSampler = require("src.render.Gen3PaletteSamples").new(self, self, state) + end + end + local changed = (state and state.contentKey) ~= (self.paletteState and self.paletteState.contentKey) + self.paletteState = state + return changed, reason +end + -- The colour the backdrop shows where nothing is drawn: palette 0, colour 0. function Gen3Tiles:backdrop() local p = self:palette(0) @@ -161,6 +187,22 @@ function Gen3Tiles:attributes(metatile) return word % 256, math.floor(word / 4096) % 16 end +-- Original cartridge behavior, distinct from attributes()' native numbering. +-- Old normalized-only records cannot prove this value and must not guess it. +function Gen3Tiles:sourceBehavior(metatile) + if type(metatile) ~= "number" or metatile < 0 or metatile % 1 ~= 0 then return nil end + if metatile >= self.metatilesInPrimary and not self.secondary then return nil end + local record, index = self:metatileBank(metatile) + local stride = record and record.sourceAttributeBytes + local mask = record and record.sourceBehaviorMask + if (stride ~= 2 and stride ~= 4) or type(mask) ~= "number" + or (mask ~= 255 and mask ~= 511) then return nil end + local data = stride == 4 and record.sourceAttributes or record.attributes + local base = index * stride + if type(data) ~= "string" or base + stride > #data then return nil end + return (byte(data, base + 1) + byte(data, base + 2) * 256) % (mask + 1) +end + -- Draw one metatile's layer into `plot(x, y, r, g, b)` at (ox, oy). Layer 1 -- is what the player walks in front of and layer 2 what they walk behind, so -- the caller draws them either side of the sprites rather than flattening the @@ -298,6 +340,34 @@ function Gen3Tiles:setAnimFrame(f) self.tileCache = {} end +-- Observe independent VRAM animation clocks without pre-baking a combined +-- cycle. A projection can keep fixed atlas slots even when animation periods +-- have a large LCM. Only changed tile decodes are invalidated; native shared +-- bank bytes and the renderer's legacy frame-selection API stay untouched. +function Gen3Tiles:setAnimationTicks(ticks) + assert(type(ticks) == "number" and ticks >= 0 and ticks % 1 == 0 + and ticks <= 9007199254740991, "invalid terrain animation ticks") + local nextOverrides = {} + for _, a in ipairs(self:animations()) do + local frame = a.frames[(math.floor(ticks / math.max(1, a.step)) % #a.frames) + 1] + if type(frame) == "string" then + for k = 0, a.count - 1 do + local at = k * 32 + if at + 32 <= #frame then nextOverrides[a.tile + k] = frame:sub(at + 1, at + 32) end + end + end + end + local previous, dirty = self.tileOverride or {}, {} + for tile, bytes in pairs(nextOverrides) do + if previous[tile] ~= bytes then dirty[tile] = true; self.tileCache[tile] = nil end + end + for tile in pairs(previous) do + if nextOverrides[tile] == nil then dirty[tile] = true; self.tileCache[tile] = nil end + end + self.tileOverride, self.animationTicks = nextOverrides, ticks + return dirty +end + -- Which metatiles change when the frame does -- the only ones a sheet has to -- hold more than one picture of. function Gen3Tiles:animatedMetatiles() diff --git a/src/render/GraphicsScope.lua b/src/render/GraphicsScope.lua new file mode 100644 index 000000000..396978870 --- /dev/null +++ b/src/render/GraphicsScope.lua @@ -0,0 +1,56 @@ +-- Fence a native render seam, including leaked pushes and canvas changes. +local S = {} +local current +-- Opaque lifetime for paired helpers inside a guarded render callback. +function S.token() return current end +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 parent = current + current = {} + local out = pack(pcall(fn, ...)) + current = parent + 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/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..731d74675 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -20,6 +20,7 @@ -- (data/palettes_yellow.lua / ROM cgbBase). local GameVersion = require("src.core.GameVersion") +local HotModules = require("src.core.HotModules") local PaletteFX = {} @@ -309,7 +310,7 @@ end -- it), so the zone now covers it whatever generation is running. On a Game -- Boy dataset those are 160x144 and this is the same rect it always was. function PaletteFX.whole(colors) - local ok, Renderer = pcall(require, "src.render.Renderer") + local ok, Renderer = HotModules.optional("src.render.Renderer") local w = ok and Renderer and (Renderer.uiWidth or Renderer.WIDTH) or 160 local h = ok and Renderer and (Renderer.uiHeight or Renderer.HEIGHT) or 144 -- zone() takes INCLUSIVE tile bounds, so the last tile index is size/8 - 1; diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 4d8143548..da7b8a382 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -6,12 +6,15 @@ -- neighbor filtering. -- Spec: docs/new-features.md (survey zoom) +local FrameProfile = require("src.core.FrameProfile") local Zoom = require("src.render.Zoom") local Tilt = require("src.render.Tilt") 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 Performance = require("src.core.Performance") +local HotModules = require("src.core.HotModules") -- RESOLVED ONCE, NOT EVERY FRAME. These three were `require`d from inside -- beginFrame and endFrame -- three string hashes into package.loaded on every @@ -291,7 +294,7 @@ function Renderer:worldViewSize() -- Gen 1-3 render native border tiles beyond the map. Capping their view -- here and enlarging it during presentation silently changes the user's -- zoom on every building/route transition and makes small-map zoom inert. - if bw and bh and require("src.core.GameVersion").isGen4() then + if bw and bh and HotModules.get("src.core.GameVersion").isGen4() then local uw, uh = self:uiSize() -- Never below the screen the generation was drawn for, so a map at least -- that big comes out exactly the size it did before. @@ -312,7 +315,7 @@ end -- keep the projected ground plane covering the receded corners; filling with -- it would undo the growth it just asked for. function Renderer:worldPresentationScale(sp, pw, ph, vw, vh) - if self.worldBoundsW and require("src.core.GameVersion").isGen4() and not Tilt.active() then + if self.worldBoundsW and HotModules.get("src.core.GameVersion").isGen4() and not Tilt.active() then return math.max(sp, pw / vw, ph / vh) end return sp @@ -320,6 +323,7 @@ end -- transparent: the world pass shows through (UI pass draws overlays only) function Renderer:beginFrame(transparent) + self.frameSerial=(self.frameSerial or 0)+1 -- a frame that ends early -- a state that returns before endFrame, an error -- caught upstream -- must not leak a queued native-resolution draw into the -- next frame's composite @@ -344,6 +348,7 @@ function Renderer:beginFrame(transparent) -- fade in from white after a battle) -- covers the window, not just the -- 160x144 letterbox self.screenVeil = nil + self.nativeTransitionVeil = nil -- edge-anchored UI regions, re-declared by their elements each frame self.uiAnchors = nil -- last frame's trueColor rects and sprite redraws go before anything @@ -416,7 +421,7 @@ function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy, source) -- required here rather than at the top: BattleTransition reaches the -- renderer through game.renderer at draw time, and a module-level require -- would put the two files in a load cycle - local BattleTransition = require("src.render.BattleTransition") + local BattleTransition = HotModules.get("src.render.BattleTransition") local order = BattleTransition.gridOrder(wipe.style, cols, rows) if order then @@ -686,11 +691,13 @@ end -- render.compose mod can composite the world/UI canvases into its own layout. function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) + local closeBlit = FrameProfile.section(" renderer: zone blit") local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil if not shader then love.graphics.setScissor(boxX, boxY, boxW, boxH) love.graphics.draw(canvas, bx, by, 0, sx, sy) love.graphics.setScissor() + closeBlit() return end love.graphics.setShader(shader) @@ -713,6 +720,7 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, end love.graphics.setScissor() love.graphics.setShader() + closeBlit() end -- THE BARS, FILLED WITH THE EDGE THEY FRAME. @@ -844,7 +852,34 @@ end -- scissored through the shade-remap shader, later zones on top. -- 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) +-- Native blits keep the frame's DPI in explicit arguments, including nested +-- drawing. The public blitCanvas seam retains its original signature/dispatch. +function Renderer:_blitAtDpi(dpiX, dpiY, canvas, sx, sy, zoneList, zoneSx, zoneSy, + bx, by, boxX, boxY, boxW, boxH) + return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, + bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) +end + +local _viewportScratch = {} +local _uiPresentationOwners = setmetatable({}, {__mode="k"}) +local function viewportResult(private, ww, wh, ox, oy, vpw, vph, scale, dpiX, dpiY) + local out = private and _viewportScratch or {} + out.width, out.height, out.gameX, out.gameY = ww, wh, ox, oy + out.gameWidth, out.gameHeight, out.scale = vpw, vph, scale + out.dpiX, out.dpiY = dpiX, dpiY + return out +end +local function uiPresentation(self, x, y, w, h, sx, sy) + local out = _uiPresentationOwners[self] + if not Performance.hotPathHoist or not out or self.uiPresentation ~= out then + out = {} + _uiPresentationOwners[self] = out + end + out.x, out.y, out.w, out.h, out.scaleX, out.scaleY = x, y, w, h, sx, sy + self.uiPresentation = out +end + +local function endFrame(self, zones, worldZones, privateViewport) love.graphics.setCanvas() local ww, wh, pw, ph, dpiX, dpiY = displayMetrics() -- Sp = integer framebuffer pixels per GB pixel; @@ -873,7 +908,7 @@ function Renderer:endFrame(zones, worldZones) local uvpw, uvph = uiw * Ux, uih * Uy local uox = math.floor((pw - uiw * Up) / 2) / dpiX local uoy = math.floor((ph - uih * Up) / 2) / dpiY - self.uiPresentation = {x=uox,y=uoy,w=uvpw,h=uvph,scaleX=Ux,scaleY=Uy} + uiPresentation(self, uox, uoy, uvpw, uvph, Ux, Uy) local GBCFX = gbcFX() -- Forced mono/Classic modes still need a whole-screen zone when a state -- exposes no SGB packets (raw DMG canvas), so sendColors can remap. @@ -913,14 +948,14 @@ function Renderer:endFrame(zones, worldZones) -- took its Gen 1 branch, with Gen 1's tile geometry, over Gen 3 -- data. GameVersion.generation() is the same answer for Gen 1 and -- Gen 2 and the right one for Gen 3. - generation = require("src.core.GameVersion").generation(), + generation = HotModules.get("src.core.GameVersion").generation(), worldOverride = self.worldOverride, worldActive = self.worldActive and true or false, zones = zones, worldZones = worldZones, ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy, vpw = vpw, vph = vph, uiw = uiw, uih = uih, scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY, - secondScreen = require("src.render.SecondScreen"), + secondScreen = HotModules.get("src.render.SecondScreen"), } if Runtime.call("render.compose", function() return false end, self, ctx) == true then self.worldActive = false @@ -928,10 +963,7 @@ function Renderer:endFrame(zones, worldZones) self.uiOverride = nil self.worldOverride = nil PaletteFX.setPass(nil) - return { - width = ww, height = wh, gameX = ox, gameY = oy, - gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY, - } + return viewportResult(privateViewport, ww, wh, ox, oy, vpw, vph, Sp, dpiX, dpiY) end end @@ -990,7 +1022,7 @@ function Renderer:endFrame(zones, worldZones) -- a visibly brighter border. local clearR, clearG, clearB = 0, 0, 0 if not self.worldActive then - local ok, Game = pcall(require, "src.core.Game") + local ok, Game = HotModules.optional("src.core.Game") local stack = ok and Game and Game.stack local base = stack and stack.visibleBase and stack:visibleBase() local state = base and stack.states and stack.states[base] @@ -1033,10 +1065,18 @@ function Renderer:endFrame(zones, worldZones) -- see Renderer:blitCanvas; bound here to the frame's dpi so the composite -- call sites below stay unchanged. - local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy, - bx, by, boxX, boxY, boxW, boxH) - return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, - bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) + local blit = self._blitAtDpi + if not Performance.hotPathHoist then + blit = function(_self, _dx, _dy, canvas, sx, sy, zoneList, zoneSx, zoneSy, + bx, by, boxX, boxY, boxW, boxH) + return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, + bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY) + end + end + + local compatibleTransition=package.loaded['src.core.game3.battle_transition'] + if compatibleTransition and compatibleTransition._compositeWorld then + compatibleTransition._compositeWorld(self) end -- A PUBLISHER MAY HAVE FREED ITS CANVAS SINCE IT HANDED IT OVER. @@ -1100,7 +1140,7 @@ function Renderer:endFrame(zones, worldZones) -- iOS flip a second time turns Platinum upside down while its UI remains -- upright. local loveMajor = love.getVersion() - local gen = require("src.core.GameVersion").generation() + local gen = HotModules.get("src.core.GameVersion").generation() if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 @@ -1148,13 +1188,13 @@ function Renderer:endFrame(zones, worldZones) if worldZones then zl, zsx, zsy = worldZones, sx, sy end wobbleBands(woy, 10 * sy, wh, function(dir, y, h) local top = math.max(0, y) - blit(self.worldCanvas, sx, sy, zl, zsx, zsy, wox + dir * wobble * sx, woy, + blit(self, dpiX, dpiY, self.worldCanvas, sx, sy, zl, zsx, zsy, wox + dir * wobble * sx, woy, 0, top, ww, h - (top - y)) end) elseif worldZones then - blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh) + blit(self, dpiX, dpiY, self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh) else - blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh) + blit(self, dpiX, dpiY, self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh) end -- OBP-baked overworld sprites replay on top of the zone pass (GBC -- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass @@ -1271,7 +1311,7 @@ function Renderer:endFrame(zones, worldZones) -- it wants the whole window rather than a fixed box in the middle of one. -- The bag, the party menu, the mart, the Pokenav, the title and the battle -- all say so; the overworld does not, so the map is still never bled. - local okGame, GameMod = pcall(require, "src.core.Game") + local okGame, GameMod = HotModules.optional("src.core.Game") local fills = okGame and GameMod and GameMod.fillScaleInStack(GameMod.stack) or false -- ...AND NOT A BATTLE. @@ -1312,12 +1352,12 @@ function Renderer:endFrame(zones, worldZones) and not GameMod.edgeBleedAllowedInStack(GameMod.stack) then fills = false end - if fills and require("src.core.GameVersion").isGen3() then + if fills and HotModules.get("src.core.GameVersion").isGen3() then self:bleedEdges(self.canvas, Ux, Uy, uox, uoy, ww, wh) end local uiSource=self.uiOverride or self.canvas local uiFactor=uiSource:getWidth()/uiw - blit(uiSource, Ux/uiFactor, Uy/uiFactor, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph) + blit(self, dpiX, dpiY, uiSource, Ux/uiFactor, Uy/uiFactor, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph) else local rest = { { uox, uoy, uvpw, uvph } } local placed = {} @@ -1339,7 +1379,7 @@ function Renderer:endFrame(zones, worldZones) -- desktop it IS the window: love.window.getSafeArea is absent or returns -- the full surface, so rect() answers 0, 0, ww, wh and every number below -- is what it has always been. Which is exactly why only mobile saw this. - local sax, say, saw, sah = require("src.core.SafeArea").rect() + local sax, say, saw, sah = HotModules.get("src.core.SafeArea").rect() if self.splitViewport then saw=math.max(0,math.min(saw,ww-sax));sah=math.max(0,math.min(sah,wh-say)) end @@ -1367,13 +1407,13 @@ function Renderer:endFrame(zones, worldZones) rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh) end for _, r in ipairs(rest) do - blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4]) + blit(self, dpiX, dpiY, self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4]) end for _, p in ipairs(placed) do -- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy). -- The zone scissors are computed from the same origin, so an SGB -- region travels with the element instead of staying in the letterbox. - blit(self.canvas, Ux, Uy, zones, Ux, Uy, + blit(self, dpiX, dpiY, self.canvas, Ux, Uy, zones, Ux, Uy, p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh) end end @@ -1383,7 +1423,7 @@ function Renderer:endFrame(zones, worldZones) -- origin and scale it used, so a queued rect covers exactly the pixels it -- replaces -- and UNDER the wipe and veil below, which are screen-wide -- effects that on hardware nothing escapes. - require("src.render.NativeOverlay").flush(uox, uoy, Ux, Uy) + HotModules.get("src.render.NativeOverlay").flush(uox, uoy, Ux, Uy) -- The battle wipe covers the whole surface, letterbox included, so it goes -- over the finished composite rather than under the UI blit. On hardware @@ -1431,6 +1471,7 @@ function Renderer:endFrame(zones, worldZones) end if present then + local closePresent = FrameProfile.section(" renderer: present pipeline") love.graphics.setCanvas() -- 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 @@ -1448,19 +1489,25 @@ function Renderer:endFrame(zones, worldZones) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(composed, 0, 0) end + closePresent() end self.worldActive = false self.uprightActive = false self.uiOverride = nil self.worldOverride = nil PaletteFX.setPass(nil) - return { - width = ww, height = wh, - gameX = ox, gameY = oy, - gameWidth = vpw, gameHeight = vph, - scale = Sp, - dpiX = dpiX, dpiY = dpiY, - } + return viewportResult(privateViewport, ww, wh, ox, oy, vpw, vph, Sp, dpiX, dpiY) end +-- Public callers (including wrappers installed by mods) always receive a new +-- record. Only the unwrapped native path may borrow the private result. +function Renderer:endFrame(zones, worldZones) + return endFrame(self, zones, worldZones, false) +end +Renderer._nativeEndFrame = Renderer.endFrame +function Renderer:_endFrameForEngine(zones, worldZones) + return endFrame(self, zones, worldZones, Performance.hotPathHoist) +end + + return Renderer diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 07f605295..8e8e81bf1 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -420,7 +420,66 @@ 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 + +-- Reveal only the current native page; no input, sound or clock is pumped. +function TextBox:revealPage() + while not self.waiting and not self.done do + local line=self.shown[#self.shown] + for i=self.charIndex+1,#self.codes do line[#line+1]=self.codes[i]end + self.charIndex=#self.codes + local page=self.pages[self.pageIndex] + if self.lineIndex<#page then + local nextIdx=self.lineIndex+1 + local conts=self.pages.contBefore and self.pages.contBefore[self.pageIndex] + if conts and conts[nextIdx]then self.waiting=true;self.contAdvance=true + else self.lineIndex=nextIdx;self:beginLine()end + elseif self.pageIndex<#self.pages then self.waiting=true;self.contAdvance=false + else self.done=true end + end +end function TextBox:update(dt) + local source=package.loaded['src.ui.game3.message'] + if require('src.core.GameVersion').isGen3()and source then return source._update(self,function()return self:_updateBody(dt)end)end + return self:_updateBody(dt) +end +function TextBox:_updateBody(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 -- A page or CONT advance blocks the whole box while the original's scroll @@ -519,9 +578,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 +591,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 @@ -618,6 +657,11 @@ function TextBox:uiSize() end function TextBox:draw() + local source=package.loaded['src.ui.game3.message'] + if require('src.core.GameVersion').isGen3()and source then return source._draw(self,function()return self:_drawBody()end)end + return self:_drawBody() +end +function TextBox:_drawBody() -- The dialogue box belongs against the bottom of the screen, not floating -- in the middle of a zoomed-out letterbox. Declared per frame; the -- renderer blits this region to the screen edge and the rest of the UI diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 5f7a007a5..c06d6c0a0 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -8,6 +8,7 @@ -- a single static SpriteBatch covering the map plus a border-block ring -- (the ring plays the role of the GB border blocks around small maps). +local FrameProfile = require("src.core.FrameProfile") local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Probe = require("src.core.Probe") @@ -999,6 +1000,16 @@ local gen3Pins={} function TileRenderer.pinGen3Sheets(protected) gen3Pins=protected or {} end +local PaletteFrameCache = require("src.render.Gen3PaletteFrameCache") +local paletteFrames = PaletteFrameCache.new(32*1024*1024,4) +function TileRenderer.gen3PaletteCacheStats() return paletteFrames:stats() end + +local function palettePictureKey(tiles) + local state=tiles.paletteState + if state and (#state.primaryFrames>0 or #state.secondaryFrames>0) then + return state.contentKey + end +end local function gen3Evict() local count = 0 @@ -1019,7 +1030,8 @@ local function gen3Evict() end if not oldest then return end local record = gen3Sheets[oldest] - for _, image in ipairs({ record.bottom, record.top }) do + paletteFrames:drop(record) + for _, image in ipairs({ record.bottom, record.top, record.cover }) do if image and image.release then pcall(image.release, image) end end gen3Sheets[oldest] = nil @@ -1107,7 +1119,7 @@ local function pixelCanvas(w, h) return plot, finish end -local function buildReflectionCover(record, tiles, statics, frames) +local function bakeReflectionCover(record, tiles, statics, frames) if not (love.image and love.image.newImageData) then return end frames = math.max(1, frames or 1) local ids = {} @@ -1124,7 +1136,7 @@ local function buildReflectionCover(record, tiles, statics, frames) local w = cols * 16 local h = math.ceil(#ids * frames / cols) * 16 local plot, finish = pixelCanvas(w, h) - local quads = {} + local quads = record.coverQuads or {} for f = 0, frames - 1 do tiles:setAnimFrame(f) for i, id in ipairs(ids) do @@ -1132,13 +1144,16 @@ local function buildReflectionCover(record, tiles, statics, frames) local ox, oy = (slot % cols) * 16, math.floor(slot / cols) * 16 tiles:drawLayer(id, 2, ox, oy, plot) quads[id] = quads[id] or {} - quads[id][f + 1] = love.graphics.newQuad(ox, oy, 16, 16, w, h) + if not quads[id][f + 1] then + quads[id][f + 1] = love.graphics.newQuad(ox, oy, 16, 16, w, h) + end end end tiles:setAnimFrame(0) - record.cover = love.graphics.newImage(finish()) - record.coverQuads = quads + local surface = finish() + record.coverQuads = record.coverQuads or quads record.coverFrames = frames + return surface,w*h*4 end function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) @@ -1164,6 +1179,10 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) primary = primary, secondary = tilesetDef.secondaryKey and store[tilesetDef.secondaryKey] or nil, }, layout) + -- Legacy records have no paletteCycles: their original bake stays intact. + if primary.paletteCycles ~= nil or (tiles.secondary and tiles.secondary.paletteCycles ~= nil) then + tiles:setPaletteTicks(animFrame) + end -- ---- WHAT MOVES, AND WHERE ITS OTHER PICTURES GO ------------------------ -- @@ -1198,7 +1217,7 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) end end - local built = {} + local built,initialSurfaces = {},{} for layer = 1, 2 do local plot, finish = pixelCanvas(w, h) -- the static sheet holds FRAME ZERO, not the tileset's shipped bytes: the @@ -1212,7 +1231,8 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) tiles:bakeMetatileInto(id, layer, slotOf[id][f], plot) end end - built[layer] = love.graphics.newImage(finish()) + initialSurfaces[layer]=finish() + built[layer] = love.graphics.newImage(initialSurfaces[layer]) end tiles:setAnimFrame(0) @@ -1220,7 +1240,54 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) tiles = tiles, metatiles = statics, slots = slots, animSlots = (frames > 1) and slotOf or nil, animFrames = frames, animStep = tiles:animStep() } - buildReflectionCover(record, tiles, statics, frames) + local coverData,coverBytes=bakeReflectionCover(record, tiles, statics, frames) + if coverData then record.cover=love.graphics.newImage(coverData) end + initialSurfaces[3]=coverData + local pictureBytes=w*h*8+(coverBytes or 0) + local initialKey=palettePictureKey(tiles) + if not initialKey or not paletteFrames:put(record,initialKey,initialSurfaces,pictureBytes) then + PaletteFrameCache.release(initialSurfaces) + end + -- Update the existing textures, including every tile-animation variant and + -- reflection cover, only when palette pixels change. Batches/quads retain + -- their image identity; independent cycles never multiply the baked atlas. + record.recolor = function() + local oldFrame = tiles.animFrame or 0 + local pictureKey=palettePictureKey(tiles) + if not pictureKey then paletteFrames:drop(record) end + local hit=pictureKey and paletteFrames:get(record,pictureKey) + if hit then + record.bottom:replacePixels(hit[1]);record.top:replacePixels(hit[2]) + if hit[3] then record.cover:replacePixels(hit[3]) end + return + end + local surfaces,retained={},false + local ok,err=xpcall(function() + for layer, image in ipairs({ record.bottom, record.top }) do + local plot, finish = pixelCanvas(w, h) + tiles:setAnimFrame(0) + tiles:bakeLayer(layer, plot) + for f = 2, frames do + tiles:setAnimFrame(f - 1) + for _, id in ipairs(movers) do + tiles:bakeMetatileInto(id, layer, slotOf[id][f], plot) + end + end + surfaces[layer]=finish() + image:replacePixels(surfaces[layer]) + end + local coverSurface,size=bakeReflectionCover(record, tiles, statics, frames) + surfaces[3]=coverSurface + if coverSurface then record.cover:replacePixels(coverSurface) end + if pictureKey then + retained=paletteFrames:put(record,pictureKey,surfaces,w*h*8+(size or 0)) + end + end,debug.traceback) + tiles:setAnimFrame(oldFrame) + if not retained then PaletteFrameCache.release(surfaces) end + if not ok then error(err,0) end + end + record.paletteTicks = animFrame gen3Sheets[key] = record gen3Evict() Probe.say("tilebake", "%s: movers=%d frames=%d step=%s primaryAnims=%s " @@ -1262,8 +1329,24 @@ function TileRenderer.gen3SheetsFor(tilesetDef, data, layout) return record end +function TileRenderer:syncGen3Palettes() + local record = self.gen3 + if not record or record.paletteTicks == animFrame then return end + record.paletteTicks = animFrame + local tiles = record.tiles + if not tiles or (tiles.primary.paletteCycles == nil + and (not tiles.secondary or tiles.secondary.paletteCycles == nil) + and not tiles.paletteState) then return end + local changed = tiles:setPaletteTicks(animFrame) + if changed and record.recolor then + record.recolor() + record.paletteRevision = (record.paletteRevision or 0) + 1 + end +end + -- a re-import replaces the data the sheets were baked from function TileRenderer.releaseGen3Sheets() + paletteFrames:clear() gen3Sheets = {} gen3Used, gen3Clock = {}, 0 gen3Pins={} @@ -1553,7 +1636,8 @@ local function bakeGen3BorderFill(self) if not (self.gen3 and self.gen3Quads) then return end local border = self.map.def and self.map.def.border if type(border) ~= "string" or #border < 8 then return end - local canvas = require("src.render.PixelCanvas").new(32, 32) + local canvas = self.gen3BorderCanvas or require("src.render.PixelCanvas").new(32, 32) + self.gen3BorderCanvas = canvas love.graphics.push("all") love.graphics.setCanvas(canvas) -- black, not white: a patch with a transparent metatile in it is a hole @@ -1572,7 +1656,9 @@ local function bakeGen3BorderFill(self) end love.graphics.setCanvas() love.graphics.pop() - local img = love.graphics.newImage(canvas:newImageData()) + local img = self.borderFill + if img then img:replacePixels(canvas:newImageData()) + else img = love.graphics.newImage(canvas:newImageData()) end img:setWrap("repeat", "repeat") img:setFilter("nearest", "nearest") self.borderFill = img @@ -1607,6 +1693,7 @@ end -- choice changes. OVERWORLD "black" leaves borderFill nil and draws a -- solid clear; "water" keeps the live hshift textures instead of a bake. function TileRenderer:ensureBorderFill() + self:syncGen3Palettes() local block = borderBlockFor(self.map) -- a Gen 3 map paints its void from its OWN border patch, so it is always -- "map" -- the VOID FILL option picks between three Gen 1 tileset blocks @@ -1619,7 +1706,13 @@ function TileRenderer:ensureBorderFill() local ready = (mode == "black") or (mode == "water" and self.borderWaterTextures) or (mode ~= "water" and mode ~= "black" and self.borderFill) - if self.borderFillMode == mode and ready then return end + if self.borderFillMode == mode and ready then + if self.gen3 and self.borderPaletteRevision ~= (self.gen3.paletteRevision or 0) then + local ok = pcall(bakeGen3BorderFill, self) + if ok then self.borderPaletteRevision = self.gen3.paletteRevision or 0 end + end + return + end if self.borderFill and self.borderFill.release then pcall(self.borderFill.release, self.borderFill) end @@ -1636,6 +1729,7 @@ function TileRenderer:ensureBorderFill() end if self.gen3 then pcall(bakeGen3BorderFill, self) + self.borderPaletteRevision = self.gen3.paletteRevision or 0 return end pcall(bakeBorderFill, self, block) @@ -1809,6 +1903,8 @@ function TileRenderer:setWin(tx0, ty0, tx1, ty1) end function TileRenderer:ensureWindow(camX, camY, vw, vh) + local closeWindow = FrameProfile.section(" tiles: ensureWindow") + self:syncGen3Palettes() local W, H = self.bodyTilesW, self.bodyTilesH vw = vw or W * 8 -- a nil view (headless draw) means the whole body vh = vh or H * 8 @@ -1860,6 +1956,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) end if win and tx0 >= win.tx0 and ty0 >= win.ty0 and tx1 <= win.tx1 and ty1 <= win.ty1 then + closeWindow() return -- still inside the last fill end -- refill with margin so the next few scrolled pixels stay covered @@ -1936,6 +2033,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) cells, drew, W, H, tostring(self.gen3.slots)) end self:setWin(tx0, ty0, tx1, ty1) + closeWindow() return end @@ -1944,6 +2042,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) -- player, and leave the reason in the log (gen3SheetsFor already logged it). if not self.image then self:setWin(tx0, ty0, tx1, ty1) + closeWindow() return end if not self.winBatch then @@ -1986,6 +2085,7 @@ function TileRenderer:ensureWindow(camX, camY, vw, vh) end end self:setWin(tx0, ty0, tx1, ty1) + closeWindow() end -- draw the static tile window, then its animated overdraw, at the camera offset @@ -2085,6 +2185,7 @@ function TileRenderer:reflectionCoverInfo(cx, cy) end function TileRenderer:drawReflectionCover(cx, cy, camX, camY) + self:syncGen3Palettes() local record = self.gen3 if not (record and record.tiles and self.map and self.map.blockAt) then return end local id = self.map:blockAt(cx, cy) @@ -2291,6 +2392,7 @@ end -- instances keep the batches they already built; MapLoader.invalidateAll -- is what drops those (14 §cache-invalidation contract). function TileRenderer.invalidate() + paletteFrames:clear() imageCache = {} shiftVariants = {} frameImages = {} diff --git a/src/render/Transition.lua b/src/render/Transition.lua index bf68f5d84..4fee20ae7 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -226,4 +226,11 @@ function BattleReturn:draw() love.graphics.setColor(1, 1, 1, 1) end +-- Scalar observations of the same native states, without advancing a fade. +function Transition.describe(state) + local class=getmetatable(state) + if class==Transition then return state:alpha(),false,state.phase=='out'and 1 or -1,true,true end + if class==WhiteFlash then return 1,true,0,true,true end + if class==BattleReturn then return state:alpha(),true,-1,true,true end +end return Transition diff --git a/src/save_convert/Gen3Save.lua b/src/save_convert/Gen3Save.lua index 511efb698..fd22ff232 100644 --- a/src/save_convert/Gen3Save.lua +++ b/src/save_convert/Gen3Save.lua @@ -80,6 +80,8 @@ Gen3Save.MON_IS_EGG = 4 -- So these are the fallback for a playthrough with nothing in it to ask, and -- the moment there is something to ask, they are not used at all. Gen3Save.LANGUAGE_ENGLISH = 2 +Gen3Save.MET_GAME_SAPPHIRE = 1 +Gen3Save.MET_GAME_RUBY = 2 Gen3Save.MET_GAME_EMERALD = 3 Gen3Save.MET_GAME_FIRE_RED = 4 Gen3Save.MET_GAME_LEAF_GREEN = 5 @@ -142,6 +144,10 @@ function Gen3Save.setLayout(layout, orders, fields, gameVersion) Gen3Save.defaultMetGame = Gen3Save.MET_GAME_FIRE_RED elseif gameVersion == "leafgreen" then Gen3Save.defaultMetGame = Gen3Save.MET_GAME_LEAF_GREEN + elseif gameVersion == "ruby" then + Gen3Save.defaultMetGame = Gen3Save.MET_GAME_RUBY + elseif gameVersion == "sapphire" then + Gen3Save.defaultMetGame = Gen3Save.MET_GAME_SAPPHIRE else Gen3Save.defaultMetGame = Gen3Save.MET_GAME_EMERALD end @@ -537,6 +543,7 @@ end function Gen3Save.encryptionKey(block2) local f = need("the encryption key") + if f.saveBlock2.encryptionKey == false then return 0 end -- RS stores plain values return u32(block2, f.saveBlock2.encryptionKey) end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 53cef91fa..25fc9cde5 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -1814,122 +1814,7 @@ end -- Color defaults to black (warp-style); "white" matches GBFadeOutToWhite -- / GBFadeInFromWhite (Mom heal, Silph Co. nurse). White defaults to -- 24 frames (3 palettes x 8); black defaults to 12 (Transition). -local FadeOverlay = {} -FadeOverlay.__index = FadeOverlay - -function FadeOverlay.new(game, ow, color) - return setmetatable({ - game = game, ow = ow, alpha = 0, color = color or "black", - }, FadeOverlay) -end - --- A FADE IS A PALETTE EFFECT, NOT A MODE -- the world keeps running under it. --- --- The state stack only updates the state on TOP, so while this overlay is up --- it is the only thing being ticked. Ticking nothing but the script runner --- starves the very thing most scripts do behind a fade: MOVE SOMEBODY. The --- move queue never steps, `waitmovement` never completes, the map's frame --- table is never re-asked, and the player is left looking at black with a --- script that can no longer finish -- which is exactly what Route 101's --- rescue does (fadescreen, removeobject, setobjectxy, applymovement, --- waitmovement) three commands after the screen goes dark. --- --- So pump the field itself. Only while something is actually driving it: with --- no script and no queued move there is nothing to advance, and running the --- field then would hand the player an input frame behind a black screen. -function FadeOverlay:update(dt) - local ow = self.ow - if ow then - local runner = ow.runner - local busy = (runner and runner.isRunning and runner:isRunning()) - or (ow.scriptMoves and #ow.scriptMoves > 0) - or (ow.pendingScripts and ow.pendingScripts[1] ~= nil) - if busy and ow.update then - ow:update(dt or 1 / 60) - elseif runner then - runner:update() - end - -- THE WATCHDOG, because a screen stuck at black is the one failure a - -- player cannot tell from a crash. - -- - -- A fade is always meant to be undone by something: the script's own - -- fade back, the `waitstate` that ends the scene, or the runner when the - -- script finishes. If all three miss it, the game is still running -- - -- input is even accepted -- behind a fully opaque rectangle, and the only - -- report anybody can make is "it went black". Two seconds of full black - -- with nothing driving the field is not a beat in either cartridge, so - -- bring the picture back and say why once. - if not self.ramp and (self.alpha or 0) >= 1 and not busy then - self.idleFrames = (self.idleFrames or 0) + 1 - if self.idleFrames >= 120 then - Logger.warn("the screen has been faded out for two seconds with no " - .. "script running -- fading back in; whatever darkened " - .. "it never undid it") - self.ramp = { from = self.alpha, to = 0, frames = 12, t = 0 } - self.idleFrames = nil - end - else - self.idleFrames = nil - end - end - local ramp = self.ramp - if not ramp then return end - ramp.t = ramp.t + 1 - local k = math.min(1, ramp.t / ramp.frames) - self.alpha = ramp.from + (ramp.to - ramp.from) * k - if ramp.t >= ramp.frames then - self.ramp = nil - if ramp.to <= 0 then - -- a box may sit above the overlay; remove in place, not pop - local states = self.game.stack.states - for i = #states, 1, -1 do - if states[i] == self then table.remove(states, i) break end - end - self:releaseVeil() - if ow then ow.fadeOverlay = nil end - end - if ramp.onDone then ramp.onDone() end - end -end - --- ...AND ON THE WHOLE SURFACE'S ORIGIN. A state with no uiSize is centred as --- Game Boy furniture (Game:draw's classicOffset), which slid the 240x160 --- rectangle 40 right and 8 down and left an unfaded L down the left and top --- through Mom's heal. -function FadeOverlay:uiSize() - return require("src.ui.Theme").uiSize() -end - -function FadeOverlay:releaseVeil() - local r = self.game and self.game.renderer - if self.ownsVeil and r then r.screenVeil = nil end - self.ownsVeil = nil -end - -function FadeOverlay:draw() - -- A PALETTE FADE HAS NO OUTSIDE. When nothing is drawn over the fade it is - -- painted as the renderer's whole-window veil, so the world bleeding past - -- the 240x160 surface darkens with it; with a box above it, it stays a - -- rectangle in the surface so the box remains readable. - local r = self.game and self.game.renderer - if r and self.game.stack:top() == self then - r.screenVeil = { self.color == "white" and 1 or 0, self.alpha or 0 } - self.ownsVeil = true - return - end - self:releaseVeil() - if self.color == "white" then - love.graphics.setColor(1, 1, 1, self.alpha) - else - love.graphics.setColor(0, 0, 0, self.alpha) - end - -- THE WHOLE SCREEN, not the Game Boy's. Fixed at 160x144 this covered the - -- top-left five eighths of a GBA surface and left an unfaded L down the - -- right and along the bottom, which is worse than no fade at all. - local w, h = require("src.ui.Theme").uiSize() - love.graphics.rectangle("fill", 0, 0, w, h) - love.graphics.setColor(1, 1, 1, 1) -end +local FadeOverlay = require('src.render.FieldFade') local function parseFadeArgs(a, b) local frames, color diff --git a/src/script/Gen3Commands.lua b/src/script/Gen3Commands.lua index 6c4d921ff..e53f7f8a8 100644 --- a/src/script/Gen3Commands.lua +++ b/src/script/Gen3Commands.lua @@ -413,7 +413,7 @@ end -- absent from the inference table: completed runs stay at scene 0 and cannot -- resurrect a previous run's defeated Elite Four. function Gen3Commands.repairFireRedLeagueFlags(save) - if require("src.core.GameVersion").get() ~= "firered" then return false end + if not require("src.core.GameVersion").isFRLG() then return false end if type(save) ~= "table" or type(save.flags) ~= "table" then return false end local scene = math.floor(tonumber(getVar(save, 0x4068)) or 0) local changed = false @@ -532,7 +532,7 @@ end function Commands.g3_set_flag(ctx, name) Commands.set_flag(ctx, name) - if require("src.core.GameVersion").get() ~= "firered" then return end + if not require("src.core.GameVersion").isFRLG() then return end if name == "FLAG_G3_04B8" then Commands.set_flag(ctx, "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0") elseif name == "FLAG_G3_04B9" then @@ -549,7 +549,7 @@ end function Commands.g3_clear_flag(ctx, name) Commands.clear_flag(ctx, name) - if require("src.core.GameVersion").get() ~= "firered" then return end + if not require("src.core.GameVersion").isFRLG() then return end if name == "FLAG_G3_04B8" then Commands.clear_flag(ctx, "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0") elseif name == "FLAG_G3_04B9" then @@ -2022,7 +2022,7 @@ function Commands.g3_trainer_battle(ctx, kind, trainerId, winScript, cantText, -- A loss without it is an ordinary whiteout. Ending the script on a win, -- as the other modes do, skipped Oak's lab's EndRivalBattle -- the heal, -- Blue leaving and the scene var -- so the trigger fought him again. - if k == 9 and require("src.core.GameVersion").get() == "firered" then + if k == 9 and require("src.core.GameVersion").isFRLG() then -- FireRed's extractor puts sRivalBattleFlags in instruction ir[6], the -- same operand position used by intro text for other battle kinds. -- This early-rival command carries no shared intro/defeat text operands. @@ -2066,7 +2066,7 @@ function Commands.g3_trainer_battle(ctx, kind, trainerId, winScript, cantText, -- only for the battle while leaving the script's normal text and flags in -- control. Types 5 and 7 are its single and double rematch records. local vsNpc, vsTrainer - if require("src.core.GameVersion").get() == "firered" and (k == 5 or k == 7) then + if require("src.core.GameVersion").isFRLG() and (k == 5 or k == 7) then vsNpc = ctx.npc vsTrainer = require("src.world.VsSeeker").rematchFor(ctx.save, ctx.game.overworld, vsNpc) @@ -3197,7 +3197,7 @@ function Commands.g3_play_fanfare(ctx, song, number) return end local frames - if require("src.core.GameVersion").get() == "firered" then + if require("src.core.GameVersion").isFRLG() then frames = Gen3Commands.FRLG_FANFARE_FRAMES[tonumber(number) or -1] end ctx.g3Fanfare = { started = (ctx.game.save and ctx.game.save.playTime) or 0, @@ -4537,6 +4537,13 @@ Gen3Commands.MAUVILLE = { RED_V2 = 0x249, } +-- RS has three switches at its own ROM coordinates; Emerald has four. +-- Keep the default unchanged for existing imports without this metadata. +function Gen3Commands.mauvilleProfile(ctx) + local constants = ctx.game and ctx.game.data and ctx.game.data.constants + return constants and constants.gen3MauvilleGym or Gen3Commands.MAUVILLE +end + -- metatile -> { metatile it becomes, impassable } local MAUVILLE_TOGGLE = { [0x220] = { 0x230, false }, [0x221] = { 0x231, false }, -- green H, on @@ -4591,7 +4598,7 @@ end local function mauvilleSweep(ctx, table_, floorCase) local map = mauvilleMap(ctx, "the barrier sweep") if not map then return 0 end - local M = Gen3Commands.MAUVILLE + local M = Gen3Commands.mauvilleProfile(ctx) local changed = 0 for y = M.Y0, M.Y1 do for x = M.X0, M.X1 do @@ -4674,7 +4681,7 @@ Gen3Commands.MAUVILLE_SOLID = { function Gen3Commands.mauvilleAssert(ctx, why) local map = mauvilleMap(ctx, why) if not (map and map.setCellShut and map.cellCollision) then return 0 end - local M = Gen3Commands.MAUVILLE + local M = Gen3Commands.mauvilleProfile(ctx) local fixed, first = 0, nil for y = M.Y0, M.Y1 do for x = M.X0, M.X1 do @@ -4701,7 +4708,7 @@ end function Gen3Commands.mauvilleReport(ctx, why) local map = ctx.overworld and ctx.overworld.map if not (map and map.blockAt and map.cellCollision) then return end - local M = Gen3Commands.MAUVILLE + local M = Gen3Commands.mauvilleProfile(ctx) Logger.debug("mauville gym: %s -- window x%d..%d y%d..%d", tostring(why), M.X0, M.X1, M.Y0, M.Y1) for y = M.Y0, M.Y1 do @@ -4719,7 +4726,7 @@ end Gen3Commands.SPECIALS[143] = function(ctx) local map = mauvilleMap(ctx, "MauvilleGymPressSwitch") if not map then return end - local M = Gen3Commands.MAUVILLE + local M = Gen3Commands.mauvilleProfile(ctx) local pressed = tonumber(getVar(ctx.save, 0x8004)) -- 0x8004 IS THE WHOLE ARGUMENT, and a nil one presses nothing: every -- switch comes up raised and the room looks untouched. The four coord @@ -4763,7 +4770,7 @@ end Gen3Commands.SPECIALS[147] = function(ctx) local map = mauvilleMap(ctx, "MauvilleGymDeactivatePuzzle") if not map then return end - local M = Gen3Commands.MAUVILLE + local M = Gen3Commands.mauvilleProfile(ctx) for _, at in ipairs(M.SWITCHES) do map:setBlock(at[1], at[2], M.PRESSED_SWITCH, false) end @@ -8585,7 +8592,7 @@ local function currentTrainer(ctx) end Gen3Commands.SPECIALS[60] = function(ctx) - if require("src.core.GameVersion").get() == "firered" then + if require("src.core.GameVersion").isFRLG() then local data = ctx.game and ctx.game.data local id = currentTrainer(ctx) if not (data and id) then return 0 end @@ -8607,7 +8614,7 @@ Gen3Commands.SPECIALS[60] = function(ctx) end Gen3Commands.SPECIALS[61] = function(ctx) - if require("src.core.GameVersion").get() == "firered" then + if require("src.core.GameVersion").isFRLG() then return require("src.world.VsSeeker").isReady(ctx.save, ctx.game and ctx.game.overworld, ctx.npc) and 1 or 0 end diff --git a/src/script/Gen3ScriptVM.lua b/src/script/Gen3ScriptVM.lua index d5c709496..194a863f3 100644 --- a/src/script/Gen3ScriptVM.lua +++ b/src/script/Gen3ScriptVM.lua @@ -570,7 +570,7 @@ end -- text. L.trainerbattle = function(ir, s) local isFireRedEarlyRival = tonumber(ir[2]) == 9 - and require("src.core.GameVersion").get() == "firered" + and require("src.core.GameVersion").isFRLG() if isFireRedEarlyRival then -- ir[6] is the raw RIVAL_BATTLE_* bitfield, passed in the handler's -- sixth operand position so the early-rival path can apply it directly. @@ -895,6 +895,14 @@ local ON_LOAD, ON_TRANSITION, ON_RESUME = 1, 3, 5 -- warp-into rows, which do not need to. local ON_FRAME_TABLE, ON_WARP_INTO_MAP_TABLE = 2, 4 +local function fieldPhase(name, game, overworld, mapId, body) + local space = package.loaded['src.core.game3.scripting.space'] + if space and type(space._nativePhase) == 'function' then + return space._nativePhase(name, game, overworld, mapId, body) + end + return body() +end + local function contributionFor(data, mapId, entry, mapDef) local contribution = {} @@ -945,6 +953,7 @@ local function contributionFor(data, mapId, entry, mapDef) if #onEnter > 0 or #onWarpIn > 0 or #onFrame > 0 then local Gen3Commands = require("src.script.Gen3Commands") contribution.onEnter = function(game, overworld) + return fieldPhase('runEnterScripts', game, overworld, mapId, function() -- a fresh visit re-arms the frame table: these closures outlive the -- map, and a save loaded into the same map has to be asked again for _, g in ipairs(onFrame) do g.armed = nil end @@ -957,6 +966,7 @@ local function contributionFor(data, mapId, entry, mapDef) break end end + end) end end @@ -977,6 +987,7 @@ local function contributionFor(data, mapId, entry, mapDef) if #onFrame > 0 then local Gen3Commands = require("src.script.Gen3Commands") contribution.onFrame = function(game, overworld) + return fieldPhase('runOnFrame', game, overworld, mapId, function() for _, g in ipairs(onFrame) do local now = Gen3Commands.getVar(game.save, g.var) if now ~= g.value then @@ -988,6 +999,7 @@ local function contributionFor(data, mapId, entry, mapDef) end end return false + end) end end diff --git a/src/script/Gen3SpecialsFRLG.lua b/src/script/Gen3SpecialsFRLG.lua index 5ad4bbcd2..b0c337c41 100644 --- a/src/script/Gen3SpecialsFRLG.lua +++ b/src/script/Gen3SpecialsFRLG.lua @@ -953,7 +953,7 @@ return function(Gen3Commands) end S[477] = function(ctx) - if require("src.core.GameVersion").get() ~= "firered" then + if not require("src.core.GameVersion").isFRLG() then return emeraldChooseMonForMoveTutor and emeraldChooseMonForMoveTutor(ctx) end local game, runner = ctx.game, ctx.runner diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 595ca1fa9..b0a39e577 100644 --- a/src/script/ScriptRunner.lua +++ b/src/script/ScriptRunner.lua @@ -125,6 +125,7 @@ function ScriptRunner:run(script, extra) end self.co = coroutine.create(function() self:exec(script, ctx) + if ctx.scriptCancelled then return end -- Commands can defer a game action until the script's own dialogue is -- done. start_battle uses this for win-path evolutions, which must not -- be covered by post-battle trainer text. @@ -174,11 +175,17 @@ end -- Execute a command list. Supports labels via jump commands: a script is -- an array of rows; control commands return a new program counter, as a -- row number or a label name. -function ScriptRunner:exec(script, ctx) +function ScriptRunner:exec(script, ctx, admission) local labels = ScriptRunner.scanLabels(script) local data = self.game and self.game.data local pc = 1 + local budget = admission and admission.maxInstructions while pc <= #script do + if budget then + if budget <= 0 then error("native script preview instruction budget exhausted", 0) end + budget = budget - 1 + end + if ctx.scriptCancelled then return end local row = script[pc] local name = row[1] -- THE ROW THE RUNNER IS STANDING ON, kept so a script that parks and @@ -197,7 +204,9 @@ function ScriptRunner:exec(script, ctx) fn, meta = Gen3Commands[name], nil end end - if not fn then + if admission and not admission.commands[name] then + pc = pc + 1 + elseif not fn then -- api 2 owned scripts fail loudly; everything else keeps the v1 -- skip so old content degrades instead of dying if ctx.source and ctx.source.strict then @@ -236,7 +245,7 @@ function ScriptRunner:exec(script, ctx) -- scripts still fail loudly, which is what their authors asked for. local jump local function invoke() - if Runtime.wantsHook("script.command") then + if not admission and Runtime.wantsHook("script.command") then local args = { select(2, unpack(row)) } return Runtime.call("script.command", function(hctx, _, hargs) return fn(hctx, unpack(hargs)) @@ -275,6 +284,19 @@ function ScriptRunner:exec(script, ctx) end end +function ScriptRunner:cancelOwned(ctx, co, silent) + if self.ctx ~= ctx or self.co ~= co or not co then return false end + ctx.scriptCancelled = true + self.co, self.waitingFrames, self.waitingCheck = nil, nil, nil + if silent then return true end + if ctx.g3Locked and Commands.g3_release then Commands.g3_release(ctx) end + if ctx.g4Locked and Commands.g4_release_all then Commands.g4_release_all(ctx) end + if ctx.g3FadedOut then require("src.script.Gen3Commands").restoreFade(ctx) end + if ctx.onDone then ctx.onDone() end + Runtime.emit("script.ended", { ctx = ctx, completed = false }) + return true +end + -- Called by blocking commands from inside the coroutine. function ScriptRunner:yield() return coroutine.yield() @@ -316,7 +338,7 @@ function ScriptRunner:resume(...) end end -function ScriptRunner:update() +local function updateBody(self) -- commands that wait on frames re-resume every step while running if self:isRunning() and self.waitingFrames then self.waitingFrames = self.waitingFrames - 1 @@ -336,4 +358,12 @@ function ScriptRunner:update() end end +function ScriptRunner:update() + local space = package.loaded['src.core.game3.scripting.space'] + if space and type(space._nativeTick) == 'function' then + return space._nativeTick(self, updateBody) + end + return updateBody(self) +end + return ScriptRunner diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 13566a36a..64a79b9ce 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -172,6 +172,21 @@ BagMenu.refreshCount = refreshCount -- the stack, so every exit that prints has to close it afterwards. For -- every other item the picker popped itself first and closePicker's identity -- check makes it a no-op (#252). +-- Shared with optional command shortcuts. Validate inventory and battle +-- before consuming; the native throw owns animation, catch and the turn. +function BagMenu.throwBall(game,battle,id,close) + local def=game.data.items[id] + if not battle or(game.save.inventory[id]or 0)<1 or not ItemEffects.isBall(id,def)then return false end + if battle.kind=='wild'and battle.wildFoesStanding and battle:wildFoesStanding()>1 then + showMessages(game,{(game.data.text and game.data.text.TEXT_B0002_00044) + or Strings("It's no good! It's impossible to aim\nwhen there are two POKéMON!")}) + return false + end + consume(game,id) + if close then close()end + battle:throwBall(ItemEffects.alias(id,def)) + return true +end local function useOn(game, battle, id, target, list, moveIndex, picker) -- PLATINUM / HGSS: EMBARGO keeps the bag off a battler under it -- refused -- with its line, the item kept and the turn not spent (battle_bag.c @@ -405,6 +420,19 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end if result == "fish" then + if require('src.core.GameVersion').isGen2() then + local ow=game.overworld + -- The upstream START menu exits before it opens this native bag. + -- Only this bag may waive its own overlay during preflight; SELECT + -- cannot steal a foreign screen's input ownership. + local ownBag=game.stack:top()==list + if ow and require('src.world.Gen2Fishing').canUse(ow,game,id,ownBag) then + list:close() + return ow:goFishing(id) + end + showMessages(game,{Strings("OAK: %s!\nThis isn't the\ntime to use that!",save_name(game))}) + return + end list:close() local ow = game.overworld local p = ow and ow.player @@ -425,16 +453,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) game.save.player.name) }) return end - -- BattleBag's hasTwoOpponents: no ball while two wild Pokemon stand - if battle.kind == "wild" and battle.wildFoesStanding and battle:wildFoesStanding() > 1 then - showMessages(game, { (game.data.text and game.data.text.TEXT_B0002_00044) - or Strings("It's no good! It's impossible to aim\nwhen there are two POKéMON!") }) - return - end - consume(game, id) - list:close() - -- Catching/AnimPlayer key off the Gen1 ball ids - battle:throwBall(ItemEffects.alias(id, game.data.items[id])) + BagMenu.throwBall(game,battle,id,function()list:close()end) return end @@ -667,7 +686,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) -- runs PrintStatsBox, LearnMoveFromLevelUp and TryEvolvingMon) if extra and extra.leveledTo and target then local GameVersion = require("src.core.GameVersion") - local gen3Candy = GameVersion.get() == "firered" and picker ~= nil + local gen3Candy = GameVersion.isFRLG() and picker ~= nil -- FireRed keeps the item-target party screen underneath the level-up -- message and stat window. Older generations keep their existing flow. if gen3Candy then refreshCount(game, list, id) else list:close() end @@ -769,7 +788,7 @@ local function pickTargetAndUse(game, battle, id, list) -- first, which needs the picker already gone. keepOpen = (not battle) and (ItemEffects.healsHP(id) or (id == "RARE_CANDY" - and require("src.core.GameVersion").get() == "firered")), + and require("src.core.GameVersion").isFRLG())), onSwitch = function(mon, picker) if not wantsMove then useOn(game, battle, id, mon, list, nil, picker) @@ -1244,11 +1263,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/Gen3MainMenu.lua b/src/ui/Gen3MainMenu.lua index 9d6cef654..cddd781ea 100644 --- a/src/ui/Gen3MainMenu.lua +++ b/src/ui/Gen3MainMenu.lua @@ -149,7 +149,7 @@ function Gen3MainMenu:choose() self.game.stack:pop() return item.onSelect() end - if item.key == "continue" and GameVersion.get() == "firered" then + if item.key == "continue" and GameVersion.isFRLG() then -- the stats are already on the window; A loads straight away if self.onContinue then self.onContinue() end elseif item.key == "continue" then @@ -361,7 +361,7 @@ function Gen3MainMenu:drawFireRed() end function Gen3MainMenu:draw() - if GameVersion.get() == "firered" then return self:drawFireRed() end + if GameVersion.isFRLG() then return self:drawFireRed() end -- the plain field the cartridge clears to, not the title art: the menu is -- its own screen and nothing shows through it -- the plain field runs the whole width of whatever surface this is on, so a diff --git a/src/ui/Gen3MoveLearnMenu.lua b/src/ui/Gen3MoveLearnMenu.lua index ee8a059eb..719dc4b7a 100644 --- a/src/ui/Gen3MoveLearnMenu.lua +++ b/src/ui/Gen3MoveLearnMenu.lua @@ -163,7 +163,13 @@ function Gen3MoveLearnMenu:forget(index) if hm[old.id] then local TextBox = require("src.render.TextBox") self.game.stack:push(TextBox.new(self.game, - Strings("HM moves can't be\nforgotten now."))) + Strings("HM moves can't be\nforgotten now."), function() + -- The summary chooser closed before answering. Return to it after + -- the refusal; the native list fallback already remains selectable. + if self.game.stack:top() == self and not self.selecting then + if not self:askOnSummary() then self.selecting = true end + end + end)) return false end local def = self.game.data.moves[self.newMoveId] diff --git a/src/ui/Gen3Options.lua b/src/ui/Gen3Options.lua index 6546c8f01..64200b688 100644 --- a/src/ui/Gen3Options.lua +++ b/src/ui/Gen3Options.lua @@ -93,7 +93,7 @@ local function frameTypeCount(game) -- Old/pre-frame caches do not carry the extracted count. Keep the -- cartridge defaults rather than exposing Emerald's twenty choices on -- FireRed, whose option menu has exactly ten (sOptionMenuItemCounts). - return require("src.core.GameVersion").get() == "firered" and 10 or 20 + return require("src.core.GameVersion").isFRLG() and 10 or 20 end function Gen3Options.new(game, opts) @@ -379,7 +379,7 @@ function Gen3Options:drawFireRed() end function Gen3Options:draw() - if require("src.core.GameVersion").get() == "firered" then return self:drawFireRed() end + if require("src.core.GameVersion").isFRLG() then return self:drawFireRed() end love.graphics.setColor(0.30, 0.44, 0.64, 1) love.graphics.rectangle("fill", 0, 0, GBA_W, GBA_H) diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..52cb2daf7 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -886,6 +886,9 @@ function Gen3PartyMenu:handOff(mon) end function Gen3PartyMenu:runAction(action, mon, row) + if action=='source_field_move'then + return require('src.ui.game3.party_menu')._fieldAction(self,mon,row) + end self.submenu = nil -- Handle hook-injected entries with onSelect callbacks if not action and row and row.onSelect then @@ -1015,7 +1018,44 @@ 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) + local source=package.loaded['src.ui.game3.party_menu'] + if source then return source._dispatch(self,dt,function()return self:_updateBody(dt)end)end + return self:_updateBody(dt) +end +function Gen3PartyMenu:_updateBody(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 -- clock, which is indistinguishable on screen and cannot drift. @@ -1042,6 +1082,11 @@ function Gen3PartyMenu:update(dt) self.scriptTick = (self.scriptTick or 0) + 1 if self.script then self.script(self) end if self.noInput then return end + local source=package.loaded['src.ui.game3.party_menu'] + if source then return source._input(self,function()return self:_inputBody()end)end + return self:_inputBody() +end +function Gen3PartyMenu:_inputBody() local input = self.game.input -- the pick's own submenu takes the stick while it is up @@ -1073,20 +1118,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 @@ -1433,7 +1467,7 @@ function Gen3PartyMenu:drawMember(mon, panel, selected, inset) -- (FireRed's DisplayPartyPokemonBarDetail prints every field in FONT_SMALL, -- the lead panel's name included) - local frlg = require("src.core.GameVersion").get() == "firered" + local frlg = require("src.core.GameVersion").isFRLG() local faced = (not panel.wide or frlg) and Font.pushFace("small") or nil love.graphics.setColor(0, 0, 0, 1) diff --git a/src/ui/Gen3ShopMenu.lua b/src/ui/Gen3ShopMenu.lua index dfb9000c3..21411a1b6 100644 --- a/src/ui/Gen3ShopMenu.lua +++ b/src/ui/Gen3ShopMenu.lua @@ -616,7 +616,7 @@ function Gen3ShopMenu.sellItem(game, id, host) -- unlike sellPrice above this cannot be answered with one value -- in every slot; it has to know which cartridge it is. local soldVars - if require("src.core.GameVersion").get() == "firered" then + if require("src.core.GameVersion").isFRLG() then soldVars = { VAR1 = label, VAR3 = tostring(paid) } else soldVars = { VAR1 = tostring(paid), VAR2 = label } diff --git a/src/ui/Gen3StartMenu.lua b/src/ui/Gen3StartMenu.lua index 36f5ded1f..ace95bfcc 100644 --- a/src/ui/Gen3StartMenu.lua +++ b/src/ui/Gen3StartMenu.lua @@ -726,4 +726,17 @@ function Gen3StartMenu:draw() love.graphics.setColor(1, 1, 1, 1) end +-- Source wrappers enter the same native update/draw phase and cannot run a +-- second input clock by calling their base repeatedly. +local nativeUpdate,nativeDraw=Gen3StartMenu.update,Gen3StartMenu.draw +function Gen3StartMenu:update(dt) + local source=package.loaded['src.ui.game3.start_menu'] + if source then return source._update(self,dt,nativeUpdate)end + return nativeUpdate(self,dt) +end +function Gen3StartMenu:draw() + local source=package.loaded['src.ui.game3.start_menu'] + if source then return source._draw(self,nativeDraw)end + return nativeDraw(self) +end return Gen3StartMenu diff --git a/src/ui/Gen3SummaryMenu.lua b/src/ui/Gen3SummaryMenu.lua index 9d54ee41b..326bb3de9 100644 --- a/src/ui/Gen3SummaryMenu.lua +++ b/src/ui/Gen3SummaryMenu.lua @@ -517,6 +517,7 @@ function Gen3SummaryMenu:update(dt) if self.monAnim then self.monAnim:update(dt) end local input = self.game.input if not input then return end + if package.loaded['src.mods.FeatureOptions']and require('src.ui.TrainingDetails').input(self,input)then return end if type(self.choose) == "table" then self:chooseInput(input) return @@ -928,6 +929,7 @@ function Gen3SummaryMenu:drawHeader() if not mon then return end local name = mon.nickname or (def and def.name) or tostring(mon.species) local title = self.pages[self.page] or "" + if package.loaded['src.mods.FeatureOptions']then title=require('src.ui.TrainingDetails').title(self,title)end if self.hasArt then -- THE PANEL, MEASURED. Its picture box is the striped rectangle in the -- art: x 8..71, y 32..95, which is exactly 64 by 64 -- a Gen 3 front pic. @@ -1281,7 +1283,9 @@ local STAT_ROWS = { function Gen3SummaryMenu:drawSkills() local mon = self.mon - local stats = mon and mon.stats or {} + local details=package.loaded['src.mods.FeatureOptions']and require('src.ui.TrainingDetails') + local training=details and details.stats(self) + local stats = training or mon and mon.stats or {} local Y = self:rows() local labelX, valueX, rightLabelX, rightValueX = self:cols() -- the six stat names come off the cartridge in ITS order (HP last); this @@ -1314,7 +1318,7 @@ function Gen3SummaryMenu:drawSkills() local rowY = statY[left and i or (i - 3)] or Y[2] local label = romLabel[row.key] or Strings(row.label) local value - if row.key == "hp" then + if row.key == "hp"and not training then value = ("%d/%d"):format(mon.hp or 0, stats.hp or 0) else value = tostring(stats[row.key] or 0) @@ -1327,7 +1331,10 @@ function Gen3SummaryMenu:drawSkills() putIn(valueWin, value, left and 4 or 2, rowY - valueWin.y, colour) else put(label, left and labelX or rightLabelX, rowY, colour) - put(value, left and valueX or rightValueX, rowY, colour) + -- Without extracted window art these are column ENDS. Left-aligning + -- a value at x=120 ran it into the second stat label at x=132. + local edge = left and (rightLabelX - 8) or (GBA_W - 16) + put(value, edge - Font.width(value), rowY, colour) end end @@ -1777,7 +1784,9 @@ function Gen3SummaryMenu:drawFireRed(r) end -- header windows: page name (0,0)+4,1; level (0,2)+4,2; nickname +40; gender +105 - frlgText(self.pages[self.page], 4, 1, header) + local title=self.pages[self.page] + if package.loaded['src.mods.FeatureOptions']then title=require('src.ui.TrainingDetails').title(self,title)end + frlgText(title, 4, 1, header) if not mon then return end local t = r.text or {} if not detail then @@ -1835,8 +1844,10 @@ function Gen3SummaryMenu:drawFireRed(r) end elseif self.page == 2 then local px, py = 160, 16 - local stats = mon.stats or {} - frlgRight(("%d/%d"):format(mon.hp or 0, stats.hp or 0), px + 77, py + 4, pane) + local details=package.loaded['src.mods.FeatureOptions']and require('src.ui.TrainingDetails') + local training=details and details.stats(self) + local stats = training or mon.stats or {} + frlgRight(training and tostring(stats.hp or 0)or("%d/%d"):format(mon.hp or 0, stats.hp or 0), px + 77, py + 4, pane) local order = { "attack", "defense", "spatk", "spdef", "speed" } for i, key in ipairs(order) do frlgRight(tostring(stats[key] or 0), px + 77, py + 22 + (i - 1) * 13, pane) diff --git a/src/ui/Gen3Title.lua b/src/ui/Gen3Title.lua index 07ae639a2..d088fea9e 100644 --- a/src/ui/Gen3Title.lua +++ b/src/ui/Gen3Title.lua @@ -395,7 +395,7 @@ function Gen3Title:draw() local prompt = Strings("PRESS START") local promptY = GBA_H - 34 local GameVersion = require("src.core.GameVersion") - local fireRed = GameVersion.get() == "firered" + local fireRed = GameVersion.isFRLG() if not drewBrand then local brand = self.title.brandText or (fireRed and Strings("POKéMON FIRERED")) diff --git a/src/ui/Gen3TitleFRLG.lua b/src/ui/Gen3TitleFRLG.lua index 783531e75..92c20e71a 100644 --- a/src/ui/Gen3TitleFRLG.lua +++ b/src/ui/Gen3TitleFRLG.lua @@ -39,6 +39,10 @@ function Title.new(game, opts) self.frame = 0 self.phase = "intro" self.flames = {} + self.streaks = {} + for i = 1, math.min(4, #(self.rec.streakY or {})) do + self.streaks[i] = { x = W + 16 + 40 * (i - 1), y = self.rec.streakY[i], index = i } + end self.spawnTimer, self.spawnDelay, self.offsetX = 0, 0, 0 self.rng = love.math.newRandomGenerator(30840) self.blinkTimer, self.blinkOn = 0, false @@ -114,7 +118,7 @@ function Title:update(dt) self:skipToRun() elseif self.phase == "run" and pressed then self.phase, self.leaveTimer = "leaving", 0 - pcall(require("src.core.Sound").playCry, self.game.data, "CHARIZARD") + pcall(require("src.core.Sound").playCry, self.game.data, self.rec.cry or "CHARIZARD") end end @@ -141,6 +145,7 @@ function Title:spawnFlame(x, y, sx, sy, visible) end function Title:tickFlames() + if self.rec.effect == "leaves" then return self:tickLeaves() end local r = self.rng self.spawnTimer = self.spawnTimer + 1 if self.spawnTimer >= self.spawnDelay then @@ -169,6 +174,36 @@ function Title:tickFlames() end end +-- LeafGreen's Task_LeafSpawner / SpriteCallback_TitleScreenLeaf: the +-- eleven 16x16 frames loop, with leftward drift instead of rising embers. +function Title:tickLeaves() + local r = self.rng + self.spawnTimer = self.spawnTimer + 1 + if self.spawnTimer >= self.spawnDelay then + self.spawnTimer, self.spawnDelay = 0, r:random(0, 5) + 6 + local roll = r:random(0, 29) + local sx = roll < 6 and 16 or (roll < 12 and 24 or 48) + local sy, y = r:random(0, 3) - 2, r:random(0, 87) + 32 + self:spawnFlame(W, y, sx, sy, true) + end + for i = #self.flames, 1, -1 do + local f = self.flames[i] + f.px, f.py = f.px - f.sx, f.py + f.sy + f.t = f.t + 1 + if f.t >= 8 then f.t, f.anim = 0, f.anim % 11 + 1 end + local x, y = math.floor(f.px / 16), math.floor(f.py / 16) + if x < -8 or y < 16 or y > 200 then table.remove(self.flames, i) end + end + local ys = self.rec.streakY + for _, s in ipairs(self.streaks) do + s.x = s.x - 7 + if s.x < -16 then + s.index = s.index % #ys + 1 + s.x, s.y = W + 16, ys[s.index] + end + end +end + -- ------------------------------------------------------------------ draw -- local function drawWhite(img, x, y, amount) @@ -202,6 +237,11 @@ function Title:draw() end -- embers ride just over the border field + local streak = self:img("streak") + if streak and not intro then + g.setColor(1, 1, 1, 1) + for _, s in ipairs(self.streaks) do g.draw(streak, s.x - 16, s.y - 8) end + end local flames, frec = self:img("flames") if flames and not intro then local size = (type(frec) == "table" and frec.size) or 16 diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index 670908c8c..c717f362f 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -886,6 +886,28 @@ function Gen4PartyMenu:step(dir) end end +function Gen4PartyMenu:cancel() + if self.switchFrom then + self.switchFrom = nil + elseif self.hpTransfer then + self.hpTransfer = nil + -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". + -- + -- Out of a battle, handing the answer back as nil is how a caller learns + -- the player cancelled, and that is right. IN a battle it crashed: the + -- battle's own onSwitch is written for a Pokemon and went straight into + -- `mon.hp`. The battle already knows what to do when this screen simply + -- closes, so closing is both the safe answer and the correct one. + elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) and not self.battle then + self:handOff(nil) + elseif self.select then + self.index = self:count() + self:choose() + else + self:close() + end +end + function Gen4PartyMenu:update(dt) self.t = (self.t or 0) + (dt or 1 / 60) local input = self.game.input @@ -918,23 +940,7 @@ function Gen4PartyMenu:update(dt) elseif input:wasPressed("a") then self:choose() elseif input:wasPressed("b") or input:wasPressed("start") then - if self.switchFrom then self.switchFrom = nil - elseif self.hpTransfer then self.hpTransfer = nil - -- B IN A BATTLE IS "BACK TO THE MENU", NOT "I PICK NOTHING". - -- - -- Out of a battle, handing the answer back as nil is how a caller learns - -- the player cancelled, and that is right. IN a battle it crashed: the - -- battle's own onSwitch is written for a Pokemon and went straight into - -- `mon.hp`. The battle already knows what to do when this screen simply - -- closes, so closing is both the safe answer and the correct one. - elseif self.onSwitch and not (self.pickOnly or self.forceSwitch) - and not self.battle then - self:handOff(nil) - elseif self.select then - self.index = self:count(); self:choose() - else - self:close() - end + self:cancel() end end diff --git a/src/ui/Gen5PokeTransferScreen.lua b/src/ui/Gen5PokeTransferScreen.lua index 0f4be03ad..a710de122 100644 --- a/src/ui/Gen5PokeTransferScreen.lua +++ b/src/ui/Gen5PokeTransferScreen.lua @@ -103,11 +103,12 @@ function S:picked(e) for i, p in ipairs(self.picks) do if p == e then return i end end end -local REFUSE = { - egg = "An Egg cannot be transferred.", - item = "A Pokémon holding an item cannot\nbe transferred.", - hm = "This Pokémon knows a hidden move.\nIt cannot be transferred.", -} +local function refusal(reason) + if reason == "egg" then return Strings("An Egg cannot be transferred.") end + if reason == "item" then return Strings("A Pokémon holding an item cannot\nbe transferred.") end + if reason == "hm" then return Strings("This Pokémon knows a hidden move.\nIt cannot be transferred.") end + return Strings("This Pokémon cannot be transferred.") +end function S:toggle(e) local at = self:picked(e) @@ -115,7 +116,7 @@ function S:toggle(e) if #self.picks >= T().MONS then return end if not e.walker then local why = T().refusal(self.srcData, e.raw) - if why then return self:say(Strings(REFUSE[why] or "This Pokémon cannot be transferred.")) end + if why then return self:say(refusal(why)) end end self.picks[#self.picks + 1] = e if #self.picks == T().MONS then diff --git a/src/ui/HgssAthleteShop.lua b/src/ui/HgssAthleteShop.lua index d9d51199d..7db88187b 100644 --- a/src/ui/HgssAthleteShop.lua +++ b/src/ui/HgssAthleteShop.lua @@ -30,6 +30,7 @@ -- (overlay 31's line for that case was not traced); a taken prize is drawn -- like any other (whether overlay 31 dims it was not read). +local Strings = require("src.core.Strings") local T = require("src.import.Gen4Text") local M = {} @@ -101,7 +102,7 @@ local function choose(self) return end self.mode, self.yesNo = "confirm", 1 - self.message = bank(self, 46, "Would you like\n" .. (def.name or "") .. "?", def.name or "") + self.message = bank(self, 46, Strings("Would you like\n%s?", def.name or ""), def.name or "") end local function transact(self) @@ -115,15 +116,15 @@ local function transact(self) if self.athlon.kind == "daily" then if isApricorn(id) then require("src.world.HgssApricorns").give(save, id - M.APRICORN_FIRST, 1) - self.message = bank(self, 49, "All right, here you go.\nCome back anytime!") + self.message = bank(self, 49, Strings("All right, here you go.\nCome back anytime!")) else require("src.inventory.Bag").add(save, id, 1, self.game.data) - self.message = self:line(6, "Here you are!\nThank you!", def.name or "", self:pocketName(def)) + self.message = self:line(6, Strings("Here you are!\nThank you!"), def.name or "", self:pocketName(def)) end S.takeDaily(save, self.cursor - 1) else S.giveCard(save, (tonumber(id) or 0) - M.DATA_CARD_01) - self.message = bank(self, 49, "All right, here you go.\nCome back anytime!") + self.message = bank(self, 49, Strings("All right, here you go.\nCome back anytime!")) end -- MartData_SubCurrency (PokeathlonSave_SubAthletePoints: not below 0) and -- GAME_STAT_CURRENCY_SPENT diff --git a/src/ui/HgssPokeathlonTeamInfo.lua b/src/ui/HgssPokeathlonTeamInfo.lua index aa75d3eaa..7a5c33749 100644 --- a/src/ui/HgssPokeathlonTeamInfo.lua +++ b/src/ui/HgssPokeathlonTeamInfo.lua @@ -48,6 +48,7 @@ -- * the OBJ set's sequence 5 (a sparkle, created hidden at the Medals and -- never shown by ov97) and 6 are not drawn +local Strings = require("src.core.Strings") local Font = require("src.render.Font") local T = require("src.import.Gen4Text") @@ -199,7 +200,7 @@ function M.draw(game, opts) elseif r.gender == "female" then text(game, line(game, 3, "♀"), 76, 120, 3, 4) end for _, l in ipairs(M.LABELS) do text(game, line(game, l[1], ""), 104, l[2]) end local sp = speciesName(game, mon) - text(game, line(game, 9, "Medals won by\n" .. sp, sp), 8, 152) + text(game, line(game, 9, Strings("Medals won by\n%s", sp), sp), 8, 152) -- the OBJs: the picture, the stars and arrows, the Medals local pic = picture(game, mon) if pic then diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index e93098790..cf42a71f2 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -172,6 +172,25 @@ local function navPressed(self, dir) return true end +function ListMenu:choose() + if #self.items == 0 then + return self:cancel() + end + beep(self) + local item = self.items[self.index] + if self.onChoose then + self.onChoose(item, self) + end +end + +function ListMenu:cancel() + beep(self) + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function ListMenu:update(dt) if self.script then self.script(self) @@ -211,16 +230,10 @@ function ListMenu:update(dt) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then - beep(self) - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() return elseif input:wasPressed("a") then - beep(self) - local item = self.items[self.index] - if self.onChoose then - self.onChoose(item, self) - end + self:choose() return end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 2415d798b..24b36069d 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -120,6 +120,35 @@ function Menu:clampScroll() end end +function Menu:choose() + -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + local item = self.items[self.index] + -- keepOpen entries run without closing the menu (e.g. the + -- Pokédex CRY option keeps the side menu up) + if not item.keepOpen then + self.game.stack:pop() + end + if item.onSelect then + item.onSelect() + end +end + +function Menu:cancel() + if not self.cancelable then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function Menu:update(dt) local input = self.game.input local wasIndex = self.index @@ -128,15 +157,7 @@ function Menu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #self.items and self.index + 1 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - local item = self.items[self.index] - -- keepOpen entries run without closing the menu (e.g. the - -- Pokédex CRY option keeps the side menu up) - if not item.keepOpen then self.game.stack:pop() end - if item.onSelect then item.onSelect() end + self:choose() elseif self.cancelable and (input:wasPressed("b") or (self.startCloses and input:wasPressed("start"))) then -- HandleMenuInput_ returns for any watched key, but only replays diff --git a/src/ui/ModalStack.lua b/src/ui/ModalStack.lua new file mode 100644 index 000000000..e3e25bd85 --- /dev/null +++ b/src/ui/ModalStack.lua @@ -0,0 +1,266 @@ +-- A modal plane over actual native states. World/battle controllers remain +-- owned by StateStack; this plane never builds another simulation or UI clock. +local Plane = {} +local methods = {} +local metadata = setmetatable({}, { __mode = "k" }) +local arrays = setmetatable({}, { __mode = "k" }) +local serial = 0 + +local function baseIndex(stack) + local base = 0 + local battle = package.loaded["src.battle.BattleState"] + for i, state in ipairs(stack.states) do + if state.isOverworld or state.isBattleState + or type(battle) == "table" and getmetatable(state) == battle then base = i end + end + return base +end + +function methods:isCurrent() + local owner = self.owner + return owner.nativeStackReady ~= false and owner.stack == self.stack and self.stack.states == self.states + and self.stack._modalTracker == self and owner.save == self.save + and owner.sessionStartedAt == self.session +end + +function methods:check() + assert(self:isCurrent(), "native modal stack belongs to a retired Game/session") +end + +function methods:added(state) + if not self:isCurrent() then return end + local prior = self.records[state] + if prior then metadata[prior].retired = true end + self.records[state] = nil +end + +function methods:removed(state) + local layer = self.records[state] + if layer then metadata[layer].retired = true; self.records[state] = nil end +end + +function methods:layer(state) + local layer = self.records[state] + if layer then return layer end + serial = serial + 1 + local modOwned = state.modOwnedScreen == true + local widget = state.nativeModalWidget or { state = state, isMenu = state.isMenu == true } + layer = { + id = state.nativeModalId or (modOwned and "modui:" .. serial) + or state.screenId or "nativeui:" .. serial, + mod = widget, + isMenu = widget.isMenu == true, + drawUnder = state.drawUnder == true, + hideBelow = state.isOpaque == true, + fullscreen = state.isOpaque == true, + } + local base = self.states[baseIndex(self.stack)] + metadata[layer] = { plane = self, state = state, base = base, map = base and base.map } + self.records[state] = layer + return layer +end + +function methods:sync() + self:check() + local count = 0 + for i = baseIndex(self.stack) + 1, #self.states do + count = count + 1 + self.layers[count] = self:layer(self.states[i]) + end + for i = #self.layers, count + 1, -1 do self.layers[i] = nil end + local base = self.states[baseIndex(self.stack)] + arrays[self.layers] = { plane = self, base = base, map = base and base.map } + return self.layers +end + +function methods:drawOrder(visible) + local layers = self:sync() + if visible then + local filtered = {} + for _, layer in ipairs(layers) do + if visible[metadata[layer].state] then filtered[#filtered + 1] = layer end + end + layers = filtered + end + local first = 1 + for i = #layers, 2, -1 do if layers[i].hideBelow then first = i; break end end + if layers[#layers] and layers[#layers].drawUnder then first = 1 end + local out = {} + for i = first, #layers do out[#out + 1] = layers[i] end + return out +end + +-- Rendering can mirror a native menu onto a different surface. Such a menu +-- must not hide the world or lower UI on this surface; logic remains modal. +function methods:renderPlan(visible) + local order, full = self:drawOrder(visible), false + for _, layer in ipairs(order) do + local value = layer.fullscreen + if type(value) == "function" then value = value() end + if value then full = true end + end + local allowed = {} + for state, shown in pairs(visible) do + if not self.records[state] then allowed[state] = shown and not full end + end + for _, layer in ipairs(order) do allowed[metadata[layer].state] = true end + return { visible = allowed, opaque = full and metadata[order[1]].state or nil } +end + +function methods:fullscreen() + for _, layer in ipairs(self:drawOrder()) do + local value = layer.fullscreen + if type(value) == "function" then value = value() end + if value then return true end + end + return false +end + +function methods:allows(state) + local own = self.records[state] + if not own then + -- sync can discover native screens pushed without this facade. + self:sync(); own = self.records[state] + end + if not own then return not self:fullscreen() end + for _, layer in ipairs(self:drawOrder()) do if layer == own then return true end end + return false +end + +function methods:opaque(state) + local layer = self.records[state] + if not layer then return state.isOpaque end + -- Fullscreen hides the scene, not UI explicitly retained by drawUnder. + -- The first visible modal is therefore the single native opacity boundary. + return self:drawOrder()[1] == layer and self:fullscreen() +end + +function methods:depth() return #self:sync() end +function methods:top() local layers = self:sync(); return layers[#layers] end +function methods:busy() return self:depth() > 0 end +function methods:has(id) + for _, layer in ipairs(self:sync()) do if layer.id == id then return true end end + return false +end + +function methods:push(id, widget, options) + self:check() + assert(type(id) == "string" and id ~= "", "native modal id must be a non-empty string") + assert(type(widget) == "table", "native modal widget must be a table") + local opts = options or {} + if opts.hideBelow ~= false then + local message = package.loaded["src.ui.game3.message"] + if message and message.closeStay then + message.closeStay() + else + -- The shared engine's stay message is a real native TextBox state. + -- Closing it must not run its completion callback or ordinary prompts. + local boxes = {} + local class = package.loaded["src.render.TextBox"] + for i = baseIndex(self.stack) + 1, #self.states do + local state = self.states[i] + if class and getmetatable(state) == class and state.stay then boxes[#boxes + 1] = state end + end + self.stack:removeAll(boxes) + end + self:check() + end + local old = {} + for _, layer in ipairs(self:sync()) do + if layer.id == id then old[#old + 1] = metadata[layer].state end + end + self.stack:removeAll(old) + self:check() + local game = self.owner + local plane = self + local state = { game = game, nativeModalId = id, nativeModalWidget = widget } + -- These modules draw in GBA coordinates. Without a native surface request + -- Game would centre them as 160x144 Game Boy overlays and clip the right edge. + function state:uiSize() return 240, 160 end + function state:wantsFillScale() return true end + -- Legacy native menu modules use dot callbacks; StateStack uses methods. + function state:update(dt) + if widget.handleInput then widget.handleInput(game.input) end + if plane:isCurrent() and plane.stack:top() == self and widget.update then widget.update(dt) end + end + function state:draw() if widget.draw then widget.draw() end end + self.stack:push(state) + local layer = self:layer(state) + layer.isMenu = opts.isMenu == nil and widget.isMenu == true or opts.isMenu == true + layer.drawUnder = opts.drawUnder == true + layer.hideBelow = opts.hideBelow ~= false + layer.fullscreen = opts.fullscreen + self:sync() +end + +function methods:pop(id) + local layers = self:sync() + for i = #layers, 1, -1 do + local layer = layers[i] + if not id or layer.id == id then + self.stack:removeAll({ metadata[layer].state }); self:sync(); return true + end + end + return false +end + +function methods:clear() + local selected = {} + for _, layer in ipairs(self:sync()) do selected[#selected + 1] = metadata[layer].state end + local ok, err = pcall(self.stack.removeAll, self.stack, selected) + self:sync() + if self.onCleared then self.onCleared() end + if not ok then error(err, 0) end +end + +-- Preview suspension is a lease, not pop/exit. Restoring a stale/foreign or +-- already retired state is rejected before mutating the actual native stack. +function methods:setLayers(layers) + self:check() + assert(type(layers) == "table", "native modal layers must be a table") + local lease = arrays[layers] + local base = baseIndex(self.stack) + local root = self.states[base] + assert(not lease or lease.plane == self and lease.base == root and lease.map == (root and root.map), + "native modal lease belongs to another scene") + local seen, states, count, last = {}, {}, 0, 0 + for key, layer in pairs(layers) do + assert(type(key) == "number" and key >= 1 and key % 1 == 0, "native modal layers must be an array") + local meta = metadata[layer] + assert(meta and meta.plane == self and not meta.retired, "foreign or retired native modal layer") + assert(meta.base == root and meta.map == (root and root.map), "native modal layer belongs to another scene") + assert(not seen[meta.state], "duplicate native modal layer") + seen[meta.state] = true; states[key] = meta.state; count = count + 1 + last = math.max(last, key) + end + assert(count == last, "native modal layers must be a dense array") + for i = #self.states, base + 1, -1 do self.states[i] = nil end + for i = 1, #layers do self.states[base + i] = states[i] end + self.layers = layers +end + +function Plane.forGame(game) + assert(type(game) == "table" and type(game.stack) == "table" + and type(game.stack.states) == "table", "native modal stack needs a live Game state stack") + local stack = game.stack + local prior = stack._modalTracker + if prior and prior.owner == game and prior:isCurrent() then return prior.view end + local model = setmetatable({ owner = game, stack = stack, states = stack.states, + save = game.save, session = game.sessionStartedAt, layers = {}, + records = setmetatable({}, { __mode = "k" }) }, { __index = methods }) + local view = setmetatable({}, { + __index = function(_, key) + if key == "_layers" then return model:sync() end + local fn = methods[key] + if fn then return function(...) return fn(model, ...) end end + end, + __newindex = function(_, key, value) + if key == "_layers" then return model:setLayers(value) end + error("unknown native modal stack field " .. tostring(key), 2) + end, + }) + model.view = view; stack._modalTracker = model + return view +end + +return Plane diff --git a/src/ui/MultiChoice.lua b/src/ui/MultiChoice.lua new file mode 100644 index 000000000..5cee501a8 --- /dev/null +++ b/src/ui/MultiChoice.lua @@ -0,0 +1,39 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Inset choice windows use native input, font, frame and stack ownership. +local Font=require('src.render.Font') +local Theme=require('src.ui.Theme') +local C={};C.__index=C +function C.new(game,options,index,onChoose,layout) + layout=layout or{};assert(type(options)=='table'and#options>0,'choice options required') + local rows={};local max=0 + for i,v in ipairs(options)do assert(type(v)=='string','invalid choice label');rows[i]=v;max=math.max(max,Font.width(v))end + local cols=math.max(1,math.min(#rows,math.floor(tonumber(layout.cols)or 1))) + local width=math.ceil(max/8)+3 + local c=setmetatable({game=game,options=rows,index=math.max(1,math.min(#rows,(index or 0)+1)),onChoose=onChoose, + cols=cols,colTiles=width,tx=layout.left or Theme.choiceBox.tx,ty=layout.top or Theme.choiceBox.ty, + tw=width*cols+1,th=math.ceil(#rows/cols)*2+2,ignoreB=layout.ignoreBPress==true},C) + return c +end +function C:update() + local input=self.game.input;local n=#self.options + if input:wasPressed('a')then self.onChoose(self.index-1);return end + if input:wasPressed('b')and not self.ignoreB then self.onChoose(127);return end + local delta=input:wasPressed('down')and self.cols or input:wasPressed('up')and-self.cols + or input:wasPressed('right')and 1 or input:wasPressed('left')and-1 + if delta then self.index=(self.index-1+delta)%n+1 end +end +function C:draw() + local ok,err=require('src.render.GraphicsScope').call(function() + Font.drawBox(self.tx,self.ty,self.tw,self.th) + love.graphics.setColor(0,0,0,1) + for i,label in ipairs(self.options)do + local x=(self.tx+2+(i-1)%self.cols*self.colTiles)*8 + local y=(self.ty+1+math.floor((i-1)/self.cols)*2)*8 + Font.draw(label,x,y) + if i==self.index then Font.drawCode(Theme.cursor,x-8,y)end + end + end) + if not ok then error(err,0)end +end +function C:uiSize()return Theme.uiSize()end +return C 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/RepelPrompt.lua b/src/ui/RepelPrompt.lua new file mode 100644 index 000000000..f58777b17 --- /dev/null +++ b/src/ui/RepelPrompt.lua @@ -0,0 +1,30 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Reuse asks through native dialogue and consumes through native item effects. +local P={} +local remembered=setmetatable({},{__mode='k'}) +function P.remember(save,id)if type(save)=='table'then remembered[save]=id end end +function P.item(game) + local inventory=game.save.inventory or{} + local last=remembered[game.save] + if last and(inventory[last]or 0)>0 then return last end + for _,id in ipairs{'MAX_REPEL','SUPER_REPEL','REPEL'}do if(inventory[id]or 0)>0 then return id end end +end +function P.show(game) + local features=package.loaded['src.mods.FeatureOptions'];local provider=features and features.provider('repelPrompt') + local id=provider and P.item(game);if not id then return false end + local flow= require('src.core.Game3')._withCallbackOwner(provider.who,function()return require('src.compat.gen1.UiFlow').new()end) + if not flow then return false end + local name=game.data.items[id]and game.data.items[id].name or id + local text=features.call(provider,'language')=='de'and('Noch einmal '..name..' verwenden?')or('Use another '..name..'?') + local TextBox=require('src.render.TextBox') + flow:push(TextBox.new(game,text,nil,{choice=function(yes) + if not flow:valid()then flow:retire();return end + if not yes or(game.save.inventory[id]or 0)<1 then flow:finish();return end + local result,messages=require('src.inventory.ItemEffects').use(game.data,game.save,id) + if result~='consumed'then flow:finish();return end + require('src.inventory.Bag').remove(game.save,id,1);P.remember(game.save,id) + flow:push(TextBox.new(game,table.concat(messages or{},'\f'),function()flow:finish()end)) + end})) + return true +end +return P diff --git a/src/ui/Screens.lua b/src/ui/Screens.lua index 704c642e3..dc76dc5ff 100644 --- a/src/ui/Screens.lua +++ b/src/ui/Screens.lua @@ -558,6 +558,7 @@ local function pushWith(game, id, ...) local ok, result = pcall(factory.new, game, ...) if ok and result then inst = result + inst.modOwnedScreen = true else Logger.error("mod screen '%s' failed: %s", id, tostring(result)) cache[id] = nil diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index 7265b74fd..b9b5eb172 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -297,7 +297,7 @@ end function ShopMenu.new(game, stock, onQuit) local counter = gen3Counter(game) if counter then - local fireRed = require("src.core.GameVersion").get() == "firered" + local fireRed = require("src.core.GameVersion").isFRLG() local Menu = require("src.ui.Menu") local menu menu = Menu.new(game, { diff --git a/src/ui/TrainingDetails.lua b/src/ui/TrainingDetails.lua new file mode 100644 index 000000000..4a8226857 --- /dev/null +++ b/src/ui/TrainingDetails.lua @@ -0,0 +1,22 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local D={} +function D.mode(menu) + local features=package.loaded['src.mods.FeatureOptions'] + if not(features and features.enabled('trainingDetails')and menu.page==2 and not menu.moveSelect and not menu.choose)then return 0 end + return menu._trainingDetailMode or 0 +end +function D.input(menu,input) + local features=package.loaded['src.mods.FeatureOptions'] + if menu.page~=2 or menu.moveSelect or menu.choose or not(features and features.enabled('trainingDetails'))then return false end + if input:wasPressed('select')then menu._trainingDetailMode=((menu._trainingDetailMode or 0)+1)%3;return true end + return false +end +function D.stats(menu) + local mode=D.mode(menu) + if mode==0 then return nil end + return mode==1 and menu.mon.ivs or menu.mon.evs +end +function D.title(menu,title) + local mode=D.mode(menu);return mode==1 and'IV'or mode==2 and'EV'or title +end +return D 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/game3/battle_chrome.lua b/src/ui/game3/battle_chrome.lua new file mode 100644 index 000000000..de2d85fae --- /dev/null +++ b/src/ui/game3/battle_chrome.lua @@ -0,0 +1,80 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Cartridge windows and native panel drawing share the admitted battle owner. +local Native=require('src.battle.Gen3Battle') +local Battle=require('src.core.game3.battle') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local Scope=require('src.render.GraphicsScope') +local Version=require('src.core.GameVersion') +local C={WIN={MSG=0,ACTION_PROMPT=1,ACTION_MENU=2,MOVE_NAME_1=3,MOVE_NAME_2=4,MOVE_NAME_3=5, + MOVE_NAME_4=6,PP=7,DUMMY=8,PP_REMAINING=9,MOVE_TYPE=10,SWITCH_PROMPT=11,YESNO=12,LEVEL_UP_BOX=13,LEVEL_UP_BANNER=14}} +local function owner() + local screen=Battle._screen();if screen then return screen end + local g=require('src.compat.gen1.NativeSession').game() + return g and{game=g,data=g.data} +end +function C.layout() + local v=Version.get();return(v=='ruby'or v=='sapphire')and'rs'or v=='emerald'and'emerald'or'frlg' +end +function C.isHoenn()return C.layout()~='frlg'end +function C.window(id) + assert(type(id)=='number'and id%1==0 and id>=0 and id<=14,'invalid battle window') + local s=owner();if not s then return nil,'no native GBA owner'end + local c=s.data.constants;local rec=c.gen3BattleWindows + local row=rec and rec.templates and rec.templates[id+1] + if not row and rec then + row=({[0]=rec.message,rec.prompt,rec.action})[id] + if id>=3 and id<=6 then row=rec.moves and rec.moves[id-2]end + end + if not row then return nil,'window not present in imported native templates'end + local w,h=row.width or row.w,row.height or row.h + if Battle._screen()==s and id<=6 then + local windows=Native.windows(s) + local frame=({[0]=windows.message,windows.prompt,windows.action})[id] + if id>=3 then frame=windows.moves and windows.moves[id-2]end + if frame then return{left=frame.textX/8,top=frame.row1/8,w=frame.cols or frame.tw-2,h=frame.th-2,x=frame.textX,y=frame.row1}end + end + return{left=row.left,top=row.top%20,w=w,h=h,x=row.left*8,y=(row.top%20)*8} +end +function C.textOrigin(id) + local w,why=C.window(id);if not w then return nil,why end + local dx=id==1 and 1 or id==9 and 2 or 0 + return w.x+dx,w.y+1,w.w*8,id>=3 and id<=7 or id==10 or id==11 +end +function C.messageOrigin() + if C.layout()~='emerald'then return 10,122,224 end + return C.textOrigin(C.WIN.MSG) +end +function C.textboxColors(fg,shadow) + local s=owner();local a,b;if s then a,b=Native.textColors(s,'message')end + return a or fg,b or shadow +end +function C.manifest() + local s=owner();if not s then return nil,'no native GBA owner'end + local c=s.data.constants;local text=Native.textbox(s) + local copy=require('src.mods.Merge').deepCopy + local templates={} + for id=0,14 do local row=C.window(id);if row then templates[id+1]={left=row.left,top=row.top,w=row.w,h=row.h}end end + return{layout=C.layout()=='frlg'and'frlg'or'rse',assetLayout=C.layout(), + windows={normal=templates}, + text=copy(text and text.text or{}),native=true} +end +local modes={none='message',menu='action',moves='moves'} +function C.drawPanel(mode) + assert(modes[mode],'invalid battle panel mode') + if Boundary.active('battle.panel')then return Boundary.invoke('battle.panel')end + local screen=Battle._screen();if not screen then return false,'no native battle'end + return Native.drawPanel(screen,modes[mode]) +end +local basePanel=C.drawPanel +function C._panel(screen,which,body) + if C.drawPanel==basePanel then return body()end + local mode=which=='action'and'menu'or which=='moves'and'moves'or'none' + local ok,result=Scope.call(function() + Boundary.dispatch('battle.panel',body,function()return Call.call(C,'drawPanel',mode)end) + end) + if not ok then error(result,0)end + -- A presentation wrapper owns the panel even if its painter returns nil. + return true +end +return C diff --git a/src/ui/game3/choice.lua b/src/ui/game3/choice.lua new file mode 100644 index 000000000..9e611a777 --- /dev/null +++ b/src/ui/game3/choice.lua @@ -0,0 +1,12 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local C={} +local active +function C.isOpen()return active~=nil and active:valid()end +function C.multi(options,defaultIdx,callback,layout) + if C.isOpen()then return false end + local flow,why=require('src.compat.gen1.UiFlow').new(callback) + if not flow then return false,why end + local state=require('src.ui.MultiChoice').new(flow.game,options,defaultIdx,function(index)flow:finish(index)end,layout) + active=flow;flow:push(state);return true +end +return C diff --git a/src/ui/game3/chrome.lua b/src/ui/game3/chrome.lua new file mode 100644 index 000000000..16f3de04a --- /dev/null +++ b/src/ui/game3/chrome.lua @@ -0,0 +1,7 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local C={} +function C.dialogueWindow() + local b=require('src.ui.Theme').textBox + return b.tx,b.ty,b.tw,b.th +end +return C diff --git a/src/ui/game3/evolution_scene.lua b/src/ui/game3/evolution_scene.lua new file mode 100644 index 000000000..f6125f69f --- /dev/null +++ b/src/ui/game3/evolution_scene.lua @@ -0,0 +1,25 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Flow=require('src.compat.gen1.UiFlow') +local Source=require('src.compat.gen1.SourcePokemon') +local Ids=require('src.compat.gen1.BattleIds') +local E={} +local active +function E.isOpen()return active~=nil and active:valid()end +function E.start(value,target,opts) + opts=opts or{};if E.isOpen()then return false end + local mon,known,g=Source.resident(value);if not mon then return false,'evolution requires a live party member'end + local member=false;for _,m in ipairs(g.save.party or{})do if m==mon then member=true end end + if not member then return false,'evolution requires the current party'end + local key=Ids.new(g.data):native('pokemon',target);if not key then return false,'unknown evolution species'end + local expected,evo=require('src.pokemon.Evolution').pendingFor(g,mon,{kind='levelup'}) + if key~=expected then return false,'evolution target changed'end + local flow,why=Flow.new(function(...)active=nil;if opts.onDone then opts.onDone(...)end end) + if not flow then return false,why end + active=flow + local ok,err=pcall(function() + flow:push(require('src.ui.Gen3EvolutionState').new(g,mon,key,function(...)flow:finish(...)end,opts.canStop==false and'ITEM'or'LEVEL',evo)) + end) + if not ok then flow:retire();active=nil;error(err,0)end + return true +end +return E diff --git a/src/ui/game3/fade.lua b/src/ui/game3/fade.lua new file mode 100644 index 000000000..97bceb0d6 --- /dev/null +++ b/src/ui/game3/fade.lua @@ -0,0 +1,75 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Current native palette/warp fades and owned field fades share native clocks. +local Native=require('src.compat.gen1.NativeSession') +local Layer=require('src.render.FieldFade') +local Transition=require('src.render.Transition') +local Flow=require('src.compat.gen1.UiFlow') +local G=require('src.core.Game3') +local F={MODE={FROM_BLACK=0,TO_BLACK=1,FROM_WHITE=2,TO_WHITE=3}} +local current +local function retire() + local c=current;current=nil + if c then + local state=c.state;state.ramp=nil;state:releaseVeil();c.flow:retire() + state.sourceRetired=true;state.game,state.ow=nil,nil;c.state,c.flow=nil,nil + end +end +local function observed() + if current and not current.flow:valid()then retire()end + local g=Native.game();if not(g and g.stack)then return end + for i=#(g.stack.states or{}),1,-1 do + local state=g.stack.states[i] + local alpha,white,direction,active,locked=Layer.describe(state) + if alpha==nil then alpha,white,direction,active,locked=Transition.describe(state)end + if alpha~=nil then return alpha,white,direction,active,locked,state end + end +end +function F.isActive()local _,_,_,active=observed();return active==true end +local function owner() + local host=package.loaded['src.mods.Runtime'];return host and host.currentMod or G._callbackOwner() +end +function F.clear() + if not current or current.who~=owner()then return false end + retire();return true +end +function F.begin(mode,speed,done) + assert(type(mode)=='number'and mode%1==0 and mode>=0 and mode<=3,'invalid native fade mode') + speed=speed or 1;assert(type(speed)=='number'and speed%1==0 and speed>=1 and speed<=600,'invalid native fade speed') + assert(done==nil or type(done)=='function','invalid fade continuation') + local g=Native.resolve();if not g then return false,'field fade requires an admitted native world'end + observed() + if current then + if current.who~=owner()then return false,'fade belongs to another mod'end + retire() + end + local _,_,_,_,_,foreign=observed();if foreign then return false,'native fade already owns presentation'end + local flow,why=Flow.new();if not flow then return false,why end + local outward=mode==1 or mode==3 + local state=Layer.new(g,nil,mode>=2 and'white'or'black',{ownedHold=true}) + state.alpha=outward and 0 or 1;state.sourceFadeDirection=outward and 1 or -1 + local record={game=g,flow=flow,state=state,who=owner()};current=record + state.ramp={from=state.alpha,to=outward and 1 or 0,frames=16*speed,steps=16,stepFrames=speed,t=0, + onDone=function() + if current~=record or not flow:valid()then return end + local callback=done;done=nil + if not outward then retire()end + if callback then G._withCallbackOwner(record.who,callback)end + end} + flow:push(state);return true +end +F._service={release=retire,poll=function()if current then observed()end end, + releaseOwner=function(_,who)if current and current.who==who then retire()end end} +require('src.core.game3.asset_stream').register(F._service) +return setmetatable(F,{__index=function(_,key) + local alpha,white,direction,active,locked=observed() + if key=='active'then return active==true end + if key=='t'then return math.floor(math.max(0,math.min(1,alpha or 0))*16+.5)end + if key=='mode'then return(white and 2 or 0)+(direction==1 and 1 or 0)end + if key=='_dir'then return direction or 0 end + if key=='lockInput'then return locked==true end +end,__newindex=function(t,key,value) + if key=='t'or key=='mode'or key=='_dir'or key=='active'or key=='lockInput'then + error('native fade observation '..tostring(key)..' is read-only',2) + end + rawset(t,key,value) +end}) diff --git a/src/ui/game3/frlg_font.lua b/src/ui/game3/frlg_font.lua new file mode 100644 index 000000000..a7649cb4b --- /dev/null +++ b/src/ui/game3/frlg_font.lua @@ -0,0 +1,51 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source text uses the native imported proportional faces and shaders. +local Font=require('src.render.Font') +local Scope=require('src.render.GraphicsScope') +local F={LINE_PITCH=15,GLYPH_HEIGHT=14} +F.COLOR={NORMAL={fg={98/255,98/255,98/255,1},shadow={213/255,213/255,205/255,1}}, + WHITE={fg={1,1,1,1},shadow={98/255,98/255,98/255,1}}} +local function face(opts,fn) + local name=opts and(opts.font or opts.small and 'small') + local pushed=type(name)=='string'and Font.hasFace(name) + if pushed then Font.pushFace(name)end + local ok,value=pcall(fn) + if pushed then Font.popFace()end + if not ok then error(value,0)end + return value +end +function F.measure(text,opts) + return face(opts,function() + local max=0 + for line in(tostring(text or'')..'\n'):gmatch('(.-)\n')do max=math.max(max,Font.width(line)+math.max(0,#Font.split(line)-1)*(opts and opts.letterSpacing or 0))end + return max + end) +end +function F.draw(text,x,y,opts) + opts=opts or{} + local ok,value=Scope.call(function()return face(opts,function() + local colors=opts.colors or F.COLOR.NORMAL + local toned=Font.beginTwoTone(opts.color or colors.fg or F.COLOR.NORMAL.fg,colors.shadow or F.COLOR.NORMAL.shadow) + local good,width=pcall(function() + local max,lineY,count=0,y,0 + for line in(tostring(text or'')..'\n'):gmatch('(.-)\n')do + local xx=x + for _,span in ipairs(Font.split(line))do + if opts.limitChars and count>=opts.limitChars then break end + local glyph=line:sub(span.from,span.to);local advance=Font.width(glyph) + if not opts.maxWidth or xx-x+advance<=opts.maxWidth then Font.draw(glyph,xx,lineY)end + xx=xx+advance+(opts.letterSpacing or 0);count=count+1 + end + max=math.max(max,xx-x-(#Font.split(line)>0 and(opts.letterSpacing or 0)or 0));lineY=lineY+(opts.linePitch or F.LINE_PITCH) + end + return max + end) + if toned then Font.endTwoTone()end + if not good then error(width,0)end + return width + end)end) + if not ok then error(value,0)end + return value +end +setmetatable(F,{__index=function(_,k)if k=='_spec'then return{nativeLayout=require('src.core.GameVersion').isFRLG()and'frlg'or'rs'}end end}) +return F diff --git a/src/ui/game3/hud.lua b/src/ui/game3/hud.lua new file mode 100644 index 000000000..f9f662c46 --- /dev/null +++ b/src/ui/game3/hud.lua @@ -0,0 +1,5 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local H={} +function H.isBusy()return require('src.core.game3.runtime').uiBusy()end +H.busy=H.isBusy +return H diff --git a/src/ui/game3/map_preview_screen.lua b/src/ui/game3/map_preview_screen.lua new file mode 100644 index 000000000..3110c9cbe --- /dev/null +++ b/src/ui/game3/map_preview_screen.lua @@ -0,0 +1,25 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Observe and dismiss the actual native dungeon preview. No parallel timer +-- or preview flags are kept by compatibility consumers. +local Native=require('src.compat.gen1.NativeSession') +local Preview=require('src.world.Gen3MapPreviewFRLG') +local M={} +local function current() + local game=Native.game();local stack=game and game.stack + for i=#(stack and stack.states or{}),1,-1 do + local state=stack.states[i] + if getmetatable(state)==Preview and state.game==game then return state,game,stack end + end +end +function M.isActive()return current()~=nil end +function M.dismiss() + local state,game,stack=current() + if not state then return false,'no native map preview'end + if stack:top()~=state then return false,'native map preview is covered'end + local done=state.onDone;state.onDone=nil + stack:pop() + if done then done()end + return true +end +M.reset=M.dismiss +return M diff --git a/src/ui/game3/message.lua b/src/ui/game3/message.lua new file mode 100644 index 000000000..ea889047f --- /dev/null +++ b/src/ui/game3/message.lua @@ -0,0 +1,67 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Dialogue observations and commands over the native printer/stack owner. +local Native=require('src.compat.gen1.NativeSession') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local M={} +local active,drawBox +local frame='normal' +local function current() + local g=Native.game();if not(g and g.stack)then return end + local TextBox=package.loaded['src.render.TextBox'];if not TextBox then return end + for i=#g.stack.states,1,-1 do + local state=g.stack.states[i] + if getmetatable(state)==TextBox then return state end + if state.isOpaque then return end + end +end +function M.isOpen()return current()~=nil end +function M.isWaiting()local b=current();return b~=nil and(b.waiting or b.done)end +function M.isTyping()local b=current();return b~=nil and not(b.waiting or b.done)end +function M.isHeld()local b=current();return b~=nil and b.sourceHeld==true end +function M.currentPage()local b=current();return b and table.concat(b.pages[b.pageIndex],'\n')end +function M.setFrame(kind)frame=kind or'normal'end +function M.frameKind()return frame end +function M.show(text,opts) + opts=opts or{} + if active and active:valid()then active:retire()end + local flow,why=require('src.compat.gen1.UiFlow').new(opts.done) + if not flow then return false,why end + local b=require('src.render.TextBox').new(flow.game,text,function()flow:finish()end, + {stay=(opts.stay or opts.hold)and{}or nil,signpost=opts.sign or opts.frame=='sign', + style=opts.colors,box=opts.box,maxPixels=opts.maxWidth}) + b.sourceHeld=opts.hold==true;b.sourceMessage=true + active=flow;flow:push(b) + if tonumber(opts.speed)==0 then b:revealPage()end + return true +end +function M.showStay(text,opts) + local copy={};for k,v in pairs(opts or{})do copy[k]=v end;copy.stay=true;return M.show(text,copy) +end +function M.close() + local b=current();if not(b and b.sourceMessage and b.nativeUiFlow)then return false end + return b.nativeUiFlow:finish() +end +function M.closeStay()local b=current();if b and b.stay then return M.close()end;return false end +function M.skipReveal()local b=current();if b then b:revealPage();return true end;return false end +function M.advance() + local b=current();if not b or b.sourceHeld then return false end + if not(b.waiting or b.done)then b:revealPage()else b:advance()end + return true +end +M.pressAB=M.advance +function M.reset()if active then active:retire();active=nil end end +function M.tick()return Boundary.invoke('message.update')end +function M.draw()return Boundary.invoke('message.draw')end +M.drawText=M.draw +function M._draw(box,body) + local previous=drawBox;drawBox=box + local ok,result=require('src.render.GraphicsScope').call(function() + return Boundary.dispatch('message.draw',body,function()return Call.call(M,'draw')end) + end) + drawBox=previous;if not ok then error(result,0)end;return result +end +function M._update(box,body) + return Boundary.dispatch('message.update',body,function()return Call.call(M,'tick')end) +end +return M diff --git a/src/ui/game3/move_relearner.lua b/src/ui/game3/move_relearner.lua new file mode 100644 index 000000000..feebbcdbc --- /dev/null +++ b/src/ui/game3/move_relearner.lua @@ -0,0 +1,42 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Flow=require('src.compat.gen1.UiFlow') +local Source=require('src.compat.gen1.SourcePokemon') +local Ids=require('src.compat.gen1.BattleIds') +local R={} +local active +function R.isOpen()return active~=nil and active:valid()end +function R.show(value,opts) + opts=opts or{};if R.isOpen()then return false end + local mon,known,g=Source.resident(value);if not mon then return false,'move learning requires a live party member'end + local member=false;for _,m in ipairs(g.save.party or{})do if m==mon then member=true end end + if not member then return false end + local flow,why=Flow.new(function(...)active=nil;if opts.onDone then opts.onDone(...)end end) + if not flow then return false,why end + active=flow;local ids=Ids.new(g.data) + local function choose() + if not flow:valid()then flow:retire();return end + local entries={} + for _,number in ipairs(require('src.core.game3.move_learn').relearnableMoves(value))do + local key=ids:native('moves',number) + if key then entries[#entries+1]={label=g.data.moves[key].name,value=key}end + end + if #entries==0 then flow:finish(false);return end + local list + list=require('src.ui.ListMenu').new(g,'Remember a move',entries,{onCancel=function()flow:finish(false)end, + onChoose=function(row) + if not flow:valid()then flow:retire();return end + g.stack:removeAll({list}) + flow:admit(function() + require('src.pokemon.Evolution').learnMoves(g,mon,{row.value},function() + local learned=false;for _,move in ipairs(mon.moves or{})do if move.id==row.value then learned=true end end + if learned then flow:finish(true)else choose()end + end) + end) + end}) + flow:push(list) + end + local ok,err=pcall(choose) + if not ok then flow:retire();active=nil;error(err,0)end + return R.isOpen() +end +return R diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua new file mode 100644 index 000000000..4dee5c802 --- /dev/null +++ b/src/ui/game3/party_menu.lua @@ -0,0 +1,114 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Source party extensions edit the real native submenu and input phase. +local Native=require('src.compat.gen1.NativeSession') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local M={} +local current +local function retire() + local c=current;current=nil + if c then + c.mons:release();c.game,c.screen,c.save,c.data,c.loader,c.rows,c.nativeList,c.list=nil,nil,nil,nil,nil,nil,nil,nil + c.extras,c.party={},{} + end +end +local function observe(screen) + local g=Native.game();if not(g and g.stack)then retire();return end + local class=package.loaded['src.ui.Gen3PartyMenu'] + if not class then retire();return end + if not screen then for i=#g.stack.states,1,-1 do if getmetatable(g.stack.states[i])==class then screen=g.stack.states[i];break end end end + if not screen or getmetatable(screen)~=class or screen.game~=g then retire();return end + if not current or current.screen~=screen or current.save~=g.save or current.data~=g.data then + retire() + local c={screen=screen,game=g,save=g.save,data=g.data,loader=g.mods,edition=require('src.core.GameVersion').get(),extras={},party={}} + current=c + function c:valid() + if not self.game or Native.game()~=self.game or self.game.save~=self.save or self.game.data~=self.data + or self.game.mods~=self.loader or require('src.core.GameVersion').get()~=self.edition then return false end + for _,s in ipairs(self.game.stack.states)do if s==self.screen then return true end end + return false + end + c.mons=require('src.compat.gen1.PokemonView').new(g,function()return c:valid()end) + end + if not current:valid()then retire();return end + return current +end +local function actions(c) + local s=c.screen;if not s.submenu then c.list,c.nativeList=nil,nil;return nil end + if s.submenu~=c.nativeList then + c.nativeList=s.submenu;c.list={};c.rows={} + for i,row in ipairs(s.submenu)do + local key=row.move or(type(row.action)=='string'and row.action:upper()or('NATIVE_ACTION_'..i)) + c.list[i]=key;c.rows[key]=row + end + end + return c.list +end +local function sync(c) + if not c or not c:valid()or not c.screen.submenu then return end + local list=actions(c);local rows={} + for i,key in ipairs(list or{})do + assert(type(key)=='string','invalid party action label') + rows[i]=c.rows[key]or{label=key,action='source_field_move',sourceLabel=key,sourceOwner=Call.owner(M,'update')} + local texts=c.extras._actionTexts + if texts and texts.list==list and texts.texts[i]then + local copy={};for k,v in pairs(rows[i])do copy[k]=v end;copy.label=texts.texts[i];rows[i]=copy + end + end + assert(#rows>0,'party action list cannot be empty') + c.nativeList=rows;c.screen.submenu=rows + c.screen.subIndex=math.max(1,math.min(#rows,c.screen.subIndex or 1)) +end +function M.update()return Boundary.invoke('party.update')end +function M.handleInput()return Boundary.invoke('party.input')end +function M._dispatch(screen,dt,body) + local c=observe(screen);if not c then return body()end;actions(c) + local result=Boundary.dispatch('party.update',body,function()return Call.call(M,'update',dt)end) + sync(c);return result +end +function M._input(screen,body) + local c=observe(screen);if not c then return body()end;actions(c) + return Boundary.dispatch('party.input',function()sync(c);return body()end,function()return Call.call(M,'handleInput',screen.game.input)end) +end +function M._fieldAction(screen,mon,row) + local c=observe(screen);if not c then return false end + local typed=c.mons:view(mon) + local result=Call.call(require('src.core.game3.field_moves'),'fromMenu',row.sourceLabel, + {mon=typed,session=require('src.core.Game3').forNative(c.game).session}) + if type(result)=='table'and result.text then screen:say(result.text)end + return true +end +local service={release=retire,poll=function()if current and not current:valid()then retire()end end, + releaseOwner=function(_,who) + local c=current;if not c or not c:valid()then retire();return end + local list=c.screen.submenu + if list then + for i=#list,1,-1 do if list[i].action=='source_field_move'and list[i].sourceOwner==who then table.remove(list,i)end end + if #list==0 then c.screen.submenu=nil else c.screen.subIndex=math.min(c.screen.subIndex or 1,#list)end + end + c.nativeList,c.list,c.rows=nil,nil,nil;c.extras={} + end} +require('src.core.game3.asset_stream').register(service) +M._service=service +setmetatable(M,{__index=function(_,key) + local c=observe();if not c then if key=='open'then return false end;return nil end + local s=c.screen + if key=='open'then return not s.closed end + if key=='mode'then return s.submenu and'action'or'party'end + if key=='cursor'then return s.index end + if key=='actionCursor'then return s.subIndex or 1 end + if key=='ACTIONS'then return actions(c)end + if key=='_party'then + local party=s:party();for i,m in ipairs(party)do c.party[i]=c.mons:view(m)end + for i=#c.party,#party+1,-1 do c.party[i]=nil end;return c.party + end + if key=='_cursorOptionText'then return function(label)local row=c.rows and c.rows[label];return row and row.label or label end end + return c.extras[key] +end,__newindex=function(t,key,value) + if type(value)=='function'and(key=='update'or key=='handleInput')then rawset(t,key,value);return end + local c=assert(observe(),'native party menu is not open') + if key=='ACTIONS'then assert(type(value)=='table','invalid party actions');actions(c);c.list=value + elseif key=='actionCursor'then assert(type(value)=='number'and value%1==0 and value>=1,'invalid party action cursor');c.screen.subIndex=value + else c.extras[key]=value end +end}) +return M diff --git a/src/ui/game3/rs/move_relearner.lua b/src/ui/game3/rs/move_relearner.lua new file mode 100644 index 000000000..378cbd1f1 --- /dev/null +++ b/src/ui/game3/rs/move_relearner.lua @@ -0,0 +1 @@ +return require('src.ui.game3.move_relearner') diff --git a/src/ui/game3/rse/move_relearner.lua b/src/ui/game3/rse/move_relearner.lua new file mode 100644 index 000000000..378cbd1f1 --- /dev/null +++ b/src/ui/game3/rse/move_relearner.lua @@ -0,0 +1 @@ +return require('src.ui.game3.move_relearner') diff --git a/src/ui/game3/stack.lua b/src/ui/game3/stack.lua new file mode 100644 index 000000000..90cfc229e --- /dev/null +++ b/src/ui/game3/stack.lua @@ -0,0 +1,42 @@ +-- Native GBA modal UI over the current Game's real StateStack. +local Plane = require("src.ui.ModalStack") +local Runtime = require("src.mods.Runtime") +local M, clearHooks = {}, {} +local function current(required) + local game = package.loaded["src.core.Game"] + if type(game) == "table" and game.nativeStackReady ~= false and game.stack + and require("src.core.GameVersion").isGen3() then + local view = Plane.forGame(game) + game.stack._modalTracker.onCleared = function() + for i = #clearHooks, 1, -1 do + local row = clearHooks[i] + if row.stopped or row.bus and row.bus ~= Runtime.hooks then table.remove(clearHooks, i) end + end + local pending = {}; for i, row in ipairs(clearHooks) do pending[i] = row end + for _, row in ipairs(pending) do if not row.stopped then row.fn() end end + end + return view + end + assert(not required, "Gen3 modal UI needs a live Gen3 Game") +end +function M.onClear(fn) + assert(type(fn) == "function", "native modal clear hook must be a function") + local row = { fn = fn, bus = Runtime.currentMod and Runtime.hooks } + clearHooks[#clearHooks + 1] = row + return function() row.stopped = true end +end +for _, name in ipairs({ "push", "pop", "clear", "top", "has", "busy", "fullscreen", "drawOrder", "depth" }) do + M[name] = function(...) + local view = current(name == "push" or name == "clear") + if view then return view[name](...) end + if name == "drawOrder" then return {} end + if name == "depth" then return 0 end + if name ~= "top" then return false end + end +end +return setmetatable(M, { + __index = function(_, key) if key == "_layers" then local view = current(true); return view._layers end end, + __newindex = function(t, key, value) + if key == "_layers" then current(true)._layers = value else rawset(t, key, value) end + end, +}) diff --git a/src/ui/game3/start_menu.lua b/src/ui/game3/start_menu.lua new file mode 100644 index 000000000..0a56b3faa --- /dev/null +++ b/src/ui/game3/start_menu.lua @@ -0,0 +1,73 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- The source menu is the native GBA START state, with its real rows/input. +local Native=require('src.compat.gen1.NativeSession') +local Boundary=require('src.compat.gen1.ScriptBoundary') +local Call=require('src.compat.gen1.SourceCall') +local Flow=require('src.compat.gen1.UiFlow') +local Scope=require('src.render.GraphicsScope') +local M={isMenu=true} +local flows={} +local function current() + local g=Native.game();local class=package.loaded['src.ui.Gen3StartMenu'] + if not(g and g.stack and class)then return end + for i=#(g.stack.states or{}),1,-1 do + local state=g.stack.states[i] + if getmetatable(state)==class and state.game==g then return state,g end + end +end +function M.isOpen()return current()~=nil end +function M.close() + local state=current();if not state then return false end + local flow=state.nativeUiFlow;local host=require('src.mods.Runtime') + local owner=host.currentMod or require('src.core.Game3')._callbackOwner() + if flow and flow.who~=owner then return false,'start menu belongs to another mod'end + state:close();if flow then flow:retire()end;return true +end +M.cancel=M.close +function M.show(opts) + if opts~=nil and(type(opts)~='table'or getmetatable(opts)~=nil)then return false,'invalid start menu options'end + for key,value in pairs(opts or{})do + if key~='index'or type(value)~='number'or value%1~=0 or value<1 then return false,'invalid start menu cursor'end + end + local g,w=Native.resolve();if not g then return false,'no native field menu'end + if current()then return false,'native start menu already open'end + local ready,why=require('src.world.WorldActions').ready(g,w);if not ready then return false,why end + local flow,reason=Flow.new();if not flow then return false,reason end + local ok,state=pcall(function()return flow:admit(require('src.ui.Gen3StartMenu').new,g,opts)end) + if not ok then flow:retire();error(state,0)end + flow:push(state);flows[flow]=true;return state +end +local function update()return Boundary.invoke('start_menu.update')end +local function draw()return Boundary.invoke('start_menu.draw')end +M.update,M.draw=update,draw +function M._update(state,dt,body) + if M.update==update then return body(state,dt)end + if current()~=state then return body(state,dt)end + return Boundary.dispatch('start_menu.update',function()return body(state,dt)end,function()return Call.call(M,'update',dt)end) +end +function M._draw(state,body) + if M.draw==draw or current()~=state then return body(state)end + local ok,why=Scope.call(function() + return Boundary.dispatch('start_menu.draw',function()return body(state)end,function()return Call.call(M,'draw')end) + end) + if not ok then error(why,0)end;return why +end +M._service={release=function() + for flow in pairs(flows)do flow:retire()end;flows={} +end,poll=function() + for flow in pairs(flows)do + local present=false + if not flow.retired then + for _,state in ipairs(flow.stack.states)do + if state.nativeUiFlow==flow then present=true;break end + end + end + if not present then flow:retire();flows[flow]=nil end + end +end} +require('src.core.game3.asset_stream').register(M._service) +return setmetatable(M,{__index=function(_,key) + local state=current() + if key=='open'then return state~=nil end + if key=='cursor'then return state and state.index end +end}) diff --git a/src/world/Encounter.lua b/src/world/Encounter.lua index 1c973b995..d5a86b74a 100644 --- a/src/world/Encounter.lua +++ b/src/world/Encounter.lua @@ -338,10 +338,27 @@ local gen4Views = setmetatable({}, { __mode = "k" }) -- and what shape they arrive in. Gen 1, 2 and 3 fall through it unchanged: -- they have no `map.encounters`, so the id is still `mapId`, and no -- `grassRate`, so the table is returned exactly as it was. +local gen3WildSetVar={firered=0x4024,leafgreen=0x4024,emerald=0x403e} +-- Alternate GBA wild headers use the live native script variable, not RNG. +-- Header zero is the base table; an out-of-range script value resets to it. +function Encounter.gen3Variant(def,save,edition) + if not(type(def)=='table'and type(def.alternates)=='table')then return def end + local key=gen3WildSetVar[edition] + local n=key and save and save.gen3Vars and save.gen3Vars[key] + if type(n)=='number'and n%1==0 and n>=1 and n<=#def.alternates then return def.alternates[n]end + return def +end + function Encounter.forMap(data, mapDef, mapId, hour, save) local all = data and data.encounters if not all then return nil end local def = all[(mapDef and mapDef.encounters) or mapId] + if data.constants and data.constants.gen3Layout then + def=Encounter.gen3Variant(def,save,require('src.core.GameVersion').get()) + if package.loaded['src.mods.FeatureOptions']then + def=require('src.world.EncounterActivity').forMap(data,save,mapDef,mapId,def) + end + end if data.field and data.field.gen2Swarms then def=require("src.world.Gen2Swarms").forMap(data,save,mapDef,mapId,def) end @@ -494,4 +511,19 @@ function Encounter.forMap(data, mapDef, mapId, hour, save) return view end +-- Guaranteed GBA selection used by the native field move and mod callers. +-- Eligibility remains with the native map; this only consumes its admitted pool. +function Encounter.sweetScent(data,save,mapDef,mapId,water,eligible,rng) + if not eligible then return nil end + local def=Encounter.forMap(data,mapDef,mapId,nil,save) + local slots=def and def.grass + if water then slots=def and def.water end + if not slots then return nil end + local roam=require('src.world.Gen3Roamers') + if roam.check(data,save,mapId,rng)then + local enc=roam.encounterFor(data,save) + if enc then return enc,'gen3'end + end + return Encounter.chooseTable(slots,rng) +end return Encounter diff --git a/src/world/EncounterActivity.lua b/src/world/EncounterActivity.lua new file mode 100644 index 000000000..d970f4af6 --- /dev/null +++ b/src/world/EncounterActivity.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional activity preferences move species between existing weighted slots. +-- No species, level range, water pool or encounter rate is invented. +local A={} +local classes={} +local groups={ + morning={10,11,12,13,14,15,16,17,18,21,22,165,166,177,178,187,188,189,265,266,267,276,277,278,279,283,284,315}, + day={23,24,27,28,37,38,46,47,69,70,71,84,85,102,103,114,161,162,179,180,181,191,192,263,264,273,274,275,285,286,309,310,322,323,331,332}, + night={19,20,41,42,43,44,45,48,49,52,53,92,93,94,163,164,167,168,169,198,200,228,229,261,262,302,353,354,355,356}, +} +for phase,ids in pairs(groups)do for _,id in ipairs(ids)do classes[id]=phase end end +function A.phase(time) + local n=(tonumber(time)or 300)%1200 + return n<200 and'morning'or n<650 and'day'or'night' +end +function A.reweight(area,phase,national,profiles) + if type(area)~='table'or type(area.slots)~='table'or#area.slots==0 then return area end + local ranked={} + for i,slot in ipairs(area.slots)do + local activity=(profiles or classes)[national(slot.species)] + local score=activity==phase and 2 or(activity=='night'or phase=='night'and activity~=nil)and 0 or 1 + ranked[i]={slot=slot,score=score,order=i} + end + table.sort(ranked,function(a,b)if a.score==b.score then return a.orderb.score end) + local out={};for k,v in pairs(area)do out[k]=v end;out.slots={} + for i,r in ipairs(ranked)do + local slot={};for k,v in pairs(r.slot)do slot[k]=v end + -- The native chooser stores weights on entries, whereas source packs + -- assign them by position. Preserve the destination's weight explicitly. + slot.chance=area.slots[i].chance;out.slots[i]=slot + end + return out +end +function A.forMap(data,save,mapDef,mapId,source) + local service=package.loaded['src.mods.FeatureOptions'] + local p=service and service.provider('timedEncounters') + if not(p and source and source.grass and mapDef and(mapDef.mapType=='ROUTE'or mapDef.mapType=='TOWN'or mapDef.mapType=='CITY'))then return source end + local id=mapId + local identity=package.loaded['src.compat.gen1.MapIdentity'] + if identity then id=identity.bind(data.maps,require('src.core.GameVersion').get()):legacyId(mapId)or mapId end + if service.call(p,'outdoors',id)~=true then return source end + local time=service.call(p,'time');local phase=A.phase(time) + local area=A.reweight(source.grass,phase,function(key)local row=data.pokemon[key];return row and row.dex end, + data.constants and data.constants.encounterActivity) + local out={};for k,v in pairs(source)do out[k]=v end;out.grass=area;return out +end +return A diff --git a/src/world/Gen2Fishing.lua b/src/world/Gen2Fishing.lua new file mode 100644 index 000000000..20099b73f --- /dev/null +++ b/src/world/Gen2Fishing.lua @@ -0,0 +1,239 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Gold/Silver/Crystal's native cast owns its rolled individual until the +-- verdict closes. Presentation consumers observe this state, never reroll it. +local M={CAST_FRAMES=40,BITE_FRAMES=40} +local Version=require('src.core.GameVersion') +local Runtime=require('src.mods.Runtime') +local rods={OLD_ROD=58,GOOD_ROD=59,SUPER_ROD=61,ITEM_058=58,ITEM_059=59,ITEM_061=61} +local delta={up={0,-1},down={0,1},left={-1,0},right={1,0}} +local active=setmetatable({},{__mode='k'}) +local owners=setmetatable({},{__mode='k'}) +local prepared=setmetatable({},{__mode='k'}) +local admitting=setmetatable({},{__mode='k'}) +local rolling=setmetatable({},{__mode='k'}) + +function M.isRod(id,items) + local index=rods[id];local def=items and items[id] + return index~=nil and not(def and def.index and def.index~=index) +end +local function context(world,game) + if not(Version.isGen2() and game and game.overworld==world + and game.data and game.save and world.map and world.player and game.stack + and type(game.stack.top)=='function' and type(game.stack.push)=='function')then return false end + if game.stack.worldOwner then + return game.stack.worldState==world and game.world==world and game.phase=='play' + end + -- The upstream stack keeps the native field among its screens. A retired + -- field cannot keep a cast alive merely because game.overworld names it. + for _,state in ipairs(game.stack.states or{})do if state==world then return true end end + return false +end +local function refs(world,game) + return setmetatable({world=world,game=game,data=game.data,save=game.save, + map=world.map,player=world.player,x=world.player.cellX,y=world.player.cellY, + facing=world.player.facing},{__mode='v'}) +end +local function valid(r,edition) + local w,g=r and r.world,r and r.game + return w and g and context(w,g) and Version.get()==edition + and g.data==r.data and g.save==r.save and w.map==r.map and w.player==r.player + and w.player.cellX==r.x and w.player.cellY==r.y and w.player.facing==r.facing +end +local function fieldReady(world,game,ignoreOverlay) + if not context(world,game)then return false end + if game.linkSession or game.linkNet and not game.linkNet.closed then return false end + local p,map=world.player,world.map + if type(p.cellX)~='number' or type(p.cellY)~='number' or p.cellX%1~=0 or p.cellY%1~=0 + or not map:inBounds(p.cellX,p.cellY)then return false end + if world.battleActive or world:busy(ignoreOverlay) or p.moving or p.surfing or p.spinning + or p.inputLocked or p.fieldMove or p.stairExit or world.iceSlide or world.mapSetup + or world.headbutt or world.moveState or world.fieldMove or world._modScriptLock then return false end + return true +end +function M.canUse(world,game,rod,ignoreOverlay) + if admitting[world] or rolling[world] or not fieldReady(world,game,ignoreOverlay) + or not M.isRod(rod,game.data.items)then return false end + local p=world.player;local d=delta[p.facing or 'down'];if not d then return false end + local x,y=p.cellX+d[1],p.cellY+d[2] + return M.facingWater(world,x,y) +end + +function M.facingWater(world,x,y) + if world.map:inBounds(x,y)then return world.map:isWaterCell(x,y) and true or false end + -- Read the same un-clamped native connection landing as movement. The rod + -- stays in this map's coordinates and still uses this map's fish group. + local dest,tileset,cx,cy=world:connectionLanding(world.player.facing) + return dest~=nil and require('src.world.Map').defIsWaterCell(dest,tileset,cx,cy) and true or false +end + +local function rollNative(world,game,rod,roll) + if not context(world,game) or not M.isRod(rod,game.data.items) then return 'nowhere' end + local p,map=world.player,world.map;local d=delta[p.facing or 'down'] + if type(p.cellX)~='number' or type(p.cellY)~='number' or p.cellX%1~=0 or p.cellY%1~=0 + or not map:inBounds(p.cellX,p.cellY)then return 'nowhere' end + if p.surfing or not d then return 'nowhere' end + local x,y=p.cellX+d[1],p.cellY+d[2] + if not M.facingWater(world,x,y)then return 'nowhere' end + local group=map.def and map.def.fishGroup + if not group or group==0 or group=='FISHGROUP_NONE' then return 'nofish' end + local tod=world.clockTimeOfDay and world.clockTimeOfDay() or world.tod + local enc,handled,candidates,resolvedGroup=roll(game.data,rod,map.def,tod,game.save) + if not handled then return 'nofish' end -- never substitute the Gen1 pond + if Runtime.wantsHook('encounter.fishing')then + enc=Runtime.call('encounter.fishing',function()return enc end,rod,map.id,candidates, + {fishGroup=resolvedGroup or group,data=game.data,maps=game.data.maps, + encounters=world.encounters,tod=tod,daytime=tod}) + end + if enc==nil or enc==false then return 'nibble' end + assert(type(enc)=='table' and (type(enc.species)=='string'or type(enc.species)=='number')and type(enc.level)=='number' + and enc.level%1==0 and enc.level>=1 and enc.level<=100,'invalid native fishing encounter') + -- The native constructor validates/resolves its own edition's species + -- identity before drawing DVs. Do not copy a second naming registry here. + local wild=require('src.pokemon.Pokemon').new(game.data,enc.species,enc.level) + require('src.battle.BattleState').giveWildHeldItem(game.data,wild) + prepared[wild]={refs=refs(world,game),edition=Version.get(),species=wild.species,level=wild.level} + return 'battle',wild +end +function M.roll(world,game,rod,roll) + if rolling[world]then return 'nowhere' end + rolling[world]=true + local ok,outcome,wild=pcall(rollNative,world,game,rod,roll) + rolling[world]=nil + if not ok then error(outcome,0)end + return outcome,wild +end + +-- Only a native individual made by the roll above may use the constructor's +-- no-reroll handoff. Consumed before any battle/stack/dex mutations. +function M.takePrepared(game,wild,species,level) + local r=prepared[wild] + assert(r and valid(r.refs,r.edition) and r.refs.game==game + and r.species==species and r.level==level and wild.species==species + and wild.level==level,'expired or invalid native fishing individual') + prepared[wild]=nil + return wild +end + +local function clear(world,st,keepWild) + local r=owners[st];owners[st]=nil + if active[world]==st then active[world]=nil end + if world.fishing==st then world.fishing=nil end + local p=r and r.refs.player + if p and p.fishingState==st then + p.fishing=nil;p.fishingState=nil;p.spriteYOffset=0 + end + if r and world.emote==r.bubble then world.emote=nil end + if not keepWild and st.wild then prepared[st.wild]=nil end +end +function M.cancel(world) + local st=active[world];if st then clear(world,st)end +end +local function finishBattle(world,game,wild) + local ok,result=pcall(function() + local battle=world:_beginFishingBattle(wild) + assert(prepared[wild]==nil and battle and battle.enemy and battle.enemy.mon==wild, + 'native fishing battle must consume the exact individual') + return battle + end) + prepared[wild]=nil + if not ok then error(result,0)end + return result +end +function M.begin(world,game,outcome,wild) + if rolling[world] or not fieldReady(world,game)then return false end + local p=world.player;local d=delta[p.facing or 'down'] + if not d or not M.facingWater(world,p.cellX+d[1],p.cellY+d[2])then return false end + assert(outcome=='battle' or outcome=='nibble' or outcome=='nofish','invalid fishing outcome') + if outcome=='battle'then + local r=prepared[wild] + assert(r and valid(r.refs,r.edition) and r.refs.world==world,'native fishing individual required') + else assert(wild==nil,'empty fishing outcome cannot carry an individual')end + local x,y=p.cellX+d[1],p.cellY+d[2] + local st={phase='cast',timer=M.CAST_FRAMES,outcome=outcome,wild=wild, + bobber={cellX=x,cellY=y,px=x*16,py=y*16},facing=p.facing or 'down'} + owners[st]={refs=refs(world,game),edition=Version.get()};active[world]=st + world.fishing=st;p.fishing=true;p.fishingState=st + return st +end +function M.use(world,game,rod) + if not M.canUse(world,game,rod)then return 'nowhere' end + admitting[world]=true + local wild + local ok,result=pcall(function() + local outcome;outcome,wild=world:rollFishing(rod) + if outcome=='nowhere'then return outcome end + world:beginFishing(outcome,wild) + local st=active[world] + if not(st and world.fishing==st and st.outcome==outcome and st.wild==wild)then return 'nowhere' end + return outcome + end) + admitting[world]=nil + if not ok or result=='nowhere'then + if wild then prepared[wild]=nil end + M.cancel(world) + end + if not ok then error(result,0)end + return result +end +function M.try(world,game,rod) + if not M.canUse(world,game,rod)then return 'nowhere' end + admitting[world]=true + local wild + local ok,result=pcall(function() + local outcome;outcome,wild=world:rollFishing(rod) + if wild then finishBattle(world,game,wild)end + return outcome + end) + admitting[world]=nil + if not ok then if wild then prepared[wild]=nil end;error(result,0)end + return result +end + +function M.tick(world,game) + local st=active[world];if not st then return end + local r=owners[st] + if not(r and valid(r.refs,r.edition)) or world.fishing~=st then clear(world,st);return end + if game.stack:top() and game.stack:top()~=world then return end + if st.phase=='done'then return end + if st.timer>0 then + st.timer=st.timer-1 + world.player.spriteYOffset=st.phase=='bite' and st.timer%2 or 0 + return + end + world.player.spriteYOffset=0 + if st.phase=='cast' and st.outcome=='battle'then + st.phase='bite';st.timer=M.BITE_FRAMES + r.bubble={npc=world.player,frames=M.BITE_FRAMES,totalFrames=M.BITE_FRAMES,bubble=1,age=0} + world.emote=r.bubble + return + end + st.phase='done' + local Strings=require('src.core.Strings') + local line=st.outcome=='battle' and Strings('Oh!\nA bite!') or Strings('Not even a nibble!') + local ok,err=pcall(function() + local box=require('src.render.TextBox').new(game,line,function() + if not(owners[st]==r and valid(r.refs,r.edition) and world.fishing==st)then + clear(world,st);return + end + clear(world,st,true) + if st.wild then finishBattle(world,game,st.wild)end + end) + r.box=box;game.stack:push(box) + end) + if not ok then clear(world,st);error(err,0)end +end +function M.poll() + for world,st in pairs(active)do + local r=owners[st];local exists=true + if r and r.box then + exists=false + for _,screen in ipairs(r.refs.game and r.refs.game.stack.states or{})do if screen==r.box then exists=true;break end end + end + if not(r and valid(r.refs,r.edition)) or world.fishing~=st or not exists then clear(world,st)end + end +end +function M.release() + for world,st in pairs(active)do clear(world,st)end + prepared=setmetatable({},{__mode='k'}) +end +return M diff --git a/src/world/Gen3FieldMove.lua b/src/world/Gen3FieldMove.lua index 4d810425a..575f8a4f5 100644 --- a/src/world/Gen3FieldMove.lua +++ b/src/world/Gen3FieldMove.lua @@ -25,17 +25,46 @@ -- (RomExtractorGen3:playerAvatarStates, constants.gen3PlayerSprites). The -- Pokemon's picture is its own front sprite, which the import already rips. -- --- RECONSTRUCTED: the BAND. Emerald's is gFieldMoveStreaksTiles, a tilemap +-- DEFAULT PRESENTATION: the BAND. Emerald's is gFieldMoveStreaksTiles, a tilemap -- scrolled behind the picture, and the import does not rip it; nothing here -- pretends otherwise. What is drawn instead is the same figure -- a lit band -- the height of the cartridge's, streaked with moving lines, sweeping in from -- one side and out the other -- in the cartridge's own window colours where -- the cache carries them. The timings below are measured off the scene. +-- Compatible observers may select the independently imported cartridge strips +-- and presentation program. The native default below remains available. local Font = require("src.render.Font") local Gen3FieldMove = {} Gen3FieldMove.__index = Gen3FieldMove +local Version=require('src.core.GameVersion') +local Presentation=require('src.world.Gen3FieldMovePresentation') +local allocations=setmetatable({},{__mode='k'}) +local preparing=setmetatable({},{__mode='k'}) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +local function release(x)if x and x.release then pcall(x.release,x)end end +local function source()return Version.isGen3()and package.loaded['src.core.game3.field_move_show_mon']end +local function nativeWorld(game) + local session=Version.isGen3()and package.loaded['src.compat.gen1.NativeSession'] + if session and session.resolve then + local owner,world=session.resolve(game);if owner==game then return world end + return nil + end + return game.overworld +end +local function valid(self,top) + if self.done then return false end + local c=self.cohort;local game=self.game + if c and not self:cohortCurrent()then return false end + local stack=game.stack;local states=stack and stack.states + if type(states)=='table'then + local found=false;for _,s in ipairs(states)do if s==self then found=true;break end end + if not found or top and states[#states]~=self then return false end + end + return true +end -- RECONSTRUCTED: the beats of the sweep, in frames. -- @@ -66,7 +95,7 @@ local BAND_TOP, BAND_HEIGHT = 52, 56 local STREAKS = 11 function Gen3FieldMove:uiSize() - return require("src.ui.Theme").uiSize() + return 240,160 end -- The two colours the band is drawn in. The cartridge's own window palette @@ -84,39 +113,76 @@ local function bandColours(game) return shade, ink end -local function frontSprite(game, mon) +local function frontPath(game, mon) if not (mon and mon.species) then return nil end local ok, path = pcall(function() return require("src.pokemon.Sprites").path( game.data, mon.species, "front", { mon = mon }) end) if not (ok and type(path) == "string") then return nil end - local okImg, img = pcall(love.graphics.newImage, path) - return okImg and img or nil + return require('src.render.Assets').resolve(path) +end +local function frontSprite(game,mon) + local path=frontPath(game,mon);if not path then return nil end + local ok,img=pcall(love.graphics.newImage,path) + return ok and img or nil,path end -- Push the sweep over whatever is on screen and call `onDone` when it has -- passed. Returns false when there is nothing to show -- no Pokemon, or a -- dataset with no picture for it -- so the caller can simply get on with the -- move rather than waiting on a state that would draw nothing. -function Gen3FieldMove.show(game, mon, onDone) - if not (game and game.stack) then return false end - local sprite = frontSprite(game, mon) +local function create(game, mon, onDone, opts) + if not(game and game.stack and game.stack.push)or(onDone~=nil and type(onDone)~='function')then return false end + for _,state in ipairs(game.stack.states or{})do if getmetatable(state)==Gen3FieldMove and not state.done then return false,'native field move is busy'end end + local world=nativeWorld(game) + if Version.isGen3()and not world then return false,'no admitted native field world'end + local cohort=setmetatable({data=game.data,save=game.save,mods=game.mods,stack=game.stack,edition=Version.get(),world=world,actor=world and world.player,map=world and world.map},{__mode='v'}) + local facade=source() + opts=opts or(facade and facade._nativeOptions and facade._nativeOptions(game))or{} + local sprite,path = frontSprite(game, mon) if not sprite then return false end local self = setmetatable({}, Gen3FieldMove) self.game = game self.mon = mon - self.sprite = sprite - self.onDone = onDone + self.sprite = sprite;self.spritePath=path self.t = 0 + self.frame=0;self.options=opts self.phase = "raise" self.shade, self.ink = bandColours(game) -- THE POSE GOES ON HERE and comes off in finish(), so it is on screen for -- exactly as long as the window is -- which is what the cartridge does. - local player = game.overworld and game.overworld.player - if player then - self.player = player + local player = world and world.player + self.actor=player + self.cohort=cohort + if not self:cohortCurrent()then release(sprite);return false,'native field move preparation cohort retired'end + self.sourceOwned=opts.sourceStarted==true or opts.sourceOwned==true;self.sourceOwner=opts.sourceOwner + local callback=onDone;local outcome,err,running + self.discardContinuation=function()callback=nil;outcome=nil;err=nil end + self.onDone=function() + if self.cancelled or not self:cohortCurrent()then return false,'native field move admission retired'end + if err then error(err,0)end + if outcome then return unpack(outcome,1,outcome.n)end + if running or not(self.completed and self.callbackReady)then return false,'native field move has not completed'end + running=true;local fn=callback;callback=nil + local values=pack(pcall(function()if fn then return fn()end end));fn=nil + if not values[1]then err=values[2];error(err,0)end + outcome={n=values.n-1};for i=2,values.n do outcome[i-1]=values[i]end + return unpack(outcome,1,outcome.n) + end + allocations[self]={front=sprite} + if opts.presentation=='cartridge'then + self.effect=Presentation.new(mon,opts,game,world) + local ok,why=self:reloadArtwork() + if not ok then self:exit();return false,why end + end + if player and(not self.effect and opts.pose~=false or self.effect and self.effect.posed)then + self.player=player self.hadPose = player.fieldMove + self.hadPoseFrame=player.fieldMovePose + self.hadPoseOwner=player._gen3FieldMovePoseOwner + player._gen3FieldMovePoseOwner=self + self.poseInstalled=true player.fieldMove = true -- the pose's own frame counter. It lives here rather than on the player -- because the overworld does not update while this state is on top, so @@ -124,29 +190,138 @@ function Gen3FieldMove.show(game, mon, onDone) self.age = 0 player.fieldMovePose = 0 end - game.stack:push(self) + local ok,why=pcall(game.stack.push,game.stack,self) + if not ok then + local stack=cohort.stack + if stack and stack.removeAll then stack:removeAll({self})else self:exit()end + return false,why + end + if self.entryRejected then self:retire();return false,'native field move start vetoed'end + return true,self +end +function Gen3FieldMove.show(game,mon,onDone,opts) + if type(game)~='table'or preparing[game]then return false,'native field move preparation is busy'end + preparing[game]=true + local result=pack(pcall(create,game,mon,onDone,opts));preparing[game]=nil + if not result[1]then error(result[2],0)end + return unpack(result,2,result.n) +end +function Gen3FieldMove:cohortCurrent() + local c=self.cohort;local game=self.game + return c and game.data==c.data and game.save==c.save and game.mods==c.mods and game.stack==c.stack + and Version.get()==c.edition and nativeWorld(game)==c.world + and(not c.world or c.world.player==c.actor and c.world.map==c.map) +end +function Gen3FieldMove:configurePresentation(opts) + if self.frame~=0 then return false,'field move options belong to admission'end + local player=self.actor + local posed=opts.pose==true + if self.poseInstalled and not posed and player._gen3FieldMovePoseOwner==self then + player.fieldMove,player.fieldMovePose,player._gen3FieldMovePoseOwner=self.hadPose,self.hadPoseFrame,self.hadPoseOwner + self.poseInstalled=false;self.player=nil + elseif posed and not self.poseInstalled and player then + self.player=player + self.hadPose,self.hadPoseFrame,self.hadPoseOwner=player.fieldMove,player.fieldMovePose,player._gen3FieldMovePoseOwner + player.fieldMove,player.fieldMovePose,player._gen3FieldMovePoseOwner=true,0,self;self.poseInstalled=true + end + if self.effect then + self.effect.posed=posed;self.effect.phase=posed and'pose'or 1;self.effect.poseWait=24 + self.effect.noDuck=opts.noDuck==true + end return true end -function Gen3FieldMove:finish() - if self.done then return end - self.done = true - if self.player then - self.player.fieldMove = self.hadPose - self.player.fieldMovePose = nil +function Gen3FieldMove:configure(mon,opts,callback) + if not valid(self,true)or self.frame~=0 then return false,'native field move start phase retired'end + if type(mon)~='table'or type(opts)~='table'or(callback~=nil and type(callback)~='function')then return false,'invalid field move options'end + local path=frontPath(self.game,mon);if not path then return false,'native field move picture unavailable'end + if path~=self.spritePath then + local sprite=frontSprite(self.game,mon);if not sprite then return false,'native field move picture unavailable'end + if not valid(self,true)then release(sprite);return false,'native field move admission retired'end + local own=allocations[self];release(own.front);own.front=sprite;self.sprite=sprite;self.spritePath=path + end + self.mon=mon;if self.effect then self.effect.species,self.effect.personality=mon.species,mon.personality end + local ok,why=self:configurePresentation(opts);if not ok then return false,why end + self.onDone=callback;return true +end +function Gen3FieldMove:reloadArtwork() + if not self.effect then return true end + local parts={} + local ok,result=pcall(function() + local bytes,why=Presentation.rgba(self.game,self.effect.outdoors and'outdoors'or'indoors');assert(bytes,why) + local data=love.image.newImageData(256,80,'rgba8',bytes);parts[#parts+1]=data + local image=love.graphics.newImage(data);parts[#parts+1]=image;image:setFilter('nearest','nearest') + local q=love.graphics.newQuad(0,0,1,1,256,80);parts[#parts+1]=q + local c=self.cohort + assert(self.game.data==c.data and self.game.save==c.save and self.game.mods==c.mods and Version.get()==c.edition,'field move artwork cohort retired') + return{band=image,quad=q,parts=parts} + end) + if not ok then for _,x in ipairs(parts)do release(x)end;return false,tostring(result)end + local own=allocations[self] + if own.presentation then for _,x in ipairs(own.presentation.parts)do release(x)end end + own.presentation=result;return true +end +function Gen3FieldMove:enter() + local facade=source();if facade and facade._nativeStart then + local result=facade._nativeStart(self);if result==false then self.entryRejected=true end;return result + end +end +function Gen3FieldMove:exit() + self.onDone=nil + if not self.completed and self.discardContinuation then self.discardContinuation()end + local own=allocations[self];allocations[self]=nil + if self.poseInstalled and self.player and self.player._gen3FieldMovePoseOwner==self then + self.player.fieldMove=self.hadPose;self.player.fieldMovePose=self.hadPoseFrame + self.player._gen3FieldMovePoseOwner=self.hadPoseOwner end - if self.game.stack then self.game.stack:pop() end - if self.onDone then self.onDone() end + self.poseInstalled=false;self.sprite=nil + if own then + release(own.front) + if own.presentation then for _,x in ipairs(own.presentation.parts)do release(x)end end + end + local facade=source();if facade and facade._nativeExit then facade._nativeExit(self)end +end +function Gen3FieldMove:retire() + self.done=true;self.cancelled=true;self.completed=false;self.onDone=nil + if self.discardContinuation then self.discardContinuation()end + local stack=self.cohort and self.cohort.stack + if stack and stack.removeAll then stack:removeAll({self})end + self:exit() +end +function Gen3FieldMove:complete(completed) + if not valid(self,true)then return false,'native field move is covered, retired or finished'end + self.done = true + self.completed=completed==true;self.cancelled=not self.completed + local callback=self.onDone;self.onDone=nil + local ok,why=true + if self.game.stack then ok,why=pcall(self.game.stack.pop,self.game.stack)end + self:exit() + self.callbackReady=true + if completed and callback then callback()end + if not ok then error(why,0)end + return true +end +function Gen3FieldMove:finish() + return self:complete(true) end -function Gen3FieldMove:update() +function Gen3FieldMove:updateBody() + if not valid(self,true)then return false end + self.frame=self.frame+1 self.t = self.t + 1 - if self.player then + if self.poseInstalled and self.player._gen3FieldMovePoseOwner==self then self.age = (self.age or 0) + 1 -- drawPose clamps, so this simply counts upward and the pose settles on -- its last frame -- the ball held up -- and stays there self.player.fieldMovePose = math.floor(self.age / POSE_STEP) end + if self.effect then + local done=Presentation.advance(self.effect,function() + require('src.core.Sound').playCry(self.game.data,self.mon.species,self.effect.noDuck and{noDuck=true,volume=125}or nil) + end) + if done then return self:finish()end + return + end if self.phase == "raise" and self.t >= RAISE_FRAMES then self.phase, self.t = "in", 0 elseif self.phase == "in" and self.t >= IN_FRAMES then @@ -157,6 +332,13 @@ function Gen3FieldMove:update() self:finish() end end +function Gen3FieldMove:update() + if not valid(self,false)then if not self.done then self:retire()end;return false end + if not valid(self,true)then return false end + local facade=source() + if facade and facade._nativeStep then return facade._nativeStep(self,function()return self:updateBody()end)end + return self:updateBody() +end -- A cutscene swallows the pad: pressing A during the sweep must not reach the -- map underneath and start something else. @@ -176,7 +358,13 @@ function Gen3FieldMove:offset(width) return 0 end -function Gen3FieldMove:draw() +function Gen3FieldMove:drawBody() + if not valid(self,false)then return false end + if self.effect then + local own=allocations[self] + if own and own.presentation then return Presentation.draw(self.effect,own.presentation,self.sprite or own.front)end + return false + end -- nothing at all while the ball goes up: see RAISE_FRAMES if self.phase == "raise" then return end local w = select(1, self:uiSize()) @@ -189,8 +377,7 @@ function Gen3FieldMove:draw() -- ...and the streaks running through it, which are what make it read as a -- sweep rather than a bar g.setColor(self.ink[1], self.ink[2], self.ink[3], 0.35) - local frame = (self.frame or 0) - self.frame = frame + 1 + local frame=self.frame for i = 1, STREAKS do local y = BAND_TOP + 3 + (i - 1) * math.floor((BAND_HEIGHT - 6) / STREAKS) local len = 40 + (i * 37) % 90 @@ -223,5 +410,21 @@ function Gen3FieldMove:draw() end g.setColor(1, 1, 1, 1) end +function Gen3FieldMove:draw() + if not valid(self,false)then return false end + local renderer=package.loaded['src.render.Renderer'] + if self.externalDrawSerial and renderer and self.externalDrawSerial==renderer.frameSerial then return true end + local facade=source() + if facade and facade._nativeDraw then return facade._nativeDraw(self,function()return self:drawBody()end)end + return self:drawBody() +end +Gen3FieldMove.valid=valid +Gen3FieldMove.present=function(self)return valid(self,false)end +Gen3FieldMove.cancel=Gen3FieldMove.retire +function Gen3FieldMove:drawExternal() + local result=self:drawBody() + local renderer=package.loaded['src.render.Renderer'];self.externalDrawSerial=renderer and renderer.frameSerial + return result +end return Gen3FieldMove diff --git a/src/world/Gen3FieldMovePresentation.lua b/src/world/Gen3FieldMovePresentation.lua new file mode 100644 index 000000000..d9667a8b7 --- /dev/null +++ b/src/world/Gen3FieldMovePresentation.lua @@ -0,0 +1,102 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- An optional cartridge presentation on the native field-move state. The +-- StateStack owns its updates; rendering never advances these observations. +local Art=require('src.import.Gen3FieldMoveArt') +local M={} +local OUTDOORS={TOWN=true,CITY=true,ROUTE=true,UNDERWATER=true,OCEAN_ROUTE=true,[1]=true,[2]=true,[3]=true,[5]=true,[6]=true} +function M.outdoors(game,world) + world=world or game.overworld + local map=world and world.map + return OUTDOORS[map and map.def and map.def.mapType]==true +end +function M.new(mon,opts,game,world) + return{species=mon.species,personality=mon.personality,noDuck=opts.noDuck==true, + outdoors=M.outdoors(game,world),phase=opts.pose and'pose'or 1,posed=opts.pose==true, + poseWait=24,hofs=0,winL=240,winT=80,winB=81,revealed=0,cleared=0} +end +function M.advance(fx,cry) + if fx.phase=='pose'then + fx.poseWait=fx.poseWait-1 + if fx.poseWait>0 then return false end + fx.phase=1 + end + local phase=fx.phase + if phase<3 then fx.phase=phase+1 + elseif phase==7 then return true + else + -- Every active band phase scrolls except the outdoor hide frame. + if not(fx.outdoors and phase==6)then fx.hofs=fx.hofs-16 end + if phase==3 then + local ready + if fx.outdoors then + fx.winL=math.max(0,fx.winL-16);fx.winT=math.max(40,fx.winT-2);fx.winB=math.min(120,fx.winB+2) + ready=fx.winL==0 and fx.winT==40 and fx.winB==120 + else + ready=fx.revealed>=16 + if not ready then fx.revealed=fx.revealed+1 end + end + if ready then fx.sprite={x=320,y=80,state='on'};fx.phase=4 end + elseif phase==4 then + if fx.sprite and fx.sprite.done then fx.phase=5 end + elseif phase==5 then + if fx.outdoors then + fx.winT=math.min(80,fx.winT+6);fx.winB=math.max(81,fx.winB-6) + if fx.winT==80 and fx.winB==81 then fx.phase=6 end + else fx.phase=6 end + elseif phase==6 then + if fx.outdoors then fx.hidden=true;fx.phase=7 + elseif fx.cleared>=16 then fx.phase=7 else fx.cleared=fx.cleared+1 end + end + end + local s=fx.sprite + if s and not s.done then + if s.state=='on'then + s.x=s.x-20 + if s.x<=120 then s.x=120;s.wait=30;s.state='wait';if cry then cry()end end + elseif s.state=='wait'then s.wait=s.wait-1;if s.wait==0 then s.state='off'end + elseif s.x < -64 then s.done=true else s.x=s.x-20 end + end + return false +end +function M.rgba(game,kind) + local facade=package.loaded['src.core.game3.field_effects'] + local cache=require('src.compat.gen1.NativeSession').game()==game and facade and facade._cache + if cache then + local extract=package.loaded['src.import.gba.extract_island1'] + local path=((extract and extract.CACHE_ROOT)or'data/generated/gba')..'/field_effects/field_move_streaks_'..kind..'.rgba' + local bytes=cache:read(path) + if bytes~=nil then assert(type(bytes)=='string'and#bytes==256*80*4,'invalid field move streak payload');return bytes end + return nil,'installed field move streak artwork is unavailable' + end + local record=game.data.constants and game.data.constants.gen3FieldMovePresentation + local Version=require('src.core.GameVersion');local edition=Version.get() + if not(record and record.schema==Art.SCHEMA and record.edition==edition + and Version.acceptsSha1(edition,record.romSha1)and record.strips and record.strips[kind])then + return nil,'native field move artwork requires a current import' + end + return Art.rgba(record.strips[kind]) +end +function M.draw(fx,resources,front) + if fx.phase=='pose'then return end + local g=love.graphics;g.setColor(1,1,1,1) + local x0,x1,y0,y1 + if fx.outdoors then + if not fx.hidden and fx.phase>=3 then x0,x1,y0,y1=fx.winL,240,math.max(fx.winT,40),math.min(fx.winB,120)end + elseif fx.phase>=3 and fx.phase<=5 then + local revealing=fx.phase==3 and fx.revealed<16 + x0,x1,y0,y1=revealing and 16 or 0,revealing and math.min(240,16*(fx.revealed+1))or 240,40,120 + elseif fx.phase==6 then x0,x1,y0,y1=math.min(240,16*fx.cleared),240,40,120 end + if x0 and x1>x0 and y1>y0 then + local x=x0 + while x= w * w + h * h then return nil end return { radius = radius } end @@ -200,10 +201,59 @@ function Gen3Flash.circle(game, viewW, viewH) return { radius = 0 } end -- bigger than the corner of the view is the same as no darkness - local w = (tonumber(viewW) or r.screen[1]) / 2 - local h = (tonumber(viewH) or r.screen[2]) / 2 + local width, height = tonumber(viewW) or r.screen[1], tonumber(viewH) or r.screen[2] + local cx, cy = centerX or width / 2, centerY or height / 2 + local w, h = math.max(math.abs(cx), math.abs(width-cx)), math.max(math.abs(cy), math.abs(height-cy)) if radius * radius >= w * w + h * h then return nil end return { radius = radius } end +-- Integer window edges, including the scanline update order. A floating-point +-- circle at pixel centres has different edge pixels. Keep geometry independent +-- of the save and graphics backend; callers get fresh, mutable span records. +function Gen3Flash.spans(radius, width, height, centerX, centerY) + local w, h = math.floor(width or 240), math.floor(height or 160) + local cx, cy = math.floor(centerX or w/2), math.floor(centerY or h/2) + radius = math.floor(radius) + assert(radius==radius and radius=0 and y=y do + put(cy-y,cx-x,cx+x); put(cy+y,cx-x,cx+x) + put(cy-x,cx-y,cx+y); put(cy+x,cx-y,cx+y) + err=err-((y*2)-1); y=y+1 + if err<0 then err=err+2*(x-1); x=x-1 end + end + local result, row = {}, 0 + while row0 then love.graphics.rectangle('fill',0,s.y,math.min(s.left,w),s.height)end + if s.right=0x5a and behavior<=0x5d then + return true,fx,fy,3,true + end + return true,fx,fy + end + return true +end +return M diff --git a/src/world/Map.lua b/src/world/Map.lua index 9d314ea65..6f1be9486 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -11,6 +11,16 @@ local Map = {} Map.__index = Map local GameVersion = require("src.core.GameVersion") +-- A layout observes the same native writes, including ordinary script writes. +function Map:noteGridChange() + self.gridRevision = (self.gridRevision or 0) + 1 + if self._layoutProjection then self._layoutProjection:syncOverrides() end +end + +function Map:gen3Layout() + return require("src.core.game3.layout_native").fromMap(self) +end + -- A GEN 3 LAYOUT KEEPS ITS METATILE GRID AS A PACKED BYTE STRING. -- -- Emerald has 324,579 metatile cells across its 441 layouts and each one is a @@ -36,7 +46,7 @@ local function blockArray(def) local blocks = def and def.blocks if type(blocks) ~= "string" then return blocks end local cached = def._blockArray - if cached then return cached end + if cached and def._blockArraySource == blocks then return cached end -- math.floor, not `//`: the game runs on LuaJIT (Lua 5.1), which has no -- integer-division operator. It parses under 5.3 and is a SYNTAX error in -- the build that matters, so the file does not load at all. @@ -46,6 +56,7 @@ local function blockArray(def) out[i] = (lo + hi * 256) % 1024 end def._blockArray = out + def._blockArraySource = blocks return out end @@ -493,6 +504,7 @@ function Map.new(def, tilesetDef) -- first two only -- its rock platforms stay at the water's own elevation -- and are fenced off by collision instead.) self.elevationPatch = {} + self.gridRevision = 0 -- ...and the same answer keyed by COORDINATE rather than by a computed -- index. See Map:setBlock: the index form depends on writer and reader -- agreeing about def.width, and a cell a script shut must not be able to @@ -556,7 +568,9 @@ local OUTDOOR_ENVIRONMENTS = { [1] = true, [2] = true } -- TOWN, ROUTE function Map.isOutdoor(def) if def.outdoor ~= nil then return def.outdoor end - if def.environment then return OUTDOOR_ENVIRONMENTS[def.environment] == true end + if def.environment ~= nil or def.environmentId ~= nil then + return OUTDOOR_ENVIRONMENTS[require("src.core.Gen2Environment").id(def)] == true + end return def.tileset == "OVERWORLD" end @@ -608,6 +622,8 @@ function Map.isSmashable(objDef) end function Map:blockAt(bx, by) + local outside = self.layoutOutside and self.layoutOutside[bx .. ":" .. by] + if outside then return outside.mid end if bx < 0 or by < 0 or bx >= self.def.width or by >= self.def.height then return self.def.borderBlock end @@ -757,6 +773,8 @@ end -- standing at -- and 15 is the "under a bridge" marker. That is why the -- rule below is a mismatch test and not an equality test. function Map:cellElevation(cx, cy) + local outside = self.layoutOutside and self.layoutOutside[cx .. ":" .. cy] + if outside then return outside.elev end local cells = self.def.elevationCells if not cells then return nil end if cx < 0 or cy < 0 or cx >= self.widthCells or cy >= self.heightCells then @@ -801,11 +819,17 @@ end -- carries you between levels. Reported from play: "unable to walk down the -- stairs into the beach's low water" -- Mossdeep's stairs are 0 and the -- shallow water 3; keeping the upper ground's 4 on the stairs walled it off. +-- Shared with typed map observations; callers still own coordinate validation. +function Map.elevationTransition(at, there, previous) + if there == nil or there == ELEVATION_UNDER_BRIDGE or previous == ELEVATION_UNDER_BRIDGE then + return at, nil + end + return there, there ~= ELEVATION_ANY and there or nil +end function Map:elevationAfter(at, cx, cy, px, py) - local there = self:cellElevation(cx, cy) - if there == nil or there == ELEVATION_UNDER_BRIDGE then return at end - if px and py and self:cellElevation(px, py) == ELEVATION_UNDER_BRIDGE then return at end - return there + local previous = px and py and self:cellElevation(px, py) or nil + local elevation = Map.elevationTransition(at, self:cellElevation(cx, cy), previous) + return elevation end -- A CELL A SCRIPT SHUT, as opposed to one the shipped map ships shut. @@ -928,6 +952,13 @@ function Map:isWalkableCell(cx, cy) return self:warpAtCell(cx, cy) ~= nil end +-- Public Gen2 terrain query. Native connection movement may inspect the +-- extended border through isWalkableCell; a free body's current map ends +-- at its actual bounds and must hand off to the connection controller. +function Map:isWalkable(cx, cy) + return self:inBounds(cx, cy) and self:isWalkableCell(cx, cy) +end + function Map:isGrassCell(cx, cy) -- Off-map cells never count as tall grass (issue #217). cellTile -- border-extends out-of-bounds coordinates with the map's borderBlock, @@ -1040,6 +1071,7 @@ function Map:setBlock(bx, by, block, impassable) self.shutCells = self.shutCells or {} self.shutCells[bx .. ":" .. by] = impassable or nil end + self:noteGridChange() end -- ...and putting one back the way it was. `setBlock` overwrites a cell for @@ -1054,7 +1086,9 @@ function Map:clearBlock(bx, by) local i = by * self.def.width + bx + 1 self.blockPatch[i] = nil if self.collisionPatch then self.collisionPatch[i] = nil end + if self.elevationPatch then self.elevationPatch[i] = nil end if self.shutCells then self.shutCells[bx .. ":" .. by] = nil end + self:noteGridChange() self.blocksDirty = true end @@ -1073,12 +1107,23 @@ function Map:setCellShut(cx, cy, shut) self.collisionPatch = self.collisionPatch or {} self.shutCells[cx .. ":" .. cy] = shut and true or nil self.collisionPatch[cy * self.def.width + cx + 1] = shut and 1 or 0 + self:noteGridChange() return true end --- What the collision bits say about a cell, with any runtime change applied. +-- The native collision value: Gen2 COLL_* classes or per-cell blocking bits. function Map:cellCollision(cx, cy) + local outside = self.layoutOutside and self.layoutOutside[cx .. ":" .. cy] + if outside then return outside.coll end local cells = self.def.collisionCells + -- Gen2 callers ask for the COLL_* class, not Gen3's per-cell blocking + -- bits. Read the same live block/quad as the native mover, including + -- changeblock and closed-cell patches. A one-cell metatile or an explicit + -- collisionCells array retains the existing blocking-bit contract. + if not cells and self.blockCells > 1 and self:speaksGen2Collision() then + if not self:inBounds(cx, cy) then return 0xFF end + return self:cellTile(cx, cy) + end if cx < 0 or cy < 0 or cx >= self.def.width or cy >= self.def.height then return cells and 1 or 0 end @@ -1095,13 +1140,18 @@ end -- Drop every runtime block change, so the map's callbacks re-establish it. function Map:clearBlockPatches() - if next(self.blockPatch) == nil and next(self.collisionPatch) == nil then + self.gen3LayoutOverride = nil + if next(self.blockPatch) == nil and next(self.collisionPatch) == nil + and next(self.elevationPatch) == nil and next(self.shutCells) == nil + and not (self.layoutOutside and next(self.layoutOutside)) then return false end self.blockPatch = {} self.collisionPatch = {} self.elevationPatch = {} self.shutCells = {} + self.layoutOutside = nil + self:noteGridChange() self.blocksDirty = nil return true end @@ -1170,7 +1220,7 @@ local FRLG_ARRIVAL_WARP = { -- or nil when the cell is not one of FireRed's directional warps. function Map:frlgWarpDirection(cx, cy) if not self.tileset.behaviourBytes then return nil end - if GameVersion.get() ~= "firered" then return nil end + if not GameVersion.isFRLG() then return nil end local b = self:cellBehaviour(cx, cy) return b and FRLG_DIRECTIONAL_WARP[b] or nil end @@ -1198,6 +1248,62 @@ end -- -- Only differing cells are touched, so a map whose script names the layout it -- already has costs one comparison per cell and changes nothing. +local function uint(value, maximum) + return type(value) == "number" and value >= 0 and value <= maximum and value % 1 == 0 +end + +-- Atomic native 16px grid writes: artwork, blocking bits and elevation share +-- one owner. Validation finishes before changing any cell. Duplicate rows +-- compose in input order and only the final changed cells count as changes. +function Map:setGen3Cells(rows) + if self.blockCells ~= 1 or self.blockTiles ~= 2 then return nil, "not a 16px metatile grid" end + if type(rows) ~= "table" then return nil, "cell writes must be an array" end + local count, last = 0, 0 + for k in pairs(rows) do + if not uint(k, 0x7fffffff) or k == 0 then return nil, "cell writes must be an array" end + count, last = count + 1, math.max(last, k) + end + if count ~= last then return nil, "cell writes must be a dense array" end + local pending, order = {}, {} + for _, row in ipairs(rows) do + if type(row) ~= "table" or not uint(row.x, self.def.width - 1) + or not uint(row.y, self.def.height - 1) then return nil, "cell write outside map" end + if row.mid == nil and row.coll == nil and row.elev == nil then return nil, "cell write has no fields" end + if row.mid ~= nil and not uint(row.mid, 1023) then return nil, "invalid metatile id" end + if row.coll ~= nil and not uint(row.coll, 3) then return nil, "invalid collision bits" end + if row.elev ~= nil and not uint(row.elev, 15) then return nil, "invalid elevation bits" end + -- A missing native elevation source is not an implicit elevation zero. + if row.elev ~= nil and not self.def.elevationCells then return nil, "map has no elevation source" end + local i = row.y * self.def.width + row.x + 1 + local to = pending[i] + if not to then + to = { x = row.x, y = row.y, mid = self:blockAt(row.x, row.y), + coll = self:cellCollision(row.x, row.y), elev = self:cellElevation(row.x, row.y) } + pending[i] = to; order[#order + 1] = i + end + if row.mid ~= nil then to.mid = row.mid end + if row.coll ~= nil then to.coll = row.coll end + if row.elev ~= nil then to.elev = row.elev end + end + local changed = 0 + for _, i in ipairs(order) do + local to = pending[i] + if to.mid ~= self:blockAt(to.x, to.y) or to.coll ~= self:cellCollision(to.x, to.y) + or to.elev ~= self:cellElevation(to.x, to.y) then + self.blockPatch[i] = to.mid + self.collisionPatch[i] = to.coll + if to.elev ~= nil then self.elevationPatch[i] = to.elev end + self.shutCells[to.x .. ":" .. to.y] = to.coll ~= 0 and true or nil + changed = changed + 1 + end + end + if changed > 0 then + self:noteGridChange() + self.blocksDirty = true + end + return changed +end + function Map:applyGen3Layout(layout) if not (layout and self.def) then return 0 end local w, h = self.def.width or 0, self.def.height or 0 @@ -1206,28 +1312,31 @@ function Map:applyGen3Layout(layout) :format(tostring(layout.id), tostring(layout.width), tostring(layout.height), w, h) end - local want = Map.blockArray(layout) - local have = Map.blockArray(self.def) - if not (want and have) then return nil, "no block data" end - self.blockPatch = self.blockPatch or {} - self.collisionPatch = self.collisionPatch or {} - self.elevationPatch = self.elevationPatch or {} - local changed = 0 + local source = layout.blocks + if type(source) ~= "table" and type(source) ~= "string" then return nil, "no block data" end + if type(source) == "string" and #source ~= w * h * 2 then return nil, "invalid packed layout length" end + if layout.collisionCells ~= nil and type(layout.collisionCells) ~= "table" then return nil, "invalid collision source" end + if layout.elevationCells ~= nil and type(layout.elevationCells) ~= "table" then return nil, "invalid elevation source" end + local rows = {} for i = 1, w * h do - local to = want[i] - if to ~= nil and to ~= have[i] then - self.blockPatch[i] = to - if layout.collisionCells then - self.collisionPatch[i] = layout.collisionCells[i] or 0 - end - if layout.elevationCells and self.def.elevationCells then - self.elevationPatch[i] = layout.elevationCells[i] or 0 - end - changed = changed + 1 + local mid, coll, elev + if type(source) == "string" then + local lo, hi = source:byte(i * 2 - 1, i * 2) + local word = lo + hi * 256 + mid, coll, elev = word % 1024, math.floor(word / 1024) % 4, math.floor(word / 4096) + else mid = source[i] end + if not uint(mid, 1023) then return nil, "invalid or missing metatile id" end + if layout.collisionCells then + coll = layout.collisionCells[i] + if not uint(coll, 3) then return nil, "invalid or missing collision bits" end + end + if layout.elevationCells then + elev = layout.elevationCells[i] + if not uint(elev, 15) then return nil, "invalid or missing elevation bits" end end + rows[i] = { x = (i - 1) % w, y = math.floor((i - 1) / w), mid = mid, coll = coll, elev = elev } end - if changed > 0 then self.blocksDirty = true end - return changed + return self:setGen3Cells(rows) end -- true if the cell's collision tile is a door tile @@ -1308,7 +1417,7 @@ function Map:isWarpTileCell(cx, cy) -- not name stopped firing. The tileset now states the rule instead of the -- engine inferring it from a gap. if self.tileset.warpsAreEvents then - if GameVersion.get() == "firered" then + if GameVersion.isFRLG() then if not self:warpAtCell(cx, cy) then return false end local b = self:cellBehaviour(cx, cy) return b ~= nil and FRLG_ARRIVAL_WARP[b] == true 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/NPC.lua b/src/world/NPC.lua index 28b1a2b3e..54d919a8c 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -988,11 +988,17 @@ function NPC:pose() end py, hopping = py - lift, true end - return self.sprite, self.px + (self.shiftPx or 0), py, self.facing, + local presentation=package.loaded["src.compat.gen1.SourceActors"] + local sprite,overridden + if presentation then sprite,overridden=presentation.renderer(self)end + if not overridden then sprite=self.sprite end + return sprite, self.px + (self.shiftPx or 0), py, self.facing, self.sliding and 0 or self:walkPhase(), self.stepFlip, hopping end function NPC:draw(camX, camY) + local presentation=package.loaded["src.compat.gen1.SourceActors"] + if presentation then local sprite,overridden=presentation.renderer(self);if overridden and not sprite then return end end local sprite, px, py, facing, phase, flip = self:pose() if self.fixedFrame then sprite:drawFixedFrame(px, py, camX, camY, self.fixedFrame) diff --git a/src/world/OptionalFollower.lua b/src/world/OptionalFollower.lua new file mode 100644 index 000000000..a00d5c05a --- /dev/null +++ b/src/world/OptionalFollower.lua @@ -0,0 +1,167 @@ +-- Opt-in GB Color/GBA companion. The public predicate selects whether it +-- exists; the native NPC update/draw lists own movement and rendering. +local NPC = require("src.world.NPC") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local F = {} +local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } + +-- Keep the predicate on the native module table so ModSeams can restore it +-- when a legacy mod fails to load or is disabled. A closure-only setter would +-- leave the unloaded mod's callback alive in subsequent game sessions. +F._shouldSpawn = function() + return false +end +function F.setShouldSpawn(fn) + local previous = F._shouldSpawn + if fn then + assert(type(fn) == "function", "follower predicate must be a function") + F._shouldSpawn = fn + end + return previous +end +local function nativeDefault(game, world) + if Version.generation() == 2 then return F._shouldSpawn(game, world) end + return false +end +local function current(world) + for i, npc in ipairs(world.npcs or {}) do + if npc.optionalFollower then + return npc, i + end + end +end +function F.current(world) + return (current(world)) +end +local function remove(world) + local npc, at = current(world) + if not npc then + return + end + table.remove(world.npcs, at) + for i, entity in ipairs(world.entities or {}) do + if entity == npc then + table.remove(world.entities, i) + break + end + end + world.follower, world.cast = nil, nil +end +function F.reset(world) + if not world then return false end + remove(world);world.followerTrail,world.pikachuTrail=nil,nil + return true +end +local function spawn(game, world, opts, viaMapLoad) + local keep = opts and (opts.keepFollower or opts.keepPikachu) + if keep then + table.insert(world.npcs, keep) + table.insert(world.entities, keep) + world.follower, world.cast = keep, nil + return + end + local p, map = world.player, world.map + local x, y = p.cellX, p.cellY + if not viaMapLoad and Version.generation() == 2 then + local d = DELTA[p.facing] or DELTA.down + if map:inBounds(x - d[1], y - d[2]) and map:isWalkableCell(x - d[1], y - d[2]) then + x, y = x - d[1], y - d[2] + end + end + -- A missing optional sheet must not mutate the content registry. Give the + -- NPC its own renderer over the player definition (then NPC's placeholder + -- fallback if neither sheet exists), rather than sharing a renderer. + local data = game.data + if not (data.sprites and data.sprites.SPRITE_PIKACHU) then + local def = p.spriteDef or (p.sprite and p.sprite.def) + if def then + data = setmetatable({ + sprites = setmetatable({ SPRITE_PIKACHU = def }, { __index = data.sprites }), + }, { __index = data }) + end + end + local npc = NPC.new(data, map.id, { + index = 250, + name = "FOLLOWER", + sprite = "SPRITE_PIKACHU", + movement = "STAY", + range = "NONE", + x = x, + y = y, + }) + npc.optionalFollower, npc.follower, npc.pikachuFollower, npc.passable = true, true, true, true + npc.facing, npc.elevation = p.facing or "down", p.elevation + table.insert(world.npcs, npc) + table.insert(world.entities, npc) + world.follower, world.cast = npc, nil + world.followerTrail = { x = p.cellX, y = p.cellY } + world.pikachuTrail = world.followerTrail +end + +function F.onMapEntered(game, world, opts, viaMapLoad) + if opts and opts.checkpoint then return end + if not (world and world.map and world.player) then + return + end + remove(world) + if Runtime.call("world.follower.spawn", nativeDefault, game, world) then + spawn(game, world, opts, viaMapLoad) + end +end + +function F.update(game, world) + if not (world and world.map and world.player) then + return + end + -- Lua truthiness is the Gen1 predicate contract, including 0 and "". + if not Runtime.call("world.follower.spawn", nativeDefault, game, world) then + remove(world) + return + end + local npc, p = current(world), world.player + if not npc then + spawn(game, world) + return + end + local trail = world.followerTrail + if not trail then + trail = { x = p.cellX, y = p.cellY } + world.followerTrail, world.pikachuTrail = trail, trail + end + if Version.generation() == 3 and math.abs(p.cellX - trail.x) + math.abs(p.cellY - trail.y) > 6 then + remove(world) + spawn(game, world) + return + end + local x, y = p.targetX or p.cellX, p.targetY or p.cellY + if x ~= trail.x or y ~= trail.y then + npc.goalX, npc.goalY = trail.x, trail.y + trail.x, trail.y = x, y + end + npc.elevation = p.elevation + if npc.moving or not npc.goalX then + return + end + local dx, dy = npc.goalX - npc.cellX, npc.goalY - npc.cellY + local distance = math.abs(dx) + math.abs(dy) + if distance == 0 then + npc.goalX, npc.goalY = nil, nil + return + end + if distance > 6 then + npc.cellX, npc.cellY = npc.goalX, npc.goalY + npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 + npc.goalX, npc.goalY = nil, nil + return + end + npc.facing = dx > 0 and "right" or dx < 0 and "left" or dy > 0 and "down" or "up" + local d = DELTA[npc.facing] + npc.stepDir, npc.targetX, npc.targetY = npc.facing, npc.cellX + d[1], npc.cellY + d[2] + local frames = p.stepFramesCur or p.stepFrames or 16 + npc.stepFrames = distance > 1 and math.max(1, math.floor(frames / 2)) or frames + npc.progress, npc.moving = 0, true + -- The world's normal NPC loop precedes this controller, so advance frame 1. + npc:update(world.map, world.entities) +end +return F diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a17b2dd6e..91806fb8c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -9,6 +9,8 @@ -- surfing, Cut trees, trainer sight lines, and dispatches interactions to -- map scripts (data/scripts/), marts, nurses or extracted text. +local FrameProfile = require("src.core.FrameProfile") +local Performance = require("src.core.Performance") local Assets = require("src.render.Assets") local Camera = require("src.render.Camera") local Collision = require("src.world.Collision") @@ -380,7 +382,7 @@ local function objectVisible(save, mapId, obj) visible = not told elseif here ~= nil then visible = here - elseif obj.eventFlag and GameVersion.get() == "firered" then + elseif obj.eventFlag and GameVersion.isFRLG() then -- An object with its OWN hide flag is spawned at map load whenever -- that flag is clear; an addobject somewhere else on the map does not -- change that. FireRed's lab rival is exactly this: present from the @@ -1081,6 +1083,8 @@ function OverworldState:stampClosedDoors() end function OverworldState:setMap(mapId, x, y, facing, opts) + local fishing=package.loaded['src.world.Gen2Fishing'] + if fishing then fishing.cancel(self)end -- BLACK / WHITE'S ARRIVAL GUARD: the warp you land on stays disarmed until -- you step off it (see checkGen4EntranceWarp's gen5 branch) self.gen5ArrivalMap, self.gen5ArrivalX, self.gen5ArrivalY = mapId, x, y @@ -2066,7 +2070,7 @@ function OverworldState:updateMapNameSignGen3() end -- FireRed's popup slides 12 frames in, holds 120, slides 12 out -- (Task_MapNamePopup); the other Gen 3 sign keeps the old timing - local frlg = GameVersion.get and GameVersion.get() == "firered" + local frlg = GameVersion.get and GameVersion.isFRLG() local text = Strings((name:gsub("[\n\f\v]", " "))) local width = 14 -- MapNamePopupAppendFloorNum: " B1F" / " 5F" widen the window by 5 tiles, @@ -2711,7 +2715,7 @@ function OverworldState:pushBattleTransition(battle, opts, onDone) -- FireRed's trainer transition comparison sums two Pokemon only when the -- trainer record itself is flagged as a double battle. Equal levels take -- the second column, just like stronger opponents do. - if GameVersion.get() == "firered" and battle and battle.kind == "trainer" + if GameVersion.isFRLG() and battle and battle.kind == "trainer" and battle.trainer and battle.trainer.doubleBattle then compareEnemyLevel = 0 for i = 1, math.min(2, #(battle.enemyParty or {})) do @@ -3467,6 +3471,7 @@ function OverworldState:update(dt) -- already feeds into draw(). -- the weather's own clock; see drawFieldWeather self.weatherFrame = (self.weatherFrame or 0) + 1 + if GameVersion.isGen3() then require('src.world.Gen3WeatherAudio').update(Game,self) end -- ...and the cracked floor's, which runs on the clock rather than on the -- steps because that is what makes a step's length decide the fall self:tickCrackedFloor() @@ -3908,6 +3913,7 @@ function OverworldState:update(dt) end -- fishing pose tail: the rod is already gone, the pose holds for the -- frames the original spends unwinding the item menu (#384) + if GameVersion.isGen2() then self:updateFishing() end if self.fishPose then self.fishPose = self.fishPose - 1 if self.fishPose <= 0 then @@ -4113,6 +4119,34 @@ function OverworldState:update(dt) UG.tickSparkles(Game, self) end -- every body on screen, home and foreign alike, in this map's cells + self:stepBody(scriptJustEnded) + + self:followCamera() + + -- pan_camera offset rides on top of the follow; the ramp resumes its + -- runner when it lands + local pan = self.cameraPan + if pan then + if pan.frames then + pan.t = pan.t + 1 + local k = math.min(1, pan.t / pan.frames) + pan.ox = pan.fromX + (pan.toX - pan.fromX) * k + pan.oy = pan.fromY + (pan.toY - pan.fromY) * k + if pan.t >= pan.frames then + pan.frames = nil + local done = pan.onDone + pan.onDone = nil + if done then done() end + end + end + self.camera.x = self.camera.x + pan.ox + self.camera.y = self.camera.y + pan.oy + end +end + +-- Native actor/input/player phase. Camera follow and scene clocks stay +-- in update; both movement interfaces use the same native arrival owner. +function OverworldState:stepBody(scriptJustEnded) self:updateCast() -- Gen 4 / BW movement types: is the field locked, who is a script walking require("src.world.Gen4MovementTypes").beginFrame(self) @@ -4213,8 +4247,13 @@ function OverworldState:update(dt) or self.engaging or self.emote or self.teleportOut or self.whirlSpin or self.player.stairExit end + local compatibilityHandled = false if (not scripted and not self.transitioning) or gen2BootBedroom then - self:handleInput() + local avatar = GameVersion.isGen3() and package.loaded["src.core.game3.player"] + if playerStepped == nil and type(avatar) == "table" and type(avatar._dispatch) == "function" then + compatibilityHandled = avatar._dispatch(Game, self, Game.input) + end + if not compatibilityHandled then self:handleInput() end self.lockedFrames = nil else -- THE WATCHDOG, because "I could not walk" is the one bug report this @@ -4296,8 +4335,14 @@ function OverworldState:update(dt) -- above carries its result down here instead of being stepped again, which -- would hand the player the one-frame advantage this fix took away from the -- NPCs. + if compatibilityHandled then return end local stepped = playerStepped if stepped == nil then stepped = self.player:update() end + self:finishPlayerStep(stepped, scripted) +end + +-- Run synchronously when a compatibility wrapper submits its native arrival. +function OverworldState:finishPlayerStep(stepped, scripted) -- the warp-arrival cell goes stale the instant the player's real cell -- leaves it, scripted walk-outs included -- pokered re-checks warps -- after simulated steps too (CheckWarpsNoCollision), so a forced @@ -4365,27 +4410,6 @@ function OverworldState:update(dt) self:onStepComplete() end - self:followCamera() - - -- pan_camera offset rides on top of the follow; the ramp resumes its - -- runner when it lands - local pan = self.cameraPan - if pan then - if pan.frames then - pan.t = pan.t + 1 - local k = math.min(1, pan.t / pan.frames) - pan.ox = pan.fromX + (pan.toX - pan.fromX) * k - pan.oy = pan.fromY + (pan.toY - pan.fromY) * k - if pan.t >= pan.frames then - pan.frames = nil - local done = pan.onDone - pan.onDone = nil - if done then done() end - end - end - self.camera.x = self.camera.x + pan.ox - self.camera.y = self.camera.y + pan.oy - end end -- any direction currently held (hJoyHeld & PAD_CTRL_PAD) @@ -5759,60 +5783,6 @@ function OverworldState:startWaterAnim(cx, cy, onDone) self.waterAnim = { x = cx, y = cy, frames = 40, total = 40, onDone = onDone } end --- TWO VOCABULARIES FOR THE SAME FOUR DIRECTIONS, and they never met. --- --- The tileset's ledge table names its directions the way the CARTRIDGE does: --- MB_JUMP_EAST, MB_JUMP_SOUTH -- so the extracted table reads --- `[0x3B] = "south"`. Movement here names them the way the PLAYER'S INPUT --- does: up, down, left, right. The test below compared one against the other --- and, of course, never matched -- and then took the early return that says --- "a dataset that names its ledges by behaviour has said all it has to say", --- so it did not fall through to anything either. --- --- The result was the exact bug the table was added to fix, still standing --- with the table in place: every ledge in Hoenn a wall, no route down off any --- terrace in the region. Both halves were right on their own and the join --- was never driven. --- --- So the join is one function, and it accepts either word. A dataset or a --- mod may say "north" or "up" and mean the same thing. -local LEDGE_DIRECTIONS = { - north = "up", south = "down", west = "left", east = "right", - up = "up", down = "down", left = "left", right = "right", -} - -local function ledgeDirection(word) - return word and LEDGE_DIRECTIONS[word] or nil -end - --- The eight Gen 3 ledge behaviours, by the direction you must be walking to --- take them (metatile_behaviors.h). The four diagonals are listed under the --- axis a cardinal input can satisfy: Emerald lets a diagonal ledge be entered --- from either of its two components, and up/down is the one this engine's --- movement can express. -local GEN3_LEDGE_BEHAVIOURS = { - [0x38] = "right", -- MB_JUMP_EAST - [0x39] = "left", -- MB_JUMP_WEST - [0x3A] = "up", -- MB_JUMP_NORTH - [0x3B] = "down", -- MB_JUMP_SOUTH - [0x3C] = "up", -- MB_JUMP_NORTHEAST - [0x3D] = "up", -- MB_JUMP_NORTHWEST - [0x3E] = "down", -- MB_JUMP_SOUTHEAST - [0x3F] = "down", -- MB_JUMP_SOUTHWEST -} - --- BLACK / WHITE'S FOUR, from the tile records' behaviour byte (Gen5Field): --- measured over every zone, 0x72 / 0x73 only ever run in columns and 0x74 / --- 0x75 only in rows, and Route 2's two ledges (y 16 and 20, facing south) are --- 0x75 -- Generation IV's east / west / north / south order, carried on. --- Reported from play: "allow hoppable ledges to work, they currently don't". -local GEN5_LEDGE_BEHAVIOURS = { - [0x72] = "right", - [0x73] = "left", - [0x74] = "up", - [0x75] = "down", -} - -- Ledge hops (data/tilesets/ledge_tiles.asm): standing tile + ledge tile -- in front + matching input direction -> jump two cells. function OverworldState:checkLedgeHop(dir) @@ -5828,60 +5798,13 @@ function OverworldState:checkLedgeHop(dir) return self:startLedgeHop(dir, fx, fy) end - -- A GEN 3 LEDGE IS A PROPERTY OF THE TILE IN FRONT, not of a pair. - -- - -- Gen 1 and Gen 2 decide a hop from (tile you stand on, tile in front, - -- direction) because their tilesets have no room to say more. Gen 3 says - -- it outright: the metatile's behaviour byte IS "you jump this, eastward". - -- Nothing derived a pair table for Hoenn, so field.ledges was empty and - -- every ledge in the region was simply a wall -- there was no route down - -- off any terrace in the game. - -- - -- cellBehaviour rather than cellTile because a ledge is blocked, and - -- cellTile answers $FF for anything blocked. - -- - -- ...AND NOTHING EVER SUPPLIED THE TABLE. `field.ledgeBehaviours` is read - -- here and written nowhere: no importer produces it and no dataset ships - -- one, so `byBehaviour` was nil on every map in the game and Hoenn fell - -- through to the pair tables below -- which are Gen 1 and Gen 2 rows keyed - -- by tile id, and match nothing on a Gen 3 pair. Every ledge in Hoenn was - -- a wall you could not hop. - -- - -- The cartridge's own answer is a fixed run of eight behaviour bytes - -- (metatile_behaviors.h, MB_JUMP_EAST through MB_JUMP_SOUTHWEST), so it is - -- stated here rather than derived. A dataset that ships its own table - -- still wins; this only fills the gap. Gated on the pair shape -- 2x2 - -- tiles to a metatile, one collision cell -- because those byte values - -- mean something else entirely in Kanto and Johto. - local byBehaviour = self.map.tileset and self.map.tileset.ledgeBehaviours - or Game.data.field and Game.data.field.ledgeBehaviours - -- first on a Black / White zone: its stand-in tileset carries Platinum's - -- table, whose codes mean other things here - if self.map.def and self.map.def.gen5 then byBehaviour = GEN5_LEDGE_BEHAVIOURS end - if not byBehaviour then - local ts = self.map.tileset - if ts and tonumber(ts.blockTiles) == 2 and tonumber(ts.blockCells) == 1 - and ts.collision then - byBehaviour = GEN3_LEDGE_BEHAVIOURS - end - end - if byBehaviour then - local behaviour = self.map:cellBehaviour(fx, fy) - if behaviour and ledgeDirection(byBehaviour[behaviour]) == dir then - -- PLATINUM'S DOUBLE JUMP, the Distortion World's: `JUMP_*_TWICE` - -- (0x5A..0x5D) plays MOVEMENT_ACTION_JUMP_DISTORTION_WORLD_*, which - -- moves 2 units a frame for 24 frames -- THREE tiles, against an - -- ordinary ledge's 16 frames and two -- and passes SEQ_NONE, so it is - -- silent (pokeplatinum src/unk_020655F4.c). All 224 such tiles are in - -- the D34 rooms; taken as ordinary ledges they landed a tile short. - if (self.map.def and self.map.def.generation) == 4 - and behaviour >= 0x5A and behaviour <= 0x5D then - return self:startLedgeHop(dir, fx, fy, 3, true) - end - return self:startLedgeHop(dir, fx, fy) + local handled, x, y, distance, silent = require("src.world.Ledge").approach( + self.map, Game.data, p.cellX, p.cellY, dir) + if handled then + if x then + if distance then return self:startLedgeHop(dir, x, y, distance, silent) end + return self:startLedgeHop(dir, x, y) end - -- a dataset that names its ledges by behaviour has said all it has to - -- say; the pair tables below are the older generations' answer return false end if self:gen2LedgeAllows(standing, dir) then @@ -6285,7 +6208,7 @@ function OverworldState:noteRunGate(why) return why end -function OverworldState:runFrames() +function OverworldState:runFrames(ignoreButton) local frames = FieldDefaults.world(Game.data, "runStepFrames") if type(frames) ~= "number" then self:noteRunGate("this dataset has no run speed at all") @@ -6390,7 +6313,7 @@ function OverworldState:runFrames() return nil end if self.player and self.player.surfing then return nil end - if not (Game.input and Game.input:isDown("b")) then return nil end + if not ignoreButton and not (Game.input and Game.input:isDown("b")) then return nil end self:noteRunGate("running") -- playing AS a Pokemon is Prism's own refusal and does not apply to Hoenn if not gen3 then @@ -6637,24 +6560,24 @@ local function gen2FishingRoll(data, rod, mapDef, tod, save) entry and "IS" or "is NOT", rows and tostring(#rows) or "none") end - return nil, true + return nil, true, entry, group end -- `call Random / cp [hl] / jr nc, .no_bite`: the roll must come in UNDER -- the group's chance byte. - if love.math.random(0, 255) >= (entry.chance or 0) then return nil, true end + if love.math.random(0, 255) >= (entry.chance or 0) then return nil, true, entry, group end local roll = love.math.random(0, 255) for _, row in ipairs(rows) do if roll <= row.chance then if row.timeGroup then local pair = field.timeFishGroups and field.timeFishGroups[row.timeGroup] local slot = pair and ((tod == "NITE" or tod == "NIGHT") and pair.nite or pair.day) - if not slot then return nil, true end - return { species = slot.species, level = slot.level }, true + if not slot then return nil, true, entry, group end + return { species = slot.species, level = slot.level }, true, entry, group end - return { species = row.species, level = row.level }, true + return { species = row.species, level = row.level }, true, entry, group end end - return nil, true + return nil, true, entry, group end -- Exposed for the headless drivers: the roll with no UI attached. @@ -6662,6 +6585,45 @@ OverworldState.rollFishingForTest = function(data, rod, mapDef, tod, save) return (gen2FishingRoll(data, rod, mapDef, tod, save)) end +-- Native Gen2 public lifecycle. The ordinary bag and registered-item path +-- both enter useRod, so presentation wrappers observe the actual decision. +local fishingService +local function nativeFishing() + local service=require('src.world.Gen2Fishing') + if fishingService~=service then + require('src.core.game3.asset_stream').register(service) + fishingService=service + end + return service +end +function OverworldState.isRod(id, items) + return nativeFishing().isRod(id, items) +end +function OverworldState:rollFishing(rod) + return nativeFishing().roll(self, Game, rod, gen2FishingRoll) +end +function OverworldState:useRod(rod) + return nativeFishing().use(self, Game, rod) +end +function OverworldState:beginFishing(outcome, wild) + return nativeFishing().begin(self, Game, outcome, wild) +end +function OverworldState:updateFishing() + local fishing = package.loaded['src.world.Gen2Fishing'] + if fishing then fishing.poll();return fishing.tick(self, Game) end +end +function OverworldState:tryFishing(rod) + return nativeFishing().try(self, Game, rod) +end +function OverworldState:_beginFishingBattle(wild) + local battle=require('src.battle.BattleState').newWild(Game,wild.species,wild.level, + {hooked=true,nativeFishingMon=wild}) + if self:inSafariGame() then battle:makeSafari(Game.save.safari) end + battle.onFinish=function(result)self:afterBattle(result,battle)end + self:pushBattle(battle) + return battle +end + -- field.fishing: `always` hooks that catch every time (the Old Rod), -- `pool` a fixed candidate list, `perMap` the field key holding per-map -- groups. The rejection-loop odds above stay engine behavior. @@ -6687,6 +6649,7 @@ end -- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group -- (no group means "Not even a nibble!"). function OverworldState:goFishing(rod) + if GameVersion.isGen2() then return self:useRod(rod) end -- SINNOH FISHES FROM ITS OWN TABLES -- see src/world/Gen4Fishing.lua. The -- pool below is the Gen 1/2 rod configuration and has nothing for Platinum. local gen4Rod = (self.map.def and self.map.def.generation) == 4 @@ -7373,7 +7336,7 @@ function OverworldState:tryPcTile(fx, fy) -- cache produced before that stage from falling through to the old PC -- menu while still leaving other Gen 3 datasets data-driven. local pc = Game.data.constants and Game.data.constants.gen3PcBehaviour - if not pc and GameVersion.get() == "firered" then pc = 0x83 end + if not pc and GameVersion.isFRLG() then pc = 0x83 end if pc and self.map:cellBehaviour(fx, fy) == pc then -- ...AND WHAT IT OPENS IS THE CARTRIDGE'S OWN SCRIPT, not this port's -- PC menu. EventScript_PC is what the field runs for this behaviour: @@ -7416,7 +7379,35 @@ function OverworldState:tryPcTile(fx, fy) return false end +function OverworldState:scriptRunning() + return (self.runner and self.runner:isRunning()) and true or false +end + +function OverworldState:busy(ignoreOverlay) + local field=package.loaded["src.core.game3.field"] + if GameVersion.isGen3()and field and field._locked(Game,self)then return true end + -- Gen2's world lives below overlays. A movement wrapper runs after the + -- native body, including a START/A press that just opened a screen: it + -- must not move behind that new owner on the same tick. + if GameVersion.isGen2() then + if self.battleActive then return true end + local stack = Game and Game.stack + local top = stack and stack.top and stack:top() + if ignoreOverlay ~= true and top and top ~= self then return true end + end + return (self:scriptRunning() or (self.scriptMoves and #self.scriptMoves > 0) + or self.engaging or self.emote or self.teleportOut or self.whirlSpin + or self.gen4Waterfall or self.transitioning or self.flyAnim or self.fishing + or (self.player and (self.player.stairExit or self.player.inputLocked or self.player.fieldMove)) + or (self.pendingScripts and self.pendingScripts[1])) and true or false +end + function OverworldState:interact() + local field=package.loaded["src.core.game3.field"] + if GameVersion.isGen3()and field then return field._dispatch(Game,self,function()return self:_interactBody()end)end + return self:_interactBody() +end +function OverworldState:_interactBody() local p = self.player local fx, fy = p:facingCell() @@ -10448,13 +10439,8 @@ function OverworldState:gen2SweetScentEncounter(deferFailure) if contest then enc = BugContest.rollEncounter(Game) elseif slots and (GameVersion.isGen3() or (slots.rate or 0) > 0) then if GameVersion.isGen3() then - local Roam=require("src.world.Gen3Roamers") - if Roam.check(Game.data,Game.save,self.map.id,love.math.random) then - enc=Roam.encounterFor(Game.data,Game.save) - if enc then roamer="gen3" end - end - end - if not enc then enc = Encounter.chooseTable(slots) end + enc,roamer=Encounter.sweetScent(Game.data,Game.save,self.map.def,self.map.id,water,true,love.math.random) + else enc = Encounter.chooseTable(slots) end end end if not enc then @@ -11902,7 +11888,7 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) if reward.flag then if Game.save.flags[reward.flag] then return self:runVictoryHook() end Game.save.flags[reward.flag] = true - if GameVersion.get() == "firered" and reward.fireredFlag then + if GameVersion.isFRLG() and reward.fireredFlag then Game.save.flags[reward.fireredFlag] = true end end @@ -13455,7 +13441,7 @@ function OverworldState:onStepComplete() -- Hoenn ever wanted a rematch -- the whole POKéNAV MATCH CALL list would -- have shown a region of trainers with nothing to say. if GameVersion.isGen3() then - if GameVersion.get() == "firered" then + if GameVersion.isFRLG() then require("src.world.VsSeeker").step(Game.save) else require("src.script.MatchCall").step(Game.data, Game.save) @@ -13511,8 +13497,9 @@ function OverworldState:onStepComplete() require("src.world.PikachuFollower").onStep(Game.save) require("src.world.HgssFollowMon").onStep(Game, self) -- Original Gen 2 friendship shares the egg step counter in stepEggs. - -- Keep the existing walking rule for other datasets. - if GameVersion.get()=="emerald" then + -- All Gen 3 editions use their own 128-step coin-flip rule, rather than + -- falling through to the generic +2/+2/+1 walking adjustment. + if GameVersion.isGen3() then require("src.pokemon.Gen3Friendship").step(Game.data,Game.save,self.map and self.map.def) elseif GameVersion.isGen4() or GameVersion.isGen5() then -- Platinum's own rule: a coin flip, Luxury Ball, egg location and Soothe @@ -13909,7 +13896,10 @@ function OverworldState:onStepComplete() local okT, T = pcall(require, "src.import.Gen4Text") g4line = okT and T.resolve(Game.data, hgss and 40 or 213, hgss and 65 or 79, Game) or nil end - Game.stack:push(TextBox.new(Game, g4line or Strings("REPEL's effect\nwore off."))) + local again=GameVersion.isGen3()and package.loaded['src.mods.FeatureOptions']and function() + require('src.ui.RepelPrompt').show(Game) + end or nil + Game.stack:push(TextBox.new(Game, g4line or Strings("REPEL's effect\nwore off."),again)) return end end @@ -15284,7 +15274,7 @@ function OverworldState:warpToHealPoint(onDone, opts) end end local finished = onDone - if opts and opts.whiteout and GameVersion.get() == "firered" then + if opts and opts.whiteout and GameVersion.isFRLG() then finished = function() -- FieldCB_RushInjuredPokemonToCenter prints this recovery message after -- the whiteout warp and before the nurse/mom follow-up script. The port @@ -15372,6 +15362,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.doorWarp = nil local arriveWarp = self.arriveWarp self.arriveWarp = nil + self.warpArrivalKind = arriveWarp local fromId = self.map.id local fromSection = self.map.def and self.map.def.regionMapSection Game.stack:push(Transition.new(Game, function() @@ -15394,7 +15385,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) end -- FIRERED: a warp into a dungeon with a preview picture shows it first local section = self.map.def and self.map.def.regionMapSection - if section and section ~= fromSection and GameVersion.get() == "firered" then + if section and section ~= fromSection and GameVersion.isFRLG() then local Preview = require("src.world.Gen3MapPreviewFRLG") if Preview.record(Game, section) then local frlg = (Game.data.constants or {}).gen3FRLGRegionMap @@ -15529,6 +15520,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) end end, function() self.transitioning = false + self.warpArrivalKind = nil -- THE ARRIVAL IS WHAT GIVES THE CONTROLS BACK. -- -- Reported from play: "Fix the lavaridge gym its bugged When you go @@ -17223,7 +17215,831 @@ function OverworldState:poseBerryTrees() end end -function OverworldState:drawWorld() +-- Only public callback seams bind a frame; ordinary draws pass context explicitly. +local function bindWorldFx(fn, state, cam) + return function() return fn(state, cam) end +end + +-- the Pokémon Center heal machine (PokeCenterOAMData): the monitor +-- tile over the machine's screen and one ball per healed mon in two +-- mirrored columns, all blinking during the jingle flash. The GB +-- draws it at fixed screen coords with the player's cell BG-aligned +-- at (64,64); anchoring those coords to where the player stood keeps +-- the overlay on the machine at any zoom. +function OverworldState:_worldFxHeal(cam) + if not self.healAnim then return end + local ha = self.healAnim + -- HOENN'S MACHINE IS NOT AN OAM TABLE. + -- + -- Everything below this block is the Game Boy's: two 8x8 tiles out of + -- field.overworldFx.healMachine at coordinates a table in the ROM gives. + -- Emerald has no such table -- its machine is a field effect whose two + -- sprites are created by machine code, and where they go is four + -- immediates INSIDE that code. extractHealMachine reads them, and the + -- record it writes carries the positions already measured from the corner + -- of the player's own cell, so nothing here has to know where the + -- cartridge's camera puts anybody. + if GameVersion.isGen4() then return end + if self:drawGen3Heal(ha, cam) then return end + local fxDef = Game.data.field.overworldFx + if self.healMachineImg == nil and fxDef and fxDef.healMachine then + local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path) + self.healMachineImg = ok and img or false + end + local img = self.healMachineImg + if img then + if not self.healMachineQuads then + local w, h = img:getWidth(), img:getHeight() + self.healMachineQuads = { + love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c) + love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) + } + end + -- The machine carries its OWN four colours. HealMachineAnim.LoadPalettes + -- (04:$6434) copies them over OBJ palette 6 before the animation runs -- + -- which is why every row of its OAM table names palette 6 -- and they are + -- white / light orange / red / black: a Poke Ball. Nothing was applying + -- any palette at all, so the sheet drew in the DMG greys it is decoded + -- in and a Pokemon Center healed with grey balls. + local def = fxDef and fxDef.healMachine + local objPal = def and def.gen2ObjPal + local basePal = (objPal and objPal[1] and objPal) or PaletteFX.GRAYS + -- the jingle flash recolors the machine sprites in place + -- (FlashSprite8Times XORs rOBP1; the sprites never disappear): + -- ha.visible == false is the flashed half of each beat, drawn with + -- the light/dark shades swapped instead of skipped + local shader + if (not ha.visible) or basePal ~= PaletteFX.GRAYS then + shader = PaletteFX.shader() + if shader then + PaletteFX.sendColors(shader, ha.visible and basePal + or PaletteFX.permute(basePal, HEAL_FLASH_MAP)) + love.graphics.setShader(shader) + end + end + -- TileRenderer windows with -floor(cam), so the overlay must use the + -- same snap or a fractional camera (odd fill/tilt view sizes) parks + -- the balls a pixel off the machine tiles + local ox = ha.px - 64 - math.floor(cam.x) + local oy = ha.py - 64 - math.floor(cam.y) + -- Gen2 puts these sprites somewhere else entirely. Its + -- HealMachineAnim.PC_ElmsLab_OAM (04:$63BC) has the monitor at x 26/30 + -- and the balls at x 24/32; Gen1's PokeCenterOAMData has the balls at + -- 40/48. The extractor reads that OAM table straight off the cartridge + -- into field.overworldFx.healMachine, so use it where it exists and keep + -- the Gen1 constants as the fallback -- drawn at Gen1's offsets the + -- whole overlay sat a full 16px right of the Gen2 machine. + local monitor = (def and def.monitor) or { { 44, 20 } } + local balls = (def and def.balls) or HEAL_BALL_XY + love.graphics.setColor(1, 1, 1, 1) + for _, m in ipairs(monitor) do + love.graphics.draw(img, self.healMachineQuads[1], ox + m[1], oy + m[2]) + end + for i = 1, math.min(ha.lit, #balls) do + local b = balls[i] + if b[3] then -- right column: OAM_XFLIP + love.graphics.draw(img, self.healMachineQuads[2], + ox + b[1] + 8, oy + b[2], 0, -1, 1) + else + love.graphics.draw(img, self.healMachineQuads[2], + ox + b[1], oy + b[2]) + end + end + if shader then love.graphics.setShader() end + end +end + +-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell, +-- flickering (AnimateBoulderDust XORs the OBJ palette every step) +function OverworldState:_worldFxDust(cam) + if not self.dustAnim then return end + local fxDef = Game.data.field.overworldFx + local smoke = fxDef and fxDef.smoke + if smoke then + if self.smokeImg == nil then + local ok, img = pcall(love.graphics.newImage, smoke.path) + self.smokeImg = ok and img or false + end + if self.smokeImg then + local da = self.dustAnim + local dx = da.x * 16 - cam.x + local dy = da.y * 16 - cam.y + local flicker = math.floor(da.frames / 4) % 2 == 0 + love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) + for i = 0, 1 do + for j = 0, 1 do + love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8) + end + end + love.graphics.setColor(1, 1, 1, 1) + end + end +end + +-- FireRed's S.S. Anne departure uses two ordinary OBJ sprites that are not +-- object events: a looping 16x32 wake behind the ship and 16x16 smoke puffs +-- from its funnel. Special 401 owns their exact lifetime/positions; this +-- pass only turns that state into the extracted ROM frames. +function OverworldState:_worldSsAnneArt(cam) + local def = Game.data.constants and Game.data.constants.gen3SSAnneFx + if not def then return nil end + if self.ssAnneWakeImg == nil then + local ok, img = pcall(Assets.image, def.wake) + self.ssAnneWakeImg = ok and img or false + end + if self.ssAnneSmokeImg == nil then + local ok, img = pcall(Assets.image, def.smoke) + self.ssAnneSmokeImg = ok and img or false + end + if self.ssAnneWakeImg and not self.ssAnneWakeQuads then + local w, h = self.ssAnneWakeImg:getDimensions() + self.ssAnneWakeQuads = { + love.graphics.newQuad(0, 0, 16, 32, w, h), + love.graphics.newQuad(16, 0, 16, 32, w, h), + } + end + if self.ssAnneSmokeImg and not self.ssAnneSmokeQuads then + local w, h = self.ssAnneSmokeImg:getDimensions() + self.ssAnneSmokeQuads = {} + for i = 0, 3 do + self.ssAnneSmokeQuads[i + 1] = love.graphics.newQuad(i * 16, 0, 16, 16, w, h) + end + end + return self.ssAnneWakeImg, self.ssAnneSmokeImg +end + +function OverworldState:_worldFxSSAnneWake(cam) + local fx = self.ssAnneDepartureFx + if not (fx and fx.boat) then return end + local wake = self:_worldSsAnneArt(cam) + if not (wake and self.ssAnneWakeQuads) then return end + local age = math.min(fx.wakeAge or 0, 132) + local cx = fx.boat.px + (fx.boat.shiftPx or 0) + 8 - cam.x + + 80 + math.floor(age / 6) + if cx < -18 then return end + local frame = math.floor(age / 12) % 2 + 1 + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(wake, self.ssAnneWakeQuads[frame], + math.floor(cx - 8), 109 - 16) +end + +function OverworldState:_worldFxSSAnneSmoke(cam) + local fx = self.ssAnneDepartureFx + if not (fx and fx.smoke and #fx.smoke > 0) then return end + local _, smoke = self:_worldSsAnneArt(cam) + if not (smoke and self.ssAnneSmokeQuads) then return end + love.graphics.setColor(1, 1, 1, 1) + for _, puff in ipairs(fx.smoke) do + local age = puff.age or 0 + local frame + if age < 10 then frame = 1 + elseif age < 30 then frame = 2 + elseif age < 50 then frame = 3 + else frame = 4 end + local cx = puff.x + math.floor(age / 4) + love.graphics.draw(smoke, self.ssAnneSmokeQuads[frame], + math.floor(cx - 8), 78 - 8) + end +end + +-- the watering can's water: droplets arcing down onto the soil, and a +-- sparkle on the last few frames as it soaks in +function OverworldState:_worldFxWater(cam) + if not self.waterAnim then return end + local wa = self.waterAnim + local total = wa.total or 40 + local age = total - wa.frames + local dx = wa.x * 16 - cam.x + local dy = wa.y * 16 - cam.y + love.graphics.setColor(0.55, 0.80, 0.98, 0.95) + for i = 0, 5 do + -- each droplet falls on its own phase, so they read as a stream + local t = ((age * 3 + i * 7) % 24) / 24 + local px = dx + 2 + i * 2 + t * 10 + local py = dy - 6 + t * 20 + if py < dy + 16 then + love.graphics.rectangle("fill", px, py, 2, 3) + end + end + -- the soak: a brief bright ring once the water has been falling a while + if age > total * 0.55 then + local k = (age - total * 0.55) / (total * 0.45) + love.graphics.setColor(1, 1, 1, 0.8 * (1 - k)) + love.graphics.rectangle("fill", dx + 2, dy + 12, 12, 2) + end + love.graphics.setColor(1, 1, 1, 1) +end + +-- the cut tree splitting apart (AnimCut): top half slides right, +-- bottom half slides left, 1px per frame, flickering as they go +function OverworldState:_worldFxCutTree(cam) + if not self.cutAnim then return end + local fxDef = Game.data.field.overworldFx + local tree = fxDef and fxDef.cutTree + if not tree then return end + if self.cutTreeImg == nil then + local ok, img = pcall(love.graphics.newImage, tree.path) + self.cutTreeImg = ok and img or false + end + local img = self.cutTreeImg + if not img then return end + if not self.cutTreeQuads then + local w, h = img:getWidth(), img:getHeight() + self.cutTreeQuads = { + love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half + love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half + } + end + local ca = self.cutAnim + local off = (ca.total or 8) - ca.frames + local dx = ca.x * 16 - cam.x + local dy = ca.y * 16 - cam.y + local flicker = ca.frames % 2 == 0 + love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) + love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy) + love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8) + love.graphics.setColor(1, 1, 1, 1) +end + +-- the "!" bubble above a trainer who spotted the player +-- the tile-anchored sparkle: an OAM sprite in the cartridge, so it rides +-- the camera the way a body does rather than the background's shake +function OverworldState:_worldFxSparkle(cam) + love.graphics.setColor(1, 1, 1, 1) + self:drawSparkles(cam.x, cam.y) +end + +-- ...and ONE of them, for the two paths that cannot draw the set in screen +-- space: each sparkle sits on its own cell, so a projected scene has to +-- anchor each one separately. The upvalue is set immediately before each +-- call and cleared after, which is the same shape the emote path uses for +-- the one NPC it belongs to. +function OverworldState:_worldFxSparkleOne(cam, item) + local s = item + if not s then return end + local sprite = self:sparkleSprite() + if not sprite then return end + local frame = self:sparkleFrame(s.clock) + if not frame then return end + love.graphics.setColor(1, 1, 1, 1) + sprite:drawFixedFrame(s.px, s.py + 4, cam.x, cam.y, frame) +end + +-- THE POKE RADAR'S PATCHES, one at a time like the sparkles +function OverworldState:_worldFxRadarOne(cam, item) + local pt = item + if not pt then return end + require("src.world.Gen4Radar").drawPatch(pt, pt.x * 16 - cam.x, pt.y * 16 - cam.y, + love.timer and love.timer.getTime() * 60 or 0) +end + +function OverworldState:_worldFxRadar(cam) + for _, pt in ipairs((self.gen4Radar and self.gen4Radar.active and self.gen4Radar.patches) or {}) do + if pt.active then self:_worldFxRadarOne(cam, pt) end + end + radarOne = nil +end + +function OverworldState:_worldFxEmote(cam) + if not (self.emote and self.emote.npc) then return end + -- bubble = false is a silent hold (a Pikachu emotion that plays a + -- cry with no bubble still pauses the world for its beat) + if self.emote.bubble == false then return end + local npc = self.emote.npc + -- SINNOH'S OWN BUBBLE, ON TOP OF THE HEAD, IN THE GROUND'S PROJECTION. + -- + -- Reported from play: *"were missing the proper exclamation point above + -- trainers when they see you for battle and its not above their heads"*. + -- Platinum had neither art source below, so it fell to the hand-drawn box, + -- placed with flat 2D arithmetic that knows nothing of terrain height or + -- the tilt -- so it landed across the face. + -- + -- The cartridge (src/overlay005/ov5_021F5A10.c, see Gen4Emotes): a 16x16 + -- billboard from fldeff.narc whose plane rises 0..16 from an anchor 32 + -- units above the owner's drawn position -- the top of a 32-unit + -- character -- bouncing 6, 10, 12, 12, 10, 6, 0 and then holding. So the + -- bubble's bottom edge sits on the sprite's top edge, centred on it, and + -- it is drawn through exactly the transform the sprite is: the same + -- terrain rise, and `freeEntity` when a 3D camera owns the frame. + local g4emotes = Game.data and Game.data.gen4_emotes + local g4ground = self.map and self.map.renderer and self.map.renderer.gen4Ground + if g4emotes and g4ground and npc.sprite then + local Gen4Emotes = require("src.import.Gen4Emotes") + local kind = (self.emote.bubble == "double" or self.emote.bubble == 2) + and g4emotes.double and "double" or "exclamation" + -- BW's "?", note and "..." by name (Gen5MoveExec), when the cache has them + local named = self.emote.bubble + if type(named) == "string" and named ~= "double" and type(g4emotes[named]) == "table" then + kind = named + end + local record = g4emotes[kind] + self.gen4EmoteImages = self.gen4EmoteImages or {} + local img = self.gen4EmoteImages[kind] + if img == nil and record and record.rgba then + local okI, made = pcall(function() + local data = love.image.newImageData(record.width, record.height, + "rgba8", record.rgba) + local image = love.graphics.newImage(data) + image:setFilter("nearest", "nearest") + return image + end) + img = okI and made or false + self.gen4EmoteImages[kind] = img + end + if img then + local age = math.max(0, (self.emote.totalFrames or Gen4Emotes.FRAMES) + - (self.emote.frames or 0)) + -- HEARTGOLD / SOULSILVER: the cartridge's own anchor, 32 world units + -- over the owner through the camera now drawing, the player + -- included (src/world/Gen4EmoteDraw.lua); Platinum below, unchanged + local EmoteDraw = require("src.world.Gen4EmoteDraw") + if EmoteDraw.applies(self.map) then + EmoteDraw.draw(self, g4ground, npc, cam, record, img, age) + return + end + local bx, by, rise = OverworldState.gen4EmoteSpot(g4ground, npc, cam, + record, age) + local function draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, bx, by) + end + local free = g4ground.freeMode and g4ground:freeMode() + if free then + -- Depth-tested where the cartridge's billboard actually is -- the + -- middle of a bubble 32 units up -- not at its trainer's feet. + g4ground:freeEntity(npc.px or 0, npc.py or 0, cam.x, cam.y, rise, draw, + Gen4Emotes.LIFT + 8) + else + draw() + end + return + end + end + -- WHERE IT HANGS, which the cartridge states outright. + -- + -- Reported from play: the bubble "should appear above the trainers head". + -- It was four pixels right of them and six low, which on a 16x32 walker + -- put it across the face rather than over it. + -- + -- The icon's own sprite callback (0B4724) copies the owner's position + -- every frame and offsets it by exactly one thing: + -- + -- icon->x = owner->x + -- icon->y = owner->y - 16 + -- + -- and a GBA sprite's x/y is its CENTRE (the corner comes from + -- centerToCornerVec), so this is "the icon's centre sits sixteen pixels + -- above the owner's" -- which is a different top-left offset for a 16x16 + -- object than for a 16x32 one, and is why one number could never be right + -- for both. Stated as centres it falls out the same for either: the x + -- offsets cancel whatever the sprite's width, and the y is the owner's + -- half-height less the icon's eight plus the sixteen. + local half = math.floor(((npc.sprite and npc.sprite.tileH) or 16) / 2) + local ex = npc.px - cam.x + local ey = npc.py - cam.y - 12 - half + local bubble = Game.data.field.emotionBubbles + local drawn = false + + -- HOENN'S OWN BUBBLE, which is not on the Game Boy's sheet. + -- + -- The "!" over a trainer's head is three 16x16 icons hanging off a sprite + -- template that only a field-effect script reaches (see extractEmotes) -- + -- which is why every sweep of the field-effect tables missed it. A Gen 3 + -- dataset that has them draws them; one that does not falls through to + -- the Game Boy sheet and then to the box below, exactly as before. + local icons = (Game.data.constants or {}).gen3Emotes + if icons then + local role = GEN3_EMOTE_ROLES[self.emote.bubble or 1] or "exclamation" + local record = icons[role] + local path = record and record.image + if path then + self.gen3EmoteImages = self.gen3EmoteImages or {} + local held = self.gen3EmoteImages[path] + if held == nil then + local okImg, img = pcall(require("src.render.Assets").image, path) + held = (okImg and img) or false + self.gen3EmoteImages[path] = held + end + if held then + local age = math.max(0, (self.emote.totalFrames or 60) + - self.emote.frames) + local frame = 0 + local left = age + for _, step in ipairs(record.timeline or {}) do + local duration = tonumber(step[2]) or 1 + if left < duration then + frame = tonumber(step[1]) or 0 + break + end + left = left - duration + frame = tonumber(step[1]) or frame + end + -- SpriteCB_TrainerIcons starts data[3] at -5, accumulates it into + -- data[4], and increments that velocity until the offset returns 0. + local bounce, velocity = 0, -5 + for _ = 1, math.min(age + 1, 11) do + bounce = bounce + velocity + if bounce ~= 0 then velocity = velocity + 1 else velocity = 0 end + end + local fw, fh = tonumber(record.width) or 16, tonumber(record.height) or 16 + local iw, ih = held:getDimensions() + local q = nil + if (tonumber(record.frames) or 1) > 1 then + self.gen3EmoteQuads = self.gen3EmoteQuads or {} + local key = path .. ":" .. tostring(frame) + q = self.gen3EmoteQuads[key] + if not q then + q = love.graphics.newQuad(frame * fw, 0, fw, fh, iw, ih) + self.gen3EmoteQuads[key] = q + end + end + love.graphics.setColor(1, 1, 1, 1) + if q then love.graphics.draw(held, q, ex, ey + bounce) + else love.graphics.draw(held, ex, ey + bounce) end + drawn = true + end + end + end + if not drawn and bubble and bubble.path then + local ok, img = pcall(function() + self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path) + return self.emoteImg + end) + -- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command + -- picks question/happy crops instead + local bi = self.emote.bubble or 1 + local rect = bubble.bubbles and bubble.bubbles[bi] + if ok and img and rect then + love.graphics.setColor(1, 1, 1, 1) + -- one Quad per bubble crop, cached: this draws every frame the "!" + -- (or the emote-command crops) is up, so a fresh Quad here churned + -- the GC. The bubble set is small and fixed, so the cache is bounded. + self.emoteQuads = self.emoteQuads or {} + local q = self.emoteQuads[bi] + if not q then + q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, + img:getDimensions()) + self.emoteQuads[bi] = q + end + love.graphics.draw(img, q, ex, ey) + drawn = true + end + end + if not drawn then + local Font = require("src.render.Font") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", ex, ey, 10, 12) + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12) + Font.draw("!", ex + 1, ey + 2) + love.graphics.setColor(1, 1, 1, 1) + end +end + +-- the FLY bird sweeping off with the player +function OverworldState:_worldFxBird(cam) + if not self.flyAnim then return end + -- FireRed has a dedicated five-frame 64x64 field-effect bird. Frames 1 + -- and 3 already contain Red/Leaf riding it during fly-out, so use the + -- cartridge sheet directly instead of substituting the selected mon's + -- battle front sprite. + local frlg = Game.data.constants and Game.data.constants.gen3FRLGFlyBird + if frlg and frlg.path then + if self.flyBirdImg == nil then + local ok, img = pcall(love.graphics.newImage, frlg.path) + self.flyBirdImg = ok and img or false + end + if self.flyBirdImg then + local gender = ((Game.save or {}).player or {}).gender == "girl" + and "girl" or "boy" + local frame = ((frlg.flyOut or {})[gender]) or (gender == "girl" and 3 or 1) + local fw = frlg.frameWidth or 64 + local fh = frlg.frameHeight or 64 + self.flyBirdQuads = self.flyBirdQuads or {} + if not self.flyBirdQuads[frame] then + local iw, ih = self.flyBirdImg:getDimensions() + self.flyBirdQuads[frame] = love.graphics.newQuad( + 0, frame * fh, fw, fh, iw, ih) + end + local t = math.max(0, math.min(48, 48 - self.flyAnim.frames)) + local reach = 16 + local dx, dy + if t <= reach then + local k = 1 - t / reach + dx, dy = -96 * k, -72 * k + else + local k = (t - reach) / (48 - reach) + dx, dy = -120 * k * k, -96 * k * k + end + local px = math.floor(self.player.px - cam.x + 8 - fw / 2 + dx) + local py = math.floor(self.player.py - cam.y + 8 - fh / 2 + dy) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.flyBirdImg, self.flyBirdQuads[frame], px, py) + require("src.render.PaletteFX").markTrueColor(px, py, fw, fh) + return + end + end + local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly") + if not self.birdSprite and birdId and Game.data.sprites[birdId] then + local SR = require("src.render.SpriteRenderer") + self.birdSprite = SR.new(Game.data.sprites[birdId]) + end + if self.birdSprite then + local t = 48 - self.flyAnim.frames + local bx = self.player.px - t * 4 + local by = self.player.py - math.floor(t * 1.5) + love.graphics.setColor(1, 1, 1, 1) + self.birdSprite:draw(bx, by, cam.x, cam.y, "left", + math.floor(t / 4) % 2, false) + return + end + + -- Legacy fallback for datasets that predate the ROM-extracted FireRed + -- bird above. Keep it for old caches/other games, but current FireRed + -- imports should never reach this branch. + -- + -- Reported from play, twice: "ensure the animation of the flying type + -- pokemon swooping up my player plays after the fly hm transition" and + -- then "Theres still no fly swoop". The machinery above has been here the + -- whole time and has never had a sprite to draw: it wants + -- playerSprites.fly, and no Gen 3 cache carries one. + -- + -- IT WAS LOOKED FOR PROPERLY THIS TIME, and it is not where a sheet like + -- that would be. Emerald's fly bird is not an object-event graphics row + -- -- the three 64x64 rows in that table are Rayquaza twice and the cable + -- car, and every 32x32 row is a person or a legendary -- and it is not + -- hanging off any field-effect script either: not one of the sixty-seven + -- scripts loads tiles, and no native any of them calls reaches a sprite + -- template with frames that size. It is loaded by C, from a sheet only + -- the fly task names, and nothing the import can currently follow gets to + -- it. + -- + -- So the departure is drawn with something this port certainly does have + -- and the cartridge's own bird is not: the FRONT SPRITE OF THE POKEMON + -- THAT IS ACTUALLY CARRYING YOU -- the same one the field-move sweep just + -- showed. It stoops in from the upper left, meets the player, and lifts + -- away with them. Reconstructed, and said so; the moment the bird IS + -- found, playerSprites.fly makes the branch above win again and this is + -- never reached. + local mon = self.flyAnim.mon + if not mon then return end + if self.flyMonImg == nil then + local okPath, path = pcall(function() + return require("src.pokemon.Sprites").path( + Game.data, mon.species, "front", { mon = mon }) + end) + local img + if okPath and type(path) == "string" then + local okImg, loaded = pcall(love.graphics.newImage, path) + img = okImg and loaded or nil + end + self.flyMonImg = img or false + end + if not self.flyMonImg then return end + local total = 48 + local t = math.max(0, math.min(total, total - self.flyAnim.frames)) + -- the stoop: in over sixteen frames, away over the rest, and the two + -- meet on the player rather than anywhere else + local reach = 16 + local dx, dy + if t <= reach then + local k = 1 - t / reach + dx, dy = -96 * k, -72 * k + else + local k = (t - reach) / (total - reach) + dx, dy = -120 * k * k, -96 * k * k + end + local iw, ih = self.flyMonImg:getDimensions() + local px = math.floor(self.player.px - cam.x + 8 - iw / 2 + dx) + local py = math.floor(self.player.py - cam.y + 8 - ih / 2 + dy) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.flyMonImg, px, py) + require("src.render.PaletteFX").markTrueColor(px, py, iw, ih) +end + +-- fishing pose: the rod tile over the faced water (gfx/fishing.asm) +function OverworldState:_worldFxRod(cam) + if not self.fishing then return end + local fx = Game.data.field.overworldFx + local rod = fx and fx.fishingRod + if rod then + if self.rodImg == nil then + local ok, img = pcall(love.graphics.newImage, rod.path) + self.rodImg = ok and img or false + end + if self.rodImg then + local p = self.player + local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down + if not self.rodQuads then + -- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile) + local iw, ih = self.rodImg:getDimensions() + self.rodQuads = {} + for i = 0, math.floor(ih / 8) - 1 do + self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih) + end + end + local quad = self.rodQuads[oam.tile] + -- the sprite's top-left is 4px above its cell (SpriteRenderer:draw) + local rx = p.px - cam.x + oam.dx + local ry = p.py - cam.y - 4 + oam.dy + love.graphics.setColor(1, 1, 1, 1) + if quad and oam.flip then + love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) + elseif quad then + love.graphics.draw(self.rodImg, quad, rx, ry) + end + end + end +end + +function OverworldState:_worldRise(e, groundForRise, groundSin, cam) + if not groundForRise then return 0 end + local rise = groundForRise:rise(e.px + (e.shiftPx or 0) + 8, e.py + 8) + -- A SPRITE OFFSET (MapObject_SetSpritePosOffset: the surf mount's ride, + -- the Distortion World cast's flights) in the field view: up by the + -- camera's height lean, toward it by the ground's + local so = e.spriteOffset + if so and (so.lift or so.oz) then + rise = rise + (so.lift or 0) * (groundForRise.heightScale or 0) + - (so.oz or 0) * (groundForRise.groundScale or 1) + end + if groundSin >= 1 then return rise end + return (e.py - cam.y) * (1 - groundSin) + rise +end + +function OverworldState:_worldDrawEntity(e, cam, bgY, freeGround, groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf, field, gymFog) + if not (self.flyAnim and self:hasFlyBird() and e == self.player) + and not e.hidden and not e.berryVisualEmpty and not plotIsEmpty(e) then + -- A FREE CAMERA PLACES ITS OWN CHARACTERS. + -- + -- Reported from play: *"isnt keeping the player the right size"*, with a + -- screenshot of a third-person Twinleaf in correct perspective and two + -- characters standing in it at flat 2D scale. They were drawn by the + -- line below, which places every sprite at `py - camY` -- the field + -- camera's 1:1 mapping, and simply not this camera's. + -- + -- `freeEntity` runs THIS SAME DRAW inside a transform, so nothing here + -- learns about projection and the entity keeps owning its own sprite, + -- frame and palette. + -- + -- In FIRST person the player is the eye, so drawing them puts their own + -- back across the middle of the view. + if freeGround then + if not (freeGround:freeMode() == "first" and e == self.player) then + local rise = riseOf(self, e, groundForRise, groundSin, cam) + -- ...AND WHICH WAY THEY FACE ON SCREEN. + -- + -- Reported from play: *"when i orbit the camera 180 degrees if i + -- walk left it looks like hes walking right"*. `screenToWorld` + -- turns the PRESS into a world direction, so the step is right; + -- the sprite then showed that world facing under a camera that is + -- no longer looking north, so the artwork was the one thing still + -- in the old frame. `worldToScreen` is its inverse, and the pair + -- are inverses because the camera sits between them. + -- + -- Set and restored around the draw rather than threaded through, + -- because `facing` is what every pose, animation and follower path + -- in the entity reads, and none of them should learn about cameras. + local view, saved = freeGround.view3d, e.facing + if view and view.worldToScreen then + e.facing = view:worldToScreen(saved) + end + local fogRow = GameVersion.isGen4() and self.map and self.map.def + and self.map.def.gen4DepthFog + local fogged = GameVersion.isGen4() + and not (type(fogRow) == "table" and fogRow.exempt and fogRow.exempt(e, self)) + freeGround:freeEntity((e.px or 0), (e.py or 0), cam.x, cam.y, rise, + function() e:draw(cam.x, cam.y + rise) end, + nil, require("src.world.Gen4SurfMount").poseFor(e, e == self.player and self.dwPose or nil), fogged) + e.facing = saved + end + -- Deliberately without the flat shadow and the grass overdraw below: + -- both are drawn in screen space against a ground plane this camera + -- does not have, and a shadow in the wrong place is worse than none. + return + end + -- IN THE WORLD'S DEPTH on Sinnoh's cartridge camera + -- (Gen4Ground:beginSprites): the billboard origin's depth, so a prop in + -- front of a character hides them; nil leaves the draw as it was + local depth = spriteDepthGround and spriteDepthGround:billboardDepth( + (e.px or 0) + (e.shiftPx or 0), e.py or 0, + e.spriteOffset and e.spriteOffset.lift, e.spriteOffset and e.spriteOffset.oz) + -- UNDER the sprite, and lifted by the same terrain rise it is, so a + -- character on a slope keeps their feet on it. + if shadows and not e.customSourceActor then + if depth then + spriteDepthGround:withSpriteDepth(depth, function() + shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(self, e, groundForRise, groundSin, cam)) + end, false) + else + shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(self, e, groundForRise, groundSin, cam)) + end + end + local fogLeft = gymFog and gymFog(e) + if fogLeft then love.graphics.setColor(fogLeft, fogLeft, fogLeft, 1) end + if depth then + spriteDepthGround:withSpriteDepth(depth, function() e:draw(cam.x, cam.y + riseOf(self, e, groundForRise, groundSin, cam)) end) + else + e:draw(cam.x, cam.y + riseOf(self, e, groundForRise, groundSin, cam)) + end + if fogLeft then love.graphics.setColor(1, 1, 1, 1) end + if field and field.actorsOnly then return end + -- tall grass overdraws the sprite's feet (GB sprite priority); + -- the overdraw is BG tiles, so it rides the shake offset too + love.graphics.setColor(1, 1, 1, 1) + if self.map:isGrassCell(e.cellX, e.cellY) then + if not self:drawGen3Grass(e.cellX, e.cellY, cam.x, bgY, + self:gen3GrassFrame(e, false)) then + if field then field.tiles:cell(e.cellX,e.cellY,cam,bgY) + else self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY)end + if grassColors then + self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY, + cam.x, bgY, grassColors) + end + end + end + if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then + if not self:drawGen3Grass(e.targetX, e.targetY, cam.x, bgY, + self:gen3GrassFrame(e, true)) then + if field then field.tiles:cell(e.targetX,e.targetY,cam,bgY) + else self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY)end + if grassColors then + self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY, + cam.x, bgY, grassColors) + end + end + end + end +end + +function OverworldState:_worldAim(renderer, ox, oy, feetX, feetZ) + local g4 = renderer and renderer.gen4Ground + if g4 then + if ox then g4.focusX, g4.focusZ = feetX - ox, feetZ - oy + else g4.focusX, g4.focusZ = nil, nil end + end +end + +-- Private cell columns never cross the public rippleCells seam. +local nativeRippleCells = OverworldState.rippleCells +function OverworldState:_worldAddCoverCell(pool, cx, cy) + local key = cx * 4096 + cy + if pool.seen[key] then return end + pool.seen[key] = true + local n = pool.n + 1 + pool.n, pool.x[n], pool.y[n] = n, cx, cy +end + +function OverworldState:_worldReflectionCover(reflectors, count, renderer, cam, bgY) + local pool = self._worldCover + if not pool then + pool = {x={}, y={}, seen={}, n=0} + self._worldCover = pool + elseif pool.busy then + pool = {x={}, y={}, seen={}, n=0} + end + pool.busy = true + local oldN = pool.n + pool.n = 0 + for key in pairs(pool.seen) do pool.seen[key] = nil end + for i = 1, count do + local cx0, cy0, cx1, cy1 = self:reflectionRect(reflectors[i]) + if cx0 then + for cy = cy0, cy1 do + for cx = cx0, cx1 do self:_worldAddCoverCell(pool, cx, cy) end + end + end + end + for _, r in ipairs(self.ripples or {}) do + for cy = math.floor(r.py / 16), math.floor((r.py + 15) / 16) do + for cx = math.floor(r.px / 16), math.floor((r.px + 15) / 16) do + self:_worldAddCoverCell(pool, cx, cy) + end + end + end + for i = pool.n + 1, oldN do pool.x[i], pool.y[i] = nil, nil end + for i = 1, pool.n do + renderer:drawReflectionCover(pool.x[i], pool.y[i], cam.x, bgY) + end + pool.busy = nil +end + +function OverworldState:_worldEntityLoop(drawEntity, cam, bgY, freeGround, groundForRise, + groundSin, grassColors, spriteDepthGround, shadows, riseOf, field, gymFog) + local onTop + for _, e in ipairs(self.entities) do + if self:gen3AboveTopLayer(e) then + onTop = onTop or {} + onTop[#onTop + 1] = e + else + drawEntity(self, e, cam, bgY, freeGround, groundForRise, groundSin, + grassColors, spriteDepthGround, shadows, riseOf, nil, gymFog) + end + end + return onTop +end + +local function drawWorldBody(self, cadenceConsumed) -- Dark-map BG shade shift, armed for the whole frame before anything draws. -- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the -- characters standing on it and any dialog over them darken together (#322); @@ -17235,7 +18051,7 @@ function OverworldState:drawWorld() -- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate -- does not speed or slow the cycle (issue #4). local TR = tileRenderer() - TR.tick() + if not cadenceConsumed then TR.tick()end -- let the renderer know whether a spinner puzzle is currently sliding -- the player, so it can flicker the arrow tiles between the blur and -- static graphic (engine/overworld/spinners.asm LoadSpinnerArrowTiles) @@ -17286,6 +18102,7 @@ function OverworldState:drawWorld() -- path below on the vanilla flat/tilt draw local override if not pipelineId then + local closeTiles = FrameProfile.section(" world: tiles") self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do @@ -17295,6 +18112,7 @@ function OverworldState:drawWorld() if self.g3MirageTower then require("src.world.Gen3MirageTower").drawTower(self, cam) end + closeTiles() end -- per-billboard SGB palette source; only needed (and only paid for) when -- tilting. nil headless / on stale palettes -> billboards go uncolorized. @@ -17312,638 +18130,23 @@ function OverworldState:drawWorld() -- in their historical order. (Bodies are byte-identical to the pre-tilt -- inline code, so the flat draw sequence is unchanged.) - -- the Pokémon Center heal machine (PokeCenterOAMData): the monitor - -- tile over the machine's screen and one ball per healed mon in two - -- mirrored columns, all blinking during the jingle flash. The GB - -- draws it at fixed screen coords with the player's cell BG-aligned - -- at (64,64); anchoring those coords to where the player stood keeps - -- the overlay on the machine at any zoom. - local function fxHeal() - if not self.healAnim then return end - local ha = self.healAnim - -- HOENN'S MACHINE IS NOT AN OAM TABLE. - -- - -- Everything below this block is the Game Boy's: two 8x8 tiles out of - -- field.overworldFx.healMachine at coordinates a table in the ROM gives. - -- Emerald has no such table -- its machine is a field effect whose two - -- sprites are created by machine code, and where they go is four - -- immediates INSIDE that code. extractHealMachine reads them, and the - -- record it writes carries the positions already measured from the corner - -- of the player's own cell, so nothing here has to know where the - -- cartridge's camera puts anybody. - if GameVersion.isGen4() then return end - if self:drawGen3Heal(ha, cam) then return end - local fxDef = Game.data.field.overworldFx - if self.healMachineImg == nil and fxDef and fxDef.healMachine then - local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path) - self.healMachineImg = ok and img or false - end - local img = self.healMachineImg - if img then - if not self.healMachineQuads then - local w, h = img:getWidth(), img:getHeight() - self.healMachineQuads = { - love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c) - love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) - } - end - -- The machine carries its OWN four colours. HealMachineAnim.LoadPalettes - -- (04:$6434) copies them over OBJ palette 6 before the animation runs -- - -- which is why every row of its OAM table names palette 6 -- and they are - -- white / light orange / red / black: a Poke Ball. Nothing was applying - -- any palette at all, so the sheet drew in the DMG greys it is decoded - -- in and a Pokemon Center healed with grey balls. - local def = fxDef and fxDef.healMachine - local objPal = def and def.gen2ObjPal - local basePal = (objPal and objPal[1] and objPal) or PaletteFX.GRAYS - -- the jingle flash recolors the machine sprites in place - -- (FlashSprite8Times XORs rOBP1; the sprites never disappear): - -- ha.visible == false is the flashed half of each beat, drawn with - -- the light/dark shades swapped instead of skipped - local shader - if (not ha.visible) or basePal ~= PaletteFX.GRAYS then - shader = PaletteFX.shader() - if shader then - PaletteFX.sendColors(shader, ha.visible and basePal - or PaletteFX.permute(basePal, HEAL_FLASH_MAP)) - love.graphics.setShader(shader) - end - end - -- TileRenderer windows with -floor(cam), so the overlay must use the - -- same snap or a fractional camera (odd fill/tilt view sizes) parks - -- the balls a pixel off the machine tiles - local ox = ha.px - 64 - math.floor(cam.x) - local oy = ha.py - 64 - math.floor(cam.y) - -- Gen2 puts these sprites somewhere else entirely. Its - -- HealMachineAnim.PC_ElmsLab_OAM (04:$63BC) has the monitor at x 26/30 - -- and the balls at x 24/32; Gen1's PokeCenterOAMData has the balls at - -- 40/48. The extractor reads that OAM table straight off the cartridge - -- into field.overworldFx.healMachine, so use it where it exists and keep - -- the Gen1 constants as the fallback -- drawn at Gen1's offsets the - -- whole overlay sat a full 16px right of the Gen2 machine. - local monitor = (def and def.monitor) or { { 44, 20 } } - local balls = (def and def.balls) or HEAL_BALL_XY - love.graphics.setColor(1, 1, 1, 1) - for _, m in ipairs(monitor) do - love.graphics.draw(img, self.healMachineQuads[1], ox + m[1], oy + m[2]) - end - for i = 1, math.min(ha.lit, #balls) do - local b = balls[i] - if b[3] then -- right column: OAM_XFLIP - love.graphics.draw(img, self.healMachineQuads[2], - ox + b[1] + 8, oy + b[2], 0, -1, 1) - else - love.graphics.draw(img, self.healMachineQuads[2], - ox + b[1], oy + b[2]) - end - end - if shader then love.graphics.setShader() end - end - end - - -- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell, - -- flickering (AnimateBoulderDust XORs the OBJ palette every step) - local function fxDust() - if not self.dustAnim then return end - local fxDef = Game.data.field.overworldFx - local smoke = fxDef and fxDef.smoke - if smoke then - if self.smokeImg == nil then - local ok, img = pcall(love.graphics.newImage, smoke.path) - self.smokeImg = ok and img or false - end - if self.smokeImg then - local da = self.dustAnim - local dx = da.x * 16 - cam.x - local dy = da.y * 16 - cam.y - local flicker = math.floor(da.frames / 4) % 2 == 0 - love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) - for i = 0, 1 do - for j = 0, 1 do - love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8) - end - end - love.graphics.setColor(1, 1, 1, 1) - end - end - end - - -- FireRed's S.S. Anne departure uses two ordinary OBJ sprites that are not - -- object events: a looping 16x32 wake behind the ship and 16x16 smoke puffs - -- from its funnel. Special 401 owns their exact lifetime/positions; this - -- pass only turns that state into the extracted ROM frames. - local function ssAnneArt() - local def = Game.data.constants and Game.data.constants.gen3SSAnneFx - if not def then return nil end - if self.ssAnneWakeImg == nil then - local ok, img = pcall(Assets.image, def.wake) - self.ssAnneWakeImg = ok and img or false - end - if self.ssAnneSmokeImg == nil then - local ok, img = pcall(Assets.image, def.smoke) - self.ssAnneSmokeImg = ok and img or false - end - if self.ssAnneWakeImg and not self.ssAnneWakeQuads then - local w, h = self.ssAnneWakeImg:getDimensions() - self.ssAnneWakeQuads = { - love.graphics.newQuad(0, 0, 16, 32, w, h), - love.graphics.newQuad(16, 0, 16, 32, w, h), - } - end - if self.ssAnneSmokeImg and not self.ssAnneSmokeQuads then - local w, h = self.ssAnneSmokeImg:getDimensions() - self.ssAnneSmokeQuads = {} - for i = 0, 3 do - self.ssAnneSmokeQuads[i + 1] = love.graphics.newQuad(i * 16, 0, 16, 16, w, h) - end - end - return self.ssAnneWakeImg, self.ssAnneSmokeImg - end - - local function fxSSAnneWake() - local fx = self.ssAnneDepartureFx - if not (fx and fx.boat) then return end - local wake = ssAnneArt() - if not (wake and self.ssAnneWakeQuads) then return end - local age = math.min(fx.wakeAge or 0, 132) - local cx = fx.boat.px + (fx.boat.shiftPx or 0) + 8 - cam.x - + 80 + math.floor(age / 6) - if cx < -18 then return end - local frame = math.floor(age / 12) % 2 + 1 - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(wake, self.ssAnneWakeQuads[frame], - math.floor(cx - 8), 109 - 16) - end - - local function fxSSAnneSmoke() - local fx = self.ssAnneDepartureFx - if not (fx and fx.smoke and #fx.smoke > 0) then return end - local _, smoke = ssAnneArt() - if not (smoke and self.ssAnneSmokeQuads) then return end - love.graphics.setColor(1, 1, 1, 1) - for _, puff in ipairs(fx.smoke) do - local age = puff.age or 0 - local frame - if age < 10 then frame = 1 - elseif age < 30 then frame = 2 - elseif age < 50 then frame = 3 - else frame = 4 end - local cx = puff.x + math.floor(age / 4) - love.graphics.draw(smoke, self.ssAnneSmokeQuads[frame], - math.floor(cx - 8), 78 - 8) - end - end - - -- the watering can's water: droplets arcing down onto the soil, and a - -- sparkle on the last few frames as it soaks in - local function fxWater() - if not self.waterAnim then return end - local wa = self.waterAnim - local total = wa.total or 40 - local age = total - wa.frames - local dx = wa.x * 16 - cam.x - local dy = wa.y * 16 - cam.y - love.graphics.setColor(0.55, 0.80, 0.98, 0.95) - for i = 0, 5 do - -- each droplet falls on its own phase, so they read as a stream - local t = ((age * 3 + i * 7) % 24) / 24 - local px = dx + 2 + i * 2 + t * 10 - local py = dy - 6 + t * 20 - if py < dy + 16 then - love.graphics.rectangle("fill", px, py, 2, 3) - end - end - -- the soak: a brief bright ring once the water has been falling a while - if age > total * 0.55 then - local k = (age - total * 0.55) / (total * 0.45) - love.graphics.setColor(1, 1, 1, 0.8 * (1 - k)) - love.graphics.rectangle("fill", dx + 2, dy + 12, 12, 2) - end - love.graphics.setColor(1, 1, 1, 1) - end - - -- the cut tree splitting apart (AnimCut): top half slides right, - -- bottom half slides left, 1px per frame, flickering as they go - local function fxCutTree() - if not self.cutAnim then return end - local fxDef = Game.data.field.overworldFx - local tree = fxDef and fxDef.cutTree - if not tree then return end - if self.cutTreeImg == nil then - local ok, img = pcall(love.graphics.newImage, tree.path) - self.cutTreeImg = ok and img or false - end - local img = self.cutTreeImg - if not img then return end - if not self.cutTreeQuads then - local w, h = img:getWidth(), img:getHeight() - self.cutTreeQuads = { - love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half - love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half - } - end - local ca = self.cutAnim - local off = (ca.total or 8) - ca.frames - local dx = ca.x * 16 - cam.x - local dy = ca.y * 16 - cam.y - local flicker = ca.frames % 2 == 0 - love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55) - love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy) - love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8) - love.graphics.setColor(1, 1, 1, 1) - end - - -- the "!" bubble above a trainer who spotted the player - -- the tile-anchored sparkle: an OAM sprite in the cartridge, so it rides - -- the camera the way a body does rather than the background's shake - local function fxSparkle() - love.graphics.setColor(1, 1, 1, 1) - self:drawSparkles(cam.x, cam.y) - end - - -- ...and ONE of them, for the two paths that cannot draw the set in screen - -- space: each sparkle sits on its own cell, so a projected scene has to - -- anchor each one separately. The upvalue is set immediately before each - -- call and cleared after, which is the same shape the emote path uses for - -- the one NPC it belongs to. - local sparkleOne = nil - local function fxSparkleOne() - local s = sparkleOne - if not s then return end - local sprite = self:sparkleSprite() - if not sprite then return end - local frame = self:sparkleFrame(s.clock) - if not frame then return end - love.graphics.setColor(1, 1, 1, 1) - sprite:drawFixedFrame(s.px, s.py + 4, cam.x, cam.y, frame) - end - - -- THE POKE RADAR'S PATCHES, one at a time like the sparkles - local radarOne = nil - local function fxRadarOne() - local pt = radarOne - if not pt then return end - require("src.world.Gen4Radar").drawPatch(pt, pt.x * 16 - cam.x, pt.y * 16 - cam.y, - love.timer and love.timer.getTime() * 60 or 0) - end - local function fxRadar() - for _, pt in ipairs((self.gen4Radar and self.gen4Radar.active and self.gen4Radar.patches) or {}) do - if pt.active then radarOne = pt; fxRadarOne() end - end - radarOne = nil - end - - local function fxEmote() - if not (self.emote and self.emote.npc) then return end - -- bubble = false is a silent hold (a Pikachu emotion that plays a - -- cry with no bubble still pauses the world for its beat) - if self.emote.bubble == false then return end - local npc = self.emote.npc - -- SINNOH'S OWN BUBBLE, ON TOP OF THE HEAD, IN THE GROUND'S PROJECTION. - -- - -- Reported from play: *"were missing the proper exclamation point above - -- trainers when they see you for battle and its not above their heads"*. - -- Platinum had neither art source below, so it fell to the hand-drawn box, - -- placed with flat 2D arithmetic that knows nothing of terrain height or - -- the tilt -- so it landed across the face. - -- - -- The cartridge (src/overlay005/ov5_021F5A10.c, see Gen4Emotes): a 16x16 - -- billboard from fldeff.narc whose plane rises 0..16 from an anchor 32 - -- units above the owner's drawn position -- the top of a 32-unit - -- character -- bouncing 6, 10, 12, 12, 10, 6, 0 and then holding. So the - -- bubble's bottom edge sits on the sprite's top edge, centred on it, and - -- it is drawn through exactly the transform the sprite is: the same - -- terrain rise, and `freeEntity` when a 3D camera owns the frame. - local g4emotes = Game.data and Game.data.gen4_emotes - local g4ground = self.map and self.map.renderer and self.map.renderer.gen4Ground - if g4emotes and g4ground and npc.sprite then - local Gen4Emotes = require("src.import.Gen4Emotes") - local kind = (self.emote.bubble == "double" or self.emote.bubble == 2) - and g4emotes.double and "double" or "exclamation" - -- BW's "?", note and "..." by name (Gen5MoveExec), when the cache has them - local named = self.emote.bubble - if type(named) == "string" and named ~= "double" and type(g4emotes[named]) == "table" then - kind = named - end - local record = g4emotes[kind] - self.gen4EmoteImages = self.gen4EmoteImages or {} - local img = self.gen4EmoteImages[kind] - if img == nil and record and record.rgba then - local okI, made = pcall(function() - local data = love.image.newImageData(record.width, record.height, - "rgba8", record.rgba) - local image = love.graphics.newImage(data) - image:setFilter("nearest", "nearest") - return image - end) - img = okI and made or false - self.gen4EmoteImages[kind] = img - end - if img then - local age = math.max(0, (self.emote.totalFrames or Gen4Emotes.FRAMES) - - (self.emote.frames or 0)) - -- HEARTGOLD / SOULSILVER: the cartridge's own anchor, 32 world units - -- over the owner through the camera now drawing, the player - -- included (src/world/Gen4EmoteDraw.lua); Platinum below, unchanged - local EmoteDraw = require("src.world.Gen4EmoteDraw") - if EmoteDraw.applies(self.map) then - EmoteDraw.draw(self, g4ground, npc, cam, record, img, age) - return - end - local bx, by, rise = OverworldState.gen4EmoteSpot(g4ground, npc, cam, - record, age) - local function draw() - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(img, bx, by) - end - local free = g4ground.freeMode and g4ground:freeMode() - if free then - -- Depth-tested where the cartridge's billboard actually is -- the - -- middle of a bubble 32 units up -- not at its trainer's feet. - g4ground:freeEntity(npc.px or 0, npc.py or 0, cam.x, cam.y, rise, draw, - Gen4Emotes.LIFT + 8) - else - draw() - end - return - end - end - -- WHERE IT HANGS, which the cartridge states outright. - -- - -- Reported from play: the bubble "should appear above the trainers head". - -- It was four pixels right of them and six low, which on a 16x32 walker - -- put it across the face rather than over it. - -- - -- The icon's own sprite callback (0B4724) copies the owner's position - -- every frame and offsets it by exactly one thing: - -- - -- icon->x = owner->x - -- icon->y = owner->y - 16 - -- - -- and a GBA sprite's x/y is its CENTRE (the corner comes from - -- centerToCornerVec), so this is "the icon's centre sits sixteen pixels - -- above the owner's" -- which is a different top-left offset for a 16x16 - -- object than for a 16x32 one, and is why one number could never be right - -- for both. Stated as centres it falls out the same for either: the x - -- offsets cancel whatever the sprite's width, and the y is the owner's - -- half-height less the icon's eight plus the sixteen. - local half = math.floor(((npc.sprite and npc.sprite.tileH) or 16) / 2) - local ex = npc.px - cam.x - local ey = npc.py - cam.y - 12 - half - local bubble = Game.data.field.emotionBubbles - local drawn = false - - -- HOENN'S OWN BUBBLE, which is not on the Game Boy's sheet. - -- - -- The "!" over a trainer's head is three 16x16 icons hanging off a sprite - -- template that only a field-effect script reaches (see extractEmotes) -- - -- which is why every sweep of the field-effect tables missed it. A Gen 3 - -- dataset that has them draws them; one that does not falls through to - -- the Game Boy sheet and then to the box below, exactly as before. - local icons = (Game.data.constants or {}).gen3Emotes - if icons then - local role = GEN3_EMOTE_ROLES[self.emote.bubble or 1] or "exclamation" - local record = icons[role] - local path = record and record.image - if path then - self.gen3EmoteImages = self.gen3EmoteImages or {} - local held = self.gen3EmoteImages[path] - if held == nil then - local okImg, img = pcall(require("src.render.Assets").image, path) - held = (okImg and img) or false - self.gen3EmoteImages[path] = held - end - if held then - local age = math.max(0, (self.emote.totalFrames or 60) - - self.emote.frames) - local frame = 0 - local left = age - for _, step in ipairs(record.timeline or {}) do - local duration = tonumber(step[2]) or 1 - if left < duration then - frame = tonumber(step[1]) or 0 - break - end - left = left - duration - frame = tonumber(step[1]) or frame - end - -- SpriteCB_TrainerIcons starts data[3] at -5, accumulates it into - -- data[4], and increments that velocity until the offset returns 0. - local bounce, velocity = 0, -5 - for _ = 1, math.min(age + 1, 11) do - bounce = bounce + velocity - if bounce ~= 0 then velocity = velocity + 1 else velocity = 0 end - end - local fw, fh = tonumber(record.width) or 16, tonumber(record.height) or 16 - local iw, ih = held:getDimensions() - local q = nil - if (tonumber(record.frames) or 1) > 1 then - self.gen3EmoteQuads = self.gen3EmoteQuads or {} - local key = path .. ":" .. tostring(frame) - q = self.gen3EmoteQuads[key] - if not q then - q = love.graphics.newQuad(frame * fw, 0, fw, fh, iw, ih) - self.gen3EmoteQuads[key] = q - end - end - love.graphics.setColor(1, 1, 1, 1) - if q then love.graphics.draw(held, q, ex, ey + bounce) - else love.graphics.draw(held, ex, ey + bounce) end - drawn = true - end - end - end - if not drawn and bubble and bubble.path then - local ok, img = pcall(function() - self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path) - return self.emoteImg - end) - -- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command - -- picks question/happy crops instead - local bi = self.emote.bubble or 1 - local rect = bubble.bubbles and bubble.bubbles[bi] - if ok and img and rect then - love.graphics.setColor(1, 1, 1, 1) - -- one Quad per bubble crop, cached: this draws every frame the "!" - -- (or the emote-command crops) is up, so a fresh Quad here churned - -- the GC. The bubble set is small and fixed, so the cache is bounded. - self.emoteQuads = self.emoteQuads or {} - local q = self.emoteQuads[bi] - if not q then - q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h, - img:getDimensions()) - self.emoteQuads[bi] = q - end - love.graphics.draw(img, q, ex, ey) - drawn = true - end - end - if not drawn then - local Font = require("src.render.Font") - love.graphics.setColor(1, 1, 1, 1) - love.graphics.rectangle("fill", ex, ey, 10, 12) - love.graphics.setColor(0, 0, 0, 1) - love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12) - Font.draw("!", ex + 1, ey + 2) - love.graphics.setColor(1, 1, 1, 1) - end - end - - -- the FLY bird sweeping off with the player - local function fxBird() - if not self.flyAnim then return end - -- FireRed has a dedicated five-frame 64x64 field-effect bird. Frames 1 - -- and 3 already contain Red/Leaf riding it during fly-out, so use the - -- cartridge sheet directly instead of substituting the selected mon's - -- battle front sprite. - local frlg = Game.data.constants and Game.data.constants.gen3FRLGFlyBird - if frlg and frlg.path then - if self.flyBirdImg == nil then - local ok, img = pcall(love.graphics.newImage, frlg.path) - self.flyBirdImg = ok and img or false - end - if self.flyBirdImg then - local gender = ((Game.save or {}).player or {}).gender == "girl" - and "girl" or "boy" - local frame = ((frlg.flyOut or {})[gender]) or (gender == "girl" and 3 or 1) - local fw = frlg.frameWidth or 64 - local fh = frlg.frameHeight or 64 - self.flyBirdQuads = self.flyBirdQuads or {} - if not self.flyBirdQuads[frame] then - local iw, ih = self.flyBirdImg:getDimensions() - self.flyBirdQuads[frame] = love.graphics.newQuad( - 0, frame * fh, fw, fh, iw, ih) - end - local t = math.max(0, math.min(48, 48 - self.flyAnim.frames)) - local reach = 16 - local dx, dy - if t <= reach then - local k = 1 - t / reach - dx, dy = -96 * k, -72 * k - else - local k = (t - reach) / (48 - reach) - dx, dy = -120 * k * k, -96 * k * k - end - local px = math.floor(self.player.px - cam.x + 8 - fw / 2 + dx) - local py = math.floor(self.player.py - cam.y + 8 - fh / 2 + dy) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.flyBirdImg, self.flyBirdQuads[frame], px, py) - require("src.render.PaletteFX").markTrueColor(px, py, fw, fh) - return - end - end - local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly") - if not self.birdSprite and birdId and Game.data.sprites[birdId] then - local SR = require("src.render.SpriteRenderer") - self.birdSprite = SR.new(Game.data.sprites[birdId]) - end - if self.birdSprite then - local t = 48 - self.flyAnim.frames - local bx = self.player.px - t * 4 - local by = self.player.py - math.floor(t * 1.5) - love.graphics.setColor(1, 1, 1, 1) - self.birdSprite:draw(bx, by, cam.x, cam.y, "left", - math.floor(t / 4) % 2, false) - return - end - - -- Legacy fallback for datasets that predate the ROM-extracted FireRed - -- bird above. Keep it for old caches/other games, but current FireRed - -- imports should never reach this branch. - -- - -- Reported from play, twice: "ensure the animation of the flying type - -- pokemon swooping up my player plays after the fly hm transition" and - -- then "Theres still no fly swoop". The machinery above has been here the - -- whole time and has never had a sprite to draw: it wants - -- playerSprites.fly, and no Gen 3 cache carries one. - -- - -- IT WAS LOOKED FOR PROPERLY THIS TIME, and it is not where a sheet like - -- that would be. Emerald's fly bird is not an object-event graphics row - -- -- the three 64x64 rows in that table are Rayquaza twice and the cable - -- car, and every 32x32 row is a person or a legendary -- and it is not - -- hanging off any field-effect script either: not one of the sixty-seven - -- scripts loads tiles, and no native any of them calls reaches a sprite - -- template with frames that size. It is loaded by C, from a sheet only - -- the fly task names, and nothing the import can currently follow gets to - -- it. - -- - -- So the departure is drawn with something this port certainly does have - -- and the cartridge's own bird is not: the FRONT SPRITE OF THE POKEMON - -- THAT IS ACTUALLY CARRYING YOU -- the same one the field-move sweep just - -- showed. It stoops in from the upper left, meets the player, and lifts - -- away with them. Reconstructed, and said so; the moment the bird IS - -- found, playerSprites.fly makes the branch above win again and this is - -- never reached. - local mon = self.flyAnim.mon - if not mon then return end - if self.flyMonImg == nil then - local okPath, path = pcall(function() - return require("src.pokemon.Sprites").path( - Game.data, mon.species, "front", { mon = mon }) - end) - local img - if okPath and type(path) == "string" then - local okImg, loaded = pcall(love.graphics.newImage, path) - img = okImg and loaded or nil - end - self.flyMonImg = img or false - end - if not self.flyMonImg then return end - local total = 48 - local t = math.max(0, math.min(total, total - self.flyAnim.frames)) - -- the stoop: in over sixteen frames, away over the rest, and the two - -- meet on the player rather than anywhere else - local reach = 16 - local dx, dy - if t <= reach then - local k = 1 - t / reach - dx, dy = -96 * k, -72 * k - else - local k = (t - reach) / (total - reach) - dx, dy = -120 * k * k, -96 * k * k - end - local iw, ih = self.flyMonImg:getDimensions() - local px = math.floor(self.player.px - cam.x + 8 - iw / 2 + dx) - local py = math.floor(self.player.py - cam.y + 8 - ih / 2 + dy) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.flyMonImg, px, py) - require("src.render.PaletteFX").markTrueColor(px, py, iw, ih) - end - - -- fishing pose: the rod tile over the faced water (gfx/fishing.asm) - local function fxRod() - if not self.fishing then return end - local fx = Game.data.field.overworldFx - local rod = fx and fx.fishingRod - if rod then - if self.rodImg == nil then - local ok, img = pcall(love.graphics.newImage, rod.path) - self.rodImg = ok and img or false - end - if self.rodImg then - local p = self.player - local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down - if not self.rodQuads then - -- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile) - local iw, ih = self.rodImg:getDimensions() - self.rodQuads = {} - for i = 0, math.floor(ih / 8) - 1 do - self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih) - end - end - local quad = self.rodQuads[oam.tile] - -- the sprite's top-left is 4px above its cell (SpriteRenderer:draw) - local rx = p.px - cam.x + oam.dx - local ry = p.py - cam.y - 4 + oam.dy - love.graphics.setColor(1, 1, 1, 1) - if quad and oam.flip then - love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) - elseif quad then - love.graphics.draw(self.rodImg, quad, rx, ry) - end - end - end - end + local sparkleOne, radarOne + local boundFx = not Performance.worldDrawHoist or pipelineId or tilt + local fxHeal = boundFx and bindWorldFx(self._worldFxHeal, self, cam) or self._worldFxHeal + local fxDust = boundFx and bindWorldFx(self._worldFxDust, self, cam) or self._worldFxDust + local fxSSAnneWake = boundFx and bindWorldFx(self._worldFxSSAnneWake, self, cam) or self._worldFxSSAnneWake + local fxSSAnneSmoke = boundFx and bindWorldFx(self._worldFxSSAnneSmoke, self, cam) or self._worldFxSSAnneSmoke + local fxWater = boundFx and bindWorldFx(self._worldFxWater, self, cam) or self._worldFxWater + local fxCutTree = boundFx and bindWorldFx(self._worldFxCutTree, self, cam) or self._worldFxCutTree + local fxSparkle = boundFx and bindWorldFx(self._worldFxSparkle, self, cam) or self._worldFxSparkle + local fxSparkleOne + if boundFx then fxSparkleOne = function() return self:_worldFxSparkleOne(cam, sparkleOne) end end + local fxRadarOne + if boundFx then fxRadarOne = function() return self:_worldFxRadarOne(cam, radarOne) end end + local fxRadar = boundFx and bindWorldFx(self._worldFxRadar, self, cam) or self._worldFxRadar + local fxEmote = bindWorldFx(self._worldFxEmote, self, cam) + local fxBird = boundFx and bindWorldFx(self._worldFxBird, self, cam) or self._worldFxBird + local fxRod = boundFx and bindWorldFx(self._worldFxRod, self, cam) or self._worldFxRod if pipelineId then -- === PIPELINE PATH: a mod owns the world pass. ====================== @@ -18009,6 +18212,7 @@ function OverworldState:drawWorld() -- Deliberately unscaled by depth, like :billboard: an effect keeps its -- crisp authored size and only its anchor moves. ctx.drawFx = function(project, scale) + local closeFx = FrameProfile.section(" world: FX") scale = scale or pscale local colors = ctx.spriteColors() local function at(drawFn, wx, wy) @@ -18068,8 +18272,8 @@ function OverworldState:drawWorld() -- The S.S. Anne effects are authored in screen OAM coordinates for this -- fixed-camera cutscene. Keep them as an upright overlay for a custom -- world pipeline; the flat/tilt paths place the wake behind the ship. - fxSSAnneWake() - fxSSAnneSmoke() + fxSSAnneWake(self, cam) + fxSSAnneSmoke(self, cam) for _, sp in ipairs(self.sparkles or {}) do sparkleOne = sp at(fxSparkleOne, sp.px + 8, sp.py + 16) @@ -18079,6 +18283,7 @@ function OverworldState:drawWorld() if pt.active then radarOne = pt; at(fxRadarOne, pt.x * 16 + 8, pt.y * 16 + 16) end end radarOne = nil + closeFx() end override = Pipelines.drawWorld(pipelineId, ctx) -- world post-processes (a miniature-diorama blur, a colour grade) fold @@ -18092,11 +18297,13 @@ function OverworldState:drawWorld() -- was retired). The ground pass was skipped on its behalf above, so -- draw it now and fall through to the flat path below rather than -- compositing an empty canvas. + local closeTiles = FrameProfile.section(" world: tiles") self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh) self.map.renderer:draw(cam.x, bgY, vw, vh) for _, nb in ipairs(self.neighbors) do nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh) end + closeTiles() end end @@ -18123,19 +18330,9 @@ function OverworldState:drawWorld() and groundForRise:freeMode()) and groundForRise or nil local groundSin = groundForRise and groundForRise.scale and select(1, groundForRise:scale()) or 1 - local function riseOf(e) - if not groundForRise then return 0 end - local rise = groundForRise:rise(e.px + (e.shiftPx or 0) + 8, e.py + 8) - -- A SPRITE OFFSET (MapObject_SetSpritePosOffset: the surf mount's ride, - -- the Distortion World cast's flights) in the field view: up by the - -- camera's height lean, toward it by the ground's - local so = e.spriteOffset - if so and (so.lift or so.oz) then - rise = rise + (so.lift or 0) * (groundForRise.heightScale or 0) - - (so.oz or 0) * (groundForRise.groundScale or 1) - end - if groundSin >= 1 then return rise end - return (e.py - cam.y) * (1 - groundSin) + rise + local riseOf = self._worldRise + if not Performance.worldDrawHoist then + riseOf = function(_self, e) return self:_worldRise(e, groundForRise, groundSin, cam) end end if override then @@ -18189,6 +18386,7 @@ function OverworldState:drawWorld() -- The COLLISION GRID IS UNTOUCHED by any of this. It is in map pixels and -- stays there; only the picture is projected, which is why a compressed -- world still walks the same. + local closeGhosts = FrameProfile.section(" world: entities") for _, g in ipairs(self.ghosts) do -- A GHOST GOES THROUGH THE FREE CAMERA TOO. -- @@ -18214,6 +18412,7 @@ function OverworldState:drawWorld() end if not drawn then g.npc:draw(cam.x - g.ox, cam.y - g.oy) end end + closeGhosts() -- HIDDEN MEANS HIDDEN, for the player as much as for anybody. -- -- Two Gen 3 things write this and neither had a reader: the @@ -18225,7 +18424,6 @@ function OverworldState:drawWorld() -- ...AND A PLOT'S ANSWER CHANGES WHILE YOU ARE STANDING THERE. A tree -- is planted, grows and is picked without the map reloading, so its -- emptiness is asked at draw time rather than remembered at spawn. - local plotEmpty = plotIsEmpty -- the ground whose depth the entity pass is drawing into, while it is local spriteDepthGround = nil -- SINNOH'S CHARACTERS STAND ON SOMETHING. Built once per frame rather @@ -18242,110 +18440,11 @@ function OverworldState:drawWorld() -- (src/world/Gen4GymFx.lua spriteFog) local gymFog = GameVersion.isGen4() and self.map and self.map.def and self.map.def.gen4DepthFog and require("src.world.Gen4GymFx").spriteFog(self) or nil - local function drawEntity(e) - if not (self.flyAnim and self:hasFlyBird() and e == self.player) - and not e.hidden and not e.berryVisualEmpty and not plotEmpty(e) then - -- A FREE CAMERA PLACES ITS OWN CHARACTERS. - -- - -- Reported from play: *"isnt keeping the player the right size"*, with a - -- screenshot of a third-person Twinleaf in correct perspective and two - -- characters standing in it at flat 2D scale. They were drawn by the - -- line below, which places every sprite at `py - camY` -- the field - -- camera's 1:1 mapping, and simply not this camera's. - -- - -- `freeEntity` runs THIS SAME DRAW inside a transform, so nothing here - -- learns about projection and the entity keeps owning its own sprite, - -- frame and palette. - -- - -- In FIRST person the player is the eye, so drawing them puts their own - -- back across the middle of the view. - if freeGround then - if not (freeGround:freeMode() == "first" and e == self.player) then - local rise = riseOf(e) - -- ...AND WHICH WAY THEY FACE ON SCREEN. - -- - -- Reported from play: *"when i orbit the camera 180 degrees if i - -- walk left it looks like hes walking right"*. `screenToWorld` - -- turns the PRESS into a world direction, so the step is right; - -- the sprite then showed that world facing under a camera that is - -- no longer looking north, so the artwork was the one thing still - -- in the old frame. `worldToScreen` is its inverse, and the pair - -- are inverses because the camera sits between them. - -- - -- Set and restored around the draw rather than threaded through, - -- because `facing` is what every pose, animation and follower path - -- in the entity reads, and none of them should learn about cameras. - local view, saved = freeGround.view3d, e.facing - if view and view.worldToScreen then - e.facing = view:worldToScreen(saved) - end - -- ...IN THE FIELD'S FOG, as the live pass tints them (gymFog): - -- the gyms' and the weathers' depth fog at the character's depth - -- on this camera, but not a model the fog leaves out (Hearthome's - -- player, gFogIgnoredModels) - local fogRow = GameVersion.isGen4() and self.map and self.map.def - and self.map.def.gen4DepthFog - local fogged = GameVersion.isGen4() - and not (type(fogRow) == "table" and fogRow.exempt and fogRow.exempt(e, self)) - freeGround:freeEntity((e.px or 0), (e.py or 0), cam.x, cam.y, rise, - function() e:draw(cam.x, cam.y + rise) end, - nil, require("src.world.Gen4SurfMount").poseFor(e, e == self.player and self.dwPose or nil), - fogged) - e.facing = saved - end - -- Deliberately without the flat shadow and the grass overdraw below: - -- both are drawn in screen space against a ground plane this camera - -- does not have, and a shadow in the wrong place is worse than none. - return - end - -- IN THE WORLD'S DEPTH on Sinnoh's cartridge camera - -- (Gen4Ground:beginSprites): the billboard origin's depth, so a prop in - -- front of a character hides them; nil leaves the draw as it was - local depth = spriteDepthGround and spriteDepthGround:billboardDepth( - (e.px or 0) + (e.shiftPx or 0), e.py or 0, - e.spriteOffset and e.spriteOffset.lift, e.spriteOffset and e.spriteOffset.oz) - -- UNDER the sprite, and lifted by the same terrain rise it is, so a - -- character on a slope keeps their feet on it. - if shadows then - if depth then - spriteDepthGround:withSpriteDepth(depth, function() - shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(e)) - end, false) - else - shadows:draw(self.map, e, cam.x, cam.y, e == self.player, riseOf(e)) - end - end - local fogLeft = gymFog and gymFog(e) - if fogLeft then love.graphics.setColor(fogLeft, fogLeft, fogLeft, 1) end - if depth then - spriteDepthGround:withSpriteDepth(depth, function() e:draw(cam.x, cam.y + riseOf(e)) end) - else - e:draw(cam.x, cam.y + riseOf(e)) - end - if fogLeft then love.graphics.setColor(1, 1, 1, 1) end - -- tall grass overdraws the sprite's feet (GB sprite priority); - -- the overdraw is BG tiles, so it rides the shake offset too - love.graphics.setColor(1, 1, 1, 1) - if self.map:isGrassCell(e.cellX, e.cellY) then - if not self:drawGen3Grass(e.cellX, e.cellY, cam.x, bgY, - self:gen3GrassFrame(e, false)) then - self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY) - if grassColors then - self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY, - cam.x, bgY, grassColors) - end - end - end - if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then - if not self:drawGen3Grass(e.targetX, e.targetY, cam.x, bgY, - self:gen3GrassFrame(e, true)) then - self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY) - if grassColors then - self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY, - cam.x, bgY, grassColors) - end - end - end + local drawEntity = self._worldDrawEntity + if not Performance.worldDrawHoist then + drawEntity = function(_self, e) + return self:_worldDrawEntity(e, cam, bgY, freeGround, groundForRise, groundSin, + grassColors, spriteDepthGround, shadows, riseOf, nil, gymFog) end end @@ -18400,16 +18499,25 @@ function OverworldState:drawWorld() -- rectangle each, which is precisely what a scissor is. It is set per -- cell and intersected with whatever clip the caller already had, so a -- reflection is painted once per water cell it falls across. - local reflectors = {} + local reflectors = Performance.worldDrawHoist and self._worldReflectors or nil + if not reflectors or reflectors.busy then + reflectors = {n=0} + if Performance.worldDrawHoist and not self._worldReflectors then self._worldReflectors = reflectors end + end + reflectors.busy = true + local oldReflectors, reflectorCount = reflectors.n, 0 for _, e in ipairs(self.entities) do if not e.hidden and e.drawReflection then e.reflects = self:reflectionSearch(e) if e.reflects then e.reflectStill = self:stillReflection(e.reflects) - reflectors[#reflectors + 1] = e + reflectorCount = reflectorCount + 1 + reflectors[reflectorCount] = e end end end + for i = reflectorCount + 1, oldReflectors do reflectors[i] = nil end + reflectors.n = reflectorCount self.reflectProbe = (self.reflectProbe or 0) + 1 if self.reflectProbe % 90 == 1 then local p, set = self.player, Game and Game.data and Game.data.constants @@ -18499,6 +18607,9 @@ function OverworldState:drawWorld() love.graphics.setColor(1, 1, 1, 1) local renderer = self.map.renderer if renderer.drawReflectionCover then + if Performance.worldDrawHoist and self.rippleCells == nativeRippleCells then + self:_worldReflectionCover(reflectors, reflectorCount, renderer, cam, bgY) + else local seen, cells = {}, {} for _, e in ipairs(reflectors) do local cx0, cy0, cx1, cy1 = self:reflectionRect(e) @@ -18518,15 +18629,17 @@ function OverworldState:drawWorld() for _, cell in ipairs(cells) do renderer:drawReflectionCover(cell[1], cell[2], cam.x, bgY) end + end end end + reflectors.busy = nil -- The rotating gates sit under the entity pass; the S.S. Anne wake has -- priority below the ship and therefore belongs immediately before it. self:drawGen3Gates(cam) -- OBJ priority 2 and a later sprite id put the wake behind the ship at the -- same priority on hardware. Draw it immediately before the entity pass. - fxSSAnneWake() + fxSSAnneWake(self, cam) local onTop = nil -- SINNOH'S CARTRIDGE CAMERA: the characters go into the world's own depth -- buffer (Gen4Ground:beginSprites) so props in front of them hide them @@ -18534,22 +18647,34 @@ function OverworldState:drawWorld() and groundForRise:beginSprites() then spriteDepthGround = groundForRise end + local closeEntities = FrameProfile.section(" world: entities") + if Performance.worldDrawHoist then + local okE, result = pcall(self._worldEntityLoop, self, drawEntity, cam, bgY, freeGround, + groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf, nil, gymFog) + if not okE then + if spriteDepthGround then spriteDepthGround:endSprites(); spriteDepthGround = nil end + error(result, 0) + end + onTop = result + else for _, e in ipairs(self.entities) do if self:gen3AboveTopLayer(e) then onTop = onTop or {} onTop[#onTop + 1] = e else - local okE, errE = pcall(drawEntity, e) + local okE, errE = pcall(drawEntity, self, e, cam, bgY, freeGround, groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf, nil, gymFog) if not okE then if spriteDepthGround then spriteDepthGround:endSprites(); spriteDepthGround = nil end error(errE, 0) end end end + end if spriteDepthGround then spriteDepthGround:endSprites() spriteDepthGround = nil end + closeEntities() -- The Gen 3 top layer goes on AFTER the entity pass and before the -- field effects: it is the half of every metatile the player walks -- behind -- treetops, upper storeys, the far rail of a bridge. On a @@ -18561,11 +18686,10 @@ function OverworldState:drawWorld() -- not the player at every zoom. Cleared after, so a stale focus can never -- outlive the frame it was true for. local feetX, feetZ = (self.player.px or 0) + 8, (self.player.py or 0) + 8 - local function aim(renderer, ox, oy) - local g4 = renderer and renderer.gen4Ground - if g4 then - if ox then g4.focusX, g4.focusZ = feetX - ox, feetZ - oy - else g4.focusX, g4.focusZ = nil, nil end + local aim = self._worldAim + if not Performance.worldDrawHoist then + aim = function(_self, renderer, ox, oy) + return self:_worldAim(renderer, ox, oy, feetX, feetZ) end end -- THE "!" GOES IN WHILE A FREE CAMERA'S 3D PASS IS STILL OPEN. @@ -18581,24 +18705,29 @@ function OverworldState:drawWorld() self:eachEmote(fxEmote) emoteEarly = true end - aim(self.map.renderer, 0, 0) + local closeTiles = FrameProfile.section(" world: tiles") + aim(self, self.map.renderer, 0, 0, feetX, feetZ) self.map.renderer:drawAbove(cam.x, bgY, vw, vh) - aim(self.map.renderer) + aim(self, self.map.renderer, nil, nil, feetX, feetZ) for _, nb in ipairs(self.neighbors) do if nb.map.renderer.drawAbove then - aim(nb.map.renderer, nb.ox, nb.oy) + aim(self, nb.map.renderer, nb.ox, nb.oy, feetX, feetZ) nb.map.renderer:drawAbove(cam.x - nb.ox, bgY - nb.oy, vw, vh) - aim(nb.map.renderer) + aim(self, nb.map.renderer, nil, nil, feetX, feetZ) end end - for _, e in ipairs(onTop or {}) do drawEntity(e) end + closeTiles() + closeEntities = FrameProfile.section(" world: entities") + for _, e in ipairs(onTop or {}) do drawEntity(self, e, cam, bgY, freeGround, groundForRise, groundSin, grassColors, spriteDepthGround, shadows, riseOf, nil, gymFog) end + closeEntities() - fxHeal() - fxDust() - fxCutTree() - fxWater() + local closeFx = FrameProfile.section(" world: FX") + fxHeal(self, cam) + fxDust(self, cam) + fxCutTree(self, cam) + fxWater(self, cam) if not emoteEarly then self:eachEmote(fxEmote) end - fxRadar() + fxRadar(self, cam) -- BLACK / WHITE: the phenomenon spot and the rod's lure (Gen5Phenomena) if GameVersion.isGen5() then local g5ground = self.map and self.map.renderer and self.map.renderer.gen4Ground @@ -18608,11 +18737,12 @@ function OverworldState:drawWorld() end -- HGSS: the Dowsing MCHN's reading (src/world/HgssDowsing.lua) if self.hgssDowsing then require("src.world.HgssDowsing").draw(self, vw, vh) end - fxSparkle() - fxBird() - fxRod() + fxSparkle(self, cam) + fxBird(self, cam) + fxRod(self, cam) -- Smoke keeps the template's priority 0, above the ship and map layers. - fxSSAnneSmoke() + fxSSAnneSmoke(self, cam) + closeFx() else -- === TILT PATH: ground-hugging FX stay on the projected ground, all -- standing things billboard upright over it in a separate pass. ====== @@ -18622,17 +18752,19 @@ function OverworldState:drawWorld() -- draws them last, over the sprites, in the same canvas; here the two -- layers are separate and composited ground-under-upright, so drawing -- them now into the still-active ground canvas is order-equivalent. - fxHeal() - fxDust() - fxCutTree() - fxWater() + local closeFx = FrameProfile.section(" world: FX") + fxHeal(self, cam) + fxDust(self, cam) + fxCutTree(self, cam) + fxWater(self, cam) -- ...and the gates with them: they are behind every upright sprite (see -- drawGen3Gates), so the ground canvas is exactly where they belong self:drawGen3Gates(cam) + closeFx() Game.renderer:beginUprightPass() - fxSSAnneWake() + fxSSAnneWake(self, cam) -- One y-sorted list of ALL upright billboards -- sprites (player, NPCs, -- ghosts) -- keyed on baseline world y (the foot / base row). Farther @@ -18649,6 +18781,7 @@ function OverworldState:drawWorld() end table.sort(items, function(a, b) return a.y < b.y end) + local closeEntities = FrameProfile.section(" world: entities") for _, it in ipairs(items) do if it.kind == "ghost" then -- ghosts billboard just like real entities (foot offset folds in the @@ -18664,7 +18797,7 @@ function OverworldState:drawWorld() local fy = e.py - cam.y + 16 local colors = zoneColorsAt(zones, fx, fy) self:billboard(fx, fy, vw, vh, colors, false, - function() e:draw(cam.x, cam.y + riseOf(e)) end) + function() e:draw(cam.x, cam.y + riseOf(self, e, groundForRise, groundSin, cam)) end) -- tall-grass feet overdraw glued to the sprite: same anchor + depth -- so it keeps hiding the feet, color-0-keyed palette so its white -- gaps still show the sprite through (drawCellBottomRaw lets the @@ -18684,6 +18817,8 @@ function OverworldState:drawWorld() end end + closeEntities() + closeFx = FrameProfile.section(" world: FX") -- Standing world FX: each billboards at the ground foot of the -- character it belongs to, so it stays upright over the tilted ground. -- emote bubble -> the spotting NPC's foot (rides above its head) @@ -18705,7 +18840,7 @@ function OverworldState:drawWorld() local fy = self.player.py - cam.y + 16 self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod) end - fxSSAnneSmoke() + fxSSAnneSmoke(self, cam) for _, sp in ipairs(self.sparkles or {}) do sparkleOne = sp local fx = sp.px - cam.x + 8 @@ -18725,6 +18860,7 @@ function OverworldState:drawWorld() radarOne = nil Game.renderer:endUprightPass() + closeFx() end -- LAST IN THE WORLD PASS, over everything the cave contains: the black @@ -18732,6 +18868,7 @@ function OverworldState:drawWorld() -- drawUI because it is anchored to the PLAYER, not to the screen -- the -- cartridge can put it at a fixed (120,80) because its camera never lets -- the player leave the middle, and this port's does at a map edge. + local closeFx = FrameProfile.section(" world: FX") self:drawGen3Flash(cam, vw, vh) -- the Mirage Tower's falling fossil and ceiling crumbs: OAM priority 0 if self.g3MirageTower or self.g3MirageCrumbles then @@ -18743,6 +18880,97 @@ function OverworldState:drawWorld() -- in the world pass rather than the UI pass for the same reason the flash -- is: it is anchored to a CELL, not to the screen. self:drawGen3Decorate(cam) + closeFx() +end + +-- A requested source field callback owns precisely the world presentation. +-- The ordinary path has no extra protected call or compatibility allocation. +function OverworldState:drawWorld() + local field=package.loaded["src.core.game3.field_view"] + if field and GameVersion.isGen3()then return field._nativeDraw(Game,self,drawWorldBody)end + return drawWorldBody(self) +end + +-- Same native actors and effect bodies as the ordinary world pass, with an +-- owned source atlas and explicit capture viewport. It cannot step the world. +function OverworldState:_drawFieldView(field) + local cam,vw,vh=field.camera,field.width,field.height + local bgY=cam.y+(self.bgShakeY or 0) + self.viewW,self.viewH=vw,vh + PaletteFX.markTrueColor(0,0,vw,vh) + if not field.actorsOnly then + love.graphics.setColor(0,0,0,1);love.graphics.rectangle("fill",0,0,vw,vh) + field.tiles:draw(false,cam,bgY) + if self.g3MirageTower then require("src.world.Gen3MirageTower").drawTower(self,cam)end + end + local function actor(e,ox,oy,ghost) + if not e or e.hidden then return end + ox,oy=ox or 0,oy or 0 + local ac={x=cam.x-ox,y=cam.y-oy} + local billboard=field.billboard + if billboard then + local x,y=(e.px or 0)-ac.x+8,(e.py or 0)-ac.y+16 + local sx,sy=Tilt.groundPoint(x,y,vw,vh) + love.graphics.push();love.graphics.translate(sx-x,sy-y) + end + if ghost then e:draw(ac.x,ac.y) + else self:_worldDrawEntity(e,ac,bgY,nil,nil,1,nil,nil,nil,self._worldRise,field)end + if billboard then love.graphics.pop()end + end + table.sort(self.ghosts,byGhostY);table.sort(self.entities,byEntityY) + if not field.skipActors then + for _,g in ipairs(self.ghosts)do actor(g.npc,g.ox,g.oy,true)end + end + if not field.actorsOnly then + local reflectors={} + if not field.skipActors then + for _,e in ipairs(self.entities)do + if not e.hidden and e.drawReflection then + e.reflects=self:reflectionSearch(e) + if e.reflects then e.reflectStill=self:stillReflection(e.reflects);reflectors[#reflectors+1]=e end + end + end + end + for _,e in ipairs(reflectors)do e:drawReflection(cam.x,cam.y)end + self:drawRipples(cam.x,bgY) + local cover={drawReflectionCover=function(_,cx,cy) + field.tiles:reflectionCover(cx,cy,cam,bgY) + end} + self:_worldReflectionCover(reflectors,#reflectors,cover,cam,bgY) + self:drawGen3Gates(cam);self:_worldFxSSAnneWake(cam) + end + local above={} + if not field.skipActors then + for _,e in ipairs(self.entities)do + if self:gen3AboveTopLayer(e)then above[#above+1]=e else actor(e)end + end + end + -- A retained capture callback cannot draw into an unrelated later frame. + local open=true + local function upper() + if not open or field.actorsOnly then return false end + field.tiles:draw(true,cam,bgY);return true + end + if not field.skipActors and field.endUnderActors then + local ok,why=pcall(field.endUnderActors,upper) + if not ok then open=false;error(why,0)end + end + local okUpper,whyUpper=pcall(upper);open=false + if not okUpper then error(whyUpper,0)end + for _,e in ipairs(above)do actor(e)end + if not field.actorsOnly then + self:_worldFxHeal(cam);self:_worldFxDust(cam);self:_worldFxCutTree(cam);self:_worldFxWater(cam) + self:eachEmote(function()self:_worldFxEmote(cam)end) + self:_worldFxRadar(cam);self:_worldFxSparkle(cam);self:_worldFxBird(cam);self:_worldFxRod(cam) + self:_worldFxSSAnneSmoke(cam) + end + require("src.core.game3.field_view")._drawFlash(self,field) + if not field.actorsOnly then + if self.g3MirageTower or self.g3MirageCrumbles then require("src.world.Gen3MirageTower").drawAbove(self,cam)end + if self.g3Orb then require("src.world.Gen3ScreenFx").draw(self,cam,vw,vh)end + self:drawGen3Decorate(cam) + end + love.graphics.setColor(1,1,1,1) end -- The cursor the DECORATE row hands the player: the decoration's own @@ -18764,51 +18992,15 @@ function OverworldState:drawGen3Decorate(cam) love.graphics.setColor(1, 1, 1, 1) end --- THE HOLE, drawn the way the cartridge cuts it: one span a scanline. --- --- SetFlashScanlineEffectWindowBoundaries fills a per-line table of left/right --- edges and the hardware window does the rest, so a row-at-a-time fill here is --- not an approximation of Emerald's circle, it IS Emerald's circle -- and it --- needs no stencil buffer, which keeps the field draw working on every backend --- the port runs on. Two rectangles a row, plus one above and one below. +-- The player's cell centre remains in world pixels at every viewport size. +-- Integer scanline geometry is shared with mod-owned field presentations. function OverworldState:drawGen3Flash(cam, vw, vh) - if not GameVersion.isGen3() then return end + if not GameVersion.isGen3() or not self.player then return end local Gen3Flash = require("src.world.Gen3Flash") - local circle = Gen3Flash.circle(Game, vw, vh) - if not (circle and self.player) then return end - local radius = tonumber(circle.radius) or 0 - love.graphics.setColor(0, 0, 0, 1) - if radius <= 0 then - -- the table's darkest level closes the window completely; the cartridge - -- never selects it, but a script may - love.graphics.rectangle("fill", 0, 0, vw, vh) - love.graphics.setColor(1, 1, 1, 1) - return - end - -- the middle of the player's own cell, which is (120,80) whenever the view - -- is the GBA's own 240x160 -- the two numbers the cartridge hard-codes - local cx = self.player.px - cam.x + 8 - local cy = self.player.py - cam.y + 8 - local top = math.max(0, math.floor(cy - radius)) - local bottom = math.min(vh, math.ceil(cy + radius)) - if top > 0 then love.graphics.rectangle("fill", 0, 0, vw, top) end - if bottom < vh then - love.graphics.rectangle("fill", 0, bottom, vw, vh - bottom) - end - for y = top, bottom - 1 do - local dy = y + 0.5 - cy - local half = math.sqrt(math.max(0, radius * radius - dy * dy)) - local left = cx - half - local right = cx + half - if left > 0 then - love.graphics.rectangle("fill", 0, y, math.min(left, vw), 1) - end - if right < vw then - love.graphics.rectangle("fill", math.max(right, 0), y, - vw - math.max(right, 0), 1) - end - end - love.graphics.setColor(1, 1, 1, 1) + local cx = math.floor(self.player.px - cam.x + 8) + local cy = math.floor(self.player.py - cam.y + 8) + local circle = Gen3Flash.circle(Game, vw, vh, cx, cy) + if circle then Gen3Flash.drawMask(circle.radius, vw, vh, cx, cy) end end -- Emerald's healing machine: the monitor over the machine's screen and one @@ -18819,48 +19011,14 @@ end -- is the same beat the Game Boy path uses to swap the machine's shades, so the -- two generations blink together off one clock. function OverworldState:drawGen3Heal(ha, cam) - if not GameVersion.isGen3() then return false end - local record = (Game.data.constants or {}).gen3HealMachine - if not (record and record.monitor and record.glow) then return false end - self.gen3HealArt = self.gen3HealArt or {} - local art = self.gen3HealArt - local function picture(path) - if art[path] == nil then - local ok, img = pcall(require("src.render.Assets").image, path) - art[path] = (ok and img) or false - end - return art[path] or nil - end - local ox = math.floor(ha.px - cam.x) - local oy = math.floor(ha.py - cam.y) + local sprites = require("src.world.Gen3HealingPresentation").snapshot(Game, self, ha) + if not sprites then return false end love.graphics.setColor(1, 1, 1, 1) - local frames = record.monitor.images or {} - local frame = frames[(ha.visible == false) and 2 or 1] or frames[1] - local monitor = frame and picture(frame) - if monitor then - love.graphics.draw(monitor, ox + record.monitor.x, oy + record.monitor.y) - end - -- Reported from play: the healing machine's layout looks wrong -- what was - -- actually wrong is that a lit ball was invisible rather than misplaced. - -- The console art the map tileset already draws bakes in its own row of - -- (unlit-looking) LED dots, and this glow sprite is the ROM's own tiny - -- 4x4 dot meant to sit exactly over one -- at native size, one more red - -- pixel-cluster on top of art that is already mostly red LEDs, it could - -- not be told apart from the backdrop it was drawn on. 2x scale and a - -- bright gold tint (a lit Poke Ball, not a red LED) makes each one land. - local glow = record.glow.image and picture(record.glow.image) - if glow then - -- NATIVE SIZE AND COLOURS. An enlarged, gold-tinted ball read better in - -- the voxel view but dwarfed the machine in the plain game; the sprite and - -- sPokeballCoordOffsets are the cartridge's own and are drawn as such. - for i = 1, math.min(ha.lit or 0, #(record.glow.offsets or {})) do - local at = record.glow.offsets[i] - love.graphics.draw(glow, ox + record.glow.x + at[1], - oy + record.glow.y + at[2]) - end - love.graphics.setColor(1, 1, 1, 1) + for _, sprite in ipairs(sprites) do + love.graphics.draw(sprite.image, math.floor(ha.px - cam.x) + sprite.x - ha.px, + math.floor(ha.py - cam.y) + sprite.y - ha.py) end - return (monitor or glow) ~= nil + return #sprites > 0 end -- screen-space overlays: drawn to the UI canvas at normal scale @@ -19099,7 +19257,7 @@ function OverworldState:drawBrailleBox() self.brailleQuads = self.brailleQuads or {} love.graphics.setColor(1, 1, 1, 1) for row, line in ipairs(wall.lines or {}) do - local y = wall.y * 8 + 8 + (row - 1) * lineHeight + local y = (wall.textY or (wall.y * 8 + 8)) + (row - 1) * lineHeight for col, value in ipairs(line) do local quad = self.brailleQuads[value] if not quad then @@ -19109,7 +19267,7 @@ function OverworldState:drawBrailleBox() img:getWidth(), img:getHeight()) self.brailleQuads[value] = quad end - love.graphics.draw(img, quad, wall.x * 8 + (col - 1) * advance, y) + love.graphics.draw(img, quad, (wall.textX or (wall.x * 8)) + (col - 1) * advance, y) end end end @@ -19275,4 +19433,18 @@ function OverworldState:captureSave(save) end end +function OverworldState:drawWorld() + local field=package.loaded["src.core.game3.field_view"] + if not GameVersion.isGen3()then return drawWorldBody(self)end + local effects=package.loaded["src.core.game3.field_effects"] + if effects and effects._hasActorExtensions and effects._hasActorExtensions()then + return require('src.compat.gen1.SourceActors').withActors(Game,self,function() + if field then return field._nativeDraw(Game,self,drawWorldBody)end + return drawWorldBody(self) + end) + end + if field then return field._nativeDraw(Game,self,drawWorldBody)end + return drawWorldBody(self) +end + return OverworldState diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 708f386b6..9bb668233 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -189,6 +189,9 @@ function PikachuFollower.current(ow) end function PikachuFollower.onMapEntered(game, ow, opts) + if require('src.core.GameVersion').isGen3()then + return require('src.world.OptionalFollower').onMapEntered(game,ow,opts,true) + end -- Bill's House owns a short scripted scene that deliberately keeps -- Pikachu off the normal trailing loop. A new map instance ends it. ow.pikachuBillsScene = nil @@ -395,6 +398,11 @@ end -- one follow step per frame: chase the cell the player last vacated -- (pikachu_follow.asm keeps it one walk step behind) function PikachuFollower.update(game, ow) + if require('src.core.GameVersion').isGen3()then + local source=package.loaded['src.compat.game3.Follower']or package.loaded['src.world.game3.Follower'] + if source and source._dispatch then return source._dispatch(game,ow,function()return require('src.world.OptionalFollower').update(game,ow)end)end + return require('src.world.OptionalFollower').update(game,ow) + end if ow.pikaHop then return end -- the counter hop owns the follower (#417) if ow.pikachuBillsScene then return end local npc = findFollower(ow) diff --git a/src/world/Player.lua b/src/world/Player.lua index 8e6b6f0fa..1fff43d2a 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -724,7 +724,18 @@ function Player:freeWalk(vx, vy, map, entities) return self.cellX ~= startX or self.cellY ~= startY end +-- Gen3 render hooks may replace the native body draw. Keep its surf bob on +-- the fixed update clock so repeated observations/draws cannot drive or stop it. +local function advanceSurfBob(self) + if GameVersion.isGen3() and self.surfing + and not (self.hopFrames and self.hopFrames > 0) + and not (self.acroBike and self.acroTrick == "hop") then + self.bobTimer = ((self.bobTimer or 0) + 1) % 32 + end +end + function Player:update() + local pose=package.loaded["src.compat.gen1.PlayerPose"];if pose then pose.tick(self)end -- A PENDING FREE-WALK IS THIS FRAME'S STEP. -- -- Consumed here rather than driven from the input handler so that the @@ -743,6 +754,7 @@ function Player:update() -- a ledge hopped in third or first person froze its arc mid-air and the -- player stood floating until the next grid step. if self.hopFrames and self.hopFrames > 0 then self.hopFrames = self.hopFrames - 1 end + advanceSurfBob(self) return self:freeWalk(free[1], free[2], free[3], free[4]) end -- LEAVING FREE MODE PUTS THE BODY BACK ON THE GRID. @@ -766,6 +778,7 @@ function Player:update() if self.hopFrames and self.hopFrames > 0 then self.hopFrames = self.hopFrames - 1 end + advanceSurfBob(self) if self.turnTimer > 0 then self.turnTimer = self.turnTimer - 1 end @@ -866,7 +879,7 @@ function Player:walkPhase() -- moving, the land-frame after a completed step, or an active wall-bonk -- (issue #230) animate; a standing sprite otherwise if not self.moving and not self.stepLanded and not self.stairExit - and not self.freeWalking + and not self.freeWalking and not self.compatWalkInPlace and not (self.bumpFrames and self.bumpFrames > 0) then return 0 end @@ -885,7 +898,8 @@ local SPIN_ORDER = { "down", "left", "up", "right" } -- The last return says the player is mid-ledge-hop, which is what the 2D -- path draws the ground shadow from and a 3D path turns into vertical lift. -- --- This ADVANCES the surf-bob and spinner timers, so exactly one of pose() +-- This advances legacy GB surf-bob and spinner timers (Gen3 surf bob is +-- update-owned), so exactly one of pose() -- and draw() may run per frame -- and draw() is written in terms of pose() -- to keep that true by construction. (hopFrames counts down in -- Player:update, on the fixed step, so it is safe to read here.) @@ -906,6 +920,7 @@ end function Player:pose() local px, py = self.px, self.py + if GameVersion.isGen2() and self.fishingState then py=py-(self.spriteYOffset or 0) end local hopping = false -- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs if self.hopFrames and self.hopFrames > 0 then @@ -953,8 +968,8 @@ function Player:pose() end py = py - math.floor(4 * math.sin(self.hopClock / 8 * math.pi) + 0.5) elseif self.surfing then - self.bobTimer = ((self.bobTimer or 0) + 1) % 32 - py = py + (self.bobTimer < 16 and 0 or 1) + if not GameVersion.isGen3() then self.bobTimer = ((self.bobTimer or 0) + 1) % 32 end + py = py + ((self.bobTimer or 0) < 16 and 0 or 1) end local facing = self.facing local phase = self:walkPhase() diff --git a/src/world/Warp.lua b/src/world/Warp.lua index 4abeef7d8..ba151acf8 100644 --- a/src/world/Warp.lua +++ b/src/world/Warp.lua @@ -177,7 +177,7 @@ function Warp.extraCheck(map, carpets, cx, cy, dir) -- to ExtraWarpCheck made plain-floor script destinations live whenever they -- happened to sit at a map edge. local GameVersion = require("src.core.GameVersion") - if GameVersion.get() == "firered" then return false end + if GameVersion.isFRLG() then return false end local Collision = require("src.world.Collision") local facingEdge = (dir == "up" and cy == 0) @@ -435,4 +435,8 @@ function Warp.destination(data, warpDef, lastMap, backupWarp, save) { warp = warpDef, lastMap = lastMap, data = data }) end +-- Read-only consumers can prepare the same destination without dispatching +-- the gameplay warp decision hook or taking the transition. +Warp.observeDestination=resolve + return Warp 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..9648fd6a0 --- /dev/null +++ b/src/world/WorldActions.lua @@ -0,0 +1,480 @@ +-- 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 MapIdentity = require("src.core.MapIdentity") +local FieldDefaults = require("src.world.FieldDefaults") +local Party = require("src.pokemon.Party") +local A = {} + +local function activeTop(stack) + return stack.active and stack:active() or stack:top() +end + +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 activeTop(game.stack) == 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 field=package.loaded["src.core.game3.field"] + if GameVersion.isGen3()and field and field._locked(game,ow)then return nil,"world is busy"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 activeTop(game.stack) == 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 MapIdentity.same(data, mapId, id)) + and save.visited + and MapIdentity.savedValue(data, 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 require("src.core.Gen2Environment").id(ow.map.def) ~= 4 + and require("src.core.Gen2Environment").id(ow.map.def) ~= 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/src/world/game3/Follower.lua b/src/world/game3/Follower.lua new file mode 100644 index 000000000..673821efb --- /dev/null +++ b/src/world/game3/Follower.lua @@ -0,0 +1,3 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Both source spellings refer to the same owned native companion service. +return require('src.compat.game3.Follower') diff --git a/tests/api3_contract/mods/portable/main.lua b/tests/api3_contract/mods/portable/main.lua new file mode 100644 index 000000000..a428b942f --- /dev/null +++ b/tests/api3_contract/mods/portable/main.lua @@ -0,0 +1,110 @@ +-- This exact entry and manifest run on both engines. No engine imports, +-- format switches or engine-specific branches belong in this fixture. +local mod = ... +local world, battle = mod.world, mod.battle +assert(type(world) == "table" and type(battle) == "table", "early handles") +local function refused(value, reason) + assert(value == nil and type(reason) == "string" and #reason > 0, tostring(reason)) +end +refused(world:party()) +refused(battle:snapshot()) +local no, why = mod:supports("world", "not_a_method") +assert(no == false and type(why) == "string") +assert(mod:supports("content", "pokemon")) +assert(mod:supports("hooks", "evolution.check")) +assert(mod:supports("events", "pokemon.received")) +assert(mod:supports("world", "party")) +-- Known API-3 names are registrable even when this host cannot execute them. +mod.hooks:wrap("render.output", function(next, ctx) + return next(ctx) +end) +mod.events:on("checkpoint.restored", function() end) +local c = mod.content +assert(c.pokemon:has("MEW")) +assert(c.maps:supports() and c.maps.available) +assert(c.maps:has("ABSENT_MAP") == false) +local map = assert(c.maps:get("PROBE")) +map.width = 99 +assert(c.maps:get("PROBE").width == 2) +for id, row in c.maps:each() do + if id == "api3Probe" then + row.value = 88 + end +end +assert(c.maps:get("PROBE").width == 2) +local before = assert(c.pokemon:get("MEW")) +assert(before.id == "MEW" and before.baseStats.hp == 100) +assert(before.level1Moves[1] == "POUND") +assert(before.baseStats.special == 100) +assert(c.moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +before.baseStats.hp = 222 +assert(c.pokemon:get("MEW").baseStats.hp == 100, "detached read") +assert(c.pokemon:patch("MEW", { baseStats = { hp = 101 } })) +assert(c.moves:patch("POUND", { power = 45 })) +assert(c.items:patch("POTION", { price = 123 })) +local found = false +for id, row in c.pokemon:each() do + if id == "MEW" then + found = true + assert(row.baseStats.hp == 101) + end +end +assert(found) +local report = { species = "MEW", hp = 101, move = "POUND", power = 45, price = 123 } +local function monCheck(mon) + assert(mon.species == "MEW" and mon.nickname == "Scout" and mon.name == "Scout") + assert(mon.status == "PSN", "short status") + assert(type(mon.shiny) == "boolean", "shiny presentation") + assert(mon.stats.special == 24 and mon.stats.hp == 40, "common stats") + assert(mon.moves[1].id == "POUND" and mon.moves[1].pp == 30 and mon.moves[1].maxPp == 35) +end +mod.events:on("pokemon.received", function(payload) + monCheck(payload.mon) + payload.mon.hp = 1 + report.event = true +end) +mod.hooks:wrap("evolution.check", function(next, game, mon, evolution, trigger) + monCheck(mon) + mon.hp, mon.status = 27, "BRN" + mon.stats.special = 31 + report.hook = true + report.special = 31 + return next(game, mon, evolution, trigger) +end) +mod.events:on("mod.portable.probe", function(payload) + report.custom = payload.value +end) +mod.events:emit("mod.portable.probe", { value = 7 }) +mod.exports.exercise = function() + local first = assert(world:party()) + monCheck(first[1]) + first[1].hp = 1 + assert(world:party()[1].hp == 30, "detached party") + report.party = true + report.status, report.shiny = first[1].status, first[1].shiny + assert(world:getFlag("mod:contract.flag") == false) + assert(world:setFlag("mod:contract.flag", true)) + assert(world:getFlag("mod:contract.flag") == true) + assert(world:setFlag("mod:contract.flag", false)) + assert(world:getFlag("mod:contract.flag") == false) + report.flags = true +end +mod.exports.snapshot = function() + local first = assert(battle:snapshot()) + assert(first.kind == "wild" and first.prompt == "menu") + monCheck(first.player) + assert(first.player.hp == 30 and first.player.maxHp == 40) + assert(type(first.revision) == "number" and first.revision > 0) + first.player.hp = 1 + assert(battle:snapshot().player.hp == 30, "detached battle snapshot") + refused(battle:submit({ id = 1, revision = first.revision, kind = "not_an_intent" })) + report.battle = + { kind = first.kind, prompt = first.prompt, species = first.player.species, status = first.player.status } +end +mod.exports.late = function() + local ok, value, reason = pcall(function() + return c.moves:patch("POUND", { power = 99 }) + end) + assert(not ok or (value == nil and type(reason) == "string"), "frozen content") +end +mod.exports.world, mod.exports.battle, mod.exports.report = world, battle, report diff --git a/tests/api3_contract/mods/portable/manifest.json b/tests/api3_contract/mods/portable/manifest.json new file mode 100644 index 000000000..d98d5f821 --- /dev/null +++ b/tests/api3_contract/mods/portable/manifest.json @@ -0,0 +1 @@ +{"id":"portable","name":"Portable API contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/rollback/main.lua b/tests/api3_contract/mods/rollback/main.lua new file mode 100644 index 000000000..daf6abe4a --- /dev/null +++ b/tests/api3_contract/mods/rollback/main.lua @@ -0,0 +1,6 @@ +local mod = ... +assert(mod.content.pokemon:patch("MEW", { baseStats = { hp = 199 } })) +mod.events:on("mod.portable.probe", function() + error("failed subscription leaked") +end) +error("intentional API 3 rollback") diff --git a/tests/api3_contract/mods/rollback/manifest.json b/tests/api3_contract/mods/rollback/manifest.json new file mode 100644 index 000000000..ab532b029 --- /dev/null +++ b/tests/api3_contract/mods/rollback/manifest.json @@ -0,0 +1 @@ +{"id":"rollback","name":"Rollback contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","dependencies":["portable"]} diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua new file mode 100644 index 000000000..0024a01d9 --- /dev/null +++ b/tests/api3_contract/run.lua @@ -0,0 +1,256 @@ +-- 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 +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua new file mode 100644 index 000000000..b7dcb960d --- /dev/null +++ b/tests/api3_contract/worker.lua @@ -0,0 +1,10 @@ +-- 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"), + "invalid worker arguments" +) +local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") +dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") diff --git a/tests/asset_pack_runtime/conf.lua b/tests/asset_pack_runtime/conf.lua new file mode 100644 index 000000000..f656c9241 --- /dev/null +++ b/tests/asset_pack_runtime/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity=os.getenv('PACK_QA_IDENTITY') or 'oasc-asset-pack-runtime-qa' + t.window=false + t.modules.graphics=false + t.modules.audio=false + t.modules.sound=false +end diff --git a/tests/asset_pack_runtime/main.lua b/tests/asset_pack_runtime/main.lua new file mode 100644 index 000000000..cabd3538d --- /dev/null +++ b/tests/asset_pack_runtime/main.lua @@ -0,0 +1,47 @@ +local root=assert(os.getenv('PACK_ENGINE_ROOT'),'PACK_ENGINE_ROOT is required') +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +function love.load() + local ok,err=pcall(function() + local I=require('src.import.Importers') + local P=require('src.mods.AssetPacks') + local W=require('src.import.LuaWriter') + local fs=I.fs() + local source='imports/asset_packs/lttp/sprites' + assert(fs.createDirectory(source)) + local image=love.image.newImageData(1,1) + image:setPixel(0,0,0.25,0.5,0.75,1) + local encoded=image:encode('png'); local bytes=encoded:getString() + image:release(); encoded:release() + assert(fs.write(source..'/pixel.png',bytes)) + local metadata=W.encode({directions={'south','north'},durations={2,3}}) + assert(fs.write(source..'/pixel.lua',metadata)) + assert(fs.write(source..'/pack.lua',W.encode({format=1,importer='lttp',pack='sprites',kind='sprite', + version='1.0.0',source={name='Generated QA pixel; no cartridge content',md5=string.rep('0',32)}, + entries={pixel={file='pixel.png',size=#bytes,width=1,height=1, + metadata={file='pixel.lua',size=#metadata}}}}))) + local co=I.install('lttp','sprites',fs,source,fs) + repeat local resumed,why=coroutine.resume(co); assert(resumed,why) until coroutine.status(co)=='dead' + local manifest={required_assets={{importer='lttp',pack='sprites'}}} + assert(not P.missing(manifest,fs)) + local api=P.new(manifest,fs) + local installed=assert(api:read('lttp','sprites','pixel')) + assert(installed==bytes) + local fileData=love.filesystem.newFileData(installed,'pixel.png') + local decoded=love.image.newImageData(fileData) + assert(decoded:getWidth()==1 and decoded:getHeight()==1) + decoded:release(); fileData:release() + assert(api:metadata('lttp','sprites','pixel').directions[2]=='north') + assert(fs.read(source..'/pixel.png')==bytes) + local path=I.manifestPath('lttp','sprites'); local previous=fs.read(path) + local failing=setmetatable({rename=function()return nil,'injected native publication failure' end},{__index=fs}) + local replacement=I.install('lttp','sprites',fs,source,failing) + local failed=false + repeat local resumed=coroutine.resume(replacement);if not resumed then failed=true;break end + until coroutine.status(replacement)=='dead' + assert(failed and fs.read(path)==previous) + assert(api:read('lttp','sprites','pixel')==bytes) + print('Native pack filesystem, PNG decode, metadata and failed replacement: PASS') + end) + if not ok then io.stderr:write(tostring(err)..'\n'); love.event.quit(1) + else love.event.quit(0) end +end diff --git a/tests/battle_auxiliary/fixture.lua b/tests/battle_auxiliary/fixture.lua new file mode 100644 index 000000000..267ec0a8e --- /dev/null +++ b/tests/battle_auxiliary/fixture.lua @@ -0,0 +1,54 @@ +local M = {} +function M.new(root, version, api, format, noMod) + local T = require("tests.modkit") + require("src.core.GameVersion").set(version) + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_battle_auxiliary/" .. name, "rb")) + files["mods/portable_battle_auxiliary/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_battle_auxiliary/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_battle_auxiliary/manifest.json"] = Json.encode(manifest) + local data = T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_battle_auxiliary" }, { + data = data, + generation = require("src.core.GameVersion").generation(), + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local game = { data = data, save = require("src.core.SaveData").newGame(), sessionStartedAt = 1 } + game.input = setmetatable({}, { __index = require("src.core.Input") }) + game.input:init() + game.stack = setmetatable({}, { __index = require("src.core.StateStack") }) + game.stack:init() + game.save.party = { require("src.pokemon.Pokemon").new(data, "FIXMON_A", 20) } + result.loader.game = game + require("src.render.Font").load(data) + local b = require("src.battle.BattleState").newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.frame, b.queue = "menu", 0, {} + b.gen4CursorActive = true + b.menuIndex, b.moveIndex = 1, 1 + game.stack.states = { b } + local exports = result.loader.exports.portable_battle_auxiliary + return { + T = T, + game = game, + battle = b, + exports = exports, + result = result, + api = api, + press = function(start, right) + local direction = require("src.core.GameVersion").generation() >= 4 and "down" or "right" + game.input.pressed = { start = start, [direction] = right } + b:update(1 / 60) + end, + close = function() + result.release() + end, + } +end +return M diff --git a/tests/battle_auxiliary/shared.lua b/tests/battle_auxiliary/shared.lua new file mode 100644 index 000000000..675edc2df --- /dev/null +++ b/tests/battle_auxiliary/shared.lua @@ -0,0 +1,97 @@ +-- Reuse native backend construction from trainer admission; use a different +-- unchanged consumer and no trainer hook, then settle the actual native menu. +local M = {} +function M.new(root, backend, version, api, format, consumer) + local f = dofile(root .. "/tests/trainer_preparation/fixture.lua").new( + root, + backend, + version, + api, + format, + false, + false, + consumer or "portable_battle_auxiliary" + ) + f.start() + local battle, game = assert(f.battle()), f.game + local update, index, ready + if backend == "gen1" and f.generation == 3 then + local B = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local Anim = require("src.core.game3.battle.anim") + local State = require("src.core.game3.battle.state") + B.reset() + B._active, B._st = true, battle + B._adapter = require("src.core.game3.battle.adapter").new(battle) + B._phase, B._auto, B._headless, B._fade = "command", false, false, false + Ui.reset({ headless = false }) + Ui.bindState(battle, game.session) + Ui._headless = false + Ui.openMenu() + Anim.reset({ headless = true }) + for id = 0, 1 do + local b, p = State.battler(battle, id), Anim.present(id) + Anim.setShown(id, b) + p.displayHp, p.displayMaxHp = b.mon.hp, b.mon.maxHp + p.displayStatus = b.status or b.mon.status or b.mon.status1 + end + update = function() + B.update(0, game) + end + index = function(value) + if value then + Ui._menuIndex = value + end + return Ui._menuIndex + end + ready = function() + return B._phase == "command" + end + elseif backend == "gen1" and f.generation == 2 then + local screen = game.stack:top() + assert(screen and screen.battle == battle, "actual Gen1 Gold battle screen missing") + screen.phase, screen.slideFrame, screen.queue = "menu", 1000, {} + screen.showPlayerHud, screen.showEnemyHud = true, true + screen.ballRows = { player = false, enemy = false } + screen.shownMon = { player = battle.player, enemy = battle.enemy } + screen.shownHp = { player = battle.player.hp, enemy = battle.enemy.hp } + screen.shownStatus = { player = false, enemy = false } + screen.shownExp = 0 + update = function() + screen:update(0) + end + index = function(value) + if value then + screen.menuIndex = value + end + return screen.menuIndex + end + ready = function() + return screen:menuAuxiliarySafe() + end + else + battle:syncSides() + battle.phase, battle.frame, battle.queue = "menu", 0, {} + battle.gen4CursorActive = true + battle.checkpointOrigin = { kind = "trainer_encounter" } + game.stack.states = { battle } + update = function() + battle:update(0) + end + index = function(value) + if value then + battle.menuIndex = value + end + return battle.menuIndex + end + ready = function() + if backend == "gen2" then + return require("src.battle.MenuAuxiliary").ready(battle) + end + return require("src.battle.BattleSafety").inspect(game, battle) == true + end + end + f.update, f.index, f.ready = update, index, ready + return f +end +return M diff --git a/tests/battle_auxiliary/worker.lua b/tests/battle_auxiliary/worker.lua new file mode 100644 index 000000000..2f5ef22f3 --- /dev/null +++ b/tests/battle_auxiliary/worker.lua @@ -0,0 +1,27 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/battle_auxiliary/shared.lua").new(root, backend, version, api, format) +local e, T = f.exports, f.T +T.eq(f.ready(), true, "actual native menu settled") +local rows = {} +for _, claim in ipairs({ false, true, false }) do + e.claim(claim) + f.index(1) + f.game.input.pressed = { start = true, right = true } + local calls = e.seen.calls + f.update() + T.eq(e.seen.calls, calls + 1, "one native dispatch") + T.eq(f.index(), claim and 1 or 2, "native claim/fallthrough") + T.eq(e.seen.context.kind, "trainer", "shared trainer context") + rows[#rows + 1] = { claim = claim, index = f.index(), kind = e.seen.context.kind } +end +e.stop() +f.index(1) +f.game.input.pressed = { start = true, right = true } +local calls = e.seen.calls +f.update() +T.eq(e.seen.calls, calls, "unsubscribe stops native hook") +T.eq(f.index(), 2, "unsubscribe preserves native navigation") +f.close() +print("BATTLE_AUXILIARY_CONTRACT " .. require("src.link.Json").encode(rows)) +T.finish("portable battle.menu_auxiliary shared native contract") diff --git a/tests/caught_marker/cases.lua b/tests/caught_marker/cases.lua new file mode 100644 index 000000000..76590df2e --- /dev/null +++ b/tests/caught_marker/cases.lua @@ -0,0 +1,73 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + local function observe(label, want, calls) + local before = e.state.calls + local got = f.visible() + T.eq(got, want, label .. " verdict") + T.eq(e.state.calls - before, calls, label .. " dispatch count") + if calls > 0 then + T.eq(e.state.battle.publicTag, "caught_context", "callback carries native context fields") + if f.api >= 3 or (f.backend == "gen2" and f.api == 2 and f.format == "unified") then + T.check(e.state.battle ~= f.battle, "unified callback has a boundary view") + else + T.eq(e.state.battle, f.battle, "legacy native callback retains identity") + end + end + rows[#rows + 1] = { label = label, visible = got, calls = e.state.calls - before } + end + for _, spec in ipairs({ + { "native", f.gen > 1 }, + { "show", true }, + { "hide", false }, + { "nil", false }, + { "zero", false }, + { "native", f.gen > 1 }, + }) do + f.reset() + e.state.mode = spec[1] + observe(spec[1], spec[2], 1) + end + e.state.mode = "show" + for _, opts in ipairs({ { caught = false }, { trainer = true }, { noDex = true } }) do + f.reset(opts) + observe(opts.caught == false and "not caught" or opts.trainer and "trainer" or "no dex", false, 0) + end + if f.gen == 2 then + f.reset({ latched = false }) + observe("entry latch", false, 0) + elseif f.gen == 3 then + for _, opts in ipairs({ { tutorial = true }, { ghost = true }, { player = true } }) do + f.reset(opts) + observe(opts.tutorial and "tutorial" or opts.ghost and "ghost" or "player", false, 0) + end + end + e.state.mode = "native" + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.caught_marker_visible", function(next, battle) + if after then + next(battle) + end + error("intentional marker wrapper failure") + end, 100) + f.reset() + observe("error:" .. tostring(after), f.gen > 1, 1) + off() + end + if f.api >= 3 then + local off = e.api.hooks:wrap("battle.caught_marker_visible", function(next, battle) + local a, b = next(battle), next(battle) + T.eq(a, b, "API-3 repeated next retains verdict") + return a + end, 100) + f.reset() + observe("replay", f.gen > 1, 1) + off() + end + e.stop() + e.stop() + f.reset() + observe("unsubscribed", f.gen > 1, 0) + return rows +end +return M diff --git a/tests/caught_marker/fixture.lua b/tests/caught_marker/fixture.lua new file mode 100644 index 000000000..3bf677834 --- /dev/null +++ b/tests/caught_marker/fixture.lua @@ -0,0 +1,127 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local T = require("tests.modkit") + local V, Json = require("src.core.GameVersion"), require("src.link.Json") + assert(V.VERSIONS[version], "unregistered version") + V.set(version) + local gen = V.generation() + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local files = {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + local f = assert(io.open(root .. "/examples/portable_caught_marker/" .. name, "rb")) + files["mods/portable_caught_marker/" .. name] = f:read("*a") + f:close() + end + local mf = Json.decode(files["mods/portable_caught_marker/manifest.json"]) + mf.api, mf.callback_format, mf.gen2compat = api, format, api < 3 and true or nil + -- Negative capability cases explicitly target the edition under test. + mf.games = { version } + files["mods/portable_caught_marker/manifest.json"] = Json.encode(mf) + if failing then + files["mods/portable_caught_marker/main.lua"] = files["mods/portable_caught_marker/main.lua"] + .. '\nerror("intentional marker entry rollback")' + end + if noMod then + files = {} + end + local result = T.sdk.loadMods( + noMod and {} or { "mods/portable_caught_marker" }, + { data = data, fs = T.sdk.memfs(files), generation = gen, dev = false } + ) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local game = { data = data, save = { options = {}, pokedex = { owned = {}, caught = {} }, party = {} } } + result.loader.game = game + local e, b, battler = result.loader.exports.portable_caught_marker + local f = { + T = T, + game = game, + data = data, + gen = gen, + backend = backend, + api = api, + format = format, + e = e, + result = result, + } + function f.reset(opts) + opts = opts or {} + local id = backend == "gen1" and gen == 3 and 16 or "FIXMON_A" + local mon = { + species = id, + nickname = "FOE", + name = "FOE", + level = 20, + hp = 60, + maxHp = 60, + stats = { hp = 60 }, + status = opts.status, + } + local dex = { owned = {}, caught = {} } + if opts.caught ~= false then + dex.owned[id], dex.caught[id] = true, true + end + if opts.noDex then + dex = nil + end + game.save.pokedex = dex + if backend == "gen1" and gen == 3 then + require("src.import.gba.versions").select(version) + b = { + kind = opts.trainer and "trainer" or "wild", + wild = not opts.trainer, + trainer = opts.trainer, + dex = dex, + session = { game = game, dex = dex }, + firstBattle = opts.tutorial, + ghostBattle = opts.ghost, + } + battler = { + species = id, + mon = mon, + isPlayer = opts.player or false, + side = opts.player and "player" or "enemy", + } + elseif backend == "gen1" and gen == 2 then + b = setmetatable({ + game = game, + data = data, + save = game.save, + caughtMark = opts.latched ~= false, + battle = { wild = not opts.trainer, enemy = mon }, + activeMon = function() + return mon + end, + }, { __index = require("src.ui.gen2.BattleState") }) + battler = mon + else + battler = { mon = mon, name = "FOE", isPlayer = opts.player or false, shownStatus = opts.status } + b = setmetatable({ + game = game, + data = data, + kind = opts.trainer and "trainer" or "wild", + enemy = battler, + caughtMark = opts.latched ~= false, + demo = opts.tutorial, + firstBattle = opts.tutorial, + ghost = opts.ghost, + }, { __index = require("src.battle.BattleState") }) + end + b.publicTag = "caught_context" + f.battle, f.battler, f.mon = b, battler, mon + return b + end + function f.visible() + if backend == "gen1" and gen == 3 then + return require("src.core.game3.battle.healthbox").shouldShowCaughtMarker(b, battler) + end + return b:caughtMarkerVisible() + end + f.reset() + f.close = function() + result.release() + end + return f +end +return M diff --git a/tests/caught_marker/worker.lua b/tests/caught_marker/worker.lua new file mode 100644 index 000000000..c0ffe9763 --- /dev/null +++ b/tests/caught_marker/worker.lua @@ -0,0 +1,22 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local Fixture = dofile(root .. "/tests/caught_marker/fixture.lua") +local f = Fixture.new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/caught_marker/cases.lua").run(f) +f.close() +for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(root, backend, version, tonumber(api), format, not failing, failing) + if failing then + bare.T.check(#bare.result.errors > 0, "intentional failed entry recorded") + else + bare.T.eq(bare.result.loader.exports.portable_caught_marker, nil, "genuine no-mod filesystem") + end + for _, link in ipairs(require("src.mods.Runtime").hooks.chains["battle.caught_marker_visible"] or {}) do + bare.T.check(link.owner ~= "portable_caught_marker", "no rollback owner survives") + end + bare.T.eq(bare.visible(), bare.gen > 1, "no-mod/rollback restores native default") + rows[#rows + 1] = { label = failing and "rollback" or "no mod", visible = bare.visible() } + bare.close() +end +print("CAUGHT_MARKER_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable native caught marker") diff --git a/tests/core_update/cases.lua b/tests/core_update/cases.lua new file mode 100644 index 000000000..2630ed60e --- /dev/null +++ b/tests/core_update/cases.lua @@ -0,0 +1,180 @@ +local M = {} +local function pack(...) + return { n = select("#", ...), ... } +end +function M.run(f) + local T, g, e, api = f.T, f.game, f.exports, f.public + local rows = {} + local function state(label) + rows[#rows + 1] = { label = label, updates = g.calls, polls = e.polls(), hostPolls = f.hostPolls() } + end + for _, dt in ipairs({ 0, 1 / 60, 0.25, 2.5 }) do + local n, polls = g.calls, e.polls() + f.update(dt) + T.eq(g.calls, n + 1, "interactive frame forwards exactly one native update") + T.eq(e.polls(), polls + 1, "interactive frame invokes wrapper once") + T.eq(e.seen.game, g, "original Game identity") + T.eq(e.seen.dt, dt, "unmodified frame delta") + T.eq(g.dts[#g.dts], dt, "native update receives frame delta") + end + state("forwarded") + e.pause(true) + local n, polls, host = g.calls, e.polls(), f.hostPolls() + f.update(1 / 60) + f.update(1 / 30) + T.eq(g.calls, n, "pause suppresses Game:update") + T.eq(e.polls(), polls + 2, "mod continues polling while simulation paused") + T.eq(f.hostPolls(), host + 2, "host housekeeping continues while paused") + state("paused") + e.pause(false) + f.update(1 / 60) + T.eq(g.calls, n + 1, "resume forwards exactly one update") + state("resumed") + if api.supports then + T.eq(api:supports("hooks", "core.update"), true, "native capability advertised") + end + -- next() forwards current args; explicit args route to another Game/delta. + local alternate = { calls = 0 } + function alternate:update(dt) + self.calls = self.calls + 1 + self.dt = dt + end + local order = {} + local outer = api.hooks:wrap("core.update", function(next, game, dt) + T.eq(game, g, "outer sees original Game") + order[#order + 1] = "outer" + next(alternate, dt / 2) + order[#order + 1] = "after" + return nil, "wrapped", nil + end, 20) + local inner = api.hooks:wrap("core.update", function(next, game, dt) + order[#order + 1] = "inner" + T.eq(game, alternate, "rewritten Game reaches downstream wrapper") + T.eq(dt, 0.125, "rewritten delta reaches downstream wrapper") + return next() + end, 10) + n = g.calls + T.same(pack(f.bridge.update(g, 0.25)), { n = 3, [2] = "wrapped" }, "wrapper nil-hole results preserved") + T.same(order, { "outer", "inner", "after" }, "descending priority and postprocessing") + T.eq(alternate.calls, 1, "native fallback uses replacement Game") + T.eq(alternate.dt, 0.125, "native fallback uses replacement delta") + T.eq(g.calls, n, "original Game not updated after explicit replacement") + outer() + inner() + T.same(pack(f.bridge.update(g, 1 / 60)), { n = 0 }, "native Game:update results deliberately discarded") + for _, after in ipairs({ false, true }) do + local off = api.hooks:wrap("core.update", function(next) + if after then + next() + end + error("intentional core.update wrapper failure") + end, 100) + n = g.calls + f.update(1 / 60) + T.eq(g.calls, n + 1, "wrapper error preserves one native update") + off() + end + local original = g.update + local attempts = 0 + g.update = function() + attempts = attempts + 1 + error("native update failure") + end + T.raises(function() + f.update(1 / 60) + end, "native update failure", "native failure escapes") + T.eq(attempts, 1, "native failure is not retried") + g.update = original + if f.api >= 3 then + local off = api.hooks:wrap("core.update", function(next) + next() + return next() + end, 100) + n = g.calls + f.update(1 / 60) + T.eq(g.calls, n + 1, "unified API-3 continuation caches downstream result") + off() + end + -- Real love.update routing: non-game owners bypass the seam. + for _, mode in ipairs({ "Importer", "TouchEditor", "editorMode", "noGame" }) do + local owner = { calls = 0 } + if mode == "Importer" then + function owner:update() + self.calls = self.calls + 1 + end + f.set("Importer", owner) + elseif mode == "TouchEditor" then + owner.update = function() + owner.calls = owner.calls + 1 + end + f.set("TouchEditor", owner) + elseif mode == "editorMode" then + owner.update = function() + owner.calls = owner.calls + 1 + end + f.set("EditorApp", owner) + f.set("editorMode", true) + else + f.set("Game", nil) + end + polls, n = e.polls(), g.calls + f.update(1 / 60) + T.eq(e.polls(), polls, mode .. " does not fire mod frame hook") + T.eq(g.calls, n, mode .. " does not update game") + if mode ~= "noGame" then + T.eq(owner.calls, 1, mode .. " native owner still updates") + end + if mode == "editorMode" then + f.set("editorMode", false) + f.set("EditorApp", nil) + elseif mode == "noGame" then + f.set("Game", g) + else + f.set(mode, nil) + end + end + state("non-game-routing") + -- Scripted act+step loops remain deterministic and bypass core.update. + f.set("speedOverride", 2) + local acts = 0 + f.set("autopilot", { + update = function() + acts = acts + 1 + end, + }) + polls, n = e.polls(), g.calls + f.update(0.9) + T.eq(acts, 2, "autopilot observes each deterministic step") + T.eq(g.calls, n + 2, "autopilot runs both deterministic updates") + T.eq(g.dts[#g.dts], 1 / 60, "autopilot retains fixed delta") + T.eq(e.polls(), polls, "autopilot bypasses frame hook") + f.set("autopilot", nil) + acts = 0 + f.set( + "driverCo", + coroutine.create(function() + while true do + acts = acts + 1 + coroutine.yield() + end + end) + ) + polls, n = e.polls(), g.calls + f.update(0.9) + T.eq(acts, 2, "driver observes each deterministic step") + T.eq(g.calls, n + 2, "driver runs both deterministic updates") + T.eq(g.dts[#g.dts], 1 / 60, "driver retains fixed delta") + T.eq(e.polls(), polls, "driver bypasses frame hook") + f.set("driverCo", nil) + f.set("speedOverride", nil) + state("scripted-routing") + e.stop() + e.stop() + polls, n = e.polls(), g.calls + f.update(1 / 60) + T.eq(e.polls(), polls, "unsubscribe is idempotent") + T.eq(g.calls, n + 1, "empty chain still forwards native update") + state("unsubscribed") + return rows +end +return M diff --git a/tests/core_update/fixture.lua b/tests/core_update/fixture.lua new file mode 100644 index 000000000..1a0d63c9f --- /dev/null +++ b/tests/core_update/fixture.lua @@ -0,0 +1,104 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + love.event = love.event or {} + local Version = require("src.core.GameVersion") + Version.set(version) + -- Load the real host callbacks without booting/importing a cartridge. + dofile("main.lua") + local entryUpdate = love.update + local function upvalue(fn, name) + for i = 1, 100 do + local n, value = debug.getupvalue(fn, i) + if not n then + break + end + if n == name then + return i, value + end + end + end + -- Gen1 decorates love.update with its native frame profiler. + local _, original = upvalue(entryUpdate, "gameUpdate") + local update = original or entryUpdate + local function set(name, value) + local owner, index = update, upvalue(update, name) + if not index then + owner, index = entryUpdate, upvalue(entryUpdate, name) + end + if not index then + local _, iterations = upvalue(update, "scriptedIterations") + if iterations then + owner, index = iterations, upvalue(iterations, name) + end + end + assert(index, "missing native update upvalue: " .. name) + debug.setupvalue(owner, index, value) + end + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/portable_core_update/" .. path, "rb")) + files["mods/portable_core_update/" .. path] = f:read("*a") + f:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_core_update/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_core_update/manifest.json"] = Json.encode(manifest) + local generation = Version.generation() + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_core_update" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local game = { calls = 0, dts = {}, mods = result.loader, data = data } + function game:update(dt) + self.calls = self.calls + 1 + self.dts[#self.dts + 1] = dt + return "ignored native result", nil, "tail" + end + set("Game", game) + set("speedOverride", nil) + local hostPolls = 0 + if backend == "gen1" then + require("src.core.HostDisplay").setBackend({ + update = function() + hostPolls = hostPolls + 1 + end, + }) + else + -- Isolate the unrelated watts service; retain love.update's real routing. + set("walkerTick", function() + hostPolls = hostPolls + 1 + end) + end + local exports = result.loader.exports.portable_core_update + return { + T = T, + result = result, + game = game, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + set = set, + update = entryUpdate, + bridge = require("src.core.PlatformHooks"), + hostPolls = function() + return hostPolls + end, + close = function() + if backend == "gen1" then + require("src.core.HostDisplay").setBackend(nil) + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/core_update/worker.lua b/tests/core_update/worker.lua new file mode 100644 index 000000000..eb7714361 --- /dev/null +++ b/tests/core_update/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/core_update/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/core_update/cases.lua").run(f) +f.close() +print("CORE_UPDATE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable core.update contract") diff --git a/tests/engine/api3_compat.lua b/tests/engine/api3_compat.lua new file mode 100644 index 000000000..a34fe4ae9 --- /dev/null +++ b/tests/engine/api3_compat.lua @@ -0,0 +1,226 @@ +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({ + "party", + "current", + "activeBlockAt", + "liveMaps", + "queueScript", + "getFlag", + "setFlag", + }) do + T.eq(m:supports("world", name), true, "implemented world method " .. name) + end + for _, name in ipairs({ + "warpTo", + "toggleObject", + "replaceBlock", + "spawnNpc", + "removeNpc", + "npc", + "startWildBattle", + "invalidateMap", + }) do + local available, why = m:supports("world", name) + T.eq(available, false, "no unverified native promise for " .. name) + T.check(type(why) == "string", "unavailable reason for " .. name) + local ok, reason = world[name](world, "bad") + T.eq(ok, nil, "unsupported method does not dispatch " .. name) + T.check(reason:find("unsupported:", 1, true), "explicit refusal for " .. name) + end + T.eq(probes(), 0, "capability and unsupported calls do not resolve Game") + T.eq(world:party(), nil, "no-game party refuses safely") + T.eq(battle:snapshot(), nil, "no-game battle refuses safely") + T.eq(m.datasets:open("gold"), nil, "unavailable dataset API callable") + T.eq(m:supports("hooks", "render.output"), false, "recognized output hook is not falsely available") + T.check( + pcall(function() + m.hooks:wrap("render.output", function(next, ctx) + return next(ctx) + end) + end), + "known absent hook can be registered" + ) + T.check( + pcall(function() + m.events:on("checkpoint.restored", function() end) + end), + "known absent event can be registered" + ) + T.raises(function() + m.events:on("not.catalogued", function() end) + end, "unknown unified callback", "unknown API-3 name rejected") + local save = { party = {}, flags = {}, modData = {} } + attach({ save = save, data = {}, stack = { states = {} } }) + local flags = {} + m.events:on("flag.changed", function(p) + flags[#flags + 1] = { p.name, p.value } + end) + T.eq(world:getFlag("mod:quest.finished"), false, "flag read defaults false") + T.eq(next(save.modData), nil, "flag read allocates no bucket") + T.check(world:setFlag("mod:quest.finished", true), "private flag set") + T.check(world:setFlag("mod:quest.finished", true), "setting same flag accepted") + T.eq(#flags, 1, "unchanged flag emits no duplicate event") + T.eq(loader.apis.other.world:getFlag("mod:quest.finished"), false, "other mod cannot see flag") + T.check(world:setFlag("mod:quest.finished", false), "private flag cleared") + T.eq(save.modData.probe.worldFlags["quest.finished"], nil, "clear removes saved boolean") + T.same( + flags, + { { "mod.probe.quest.finished", true }, { "mod.probe.quest.finished", false } }, + "portable flag notifications" + ) + T.eq(next(save.flags), nil, "private flags never touch native flags") + T.eq(world:setFlag("mod:bad", "yes"), nil, "non-boolean flag rejected") + T.eq(world:setFlag("NATIVE_FLAG", true), nil, "unverified native flag mapping 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) + 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/asset_pack_contracts.lua b/tests/engine/asset_pack_contracts.lua new file mode 100644 index 000000000..972cf5925 --- /dev/null +++ b/tests/engine/asset_pack_contracts.lua @@ -0,0 +1,251 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Data=require('src.import.PackData') +local I=require('src.import.Importers') +local Packs=require('src.mods.AssetPacks') +local Manifest=require('src.mods.Manifest') +local Writer=require('src.import.LuaWriter') +local Json=require('src.link.Json') +local Versions=require('src.core.GameVersion') +local Manager=require('src.mods.ManagerState') +local CacheFs=require('src.import.CacheFs') +local Launcher=require('src.mods.LauncherMods') +local Inbox=require('src.import.PackInbox') +local savedVersion=Versions.get(); Versions.set('red') + +local function memory(initial) + local files,times={},{}; local clock=0 + local fs={reads=0,maxRead=0} + for k,v in pairs(initial or {}) do files[k]=v; times[k]=0 end + function fs.getInfo(path,kind) + if files[path] then return {type='file',size=#files[path],modtime=times[path]} end + if kind=='file' then return nil end + for key in pairs(files) do if key:sub(1,#path+1)==path..'/' then return {type='directory'} end end + end + function fs.read(path,n) + fs.reads=fs.reads+1 + return files[path] and files[path]:sub(1,n or -1) + end + function fs.write(path,body) + if fs.failWrite then return nil,'write failed' end + clock=clock+1; files[path]=fs.shortWrite and body:sub(1,1) or body; times[path]=clock; return true + end + function fs.remove(path) files[path]=nil; times[path]=nil; return true end + function fs.rename(from,to) + if fs.throwRename then error('rename exception') end + if fs.failRename then return nil,'rename failed' end + if fs.failManifestRename and to:match('/pack%.lua$') then return nil,'manifest replacement failed' end + if not files[from] then return nil,'no staging file' end + local ok=fs.write(to,files[from]); if ok then fs.remove(from) end; return ok + end + function fs.createDirectory() return not fs.failDirectory end + function fs.load(path) return (loadstring or load)(files[path] or 'error("missing source")',path) end + function fs.getDirectoryItems(path) + local out,seen={},{}; for key in pairs(files) do + if key:sub(1,#path+1)==path..'/' then local part=key:sub(#path+2):match('^[^/]+') + if part and not seen[part] then out[#out+1]=part; seen[part]=true end end + end; table.sort(out); return out + end + function fs.newFile(path) + return {open=function()return files[path]~=nil end,close=function()return true end, + read=function(_,n) fs.maxRead=math.max(fs.maxRead,n); return fs.read(path,n) end} + end + return fs,files +end +local function raw(extra,id) + local m={id=id or 'consumer',name='Pack consumer',version='1.0.0',api=2,entry='main.lua',games={'all'}} + for k,v in pairs(extra or {}) do m[k]=v end; return m +end +local requirement={importer='lttp',pack='sprites',version='>=1.0.0'} +local function pack(version,bytes) + return {format=1,importer='lttp',pack='sprites',kind='sprite',version=version or '1.2.0', + source={name='Synthetic cartridge',md5=string.rep('a',32),size=1048576}, + entries={hero={file='hero.png',size=#(bytes or 'PNG!'),width=16,height=24, + sprite={anchorX=8,anchorY=24,durations={2,3}},metadata={file='hero.lua',size=0}}, + alias={file='hero.png',size=#(bytes or 'PNG!')}}} +end +local meta=Writer.encode({animations={{name='walk',frames={1,2}}},directions={'south','north'}}) +local function input(version,bytes) + local m=pack(version,bytes); m.entries.hero.metadata.size=#meta + return memory({['inbox/pack.lua']=Writer.encode(m),['inbox/hero.png']=bytes or 'PNG!', ['inbox/hero.lua']=meta}) +end +local function complete(co) + local value + repeat local ok,result=coroutine.resume(co); if not ok then return nil,result end; value=result + until coroutine.status(co)=='dead' + return value +end + +T.same(Data.decode(Writer.encode({array={1,'a\0b',true,false},number=-1.5e4})), + {array={1,'a\0b',true,false},number=-1.5e4},'portable literals round trip without executing a chunk') +for _,bad in ipairs({'return os.execute("x")','return (function() while true do end end)()', + 'return {x=1,x=2}','return {a=1}; error("executed")','return {a=1/0}', + 'return {a=1e999}','return {[{}]=true}','return {a=function() end}', + 'return '..string.rep('{',66)..string.rep('}',66)}) do + T.eq(Data.decode(bad),nil,'executable or unbounded pack data is refused') +end +local oldLimit=Data.MAX_NODES; Data.MAX_NODES=3 +T.eq(Data.decode('return {1,2,3,4}'),nil,'literal node budget is enforced'); Data.MAX_NODES=oldLimit +for _,desc in ipairs(I.all()) do T.check(I.validateDescriptor(desc),'known portable descriptor validates') end +local desc=I.get('lttp'); desc.packs[1].kind='bad' +T.eq(I.pack('lttp','sprites').kind,'sprite','catalogue callers cannot mutate pack identity') +for _,bad in ipairs({'../escape','/absolute','C:drive','nul\0path','back\\slash'}) do + T.eq(I.assetPath('lttp','sprites',bad),nil,'asset paths remain within their pack') + T.eq(I.packRoot(bad,'sprites'),nil,'importer identities cannot escape storage') +end +for _,bad in ipairs({{required_assets={[2]=requirement}},{required_assets={stray=requirement}}, + {required_assets={{importer='LTTP',pack='sprites'}}}, + {required_assets={{importer='lttp',pack='sprites',version='broken'}}}, + {required_assets={requirement},optional_assets={requirement}}}) do + T.raises(function()Manifest.validate(raw(bad))end,nil,'invalid or ambiguous requirements are rejected') +end +local m=Manifest.validate(raw({required_assets={requirement},optional_assets={{importer='lttp',pack='music'}}})) +T.check(m.required_assets[1].required,'required declarations survive normalization') +T.eq(m.optional_assets[1].required,false,'optional declarations stay optional') + +local source,original=input(); local fs,files=memory() +local co=I.install('lttp','sprites',source,'inbox',fs) +T.eq(I.state('lttp',fs),'missing','unpublished extraction is unavailable') +local ok=coroutine.resume(co); T.check(ok,'producer starts and yields progress') +T.eq(I.resolve(requirement,fs),nil,'partly copied entries do not publish a manifest') +local result,err=complete(co); T.check(result~=nil,'complete producer publishes: '..tostring(err)) +T.eq(result.entries,2,'aliases retain their entry identities') +T.eq(I.state('lttp',fs),'partial','one installed graphics pack does not claim all packs ready') +T.eq(#original['inbox/hero.png'],4,'installation preserves original source') +local api=Packs.new(m,fs) +T.eq(api:read('lttp','sprites','hero'),'PNG!','native consumer reads installed data') +T.eq(api:info('lttp','sprites').version,'1.2.0','consumer sees actual pack version') +T.eq(#api:entries('lttp','sprites'),2,'consumer can enumerate aliases') +T.same(api:metadata('lttp','sprites','hero').directions,{'south','north'},'animation metadata supports literal arrays') +local exposed=api:entry('lttp','sprites','hero'); exposed.sprite.durations[1]=999 +T.eq(api:entry('lttp','sprites','hero').sprite.durations[1],2,'consumer receives detached nested metadata') +T.eq(api:read('lttp','tiles','hero'),nil,'undeclared pack is inaccessible') +T.eq(api:read('lttp','sprites','absent'),nil,'unknown entry is unavailable') +T.eq(api:info('lttp','music'),nil,'missing optional pack is unavailable without blocking') +T.eq(Packs.missing(m,fs),nil,'complete declared pack satisfies requirement') +T.check(not I.resolve({importer='lttp',pack='sprites',version='>=2.0.0'},fs),'pack version ranges are enforced') +local manifestPath=I.manifestPath('lttp','sprites') +local prior=files[manifestPath] +for _,fault in ipairs({'failWrite','shortWrite','failRename','throwRename','failDirectory','failManifestRename'}) do + local replacement=input('2.0.0','DIFFERENT') + fs[fault]=true + T.eq(complete(I.install('lttp','sprites',replacement,'inbox',fs)),nil,'producer rejects '..fault) + fs[fault]=false + T.eq(files[manifestPath],prior,'failed publication preserves old descriptor') + T.eq(api:read('lttp','sprites','hero'),'PNG!','failed replacement preserves old asset') + local pending=0; for path in pairs(files) do if path:find('.pending-',1,true) then pending=pending+1 end end + T.eq(pending,0,'failed transaction cleans its staging files') +end +local cancelled=I.install('lttp','sprites',input('3.0.0','CANCELLED'),'inbox',fs) +T.check(coroutine.resume(cancelled),'replacement can yield before completion') +T.eq(files[manifestPath],prior,'abandoning an unfinished coroutine preserves previous descriptor') +T.eq(api:read('lttp','sprites','hero'),'PNG!','abandoning a producer preserves old assets') +local missingSource=input('2.0.0'); missingSource.remove('inbox/hero.png') +T.eq(complete(I.install('lttp','sprites',missingSource,'inbox',fs)),nil,'missing producer input cannot publish') +local badSource,badFiles=input('2.0.0'); badFiles['inbox/hero.lua']='return os.execute("bad")' +local badManifest=assert(Data.decode(badFiles['inbox/pack.lua'])) +badManifest.entries.hero.metadata.size=#badFiles['inbox/hero.lua'] +badFiles['inbox/pack.lua']=Writer.encode(badManifest) +T.eq(complete(I.install('lttp','sprites',badSource,'inbox',fs)),nil,'invalid animation metadata cannot publish') +T.eq(files[manifestPath],prior,'invalid extraction preserves installed pack') +result=complete(I.install('lttp','sprites',input('2.0.0','NEW!'),'inbox',fs)) +T.check(result~=nil,'successful update publishes new descriptor') +T.eq(api:info('lttp','sprites').version,'2.0.0','already constructed consumer observes update') +T.eq(api:read('lttp','sprites','hero'),'NEW!','already constructed consumer reads replacement') +local hero=api:entry('lttp','sprites','hero') +local heroPath=I.assetPath('lttp','sprites',hero.file) +files[heroPath]=string.rep('x',Data.MAX_BYTES+1) +local reads=fs.reads +T.eq(api:read('lttp','sprites','hero'),nil,'oversized on-disk entry is refused') +T.eq(fs.reads,reads,'oversized entry is rejected before allocating it') +T.check(Packs.missing(m,fs)~=nil,'incomplete pack is not ready') +files[heroPath]='NEW!' +T.check(fs.maxRead<=Data.MAX_BYTES,'physical reads stay bounded') +T.eq(api:info('lttp','sprites').version,'2.0.0','reader cache is populated before a scoped update') +local baseTime=fs.getInfo(manifestPath).modtime +local getInfo=fs.getInfo +fs.getInfo=function(path,kind)local info=getInfo(path,kind);if path==manifestPath and info then info.modtime=baseTime end;return info end +local proxy=setmetatable({}, {__index=fs}) +T.check(complete(I.install('lttp','sprites',input('2.1.0','MORE'),'inbox',proxy))~=nil,'scoped filesystem provider publishes') +T.eq(api:info('lttp','sprites').version,'2.1.0','scoped publication invalidates readers even with unchanged coarse timestamp') +fs.getInfo=getInfo +local backing='first'; fs.getRealDirectory=function()return backing end +api:info('lttp','sprites') +files[manifestPath]=files[manifestPath]:gsub('2%.1%.0','2.2.0'); backing='second' +T.eq(api:info('lttp','sprites').version,'2.2.0','changed data mount invalidates equal-size and equal-time descriptor') + +local paletteScript='return os.execute("bad")' +local aliasSource=memory({['inbox/pack.lua']=Writer.encode({format=1,importer='lttp',pack='palettes',kind='palette', + version='1.0.0',source={md5=string.rep('a',32)},entries={color={file='palette.lua',size=#paletteScript, + metadata={file='palette.lua',size=#paletteScript}}}}),['inbox/palette.lua']=paletteScript}) +local aliasResult,aliasError=complete(I.install('lttp','palettes',aliasSource,'inbox',fs)) +T.eq(aliasResult,nil,'metadata alias of a previously copied data file is still parsed') +T.check(aliasError:find('expected a literal',1,true)~=nil,'metadata alias failure comes from the literal decoder') + +local inboxFs,inboxFiles=input('3.0.0','INBOX') +local root=Inbox.ROOT..'/lttp/sprites' +local inboxSeed={} +for path,bytes in pairs(inboxFiles) do inboxSeed[root..path:sub(#'inbox'+1)]=bytes end +-- A second, invalid pack must not prevent the independent valid pack landing. +inboxSeed[Inbox.ROOT..'/lttp/tiles/pack.lua']='return os.execute("bad")' +local batchFs,batchFiles=memory(inboxSeed) +local batch=complete(Inbox.start(batchFs)) +T.eq(batch.found,2,'portable inbox discovers known pack descriptors') +T.eq(#batch.installed,1,'valid pack installs independently') +T.eq(#batch.failed,1,'invalid pack is reported separately') +T.eq(Packs.new(m,batchFs):read('lttp','sprites','hero'),'INBOX','inbox provider feeds actual consumer') +T.eq(batchFiles[root..'/hero.png'],'INBOX','console/mobile inbox source is preserved') +T.eq(complete(Inbox.start(memory())).found,0,'empty inbox is a normal result') + +local RomImporter=require('src.import.RomImporter') +local oldDataFs=CacheFs.dataFs; CacheFs.dataFs=function()return batchFs end +for _,platform in ipairs({'ios','android','switch','desktop'}) do + local refresh=0 + local view=setmetatable({workState='idle',ios=platform=='ios',android=platform=='android', + _refreshMods=function()refresh=refresh+1 end},RomImporter) + view:installAssetPacks() + local first=view._assetPackInstallJob; view:installAssetPacks() + T.eq(view._assetPackInstallJob,first,'duplicate launcher presses keep one pack job on '..platform) + while view._assetPackInstallJob do view:_stepAssetPacks() end + T.eq(refresh,1,'launcher refreshes readiness after install on '..platform) + T.check(not view.modNotice.ok,'launcher reports partial failure on '..platform) +end +CacheFs.dataFs=oldDataFs + +local function boot(includePack) + local definitions={consumer={required_assets={requirement}},dependent={dependencies={'consumer'}}, + optional={optional_assets={{importer='lttp',pack='music'}}},independent={}} + local data,paths={},{} + for id,extra in pairs(definitions) do + local path='mods/'..id; paths[#paths+1]=path + data[path..'/manifest.json']=Json.encode(raw(extra,id)) + data[path..'/main.lua']='local mod=...;mod.exports.executed=true;mod.exports.packs=mod.packs:list()' + end + if includePack then for path,body in pairs(files) do data[path]=body end end + local f=memory(data) + return T.sdk.loadMods(paths,{fs=f,dev=false}),f +end +local run,waitingFs=boot() +T.eq(run.mods.consumer.state,'missing_assets','required pack gets actionable load state') +T.eq((run.loader.apis or {}).consumer,nil,'pack gate runs before API creation') +T.eq(run.loader.exports.dependent,nil,'required pack gate propagates to dependent') +T.check(run.loader.exports.optional.executed,'missing optional pack allows mod entry') +T.check(run.loader.exports.independent.executed,'unrelated mods still execute') +local manager=Manager.new({mods=run.loader,save={options={}}}) +T.check(not manager.restartPending,'still missing pack does not stage restart') +T.check(complete(I.install('lttp','sprites',input(),'inbox',waitingFs))~=nil,'pack can be installed after blocked boot') +manager:refresh(); T.check(manager.restartPending,'satisfied blocked pack stages restart') +run.release() +run=boot(true) +T.check(run.loader.exports.consumer.executed,'consumer recovers on fresh boot') +T.check(run.loader.exports.dependent.executed,'dependent recovers on fresh boot') +T.eq(run.loader.exports.consumer.packs[1].installed,true,'real mod entry receives native packs API') +run.release() +local oldFs=CacheFs.dataFs; CacheFs.dataFs=function()return waitingFs end +local rows=Launcher.deriveList({m},{mods={}},'red') +T.eq(rows[1].status,'ok','launcher recognizes installed required pack') +waitingFs.remove(manifestPath) +rows=Launcher.deriveList({m},{mods={}},'red') +T.eq(rows[1].status,'warn','launcher surfaces missing asset requirements') +CacheFs.dataFs=oldFs; Versions.set(savedVersion) +T.finish('Native asset pack contracts') diff --git a/tests/engine/async_download_queue.lua b/tests/engine/async_download_queue.lua new file mode 100644 index 000000000..8e385c753 --- /dev/null +++ b/tests/engine/async_download_queue.lua @@ -0,0 +1,107 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local checks, ticks, workers, queued = 0, 0, {}, {} +local function check(ok, label) checks = checks + 1; assert(ok, label) end +local function channel() + local q = {} + function q:push(v) self[#self + 1] = v; if type(v) == "table" and v.stage then queued[v.id] = v end end + function q:pop() if #self > 0 then return table.remove(self, 1) end end + function q:peek() return self[1] end + function q:clear() for i = #self, 1, -1 do self[i] = nil end end + function q:performAtomic(fn) return fn(self) end + return q +end +love = {system = {getOS = function() return "Linux" end}, timer = {getTime = function() return ticks end}, + filesystem = {createDirectory = function() return true end, getSaveDirectory = function() return "/owned" end, + getInfo = function() error("filesystem IO in main-thread poll") end, + remove = function() return true end}, thread = {newChannel = channel, + newThread = function() + local t = {start = function(self, c, r) self.commands, self.results = c, r end, + getError = function(self) return self.err end, wait = function() end} + workers[#workers + 1] = t; return t + end}} +package.loaded["src.core.HostShell"] = {canSpawnProcess = function() return true end} +local rename = os.rename +os.rename = function() error("filesystem publication in main-thread poll") end +local D = require("src.net.AsyncDownload") +local function complete(id, ok, bytes, err) + check(queued[id] ~= nil, "request queued before worker result") + workers[#workers].results:push({id = id, ok = ok, bytes = bytes, err = err, httpStatus = ok and 200 or 404}) +end +local function claim(id) + local control = queued[id].control + return control:performAtomic(function(c) + if c:peek() ~= "open" then return false end + c:clear(); c:push("publishing"); return true + end) +end +for _, dest in ipairs({"", "/absolute", "../escape", "x/../escape", "C:\\escape", "x//file", "x/", "net-download/file"}) do + check(D.download("https://host/file", dest) == nil, "invalid target refused: " .. dest) +end +check(D.download("file:///private", "out") == nil, "only HTTP transport") +check(D.download("http://host/file", "out", {boundedHttpsGet = 1}) == nil, "HTTPS constraint") +check(D.download("https://host/file", "out", {size = 3, maxBytes = 2}) == nil, "contradictory size limit") +check(D.download("https://host/file", "out", {size = .5}) == nil, "fractional size refused") +check(D.download("https://host/file", "out", {maxBytes = math.huge}) == nil, "infinite limit refused") +check(D.download("https://host/file", "out", {userAgent = "bad\r\nheader"}) == nil, "invalid header refused") +local id = assert(D.download("https://host/file", "nested/out", {size = 6})) +check(#workers == 2, "fixed file worker ceiling") +check(D.download("https://host/file", "nested\\out") == nil, "one active writer per normalized destination") +check(D.poll(id).status == "pending", "poll starts no HTTP or filesystem work") +queued[id].progress:push(3) +local progress = D.poll(id) +check(progress.receivedBytes == 3 and progress.progress == .5, "coalesced progress is available without filesystem IO") +queued[id].progress:clear() +check(D.poll(id).receivedBytes == 3, "progress cannot move backwards between worker updates") +check(claim(id), "worker claims completed transfer") +check(D.cancel(id) == false, "cancellation after publication claim is refused") +check(D.poll(id).phase == "publishing" and D.poll(id).cancellable == false, "publication boundary exposed honestly") +complete(id, true, 6) +local state = D.poll(id) +check(state.status == "ok" and state.receivedBytes == 6 and state.progress == 1, "completed file metadata") +state.status = "modified"; check(D.poll(id).status == "ok", "detached polling state") +check(not D.cancel(id) and D.poll(id).status == "ok", "completed publication is not rolled back") +D.release(id); check(D.poll(id).status == "error", "released handle invalid") +for _, mode in ipairs({"cancel", "release", "timeout"}) do + id = assert(D.download("https://host/file", "preserved", {size = 6, maxSeconds = 1})) + if mode == "cancel" then check(D.cancel(id), "pre-publication cancellation accepted") + elseif mode == "release" then D.release(id) + else ticks = ticks + 2; check(D.poll(id).status == "error", "queue time counts toward deadline") end + check(not claim(id), mode .. " cannot permit worker publication") + complete(id, true, 6) -- adversarial late result must not revive the request + check(D.poll(id).status == "error", mode .. " remains terminal after late completion") + D.release(id) +end +for _, err in ipairs({"HTTP 404", "download size mismatch", "could not publish download"}) do + id = assert(D.download("https://host/file", "retry")); complete(id, false, nil, err) + state = D.poll(id) + check(state.status == "error" and state.err == err and state.httpStatus == 404, "worker error preserved: " .. err) + D.release(id) +end +id = assert(D.download("https://host/empty", "empty", {size = 0})); claim(id); complete(id, true, 0) +check(D.poll(id).status == "ok" and D.poll(id).receivedBytes == 0, "zero-byte success completes") +D.release(id) +id = assert(D.download("https://host/finishing", "released-publish")); check(claim(id), "publication claim before release") +D.release(id) +check(D.download("https://host/retry", "released-publish") == nil, "released publication still owns destination until finished") +complete(id, true, 6) +local replacement = assert(D.download("https://host/retry", "released-publish")) +check(D.poll(id).status == "error", "released completion handle remains invalid") +D.cancel(replacement); complete(replacement, false, nil, "cancelled"); D.poll(replacement); D.release(replacement) +id = assert(D.download("https://host/finishing", "shutdown-publish")); claim(id); complete(id, true, 6) +D.shutdown(); check(D.poll(id).status == "ok", "shutdown drains a completed publication instead of leaving it pending") +D.release(id) +local held = {} +for i = 1, D.MAX_JOBS do held[i] = assert(D.download("https://host/slow", "held" .. i)) end +check(D.download("https://host/slow", "extra") == nil, "bounded active and queued file jobs") +for _, h in ipairs(held) do D.cancel(h); D.release(h) end +check(D.download("https://host/slow", "extra") == nil, "release cannot evade occupied worker budget") +for _, h in ipairs(held) do complete(h, false, nil, "cancelled") end +id = assert(D.download("https://host/new", "reused")) +check(D.poll(held[1]).status == "error", "released request never revived") +workers[#workers].err = "worker stopped" +check(D.poll(id).status == "error", "worker death terminates pending file jobs") +D.shutdown() +id = assert(D.download("https://host/new", "restart")); claim(id); complete(id, true, 5) +check(D.poll(id).status == "ok", "file pool restarts after shutdown") +D.release(id); D.shutdown(); os.rename = rename +print(("async file download queue: %d checks PASS"):format(checks)) diff --git a/tests/engine/async_get_queue.lua b/tests/engine/async_get_queue.lua new file mode 100644 index 000000000..ecc4aa833 --- /dev/null +++ b/tests/engine/async_get_queue.lua @@ -0,0 +1,51 @@ +package.path='./?.lua;'..package.path +local ticks, checks, channels, workers = 0, 0, {}, {} +local function check(ok, label) checks=checks+1;assert(ok,label)end +local function channel() + local q={} + function q:push(v)self[#self+1]=v end + function q:pop()if #self==0 then return nil end;return table.remove(self,1)end + function q:peek()return self[1]end + function q:clear()for i=#self,1,-1 do self[i]=nil end end + channels[#channels+1]=q;return q +end +love={timer={getTime=function()return ticks end},thread={newChannel=channel,newThread=function() + local t={start=function(self,c,r,q)self.commands,self.results,self.quit=c,r,q end, + getError=function(self)return self.err end,wait=function()end};workers[#workers+1]=t;return t +end}} +local Get=require('src.net.AsyncGet') +check(Get.get('file:///x')==nil,'direct caller URL validation') +check(Get.get('http://host',{boundedHttpsGet=1})==nil,'direct caller HTTPS constraint') +local handles={} +for i=1,Get.MAX_JOBS do handles[i]=assert(Get.get('https://host/x')) end +check(#workers==3,'fixed worker ceiling') +check(Get.get('https://host/x')==nil,'global active ceiling') +for _,id in ipairs(handles)do Get.cancel(id);Get.release(id)end +check(Get.get('https://host/x')==nil,'release cannot evade running worker ceiling') +for _,id in ipairs(handles)do workers[1].results:push({id=id,ok=true,body='late'})end +local id=assert(Get.get('https://host/x',{maxSeconds=1})) +check(Get.poll(handles[1]).status=='error','released handle never revived by late result') +ticks=2 +check(Get.poll(id).status=='error','queue time counts toward deadline') +workers[1].results:push({id=id,ok=true,body='too late'}) +check(Get.poll(id).body==nil,'timeout never accepts late body');Get.release(id) +id=assert(Get.get('https://host/x'));Get.cancel(id) +workers[1].results:push({id=id,ok=true,body='late'}) +check(Get.poll(id).status=='cancelled' and Get.poll(id).body==nil,'cancel never accepts late body');Get.release(id) +id=assert(Get.get('https://host/x'));workers[1].err='worker stopped' +check(Get.poll(id).status=='error','worker failure terminates pending jobs');Get.release(id) +Get.shutdown();check(Get.available(),'shutdown restarts pool') +id=assert(Get.get('https://host/x'));local w=workers[#workers] +w.results:push({id=id,ok=true,body='ok'}) +local state=Get.poll(id);state.status='changed' +check(Get.poll(id).status=='ok','poll returns detached state') +Get.release(id);Get.shutdown() +check(Get.postLog('http://host','log')==nil,'POST always requires HTTPS') +check(Get.postLog('https://host',string.rep('x',512*1024+1))==nil,'wire payload has a byte ceiling') +id=assert(Get.postLog('https://host','log',{contentType='text/plain'})) +w=workers[#workers] +w.results:push({id=id,ok=true,body='must not escape',err='must not escape',httpStatus=204}) +state=Get.poll(id) +check(state.status=='ok' and state.body==nil and state.err==nil,'one-way worker results hide body and diagnostics') +Get.release(id);Get.shutdown() +print(('async GET queue: %d checks PASS'):format(checks)) 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_auxiliary.lua b/tests/engine/battle_auxiliary.lua new file mode 100644 index 000000000..1e73e68b9 --- /dev/null +++ b/tests/engine/battle_auxiliary.lua @@ -0,0 +1,146 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local F = dofile("tests/battle_auxiliary/fixture.lua") +local Version, Auxiliary = require("src.core.GameVersion"), require("src.battle.MenuAuxiliary") +local oldVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = F.new(".", version, mode[1], mode[2]) + local b, game, e = f.battle, f.game, f.exports + T.eq(e.api:supports("hooks", "battle.menu_auxiliary"), true, version .. " capability") + T.eq(Auxiliary.ready(b), true, "native command menu ready") + -- Reach the boundary through actual native queue/drain execution. Merely + -- constructing an idle menu misses stale insertion and closing markers. + local during + b:act(function() during = Auxiliary.ready(b) end) + b:updateQueue() + T.eq(during, false, "callback insertion cursor blocks reentrant START") + T.eq(Auxiliary.ready(b), true, "native callback releases menu boundary") + b.enemy.mon.hp = b.enemy.mon.hp - 2 + local frames = 0 + while b:stepHPDrain() and frames < 1000 do + frames = frames + 1 + T.eq(Auxiliary.ready(b), false, "native drain and closing hold block START") + end + T.check(frames < 1000, "native HP drain finishes") + T.eq(Auxiliary.ready(b), true, "completed native drain releases menu boundary") + f.press(false, true) + T.eq(e.seen.calls, 0, "no START leaves hook idle") + T.eq(b.menuIndex, 2, "ordinary native navigation") + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, 1, "START invokes native hook once") + T.eq(b.menuIndex, 2, "unclaimed START preserves native navigation") + T.eq(e.seen.game, game, "opaque native Game") + T.same(e.seen.context, { kind = "wild" }, "data-only native context") + e.claim(true) + b.menuIndex = 1 + f.press(true, true) + T.eq(b.menuIndex, 1, "claimed input skips native menu navigation") + for _, row in ipairs({ {}, { value = false }, { value = 0 }, { value = "" }, { value = {} } }) do + local off = e.api.hooks:wrap("battle.menu_auxiliary", function() + return row.value + end, 100) + b.menuIndex = 1 + f.press(true, true) + T.eq(b.menuIndex, 2, "only literal true claims START") + off() + end + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.menu_auxiliary", function(next) + if after then + next() + end + error("intentional auxiliary failure") + end, 100) + b.menuIndex = 1 + local calls = e.seen.calls + f.press(true, true) + T.eq(b.menuIndex, 1, "wrapper failure preserves downstream claim") + T.eq(e.seen.calls, calls + 1, "failure does not repeat downstream") + off() + end + -- Presentation is caught up during native update; an unsettled frame must + -- not turn into an eligible START action halfway through that same update. + local calls = e.seen.calls + b.player.shownHP = b.player.mon.hp - 1 + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, calls, "unsettled HP rejected before catch-up") + T.eq(b.menuIndex, 2, "native catch-up/navigation still runs") + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, calls + 1, "fresh START after settling works") + local bad = { + { owner = b, key = "kind", value = "link" }, + { owner = b, key = "safari", value = {} }, + { owner = b, key = "pokedudeDemo", value = true }, + { owner = b, key = "palace", value = true }, + { owner = b, key = "phase", value = "moveSelect" }, + { owner = b, key = "queue", value = { {} } }, + { owner = b, key = "pendingActions", value = { [0] = { kind = "move" } } }, + { owner = b, key = "animPlaying", value = true }, + { owner = b, key = "waitFrames", value = 1 }, + { owner = b, key = "introSlide", value = 1 }, + { owner = b, key = "waitingUI", value = true }, + { owner = b.player, key = "mustRecharge", value = true }, + { owner = b.player, key = "shownStatus", value = "PSN" }, + { owner = b.player, key = "drainFloor", value = 0 }, + { owner = game.stack, key = "states", value = { b, {} } }, + } + for _, change in ipairs(bad) do + local old = change.owner[change.key] + change.owner[change.key] = change.value + T.eq(Auxiliary.ready(b), false, "unsafe " .. change.key) + game.input.pressed = { start = true } + T.eq(Auxiliary.request(b), nil, "unsafe START does not allocate request") + change.owner[change.key] = old + end + game.input.pressed = { start = true } + local token = assert(Auxiliary.request(b)) + calls = e.seen.calls + game.sessionStartedAt = 2 + T.eq(Auxiliary.dispatch(b, token), true, "retired session input consumed") + T.eq(e.seen.calls, calls, "retired session never dispatches hook") + game.sessionStartedAt = 1 + -- Even a faulty wrapper that opens an overlay and returns false cannot + -- feed that same input through the old native menu. + local overlay = {} + local off = e.api.hooks:wrap("battle.menu_auxiliary", function() + game.stack.states = { b, overlay } + return false + end, 100) + b.menuIndex = 1 + f.press(true, true) + T.eq(game.stack:top(), overlay, "custom overlay stays on stack") + T.eq(b.menuIndex, 1, "overlay invalidation prevents stale native fallthrough") + off() + game.stack.states = { b } + e.stop() + e.stop() + calls = e.seen.calls + b.menuIndex = 1 + f.press(true, true) + T.eq(e.seen.calls, calls, "unsubscribe is idempotent") + T.eq(b.menuIndex, 2, "unsubscribe restores native menu") + local player, enemy = b.player, b.enemy + b.double = true + b:placeBattler(2, player) + b:placeBattler(0, nil) + b.choosingSlot = 2 + T.eq(Auxiliary.ready(b), true, "double partner can own an otherwise empty player side") + b:placeBattler(1, nil) + T.eq(Auxiliary.ready(b), false, "no live opponent cannot open an auxiliary menu") + b:placeBattler(1, enemy) + T.eq(Auxiliary.ready(b), true, "restored opponent returns to a live decision") + f.close() + local unreadable = setmetatable({}, { + __index = function() + error("no-hook path touched battle") + end, + }) + T.eq(Auxiliary.request(unreadable), nil, "no-mod hot path skips native traversal") + end +end +Version.set(oldVersion) +T.finish("portable battle.menu_auxiliary native dispatch") 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/bounded_http.lua b/tests/engine/bounded_http.lua new file mode 100644 index 000000000..8206e31b6 --- /dev/null +++ b/tests/engine/bounded_http.lua @@ -0,0 +1,64 @@ +-- 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 Json=require('src.link.Json') +local Http=require('src.net.BoundedHttp') +local spawn=Host.canSpawnProcess +Host.canSpawnProcess=function()return false end +local previous=love +local binary='a\0\255' +local calls,request,reply=0,nil,{status=200,bodyBase64='YQD/'} +love={system={},data={ + encode=function(kind,format,value) + assert(kind=='string' and format=='base64' and value==binary) + return 'YQD/' + end, + decode=function(kind,format,value) + assert(kind=='string' and format=='base64' and value=='YQD/') + return binary + end, +}} +T.eq(Http.available(),false,'old mobile runtime does not advertise bounded HTTP') +love.system.boundedHttpVersion=function()return 2 end +love.system.httpRequestBounded=function(wire) + calls=calls+1;request=Json.decode(wire) + if type(reply)=='string' then return reply end + return Json.encode(reply) +end +T.eq(Http.nativeAvailable(),false,'unknown wire version is not silently accepted') +love.system.boundedHttpVersion=function()return 1 end +T.eq(Http.available(),true,'matching native protocol enables the service without subprocesses') +T.eq(Http.request('https://fixture.invalid/get',{maxBytes=3}),binary,'native GET preserves binary bytes') +T.eq(request.method,'GET','GET dispatch reaches the native binding') +T.eq(request.maxBytes,3,'response bound reaches the native adapter') +T.eq(Http.request('https://fixture.invalid/log',{method='POST',body=binary,discardBody=true}),'','log success discards the native reply') +T.eq(request.bodyBase64,'YQD/','binary request uses the native wire codec') +T.eq(request.httpsOnly,true,'POST wire always enforces HTTPS') +T.eq(request.discardBody,true,'native adapter receives one-way mode') +local count=calls +T.eq(Http.request('http://fixture.invalid/log',{method='POST',body=binary}),nil,'insecure POST is refused before native submission') +T.eq(Http.request('file:///fixture'),nil,'non-HTTP target is refused before native submission') +T.eq(Http.request('https://fixture.invalid',{maxSeconds=0}),nil,'invalid deadline submits nothing') +T.eq(calls,count,'invalid requests never enter the platform transport') +reply={status=200,bodyBase64='YQD/'} +T.eq(Http.request('https://fixture.invalid',{maxBytes=2}),nil,'decoded binary cannot exceed the requested bound') +reply={status=200,bodyBase64='invalid'} +T.eq(Http.request('https://fixture.invalid'),nil,'malformed native Base64 is a failure') +reply={status=200,bodyBase64=string.rep('x',12)} +T.eq(Http.request('https://fixture.invalid',{maxBytes=3}),nil,'oversized wire body is rejected before decoding') +for _,bad in ipairs({'not JSON','[]',Json.encode({status=999}),Json.encode({status=200.5}), + Json.encode({status=200,error='private platform diagnostic',bodyBase64='YQD/'})})do + reply=bad + local body,why=Http.request('https://fixture.invalid') + T.eq(body,nil,'malformed or failed native response cannot become success') + T.check(not tostring(why):find('private',1,true),'native diagnostics do not escape the bridge') +end +reply={status=307,bodyBase64='private redirect reply'} +local body,why,status=Http.request('https://fixture.invalid/log',{method='POST',body=binary,discardBody=true}) +T.eq(body,nil,'one-way redirect remains a failure') +T.eq(status,307,'redirect status is retained without its body') +reply={status=204} +T.eq(Http.request('https://fixture.invalid/log',{method='POST',body=binary,discardBody=true}),'','empty accepted log response needs no Base64 field') +love=previous;Host.canSpawnProcess=spawn +T.finish('Bounded native HTTP wire') diff --git a/tests/engine/caught_marker_contract.lua b/tests/engine/caught_marker_contract.lua new file mode 100644 index 000000000..cabfe7ce7 --- /dev/null +++ b/tests/engine/caught_marker_contract.lua @@ -0,0 +1,44 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local V, Runtime = require("src.core.GameVersion"), require("src.mods.Runtime") +local Fixture = dofile("tests/caught_marker/fixture.lua") +local old = V.get() +for _, version in ipairs(V.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + T.eq( + f.e.api:supports("hooks", "battle.caught_marker_visible"), + f.gen <= 3, + "version-aware marker capability" + ) + if f.gen <= 3 then + dofile("tests/caught_marker/cases.lua").run(f) + else + f.e.state.mode = "show" + T.eq(f.visible(), false, "DS helper never dispatches unavailable marker hook") + T.eq(f.e.state.calls, 0, "DS callback remains unused") + end + f.close() + for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(".", "gen2", version, mode[1], mode[2], not failing, failing) + if failing then + T.check(#bare.result.errors > 0, "failed entry recorded") + else + T.eq(bare.result.loader.exports.portable_caught_marker, nil, "no-mod consumer is absent") + end + for _, link in ipairs(Runtime.hooks.chains["battle.caught_marker_visible"] or {}) do + T.check(link.owner ~= "portable_caught_marker", "rollback removes marker wrappers") + end + local call = Runtime.call + Runtime.call = function(name, ...) + assert(name ~= "battle.caught_marker_visible", "empty-chain marker path dispatched") + return call(name, ...) + end + T.eq(bare.visible(), bare.gen > 1 and bare.gen <= 3, "native default after no-mod/rollback") + Runtime.call = call + bare.close() + end + end +end +V.set(old) +T.finish("native caught marker hook and public SDK contracts") diff --git a/tests/engine/compatibility_cell_semantics.lua b/tests/engine/compatibility_cell_semantics.lua new file mode 100644 index 000000000..2ea28a94d --- /dev/null +++ b/tests/engine/compatibility_cell_semantics.lua @@ -0,0 +1,55 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local MB = require('src.compat.gen1.MetatileBehavior') +local Class = require('src.compat.gen1.CollisionClass') +local fr, em = Class.impl('frlg'), Class.impl('rse') +T.eq(MB.fromRaw('firered', 17), 17, 'Kanto raw byte retains meaning') +T.eq(MB.fromRaw('emerald', 17), MB.INTERIOR_DEEP_WATER, 'Hoenn byte 17 is not Kanto fast water') +T.check(MB.fromRaw('emerald', 17) ~= MB.FAST_WATER, 'families do not share behavior byte meanings') +for _, game in ipairs({'emerald', 'ruby', 'sapphire'}) do + local translator = MB.translator(game) + for raw = 0, 255 do T.eq(translator.canon(translator.raw(translator.canon(raw))), translator.canon(raw)) end + T.eq(translator.canon(.5), nil, 'fractional input is not silently truncated') + T.eq(MB.fromRaw(game, .5), nil, 'failed conversion does not fall back to raw') + local copy = translator.table(); copy[0] = -1 + T.check(translator.table()[0] ~= -1, 'conversion snapshots cannot corrupt shared metadata') +end +local copy = MB.all(); copy.NORMAL = -1 +T.eq(MB.id('MB_NORMAL'), 0, 'name snapshots cannot corrupt IDs') +T.eq(fr.fromCell(0, 0, MB.POND_WATER, 'route'), 0x29, 'water is classified, not raw collision zero') +T.eq(fr.fromCell(0, 1, MB.POND_WATER, 'route'), 7, 'blocked water stays blocked') +T.eq(fr.fromCell(0, 0, MB.TALL_GRASS, 'route'), 0x18) +T.eq(fr.fromCell(0, 0, MB.JUMP_NORTH, 'route'), 0xa2) +T.eq(fr.fromCell(0, 0, MB.WARP_DOOR, 'indoor'), 0x72, 'indoor warp retains facing') +T.eq(fr.fromCell(0, 0, MB.WARP_DOOR, 'town'), 0x71, 'outdoor door retains original arrival contract') +T.eq(fr.fromCell(10, nil, nil, 'route'), 7, 'nil collision differs from zero in Kanto terrain fallback') +T.eq(fr.fromCell(10, 0, nil, 'route'), 0) +T.eq(fr.fromCell(10, 1, nil, 'indoor'), 7, 'indoor reused metatile is not an outdoor tree') +T.eq(fr.fromCell(646, 0, 0, 'route'), 0, 'dead shrub guard does not become new foliage') +local bits = {[MB.POND_WATER] = 2, [MB.TALL_GRASS] = 2} +em.setTileBits(bits) +T.eq(em.fromCell(0, 0, MB.POND_WATER, 'route'), 0x29) +T.eq(em.fromCell(0, 1, MB.POND_WATER, 'route'), 7) +T.eq(em.fromCell(0, 0, MB.TALL_GRASS, 'route'), 0x29, 'actual surf flag precedes static category') +bits[MB.TALL_GRASS] = 0 +T.eq(em.fromCell(0, 0, MB.TALL_GRASS, 'route'), 0x18, 'borrowed flag mutation is visible immediately') +bits[MB.POND_WATER] = nil +T.eq(em.fromCell(0, nil, MB.POND_WATER, 'route'), 0, 'Hoenn fallback accepts nil collision') +T.eq(fr.fromCell(0, 0, MB.POND_WATER, 'route'), 0x29, 'Hoenn flags do not change Kanto classification') +em.setTileBits(nil) +T.eq(em._tileBits, nil, 'flag owner can retire its source') +T.eq(em.fromCell(0, 0, MB.POND_WATER, 'route'), 0, 'no invented surfability without source flags') +local classify, seed = em.classify, em.seed +em.classify = function() return 'LEDGE', 'W' end +T.eq(em.fromCell(0, 0, 0, 'route'), 0xa1, 'replacement classifier bypasses fixed rows') +em.classify = classify +em.seed = function(category) return category == 'TALL_GRASS' and 123 or 456 end +T.eq(em.fromCell(0, 0, MB.TALL_GRASS, 'route'), 123, 'replacement seed bypasses fixed rows') +em.seed = seed +T.eq(em.fromCell(0, 0, MB.TALL_GRASS, 'route'), 0x18, 'restoring extension functions restores dispatch') +local Class=require('src.compat.gen1.CollisionClass') +local a=Class.new('rse',{[16]=2});local b=Class.new('rse',{}) +T.eq(a.fromCell(0,0,16,'route'),0x29);T.eq(b.fromCell(0,0,16,'route'),0) +b.setTileBits({[16]=2});T.eq(b.fromCell(0,0,16,'route'),0x29) +a.setTileBits(nil);T.eq(a.fromCell(0,0,16,'route'),0);T.eq(b.fromCell(0,0,16,'route'),0x29,'contexts do not share flag state') +T.finish('compatibility cell semantics') diff --git a/tests/engine/compatibility_pair_identity.lua b/tests/engine/compatibility_pair_identity.lua new file mode 100644 index 000000000..a60cd0618 --- /dev/null +++ b/tests/engine/compatibility_pair_identity.lua @@ -0,0 +1,50 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local M=require('src.compat.gen1.PairIdentity');local Names=require('src.compat.gen1.PairNames') +local count=0 +for _,ed in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + local defs,banks={},{} + for i,row in ipairs(Names[ed])do + count=count+1 + local name,p,s,alias=M.lookup(ed,row[1],row[2]);T.eq(name,row[3]);T.eq(p,row[4]);T.eq(s,row[5]);T.eq(alias,row[6]) + T.eq(M.lookup(ed,row[1],-1),nil);T.eq(M.lookup(ed,.5,row[2]),nil) + local pk,sk='P'..i,'S'..i + banks[pk]={address=row[1],secondary=false};banks[sk]={address=row[2],secondary=true} + defs['NATIVE_'..i]={id='NATIVE_'..i,primaryKey=pk,secondaryKey=sk} + end + local binding=M.bind(defs,banks,ed) + for i,row in ipairs(Names[ed])do + local key='NATIVE_'..i + T.eq(binding:nativeId(row[3]),key);T.eq(binding:canonicalId(key),row[3]) + local a,b=binding:descriptor(key),binding:descriptor(row[3]) + T.eq(a.id,b.id);T.eq(a.primary,row[4]);T.eq(a.secondary,row[5]);T.eq(a.tileset,row[6]) + a.id='EDIT';T.eq(binding:descriptor(key).id,row[3],'descriptor edits cannot corrupt binding') + T.eq(defs[key].primaryKey,'P'..i,'binding does not mutate native pair') + end + local first=Names[ed][1];local original=defs.NATIVE_1 + banks.UNKNOWN={address=1,secondary=true};defs.UNRECOGNIZED={primaryKey='P1',secondaryKey='UNKNOWN'} + local unknown=M.bind(defs,banks,ed);T.eq(unknown:canonicalId('UNRECOGNIZED'),nil,'unknown secondary source is never guessed from its primary');unknown:release();unknown=nil + defs.UNRECOGNIZED=nil;banks.UNKNOWN=nil + -- An alias to the identical owner is safe; independent owners remain ambiguous. + defs.ALIAS=original + local alias=M.bind(defs,banks,ed);T.eq(alias:nativeId(first[3]),'NATIVE_1');T.eq(alias:canonicalId('ALIAS'),first[3]);alias:release() + defs.ALIAS={primaryKey=original.primaryKey,secondaryKey=original.secondaryKey} + local ambiguous=M.bind(defs,banks,ed);local key,why=ambiguous:nativeId(first[3]) + T.eq(key,nil);T.eq(why,'ambiguous native pair identity');ambiguous:release();defs.ALIAS=nil + banks.P1.address=-1;T.eq(binding:descriptor('NATIVE_1'),nil,'source address mutation retires lookup') + banks.P1.address=first[1];T.eq(binding:canonicalId('NATIVE_1'),first[3]) + local p=banks.P1;banks.P1={address=first[1],secondary=false} + T.eq(binding:nativeId(first[3]),nil,'bank replacement retires lookup');banks.P1=p + p.secondary=true;T.eq(binding:canonicalId('NATIVE_1'),nil,'wrong bank role never aliases');p.secondary=false + local sk=original.secondaryKey;original.secondaryKey='P1';T.eq(binding:canonicalId('NATIVE_1'),nil);original.secondaryKey=sk + defs.NATIVE_1={primaryKey='P1',secondaryKey='S1'};T.eq(binding:canonicalId('NATIVE_1'),nil,'pair owner replacement retires lookup');defs.NATIVE_1=original + local weak=setmetatable({defs,banks,original,p},{__mode='v'}) + binding:release();T.eq(binding:canonicalId('NATIVE_1'),nil);T.eq(binding:nativeId(first[3]),nil) + T.eq(binding:descriptor('NATIVE_1'),nil);T.eq(binding:descriptor(first[3]),nil) + alias=nil;ambiguous=nil;defs=nil;banks=nil;original=nil;p=nil;collectgarbage('collect');collectgarbage('collect') + T.eq(weak[1],nil,'released binding drops native pair table');T.eq(weak[2],nil,'released binding drops bank table') + T.eq(weak[3],nil);T.eq(weak[4],nil) +end +T.eq(count,303,'all source pair identities across five editions') +T.eq(M.lookup('gold',0,0),nil,'native Gen2 has no compatibility pair identity') +T.finish('native semantic tileset pair identities') diff --git a/tests/engine/compute_permission.lua b/tests/engine/compute_permission.lua new file mode 100644 index 000000000..746a2862c --- /dev/null +++ b/tests/engine/compute_permission.lua @@ -0,0 +1,28 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Manifest=require('src.mods.Manifest') +local Json=require('src.link.Json') +local function manifest(permissions) + return {id='compute_probe',name='Compute probe',version='1.0.0',api=2,entry='main.lua', + games={'gen1','gen2','crystal'},game_version='>=0.1.90 <2.0.0',permissions=permissions} +end +local raw=manifest({'network','engine_internals','filesystem','compute'}) +local validated=Manifest.validate(raw,'mods/compute_probe') +T.eq(validated.permissionSet.compute,true,'declared thread capability retained') +T.eq(validated.permissionSet.background,nil,'compute does not grant background jobs') +T.eq(#validated.permissions,4,'all declared permissions retained') +T.eq(Manifest.validate(manifest({}),'mods/plain').permissionSet.compute,nil,'no implicit grant') +T.raises(function()Manifest.validate(manifest({'compute_typo'}),'mods/typo')end,'unknown permission','unknown permissions still rejected') +local original=love.thread;local sentinel={} +love.thread=sentinel +local fs=T.sdk.memfs({ + ['mods/compute_probe/manifest.json']=Json.encode(raw), + ['mods/compute_probe/main.lua']=[[local mod=...;mod.exports.thread=love.thread;mod.exports.loaded=true]], +}) +local result=T.sdk.loadMod('mods/compute_probe',{fs=fs}) +T.eq(#result.errors,0,'real discovery and loader admit compute manifest') +T.eq(result.mod and result.mod.state,'loaded','entry actually runs') +T.eq(result.loader.exports.compute_probe.loaded,true,'real exports published') +T.eq(result.loader.exports.compute_probe.thread,sentinel,'native thread API remains the host module') +result.release();love.thread=original +T.finish('compute permission') diff --git a/tests/engine/core_update.lua b/tests/engine/core_update.lua new file mode 100644 index 000000000..fcac5b9a9 --- /dev/null +++ b/tests/engine/core_update.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/core_update/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +dofile("tests/core_update/cases.lua").run(f) +f.close() +f.T.finish("portable core.update") diff --git a/tests/engine/core_update_routing.lua b/tests/engine/core_update_routing.lua new file mode 100644 index 000000000..515f520d5 --- /dev/null +++ b/tests/engine/core_update_routing.lua @@ -0,0 +1,102 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/core_update/fixture.lua") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local savedVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, g, api = f.exports, f.game, f.public + local label = version .. "/" .. mode[1] .. "/" .. mode[2] .. ": " + T.eq(api:supports("hooks", "core.update"), true, label .. "native hook capability") + f.update(0.125) + T.eq(g.calls, 1, label .. "native update runs once") + T.eq(e.polls(), 1, label .. "hook runs once") + T.eq(e.seen.game, g, label .. "Game stays opaque under every callback format") + T.eq(e.seen.dt, 0.125, label .. "delta stays scalar") + e.pause(true) + T.eq(f.bridge.update(g, 0.5), "paused", label .. "hook result returned by bridge") + T.eq(g.calls, 1, label .. "bridge respects pause") + e.pause(false) + -- Boot reports own their frame before any Game/mod processing. + f.set("bootReport", {}) + local polls, n, host = e.polls(), g.calls, f.hostPolls() + f.update(1 / 60) + T.eq(e.polls(), polls, label .. "boot report bypasses hook") + T.eq(g.calls, n, label .. "boot report bypasses game") + T.eq(f.hostPolls(), host, label .. "boot report retains native housekeeping boundary") + f.set("bootReport", nil) + -- Driver failure/completion must not accidentally fall into the hook. + local quit, oldQuit = {}, love.event.quit + love.event.quit = function(code) + quit[#quit + 1] = code or "normal" + end + f.set( + "driverCo", + coroutine.create(function() + error("intentional core.update driver failure") + end) + ) + f.update(1 / 60) + T.eq(quit[1], 1, label .. "driver error requests failure exit") + T.eq(e.polls(), polls, label .. "failed driver bypasses hook") + T.eq(g.calls, n, label .. "failed driver does not update game") + f.set("driverCo", coroutine.create(function() end)) + f.update(1 / 60) + T.eq(quit[2], "normal", label .. "completed driver requests native exit") + T.eq(e.polls(), polls, label .. "completed driver bypasses hook") + T.eq(g.calls, n, label .. "completed driver does not update game") + f.set("driverCo", nil) + love.event.quit = oldQuit + -- Owner cleanup removes only that contributor's frame wrapper. + local peerCalls = 0 + local off = f.result.loader.hooks:wrap("core.update", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 5, "other_owner") + f.result.loader.hooks:removeOwner("portable_core_update") + f.update(1 / 60) + T.eq(e.polls(), polls, label .. "owner removal retires mod wrapper") + T.eq(peerCalls, 1, label .. "owner removal preserves another wrapper") + T.eq(g.calls, n + 1, label .. "owner removal preserves native update") + off() + f.close() + end +end +-- With no mods, the actual love.update and direct bridge keep native behavior. +local bare = Fixture.new(".", "gen2", "red", 2, "native", true) +T.eq(Runtime.wantsHook("core.update"), false, "no-mod Runtime has no frame subscriber") +for _, dt in ipairs({ 0, 0.125, 2 }) do + bare.update(dt) +end +T.eq(bare.game.calls, 3, "no-mod interactive update runs once per call") +T.same(bare.game.dts, { 0, 0.125, 2 }, "no-mod delta forwarding unchanged") +T.eq(select("#", bare.bridge.update(bare.game, 0)), 0, "native return values discarded without subscribers") +bare.close() +-- A failed entry's registered update cannot survive its public-loader rollback. +local files = { + ["mods/broken_frame/manifest.json"] = [[{"id":"broken_frame","name":"Broken Frame", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_frame/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("core.update", function() error("rolled-back wrapper survived") end) + error("intentional frame entry failure") + ]], +} +local failed = T.sdk.loadMods({ "mods/broken_frame" }, { fs = T.sdk.memfs(files), data = T.fixtures.fresh() }) +T.check(#failed.errors > 0, "failed frame entry is reported") +T.eq(Runtime.wantsHook("core.update"), false, "rollback removes registered frame wrapper") +local count = 0 +require("src.core.PlatformHooks").update({ + update = function() + count = count + 1 + end, +}, 1 / 60) +T.eq(count, 1, "rollback preserves native frame fallback") +Loader.teardown() +T.eq(Runtime.wantsHook("core.update"), false, "teardown leaves no frame wrapper") +failed.release() +Version.set(savedVersion) +T.finish("core.update routing") diff --git a/tests/engine/example_gallery_acceptance.lua b/tests/engine/example_gallery_acceptance.lua new file mode 100644 index 000000000..85c280cf8 --- /dev/null +++ b/tests/engine/example_gallery_acceptance.lua @@ -0,0 +1,2 @@ +-- The gallery's actual load/hook assertions must run in the ROM-free engine tier. +dofile('tests/mod_examples_tests.lua') diff --git a/tests/engine/frame_pacing.lua b/tests/engine/frame_pacing.lua new file mode 100644 index 000000000..27a96b39e --- /dev/null +++ b/tests/engine/frame_pacing.lua @@ -0,0 +1,153 @@ +local T = require("tests.harness") +local Cap = require("src.core.FrameCap") +local function clock(start) + local now, sleeps, total = start or 0, 0, 0 + return { + getTime = function() + return now + end, + sleep = function(dt) + now = now + dt + sleeps = sleeps + 1 + total = total + dt + end, + advance = function(dt) + now = now + dt + end, + sleeps = function() + return sleeps, total + end, + } +end +local function window(sync, rate) + local calls = 0 + return { + getVSync = function() + return sync + end, + getMode = function() + calls = calls + 1 + return 320, 240, { refreshrate = rate } + end, + modeCalls = function() + return calls + end, + set = function(s, r) + sync, rate = s, r + end, + } +end +Cap.current = 60 +T.eq(Cap.vsyncPacing, true, "P1 switch defaults on") +for _, sync in ipairs({ 1, -1, 2 }) do + local timer, w = clock(), window(sync, 60) + local nextFrame = 0 + for _ = 1, 60 do + timer.advance(1 / 60) + nextFrame = Cap.pace(timer, w, true, nextFrame) + end + T.eq(timer.sleeps(), 0, "active VSync at the cap receives no additional sleep") + T.eq(w.modeCalls(), 1, "display flags are not allocated each frame") +end +for _, case in ipairs({ + { sync = 0, rate = 60, presented = true }, + { sync = 1, rate = 144, presented = true }, + { sync = 1, rate = 0, presented = true }, + { sync = 1, rate = 60, presented = false }, +}) do + local timer, w = clock(), window(case.sync, case.rate) + local deadline = Cap.pace(timer, w, case.presented, 0) + T.check(timer.sleeps() > 0, "software cap remains for disabled/unknown/faster/unpresented cases") + T.check(math.abs(deadline - 1 / 60) < 1e-9, "software deadline remains one frame ahead") +end +local timer, w = clock(), window(1, 60) +Cap.vsyncPacing = false +Cap.pace(timer, w, true, 0) +T.check(timer.sleeps() > 0, "P1 switch restores software pacing") +T.eq(w.modeCalls(), 0, "disabled P1 does not query display flags") +Cap.vsyncPacing = true +timer, w = clock(), window(1, 60) +local deadline = Cap.pace(timer, w, true, 0) +w.set(0, 60) +deadline = Cap.pace(timer, w, true, deadline) +T.check(math.abs(deadline - 1 / 60) < 1e-9, "turning VSync off resumes the carried software deadline") +timer.advance(1) +deadline = Cap.pace(timer, w, true, deadline) +T.eq(deadline, timer.getTime(), "stalls re-anchor instead of accumulating catch-up frames") +deadline = Cap.pace(timer, w, true, deadline) +T.eq(deadline, timer.getTime(), "software pacing resumes after a stall") +timer, w = clock(), window(1, 60) +Cap.pace(timer, w, true, 0) +w.set(1, 144) +timer.advance(1.01) +deadline = Cap.pace(timer, w, true, timer.getTime()) +T.check(timer.sleeps() > 0, "display rate changes refresh the software-cap decision") +T.eq(w.modeCalls(), 2, "display sample refresh is bounded") +timer = clock() +Cap.pace(timer, nil, true, 0) +T.check(timer.sleeps() > 0, "headless provider absence retains the software cap") + +-- Execute the actual run-loop body with native present/timer boundaries +-- substituted, so a correct helper wired to the wrong loop still fails. +local file = assert(io.open("main.lua", "r")) +local source = file:read("*a") +file:close() +local start = assert(source:find("function love.run()", 1, true)) +for _, active in ipairs({ true, false }) do + local fake = clock() + fake.step = function() + return 1 / 60 + end + local presents = 0 + local noop = function() end + local env = { + love = { + timer = fake, + window = window(1, 60), + graphics = { + isActive = function() + return active + end, + origin = noop, + clear = noop, + getBackgroundColor = function() + return 0, 0, 0, 1 + end, + present = function() + presents = presents + 1 + fake.advance(1 / 60 - 0.001) + end, + }, + }, + BootTrace = { mark = noop, frame = noop }, + Frames = { tick = noop }, + FrameProfile = { + section = function() + return noop + end, + }, + WindowFrame = { + draw = function(fn) + fn() + end, + }, + GraphicsStack = { drain = noop }, + pacingEnabled = function() + return true + end, + lastLogFlush = 0, + } + setmetatable(env, { __index = _G }) + local chunk + if setfenv then + chunk = assert(loadstring(source:sub(start), "@main.lua:love.run")) + setfenv(chunk, env) + else + chunk = assert(load(source:sub(start), "@main.lua:love.run", "t", env)) + end + chunk() + env.love.run()() + T.eq(presents, active and 1 or 0, "actual main loop presents only active graphics") + T.eq(fake.sleeps() > 0, not active, "actual main loop bypasses sleep only after native presentation") +end +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/gc_frame_budget.lua b/tests/engine/gc_frame_budget.lua new file mode 100644 index 000000000..974ba084e --- /dev/null +++ b/tests/engine/gc_frame_budget.lua @@ -0,0 +1,126 @@ +local T = require("tests.harness") +local Performance = require("src.core.Performance") +local Budget = require("src.core.GCFrameBudget") +local Cap = require("src.core.FrameCap") + +local function setup(cost) + local state = { now = 0, pause = 200, mul = 200, steps = 0, running = true, stepArgs = {}, sleeps = 0 } + local timer = { + getTime = function() + return state.now + end, + sleep = function(dt) + state.now = state.now + dt + state.sleeps = state.sleeps + 1 + end, + } + local function collector(command, ...) + local value = (...) + if command == "setpause" then + local old = state.pause + state.pause = value + return old + end + if command == "setstepmul" then + local old = state.mul + state.mul = value + return old + end + if command == "isrunning" then + return state.running + end + assert(command == "step", "unexpected collector command") + state.steps = state.steps + 1 + state.stepArgs[#state.stepArgs + 1] = select("#", ...) + state.now = state.now + (cost or 0.0001) + return state.completeAt and state.steps >= state.completeAt or false + end + return state, timer, collector +end +T.eq(Performance.gcBudget, true, "P2 defaults on") +T.eq(Performance.GC_PAUSE, 120, "requested collector pause") +T.eq(Performance.GC_STEP_MUL, 300, "requested collector step multiplier") +for _, tier in ipairs({ "high", "balanced", "low" }) do + T.eq(Performance.CAPS[tier].gcBudgetSeconds, 0.0005, "tier carries the default idle budget") +end +local state, timer, gc = setup() +Performance.tier = "high" +Budget.beginFrame(Performance, gc) +T.eq(state.pause, 120, "frame applies requested pause") +T.eq(state.mul, 300, "frame applies requested step multiplier") +local steps = Budget.idle(timer, 0.01) +T.eq(steps, 5, "idle work stops at the half-millisecond budget") +for _, n in ipairs(state.stepArgs) do + T.eq(n, 0, "incremental step has no fixed kilobyte argument") +end +local before = state.steps +T.eq(Budget.idle(timer, state.now), 0, "no collector work starts at an exhausted deadline") +T.eq(state.steps, before, "expired deadline leaves collector untouched") +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +T.eq(Budget.idle(timer, 0.00025), 3, "shorter deadline bounds started steps") +T.check(state.now <= 0.00035, "indivisible work can overshoot by at most one tested step") +state, timer, gc = setup() +state.completeAt = 2 +Budget.beginFrame(Performance, gc) +T.eq(Budget.idle(timer, 0.01), 2, "completed cycle does not immediately start another cycle") +state.running = false +T.eq(Budget.idle(timer, 0.01), 0, "a stopped collector is not restarted") +state.running = true +Budget._facadeHandled() +T.eq(Budget.idle(timer, 0.01), 0, "existing public GC delegation retains its frame") +Budget.beginFrame(Performance, gc) +T.check(Budget.idle(timer, 0.01) > 0, "next frame resets compatibility delegation ownership") +Performance.gcBudget = false +Budget.beginFrame(Performance, gc) +T.eq(state.pause, 200, "disable restores previous collector pause") +T.eq(state.mul, 200, "disable restores previous step multiplier") +T.eq(Budget.idle(timer, 0.01), 0, "disabled P2 does no idle collector work") +Performance.gcBudget = true +local caps = Performance.CAPS.low +local oldPause, oldMul, oldSeconds = caps.gcPause, caps.gcStepMul, caps.gcBudgetSeconds +caps.gcPause, caps.gcStepMul, caps.gcBudgetSeconds = 140, 250, 0.0002 +Performance.tier = "low" +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +T.eq(state.pause, 140, "tier-specific pause is respected") +T.eq(state.mul, 250, "tier-specific step multiplier is respected") +T.eq(Budget.idle(timer, 0.01), 2, "tier can reduce the idle budget") +caps.gcBudgetSeconds = 0.01 +Budget.beginFrame(Performance, gc) +before = state.steps +Budget.idle(timer, state.now + 0.1) +T.check(state.steps - before <= 6, "tier override cannot exceed the half-millisecond ceiling") +caps.gcPause, caps.gcStepMul, caps.gcBudgetSeconds = oldPause, oldMul, oldSeconds +Performance.tier = "high" +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +Cap.current, Cap.vsyncPacing = 60, false +local deadline = Cap.pace(timer, nil, false, 0, Budget.idle) +T.eq(state.steps, 5, "actual software pacing performs bounded GC before sleeping") +T.check(state.sleeps > 0, "software pacing sleeps after collection") +T.check(math.abs(deadline - 1 / 60) < 1e-9, "collection does not move the software deadline") +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +Cap.vsyncPacing = true +local window = { + getVSync = function() + return 1 + end, + getMode = function() + return 320, 180, { refreshrate = 60 } + end, +} +state.now = 1 / 60 - 0.0003 +Cap.pace(timer, window, true, 0, Budget.idle) +T.check(state.steps > 0 and state.steps <= 4, "VSync uses only actual remaining deadline time") +T.eq(state.sleeps, 0, "VSync receives no extra sleep after collection") +state, timer, gc = setup() +Budget.beginFrame(Performance, gc) +state.now = 1 / 60 + 0.001 +Cap.pace(timer, window, true, 0, Budget.idle) +T.eq(state.steps, 0, "late VSync present receives no invented idle budget") +Performance.gcBudget = false +Budget.configure(Performance, gc) +Performance.gcBudget = true +T.finish() diff --git a/tests/engine/gen1_connections.lua b/tests/engine/gen1_connections.lua new file mode 100644 index 000000000..69383ac33 --- /dev/null +++ b/tests/engine/gen1_connections.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local checks = dofile("tests/gba_layout/connections.lua")(require("src.core.game3.connections")) +T.check(checks > 12000, "unchanged connection consumer exercises parallel offsets and all edge/corner samples") +T.finish("Legacy connection compatibility (" .. checks .. " consumer checks)") diff --git a/tests/engine/gen1_layout_native.lua b/tests/engine/gen1_layout_native.lua new file mode 100644 index 000000000..11de70372 --- /dev/null +++ b/tests/engine/gen1_layout_native.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local checks = dofile("tests/gba_layout/consumer.lua")(require("src.core.game3.layout_native")) +T.check(checks > 240, "unchanged legacy layout consumer exercises sampling, writes, workers and invalidation") +T.finish("Gen1 decoded layout compatibility (" .. checks .. " consumer checks)") diff --git a/tests/engine/gen1_map_identity.lua b/tests/engine/gen1_map_identity.lua new file mode 100644 index 000000000..285cea7eb --- /dev/null +++ b/tests/engine/gen1_map_identity.lua @@ -0,0 +1,56 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Identity = require("src.compat.gen1.MapIdentity") +local Names = require("src.compat.gen1.MapNames") +local Version = require("src.core.GameVersion") +local Catalog = require("src.import.gba.map_catalog") +local prior = Version.get() +for _, edition in ipairs({ "firered", "leafgreen", "emerald", "ruby", "sapphire" }) do + Version.set(edition) + local maps, nativeKeys = {}, {} + for _, row in ipairs(Names[edition]) do + local key = ("NATIVE_%d_%d"):format(row[1], row[2]) + maps[key] = { id = key, group = row[1], number = row[2], untouched = {} } + nativeKeys[row[3]] = key + end + local binding = Identity.bind(maps, edition) + for _, row in ipairs(Names[edition]) do + local key, def = binding:nativeId(row[3]) + T.eq(key, nativeKeys[row[3]], "original name reaches actual native record") + T.eq(def, maps[key], "definition is borrowed with its exact identity") + T.eq(binding:legacyId(key), row[3], "native name returns original name") + if Version.get() == edition then + T.eq(Catalog.resolve(row[4]), row[3], "original mod import resolves pret alias") + else + T.eq(Catalog.isKnown(row[3]), false, "unavailable host edition has no active catalog") + end + T.eq(def.id, key, "native id was never rewritten") + end + local first = Names[edition][1] + local key = nativeKeys[first[3]] + local def = maps[key] + maps[key] = nil + T.eq(binding:nativeId(first[3]), nil, "removed native record retires its binding") + maps[key] = { id = key, group = first[1], number = first[2] } + T.eq(binding:nativeId(first[3]), nil, "replacement cannot inherit prior binding") + maps[key] = def + local duplicate = { id = "OTHER", group = def.group, number = def.number } + maps.OTHER = duplicate + local ambiguous = Identity.bind(maps, edition) + T.eq(ambiguous:nativeId(first[3]), nil, "ambiguous slot cannot choose a different room") + maps.OTHER = def + local aliases = Identity.bind(maps, edition) + T.eq(aliases:nativeId(first[3]), key, "same-object aliases prefer actual def.id") + T.eq(aliases:legacyId("OTHER"), first[3], "same-object alias has same legacy identity") + def.group = 255 + T.eq(aliases:nativeId(first[3]), nil, "changed slot retires prior binding") + T.eq(binding:nativeId("invented"), nil, "unknown mod map is not guessed") + T.eq(binding:legacyId("invented"), nil, "unknown native map is not guessed") +end +Version.set("gold") +T.eq(Catalog.isKnown("FR_PALLET_TOWN"), false, "other generations do not acquire Gen3 maps") +T.eq(Identity.lookup("firered", -1, 0), nil, "negative group is rejected") +T.eq(Identity.lookup("firered", 3.5, 0), nil, "fractional group is rejected") +T.eq(Identity.lookup("firered", 3, 256), nil, "out of range map number is rejected") +Version.set(prior) +T.finish("Gen1 native map identity compatibility") diff --git a/tests/engine/gen2_fishing_lifecycle.lua b/tests/engine/gen2_fishing_lifecycle.lua new file mode 100644 index 000000000..33e8dcace --- /dev/null +++ b/tests/engine/gen2_fishing_lifecycle.lua @@ -0,0 +1,316 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +local World=require('src.world.OverworldController') +local Fishing=require('src.world.Gen2Fishing') +local Pokemon=require('src.pokemon.Pokemon') +local Battle=require('src.battle.BattleState') +local Stack=require('src.core.StateStack') +local Map=require('src.world.Map') +local Player=require('src.world.Player') +local StartMenu=require('src.ui.StartMenu') +local BagMenu=require('src.ui.BagMenu') +local Input=require('src.core.Input') +local before=Version.get();local random=love.math.random +local slot,previousGame +for i=1,100 do local name,value=debug.getupvalue(World.useRod,i) + if not name then break end;if name=='Game'then slot,previousGame=i,value;break end +end +assert(slot) +local draws,creations=0,0 +local new=Pokemon.new +Pokemon.new=function(...)creations=creations+1;return new(...)end +local function fixture(edition) + Fishing.release();Version.set(edition or 'crystal') + love.math.random=random + local data=T.fixtures.fresh();data.constants.gen=2 + data.field.fishGroups={POND={chance=128,rods={ + old={{chance=255,species='FIXMON_B',level=7}}, + good={{chance=255,species='FIXMON_B',level=17}}, + super={{chance=255,species='FIXMON_B',level=27}}, + }}} + data.items.ITEM_058={index=58,key='OLD_ROD',name='OLD ROD',keyItem=true,pocket='KEY_ITEM',registerable=true} + local game=T.link.fakeGame(data,'FIXMON_A',{level=30}) + T.link.prepare(data) + local stack=setmetatable({}, {__index=Stack});stack:init();game.stack=stack + local ts={id='FISH',collision={},walkable={0},waterTiles={0x29},blocks={}} + for i=1,40 do ts.collision[i]=0x29 end + local def={id='NATIVE_FISH',tileset='FISH',width=3,height=3,blocks={1,2,3,4,5,6,7,8,9},generation=2,fishGroup='POND',connections={}} + data.maps[def.id]=def;data.tilesets.FISH=ts + local p=setmetatable({cellX=2,cellY=2,px=32,py=32,facing='right'},{__index=Player}) + local w=setmetatable({isOverworld=true,map=Map.new(def,ts),player=p,scriptMoves={},pendingScripts={},runner={isRunning=function()return false end}},{__index=World}) + game.overworld=w;game.world=w;game.phase='play';stack.states={w} + function w:pushBattle(battle)game.lastBattle=battle end -- device transition only + debug.setupvalue(World.useRod,slot,game) + draws,creations=0,0 + love.math.random=function(a,b) + draws=draws+1 + if b==nil then return a and 1 or .1 end + return a + end + return w,game,data,def,ts +end +local function closeText(game) + local box=game.stack:top();T.check(box~=nil,'native verdict textbox exists') + for _=1,600 do + if game.stack:top()~=box then break end + Input.pressed={a=true};game.stack:update(1/60) + end + Input.pressed={} + T.neq(game.stack:top(),box,'ordinary native textbox input completes the verdict') +end +local function verdict(w) + for _=1,83 do w:updateFishing()end +end +for _,edition in ipairs({'gold','silver','crystal','prism'})do + for _,rod in ipairs({'OLD_ROD','GOOD_ROD','SUPER_ROD','ITEM_058','ITEM_059','ITEM_061'})do + local w,g=fixture(edition) + local begin=w.beginFishing;local calls,observed=0 + w.beginFishing=function(self,outcome,wild) + calls=calls+1;observed=wild;return begin(self,outcome,wild) + end + T.eq(w:goFishing(rod),'battle','ordinary native rod path enters the public lifecycle') + local st=w.fishing;T.eq(calls,1,'one synchronous public cast admission') + T.eq(st.wild,observed,'presentation sees the actual rolled individual') + T.eq(st.wild.species,'FIXMON_B','ROM group chooses the encounter') + T.eq(creations,1,'one native Pokemon created for the cast') + T.eq(pcall(Battle.newWild,g,'FIXMON_B',st.wild.level,{nativeFishingMon={species='FIXMON_B',level=st.wild.level}}),false,'fabricated no-reroll admission is refused') + T.eq(w.player.fishingState,st,'player and world share the same native state') + T.eq(st.bobber.cellX,3);T.eq(st.bobber.cellY,2);T.eq(st.bobber.px,48) + T.eq(w:useRod(rod),'nowhere','active cast refuses reentry without another roll') + T.eq(creations,1) + for _=1,40 do w:updateFishing()end + T.eq(st.phase,'cast');T.eq(st.timer,0,'cast uses native frame ticks') + w:updateFishing();T.eq(st.phase,'bite');T.eq(st.timer,40) + w:updateFishing() + local _,_,biteY=w.player:pose() + T.eq(biteY,w.player.py-1,'native player pose applies the bite jerk') + local bubble=w.emote;T.eq(bubble.npc,w.player,'native bite emote belongs to the player') + for _=1,39 do w:updateFishing()end + T.eq(st.timer,0);T.eq(st.phase,'bite') + w:updateFishing();T.eq(st.phase,'done');T.eq(g.lastBattle,nil,'verdict owns the field before battle') + closeText(g) + T.eq(w.fishing,nil);T.eq(w.player.fishingState,nil);T.eq(w.player.fishing,nil) + T.eq(w.emote,nil,'only the cast-owned bite emote is released') + T.eq(g.lastBattle.enemy.mon,observed,'battle receives the identical individual, DVs, moves and stats') + T.eq(creations,1,'battle constructor does not generate a second Pokemon') + T.eq(pcall(Fishing.takePrepared,g,observed,observed.species,observed.level),false,'prepared handoff is single use') + end +end +-- Distinct cart outcomes: no target, no fish group, genuine no bite. +for _,kind in ipairs({'dry','surf','nofish','nibble'})do + local w,g,data,def,ts=fixture() + if kind=='dry'then for i in pairs(ts.collision)do ts.collision[i]=0 end end + if kind=='surf'then w.player.surfing=true end + if kind=='nofish'then def.fishGroup=0 end + if kind=='nibble'then data.field.fishGroups.POND.chance=0 end + local outcome=w:useRod('ITEM_058') + T.eq(outcome,(kind=='dry'or kind=='surf')and'nowhere'or kind) + T.eq(creations,0,'empty/refused casts never generate a Pokemon') + if outcome=='nowhere'then T.eq(w.fishing,nil);T.eq(draws,0) + else verdict(w);closeText(g);T.eq(w.fishing,nil);T.eq(g.lastBattle,nil)end +end +-- Drive the actual upstream START -> bag -> USE sequence. Its Menu exits +-- START before opening the bag, unlike the fork's native Gen2 PACK widget. +for _,wet in ipairs({false,true})do + local w,g,data,def,ts=fixture() + if not wet then for i in pairs(ts.collision)do ts.collision[i]=0 end end + g.save.inventory.ITEM_058=1 + local start=StartMenu.new(g);g.stack:push(start) + local at;for i,row in ipairs(start.items)do if row.label=='ITEM'then at=i end end + T.check(at~=nil,'actual upstream START offers its native bag') + start.index=at;start:choose() + local pack=g.stack:top();T.neq(pack,start,'native START hands input to its bag') + -- Move to KEY ITEMS through the bag's real pocket input. + for _=1,2 do Input.pressed={right=true};g.stack:update(1/60)end + Input.pressed={} + local row;for i,item in ipairs(pack.items)do if item.value=='ITEM_058'then pack.index=i;row=item end end + T.check(row~=nil,'native pocket contains the held rod') + pack:choose();local submenu=g.stack:top() + T.neq(submenu,pack,'actual bag opens the USE submenu') + local use=w.useRod;local calls=0;w.useRod=function(self,rod)calls=calls+1;return use(self,rod)end + submenu:choose() + T.eq(calls,wet and 1 or 0,'bag uses the public rod only after native admission') + if wet then + T.eq(g.stack:top(),w,'successful rod returns to the actual native field');T.check(w.fishing~=nil) + else + local retained=false;for _,state in ipairs(g.stack.states)do if state==pack then retained=true end end + T.eq(retained,true,'refused rod preserves its native bag');T.eq(draws,0) + end + T.eq(g.save.inventory.ITEM_058,1,'rods are never consumed') +end + +do + local w,g=fixture();g.save.inventory.ITEM_058=1;g.save.registeredItem='ITEM_058' + local use=w.useRod;local calls=0;w.useRod=function(self,rod)calls=calls+1;return use(self,rod)end + T.eq(BagMenu.useRegistered(g),true);T.eq(calls,1,'SELECT enters the same public lifecycle') + T.check(w.fishing~=nil);T.eq(g.save.inventory.ITEM_058,1) +end +for _,key in ipairs({'battleActive','transitioning','emote','mapSetup','headbutt','moveState','iceSlide','_modScriptLock'})do + local w=fixture();w[key]=true + T.eq(w:useRod('OLD_ROD'),'nowhere',key..' retains native input ownership');T.eq(draws,0) +end +for _,key in ipairs({'moving','surfing','inputLocked','fieldMove','stairExit','spinning'})do + local w=fixture();w.player[key]=true + T.eq(w:useRod('OLD_ROD'),'nowhere',key..' retains actor ownership');T.eq(draws,0) +end +for _,change in ipairs({'save','data','map','player','position','edition','removedText','worldRetired'})do + local w,g=fixture();T.eq(w:useRod('OLD_ROD'),'battle');local st=w.fishing;local old=w.player + if change=='removedText'then verdict(w);g.stack:pop() + elseif change=='worldRetired'then g.stack:pop() + elseif change=='save'then g.save={} + elseif change=='data'then g.data={} + elseif change=='map'then w.map={} + elseif change=='player'then w.player={cellX=2,cellY=2,facing='right'} + elseif change=='position'then old.cellX=3 + else Version.set('gold')end + require('src.core.game3.asset_stream').poll();T.eq(w.fishing,nil,change..' retires the cast through native services');T.eq(old.fishingState,nil) + T.eq(pcall(Fishing.takePrepared,g,st.wild,st.wild.species,st.wild.level),false,'retired individual cannot enter battle') +end +do + local w,g=fixture();w:useRod('OLD_ROD');verdict(w);local box=g.stack:top();local replacement={name='new owner'} + w.emote=replacement;Fishing.cancel(w);closeText(g) + T.eq(g.lastBattle,nil,'cancelled verdict cannot start a stale battle');T.eq(w.emote,replacement,'cleanup preserves another emote owner') +end +do + local w,g,data=fixture();data.items.ITEM_058.index=1 + T.eq(World.isRod('ITEM_058',data.items),false,'conflicting ROM item metadata is not treated as a rod') + T.eq(w:useRod('ITEM_058'),'nowhere');T.eq(draws,0) + T.eq(w:useRod('BICYCLE'),'nowhere');T.eq(draws,0) +end +for _,night in ipairs({false,true})do + local w,g,data=fixture();w.clockTimeOfDay=function()return night and'NITE'or'DAY'end + data.field.fishGroups.POND.rods.old={{chance=255,timeGroup=1}} + data.field.timeFishGroups={[1]={day={species='FIXMON_A',level=8},nite={species='FIXMON_B',level=9}}} + local outcome,wild=w:rollFishing('OLD_ROD') + T.eq(outcome,'battle');T.eq(wild.species,night and'FIXMON_B'or'FIXMON_A');T.eq(wild.level,night and 9 or 8) + T.eq(w.fishing,nil,'pure native roll does not start animation or battle') + local st=w:beginFishing(outcome,wild);T.eq(st.wild,wild,'public cast accepts its exact native roll') +end +for _,offset in ipairs({1,2})do + local w,g,data,def,ts=fixture();w.player.cellX=5 + local dest={id='FISH_DEST',tileset='FISH',width=3,height=3,blocks={10,11,12,13,14,15,16,17,18},fishGroup='OTHER'} + data.maps[dest.id]=dest;def.connections.east={map=dest.id,offset=offset} + for i=41,80 do ts.collision[i]=0x29 end + local outcome=w:useRod('OLD_ROD') + T.eq(outcome,offset==1 and'battle'or'nowhere','native un-clamped seam selects the actual facing-water tile') + if offset==1 then + T.eq(w.fishing.bobber.cellX,6,'bobber stays in current-map coordinates at a seam') + T.eq(w.fishing.wild.level,7,'fishing keeps the current map fish group') + else T.eq(draws,0,'non-overlapping native connection strip never rolls')end + T.eq(w.map.id,def.id,'fishing query cannot load or switch the current map') +end +for _,covered in ipairs({false,true})do + local w,g,data,def,ts=fixture();w.player.cellX=5 + local dest={id='FISH_DEST',tileset='FISH',width=3,height=3,blocks={10,11,12,13,14,15,16,17,18}} + data.maps[dest.id]=dest;def.connections.east={map=dest.id,offset=1} + for i=41,80 do ts.collision[i]=0x29 end + g.save.inventory.ITEM_058=1;g.save.registeredItem='ITEM_058' + local foreign={name='unrelated screen'};local foreignSlot=#g.stack.states+1;if covered then g.stack:push(foreign)end + local use=w.useRod;local calls=0;w.useRod=function(self,rod)calls=calls+1;return use(self,rod)end + BagMenu.useRegistered(g) + T.eq(calls,covered and 0 or 1,'registered rod uses native boundary water and respects overlays') + if covered then + T.eq(g.stack.states[foreignSlot],foreign,'registered item cannot close another screen');T.eq(w.fishing,nil);T.eq(draws,0) + else T.eq(w.fishing.bobber.cellX,6);T.eq(w.fishing.wild.level,7)end + T.eq(g.save.inventory.ITEM_058,1) +end +do + local w=fixture();local roll=w.rollFishing;local calls=0 + w.rollFishing=function(self,rod) + calls=calls+1;T.eq(self:useRod(rod),'nowhere','public roll wrapper cannot reenter rod admission') + return roll(self,rod) + end + T.eq(w:useRod('OLD_ROD'),'battle');T.eq(calls,1,'actual rod uses the public roll exactly once') +end +do + local w=fixture();local begin=w.beginFishing + w.beginFishing=function(self,...)begin(self,...)end + T.eq(w:useRod('OLD_ROD'),'battle','an observing wrapper may preserve the original void-return convention') + T.check(w.fishing~=nil) +end +do + local w,g=fixture() + T.eq(w:tryFishing('OLD_ROD'),'battle','legacy immediate encounter uses the same native roll') + T.eq(w.fishing,nil,'immediate legacy path does not create a competing cast') + T.eq(g.lastBattle.enemy.mon.species,'FIXMON_B');T.eq(creations,1) +end +do + local w,g=fixture();local native=Battle.newWild(g,'FIXMON_B',7) + T.eq(native.enemy.mon.species,'FIXMON_B');T.eq(creations,1,'ordinary native battles still construct their own Pokemon') + local outcome,wild=w:rollFishing('OLD_ROD');local count=creations + g.save={} + T.eq(pcall(Battle.newWild,g,wild.species,wild.level,{nativeFishingMon=wild}),false,'expired pure roll cannot bypass cohort admission') + T.eq(creations,count,'invalid handoff fails before Pokemon/RNG construction') +end +for _,where in ipairs({'roll','begin','text'})do + local w,g=fixture();local original + if where=='roll'then + original=w.rollFishing;w.rollFishing=function()error('intentional fishing roll failure')end + elseif where=='begin'then + original=w.beginFishing;w.beginFishing=function(self,...)original(self,...);error('intentional fishing cast failure')end + else + w:useRod('OLD_ROD');local TextBox=require('src.render.TextBox');original=TextBox.new + TextBox.new=function()error('intentional fishing textbox failure')end + end + local ok + if where=='text'then ok=pcall(verdict,w);require('src.render.TextBox').new=original + else ok=pcall(w.useRod,w,'OLD_ROD');w[where=='roll'and'rollFishing'or'beginFishing']=original end + T.eq(ok,false,'native '..where..' errors remain visible') + T.eq(w.fishing,nil);T.eq(w.player.fishingState,nil);T.eq(w.player.fishing,nil) + T.eq(w:useRod('OLD_ROD'),'battle','failed admission releases its lease for the next real action') +end +do + local w,g=fixture() + g.stack.worldOwner=nil;g.stack.worldState=nil;g.stack.states={w} + T.eq(w:useRod('OLD_ROD'),'battle','native lifecycle also follows the screen-owned field contract') + local player=w.player;g.stack.states={} + Fishing.poll();T.eq(w.fishing,nil,'screen-owned field retirement releases the cast') + T.eq(player.fishingState,nil) +end +do + local w,g=fixture();w:useRod('OLD_ROD');w._beginFishingBattle=function()return {enemy={mon={}}}end + verdict(w) + local box=g.stack:top();box.done=true;box.waiting=false;Input.pressed={a=true} + T.eq(pcall(g.stack.update,g.stack,1/60),false,'a fake battle callback cannot report a native handoff') + T.eq(w.fishing,nil);T.eq(w.player.fishingState,nil) + Input.pressed={} +end +for _,edition in ipairs({'red','emerald','firered','platinum','black'})do + local w=fixture(edition);T.eq(w:useRod('OLD_ROD'),'nowhere','Gen2 public path cannot alter other generations');T.eq(draws,0) +end +do + local w,g,data=fixture() + data.field.fishSwarms={routes={POND={kind='native_swarm',group='SWARM'}}} + data.field.fishGroups.SWARM={chance=128,rods={old={{chance=255,species='FIXMON_A',level=12}}}} + g.save.g2FishSwarm='native_swarm' + local loaded=T.sdk.loadMods({'mods/fishing_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/fishing_probe/manifest.json']='{"id":"fishing_probe","name":"Fishing probe","version":"1.0.0","api":2,"entry":"main.lua"}', + ['mods/fishing_probe/main.lua']=[[ + local mod=...;mod.exports.calls=0;mod.exports.reply={species='FIXMON_B',level=19} + mod.hooks:wrap('encounter.fishing',function(nextFn,rod,map,candidates,ctx) + mod.exports.calls=mod.exports.calls+1;mod.exports.group=ctx.fishGroup + mod.exports.candidates=candidates;mod.exports.rod=rod;mod.exports.map=map + local rolled=nextFn(rod,map,candidates,ctx);mod.exports.native=rolled + return mod.exports.reply + end) + ]], + })}) + T.eq(#loaded.errors,0,'actual public fishing hook provider installs') + local outcome,wild=w:rollFishing('OLD_ROD');local observed=loaded.loader.exports.fishing_probe + T.eq(outcome,'battle');T.eq(observed.calls,1);T.eq(observed.group,'SWARM') + T.eq(observed.candidates,data.field.fishGroups.SWARM,'hook observes the actual native swarm group') + T.eq(observed.native.species,'FIXMON_A');T.eq(observed.native.level,12) + T.eq(wild.species,'FIXMON_B');T.eq(wild.level,19,'hook replacement becomes the actual native individual') + T.eq(creations,1,'hook never creates a second display Pokemon') + for _,reply in ipairs({{species='UNKNOWN_NATIVE_FISH',level=7},{species='FIXMON_B',level=0}, + {species='FIXMON_B',level=101},{species='FIXMON_B',level=1.5},{level=7}})do + observed.reply=reply;draws=0 + T.eq(pcall(w.useRod,w,'OLD_ROD'),false,'invalid hook encounter cannot acquire a cast') + T.eq(draws,2,'invalid encounter fails before drawing native Pokemon DVs') + T.eq(w.fishing,nil);T.eq(w.player.fishingState,nil) + end + loaded.release() +end +Fishing.release();Pokemon.new=new;love.math.random=random;debug.setupvalue(World.useRod,slot,previousGame);Version.set(before) +T.finish('native Gen2 fishing lifecycle and exact battle individual') diff --git a/tests/engine/gen3_modal_shared.lua b/tests/engine/gen3_modal_shared.lua new file mode 100644 index 000000000..303dcc2bc --- /dev/null +++ b/tests/engine/gen3_modal_shared.lua @@ -0,0 +1,16 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local prior=Version.get() +local Game=require('src.core.Game');local Native=require('src.core.StateStack') +local before={stack=Game.stack,save=Game.save,session=Game.sessionStartedAt,ready=Game.nativeStackReady} +local consumer=dofile('tests/gba_modal/consumer.lua') +for _,version in ipairs{'firered','emerald'}do + Version.set(version);Game.stack=setmetatable({},{__index=Native});Game.stack:init() + Game.save={};Game.sessionStartedAt=1;Game.nativeStackReady=true + local world={isOverworld=true};Game.stack:push(world) + consumer(T,require('src.ui.game3.stack')) + T.eq(Game.stack:top(),world,'shared native consumer preserves '..version..' world') +end +Game.stack,Game.save,Game.sessionStartedAt,Game.nativeStackReady=before.stack,before.save,before.session,before.ready +Version.set(prior) +T.finish('shared native GBA modal consumer') diff --git a/tests/engine/gen3_modal_stack.lua b/tests/engine/gen3_modal_stack.lua new file mode 100644 index 000000000..142d52230 --- /dev/null +++ b/tests/engine/gen3_modal_stack.lua @@ -0,0 +1,165 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion');local priorVersion=Version.get() +local Native=require('src.core.StateStack');local Plane=require('src.ui.ModalStack') +local Game=require('src.core.Game');local G3=require('src.ui.game3.stack') +local Save=require('src.core.SaveData');local Pokemon=require('src.pokemon.Pokemon') +local Battle=require('src.battle.BattleState');local Screens=require('src.ui.Screens') +local before={stack=Game.stack,save=Game.save,data=Game.data,input=Game.input,session=Game.sessionStartedAt,ready=Game.nativeStackReady} +local function setup(version) + Version.set(version);local stack=setmetatable({},{__index=Native});stack:init() + local data=T.fixtures.fresh();data.constants.gen=3;require('src.render.Font').load(data) + local save=Save.newGame();save.party={Pokemon.new(data,'FIXMON_A',30)} + Game.stack,Game.data,Game.save,Game.sessionStartedAt=stack,data,save,1 + Game.nativeStackReady=true + Game.input=setmetatable({},{__index=require('src.core.Input')});Game.input:init() + return stack,Plane.forGame(Game) +end +for _,version in ipairs{'firered','emerald'}do + local stack,ui=setup(version);local updates,draws,exits,enters=0,{},0,0 + local world={isOverworld=true,isOpaque=true,map={id='FIX_TOWN'},update=function()updates=updates+1 end, + draw=function()draws[#draws+1]='world'end} + stack:push(world) + T.eq(ui.depth(),0,version..' world is not a modal') + T.eq(G3.busy(),false,'public GBA spelling sees actual empty modal plane') + T.eq(stack:top(),world,'native world/input owner retained') + local battle=Battle.newWild(Game,'FIXMON_C',23);stack:push(battle) + T.eq(ui.top(),nil,'actual native battle controller is not a menu') + T.eq(stack:top(),battle,'real battle remains native execution root') + stack:pop();T.eq(stack:top(),world,'native battle pop restores same world') + local code=[=[local mod=... + mod.content.screens:register('NATIVE_PLANE_TEST',{new=function(game,payload) + return{game=game,payload=payload,isOpaque=payload.opaque, + enter=function()mod.exports.entered=(mod.exports.entered or 0)+1 end, + exit=function()mod.exports.exited=(mod.exports.exited or 0)+1 end, + update=function(self)if game.input:wasPressed('b')then game.stack:pop()end end, + draw=function(self)mod.exports.draw(self.payload.name)end} + end}) + mod.exports.open=function(game,payload)return mod.ui.push(game,'NATIVE_PLANE_TEST',payload)end + mod.exports.draw=function()end + mod.exports.pushed=0;mod.exports.popped=0 + mod.events:on('screen.pushed',function()mod.exports.pushed=mod.exports.pushed+1 end) + mod.events:on('screen.popped',function()mod.exports.popped=mod.exports.popped+1 end) + ]=] + local Json=require('src.link.Json');local files={['mods/modal/manifest.json']=Json.encode{id='modal',name='modal',version='1.0.0',api=3,entry='main.lua'},['mods/modal/main.lua']=code} + local f=T.sdk.loadMod('mods/modal',{fs=T.sdk.memfs(files),data=Game.data,dev=false});f.loader.game=Game + T.eq(#f.errors,0,'real API3 screen factory loads');local e=f.loader.exports.modal + e.draw=function(name)draws[#draws+1]=name end + local first=e.open(Game,{name='first',opaque=false});local firstLayer=G3.top() + T.eq(firstLayer.mod.state,first,'native mod UI layer exposes actual screen, not a copied controller') + T.check(firstLayer.id:match('^modui:'),'registered native screen has stable modui layer id') + T.eq(firstLayer.id,G3.top().id,'layer identity stable across reads') + T.eq(stack:top(),first,'actual StateStack owns same registered screen') + local second=e.open(Game,{name='second',opaque=false});local secondLayer=G3.top() + T.neq(secondLayer.id,firstLayer.id,'nested same-factory screens have distinct native identities') + T.eq(e.entered,2,'native enter once per screen');T.eq(e.pushed,2,'native push event once per screen') + draws={};stack:draw();T.same(draws,{'world','first','second'},'transparent native layers preserve real world') + secondLayer.hideBelow=true;draws={};stack:draw();T.same(draws,{'world','second'},'hideBelow hides other UI without hiding world') + secondLayer.drawUnder=true;draws={};stack:draw();T.same(draws,{'world','first','second'},'drawUnder restores lower UI') + secondLayer.fullscreen=function()return true end;draws={};stack:draw();T.same(draws,{'first','second'},'actual fullscreen predicate suppresses world draw') + T.eq(ui.fullscreen(),true,'same fullscreen policy through facade') + local hookCalls=0 + local off=f.loader.hooks:wrap('screen.render_visible',function(next,state) + hookCalls=hookCalls+1;if state==second then return false end;return next(state) + end,0,'modal') + draws={};stack:draw();T.same(draws,{'world','first'},'mirrored fullscreen menu reveals native scene on main surface') + T.eq(hookCalls,3,'render plan evaluates each native visibility hook once per draw') + T.eq(stack:top(),second,'render mirroring preserves native input ownership') + T.eq(ui.fullscreen(),true,'logic fullscreen state is independent of surface mirroring');off() + secondLayer.fullscreen=false + local leased=ui._layers;ui._layers={} + T.eq(stack:top(),world,'preview suspension leaves actual world controller') + T.eq(e.exited,nil,'suspension never exits native menus') + T.eq(e.popped,0,'suspension emits no fake pop') + T.eq(#leased,2,'leased rows survive suspension') + ui.push('preview',{update=function()updates=updates+10 end,draw=function()draws[#draws+1]='preview'end}) + stack:update(1/60);T.eq(updates,10,'preview is updated by native stack once') + T.eq(ui.pop('preview'),true,'native preview closes') + ui._layers=leased + T.eq(stack:top(),second,'restoration resumes same native screen') + T.eq(e.entered,2,'restoration never re-enters native menu') + T.eq(e.pushed,3,'only actual preview push emitted a new event') + local retiredLayer=leased[2] + Game.input.pressed={b=true};stack:update(1/60);Game.input.pressed={} + T.eq(stack:top(),first,'real native input closes second screen') + T.eq(e.exited,1,'native exit once after ordinary pop');T.eq(e.popped,2,'ordinary and preview pop events exact') + T.raises(function()ui._layers={firstLayer,retiredLayer}end,'retired','retired menu cannot be resurrected') + local live=ui._layers;ui._layers={};world.map={id='FIX_ROUTE'} + T.raises(function()ui._layers=live end,'another scene','old map preview cannot restore into a different scene') + T.raises(function()ui._layers={live[1]}end,'another scene','copied layer rows cannot bypass native scene ownership') + world.map={id='FIX_TOWN'} -- retain real identity below, not the same spelling + T.raises(function()ui._layers=live end,'another scene','equal map spelling does not forge scene ownership') + -- Restore only after returning to the exact original native map object. + -- Existing first screen is suspended; a fresh fixture below covers session + -- invalidation without pretending this map replacement restored its lease. + f.release() + local stale=ui;Game.save=Save.newGame() + T.raises(function()stale.depth()end,'retired','save replacement invalidates old plane') + stack:init();Game.save=Save.newGame();Game.sessionStartedAt=2 + T.raises(function()stale.clear()end,'retired','native reinitialization invalidates prior plane') +end +-- Targeted removal, atomic clear/reentry, legacy input ownership, validation. +do + local stack,ui=setup('firered');local world={isOverworld=true,isOpaque=true};stack:push(world) + local calls={};ui.push('one',{isMenu=true,handleInput=function(input)T.eq(input,Game.input,'native input object');calls[#calls+1]='input';ui.pop('one')end,update=function()calls[#calls+1]='stale'end}) + local width,height=stack:top():uiSize() + T.eq(width,240,'legacy GBA menu requests native width');T.eq(height,160,'legacy GBA menu requests native height') + T.eq(Game.nativeSurfaceInStack(stack),stack:top(),'legacy menu owns the real native surface') + stack:update(1/60);T.same(calls,{'input'},'a popped input owner never updates afterward') + ui.push('one',{});ui.push('two',{});T.eq(ui.pop('one'),true,'named removal reaches lower native UI') + T.eq(ui.top().id,'two','named removal preserves actual top');T.eq(ui.depth(),1,'only named UI removed') + ui.push('two',{});T.eq(ui.depth(),1,'reopening native id replaces prior layer') + ui.clear();T.eq(stack:top(),world,'clear preserves core world') + local closed=0 + local a={screenId='A',exit=function()closed=closed+1;ui.push('after-clear',{})end} + local b={screenId='B',exit=function()closed=closed+1 end} + stack:push(a);stack:push(b);ui.clear() + T.eq(closed,2,'native clear exits every captured screen exactly once') + T.eq(ui.top().id,'after-clear','new UI created during exit survives atomic clear') + ui.clear();local leased + ui.push('safe',{});leased=ui._layers;ui._layers={} + T.raises(function()ui._layers={leased[1],leased[1]}end,'duplicate','duplicate rows rejected before mutation') + T.eq(stack:top(),world,'rejected layer list cannot remove native root') + T.raises(function()ui._layers={[2]=leased[1]}end,'dense','holes rejected before native mutation') + local other=setmetatable({},{__index=Native});other:init() + local foreign=Plane.forGame{stack=other};foreign.push('foreign',{}) + T.raises(function()ui._layers=foreign._layers end,'another scene','foreign layer owner rejected') + ui._layers=leased;T.eq(ui.has('safe'),true,'valid owned lease still restores') + ui.clear();local bad={exit=function()error('intentional native exit failure')end};local good=0 + stack:push({exit=function()good=good+1 end});stack:push(bad) + T.raises(function()ui.clear()end,'intentional','native clear reports callback failure') + T.eq(good,1,'remaining native exits run after another exit fails');T.eq(stack:top(),world,'failed exit cannot strand removed UI') + local TextBox=require('src.render.TextBox');local completed=0 + local stay=TextBox.new(Game,'Native stay',function()completed=completed+1 end,{stay={}}) + stack:push(stay);ui.push('transparent',{}, {hideBelow=false}) + T.check(stack.states[2]==stay,'transparent UI retains actual native stay text') + ui.pop();ui.push('covers',{}) + T.eq(stack.states[2].nativeModalId,'covers','covering UI closes actual native stay text') + T.eq(completed,0,'closing stay text does not complete its script callback') + ui.clear();local prompt=TextBox.new(Game,'Native prompt',function()completed=completed+1 end) + stack:push(prompt);ui.push('prompt-cover',{}) + T.eq(stack.states[2],prompt,'ordinary native prompt is never auto-closed') + ui.clear();T.eq(completed,0,'modal cleanup does not fake script completion') + local old=ui;Game.nativeStackReady=false + T.eq(G3.top(),nil,'reload cannot expose previous game UI') + T.eq(G3.depth(),0,'reload has no advertised active modal plane') + T.raises(function()G3.push('bad',{})end,'live Gen3','reload cannot push into old stack') + T.raises(function()old.clear()end,'retired','old plane cannot close UI during reload') + Game.nativeStackReady=true + local Runtime=require('src.mods.Runtime');local Events=require('src.mods.Events');local Hooks=require('src.mods.Hooks') + local oldEvents,oldHooks,oldErrors=Runtime.events,Runtime.hooks,Runtime.errors + local staleCalls,liveCalls=0,0;Runtime.currentMod='fixture-modal' + G3.onClear(function()staleCalls=staleCalls+1 end);Runtime.currentMod=nil + Runtime.install(Events.new(),Hooks.new(),{}) + local off=G3.onClear(function()liveCalls=liveCalls+1 end) + G3.clear();T.eq(staleCalls,0,'retired loader hooks do not run in next session') + T.eq(liveCalls,1,'current clear observer runs once');off();off();G3.clear() + T.eq(liveCalls,1,'clear observer unsubscription is idempotent') + Runtime.install(oldEvents,oldHooks,oldErrors) + Version.set('crystal');T.eq(G3.top(),nil,'GBA modal API does not claim a Gen2 owner') + T.raises(function()G3.push('wrong',{})end,'live Gen3','Gen2 cannot mutate its stack through GBA API') +end +Game.stack,Game.save,Game.data,Game.input,Game.sessionStartedAt=before.stack,before.save,before.data,before.input,before.session +Game.nativeStackReady=before.ready +Version.set(priorVersion);Screens.invalidate() +T.finish('native GBA modal ownership and lifecycle') diff --git a/tests/engine/gen3_money_command_widths.lua b/tests/engine/gen3_money_command_widths.lua new file mode 100644 index 000000000..c2bf2a74e --- /dev/null +++ b/tests/engine/gen3_money_command_widths.lua @@ -0,0 +1 @@ +dofile('tools/script-command-widths/check.lua') diff --git a/tests/engine/gen3_palette_frame_cache.lua b/tests/engine/gen3_palette_frame_cache.lua new file mode 100644 index 000000000..6fca0262d --- /dev/null +++ b/tests/engine/gen3_palette_frame_cache.lua @@ -0,0 +1,48 @@ +package.path="./?.lua;./?/init.lua;"..package.path +local T=require("tests.harness") +local eq,check=T.eq,T.check +local FrameCache=require("src.render.Gen3PaletteFrameCache") +local released={} +local function value(name) + local data={name=name} + function data:release()released[name]=(released[name] or 0)+1 end + return {data} +end +local cache=FrameCache.new(100,2) +local a,b={},{} +local a1,a2,a3,b1=value('a1'),value('a2'),value('a3'),value('b1') +check(cache:put(a,'one',a1,30),'first owner stores picture') +check(cache:put(a,'two',a2,30),'owner stores second picture') +eq(cache:get(a,'one'),a1,'return visits update LRU') +check(cache:put(a,'three',a3,30),'per-owner limit evicts oldest') +eq(released.a2,1,'cold owner picture released once') +eq(cache:get(a,'two'),nil,'evicted picture unavailable') +check(cache:put(b,'one',b1,50),'global byte pressure stores another owner') +eq(released.a1,1,'global eviction follows actual LRU') +eq(cache:get(b,'one'),b1,'owner namespaces separate equal keys') +eq(cache:stats().bytes,80,'total byte accounting exact') +local big=value('oversize') +check(not cache:put(a,'large',big,101),'oversize picture cannot evict everything or exceed budget') +eq(released.oversize,nil,'rejected picture remains caller-owned') +FrameCache.release(big);eq(released.oversize,1,'caller can release rejected picture') +cache:drop(a);eq(released.a3,1,'owner release frees only its own pictures') +eq(cache:get(b,'one'),b1,'other owner survives release') +cache:clear();eq(released.b1,1,'global reset frees remaining picture') +eq(cache:stats().bytes,0,'global reset removes byte accounting') +eq(cache:stats().entries,0,'global reset removes entries') +cache:clear();cache:drop(a);eq(released.b1,1,'repeated reset never double-releases') +local prior=value('prior');local replacement=value('replacement') +cache:put(a,'same',prior,25);cache:put(a,'same',replacement,40) +eq(released.prior,1,'replacing picture releases old storage') +eq(cache:stats().bytes,40,'replacement size is accounted') +cache:clear();eq(released.replacement,1,'replacement released on reset') +for i=1,200 do + local owner=i%2==0 and a or b + local picture=value('cycle-'..i) + cache:put(owner,tostring(i%7),picture,25) + check(cache:stats().bytes<=100,'many cycles remain within global budget') + check(cache:stats().entries<=4,'many cycles remain within both owner limits') +end +cache:clear() +for i=1,200 do eq(released['cycle-'..i],1,'every retained picture released exactly once') end +T.finish() diff --git a/tests/engine/gen3_palette_program_failures.lua b/tests/engine/gen3_palette_program_failures.lua new file mode 100644 index 000000000..fb59eb3b1 --- /dev/null +++ b/tests/engine/gen3_palette_program_failures.lua @@ -0,0 +1,97 @@ +package.path="./?.lua;./?/init.lua;"..package.path +local T=require("tests.modkit") +local F=require("tests.palette_programs.fixture") +local M=require("src.import.Gen3PalettePrograms") +local Copy=require("src.mods.Merge").deepCopy +local Verify=require("src.import.RomVerification") +local Extractor=require("src.import.RomExtractorGen3") +for _,game in ipairs({"emerald","firered"}) do + local f=F.new(game) + local original=Copy(f.sets) + local manifest=Copy(f.manifest) + local bytes=f.rom.data + local function reset() + f.sets=Copy(original);f.manifest=Copy(manifest) + f.rom=require("src.import.RomGba").new(bytes) + end + local first + for k,v in pairs(original) do if v.callback~=0 and k~="unknown" and k~="null_callback" then first=k;break end end + local function reject(change) + reset();change(f.sets[first],f) + f.sets[first].paletteCycles={{stale=true}} + local ok,report=pcall(M.extract,f.rom,f.sets,f.manifest,f.manifest.romSha1) + T.eq(ok,true,"bad program cannot abort the import") + T.check(report and report.unavailable>0,"bad program explicitly counted unavailable") + T.eq(f.sets[first].paletteCycles,nil,"bad program cannot retain previous successful cycles") + T.eq(f.sets[first].paletteProgram.status,"unavailable","record contains failure diagnostic") + end + for _,bad in ipairs({false,"1",-1,0.5,math.huge,0/0,0x08000000,0x08002000,0x0A000001}) do + reject(function(r) r.callback=bad end) + end + reject(function(r) r.callback=nil end) + reject(function(r) r.secondary=nil end) + reject(function(r) r.secondary=not r.secondary end) + for _,h in ipairs({0xE000,0xD000,0xF000,0xDF00,0xB500,0xFFFF,0x6000,0x4770,0x46FF}) do + reject(function(r,f) f.change(r.callback-0x08000001,F.le(h,2)) end) + end + reject(function(r,f) f.change(r.callback-0x08000001,string.rep(F.le(0x46C0,2),64)) end) + reject(function(r,f) f.change(r.callback-0x08000001+44,F.le(0x08100000,4)) end) + reject(function(r,f) f.change(r.callback-0x08000001+40,F.le(0x06000000,4)) end) + reject(function(r,f) f.change(r.callback-0x08000001+40,F.le(0x03000021,4)) end) + reject(function(r,f) f.change(r.callback-0x08000001+32,F.le(0x08000000,4)) end) + reject(function(r,f) f.change(r.callback-0x08000001+20,F.le(0x6008,2)..F.le(0x4770,2)) end) + reset() + local calls=0 + local oldRead=f.rom.u16 + f.rom.u16=function(...) calls=calls+1;return oldRead(...) end + for _,digest in ipairs({false,"",string.rep("0",40),"f3ae088181bf583e55daf962a92bb46f4f1d07b7x"}) do + for _,r in pairs(f.sets) do r.paletteCycles={{stale=true}} end + local report=M.extract(f.rom,f.sets,f.manifest,digest) + T.eq(report.animated,0,"unknown revision cannot authorize palette reads") + T.eq(report.static,0,"identity failure cannot certify static programs") + T.eq(calls,0,"no initializer reads before actual identity matches") + for _,r in pairs(f.sets) do T.eq(r.paletteCycles,nil,"identity failure removes all stale metadata") end + end + reset() + f.manifest.terrainPalettePrograms=nil + T.eq(M.extract(f.rom,f.sets,f.manifest,manifest.romSha1).animated,0,"legacy manifest declines safely") + reset();f.manifest.terrainPalettePrograms.version=2 + T.eq(M.extract(f.rom,f.sets,f.manifest,manifest.romSha1).static,0,"unknown profile version declines") + -- Hashing is a stage requirement. Digest failure never silently falls back + -- to trusting the launcher's selected game name or the manifest's hash. + reset() + local stage=setmetatable({rom=f.rom,manifest=f.manifest,_tilesets=f.sets},{__index=Extractor}) + stage.beginStage=function() end;stage.write=function() end + local hash=Verify.sha1;Verify.sha1=function() error("hash unavailable") end + stage:extractTilesetPalettePrograms();Verify.sha1=hash + T.eq(f.sets[first].paletteProgram.status,"unavailable","hash failure persists useful status") + T.eq(f.sets[first].paletteCycles,nil,"hash failure cannot retain plausible cycles") +end +do + local f=F.new("emerald") + local original=f.rom.data + local function reject(change) + local probe=F.new("emerald");change(probe) + local report=M.extract(probe.rom,probe.sets,probe.manifest,probe.manifest.romSha1) + T.eq(report.animated,0,"malformed palette recipe/data never partially succeeds") + T.eq(probe.sets["battle-dome-floor"].paletteCycles,nil,"no partially read frames published") + end + for _,field in ipairs({"first","count","frames","step","phase","table"}) do + for _,bad in ipairs({false,"1",-1,0.5,math.huge,0/0}) do reject(function(p) p.program[field]=bad end) end + end + reject(function(p) p.program.step=0 end) + reject(function(p) p.program.frames=1 end) + reject(function(p) p.program.frames=257 end) + reject(function(p) p.program.count=0 end) + reject(function(p) p.program.first=95 end) + reject(function(p) p.program.first=250 end) + reject(function(p) p.program.table=p.rom.size-8 end) + reject(function(p) p.program.table=p.program.table+1 end) + reject(function(p) p.change(p.program.table,F.le(0x02000000,4)) end) + reject(function(p) p.change(p.program.table,F.le(0x08000000+p.rom.size-16,4)) end) + reject(function(p) p.change(p.program.table,F.le(0x08006001,4)) end) + reject(function(p) p.change(0x6000,F.le(0x8000,2)) end) + reject(function(p) p.change(p.program.table+12,F.le(0,4)) end) + T.eq(f.rom.data,original,"all malformed probes leave independent fixture intact") +end +T.finish("ROM palette program identity, bounds and unsupported callbacks") diff --git a/tests/engine/gen3_palette_program_import.lua b/tests/engine/gen3_palette_program_import.lua new file mode 100644 index 000000000..055d2b452 --- /dev/null +++ b/tests/engine/gen3_palette_program_import.lua @@ -0,0 +1,118 @@ +package.path="./?.lua;./?/init.lua;"..package.path +local T=require("tests.modkit") +local F=require("tests.palette_programs.fixture") +local M=require("src.import.Gen3PalettePrograms") +local Extractor=require("src.import.RomExtractorGen3") +local Verify=require("src.import.RomVerification") +local Writer=require("src.import.LuaWriter") +local Copy=require("src.mods.Merge").deepCopy +for _,game in ipairs({"emerald","firered"}) do + local f=F.new(game) + local original=f.rom.data + local report=M.extract(f.rom,f.sets,f.manifest,f.manifest.romSha1) + T.eq(report.animated,game=="emerald" and 1 or 0,"Smaragd palette program versus FireRed tile-only callbacks") + T.eq(report.static,game=="emerald" and 21 or 8,"all known tile-only/null programs positively classified") + T.eq(report.unavailable,1,"unknown runtime remains explicitly incomplete") + T.eq(f.sets.unknown.paletteCycles,nil,"no invented static array for unknown program") + for id,record in pairs(f.sets) do + if id~="unknown" then + T.check(type(record.paletteCycles)=="table","each supported source explicitly declares cycles") + T.eq(record.paletteProgram.romSha1,f.manifest.romSha1,"per-record provenance pins identity") + end + end + if game=="emerald" then + local run=f.sets["battle-dome-floor"].paletteCycles[1] + T.eq(run.first,128,"BG palette 8 absolute destination") + T.eq(run.count,16,"complete 16-colour palette copied") + T.eq(run.step,4,"source callback cadence") + T.eq(#run.frames,4,"four palette pointers, not inferred from tile VRAM") + T.same(run.frames,f.frames,"all colours use exact independent BGR555 floor decoding") + T.eq(#run.sourceFrames,4,"source palette words retained for every picture") + for frame=0,3 do + local at=f.rom:u32(f.program.table+frame*4)-0x08000000 + local raw=f.rom:bytes(at,32) + for color=0,15 do + local lo,hi=run.sourceFrames[frame+1]:byte(color*2+1,color*2+2) + T.eq(lo+hi*256,raw[color*2+1]+raw[color*2+2]*256, + "original BGR555 words survive independently of RGB rounding") + end + end + end + T.eq(f.rom.data,original,"analysis cannot modify cartridge bytes") + -- Exercise the actual automatic stage, real ROM reader and real Lua cache + -- serializer; only hash and file destination are injected for synthetic ROMs. + local stages,writes,hashes=0,0,0 + local stage=setmetatable({rom=f.rom,manifest=f.manifest,_tilesets=Copy(f.sets)}, {__index=Extractor}) + stage.beginStage=function(_,name) stages=stages+1;T.eq(name,"Gen3 tileset palette programs","automatic stage name") end + stage.write=function(_,name,value) + writes=writes+1;T.eq(name,"map_tilesets","uses existing cache namespace") + stage.serialized=Writer.encode(value) + end + local hash=Verify.sha1 + Verify.sha1=function(bytes) hashes=hashes+1;T.eq(bytes,f.rom.data,"authenticates the actual stage ROM bytes");return f.manifest.romSha1 end + local rand,gpu=math.random,love.graphics.newImage + math.random=function() error("import cannot consume gameplay RNG") end + love.graphics.newImage=function() error("data import cannot allocate graphics") end + local before=package.loaded["src.core.Game"] + stage:extractTilesetPalettePrograms() + math.random,love.graphics.newImage=rand,gpu + Verify.sha1=hash + T.eq(stages,1,"stage runs once");T.eq(writes,1,"one cache write after complete analysis");T.eq(hashes,1,"digest once per stage") + T.eq(package.loaded["src.core.Game"],before,"import never bootstraps player Game") + local loaded=assert(loadstring(stage.serialized))() + T.same(loaded._romInfo.palettePrograms,report,"persisted summary matches all callback classifications") + T.same(loaded[game=="emerald" and "battle-dome-floor" or "general"].paletteCycles, + f.sets[game=="emerald" and "battle-dome-floor" or "general"].paletteCycles,"binary palette frames survive actual cache serialization") + local tileStage,paletteStage + for i,name in ipairs(Extractor.DATA_STAGES) do + if name=="extractTilesetAnimations" then tileStage=i end + if name=="extractTilesetPalettePrograms" then paletteStage=i end + end + T.eq(paletteStage,tileStage+1,"normal import automatically reads palette programs after tile animations") + -- Import actual synthetic Tileset headers too. FireRed swaps the callback + -- and attribute pointers relative to Emerald; this catches a program + -- reader which works with hand-built records but loses real header data. + local headerRom=F.new(game) + for i,name in ipairs({"general",game=="emerald" and "battle-dome-floor" or "celadon"}) do + local at=i*0x100 + local gfx=0x8000+(i-1)*0x8000 + local pals=gfx+0x4000 + local meta=gfx+0x5000 + local callback=headerRom.sets[name].callback + local attrs=0x08000000+meta+48 + headerRom.change(at,string.char(0,i-1,0,0)..F.le(0x08000000+gfx,4)..F.le(0x08000000+pals,4) + ..F.le(0x08000000+meta,4)..F.le(game=="emerald" and attrs or callback,4) + ..F.le(game=="emerald" and callback or attrs,4)) + end + local captured={} + local headers=setmetatable({rom=headerRom.rom,manifest=headerRom.manifest,layout=headerRom.manifest.layout, + stage=0,_tilesetAddresses={[0x100]=true,[0x200]=true},_layoutRecords={}}, {__index=Extractor}) + headers.write=function(_,name,value) captured[name]=value end + headers.saveImage=function() end;headers.tilesetSheet=function() return {} end + -- These independent behaviour derivations need full map/script datasets; + -- they are outside this header/palette test and remain covered elsewhere. + for _,name in ipairs({"counterBehaviour","pcBehaviour","wallBehaviours","warpBehaviours","diveTerrain", + "waterfallTerrain","iceBehaviours","bikeBehaviours","escalatorBehaviours","currentBehaviours", + "forcedMovementBehaviours","lavaridgeWarps","noRunBehaviours","checkBehaviourNames"}) do + headers[name]=function() end + end + headers:extractTilesets() + local primary,secondary=headers._tilesets.TILESET_0000100,headers._tilesets.TILESET_0000200 + T.check(primary~=nil and secondary~=nil,"real header stage imports both source banks") + for _,record in ipairs({primary,secondary})do + T.eq(#record.sourcePaletteBytes,512,"complete original palette bank retained") + local raw=headerRom.rom:bytes(assert(headerRom.rom:pointer(record.address+8)),512) + for i=1,512 do T.eq(record.sourcePaletteBytes:byte(i),raw[i],"header-selected palette bytes preserved")end + end + T.eq(primary.callback,headerRom.sets.general.callback,"primary callback comes from title-specific header slot") + T.eq(secondary.callback,headerRom.sets[game=="emerald" and "battle-dome-floor" or "celadon"].callback, + "secondary callback comes from title-specific header slot") + local oldHash=Verify.sha1 + Verify.sha1=function(b) T.eq(b,headerRom.rom.data,"header-import palette stage verifies same bytes");return headerRom.manifest.romSha1 end + headers:extractTilesetPalettePrograms();Verify.sha1=oldHash + T.eq(captured.map_tilesets._romInfo.palettePrograms.unavailable,0,"actual header-to-program path is complete") + T.eq(primary.paletteProgram.status,"static","imported primary tile-only program classified") + T.eq(secondary.paletteProgram.status,game=="emerald" and "animated" or "static","imported secondary classified") + if game=="emerald" then T.same(secondary.paletteCycles[1].frames,headerRom.frames,"header path reads all frame bytes correctly") end +end +T.finish("Automatic Gen3 ROM palette program data import") diff --git a/tests/engine/gen3_palette_renderer.lua b/tests/engine/gen3_palette_renderer.lua new file mode 100644 index 000000000..b139d5c1b --- /dev/null +++ b/tests/engine/gen3_palette_renderer.lua @@ -0,0 +1,255 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local F = require("tools.native-palettes.fixture") +local Copy = require("src.mods.Merge").deepCopy +local TR = require("src.render.TileRenderer") +local Tiles = require("src.render.Gen3Tiles") +-- Pixel-bearing texture/Canvas stand-ins: run the real sheet bake, refresh, +-- cached batch and border paths without a window, GPU, cartridge or save. +local original={image=love.image,newImage=love.graphics.newImage,newCanvas=love.graphics.newCanvas, + draw=love.graphics.draw,clear=love.graphics.clear} +local allocations,updates,canvases,surfaceReleases=0,0,0,0 +local function surface(w,h,format,bytes) + local data={w=w,h=h,bytes=bytes or string.rep("\0",w*h*4)} + function data:release() + T.check(not self.released,"CPU atlas released once") + self.released=true;self.bytes=nil;surfaceReleases=surfaceReleases+1 + end + return data +end +local function image(data) + T.check(not data.released,"image upload receives live CPU pixels") + allocations=allocations+1 + local out={w=data.w,h=data.h,bytes=data.bytes,updates=0} + function out:replacePixels(next) + T.check(not next.released,"palette upload never uses evicted CPU pixels") + T.eq(next.w,self.w,"palette refresh preserves image width") + T.eq(next.h,self.h,"palette refresh preserves image height") + self.bytes=next.bytes;self.updates=self.updates+1;updates=updates+1 + end + function out:setWrap() end + function out:setFilter() end + return out +end +love.image={newImageData=surface} +love.graphics.newImage=image +love.graphics.newCanvas=function(w,h) + canvases=canvases+1 + local c={w=w,h=h,pixels={}} + function c:newImageData() return surface(w,h,"rgba8",table.concat(self.pixels)) end + return c +end +love.graphics.clear=function(r,g,b,a) + local canvas=love.graphics.getCanvas() + if not canvas then return end + local p=string.char(r*255,g*255,b*255,(a or 1)*255) + for i=1,canvas.w*canvas.h do canvas.pixels[i]=p end +end +love.graphics.draw=function(img,quad,ox,oy) + local canvas=love.graphics.getCanvas() + if not canvas then return end + for y=0,quad.h-1 do for x=0,quad.w-1 do + local at=((quad.y+y)*img.w+quad.x+x)*4+1 + local px=img.bytes:sub(at,at+3) + if px:byte(4)==255 then canvas.pixels[(y+oy)*canvas.w+x+ox+1]=px end + end end +end +local function expectedSheet(decoder,record,layer) + local pixels={} + for i=1,record.width*record.height do pixels[i]="\0\0\0\0" end + local function plot(x,y,r,g,b) pixels[y*record.width+x+1]=string.char(r,g,b,255) end + decoder:setAnimFrame(0);decoder:bakeLayer(layer,plot) + for mid,slots in pairs(record.animSlots or {}) do + for frame=2,record.animFrames do + decoder:setAnimFrame(frame-1);decoder:bakeMetatileInto(mid,layer,slots[frame],plot) + end + end + return table.concat(pixels) +end +for _,version in ipairs({"emerald","firered"}) do + TR.releaseGen3Sheets() + local f=F.new(version,2,true) + local s=f.state + local layout=s.game.data.constants.gen3Layout + local split=layout.palettesInPrimary + f.records.P.paletteCycles={ {kind="rotate",first=17,count=4,step=3,direction=1} } + f.records.S.paletteCycles={ {kind="rotate",first=split*16+1,count=4,step=5,direction=-1} } + local originalP,originalS=Copy(f.records.P.palettes),Copy(f.records.S.palettes) + local record=assert(TR.gen3SheetsFor(s.map.tileset,s.game.data,layout)) + local renderer=setmetatable({gen3=record,map=s.map,gen3Key=s.map.tileset.id, + gen3Quads={},bodyTilesW=6,bodyTilesH=4,blockTiles=2}, {__index=TR}) + -- The real sheet column count, not a guessed source-layout dimension. + for id=0,record.slots-1 do + renderer.gen3Quads[id]=love.graphics.newQuad(id%Tiles.SHEET_COLS*16,math.floor(id/Tiles.SHEET_COLS)*16,16,16) + end + s.map.def.border=string.rep("\0",8) + renderer:ensureWindow(0,0,48,32) + renderer:ensureBorderFill() + local bottom,top,cover,border=record.bottom,record.top,record.cover,renderer.borderFill + T.check(cover~=nil,"fixture exercises reflection-cover refresh") + T.check(border~=nil,"fixture exercises border refresh") + local batch=renderer.winBatch + local baseAllocations=allocations + local baseCanvases=canvases + for step=0,12 do + local priorKey=record.tiles.paletteState.contentKey + local priorUpdates=bottom.updates+top.updates+(cover and cover.updates or 0) + renderer:ensureWindow(0,0,48,32) + renderer:ensureBorderFill() + local ticks=TR.animFrame() + local p,q=Copy(f.records.P),Copy(f.records.S) + for c=0,3 do + p.palettes[2][c+2]=Copy(originalP[2][(c-math.floor(ticks/3))%4+2]) + q.palettes[split+1][c+2]=Copy(originalS[split+1][(c+math.floor(ticks/5))%4+2]) + end + local expected=Tiles.new({primary=p,secondary=q},layout) + T.eq(record.bottom.bytes,expectedSheet(expected,record,1),"native bottom atlas uses independently expected palette") + T.eq(record.top.bytes,expectedSheet(expected,record,2),"native top atlas and every tile frame recolored") + if cover then + for mid,quads in pairs(record.coverQuads) do + for frame,quad in ipairs(quads) do + expected:setAnimFrame(frame-1) + local pixels={} + for i=1,256 do pixels[i]="\0\0\0\0" end + expected:drawLayer(mid,2,0,0,function(x,y,r,g,b) pixels[y*16+x+1]=string.char(r,g,b,255) end) + local got={} + for y=0,15 do + local start=((quad.y+y)*cover.w+quad.x)*4+1 + got[#got+1]=cover.bytes:sub(start,start+63) + end + T.eq(table.concat(got),table.concat(pixels),"reflection cover uses current palette in every tile frame") + end + end + end + local borderPixels={} + for y=0,31 do for x=0,31 do + local at=((y%16)*bottom.w+x%16)*4+1 + local a,b=bottom.bytes:sub(at,at+3),top.bytes:sub(at,at+3) + borderPixels[#borderPixels+1]=b:byte(4)==255 and b or (a:byte(4)==255 and a or "\0\0\0\255") + end end + T.eq(border.bytes,table.concat(borderPixels),"map border actually receives current atlas colours") + T.eq(record.bottom,bottom,"native bottom texture retained") + T.eq(record.top,top,"native top texture retained") + T.eq(record.cover,cover,"reflection texture retained") + T.eq(renderer.borderFill,border,"border texture retained") + T.eq(renderer.winBatch,batch,"cached batch survives palette updates") + T.eq(allocations,baseAllocations,"no GPU-image churn during palette cycle") + T.eq(canvases,baseCanvases,"no border canvas churn during palette cycle") + local count=updates + renderer:drawReflectionCover(0,0,0,0) + renderer:syncGen3Palettes() + T.eq(updates,count,"multiple passes in one tick never repaint twice") + if priorKey==record.tiles.paletteState.contentKey then + T.eq(priorUpdates,bottom.updates+top.updates+(cover and cover.updates or 0),"stable picture avoids sheet upload") + end + if step==0 then + local second=setmetatable({gen3=record,map=s.map,gen3Quads=renderer.gen3Quads},{__index=TR}) + second:ensureBorderFill() + local old=second.borderFill + for _=1,5 do TR.tick(1/60) end + renderer:syncGen3Palettes() + local prior=updates + second:ensureBorderFill() + T.eq(second.borderFill,old,"shared pair refresh retains each renderer's border image") + T.check(updates>prior,"second renderer refreshes border even after pair was already sampled") + baseAllocations=allocations + baseCanvases=canvases + else TR.tick(1/60) end + end + T.check(bottom.updates>0 and top.updates>0 and cover.updates>0,"both layers and covers receive changed colours") + T.check(border.updates>0,"palette changes reach map border") + T.same(f.records.P.palettes,originalP,"native cycle never modifies source primary") + T.same(f.records.S.palettes,originalS,"native cycle never modifies source secondary") + -- Invalid/removed metadata returns to base colors instead of leaving a + -- formerly animated palette frozen in the texture. + f.records.P.paletteCycles=nil + TR.tick(1/60);renderer:syncGen3Palettes() + T.eq(record.tiles.paletteState,nil,"incomplete source metadata leaves native base fallback") + local base=Tiles.new({primary=f.records.P,secondary=f.records.S},layout) + T.eq(record.bottom.bytes,expectedSheet(base,record,1),"base fallback actually reaches existing atlas") + local before=updates + TR.tick(1/60);renderer:syncGen3Palettes() + T.eq(updates,before,"invalid metadata does not repeatedly upload unchanged base") + f.run.release() +end +-- Legacy/no-mod source remains pixel-identical and performs no new upload. +TR.releaseGen3Sheets() +local f=F.new("emerald",2) +local rec=assert(TR.gen3SheetsFor(f.state.map.tileset,f.state.game.data,f.state.game.data.constants.gen3Layout)) +local r=setmetatable({gen3=rec},{__index=TR}) +local before=updates +for _=1,20 do TR.tick(1/60);r:syncGen3Palettes() end +T.eq(updates,before,"legacy metadata-free native rendering has no palette uploads") +T.eq(rec.tiles.paletteState,nil,"legacy decoder retains original palettes") +f.run.release() + +-- The repeating four-picture program must stop baking after warmup, while +-- every restored atlas/animation/cover remains pixel-identical to decoding. +TR.releaseGen3Sheets() +f=F.new("emerald",2) +local frames={} +for i=1,4 do frames[i]=string.rep(string.char(i*29,i*43,i*53),4) end +f.records.P.paletteCycles={{kind="frames",first=17,count=4,step=1,frames=frames}} +f.records.S.paletteCycles={} +rec=assert(TR.gen3SheetsFor(f.state.map.tileset,f.state.game.data,f.state.game.data.constants.gen3Layout)) +r=setmetatable({gen3=rec},{__index=TR}) +local bakes=0 +local bake=rec.tiles.bakeLayer +rec.tiles.bakeLayer=function(self,... )bakes=bakes+1;return bake(self,...)end +local originalBottom,originalTop,originalCover=rec.bottom,rec.top,rec.cover +for n=1,40 do + TR.tick(1/60);r:syncGen3Palettes() + local expected=Tiles.new({primary=f.records.P,secondary=f.records.S},f.state.game.data.constants.gen3Layout) + expected:setPaletteTicks(TR.animFrame()) + T.eq(rec.bottom.bytes,expectedSheet(expected,rec,1),"cached bottom equals independent current decode") + T.eq(rec.top.bytes,expectedSheet(expected,rec,2),"cached top/animation slots equal current decode") + if rec.cover then + for mid,quads in pairs(rec.coverQuads) do + for frame,quad in ipairs(quads) do + expected:setAnimFrame(frame-1) + local pixels={};for i=1,256 do pixels[i]="\0\0\0\0" end + expected:drawLayer(mid,2,0,0,function(x,y,rr,gg,bb)pixels[y*16+x+1]=string.char(rr,gg,bb,255)end) + local got={} + for y=0,15 do local at=((quad.y+y)*rec.cover.w+quad.x)*4+1;got[#got+1]=rec.cover.bytes:sub(at,at+63)end + T.eq(table.concat(got),table.concat(pixels),"cached reflection cover equals independent decode") + end + end + end + T.eq(rec.bottom,originalBottom,"cache hit preserves bottom GPU identity") + T.eq(rec.top,originalTop,"cache hit preserves top GPU identity") + T.eq(rec.cover,originalCover,"cache hit preserves reflection GPU identity") +end +T.eq(bakes,6,"four-picture cycle bakes only three missing two-layer pictures") +T.eq(TR.gen3PaletteCacheStats().entries,4,"four-picture cycle retains four pictures") +local warmed=bakes +for _=1,100 do TR.tick(1/60);r:syncGen3Palettes() end +T.eq(bakes,warmed,"many repeated palette cycles perform no full atlas bake") +-- A new colour picture fails its upload: no partial picture enters the cache, +-- temporary CPU pixels are released and the tile-animation selection survives. +local savedUpload=rec.bottom.replacePixels +rec.bottom.replacePixels=function()error("intentional atlas upload failure")end +for i=1,4 do frames[i]=string.rep(string.char(250-i,240-i,230-i),4) end +local releasedBefore=surfaceReleases +local frameBefore=rec.tiles.animFrame +TR.tick(1/60) +T.check(not pcall(r.syncGen3Palettes,r),"upload failure propagates") +T.eq(surfaceReleases,releasedBefore+1,"failed upload releases its temporary surface") +T.eq(rec.tiles.animFrame,frameBefore,"failed bake restores animation selection") +T.eq(TR.gen3PaletteCacheStats().entries,4,"failed picture is never cached") +rec.bottom.replacePixels=savedUpload +releasedBefore=surfaceReleases +TR.releaseGen3Sheets() +T.eq(TR.gen3PaletteCacheStats().bytes,0,"source/cache reset releases all CPU atlas bytes") +T.eq(TR.gen3PaletteCacheStats().entries,0,"source/cache reset drops prior colour pictures") +T.eq(surfaceReleases-releasedBefore,4*(rec.cover and 3 or 2),"reset releases every cached layer once") +local fresh=assert(TR.gen3SheetsFor(f.state.map.tileset,f.state.game.data,f.state.game.data.constants.gen3Layout)) +T.check(fresh~=rec,"source reset creates a new owner even with the same tileset id") +T.eq(TR.gen3PaletteCacheStats().entries,1,"source rebuild primes only its own current picture") +TR.invalidate() +T.eq(TR.gen3PaletteCacheStats().bytes,0,"asset invalidation also releases CPU picture storage") +T.eq(TR.gen3PaletteCacheStats().entries,0,"asset invalidation cannot retain source pictures") +T.check(fresh.bottom.bytes~=nil,"asset invalidation preserves live GPU resources") +f.run.release() +love.image=original.image +for _,name in ipairs({"newImage","newCanvas","draw","clear"}) do love.graphics[name]=original[name] end +T.finish("Native palette sheet, batch, border and reflection refresh") diff --git a/tests/engine/gen3_palette_samples.lua b/tests/engine/gen3_palette_samples.lua new file mode 100644 index 000000000..ce086699c --- /dev/null +++ b/tests/engine/gen3_palette_samples.lua @@ -0,0 +1,83 @@ +package.path="./?.lua;./?/init.lua;"..package.path +local T=require("tests.modkit") +local F=require("tools.native-palettes.fixture") +local Tiles=require("src.render.Gen3Tiles") +local Cycles=require("src.render.Gen3PaletteCycles") +local Copy=require("src.mods.Merge").deepCopy +local raw=Cycles.sample +local samples=0 +Cycles.sample=function(...) samples=samples+1;return raw(...) end +for _,version in ipairs({"emerald","firered"})do + local f=F.new(version,2,true);local layout=f.state.game.data.constants.gen3Layout + local pair={primary=f.records.P,secondary=f.records.S} + pair.primary.paletteCycles={} + local first=layout.palettesInPrimary*16 + local frames={string.char(1,2,3,4,5,6),string.char(6,7,8,9,10,11),string.char(21,22,23,24,25,26),string.char(31,32,33,34,35,36)} + pair.secondary.paletteCycles={{kind="frames",first=first,count=2,step=4,phase=1,frames=frames}} + local original=Copy(pair);local d=Tiles.new(pair,layout) + for tick=0,31 do + d:setPaletteTicks(tick) + T.same(d.paletteState,assert(raw(pair,layout,tick)),"native sample equals independent pure sampler") + end + local count=samples;local cache=d.paletteSampler;local before=cache:stats() + for tick=32,255 do + d:setPaletteTicks(tick) + T.same(d.paletteState,assert(raw(pair,layout,tick)),"warmed sample preserves all RGB bytes and phase indices") + end + T.eq(samples,count,"four repeating states cause zero full samples after warmup") + T.eq(cache:stats().entries,4,"native sample pictures bounded to four") + T.check(cache:stats().hits>before.hits,"warmed states reused") + T.same(pair,original,"shared source tables remain unchanged") + local held=d.paletteState + pair.primary.palettes[16][16][1]=(pair.primary.palettes[16][16][1]+1)%256 + d:setPaletteTicks(256) + T.check(d.paletteState~=held,"same-table RGB edit invalidates retained states") + T.same(d.paletteState,assert(raw(pair,layout,256)),"source edit outside active program also observed") + pair.secondary.paletteCycles[1].frames[2]=string.char(101,102,103,104,105,106) + for tick=257,272 do d:setPaletteTicks(tick);T.same(d.paletteState,assert(raw(pair,layout,tick)),"same-table frame edit invalidates even before frame selected") end + pair.secondary.paletteCycles[1].step=3;pair.secondary.paletteCycles[1].phase=2 + d:setPaletteTicks(273);T.same(d.paletteState,assert(raw(pair,layout,273)),"cadence and phase edits invalidate state selection") + -- Multiple independent programs produce more than four combinations. + pair.primary.paletteCycles={{kind="rotate",first=17,count=4,step=3,direction=-1}} + for tick=0,95 do + d:setPaletteTicks(tick);T.same(d.paletteState,assert(raw(pair,layout,tick)),"bounded eviction preserves independent program phases") + T.check(cache:stats().entries<=4,"phase combinations cannot grow retained picture count") + end + pair.primary.paletteCycles[1].direction=1 + d:setPaletteTicks(96);T.same(d.paletteState,assert(raw(pair,layout,96)),"rotate direction edit observed") + local function reject(edit) + d.primary,d.secondary=Copy(original.primary),Copy(original.secondary) + d:setPaletteTicks(0);edit(d) + local _,reason=d:setPaletteTicks(1) + T.eq(d.paletteState,nil,"invalid source cannot reuse previous colours") + T.eq(reason,"palette animation data unavailable","malformed metadata still declines") + T.eq(cache:stats().entries,0,"invalid source drops retained samples") + end + reject(function(t)t.primary.paletteCycles=nil end) + reject(function(t)t.secondary.paletteCycles.extra=true end) + reject(function(t)t.secondary.paletteCycles[1].frames[3]="short" end) + reject(function(t)t.secondary.paletteCycles[1].frames=setmetatable(Copy(frames),{}) end) + reject(function(t)t.primary.palettes[1][1][2]=256 end) + reject(function(t)t.primary.palettes[1].extra=true end) + reject(function(t)t.secondary=setmetatable(t.secondary,{}) end) + d.primary,d.secondary=Copy(original.primary),Copy(original.secondary);d:setPaletteTicks(0) + for _,tick in ipairs({-1,0/0,math.huge,9007199254740992})do + local _,reason=d:setPaletteTicks(tick) + T.eq(d.paletteState,nil,"invalid clock clears cached output") + T.eq(reason,"terrain clock unavailable","clock rejection preserved") + T.eq(cache:stats().entries,0,"invalid clock drops native pictures") + d:setPaletteTicks(0) + end + d.primary.paletteCycles={};d.secondary.paletteCycles={};d:setPaletteTicks(0) + local still=d.paletteState;d:setPaletteTicks(1) + T.eq(d.paletteState,still,"animated-to-static transition keeps static reuse") + local static=Tiles.new({primary=d.primary,secondary=d.secondary},layout);static:setPaletteTicks(0) + T.eq(static.paletteSampler,nil,"initially static FireRed/legacy terrain gets no sample cache") + d.primary,d.secondary=Copy(original.primary),Copy(original.secondary);d:setPaletteTicks(9007199254740991) + T.same(d.paletteState,assert(raw(d,layout,9007199254740991)),"maximum exact tick phase precision preserved") + local detached=assert(raw(d,layout,9007199254740991));detached.primary[1][1][1]=-1 + T.check(d.paletteState.primary[1][1][1]>=0,"public samples remain detached from native retained state") + f.run.release() +end +Cycles.sample=raw +T.finish("Native animated palette sample reuse, eviction and source invalidation") diff --git a/tests/engine/gen3_source_attributes.lua b/tests/engine/gen3_source_attributes.lua new file mode 100644 index 000000000..444c32c18 --- /dev/null +++ b/tests/engine/gen3_source_attributes.lua @@ -0,0 +1,96 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local X=require('src.import.RomExtractorGen3') +local Tiles=require('src.render.Gen3Tiles') +local Rom=require('src.import.RomGba') +local function word(n, bytes) + local out={};for _=1,bytes do out[#out+1]=string.char(n%256);n=math.floor(n/256)end + return table.concat(out) +end +local function fixture(stride) + local bytes={};for i=1,20000 do bytes[i]='\0'end + local function put(at,data)for i=1,#data do bytes[at+i]=data:sub(i,i)end end + local pointer=function(at,n)put(at,word(n+0x08000000,4))end + local a=32 + pointer(a+4,1024);pointer(a+8,512);pointer(a+12,128) + pointer(a+(stride==4 and 20 or 16),176) + -- Same normalized behavior can come from distinct Kanto source bytes. + local values=stride==4 and {0x400000d1,0x20000011,0x200001d1} or {0x3002,0x2011,0x1008} + for i,n in ipairs(values)do put(176+(i-1)*stride,word(n,stride))end + local captured={} + local x=setmetatable({rom=Rom.new(table.concat(bytes)),manifest={metatileAttributes={bytes=stride}}, + layout={},_tilesetAddresses={[a]=true},_layoutRecords={}},X) + x.beginStage=function()end;x.tick=function()end + local stop={} + x.write=function(_,name,value) + captured[name]=value + if name=='map_tilesets' then error(stop,0)end + end + x.tilesetSheet=function()return {}end;x.saveImage=function()end + -- No map pairs: this fixture isolates actual attribute extraction, not + -- unrelated furniture-script scanning against a synthetic tiny cartridge. + x.isFireRedManifest=function()return false end + local ok,why=pcall(x.extractTilesets,x) + T.eq(ok,false);T.eq(why,stop,'stop only after the actual tileset write boundary') + local r=assert(captured.map_tilesets.TILESET_0000020) + T.eq(captured.map_tilesets._romInfo.skipped,0) + T.eq(r.sourceAttributeBytes,stride) + local expected={};for _,n in ipairs(values)do expected[#expected+1]=word(n,stride)end + if stride==4 then + T.eq(r.sourceAttributes,table.concat(expected),'original 32-bit attributes survive exactly') + T.eq(r.sourceBehaviorMask,511,'default FRLG mask preserves the ninth behavior bit') + else + T.eq(r.sourceAttributes,nil,'16-bit source does not allocate a duplicate string') + T.eq(r.attributes,table.concat(expected),'native 16-bit attributes already are source bytes') + T.eq(r.sourceBehaviorMask,255) + end + local tiles=Tiles.new({primary=r},{metatilesInPrimary=3}) + local b0,l0=tiles:attributes(0);local b1,l1=tiles:attributes(1) + T.eq(l0,stride==4 and 2 or 3,'native layer convention unchanged') + T.eq(l1,stride==4 and 1 or 2) + T.eq(b0,stride==4 and 0x5a or 2,'native remap unchanged') + T.eq(b1,stride==4 and 0x5b or 17) + T.eq(tiles:sourceBehavior(0),stride==4 and 0xd1 or 2,'source numbering is separate') + T.eq(tiles:sourceBehavior(1),17) + T.eq(tiles:sourceBehavior(2),stride==4 and 0x1d1 or 8,'ninth source behavior bit is retained') + return r +end +local fr,em=fixture(4),fixture(2) +local pair=Tiles.new({primary=fr,secondary=em},{metatilesInPrimary=3}) +T.eq(pair:sourceBehavior(3),2,'secondary bank uses its own attribute width') +T.eq(pair:sourceBehavior(4),17) +T.eq(pair:sourceBehavior(6),nil,'out-of-range source has no guessed behavior') +for _,id in ipairs({-1,.5,'0',false,0/0,math.huge})do T.eq(pair:sourceBehavior(id),nil)end +local stale=Tiles.new({primary={attributes=fr.attributes}},{metatilesInPrimary=2}) +T.eq(stale:sourceBehavior(0),nil,'normalized-only cache cannot reconstruct source') +T.eq(stale:attributes(0),0x5a,'stale cache native convention still reads normally') +T.eq(Tiles.new({primary=fr},{metatilesInPrimary=3}):sourceBehavior(3),nil,'missing secondary cannot borrow primary source') +fr.sourceAttributes=fr.sourceAttributes:sub(1,3) +T.eq(pair:sourceBehavior(0),nil,'truncated source fails explicitly') +fr.sourceBehaviorMask=254 +T.eq(pair:sourceBehavior(0),nil,'malformed mask rejected') +local Importer=require('src.import.RomImporter') +local V=require('src.core.GameVersion') +local markerFor +for i=1,100 do + local name,value=debug.getupvalue(Importer.readyReport,i) + if not name then break end + if name=='markerFor' then markerFor=value;break end +end +T.eq(type(markerFor),'function','inspect actual format gate without reading or mounting a cache') +for _,edition in ipairs({'firered','leafgreen','emerald','ruby','sapphire'})do + local info=V.info(edition) + if info then + local marker=markerFor(edition) + T.check(marker:find('source-metatile-attrs-v1:',1,true)~=nil,edition..' cache needs source provenance') + T.eq(marker:sub(-40),info.sha1,edition..' marker retains exact cartridge identity') + if edition=='ruby' or edition=='sapphire' then + T.check(marker:find('rs-item-menu-v4:',1,true)~=nil,edition..' previous import fixes retained') + end + end +end +for _,edition in ipairs({'red','gold','crystal'})do + T.eq(markerFor(edition):find('source-metatile-attrs-v1:',1,true),nil,edition..' cache unchanged') +end +T.finish('original GBA metatile attributes and native normalization') diff --git a/tests/engine/gen3_source_flags.lua b/tests/engine/gen3_source_flags.lua new file mode 100644 index 000000000..51d3a3e56 --- /dev/null +++ b/tests/engine/gen3_source_flags.lua @@ -0,0 +1,18 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local oldLove=love +local raw=string.rep(string.char(0,1,2,3,4,5,6,7),30) +love={data={hash=function(algorithm,value)T.eq(algorithm,'sha256');return string.rep(value==raw and 'x'or'y',32)end}} +local read=require('src.import.Gen3SourceFlags').read +local spec={offset=4,count=240,sha256=string.rep('78',32)} +T.eq(read({data='HEAD'..raw},spec),raw,'only verified original flag bytes are published') +for _,bad in ipairs({{}, {offset=-1,count=240,sha256=spec.sha256},{offset=.5,count=240,sha256=spec.sha256}, + {offset=4,count=256,sha256=spec.sha256},{offset=4,count=240,sha256='invalid'}, + {offset=4,count=240,sha256=string.rep('X',64)}})do T.eq(read({data='HEAD'..raw},bad),nil)end +T.eq(read({data='HEAD'..raw:sub(1,239)},spec),nil,'truncated source cannot masquerade as flags') +T.eq(read({data='HEAD'..raw:sub(2)..'x'},spec),nil,'wrong range fails checksum') +love.data.hash=function()return string.rep('x',32)end +T.eq(read({data='HEAD'..string.rep(string.char(8),240)},spec),nil,'unknown flag bits rejected after checksum') +love.data.hash=nil;T.eq(read({data='HEAD'..raw},spec),nil,'missing checksum support is explicit') +love=oldLove +T.finish('verified source terrain flags') diff --git a/tests/engine/gen3_source_map_layouts.lua b/tests/engine/gen3_source_map_layouts.lua new file mode 100644 index 000000000..45cd02d66 --- /dev/null +++ b/tests/engine/gen3_source_map_layouts.lua @@ -0,0 +1,29 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local X=require('src.import.RomExtractorGen3') +local Rom=require('src.import.RomGba') +local function le(n,k)local s=''for _=1,k do s=s..string.char(n%256);n=math.floor(n/256)end;return s end +local bytes={};for i=1,1024 do bytes[i]='\0'end +local function put(at,s)for i=1,#s do bytes[at+i]=s:sub(i,i)end end +local function ptr(at,v)put(at,le(0x08000000+v,4))end +ptr(16,64);ptr(20,128) +for _,a in ipairs({64,128})do + put(a,le(3,4)..le(2,4));ptr(a+8,256);ptr(a+12,512);ptr(a+16,160);ptr(a+20,192) +end +put(64+24,string.char(1,2));put(128+24,string.char(0,0)) +put(256,le(5,2)..le(6,2)..le(7,2)..le(8,2)) +for i=0,5 do put(512+i*2,le(i+(i%4)*1024+6*4096,2))end +local x=setmetatable({rom=Rom.new(table.concat(bytes)),manifest={metatileAttributes={bytes=4}}, + symbols={gMapLayouts=16},layout={numMapLayouts=2}},X) +x.beginStage=function()end;x.tick=function()end +local out;x.write=function(_,name,records)T.eq(name,'map_layouts');out=records end +x:extractMapLayouts() +T.eq(out[1].border,le(5,2)..le(6,2)..le(7,2)..le(8,2),'native border representation unchanged') +T.eq(out[1].sourceBorder,le(5,2)..le(6,2),'actual source dimensions retain their own bytes') +T.eq(out[1].sourceBorderWidth,1);T.eq(out[1].sourceBorderHeight,2) +T.eq(out[2].border,out[1].border,'unused source metadata does not change native extraction') +T.eq(out[2].sourceBorder,nil,'unused zero-sized source border is unavailable') +T.eq(out[2].sourceBorderWidth,0);T.eq(out[2].sourceBorderHeight,0) +for i=1,6 do T.eq(out[1].collisionCells[i],(i-1)%4);T.eq(out[1].elevationCells[i],6)end +T.finish('original map borders without native layout changes') diff --git a/tests/engine/gen3_static_palette_reuse.lua b/tests/engine/gen3_static_palette_reuse.lua new file mode 100644 index 000000000..da2d72ae6 --- /dev/null +++ b/tests/engine/gen3_static_palette_reuse.lua @@ -0,0 +1,57 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local F = require("tools.native-palettes.fixture") +local Tiles = require("src.render.Gen3Tiles") +local Copy = require("src.mods.Merge").deepCopy +for _, version in ipairs({"emerald", "firered"}) do + local f = F.new(version, 2, true) + local layout = f.state.game.data.constants.gen3Layout + local pair = {primary=f.records.P, secondary=f.records.S} + pair.primary.paletteCycles={};pair.secondary.paletteCycles={} + local decoder = Tiles.new(pair, layout) + T.eq(decoder:setPaletteTicks(0), true, "first static picture installed") + local first, original = decoder.paletteState, Copy(pair) + for tick=1,600 do + T.eq(decoder:setPaletteTicks(tick), false, "static terrain never recolours") + T.eq(decoder.paletteState, first, "static picture retains its owned colour tables") + end + T.eq(first.ticks,600,"static clock still advances") + T.same(pair,original,"sampling does not mutate shared cartridge data") + -- In-place edits, including entries outside the active bank split, must + -- invalidate a picture; identity-only source caching would miss these. + pair.secondary.palettes[1][1][2]=(pair.secondary.palettes[1][1][2]+1)%256 + T.eq(decoder:setPaletteTicks(601),true,"same-table source edit is observed") + T.check(decoder.paletteState~=first,"changed source receives fresh owned picture") + local changed=decoder.paletteState + T.eq(first.secondary[1][1][2],original.secondary.palettes[1][1][2],"old picture remains detached") + pair.primary.paletteCycles={{kind="rotate",first=17,count=4,step=1,direction=1}} + T.eq(decoder:setPaletteTicks(602),true,"static-to-animated transition is sampled") + T.check(#decoder.paletteState.primaryFrames==1,"animation resumes") + pair.primary.paletteCycles={} + T.eq(decoder:setPaletteTicks(603),true,"animated-to-static transition restores base") + local function reject(edit) + pair.primary=Copy(original.primary);pair.secondary=Copy(original.secondary) + decoder.primary,decoder.secondary=pair.primary,pair.secondary + assert(decoder:setPaletteTicks(0)~=nil) + edit(pair) + local _,reason=decoder:setPaletteTicks(1) + T.eq(decoder.paletteState,nil,"invalid source cannot reuse stale colours") + T.eq(reason,"palette animation data unavailable","invalid static source declines") + end + reject(function(p)p.primary.paletteCycles=nil end) + reject(function(p)p.secondary.paletteCycles.extra=true end) + reject(function(p)p.primary.palettes[2].extra=true end) + reject(function(p)p.secondary.palettes[16][16][3]=256 end) + reject(function(p)p.primary.palettes[1][1][1]=0/0 end) + reject(function(p)p.primary.palettes[1][1]=setmetatable({1,2,3},{}) end) + decoder.primary,decoder.secondary=original.primary,original.secondary + decoder:setPaletteTicks(0) + local _,reason=decoder:setPaletteTicks(-1) + T.eq(decoder.paletteState,nil,"invalid clock cannot retain static picture") + T.eq(reason,"terrain clock unavailable","clock rejection preserved") + decoder:setPaletteTicks(0);decoder.palettesInPrimary=0 + decoder:setPaletteTicks(1) + T.eq(decoder.paletteState,nil,"invalid split cannot reuse static picture") + f.run.release() +end +T.finish("Native static palette reuse and mutable-source invalidation") diff --git a/tests/engine/hot_modules.lua b/tests/engine/hot_modules.lua new file mode 100644 index 000000000..a234303cf --- /dev/null +++ b/tests/engine/hot_modules.lua @@ -0,0 +1,52 @@ +local T = require("tests.harness") +local Performance = require("src.core.Performance") +local Hot = require("src.core.HotModules") +local name = "tests.hot_module_probe" +local originalRequire = require +local calls, loads = 0, 0 +package.preload[name] = function() + loads = loads + 1 + return { generation = loads } +end +_G.require = function(path, ...) + calls = calls + 1 + return originalRequire(path, ...) +end +Performance.hotPathHoist = true +local first = Hot.get(name) +T.eq(loads, 1, "cold dependency is loaded once") +T.eq(Hot.get(name), first, "warm module retains identity") +T.eq(calls, 1, "warm path avoids require dispatch") +first.liveField = "changed" +T.eq(Hot.get(name).liveField, "changed", "module field mutations remain live") +local replacement = { generation = 20 } +package.loaded[name] = replacement +T.eq(Hot.get(name), replacement, "explicit package.loaded replacement is observed") +package.loaded[name] = nil +T.eq(Hot.get(name).generation, 2, "clearing package.loaded reloads the dependency") +Performance.hotPathHoist = false +local before = calls +Hot.get(name) +Hot.get(name) +T.eq(calls - before, 2, "P3 disabled restores normal require dispatch") +Performance.hotPathHoist = true +package.loaded[name] = nil +package.preload[name] = function() + error("deliberate optional load failure") +end +local ok = Hot.optional(name) +T.eq(ok, false, "optional dependency errors remain contained") +package.loaded[name] = nil +package.preload[name] = function() + return replacement +end +local value +ok, value = Hot.optional(name) +T.check(ok and value == replacement, "failed loads are not memoized as successful modules") +before = calls +ok, value = Hot.optional(name) +T.check(ok and value == replacement, "optional warm module retains its result") +T.eq(calls, before, "optional warm path avoids pcall/require dispatch") +_G.require = originalRequire +package.loaded[name], package.preload[name] = nil, nil +T.finish() diff --git a/tests/engine/hot_renderer_records.lua b/tests/engine/hot_renderer_records.lua new file mode 100644 index 000000000..4d4fd3eff --- /dev/null +++ b/tests/engine/hot_renderer_records.lua @@ -0,0 +1,121 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Performance = require("src.core.Performance") +local Version = require("src.core.GameVersion") +Version.set("red") +local Renderer = require("src.render.Renderer") +local Game = require("src.core.Game") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local Stack = require("src.core.StateStack") +local w, h = 640, 480 +love.graphics.getDimensions = function() + return w, h +end +love.graphics.getPixelDimensions = function() + return w, h +end +Renderer:init() +local function present(private) + Renderer:beginFrame() + if private then + return Renderer:_endFrameForEngine() + end + return Renderer:endFrame() +end +for _, enabled in ipairs({ false, true }) do + Performance.hotPathHoist = enabled + w, h = 640, 480 + local first = present() + local oldUI = Renderer.uiPresentation + w, h = 800, 600 + local second = present() + T.check(first ~= second, "public endFrame returns detached records with P3 " .. tostring(enabled)) + T.eq(first.width, 640, "retained public viewport keeps its original width") + T.eq(second.width, 800, "next public viewport has current width") + T.eq(oldUI == Renderer.uiPresentation, enabled, "existing renderer-owned UI view follows the switch") +end +Performance.hotPathHoist = true +local first = present(true) +T.eq(present(true), first, "unobserved native result can reuse private storage") +local foreign = { custom = true } +Renderer.uiPresentation = foreign +present() +T.check(Renderer.uiPresentation ~= foreign, "engine does not reuse a replacement owned by a mod") +T.eq(foreign.x, nil, "replacement supplied by a mod is not mutated") +local realBlit = Renderer.blitCanvas +local forwarded +Renderer.blitCanvas = function(self, ...) + forwarded = { self, ... } + return "forwarded" +end +local canvas, zones = {}, {} +T.eq( + Renderer:_blitAtDpi(1.5, 2, canvas, 3, 4, zones, 5, 6, 7, 8, 9, 10, 11, 12), + "forwarded", + "hoisted blit preserves the public return" +) +T.eq(forwarded[1], Renderer, "hoisted blit preserves the public receiver") +T.eq(forwarded[2], canvas, "hoisted blit preserves canvas identity") +T.eq(forwarded[5], zones, "hoisted blit preserves zone identity") +T.eq(forwarded[14], 1.5, "hoisted blit keeps horizontal DPI in the original position") +T.eq(forwarded[15], 2, "hoisted blit keeps vertical DPI in the original position") +Renderer.blitCanvas = realBlit + +local hooks = Hooks.new() +Runtime.hooks = hooks +local state = { draw = function() end } +local stack = setmetatable({ states = { state } }, { __index = Stack }) +local fake = setmetatable( + { stack = stack, overworld = {}, data = require("tests.modkit.fixtures").fresh(), save = { options = {} } }, + { __index = Game } +) +local received = {} +hooks:wrap("render.hud", function(nextFn, game, viewport) + T.eq(game, fake, "HUD receives the original game") + received[#received + 1] = viewport + return nextFn(game, viewport) +end, 0, "retention") +w, h = 640, 480 +fake:_draw() +w, h = 800, 600 +fake:_draw() +T.check(received[1] ~= received[2], "HUD never receives recycled private results") +T.eq(received[1].width, 640, "retained HUD viewport remains unchanged") +T.eq(received[2].width, 800, "next HUD viewport receives current geometry") +local original = Renderer.endFrame +local wrapped, offered = 0, nil +Renderer.endFrame = function(self, ...) + wrapped = wrapped + 1 + offered = original(self, ...) + offered.modField = "kept" + return offered +end +fake:_draw() +T.eq(wrapped, 1, "existing public endFrame wrapper is not bypassed") +T.eq(received[#received], offered, "wrapper record is forwarded without replacing its identity") +T.eq(received[#received].modField, "kept", "wrapper-added fields remain available") +Renderer.endFrame = original +hooks:removeOwner("retention") +received = {} +local installed = false +hooks:wrap("render.compose", function(nextFn, ...) + if not installed then + installed = true + hooks:wrap("render.hud", function(nextHud, game, viewport) + received[#received + 1] = viewport + return nextHud(game, viewport) + end, 0, "late-hud") + end + return nextFn(...) +end, 0, "late-compose") +w, h = 640, 480 +fake:_draw() +w, h = 800, 600 +fake:_draw() +T.check(received[1] ~= received[2], "a HUD registered during compose also receives detached records") +T.eq(received[1].width, 640, "late registration does not leak mutable native metrics") +hooks:removeOwner("late-hud") +hooks:removeOwner("late-compose") +-- Window UI is absent from this engine base; the fork exercises that path. +T.finish() diff --git a/tests/engine/leafgreen_manifest.lua b/tests/engine/leafgreen_manifest.lua new file mode 100644 index 000000000..4bf72e9ec --- /dev/null +++ b/tests/engine/leafgreen_manifest.lua @@ -0,0 +1,25 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local X=require('src.import.RomExtractorGen3') +local Json=require('src.link.Json') +local f=assert(io.open('tools/rom_manifest_leafgreen.json')) +local m=Json.decode(f:read('*a'));f:close() +local originalLogo=X.FRLG_TITLE.LOGO.TILES +local originalMon=X.FRLG_ART.CREDITS.MONS[1].species +X.applyAddressMap(m.addressMap) +X.applyStageOverrides(m.stageOverrides) +T.eq(m.romSha1,'574fa542ffebb14be69902d1d36f1ec0a4afd71e','canonical independent ROM') +T.eq(m.tradeArt.romKey,'BPGE0','own trade graphics contract') +T.eq(X.FRLG_TITLE.EFFECT,'leaves','LeafGreen title effect') +T.eq(X.FRLG_TITLE.CRY,'VENUSAUR','LeafGreen title cry') +T.eq(X.FRLG_TITLE.FLAME_X,false,'no FireRed flame positions') +T.eq(X.FRLG_ART.CREDITS.MONS[1].species,originalMon,'mixed pose record retains named species') +T.eq(type(X.FRLG_ART.CREDITS.MONS[1][1]),'table','mixed pose record retains numeric pose') +T.eq(X.FRLG_START_MENU.LABELS[4],false,'sparse native menu slot retained') +T.eq(X.FRLG_BEHAVIOUR_OVERRIDES[0x11],0x5B,'numeric behaviour keys decoded') +X.applyAddressMap(nil) +T.eq(X.FRLG_TITLE.LOGO.TILES,originalLogo,'next import gets original table back') +T.eq(X.FRLG_TITLE.EFFECT,'flames','next FireRed import gets its own effect') +T.eq(X.FRLG_ART.CREDITS.MONS[1].species,originalMon,'next import keeps original mixed record') +T.finish('LeafGreen manifest and stage isolation') diff --git a/tests/engine/leafgreen_registration.lua b/tests/engine/leafgreen_registration.lua new file mode 100644 index 000000000..74fc1f3fd --- /dev/null +++ b/tests/engine/leafgreen_registration.lua @@ -0,0 +1,36 @@ +-- LeafGreen is its own cartridge; only FRLG gameplay/UI contracts are shared. +package.path = './?.lua;./?/init.lua;' .. package.path +love = love or require('tests.love_stub') +local T = require('tests.harness') +local V = require('src.core.GameVersion') +local R = require('src.import.RomRevisions') +local Bag = require('src.inventory.Bag') +local previous = V.get() +local leaf = V.info('leafgreen') +T.eq(leaf.sha1, '574fa542ffebb14be69902d1d36f1ec0a4afd71e', 'canonical LeafGreen Rev 0 hash') +T.eq(leaf.importable, true, 'LeafGreen import enabled') +T.eq(leaf.generation, 3, 'LeafGreen native generation') +T.eq(leaf.cachePrefix, 'leafgreen/', 'independent cartridge cache') +T.eq(leaf.saveSuffix, '_leafgreen', 'independent cartridge save') +T.eq(R.acceptedRow(leaf.sha1), 'leafgreen', 'revision explanation agrees with importer') +T.eq(R.acceptedRow('7862c67bdecbe21d1d69ce082ce34327e1c6ed5e'), nil, 'unmapped Rev 1 is still rejected') +for _, id in ipairs{'firered','leafgreen'} do + V.set(id) + T.eq(V.isFRLG(), true, id .. ' uses FRLG contracts') + local save = { inventory = {}, bagOrder = {} } + local data = {constants={gen3Bag={pockets={ITEM=42,KEY_ITEM=30,BALL=13,TM_HM=58,BERRIES=43}}}, + items={TM39={pocket='TM_HM',machine={kind='TM',move='ROCK_TOMB'}},TM_CASE={pocket='KEY_ITEM',keyItem=true}}} + T.eq(Bag.add(save,'TM39',1,data),true,id .. ' accepts native TM') + T.eq(save.inventory.TM_CASE,1,id .. ' creates the native TM CASE') +end +V.set('emerald') +T.eq(V.isFRLG(),false,'Hoenn does not inherit Kanto rules') +local order={};for i,id in ipairs(V.ORDER)do order[id]=i end +T.eq(order.leafgreen,order.firered+1,'LeafGreen is reachable beside FireRed') +local gen3Between=order.emerald>order.leafgreen +for i=order.leafgreen+1,order.emerald-1 do + gen3Between=gen3Between and V.info(V.ORDER[i]).generation==3 +end +T.check(gen3Between,'Emerald follows the Kanto pair with only Gen3 siblings between') +V.set(previous) +T.finish('LeafGreen registration and FRLG contracts') diff --git a/tests/engine/log_reporting.lua b/tests/engine/log_reporting.lua new file mode 100644 index 000000000..78c4088ef --- /dev/null +++ b/tests/engine/log_reporting.lua @@ -0,0 +1,91 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Manifest=require('src.mods.Manifest') +local Net=require('src.mods.Net') +local Fetch=require('src.net.Fetch') +local Transport=require('src.net.BoundedHttp') +local Log=require('src.mods.LogReport') +local Json=require('src.link.Json') +local function raw(extra) + local m={id='logmod',name='Log mod',version='1.0.0',api=2,entry='main.lua',permissions={'network'},log_url='https://logs.example.invalid/ingest'} + for k,v in pairs(extra or {})do m[k]=v end;return m +end +T.eq(Manifest.validate(raw()).log_url,'https://logs.example.invalid/ingest','manifest retains fixed destination') +for _,url in ipairs({'http://logs.invalid','https://','file:///log','https://log.invalid\nX: y',false})do + T.raises(function()Manifest.validate(raw({log_url=url}))end,'log_url','invalid destination rejected before entry') +end +T.raises(function()Manifest.validate(raw({permissions={}}))end,'network','strict declaration needs network grant') +T.eq(Manifest.validate(raw({api=1,permissions={}})).log_url,nil,'legacy warning cannot retain an ungranted endpoint') +local body='a\0\255\r\n`literal` $(literal) "quote"' +T.eq(Log.prepare('a',body),body,'text preserves arbitrary bytes') +T.eq(Log.prepare('a',string.rep('x',65536)),string.rep('x',65536),'exact input limit accepted') +T.eq(Log.prepare('a',string.rep('x',65537)),nil,'oversized input refused') +for _,opts in ipairs({false,'json',{format=false},{format=1},{format='xml'},{url='https://other.invalid'}, + {headers={}},{maxSeconds=99},setmetatable({},{__index=function()error('must not run')end})})do + T.eq(Log.prepare('a','x',opts),nil,'closed option contract rejects unknown switches and values') +end +local encoded,mime=Log.prepare('a','hello',{format='json'});local envelope=Json.decode(encoded) +T.eq(mime,'application/json','JSON MIME fixed');T.eq(envelope.mod,'a','envelope attributes owner') +T.eq(envelope.body,'hello','envelope retains body');T.eq(envelope.format,'json','envelope format') +T.eq(type(envelope.ts),'number','envelope captures one timestamp') +local original,available={},Transport.available +for _,name in ipairs({'get','postLog','poll','cancel','release','isPending'})do original[name]=Fetch[name]end +Transport.available=function()return true end +local calls,jobs,nextId={},{},0 +local function start(method,url,payload,opts) + nextId=nextId+1;jobs[nextId]={status='pending'} + calls[#calls+1]={method=method,url=url,body=payload,opts=opts,id=nextId};return nextId +end +Fetch.get=function(url,opts)return start('GET',url,nil,opts)end +Fetch.postLog=function(url,payload,opts)return start('POST',url,payload,opts)end +Fetch.poll=function(id)return jobs[id] or {status='error'}end +Fetch.cancel=function(id)if jobs[id]then jobs[id]={status='cancelled'}end end +Fetch.release=function(id)jobs[id]=nil end +Fetch.isPending=function(id)return jobs[id] and jobs[id].status=='pending'end +local loader={};local mod={manifest=Manifest.validate(raw())} +local api=Net.forMod(loader,mod);local send=Net.logReporter(loader,mod) +mod.manifest.log_url='https://changed.invalid' +local handle=assert(send(nil,body)) +local call=calls[#calls] +T.eq(call.url,'https://logs.example.invalid/ingest','runtime manifest mutation cannot choose another destination') +T.eq(call.body,body,'worker receives literal body bytes');T.eq(call.method,'POST','log uses POST') +T.eq(call.opts.userAgent,'gen2recomp-mod/logmod','caller identity cannot be overridden') +T.eq(call.opts.boundedHttpsGet,1,'HTTPS constraint is mandatory') +jobs[call.id]={status='ok',body='server private body',err='server diagnostic',raw='server private body',httpStatus=204,progress=1} +local state=api:poll(handle) +T.eq(state.status,'ok','accepted transfer result');T.eq(state.body,nil,'reply body never reaches mod') +T.eq(state.raw,nil,'one-way result is a closed public shape');T.eq(state.err,nil,'success cannot leak arbitrary diagnostics') +T.eq(state.httpStatus,204,'HTTP status remains observable');api:release(handle) +T.eq(next(loader.netOneWay.logmod),nil,'release removes private log-handle metadata') +local other=Net.forMod(loader,{manifest={id='other',permissionSet={network=true}}}) +handle=assert(send(nil,'failure'));call=calls[#calls] +jobs[call.id]={status='error',body='server secret',err='server secret'} +T.eq(api:poll(handle).err,'log upload failed','remote failures have a stable diagnostic') +T.eq(other:poll(handle).status,'error','a log handle belongs to its own mod') +api:release(handle) +local count=#calls +T.eq(send(nil,'x',{url='https://override.invalid'}),nil,'per-call destination override refused') +T.eq(#calls,count,'invalid options submit no request') +local held={} +for i=1,3 do held[i]=assert(api:get('https://data.invalid'))end +held[4]=assert(send(nil,'one more')) +T.eq(send(nil,'over quota'),nil,'GET and log share the per-mod ceiling') +Net.releaseAll(loader,'logmod') +T.eq(api:poll(held[4]).status,'error','unload invalidates log requests') +local missing=Net.logReporter(loader,{manifest={id='none',permissionSet={network=true}}}) +T.eq(missing(nil,'x'),nil,'missing destination is a normal refusal') +local denied=Net.logReporter(loader,{manifest={id='denied',permissionSet={}}}) +T.raises(function()denied(nil,'x')end,'network','ungranted call names its permission') +Transport.available=function()return false end +T.eq(send(nil,'offline'),nil,'unsupported native transport does not advertise a successful send') +Transport.available=function()return true end +local files={['mods/logmod/manifest.json']=Json.encode(raw()),['mods/logmod/main.lua']=[[ +local mod=...;mod.exports.handle=assert(mod:postLog('from actual loader'));mod.exports.fetch=mod.fetch +]]} +local loaded=T.sdk.loadMod('mods/logmod',{fs=T.sdk.memfs(files)}) +T.eq(loaded.mod.state,'loaded','actual mod loader installs postLog') +T.eq(calls[#calls].body,'from actual loader','actual entry reaches the native transport facade') +loaded.release();Net.unloadAll() +Transport.available=available;for name,method in pairs(original)do Fetch[name]=method end +T.finish('Native fixed-destination log reporting') diff --git a/tests/engine/logic_speed.lua b/tests/engine/logic_speed.lua new file mode 100644 index 000000000..cee2e37f0 --- /dev/null +++ b/tests/engine/logic_speed.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/logic_speed/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +dofile("tests/logic_speed/cases.lua").run(f) +f.close() +f.T.finish("portable core.logic_speed") diff --git a/tests/engine/logic_speed_clock.lua b/tests/engine/logic_speed_clock.lua new file mode 100644 index 000000000..1ed5faff4 --- /dev/null +++ b/tests/engine/logic_speed_clock.lua @@ -0,0 +1,161 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Game = require("src.core.Game") +local FixedStep = require("src.core.FixedStep") +local Runtime = require("src.mods.Runtime") +local Hooks = require("src.mods.Hooks") +local noop = function() end +local g = setmetatable({ + bootConfig = noop, + adoptSave = noop, + applyOptions = noop, + updateCameraStick = noop, +}, { __index = Game }) +local savedModules, savedUpvalues = {}, {} +local function module(name, value) + savedModules[name] = { package.loaded[name] } + package.loaded[name] = value +end +local function upvalue(name, value) + for i = 1, 100 do + local key, original = debug.getupvalue(Game.load, i) + if not key then + break + end + if key == name then + savedUpvalues[i] = original + debug.setupvalue(Game.load, i, value) + return + end + end + error("missing load upvalue: " .. name) +end +-- Stop real Game:load immediately after it installs its real FixedStep +-- callback. Unrelated ROM loading, rendering and save services are isolated. +upvalue("Data", { load = noop, applyMapEditorOverlay = noop, publishGen2Palettes = noop }) +upvalue("ModLoader", { + new = function() + return { load = noop, status = noop } + end, +}) +for _, name in ipairs({ "Input", "TouchControls", "Renderer" }) do + upvalue(name, { init = noop }) +end +upvalue("StateStack", { init = noop }) +upvalue("SaveData", { + loadOptions = noop, + newGame = function() + return { options = { speed = 4 } } + end, +}) +module("src.render.Pipelines", { install = noop }) +module("src.translation.Service", { install = noop }) +for _, name in ipairs({ "src.render.Font", "src.ui.Theme", "src.core.Strings" }) do + module(name, { load = noop }) +end +local init = FixedStep.init +local stop = {} +FixedStep.init = function(self, callback) + init(self, callback) + error(stop) +end +local ok, err = pcall(Game.load, g) +FixedStep.init = init +for index, value in pairs(savedUpvalues) do + debug.setupvalue(Game.load, index, value) +end +T.eq(ok, false, "test stops at the intended boot boundary") +T.eq(err, stop, "real Game:load reached FixedStep installation") +T.eq(type(FixedStep.callback), "function", "native step callback captured") + +local logic, audio, tilt, pipeline, camera = 0, 0, 0, 0, 0 +module("src.pokemon.Gen4PoketchState", { tick = noop }) +module("src.core.Music", { + update = function() + audio = audio + 1 + end, +}) +module("src.render.Tilt", { + update = function(dt) + tilt = tilt + dt + end, +}) +package.loaded["src.render.Pipelines"] = { + update = function(dt) + pipeline = pipeline + dt + end, +} +module("src.core.DiscordPresence", { update = noop }) +g.updateCameraStick = function(_, dt) + camera = camera + dt +end +local hooks, savedHooks = Hooks.new(), Runtime.hooks +Runtime.hooks = hooks +local polls, value = 0, 4 +local off = hooks:wrap("core.logic_speed", function(next, game) + T.eq(game, g, "native update passes its Game to speed hook") + polls = polls + 1 + return value +end) +g.stack = { states = {} } +local lockAt, lockKind +function g:step(dt) + logic = logic + 1 + T.eq(dt, FixedStep.STEP, "simulation retains fixed delta") + if logic == lockAt then + if lockKind == "link" then + self.linkSession = true + else + self.stack.states = { { isMinigame = true }, { isMenu = true } } + end + end +end +local function reset() + logic, audio, tilt, pipeline, camera = 0, 0, 0, 0, 0 + FixedStep.accum, FixedStep.suppressCatchup = 0, false + g.audioAccum, g.linkSession, g.stack.states = 0, nil, {} +end +for _, speed in ipairs({ 1, 4, 20, 100, 200 }) do + reset() + value = speed + local before = polls + g:update(FixedStep.STEP) + -- Floating point subtraction can leave the final step just below STEP. + T.check(logic >= speed - 1 and logic <= speed, "speed controls native logic budget") + T.eq(polls, before + 1, "one speed dispatch per Game:update, not per simulation step") + T.eq(audio, 1, "audio stays at real-time 60Hz") + T.eq(tilt, FixedStep.STEP, "tilt uses real delta") + T.eq(pipeline, FixedStep.STEP, "render pipelines use real delta") + T.eq(camera, FixedStep.STEP, "camera uses real delta") + T.eq(g._frameSpeed, speed, "native update records frame speed for lock edges") +end +for _, kind in ipairs({ "link", "fixed" }) do + reset() + value, lockAt, lockKind = 200, 3, kind + local before = polls + g:update(FixedStep.STEP) + T.eq(logic, 3, kind .. " starts mid-frame and cancels remaining accelerated ticks") + T.eq(FixedStep.accum, 0, kind .. " drains only the current budget") + T.eq(FixedStep.suppressCatchup, false, kind .. " does not arm hitch suppression") + T.eq(audio, 1, kind .. " leaves audio real-time") + g:update(FixedStep.STEP) + T.eq(logic, 4, kind .. " next frame runs at literal 1X") + T.eq(polls, before + 1, kind .. " locked next frame bypasses hook") + g.linkSession, g.stack.states = nil, {} + lockAt = nil + value = 4 + g:update(FixedStep.STEP) + T.check(logic >= 7 and logic <= 8, kind .. " unlock restores accelerated simulation") +end +reset() +value = 0 / 0 +g:update(FixedStep.STEP) +T.eq(logic, 1, "invalid mod speed cannot poison simulation clock") +T.eq(g._frameSpeed, 1, "invalid mod speed normalized before accumulation") +T.eq(audio, 1, "invalid mod speed cannot poison audio clock") +off() +Runtime.hooks = savedHooks +for name, original in pairs(savedModules) do + package.loaded[name] = original[1] +end +T.finish("core.logic_speed native clocks") diff --git a/tests/engine/logic_speed_routing.lua b/tests/engine/logic_speed_routing.lua new file mode 100644 index 000000000..c73cd17a3 --- /dev/null +++ b/tests/engine/logic_speed_routing.lua @@ -0,0 +1,76 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/logic_speed/fixture.lua") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, g, api = f.exports, f.game, f.public + local label = version .. "/" .. mode[1] .. "/" .. mode[2] .. ": " + T.eq(api:supports("hooks", "core.logic_speed"), true, label .. "native capability") + T.eq(g:logicSpeed(), 4, label .. "native option through hook") + T.eq(e.seen.game, g, label .. "opaque owner identity") + T.eq(e.seen.extra, 0, label .. "scalar signature") + T.eq(e.seen.calls, 1, label .. "one dispatch") + e.setSpeed(20) + T.eq(g:logicSpeed(), 20, label .. "override") + local calls = e.seen.calls + g.linkNet = { closed = false } + g.speedOverride = 100 + T.eq(g:logicSpeed(), 1, label .. "open link beats override") + T.eq(e.seen.calls, calls, label .. "link bypasses hook") + g.linkNet.closed = true + T.eq(g:logicSpeed(), 100, label .. "closed link restores explicit override") + T.eq(e.seen.calls, calls, label .. "explicit override bypasses hook") + g.speedOverride = nil + T.eq(g:logicSpeed(), 20, label .. "closed link permits hook") + for _, marker in ipairs({ "isFixedSpeed", "isMinigame" }) do + g.stack.states = { { [marker] = true }, { isMenu = true } } + calls = e.seen.calls + T.eq(g:logicSpeed(), 1, label .. marker .. " stays locked under a menu") + T.eq(select(2, g:speedLocked()), "minigame", label .. "lock reason") + T.eq(e.seen.calls, calls, label .. "fixed-speed bypasses hook") + end + g.stack = nil + T.eq(g:logicSpeed(), 20, label .. "missing stack is unlocked") + -- Loader ownership removes only the affected mod's contribution. + local peers = 0 + local off = f.result.loader.hooks:wrap("core.logic_speed", function(next, ...) + peers = peers + 1 + return next(...) + end, 10, "other_speed_owner") + f.result.loader.hooks:removeOwner("portable_logic_speed") + calls = e.seen.calls + T.eq(g:logicSpeed(), 4, label .. "owner cleanup restores native option") + T.eq(e.seen.calls, calls, label .. "owner cleanup retires consumer") + T.eq(peers, 1, label .. "other owner still runs") + off() + f.close() + end +end +local bare = Fixture.new(".", "gen2", "red", 3, "unified", true) +T.eq(Runtime.wantsHook("core.logic_speed"), false, "no-mod fast path") +T.eq(bare.game:logicSpeed(), 4, "no-mod native option") +bare.game.save = nil +T.eq(bare.game:logicSpeed(), 1, "no save uses native default") +bare.close() +local files = { + ["mods/broken_speed/manifest.json"] = [[{"id":"broken_speed","name":"Broken Speed", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_speed/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("core.logic_speed", function() return 200 end) + error("intentional speed entry failure") + ]], +} +local failed = T.sdk.loadMods({ "mods/broken_speed" }, { fs = T.sdk.memfs(files), data = T.fixtures.fresh() }) +T.check(#failed.errors > 0, "failed entry is reported") +T.eq(Runtime.wantsHook("core.logic_speed"), false, "entry rollback removes speed hook") +Loader.teardown() +T.eq(Runtime.wantsHook("core.logic_speed"), false, "teardown removes speed hook") +failed.release() +Version.set(originalVersion) +T.finish("core.logic_speed routing") diff --git a/tests/engine/mod_input_headless.lua b/tests/engine/mod_input_headless.lua new file mode 100644 index 000000000..15f816759 --- /dev/null +++ b/tests/engine/mod_input_headless.lua @@ -0,0 +1,64 @@ +-- Construct/query the input facade without a live Game, Input or LOVE runtime. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +love = nil +local Loader = require("src.mods.Loader") +local Json = require("src.link.Json") +local manifest = Json.encode({ + id = "headless_input", + name = "Headless Input", + version = "1.0.0", + api = 2, + entry = "main.lua", +}) +local files = { + ["mods/headless_input/manifest.json"] = manifest, + ["mods/headless_input/main.lua"] = [[ + local mod = ... + mod.exports.input = mod.input + mod.exports.hasHandle = type(mod.input.tap) == "function" + and type(mod.input.press) == "function" and type(mod.input.release) == "function" + ]], +} +local fs = { + read = function(path) + return files[path] + end, + load = function(path) + return loadstring(files[path], path) + end, + getInfo = function(path) + if path == "mods" or path == "mods/headless_input" then + return { type = "directory" } + end + if files[path] then + return { type = "file" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { "headless_input" } or {} + end, +} +local oldGame, oldInput = package.loaded["src.core.Game"], package.loaded["src.core.Input"] +local loader = Loader.new({ fs = fs, dev = true }) +loader._game = function() + error("mod.input must not bootstrap Game") +end +T.check(loader:load({ pokemon = {} }), "headless native API loads") +T.eq(#loader.errors, 0, "no load-time error") +local e = assert(loader.exports.headless_input) +T.eq(e.hasHandle, true, "complete facade available inside entry chunk") +for _, method in ipairs({ "tap", "press" }) do + for _, game in ipairs({ false, 1, "game", {}, { input = {} } }) do + T.raises(function() + e.input[method](e.input, game, "a") + end, "mod.input needs the live game", "invalid game rejected before Input work") + end +end +T.eq(e.input:release({}), false, "unknown token is safe before Game exists") +T.eq(e.input:release(nil), false, "missing token is safe before Game exists") +T.eq(package.loaded["src.core.Game"], oldGame, "does not import Game") +T.eq(package.loaded["src.core.Input"], oldInput, "does not import Input") +T.eq(love, nil, "does not install LOVE globals") +loader:_closeModInput() +T.finish("headless mod.input") diff --git a/tests/engine/mod_input_lifecycle.lua b/tests/engine/mod_input_lifecycle.lua new file mode 100644 index 000000000..32267d04f --- /dev/null +++ b/tests/engine/mod_input_lifecycle.lua @@ -0,0 +1,190 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/mod_input/fixture.lua") +local Control = require("src.mods.InputControl") +local Input = require("src.core.Input") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Game = require("src.core.Game") +local Touch = require("src.core.TouchControls") +local savedVersion = Version.get() +Touch:init() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local label = version .. "/" .. mode[1] .. "/" .. mode[2] .. ": " + f.ready() + local own, peer, g = f.primary, f.peer, f.game + setmetatable(g, { __index = Game }) + for _, recover in ipairs({ + function() + g:focus(false) + end, + function() + g:focus(true) + end, + function() + g:visible(false) + end, + function() + g:visible(true) + end, + function() + g:joystickremoved(nil) + end, + function() + Input:init() + end, + }) do + local token = own.press("up") + Input:step() + recover() + T.eq(Input:isDown("up"), false, label .. "native recovery clears hold") + T.eq(own.release(token), false, label .. "native recovery retires token") + local fresh = own.press("up") + T.eq(own.release(token), false, label .. "old token cannot affect resumed input") + T.eq(Input:isDown("up"), true, label .. "new input remains held") + T.eq(own.release(fresh), true, label .. "API remains usable after recovery") + end + local api = f.result.loader.apis.portable_mod_input + local seen = 0 + for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + api.hooks:wrap(name, function(next, ...) + seen = seen + 1 + return next(...) + end) + end + own.tap("a") + local token = own.press("b") + own.release(token) + T.eq(seen, 0, label .. "logical input bypasses raw hooks/hotkeys") + g:keypressed("up") + g:keyreleased("up") + T.eq(seen, 2, label .. "physical input still dispatches raw hooks") + -- Logical input can be used within a raw hook without recursion. + local held + local off = api.hooks:wrap("input.key", function(_, game, event) + if event.phase == "pressed" then + held = own.input:press(game, "a") + end + end, 100) + g:keypressed("up") + T.eq(Input:isDown("up"), false, label .. "raw hook can suppress original action") + T.eq(Input:isDown("a"), true, label .. "raw hook can request logical action") + T.eq(own.release(held), true, label .. "logical hold from hook releases") + off() + -- Replacing a mod's handle retires the old one without touching peers. + token = own.press("select") + local peerToken = peer.press("select") + local fresh = f.result.loader.inputControl:api("portable_mod_input") + T.eq(own.release(token), false, label .. "replaced API retires old token") + T.eq(Input:isDown("select"), true, label .. "replacement preserves peer") + T.raises(function() + own.press("a") + end, "no longer active", label .. "old API cannot press again") + token = fresh:press(g, "select") + peer.release(peerToken) + T.eq(Input:isDown("select"), true, label .. "new handle owns independent source") + T.eq(fresh:release(token), true, label .. "new handle releases normally") + f.close() + f.result.loader:_closeModInput() + end +end +-- A reset retires tokens for that Input instance only. +Input:init() +local other = setmetatable({}, { __index = Input }) +other:init() +local control = Control.new() +local api = control:api("isolation") +local a = api:press({ input = Input }, "a") +local b = api:press({ input = other }, "a") +Input:reset() +T.eq(api:release(a), false, "reset retires matching Input token") +T.eq(other:isDown("a"), true, "another Input instance remains held") +T.eq(api:release(b), true, "another Input instance's token remains valid") +control:close() +-- Two sessions with the same mod ID still own different sources. +local one, two = Control.new(), Control.new() +local x, y = one:api("same_id"), two:api("same_id") +a = x:press({ input = Input }, "b") +b = y:press({ input = Input }, "b") +one:close() +T.eq(x:release(a), false, "closed session token retired") +T.eq(Input:isDown("b"), true, "closing old session preserves replacement source") +T.eq(y:release(b), true, "replacement session releases normally") +two:close() +-- A failed entry is cleaned through the real loader's rollback path. +Input:init() +local capture = { game = { input = Input } } +_G.MOD_INPUT_ROLLBACK_FIXTURE = capture +local files = {} +local Json = require("src.link.Json") +for _, id in ipairs({ "aaa_input_peer", "broken_input" }) do + files["mods/" .. id .. "/manifest.json"] = Json.encode({ + id = id, + name = id, + version = "1.0.0", + api = 3, + games = { "all" }, + generations = { 1, 2, 3, 4, 5 }, + entry = "main.lua", + }) +end +files["mods/aaa_input_peer/main.lua"] = [[ + local mod = ... + local f = MOD_INPUT_ROLLBACK_FIXTURE + f.peer, f.peerToken = mod.input, mod.input:press(f.game, "b") +]] +files["mods/broken_input/main.lua"] = [[ + local mod = ... + local f = MOD_INPUT_ROLLBACK_FIXTURE + f.broken, f.brokenToken = mod.input, mod.input:press(f.game, "b") + f.brokenSelect = mod.input:press(f.game, "select") + error("intentional mod.input rollback") +]] +local failed = T.sdk.loadMods({ "mods/aaa_input_peer", "mods/broken_input" }, { + data = T.fixtures.fresh(), + fs = T.sdk.memfs(files), + dev = false, +}) +_G.MOD_INPUT_ROLLBACK_FIXTURE = nil +T.check(#failed.errors > 0, "failed input entry reported") +T.eq(Input:isDown("select"), false, "failed entry releases its exclusive hold") +T.eq(Input:isDown("b"), true, "failed entry preserves successful peer hold") +T.eq(capture.broken:release(capture.brokenToken), false, "rollback retires failed token") +T.eq(capture.peer:release(capture.peerToken), true, "peer still owns its token") +T.raises(function() + capture.broken:press(capture.game, "a") +end, "no longer active", "rolled-back API retired") +failed.loader:_closeModInput() +failed.release() +-- Native hot reload closes the old service before building replacement mods. +local f = Fixture.new(".", "gen2", "red", 3, "unified") +f.ready() +a = f.primary.press("a") +f.game.data = T.fixtures.fresh() +rawset(f.game.data, "reloadGenerated", function() end) +local replacement = require("src.dev.HotReload").run(f.game, { fs = T.sdk.memfs({}), dev = false }) +T.eq(#replacement.errors, 0, "ROM-free hot reload succeeds") +T.eq(Input:isDown("a"), false, "hot reload releases old holds") +T.eq(f.primary.release(a), false, "hot reload retires old token") +T.raises(function() + f.primary.press("a") +end, "no longer active", "old service cannot press after reload") +f.close() +-- Native teardown retires all handles, including holds whose caller lost +-- its token/loader references. Outstanding sources must survive GC to cleanup. +Input:init() +Input:keypressed("z") +do + local orphan = Control.new():api("orphan") + orphan:press({ input = Input }, "b") +end +collectgarbage("collect") +Loader.teardown() +T.eq(Input:isDown("b"), false, "teardown releases orphaned mod hold after GC") +T.eq(Input:isDown("a"), true, "teardown preserves keyboard source") +Input:keyreleased("z") +Input:reset() +Version.set(savedVersion) +T.finish("mod.input lifecycle") diff --git a/tests/engine/mod_supports.lua b/tests/engine/mod_supports.lua new file mode 100644 index 000000000..7ad2d3214 --- /dev/null +++ b/tests/engine/mod_supports.lua @@ -0,0 +1,252 @@ +-- 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(yes(api, 'hooks', 'core.logic_speed'), + version .. ': native speed hook is advertised') + T.check(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_actor_presentation_gc.lua b/tests/engine/native_actor_presentation_gc.lua new file mode 100644 index 000000000..3c6efa0d5 --- /dev/null +++ b/tests/engine/native_actor_presentation_gc.lua @@ -0,0 +1,71 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Objects=require('src.core.game3.objects') +local Actors=require('src.compat.gen1.SourceActors') +local Optional=require('src.world.OptionalFollower') +local Host=require('src.mods.Runtime') +local Stream=require('src.core.game3.asset_stream') +local G=require('src.core.Game3') +local previousHooks=Host.hooks +local function gc()collectgarbage('collect');collectgarbage('collect')end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Objects.release();G.release();Actors.release() + local f=require('tools.session-runtime.fixture').new(edition);local g,w,p=f.game,f.world,f.player + w.npcs={};w.entities={p};g.mods.mods={gc_owner={enabled=true},peer={enabled=true}} + Host.hooks=require('src.mods.Hooks').new() + Host.hooks:wrap('world.follower.spawn',function()return true end,0,'gc_owner') + Optional.onMapEntered(g,w,nil,true) + local npc=assert(Optional.current(w),'native follower admission failed');local nativeDraws,customDraws=0,0 + npc.sprite={draw=function()nativeDraws=nativeDraws+1 end} + Host.currentMod='gc_owner' + do local view=Objects.find(250);view.sprite=nil end + Host.currentMod=nil;gc() + local sprite,overridden=Actors.renderer(npc) + T.eq(sprite,nil);T.eq(overridden,true,'an owned absent renderer survives view collection') + npc:draw(0,0);T.eq(nativeDraws,0,'native fallback artwork cannot reappear after GC') + local watched=setmetatable({},{__mode='v'}) + Host.currentMod='gc_owner' + do + local view=Objects.find(250);local renderer={draw=function()customDraws=customDraws+1 end} + watched[1]=renderer;view.sprite=renderer + end + Host.currentMod=nil;gc() + T.check(watched[1]~=nil,'an installed renderer remains owned without a retained view') + npc:draw(0,0);T.eq(customDraws,1) + Stream.cancelOwner('peer');gc();npc:draw(0,0);T.eq(customDraws,2,'foreign teardown cannot retire the renderer') + Stream.cancelOwner('gc_owner');gc() + T.eq(watched[1],nil,'owner teardown releases installed artwork') + npc:draw(0,0);T.eq(nativeDraws,1,'owner teardown restores native artwork') + -- Removing a native actor or retiring its dataset releases its lease too. + Host.currentMod='gc_owner' + local retainedView=Objects.find(250) + do local r={draw=function()end};watched[1]=r;retainedView.sprite=r end + Host.currentMod=nil;gc() + w.npcs={};w.entities={p} + T.eq(type(Actors.poll),'function') + if Actors.poll then Actors.poll()end + gc();T.eq(watched[1],nil,'removed native actors release presentation without a draw') + T.eq(retainedView.localId,nil,'retained caller view observes native removal') + local _,active=Actors.renderer(npc);T.eq(active,false) + w.npcs={npc};w.entities={p,npc};Host.currentMod='gc_owner' + local cohortView=Objects.find(250) + do local r={draw=function()end};watched[1]=r;cohortView.sprite=r end + Host.currentMod=nil;gc();g.save={player={}} + if Actors.poll then Actors.poll()end + gc();T.eq(watched[1],nil,'a replaced save retires the previous presentation cohort') + T.eq(cohortView.localId,nil,'retained caller view observes cohort retirement') + -- The draw-time lookup must release the same ownership even when poll has + -- not run between native removal and the last caller asking for artwork. + g.save={player={}};Host.currentMod='gc_owner' + local drawView=Objects.find(250) + do local r={draw=function()end};watched[1]=r;drawView.sprite=r end + Host.currentMod=nil;w.npcs={};w.entities={p} + local removed,owned=Actors.renderer(npc) + T.eq(removed,nil);T.eq(owned,false,'renderer lookup retires a removed native actor') + gc();T.eq(watched[1],nil,'renderer lookup releases artwork while its view is retained') + T.eq(drawView.localId,nil,'draw-time retirement keeps the native view invalid') + Objects.release();Actors.release();G.release() +end +Host.hooks=previousHooks +T.finish('owned native actor presentation survives GC and retires with its cohort') diff --git a/tests/engine/native_atlas_palettes.lua b/tests/engine/native_atlas_palettes.lua new file mode 100644 index 000000000..75d1cb719 --- /dev/null +++ b/tests/engine/native_atlas_palettes.lua @@ -0,0 +1 @@ +dofile('tools/native-atlas/palette-check.lua') diff --git a/tests/engine/native_atlas_pixels.lua b/tests/engine/native_atlas_pixels.lua new file mode 100644 index 000000000..1c12822e4 --- /dev/null +++ b/tests/engine/native_atlas_pixels.lua @@ -0,0 +1 @@ +dofile('tools/native-atlas/pixels-check.lua') diff --git a/tests/engine/native_atlas_resources.lua b/tests/engine/native_atlas_resources.lua new file mode 100644 index 000000000..1396310a6 --- /dev/null +++ b/tests/engine/native_atlas_resources.lua @@ -0,0 +1 @@ +dofile('tools/native-atlas/resources-check.lua') diff --git a/tests/engine/native_battle_animation.lua b/tests/engine/native_battle_animation.lua new file mode 100644 index 000000000..f7d3c2172 --- /dev/null +++ b/tests/engine/native_battle_animation.lua @@ -0,0 +1,137 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Battle=require('src.core.game3.battle') +local A=require('src.core.game3.battle.anim') +local C=A.Coords +local Clock=require('src.battle.PresentationClock') +local base=Battle.update +T.eq(A.update(1/60),false,'standalone callers cannot start an animation clock') +T.eq(A.launchMove(33),false);T.eq(A.present(0),nil) +local unpack=table.unpack or unpack +local function pack(...)return{n=select('#',...),...}end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + A.reset();Battle._service.release() + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game;package.loaded['src.core.Game']=game + game.data.constants.gen3BattleAnimGfx={} + game.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local b=f.fresh('TACKLE').battle;b.enter=false;game.stack:push(b);b.phase='QA_IDLE' + local st=Battle.getState();local p=A.present(0) + T.eq(A.present('player'),p);T.eq(p.displayHp,400);T.eq(p.displayMaxHp,400) + T.eq(p.id,0);T.eq(p.side,'player');T.eq(A.present(2),nil) + T.raises(function()p.id=1 end,'read-only') + T.raises(function()p.alpha=0/0 end,'invalid presentation') + C.setDouble(true);T.eq(C.isDouble(),true);C.setDouble(nil);T.eq(C.isDouble(),false) + C.bind(2,3);T.eq(C.idOf('player'),2);T.eq(C.idOf('enemy'),3) + local got=pack(C.withBattler(0,function(side,a,z)T.eq(side,'player');T.eq(C.idOf(side),0);return a,z,nil end,7,8)) + T.eq(got.n,3);T.eq(got[1],7);T.eq(got[2],8) + T.eq(C.idOf('player'),2,'nested binding is restored including trailing nils') + T.raises(function()C.withBattler(0,function()error('binding failure')end)end,'binding failure') + T.eq(C.idOf('player'),2) + local arg=C.sideArg(function(a,side,z)T.eq(a,4);T.eq(side,'enemy');T.eq(C.idOf(side),3);T.eq(z,nil);return true,nil end,2) + T.eq(pack(arg(4,3,nil)).n,2);C.bind(nil) + T.same(C.monDrawOrder(st),{1,0});T.eq(C.subpriority(0),30) + T.eq(C.bgPriorityRank(3),2);T.eq(C.monBehindZ(0),195) + T.same(pack(C.particleBand(2)),{n=2,201,999}) + game.data.constants.gen3BattlerCoords={singles={{x=73,y=81},{x=177,y=41}}} + local x,y=A.battlerCenter(0);T.eq(x,73);T.eq(y,81,'authored anchors use the admitted cartridge table') + local override={[0]={x=25,y=27}} + T.eq(C.setCoordinateOverrides(override),nil);T.eq(C.coords(nil,0),override[0]);C.setCoordinateOverrides(nil) + local poseFrames,poseDt=0 + b.player.picAnim={update=function(_,dt)poseFrames=poseFrames+1;poseDt=dt end} + local done=0 + T.eq(A.launchMove(33,{attackerId=0,targetId=1,onEnd=function()done=done+1 end}),true) + T.eq(A.launchMove(33),false,'an active native clip cannot be overwritten') + T.eq(b.gen3Anim.attackerPosition,0);T.eq(b.gen3Anim.targetPosition,1) + local ball=require('src.battle.Gen3BallAnim').new(game.data,{sendOut='opponent',battler=b.enemy}) + b.gen3Ball,b.gen3BallPlaying=ball,true + b.waitFrames=3;b.queue={{fn=function()error('held battle command executed')end}} + local frames=b.frame + local stepped,completed=0,0 + A.tweenStage(2,function(u,t)stepped=stepped+1;p.ox=12*u;T.eq(t.frames,stepped)end,function()completed=completed+1 end) + Battle.update=function(dt) + T.eq(A.update(999),true);T.eq(A.update(-999),true) + base(dt);return base(dt) + end + b:update(1/60) + T.eq(b.frame,frames+1);T.eq(b.gen3Anim.frame,1);T.eq(ball.frame,1) + T.eq(poseFrames,1);T.eq(poseDt,1/60,'a source argument cannot replace the native dt') + T.eq(b.waitFrames,3,'a source clip owns its own hold');T.eq(#b.queue,1);T.eq(stepped,1);T.eq(completed,0) + T.eq(p.ox,6);T.eq(A.battlerCenter(0),79) + Battle.update=function()return A.update(0)end + b:update(1/60) + T.eq(b.frame,frames+2);T.eq(b.gen3Anim.frame,2);T.eq(b.waitFrames,3) + T.eq(stepped,2);T.eq(completed,1);T.eq(#b.queue,1,'a source ending hold advances display without combat') + for i=1,300 do if not b.gen3AnimPlaying then break end;b:update(1/60)end + T.eq(b.gen3AnimPlaying,false);T.eq(done,1,'source completion follows the real native clip') + for i=1,3 do b:update(1/60)end;T.eq(b.waitFrames,0) + b.player.mon.hp=380;b.player.shownHP=400;b.draining=true + b:update(1/60) + local shown=b.player.shownHP;T.check(shown<400);T.eq(b.player.mon.hp,380,'display never applies damage') + T.eq(A.displayHpRatio(0),shown/400);T.eq(A.hpTweening(),true) + local heldFrame=b.frame;local overlay={};game.stack:push(overlay) + T.eq(A.update(1/60),false);T.eq(b:update(1/60),false) + T.eq(b.frame,heldFrame);T.eq(b.player.shownHP,shown);game.stack:pop() + -- Retire through a real session replacement; neither old poses nor queued + -- callbacks may write to the new admission, even if a save is restored. + local stale=p;local cancelled=0 + A.tweenStage(1,function()cancelled=cancelled+1 end) + local save=game.save;game.save={};T.neq(A.present(0),stale,'a replacement save creates a new presentation identity');game.save=save + T.raises(function()stale.ox=10 end,'retired') + b:update(1/60);T.eq(cancelled,0);T.neq(A.present(0),stale) + b.player.picAnim=nil;Battle.update=base + game.stack.states={};f.close();A._service.poll() + local function nativeTrace(enabled) + package.loaded['src.core.game3.battle.anim']=enabled and A or nil + local fixture=require('tools.battle-source-runtime.fixture').new(edition) + local g=fixture.game;package.loaded['src.core.Game']=g + g.data.constants.gen3BattleAnimGfx={} + g.data.moves.MOVE_033.anim={duration=4,shakes={{at=0,duration=4,x=2,y=1}}} + local state=fixture.fresh('TACKLE').battle;state.enter=false;g.stack:push(state) + state.player.shownHP,state.enemy.shownHP=400,400 + state.phase='messages';state.current=nil + state.queue={{fn=function() + local s=state + s.gen3Anim:start('MOVE_033',true,0,1,nil,s);s.gen3AnimPlaying=true + s.waitFrames=s.gen3Anim.total + end},{fn=function() + local s=state + s.enemy.mon.hp=370;s.enemy.shownHP=400;s.draining=true + end},{fn=function() + local s=state + s.gen3Ball=require('src.battle.Gen3BallAnim').new(g.data,{sendOut='opponent',battler=s.enemy}) + s.gen3BallPlaying=true;s.waitFrames=80 + end}} + local trace={} + for frame=1,180 do + state:update(1/60) + trace[frame]={frame=state.frame,phase=state.phase,queue=#state.queue,wait=state.waitFrames, + hp=state.enemy.mon.hp,shown=state.enemy.shownHP,draining=state.draining, + anim=state.gen3Anim.frame,playing=state.gen3AnimPlaying, + ball=state.gen3Ball and state.gen3Ball.frame,ballPlaying=state.gen3BallPlaying} + end + g.stack.states={};fixture.close();Battle._service.release();A.reset() + return trace + end + local baseline=nativeTrace(false);local adapted=nativeTrace(true) + for frame=1,#baseline do + local different=false + for key,value in pairs(baseline[frame])do if adapted[frame][key]~=value then different=true end end + for key,value in pairs(adapted[frame])do if baseline[frame][key]~=value then different=true end end + if different then print('FIRST_NATIVE_TRACE_DIFFERENCE',edition,frame, + require('src.link.Json').encode(baseline[frame]),require('src.link.Json').encode(adapted[frame]));break end + end + T.same(adapted,baseline,'an imported animation namespace preserves native queue timing '..edition) +end +Battle.update=base;A.reset();Battle._service.release() +-- Failed native work remains failed throughout its phase; a source wrapper +-- cannot swallow it and retry. The next real frame can recover normally. +local screen={};local attempts=0 +T.raises(function()Clock.run(screen,1/60,function() + local function fail()attempts=attempts+1;error('display failure')end + pcall(Clock.once,screen,'fx',fail);pcall(Clock.once,screen,'fx',fail) +end)end,'display failure') +T.eq(attempts,1);T.eq(Clock.active(screen),false) +T.eq(Clock.run(screen,1/60,function()return Clock.once(screen,'fx',function()return true,nil,4 end)end),true) +T.finish('native battle animation ownership') diff --git a/tests/engine/native_battle_chrome.lua b/tests/engine/native_battle_chrome.lua new file mode 100644 index 000000000..a9582115e --- /dev/null +++ b/tests/engine/native_battle_chrome.lua @@ -0,0 +1,74 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Battle=require('src.core.game3.battle') +local Chrome=require('src.ui.game3.battle_chrome') +local Health=require('src.core.game3.battle.healthbox') +local Native=require('src.battle.Gen3Battle') +local originalGame=package.loaded['src.core.Game'] +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Battle._service.release() + local f=require('tools.battle-source-runtime.fixture').new(edition);local g=f.game;package.loaded['src.core.Game']=g + local c=g.data.constants + c.gen3BattleWindows={message={left=2,top=15,width=26,height=4},prompt={left=1,top=15,width=14,height=4},action={left=16,top=15,width=13,height=4},moves={}} + for i=1,4 do c.gen3BattleWindows.moves[i]={left=1+(i-1)%2*9,top=15+math.floor((i-1)/2)*2,width=8,height=2}end + c.gen3BattleTextbox={images={},text={message={foreground={12,34,56},shadow={90,80,70}}}} + c.gen3BattleAnimGfx={PROBE={image='tests/fixture_data/assets/fixmon_a_front.png',frameWidth=8,frameHeight=8,frames=1}} + g.data.moves.MOVE_033.anim={duration=8,events={{at=0,sheet='PROBE',x=3,y=5,target=true,subpriority=40}}} + local b=f.fresh('TACKLE').battle;b.enter=false;g.stack:push(b) + local st=Battle.getState() + local present=require('src.core.game3.battle.anim').present(0) + b.player.mon.status='PAR';b.player.shownStatus=nil;T.eq(present.displayStatus,false,'a queued future ailment is not shown early') + b.player.shownStatus='PSN';T.eq(present.displayStatus,8);b.player.mon.status=nil;T.eq(present.displayStatus,8,'shown ailment survives logical cure until the native queue displays it') + b.player.shownStatus=false;T.eq(present.displayStatus,false) + T.eq(Chrome.layout(),(edition=='ruby'or edition=='sapphire')and'rs'or edition=='emerald'and'emerald'or'frlg') + local w=Chrome.window(0);T.eq(w.x,16);T.eq(w.y,120);T.eq(w.w,26);w.left=999;T.eq(c.gen3BattleWindows.message.left,2) + local x,y,width,narrow=Chrome.textOrigin(3);T.eq(x,8);T.eq(y,121);T.eq(width,64);T.eq(narrow,true) + T.eq(Chrome.window(14),nil,'unimported windows are not fabricated') + local originalWidth=b.gen3SurfaceWidth;b.gen3SurfaceWidth=function()return 320 end + T.eq(Chrome.window(2).x,16*8+80,'action text follows the native right edge') + T.eq(Chrome.window(0).w,36,'message width follows the actual native surface') + T.eq(Chrome.window(3).x,8,'move names retain the native left edge');b.gen3SurfaceWidth=originalWidth + T.raises(function()Chrome.window(0/0)end,'invalid battle window') + local fg,shadow=Chrome.textboxColors();T.eq(fg[1],12/255);T.eq(shadow[3],70/255) + local base=Chrome.drawPanel;local native=Native.drawPanel;local draws=0 + Native.drawPanel=function(screen,mode)T.eq(screen,b);T.eq(mode,'action');draws=draws+1;return true end + T.eq(base('menu'),true);T.eq(draws,1) + Chrome.drawPanel=function(mode)T.eq(mode,'menu');base(mode);return base(mode)end + local body=0;T.eq(Chrome._panel(b,'action',function()body=body+1;return true end),true);T.eq(body,1,'nested bases consume one native panel phase') + Chrome.drawPanel=function()love.graphics.push();error('owned panel failure')end + T.raises(function()Chrome._panel(b,'action',function()end)end,'owned panel failure') + Chrome.drawPanel=base;Native.drawPanel=native + local geometry=Health.center(st,0);T.check(geometry~=nil);local realGeometry=Native.healthboxGeometry + local hx,hy=Native.healthboxGeometry(b,0);T.eq(geometry.x,hx+32);T.eq(geometry.y,hy+16) + T.eq(Health.center({},0),nil);T.eq(Health.draw(0,{}),false,'a foreign battler cannot paint the resident') + local healthBase=Health.draw;local count,wrappers=0,0 + Health.draw=function(id,view,opts)wrappers=wrappers+1;T.eq(id,0);T.eq(view,st.player);T.eq(opts.st,st);healthBase(id,view,opts);return healthBase(id,view,opts)end + Health._nativeDraw(b,b.player,hx,hy,true,function()count=count+1;return true end);T.eq(count,1,'healthbox bases consume one native painter');T.eq(wrappers,1,'native HUD dispatch actually enters the source wrapper') + Health.draw=healthBase + local Pic=require('src.core.game3.battle.pic_coords') + g.data.pokemon.CHARMANDER.frontPicCoords={yOffset=13};g.data.pokemon.CHARMANDER.backPicCoords={yOffset=7};c.gen3Elevation={CHARMANDER=4} + T.eq(Pic.front[4],13);T.eq(Pic.back[4],7);T.eq(Pic.elev[4],4) + g.data.pokemon.CHARMANDER.frontPicCoords.yOffset=2;T.eq(Pic.front[4],2,'native metadata changes are live') + T.raises(function()Pic.front[4]=20 end,'read-only') + local Sprites=require('src.core.game3.battle.anim_sprites');local Anim=require('src.core.game3.battle.anim') + T.eq(Anim.launchMove(33),true) + local pool=Sprites._pool;T.eq(#pool,1);T.eq(Sprites._pool,pool) + local row=pool[1];local anim=b.gen3Anim;local event=anim.events[1] + local pose=anim:eventPose(event,b,Native);T.eq(row.x,pose.centreX+pose.dx);T.eq(row.y,pose.centreY+pose.dy) + T.eq(row.active,true);T.eq(row.tag,'PROBE');T.eq(row.subpriority,40) + local frame=anim.frame;Sprites.forEachActive(function(p)T.eq(p,row)end);T.eq(anim.frame,frame,'particle inspection cannot advance its native clock') + local paints=0;local painter=anim.drawEventBody + anim.drawEventBody=function()paints=paints+1;return 'native particle'end + row.visible=false;T.eq(anim:drawEvent(event,b,Native,{}),true);T.eq(paints,0) + row.visible=true;row.x=row.x+9;T.eq(anim:drawEvent(event,b,Native,{}),'native particle');T.eq(paints,1) + row.x=0/0;T.raises(function()anim:drawEvent(event,b,Native,{})end,'invalid particle presentation') + row.x=pose.centreX+pose.dx + local host=require('src.mods.Runtime');host.currentMod='particle_owner';T.eq(Sprites._pool,pool);host.currentMod=nil + require('src.core.game3.asset_stream').cancelOwner('particle_owner');T.eq(row.active,false);T.eq(anim.frame,frame,'view retirement preserves native playback') + anim.drawEventBody=painter;b.gen3AnimPlaying=false;T.eq(#Sprites._pool,0) + b.result='run';b:finish();Battle._service.release();T.eq(Health.draw(0,st.player),false,'a retired capture cannot draw its old resident') + f.close();require('src.core.Game3').release() +end +package.loaded['src.core.Game']=originalGame +T.finish('native battle windows, panel phases, healthbox geometry and imported picture metadata') diff --git a/tests/engine/native_battle_ownership.lua b/tests/engine/native_battle_ownership.lua new file mode 100644 index 000000000..b52cc9d8f --- /dev/null +++ b/tests/engine/native_battle_ownership.lua @@ -0,0 +1 @@ +dofile('tools/battle-source-runtime/ownership-check.lua') diff --git a/tests/engine/native_battle_session.lua b/tests/engine/native_battle_session.lua new file mode 100644 index 000000000..6b5ccf7c8 --- /dev/null +++ b/tests/engine/native_battle_session.lua @@ -0,0 +1,139 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Battle=require('src.core.game3.battle') +local Background=require('src.core.game3.battle.bg') +local Ui=require('src.core.game3.battle.ui') +local baseUpdate,baseDraw=Battle.update,Battle.draw +local originalGame=package.loaded['src.core.Game'] +local retained,weak +T.eq(Battle.isActive(),false) +T.eq(baseUpdate(),nil,'a source call cannot manufacture a native tick') +T.eq(baseDraw(),false) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game; + package.loaded['src.core.Game']=game + game.data.constants=game.data.constants or{} + game.data.constants.speciesOrder={[4]='CHARMANDER'} + game.data.constants.moveOrder={[33]=f.ids.TACKLE} + game.data.constants.typeOrder={'NORMAL'} + game.data.constants.abilityOrder={'NONE','PROBE_ABILITY'} + game.data.constants.abilities={NONE={index=0},PROBE_ABILITY={index=1}} + game.data.constants.types={NORMAL={index=0}} + game.data.constants.itemOrder={[7]='PROBE_ITEM'} + game.data.items.PROBE_ITEM={index=7} + local b=f.fresh('TACKLE').battle + b.player.ability='PROBE_ABILITY';b.player.mon.item='PROBE_ITEM' + local starts=0;local start=Battle.start + Battle.start=function(opts)starts=starts+1;T.eq(opts.wild,true);return start(opts)end + game.stack:push(b);Battle.start=start;T.eq(starts,1,'real native entry notifies source start once') + local st=assert(Battle.getState()) + T.eq(Battle.isActive(),true) + T.eq(baseDraw({data=game.data,save=game.save,stack=game.stack}),false,'foreign capture cannot retire the active view') + T.eq(Ui.bindState(nil),false) + T.eq(st,Battle.getState(),'one identity for the native admission') + T.eq(st.player.id,0);T.eq(st.enemy.id,1) + T.eq(st.player.side,'player');T.eq(st.enemy.side,'enemy') + T.eq(st.player.mon.species,4);T.eq(st.player.mon.moves[1],33) + T.eq(st.player.mon.item,7);T.eq(st.player.ability,1);T.eq(st.player.type1,0) + T.eq(st.player.mon.maxHp,400);T.eq(st.party[1],st.player.mon) + T.neq(st.player.mon,b.player.mon);T.neq(st, b) + st.player.mon.hp=399;T.eq(b.player.mon.hp,399) + st.player.mon.status=128;T.eq(b.player.mon.status,'PSN');T.eq(b.player.mon.toxic,true) + st.player.mon.status=0;T.eq(b.player.mon.status,nil) + T.raises(function()st.player.mon.hp=-1 end,'invalid native HP') + T.raises(function()st.weather='SUN'end,'not directly writable') + T.raises(function()st.player={}end,'not directly writable') + st.paperPresentation={enabled=true};T.eq(st.paperPresentation.enabled,true) + local ticks=0;local tickFx=b.tickFx + b.tickFx=function(self,...)ticks=ticks+1;return tickFx(self,...)end + b.phase='menu';b.queue={};b.current=nil + local pp=st.player.mon.pp + pp[1]=9 + Battle.update=function(dt,view) + T.eq(require('src.core.Game3').native(view),game) + baseUpdate(dt,view);return baseUpdate(dt,view) + end + local pump=Ui.pump + Ui.pump=function()pump();return pump()end + b:update(1/60);Ui.pump=pump + T.eq(pump(),nil,'UI calls outside the native input phase cannot tick combat') + T.eq(Ui.bindState(st),true);T.eq(Ui.bindState({}),false) + for _,method in ipairs{'statusHUDVisible','bottomUIVisible','moveGridNavigation'}do + if type(b[method])=='function'then T.eq(Ui[method](),b[method](b)) + else local value,why=Ui[method]();T.eq(value,nil);T.check(why:find('native UI query unavailable',1,true)~=nil)end + end + T.eq(ticks,1,'two wrapper base calls consume the native tick once') + T.eq(b.player.mon.moves[1].pp,9,'validated source PP edit reaches the actual native move') + T.eq(baseUpdate(1/60),nil);T.eq(ticks,1,'direct source update outside native scope has no clock') + local overlay={};game.stack:push(overlay) + T.eq(Battle.getState(),st,'covered combat remains observable') + T.eq(Battle.abort('run'),false,'abort cannot pop a foreign overlay') + T.eq(b:update(1/60),false);T.eq(ticks,1) + game.stack:pop() + Battle.update=baseUpdate + T.eq(Battle.statWindowPhase(),false) + game.stack:push(b.StatBox.new(game,b.player.mon)) + T.eq(Battle.statWindowPhase(),true,'the actual native level-up window is observed') + game.stack:pop() + local Host=require('src.mods.Runtime');Host.currentMod='battle_owner' + T.eq(Battle.getState(),st);Host.currentMod=nil + require('src.core.game3.asset_stream').cancelOwner('other_owner');T.eq(Battle.getState(),st) + require('src.core.game3.asset_stream').cancelOwner('battle_owner') + T.raises(function()st.player.mon.hp=1 end,'retired') + T.eq(Battle.isActive(),true,'mod teardown retires views while native combat continues') + local fresh=Battle.getState();T.neq(fresh,st);st=fresh + local draws,fields=0,0 + local NativeDraw=require('src.battle.Gen3Battle');local draw,field=NativeDraw.draw,NativeDraw.drawField + NativeDraw.draw=function(screen)draws=draws+1;screen:drawBattleField()end + NativeDraw.drawField=function()fields=fields+1 end + local uiDraw=Ui.draw;local uiDraws=0 + Ui.draw=function(w,h)uiDraws=uiDraws+1;uiDraw(w,h);return uiDraw(w,h)end + Battle.draw=function(view,w,h)T.eq(w,240);T.eq(h,160);baseDraw(view,w,h);return baseDraw(view,w,h)end + b:draw();T.eq(uiDraws,1);T.eq(draws,1);T.eq(fields,1,'base draw consumes the field and native UI once') + local bgDraw=Background.draw + Background.draw=function()return true end + b:draw();T.eq(draws,2);T.eq(fields,1,'background replacement leaves the native UI draw admitted') + Background.draw=bgDraw + Battle.draw=function()love.graphics.push();error('battle presentation error')end + local push,pop=love.graphics.push,love.graphics.pop + T.raises(function()b:draw()end,'battle presentation error') + T.eq(love.graphics.push,push);T.eq(love.graphics.pop,pop) + Battle.draw=baseDraw;Ui.draw=uiDraw;NativeDraw.draw,NativeDraw.drawField=draw,field + local bad=st.player.mon.moves;bad[1]=99 + T.raises(function()b:update(1/60)end,'source move replacement') + bad[1]=33;b:update(1/60) + local rng=st.rng + local nativeHp=b.player.mon.hp + local callbacks=0;b.onFinish=function(result)T.eq(result,'lose');callbacks=callbacks+1 end + b.result='lose';b:finish() + T.eq(Battle.isActive(),false);T.eq(Battle.getState(),st) + T.eq(st.result,'lose');T.eq(st.over,true);T.eq(callbacks,1) + T.eq(st.player.mon.hp,nativeHp,'retired state has a detached final observation') + T.raises(function()st.player.mon.hp=10 end,'retired') + T.raises(function()rng(1,2)end,'retired') + T.eq(Battle.abort('run'),false,'completion cannot pop a subsequent native screen') + T.eq(Battle.reset(),true);T.eq(Battle.getState(),nil) + retained={st,st.player,st.player.mon,rng} + weak=setmetatable({game,game.save,b},{__mode='v'}) + f.close();require('src.core.Game3').release() +end +package.loaded['src.core.Game']=originalGame +for _=1,4 do collectgarbage('collect')end +T.eq(weak[3],nil,'retained completed views do not keep the native controller alive') +T.eq(retained[1].result,'lose');T.raises(function()retained[4](1,2)end,'retired') +-- Isolate this view's ownership from the host's process-wide Game3 facade cache. +local function retiredViews() + local data={pokemon={},moves={},constants={}} + local game={data=data,save={party={}}} + local screen={kind='wild',result='run',rng=function(lo)return lo end} + local weak=setmetatable({game,game.save,data,screen},{__mode='v'}) + local e=require('src.compat.gen1.BattleView').new(game,screen,function()return true end) + local view,rng=e.view,e.rng;e:retire();return view,rng,weak +end +local view,rng,refs=retiredViews() +for _=1,4 do collectgarbage('collect')end +for i=1,4 do T.eq(refs[i],nil,'retired battle views release native owner '..i)end +T.eq(view.result,'run');T.raises(function()rng(1,2)end,'retired') +T.finish('native GBA battle admission, numeric views, once-only phases and teardown') diff --git a/tests/engine/native_battle_source_lifecycle.lua b/tests/engine/native_battle_source_lifecycle.lua new file mode 100644 index 000000000..230905d08 --- /dev/null +++ b/tests/engine/native_battle_source_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/battle-source-runtime/lifecycle-check.lua') diff --git a/tests/engine/native_companion_animation_observation.lua b/tests/engine/native_companion_animation_observation.lua new file mode 100644 index 000000000..c4789f98e --- /dev/null +++ b/tests/engine/native_companion_animation_observation.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('tools.native-atlas.cpu-gpu').install(love) +local N=require('src.core.game3.tileset_native') +local A=require('src.core.game3.tileset_anim') +local B=require('src.compat.gen1.MetatileBehavior') +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.native-atlas.session-fixture').new(edition) + local primary=f.game.data.map_tilesets.P + local translator=B.translator(edition) + local raw=assert(translator and translator.raw(B.POND_WATER)or B.POND_WATER) + primary.sourceBehaviorMask=edition=='firered'or edition=='leafgreen' + primary.sourceBehaviorMask=primary.sourceBehaviorMask and 511 or 255 + if primary.sourceAttributeBytes==4 then + primary.sourceAttributes=string.char(raw,0,0,0)..primary.sourceAttributes:sub(5) + else primary.attributes=string.char(raw,0)..primary.attributes:sub(3)end + primary.animations={{tile=0,count=1,step=7,frames={string.rep('\17',32),string.rep('\34',32)}}} + local atlas=assert(N.get(f.pair)) + local tick=atlas._model.decoder.animationTicks + local bytes=atlas.imageData:getString() + T.eq(A.bindPair(f.pair),true);T.eq(A._pair,f.pair) + T.eq(A.bindPair(f.pair,{}),false,'foreign atlas cannot replace native ownership') + local entry=assert(A._pairs[f.pair]);local bank=assert(entry.banks.water) + T.eq(entry.atlas,atlas);T.eq(entry.rse,edition~='firered'and edition~='leafgreen') + T.eq(bank.frames,2);T.eq(bank.period,14);T.eq(bank.ticks[1],0);T.eq(bank.ticks[2],7) + T.eq(#bank.rgba,#bank.mids*1024*2) + local size=#bank.mids*1024 + T.check(bank.rgba:sub(1,size)~=bank.rgba:sub(size+1),'actual imported animation pixels differ') + T.eq(atlas.imageData:getString(),bytes,'metadata does not repaint the live atlas') + T.eq(atlas._model.decoder.animationTicks,tick,'metadata does not rewind or advance the live decoder') + T.eq(A._pairs[f.pair],entry,'unchanged admitted atlas reuses its observation') + A.invalidate();T.eq(A._pair,nil);T.check(A._pairs[f.pair]~=entry) + local retained=setmetatable({atlas},{__mode='v'}) + N.trim({},0);atlas=nil;collectgarbage('collect') + T.eq(retained[1],nil,'metadata cannot retain a trimmed native atlas') + T.eq(entry.atlas,nil,'retained observations do not prolong the resource owner') + local original=package.loaded['src.core.Game'];package.loaded['src.core.Game']=nil + T.eq(A._pairs[f.pair],nil,'a retired session cannot expose cached metadata') + package.loaded['src.core.Game']=original + N.release();f.views:release();require('src.core.game3.map').release() +end +T.finish('native animation metadata, exact pixels and clock isolation') diff --git a/tests/engine/native_companion_audio.lua b/tests/engine/native_companion_audio.lua new file mode 100644 index 000000000..9283cb2b2 --- /dev/null +++ b/tests/engine/native_companion_audio.lua @@ -0,0 +1,40 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Sound=require('src.core.Sound') +local A=require('src.core.game3.audio') +local Host=require('src.mods.Runtime') +local Stream=require('src.core.game3.asset_stream') +local original,oldAudio=package.loaded['src.core.Game'],love.audio +local voices={} +love.audio={newSource=function() + local s={} + function s:play()self.playing=true end + function s:stop()self.playing=false end + function s:setVolume(v)self.volume=v end + function s:isPlaying()return self.playing end + voices[#voices+1]=s;return s +end} +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + require('src.core.GameVersion').set(edition) + Stream.shutdown();Sound.invalidate();voices={} + local g={data={audio={songs={RAIN={sfxName='SE_RAIN',index=4,file='synthetic-rain.wav'}}}},save={options={sfxVol=7}}} + package.loaded['src.core.Game']=g + local native=Sound.playNamedEffect(g.data,'SE_RAIN') + Host.currentMod='one';T.eq(A.playSe('SE_RAIN',{sourceVolume=0}),true) + local one=A._lastSe;T.neq(one,native);T.eq(one.volume,0);T.eq(A._seMeta[one].id,4) + Host.currentMod='two';T.eq(A.playSe('SE_RAIN'),true);local two=A._lastSe + T.neq(one,two);T.neq(two,native) + local count=#voices + T.raises(function()A.playSe('SE_RAIN',{sourceVolume=-1})end,'invalid source volume');T.eq(#voices,count) + T.eq(A.stopSe('SE_RAIN'),true);T.eq(two.playing,false);T.eq(one.playing,true);T.eq(native.playing,true) + Stream.cancelOwner('one');T.eq(one.playing,false);T.eq(native.playing,true) + T.eq(#A._seSources,0) + Host.currentMod='one';A.playSe('SE_RAIN');one=A._lastSe + g.save={options={sfxVol=0}};Stream.poll() + T.eq(one.playing,false);T.eq(A._sfxVolume,0);T.eq(A.isReady(),true) + Host.currentMod=nil;T.eq(A.stopSe(),false) + Stream.shutdown();Sound.invalidate() +end +Host.currentMod=nil;love.audio=oldAudio;package.loaded['src.core.Game']=original +T.finish('native companion voice isolation, volume admission and owner retirement') diff --git a/tests/engine/native_companion_damage.lua b/tests/engine/native_companion_damage.lua new file mode 100644 index 000000000..318512059 --- /dev/null +++ b/tests/engine/native_companion_damage.lua @@ -0,0 +1,59 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Damage=require('src.core.game3.battle.damage') +local Merge=require('src.mods.Merge') +local original=package.loaded['src.core.Game'] +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + local g=f.game;package.loaded['src.core.Game']=g + local b=f.fresh('TACKLE').battle + g.data.constants.types={NORMAL={index=0}};g.data.constants.typeOrder={'NORMAL'} + g.data.move_effects=nil + local mon={species=4,level=30,personality=7,friendship=255,moves={33},pp={10}} + Damage.ensureStats(mon) + local target=Merge.deepCopy(mon);target.hp=math.min(40,target.maxHp) + local row=g.data.moves.MOVE_033 + local opts={noCrit=true,rng=function(lo,hi)return hi or lo end} + local function query(effect,power) + row.effect,row.power=effect,power + return Damage.calc({mon=mon},{mon=target},33,opts) + end + local hp,pp=target.hp,mon.pp[1] + row.fixedDamage=20 + local damage,info=query('SPECIAL_DAMAGE_EFFECT',1) + T.eq(damage,20);T.eq(info.critical,false) + row.fixedDamage='level';T.eq(query('SPECIAL_DAMAGE_EFFECT',1),30) + row.fixedDamage=nil + T.eq(query('SUPER_FANG_EFFECT',1),math.max(1,math.floor(hp/2))) + T.eq(query('FALSE_SWIPE_EFFECT',1000),hp-1) + mon.hp=1;T.eq(query('ENDEAVOR_EFFECT',1),hp-1) + mon.hp=mon.maxHp;target.hp=1 + damage,info=query('ENDEAVOR_EFFECT',1);T.eq(damage,0);T.eq(info.failed,true) + target.hp=hp + local variable=query('RETURN_EFFECT',1) + local ordinary=query('NO_ADDITIONAL_EFFECT',102) + T.eq(variable,ordinary,'native dynamic power uses the same formula') + local failedBefore=target.hp + opts.rng=function(lo,hi)return hi or lo end + damage,info=query('PRESENT_EFFECT',1) + T.eq(damage,0);T.eq(info.failed,true);T.eq(target.hp,failedBefore,'a damage query cannot heal its target') + -- Merged mod records receive detached operands and the actual native + -- computeDamage callback. They cannot consume PP or HP on a resident. + g.data.move_effects={QUERY={chooseDamage=function(ctx) + ctx.user.mon.hp=1;ctx.user.mon.moves[1].pp=0 + ctx.target.mon.hp=0 + return 19,{crit=false,typeMult=10} + end}} + row.effect,row.power='QUERY',1 + g.stack:push(b) + local st=require('src.core.game3.battle').getState() + local residentHP,residentPP=b.player.mon.hp,b.player.mon.moves[1].pp + T.eq(Damage.calc({mon=st.player.mon},{mon=st.enemy.mon},33,opts),19) + T.eq(b.player.mon.hp,residentHP);T.eq(b.player.mon.moves[1].pp,residentPP) + T.eq(mon.pp[1],pp) + b.result='run';b:finish();f.close() + require('src.core.Game3').release() +end +package.loaded['src.core.Game']=original +T.finish('native companion fixed, dynamic and detached effect damage') diff --git a/tests/engine/native_companion_map_alias.lua b/tests/engine/native_companion_map_alias.lua new file mode 100644 index 000000000..43d4103f9 --- /dev/null +++ b/tests/engine/native_companion_map_alias.lua @@ -0,0 +1,21 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Identity=require('src.compat.gen1.MapIdentity') +local copy=require('src.mods.Merge').deepCopy +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local name=Identity.lookup(edition,4,1) + local def={id='MAP_G04_N01',group=4,number=1,blocks={{1,2,3}},scripts={entry={'noop'}}} + local alias=copy(def);alias.id='MAP_G_04_N_01';alias.sourceId=def.id + local maps={[def.id]=def,[alias.id]=alias} + T.eq(Identity.bind(maps,edition):nativeId(name),def.id,'Data spelling copies do not shadow the cartridge room') + alias.blocks[1][1]=9 + T.eq(Identity.bind(maps,edition):nativeId(name),nil,'authored alternate layouts remain ambiguous') + alias.blocks[1][1]=1;alias.scripts.entry[1]='changed' + T.eq(Identity.bind(maps,edition):nativeId(name),nil,'distinct scripts cannot inherit cartridge identity') + alias.scripts=copy(def.scripts);alias.sourceId=nil + T.eq(Identity.bind(maps,edition):nativeId(name),nil,'similar spelling without source provenance is insufficient') + alias.sourceId=def.id;alias.id='OTHER';maps[alias.id]=alias + T.eq(Identity.bind(maps,edition):nativeId(name),nil,'arbitrary claimed source ids are insufficient') +end +T.finish('native companion map identity with unchanged generated spelling aliases') diff --git a/tests/engine/native_companion_runtime.lua b/tests/engine/native_companion_runtime.lua new file mode 100644 index 000000000..83954d73c --- /dev/null +++ b/tests/engine/native_companion_runtime.lua @@ -0,0 +1,276 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local G=require('src.core.Game3') +local Host=require('src.mods.Runtime') +local Views=require('src.compat.gen1.SessionView') +local PViews=require('src.compat.gen1.PokemonView') +local Bag=require('src.core.game3.bag') +local Field=require('src.core.game3.field') +local Source=require('src.compat.gen1.SourcePokemon') +local Scope=require('src.compat.gen1.ScriptBoundary') +local Message=require('src.ui.game3.message') +local Choice=require('src.ui.game3.choice') +local Stream=require('src.core.game3.asset_stream') +T.eq(require('src.core.game3.player').running,false,'no admitted player is not running') +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Stream.shutdown();G.release() + local bf=require('tools.battle-source-runtime.fixture').new(edition) + local nativeMon=bf.fresh('TACKLE').battle.player.mon + local f=require('tools.session-runtime.fixture').new(edition) + local g,w,p=f.game,f.world,f.player + local maps=g.data.maps;g.data=bf.data;g.data.maps=maps + g.data.constants.types={NORMAL={index=0}};g.data.constants.typeOrder={'NORMAL'} + local pair=require('src.compat.gen1.PairNames')[edition][1] + g.data.tilesets={PAIR={id='PAIR',primaryKey='P',secondaryKey='S',blockCells=1,blockTiles=2}} + g.data.map_tilesets={P={address=pair[1],secondary=false},S={address=pair[2],secondary=true}} + w.map.def.tileset='PAIR' + g.data.constants.gen3Layout={};g.data.constants.itemOrder={[7]='PROBE_ITEM'} + local evolved=require('src.mods.Merge').deepCopy(g.data.pokemon.CHARMANDER) + evolved.id,evolved.name,evolved.index,evolved.dex='CHARMELEON','CHARMELEON',5,5 + g.data.pokemon.CHARMELEON=evolved;g.data.constants.speciesOrder[5]='CHARMELEON' + for number,name in pairs{[34]='GROWL',[35]='TAIL_WHIP',[36]='SPLASH',[37]='LEER'}do + g.data.constants.moveOrder[number]=name + g.data.moves[name]={id=name,index=number,name=name,pp=40,power=0,type='NORMAL',category='status',accuracy=100} + end + g.data.items.PROBE_ITEM={id='PROBE_ITEM',name='PROBE',index=7,pocket='ITEM',price=1} + g.save.party={nativeMon};g.save.inventory={PROBE_ITEM=2};g.save.bagOrder={'PROBE_ITEM'} + g.save.pokedex={seen={},owned={},caught={}} + g.mods={mods={runtime_probe={enabled=true},other={enabled=true}},exports={},hooks={wrap=function()end}} + Host.currentMod='runtime_probe' + local view=assert(G.forNative(g));local session=view.session;local mon=session.party[1] + T.check(mon~=nativeMon);T.eq(mon.species,4);T.eq(mon.moves[1],33);T.eq(mon.pp[1],10) + local snapshot={};for k,v in pairs(mon)do snapshot[k]=v end + T.eq(snapshot.species,4);T.neq(snapshot.moves,mon.moves) + local pp=mon.pp;pp[1]=7;G._withCallbackOwner('runtime_probe',function()end);T.eq(nativeMon.moves[1].pp,7) + local invalid=mon.pp;invalid[1]=-1;T.raises(function()PViews.flush()end,'invalid native PP') + T.eq(nativeMon.moves[1].pp,7);invalid[1]=7;PViews.flush() + local numericMoves=mon.moves;numericMoves[1]=34;T.raises(function()PViews.flush()end,'native moves changed') + numericMoves[1]=33;PViews.flush() + require('src.core.game3.pokemon').setFriendship(mon,200);T.eq(nativeMon.happiness,200) + mon.hp=12;T.eq(nativeMon.hp,12);mon.status=128;T.eq(nativeMon.status,'PSN');T.eq(nativeMon.toxic,true) + mon.status=0;T.eq(nativeMon.status,nil) + T.eq(Bag.count(session.bag,7),2) + local inventory,order=g.save.inventory,g.save.bagOrder + T.eq(Bag.canAdd(session.bag,7,2),true);T.eq(g.save.inventory,inventory);T.eq(g.save.bagOrder,order);T.eq(inventory.PROBE_ITEM,2) + T.eq(Bag.add(session.bag,7,2),true);T.eq(inventory.PROBE_ITEM,4) + T.eq(Bag.remove(session.bag,7,-2),false);T.eq(inventory.PROBE_ITEM,4) + T.eq(Bag.add(session.bag,7,0/0),false);T.eq(Bag.add(session.bag,9999,1),false) + T.eq(Bag.remove(session.bag,7,2),true);T.eq(inventory.PROBE_ITEM,2) + local Dex=require('src.core.game3.dex');T.eq(Dex.isCaught(session.dex,4),false) + g.save.pokedex.owned.CHARMANDER=true;T.eq(Dex.isCaught(session.dex,4),true) + local Safari=require('src.core.game3.safari');T.eq(Safari.isActive(session),false) + g.save.safari={balls=30};T.eq(Safari.isActive(session),true);g.save.safari=nil + T.eq(Field.lock('same'),true);T.eq(Field.locked,true);T.eq(w:busy(),true) + Host.currentMod='other';Field.lock('same');Field.unlock('same');T.eq(Field.locked,true,'one mod cannot release another lock') + Stream.cancelOwner('other');T.eq(Field.locked,true) + Host.currentMod='runtime_probe';Field.unlock('same');T.eq(Field.locked,false) + local interactions=0;local base=Field.interact + Field.interact=function()base();return base()end + Field._dispatch(g,w,function()interactions=interactions+1;return'handled'end) + T.eq(interactions,1);T.eq(base(),nil,'no manufactured interaction between native phases');Field.interact=base + local Player=require('src.core.game3.player');T.eq(Player.startFieldMove(3,'test'),true) + T.eq(p.fieldMove,true);T.eq(w:busy(),true);T.eq(Player.startFieldMove(2),false) + p:update();p:update();T.eq(Player.fieldMoveAnim,1);p:update();T.eq(Player.fieldMoveAnim,0);T.eq(p._gen3FieldMovePoseOwner,nil) + Player.startFieldMove(3);Stream.cancelOwner('runtime_probe');T.eq(p.fieldMove,nil) + local moves=require('src.core.game3.field_moves') + g.data.constants.gen3FieldMoves={SURF={badgeFlag=0x820}} + T.eq(moves.hasBadge(session,'SURF'),false);g.save.flags.FLAG_G3_0820=true;T.eq(moves.hasBadge(session,'SURF'),true) + w.partyKnows=function(_,key)return key=='SURF'and nativeMon end + local user,slot=moves.partyMoveUser(session.party,'SURF');T.eq(user,mon);T.eq(slot,0) + local none,missing=moves.partyMoveUser(session.party,'CUT');T.eq(none,nil);T.eq(missing,6) + local fighter={species=4,level=30,personality=9,ivs={hp=31,atk=20,def=10,spe=8,spa=7,spd=6},moves={33},pp={10}} + require('src.core.game3.battle.damage').ensureStats(fighter) + T.check(fighter.maxHp>1);T.eq(fighter.hp,fighter.maxHp) + local raw,owner=Source.resolve(fighter);T.eq(owner,g);T.eq(raw.species,'CHARMANDER');T.eq(raw.ivs.attack,20) + local def=g.data.moves.MOVE_033;local row=require('src.core.game3.battle.moves').get(33) + T.eq(row.power,def.power);T.eq(row.type,0);row.power=900;T.eq(def.power,35) + local damage,info=require('src.core.game3.battle.damage').calc({mon=fighter},{mon=fighter},33,{noCrit=true,rng=function(lo,hi)return hi or lo end}) + T.check(damage>0);T.eq(info.critical,false);T.eq(info.effectiveness,1) + local NativeDamage=require('src.battle.Damage');local rules=require('src.battle.Rulesets').gen3_emerald + local b={mon=raw,def=g.data.pokemon.CHARMANDER,curStats=raw.stats,curTypes={'NORMAL'},stages={},items=g.data.items,statuses=g.data.statuses} + local expected=NativeDamage.compute(rules,b,b,def,{forceCrit=false,rng=function(lo,hi)return hi or lo end,data=g.data}) + T.eq(damage,expected,'field damage shares the native formula') + nativeMon.stats=require('src.pokemon.Stats').calcGen3(g.data.pokemon.CHARMANDER,30,nativeMon.ivs,nativeMon.evs,nativeMon.nature);nativeMon.hp=nativeMon.stats.hp + nativeMon.level=30;nativeMon.exp=require('src.pokemon.Growth').expForLevel(g.data.pokemon.CHARMANDER.growthRate,30,g.data.growth_rates) + local exp=require('src.core.game3.battle.experience');local before=nativeMon.exp + local threshold=require('src.pokemon.Growth').expForLevel(g.data.pokemon.CHARMANDER.growthRate,31,g.data.growth_rates) + local award=exp.apply(mon,threshold-before+1) + T.eq(nativeMon.level,31);T.eq(award.fromLevel,30);T.eq(award.toLevel,31);T.eq(award.gained,threshold-before+1);T.eq(award.levels[1],31) + T.check(#award.steps>=1) + local sfx=require('src.core.game3.se_ids');T.eq(sfx.SE_SUCCESS,(edition=='firered'or edition=='leafgreen')and 25 or 31) + local catalogue=require('src.core.game3.items_data');T.eq(catalogue.info(7).pocket,'ITEMS');T.eq(catalogue.isTm(7),false) + local r=require('src.core.game3.rng');local random=r.Random32();T.check(random>=0 and random<4294967296 and random%1==0) + T.eq(r.mulU32(0xffffffff,0xffffffff),1) + T.eq(Message.show('Hello',{speed=0}),true);T.eq(Message.isOpen(),true);T.eq(Message.isWaiting(),true) + local box=g.stack:top();T.eq(box.done,true);T.eq(Message.currentPage(),'Hello') + local count,update=0,Message.tick + Message.tick=function()count=count+1;update();return update()end + box:update(1/60);T.eq(count,1);T.eq(g.stack:top(),box);Message.tick=update + T.eq(Message.close(),true);T.eq(g.stack:top(),w);T.eq(Message.isOpen(),false) + local chosen,calls=nil,0 + T.eq(Choice.multi({'A','B','C'},1,function(index)chosen=index;calls=calls+1 end),true) + local choice=g.stack:top();f.input.edges.a=true;choice:update();f.input.edges.a=nil + T.eq(chosen,1);T.eq(calls,1);choice.onChoose(0);T.eq(calls,1,'one continuation per choice') + T.eq(g.stack:top(),w) + Message.showStay('Held',{speed=0});local held=g.stack:top() + local foreign={modOwnedScreen=true};g.stack:push(foreign) + Stream.cancelOwner('runtime_probe');T.eq(g.stack:top(),foreign,'teardown leaves foreign overlays intact') + for _,s in ipairs(g.stack.states)do T.neq(s,held)end + g.stack:pop() + -- Admission carries the visible individual into the actual native constructor. + local battle,cleanup,done=nil,0,0 + function w:pushBattle(value)battle=value;g.stack:push(value);return true end + function w:afterBattle()cleanup=cleanup+1 end + Host.currentMod='runtime_probe' + local ok,why=require('src.core.game3.battle_bridge').startWild(nil,view, + {species=4,level=17,personality=0x12345678,ivs={hp=31,atk=30,def=29,spe=28,spa=27,spd=26},moves={33},pp={4},hp=12,status=64,item=7}, + {done=function()done=done+1 end}) + T.eq(ok,true,tostring(why));T.check(battle~=nil) + if battle then + T.eq(battle.enemy.mon.personality,0x12345678);T.eq(battle.enemy.mon.ivs.spdef,26) + T.eq(battle.enemy.mon.moves[1].pp,4);T.eq(battle.enemy.mon.hp,12);T.eq(battle.enemy.mon.status,'PAR');T.eq(battle.enemy.mon.item,'PROBE_ITEM') + battle.onFinish('run');battle.onFinish('run');T.eq(cleanup,1);T.eq(done,1) + g.stack:removeAll({battle}) + end + -- Source collectors and follower renderers are presentation-only. + local Actors=require('src.compat.gen1.SourceActors') + local effects=require('src.core.game3.field_effects');local collect=effects.collectActors + local rendered,collectorCalls=0,0 + local originalEntities=w.entities + effects.collectActors=function(rows,cx,cy) + collectorCalls=collectorCalls+1;collect(rows,cx,cy);collect(rows,cx,cy) + rows[#rows+1]={kind='test',x=32,y=48,sortY=48,elevation=3,draw=function()rendered=rendered+1 end} + end + local retainedActor + Actors.withActors(g,w,function() + T.neq(w.entities,originalEntities);T.eq(#w.entities,#originalEntities+1) + retainedActor=w.entities[#w.entities];retainedActor:draw(0,0) + T.eq(require('src.world.Collision').occupied(w.entities,retainedActor.cellX,retainedActor.cellY),nil,'a draw-only actor cannot enter the collision cast') + end) + T.eq(w.entities,originalEntities);T.eq(collectorCalls,1);T.eq(rendered,1) + T.eq(retainedActor:draw(0,0),false,'draw-only proxies retire after their frame') + T.raises(function()Actors.withActors(g,w,function()error('actor failure')end)end,'actor failure') + T.eq(w.entities,originalEntities,'failed custom rendering restores the logical actor collection') + effects.collectActors=collect + local Follower=require('src.compat.game3.Follower');local baseFollow=Follower.update;local updates=0 + Follower.update=function()baseFollow();return baseFollow()end + Follower._dispatch(g,w,function()updates=updates+1 end);T.eq(updates,1);Follower.update=baseFollow + local menuSource=require('src.ui.game3.party_menu') + local menu=require('src.ui.Gen3PartyMenu').new(g) + g.stack:push(menu) + menu.submenu={{label='SUMMARY',action='summary'},{label='ITEM',action='item'},{label='CANCEL',action='cancel'}};menu.subIndex=1 + local mu,mi=menuSource.update,menuSource.handleInput + menuSource.update=function(dt) + mu(dt);local a=menuSource.ACTIONS;if a and a[1]=='SUMMARY'and a[2]~='ROLE'then table.insert(a,2,'ROLE')end + end + menuSource.handleInput=function(input) + if input:wasPressed('a')and menuSource.ACTIONS[menuSource.actionCursor]=='ROLE'then + menuSource.ACTIONS={'FOLLOW','BACK'};menuSource.actionCursor=1;return true + end + return mi(input) + end + menu:update(1/60);T.eq(menu.submenu[2].sourceLabel,'ROLE');T.eq(menuSource._party[1].species,4) + menu.subIndex=2;f.input.edges.a=true;menu:update(1/60);f.input.edges.a=nil + T.eq(menu.submenu[1].sourceLabel,'FOLLOW','source submenu edits reach native navigation') + T.eq(menu.submenu[2].sourceLabel,'BACK');T.eq(menuSource.actionCursor,1) + menuSource.update,menuSource.handleInput=mu,mi + local peerSelected=0 + menu.submenu={{label='PEER',onSelect=function()peerSelected=peerSelected+1 end},{label='CANCEL',action='cancel'}};menu.subIndex=1 + menu:update(1/60) + local peerKey=menuSource.ACTIONS[1] + menuSource.ACTIONS={'ROLE',peerKey,'CANCEL'};menu:update(1/60) + Stream.cancelOwner('runtime_probe') + T.eq(#menu.submenu,2,'unloading removes only owned source action rows') + T.eq(menu.submenu[1].label,'PEER','native peer actions survive source teardown') + f.input.edges.a=true;menu:update(1/60);f.input.edges.a=nil + T.eq(peerSelected,1,'native callback-only submenu actions keep functioning') + g.stack:removeAll({menu}) + -- Native evolution cancellation/completion and reminder windows remain owned. + local Evo=require('src.ui.game3.evolution_scene');local Relearn=require('src.ui.game3.move_relearner') + local row=g.data.pokemon.CHARMANDER + row.evolutions={{method='LEVEL',level=31,species='CHARMELEON'}} + T.eq(require('src.core.game3.evolution').levelTarget(mon,session),5) + local evoDone=0 + local function closeText() + local class=require('src.render.TextBox') + for _=1,40 do + local top=g.stack:top();if getmetatable(top)~=class then return end + top:revealPage();top.holdFrames=0;top.preWait=0;top:advance() + end + error('native dialogue did not finish') + end + T.eq(Evo.start(mon,5,{onDone=function()evoDone=evoDone+1 end}),true) + local scene=g.stack.states[#g.stack.states-1];closeText();scene:stop();closeText() + T.eq(nativeMon.species,'CHARMANDER');T.eq(evoDone,1);T.eq(Evo.isOpen(),false);T.eq(g.stack:top(),w) + T.eq(Evo.start(mon,5,{onDone=function()evoDone=evoDone+1 end}),true) + scene=g.stack.states[#g.stack.states-1];closeText();scene:finish();closeText() + T.eq(nativeMon.species,'CHARMELEON');T.eq(mon.species,5);T.eq(evoDone,2);T.eq(g.stack:top(),w) + local movesRow=g.data.pokemon.CHARMELEON;movesRow.learnset={{level=1,move='GROWL'}} + T.eq(require('src.core.game3.move_learn').relearnableMoves(mon)[1],34) + local learned,learnDone=nil,0 + local function chooseTop() + local input=g.input;local pressed=input.wasPressed + input.wasPressed=function(_,key)return key=='a'end + g.stack:update(1/60);input.wasPressed=pressed + end + T.eq(Relearn.show(mon,{onDone=function(ok)learned=ok;learnDone=learnDone+1 end}),true) + chooseTop();closeText() + T.eq(nativeMon.moves[2].id,'GROWL');T.eq(nativeMon.moves[2].pp,40) + T.eq(learned,true);T.eq(learnDone,1);T.eq(Relearn.isOpen(),false);T.eq(g.stack:top(),w) + nativeMon.moves[3]={id='TAIL_WHIP',pp=40};nativeMon.moves[4]={id='SPLASH',pp=40} + movesRow.learnset={{level=1,move='LEER'}} + T.eq(Relearn.show(mon,{onDone=function()learnDone=learnDone+1 end}),true) + chooseTop();T.eq(getmetatable(g.stack:top()),require('src.render.TextBox')) + Stream.cancelOwner('runtime_probe');T.eq(g.stack:top(),w);T.eq(learnDone,1) + T.eq(nativeMon.moves[4].id,'SPLASH','unloading a reminder cannot replace a native move') + -- Optional features retain native pools, input guards and consumption. + local Features=require('src.core.game3.enhancements') + local flags={timedEncounters=true,trainingDetails=true,repelPrompt=true,quickBall=true} + local release=Features.attach{active=function()return true end,option=function(key)return flags[key]end, + time=function()return 100 end,outdoors=function()return true end,language=function()return'en'end} + T.eq(Features.enabled('quickBall'),true) + g.mods.mods.runtime_probe.enabled=false;T.eq(Features.enabled('quickBall'),false,'disabled owner cannot enable native conveniences') + g.mods.mods.runtime_probe.enabled=true;T.eq(Features.enabled('quickBall'),true) + local sourcePool={grass={rate=100,buckets={60,100},bucketSpan=100,slots={{species='LARV',level=3,chance=60},{species='SEED',level=5,chance=40}}}} + g.data.pokemon.LARV={dex=19};g.data.pokemon.SEED={dex=10} + g.data.constants.speciesOrder[19]='LARV';g.data.constants.speciesOrder[10]='SEED' + g.data.encounters={TEST=sourcePool} + local nativePool=require('src.world.Encounter').forMap(g.data,w.map.def,'TEST',nil,g.save) + T.neq(nativePool,sourcePool);T.eq(nativePool.grass.slots[1].species,'SEED') + T.eq(nativePool.grass.slots[1].chance,60);T.eq(nativePool.grass.slots[2].chance,40) + T.eq(sourcePool.grass.slots[1].species,'LARV','timed frequencies do not rewrite imported pools') + local encounters=require('src.core.game3.encounters') + T.eq(encounters.tableFor(f.canonical).land.slots[1].species,10) + T.check(encounters.rollSweetScent(f.canonical,'land')~=nil,'guaranteed selection uses a native weighted pool') + T.eq(encounters.rollSweetScent(f.canonical,'water'),nil,'missing water never falls back to land') + local details=require('src.ui.TrainingDetails');local summary={game=g,page=2,mon=nativeMon,pages={'Info','Skills','Moves'}} + f.input.edges.select=true;require('src.ui.Gen3SummaryMenu').update(summary,1/60);f.input.edges.select=nil + T.eq(details.mode(summary),1);T.eq(details.stats(summary),nativeMon.ivs);T.eq(details.title(summary,'Skills'),'IV') + f.input.edges.select=true;require('src.ui.Gen3SummaryMenu').update(summary,1/60);f.input.edges.select=nil + T.eq(details.mode(summary),2);T.eq(details.stats(summary),nativeMon.evs) + flags.trainingDetails=false;T.eq(details.mode(summary),0);T.eq(details.stats(summary),nil) + g.data.items.MAX_REPEL={id='MAX_REPEL',name='MAX REPEL',index=88,pocket='ITEM'};g.save.inventory.MAX_REPEL=1 + local prompt=require('src.ui.RepelPrompt');T.eq(prompt.show(g),true) + g.stack:top().choice(false);T.eq(g.save.inventory.MAX_REPEL,1);T.eq(g.save.repelSteps,nil) + T.eq(prompt.show(g),true);g.stack:top().choice(true) + T.eq(g.save.inventory.MAX_REPEL,nil);T.eq(g.save.repelSteps,250) + local nativeText=g.stack:top();nativeText:revealPage();nativeText:advance();T.eq(g.stack:top(),w) + g.data.items.POKE_BALL={id='POKE_BALL',name='POKE BALL',index=4,pocket='BALL'};g.save.inventory.POKE_BALL=2 + local qb=require('src.battle.BattleState').newWild(g,'CHARMANDER',17);g.stack:push(qb) + qb.phase='menu';qb.queue={};qb.current=nil;qb.introSlide=0;qb.showEnemyTrainer=nil;qb.showPlayerBack=nil;qb.enemySendingOut=nil + local Q=require('src.battle.QuickItem');local thrown=0 + qb.throwBall=function()thrown=thrown+1 end + f.input.edges.r=true;local request=Q.request(qb);f.input.edges.r=nil + T.check(request~=nil,'quick item uses the native idle-menu admission') + if request then T.eq(Q.dispatch(qb,request),true);T.eq(thrown,1);T.eq(g.save.inventory.POKE_BALL,1)end + qb.queue={{wait=1}};f.input.edges.r=true;T.eq(Q.request(qb),nil);f.input.edges.r=nil + T.eq(g.save.inventory.POKE_BALL,1,'busy combat cannot spend another ball') + g.stack:removeAll({qb});release();T.eq(Features.enabled('quickBall'),false) + T.eq(require('src.world.Encounter').forMap(g.data,w.map.def,'TEST',nil,g.save),sourcePool) + local prior=g.save;g.save={player=prior.player,options={}} + T.eq(session.party,nil);T.raises(function()mon.hp=1 end,'retired') + T.eq(Bag.add(session.bag or{},7,1),false) + Host.currentMod=nil;Stream.shutdown();G.release();bf.close() +end +T.finish('native companion commands, typed residents, UI and encounter lifecycle') diff --git a/tests/engine/native_companion_terrain.lua b/tests/engine/native_companion_terrain.lua new file mode 100644 index 000000000..f6603db48 --- /dev/null +++ b/tests/engine/native_companion_terrain.lua @@ -0,0 +1,164 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local C=require('src.core.game3.collision') +local E=require('src.core.game3.encounters') +local Map=require('src.core.game3.map') +local Ledge=require('src.world.Ledge') +local NativeMap=require('src.world.Map') +local previous=package.loaded['src.core.Game'] +T.eq(previous,nil,'imports do not create a game') +T.eq(E.ensureLoaded(),false);T.eq(E.tableFor('unknown'),nil);T.eq(E.terrainAt(0,0),nil) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.map-runtime.fixture').new(edition) + local g,w,m=f.game,f.world,f.map + w.player={cellX=0,cellY=0,elevation=6};w.entities={w.player} + g.save={player={},flags={},gen3Vars={}} + package.loaded['src.core.Game']=g + local def=assert(Map.currentDef()) + for _,from in ipairs{0,3,6,15}do for _,to in ipairs{0,1,3,6,15}do + m.elevationPatch[1]=from;m.elevationPatch[2]=to;m:noteGridChange() + local first,second=C.nextElevation(def,6,1,0,0,0) + local keep=from==15 or to==15 + T.eq(first,keep and 6 or to,'height follows stairs and bridge transitions') + local expected + if not keep and to~=0 then expected=to end + T.eq(second,expected,'secondary sprite elevation omits zero and bridge markers') + T.eq(select('#',C.nextElevation(def,6,1,0,0,0)),2,'source query keeps both return slots') + T.eq(m:elevationAfter(6,1,0,0,0),first,'native mover shares the transition') + T.eq(select('#',m:elevationAfter(6,1,0,0,0)),1,'native method keeps its return arity') + end end + m.elevationPatch[2]=3;m:noteGridChange() + T.eq(C.nextElevation(def,6,1,0),3,'an omitted previous cell still accepts the destination height') + T.eq(C.nextElevation({},6,1,0,0,0),6,'foreign map is not admitted') + T.eq(C.nextElevation(def,6,0/0,0,0,0),6) + local directions={'right','left','up','down','up','up','down','down'} + m.tileset.collision={};m.def.behaviorCells=nil + local W=require('src.world.OverworldController') + local gameSlot,oldGame + for i=1,100 do + local name,value=debug.getupvalue(W.checkLedgeHop,i) + if name=='Game'then gameSlot,oldGame=i,value;break end + end + assert(gameSlot);debug.setupvalue(W.checkLedgeHop,gameSlot,g) + local call + w.startLedgeHop=function(_,...)call={n=select('#',...),...};return true end + w.gen2LedgeAllows=function()return false end + for n=1,8 do + for _,dir in ipairs{'up','down','left','right'}do + -- Center is (1,0); only horizontal hops fit this tiny real map. + m.tileset.collision[m:blockAt(1,0)+1]=0x37+n + local originX=dir=='left'and 2 or 0 + local lx,ly=C.ledgeLanding(g,originX,0,dir) + local matching=(dir=='right'or dir=='left')and directions[n]==dir + T.eq(lx,matching and (dir=='right'and 2 or 0)or nil,'ledge facing and landing bounds') + T.eq(ly,matching and 0 or nil) + w.player.cellX,w.player.cellY=originX,0 + call=nil + T.eq(W.checkLedgeHop(w,dir),matching==true,'actual native input dispatch keeps its hop decision') + if matching then + T.eq(call[1],dir);T.eq(call[2],1);T.eq(call[3],0) + T.eq(call.n,3,'ordinary native hop callback preserves its argument count') + else T.eq(call,nil)end + end + end + debug.setupvalue(W.checkLedgeHop,gameSlot,oldGame) + m.tileset.ledgeBehaviours={[0x38]='west'} + m.tileset.collision[m:blockAt(1,0)+1]=0x38 + T.eq(C.ledgeLanding(g,0,0,'right'),nil,'native dataset direction override wins') + T.eq(C.ledgeLanding(g,2,0,'left'),0) + T.eq(C.ledgeLanding(g,0,0,'bad'),nil) + T.eq(C.ledgeLanding(g,0/0,0,'right'),nil) + T.eq(C.ledgeLanding(g,1,0,'right'),nil,'never returns an off-map landing') + -- Pure source observations include current native patches and grass differs + -- from cave/desert encounter terrain. They do not execute movement hooks. + m.tileset.ledgeBehaviours=nil + m.grassTiles={[0]=true};m.encounterTiles={[0]=true,[1]=true} + m.collisionPatch={};m.elevationPatch={} + for y=0,1 do for x=0,2 do + T.eq(C.isGrass(x,y),m:isGrassCell(x,y)) + local water=m:isWaterCell(x,y) + local expected=water and 'water'or m:isEncounterCell(x,y)and 'land'or nil + T.eq(E.terrainAt(x,y),expected) + end end + T.eq(C.isGrass(3,0),false);T.eq(E.terrainAt(3,0),nil) + T.eq(E.terrainAt(math.huge,0),nil);T.eq(E.terrainAt(1.5,0),nil) + m.tileset.collision[1],m.tileset.collision[2],m.tileset.collision[3]=2,3,0x15 + m.grassTiles={[2]=true};m.encounterTiles={[2]=true,[3]=true} + m:setGen3Cells({{x=0,y=0,mid=0,coll=0,elev=3},{x=1,y=0,mid=1,coll=0,elev=3}, + {x=2,y=0,mid=2,coll=0,elev=1}}) + T.eq(C.isGrass(0,0),true);T.eq(C.isGrass(1,0),false) + T.eq(E.terrainAt(0,0),'land');T.eq(E.terrainAt(1,0),'land','caves need encounters without grass cover') + T.eq(E.terrainAt(2,0),'water') + m:setGen3Cells({{x=0,y=0,coll=1}}) + T.eq(E.terrainAt(0,0),nil,'blocked grass is not encounter ground') + m.def.mapType='UNDERWATER';m.tileset.collision[1]=0x2a + m:setGen3Cells({{x=0,y=0,coll=0}}) + T.eq(E.terrainAt(0,0),'water','seaweed uses native underwater rules, even without surfing') + m.tileset.collision[1]=0 + T.eq(E.terrainAt(0,0),nil,'bare sea floor has no encounter terrain') + m.def.mapType='ROUTE' + g.data.pokemon={COMMON={index=1},SPARSE={index=277,dex=252}} + g.data.constants.speciesOrder={[1]='COMMON',[277]='SPARSE'} + local base={grass={rate=21,slots={{species='SPARSE',min=2,max=9}}}, + water={rate=15,slots={{species='COMMON',level=12}}}, + rock={rate=10,slots={{species='COMMON',min=3,max=4}}}, + fish={rate=25,slots={{species='SPARSE',min=5,max=6}}}} + local alternate={grass={rate=9,slots={{species='COMMON',min=7,max=8}}}} + base.alternates={alternate} + g.data.encounters={NATIVE_A=base} + T.eq(E.ensureLoaded(),true) + local view=assert(E.tableFor(f.mapId)) + T.eq(view.land.slots[1].species,277,'species uses cartridge slot, never National Dex') + T.eq(view.land.slots[1].minLevel,2);T.eq(view.land.slots[1].maxLevel,9) + T.eq(view.water.slots[1].level,12);T.eq(view.rocks.slots[1].minLevel,3) + T.eq(view.fishing.slots[1].species,277) + T.eq(E.tableFor('NATIVE_A').land.rate,21,'native map names resolve to the same owned map') + T.eq(E.tableFor(m.def.group..':'..m.def.number).land.rate,21,'ROM map slot aliases resolve') + T.eq(E.tableFor('unknown'),nil) + view.land.slots[1].species=1;view.land.rate=0 + T.eq(base.grass.slots[1].species,'SPARSE');T.eq(base.grass.rate,21) + local variable=({firered=0x4024,leafgreen=0x4024,emerald=0x403e})[edition] + if variable then + g.save.gen3Vars[variable]=1 + T.eq(E.tableFor(f.mapId).land.rate,9,'live script state selects alternate header') + T.eq(require('src.world.Encounter').forMap(g.data,m.def,m.id,nil,g.save),alternate, + 'native wild encounters use the same effective table') + for _,invalid in ipairs{-1,2,65535,0/0,math.huge,1.5}do + g.save.gen3Vars[variable]=invalid + T.eq(E.tableFor(f.mapId).land.rate,21,'invalid alternate resets to base') + end + g.save.gen3Vars[variable]=0 + end + local oldRandom=love.math.random + love.math.random=function()error('observation consumed native RNG')end + T.check(pcall(function()E.ensureLoaded();E.tableFor(f.mapId);E.terrainAt(0,0)end)) + love.math.random=oldRandom + base.grass.slots[1].species='MISSING' + local missing,why=E.tableFor(f.mapId) + T.eq(missing,nil);T.check(why and why:find('cartridge index',1,true)~=nil) + g.data.encounters={NATIVE_A={grass={rate=3,slots={{species='COMMON',min=1,max=1}}}}} + T.eq(E.tableFor(f.mapId).land.rate,3,'native table replacement is observed immediately') + local raw=g.data.maps.NATIVE_A;g.data.maps.NATIVE_A={} + T.eq(E.tableFor(f.mapId),nil,'retired map identity cannot expose old encounters') + T.eq(C.nextElevation(def,6,1,0,0,0),6) + g.data.maps.NATIVE_A=raw + g.stack.states={{isTitle=true}} + T.eq(C.isGrass(0,0),false);T.eq(E.terrainAt(0,0),nil) + C.release();Map.release();f.views:release() +end +-- The native classifier still preserves pair rules, explicit overrides and +-- the later-edition Distortion World and BW behavior spaces. +do + local map={def={},tileset={},inBounds=function()return true end,cellBehaviour=function()return 0x5a end} + T.eq(Ledge.approach(map,{},0,0,'right'),false,'older pair rules retain native fallback') + map.def.generation=4;map.tileset.ledgeBehaviours={[0x5a]='east'} + local handled,x,y,distance,silent=Ledge.approach(map,{},0,0,'right') + T.eq(handled,true);T.eq(x,1);T.eq(y,0);T.eq(distance,3);T.eq(silent,true) + map.def.gen5=true;map.cellBehaviour=function()return 0x75 end + local _,bx,by=Ledge.approach(map,{},0,0,'down') + T.eq(bx,0);T.eq(by,1,'BW rules take precedence over imported stand-in tileset') +end +package.loaded['src.core.Game']=previous +T.finish('native companion terrain and encounter observations') diff --git a/tests/engine/native_companion_terrain_lifecycle.lua b/tests/engine/native_companion_terrain_lifecycle.lua new file mode 100644 index 000000000..cddcc7019 --- /dev/null +++ b/tests/engine/native_companion_terrain_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/companion-terrain/lifecycle-check.lua') diff --git a/tests/engine/native_companion_warp_read.lua b/tests/engine/native_companion_warp_read.lua new file mode 100644 index 000000000..ae467af86 --- /dev/null +++ b/tests/engine/native_companion_warp_read.lua @@ -0,0 +1,23 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Map=require('src.core.game3.map') +local C=require('src.core.game3.collision') +local original=package.loaded['src.core.Game'] +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.map-runtime.fixture').new(edition) + local g,w=f.game,f.world;package.loaded['src.core.Game']=g + w.player={};g.save={flags={}} + local def=assert(Map.currentDef());local warp=assert(def.warps[1]) + local before=require('src.link.Json').encode(g.save) + local runtime=require('src.mods.Runtime');local call=runtime.call + runtime.call=function(name,...)assert(name~='warp.destination','read cannot execute warp decision');return call(name,...)end + local id,x,y=C.resolveWarpDestination(g,warp) + T.eq(id,warp.destMap);T.check(type(x)=='number');T.check(type(y)=='number') + T.eq(require('src.link.Json').encode(g.save),before);T.eq(w.transitioning,nil) + T.eq(C.resolveWarpDestination(g,{destMap=warp.destMap,destWarp=warp.destWarp}),nil,'foreign copied records have no native provenance') + Map.release();T.eq(C.resolveWarpDestination(g,warp),nil,'retired source records cannot resolve') + runtime.call=call;f.views:release();C.clear() +end +package.loaded['src.core.Game']=original +T.finish('native companion destination reads preserve gameplay hooks and save state') diff --git a/tests/engine/native_dataset_source.lua b/tests/engine/native_dataset_source.lua new file mode 100644 index 000000000..29e6ca1fa --- /dev/null +++ b/tests/engine/native_dataset_source.lua @@ -0,0 +1,36 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Dataset=require('src.core.game3.dataset') +local Fs=require('src.import.CacheFs') +local Version=require('src.core.GameVersion') +local Stream=require('src.core.game3.asset_stream') +local root,fs=Fs.root,Fs.dataFs +local seen,files={},{} +Fs.root=function()return nil end +Fs.dataFs=function()return{ + read=function(name)seen[#seen+1]=name;return files[name]end, + getInfo=function(name)seen[#seen+1]=name;return files[name]and{type='file'}or nil end, +}end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Stream.shutdown();require('src.core.Game3').release() + local f=require('tools.session-runtime.fixture').new(edition) + local prefix=Version.cachePrefix();local rel='data/generated/gba/native/test/palettes.bin' + files[prefix..rel]='exact-raw-'..edition..'\0\255';files[rel]='wrong-unversioned-artifact' + local cache=Dataset.cache();T.eq(Dataset.cache(),cache,'same native dataset retains its reader identity') + T.eq(cache:read(rel),files[prefix..rel]);T.eq(cache:exists(rel),true) + T.eq(seen[#seen],prefix..rel,'reader uses the current cartridge prefix without changing CacheFs.prefix') + T.eq(cache:read('data/generated/missing.bin'),nil);T.eq(cache:exists('data/generated/missing.bin'),false) + local count=#seen + for _,bad in ipairs{'../save.lua','saves/player.sav','data/generated/../save.lua','/data/generated/x','data/generated/C:\\save','data/generated//x'}do + T.eq(cache:read(bad),nil);T.eq(cache:exists(bad),false) + end + T.eq(#seen,count,'non-artifact paths never reach a filesystem') + local data=f.game.data;f.game.data={};Stream.poll() + T.eq(cache:read(rel),nil,'replacement import retires the old reader');T.eq(cache:exists(rel),false) + f.game.data=data;T.neq(Dataset.cache(),cache,'retired reader cannot revive') + local nextCache=Dataset.cache();Stream.shutdown();T.eq(nextCache:read(rel),nil,'shutdown revokes retained readers') + files={} +end +Fs.root,Fs.dataFs=root,fs +T.finish('native imported artifact readers, edition isolation and session revocation') diff --git a/tests/engine/native_fade_source.lua b/tests/engine/native_fade_source.lua new file mode 100644 index 000000000..4c00024e3 --- /dev/null +++ b/tests/engine/native_fade_source.lua @@ -0,0 +1,53 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Fade=require('src.ui.game3.fade') +local Layer=require('src.render.FieldFade') +local Transition=require('src.render.Transition') +local Host=require('src.mods.Runtime') +local Stream=require('src.core.game3.asset_stream') +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Stream.shutdown();require('src.core.Game3').release() + local f=require('tools.session-runtime.fixture').new(edition);local g,w=f.game,f.world + g.mods.mods={fade_owner={enabled=true},peer={enabled=true}} + local gameUpdate=w.update;w.update=function()error('source fade cannot pump a second field clock')end + Host.currentMod='fade_owner' + T.eq(Fade.isActive(),false);T.eq(Fade.t,0) + local done=0 + T.eq(Fade.begin(Fade.MODE.TO_BLACK,2,function()done=done+1 end),true) + local owned=g.stack:top();T.eq(getmetatable(owned),Layer) + T.eq(Fade.lockInput,true);T.eq(Fade._dir,1) + for step=1,32 do + g.stack:update(1/60);T.eq(Fade.t,math.floor(step/2),'source palette step follows native fixed ticks') + end + T.eq(done,1);T.eq(Fade.t,16);T.eq(Fade.isActive(),false);T.eq(g.stack:top(),owned,'covered palette remains until its owner clears it') + for _=1,130 do g.stack:update(1/60)end + T.eq(Fade.t,16,'an owned hold does not trigger the native abandoned-script watchdog') + Host.currentMod='peer';T.eq(Fade.clear(),false);T.eq(Fade.begin(0,1),false);T.eq(g.stack:top(),owned) + Host.currentMod='fade_owner';T.eq(Fade.begin(Fade.MODE.FROM_BLACK,1,function()done=done+1 end),true) + for _=1,16 do g.stack:update(1/60)end + T.eq(done,2);T.eq(Fade.t,0);T.eq(g.stack:top(),w);T.eq(owned.sourceRetired,true) + T.eq(Fade.begin(Fade.MODE.TO_WHITE,1,function()done=done+100 end),true) + local old=g.stack:top();g.renderer={};old:draw();T.check(g.renderer.screenVeil~=nil) + collectgarbage('collect');Stream.cancelOwner('fade_owner') + T.eq(g.stack:top(),w);T.eq(g.renderer.screenVeil,nil,'unload releases the owned window veil') + T.eq(old.sourceRetired,true);T.eq(old.game,nil);old:update(1/60);old:draw();T.eq(done,2) + -- Native fades remain native, including script runner progress and cleanup. + w.update=gameUpdate + local script=Layer.new(g,w,'white');w.fadeOverlay=script + script.ramp={from=0,to=1,frames=4,t=0} + g.stack:push(script) + T.eq(Fade.isActive(),true);T.eq(Fade.mode,3);T.eq(Fade._dir,1) + T.eq(Fade.clear(),false);T.eq(Fade.begin(0,1),false,'a source request cannot replace a native script fade') + script.ramp.t=2;script.alpha=.5;T.eq(Fade.t,8) + g.stack:removeAll({script});T.eq(w.fadeOverlay,nil) + local warp=Transition.new(g);g.stack:push(warp);warp.t=math.floor(warp.frames/2) + T.eq(Fade.isActive(),true);T.eq(Fade.t,math.floor(warp:alpha()*16+.5));T.eq(Fade.mode,1) + warp.phase='in';T.eq(Fade._dir,-1);T.eq(Fade.mode,0) + g.stack:removeAll({warp});T.eq(Fade.lockInput,false) + local flash=Transition.whiteFlash(g);g.stack:push(flash);T.eq(Fade.t,16);T.eq(Fade.mode,2);g.stack:removeAll({flash}) + local back=Transition.battleReturn(g);g.stack:push(back);T.eq(Fade.mode,2);T.eq(Fade._dir,-1);g.stack:removeAll({back}) + T.raises(function()Fade.t=10 end,'read-only');T.raises(function()Fade.begin(1,0)end,'invalid native fade speed') + Host.currentMod=nil;Stream.shutdown();require('src.core.Game3').release() +end +T.finish('native fade observations, owned palette steps and foreign-owner isolation') diff --git a/tests/engine/native_field_collision.lua b/tests/engine/native_field_collision.lua new file mode 100644 index 000000000..86c6f0863 --- /dev/null +++ b/tests/engine/native_field_collision.lua @@ -0,0 +1 @@ +dofile('tools/field-collision/check.lua') diff --git a/tests/engine/native_field_effect_sheets.lua b/tests/engine/native_field_effect_sheets.lua new file mode 100644 index 000000000..4da051d23 --- /dev/null +++ b/tests/engine/native_field_effect_sheets.lua @@ -0,0 +1 @@ +dofile('tools/field-effect-sheets/check.lua') diff --git a/tests/engine/native_field_move_audio.lua b/tests/engine/native_field_move_audio.lua new file mode 100644 index 000000000..d03055026 --- /dev/null +++ b/tests/engine/native_field_move_audio.lua @@ -0,0 +1 @@ +dofile('tools/field-move-runtime/audio-check.lua') diff --git a/tests/engine/native_field_move_lifecycle.lua b/tests/engine/native_field_move_lifecycle.lua new file mode 100644 index 000000000..c8a81da29 --- /dev/null +++ b/tests/engine/native_field_move_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/field-move-runtime/lifecycle-check.lua') diff --git a/tests/engine/native_field_move_presentation.lua b/tests/engine/native_field_move_presentation.lua new file mode 100644 index 000000000..731649605 --- /dev/null +++ b/tests/engine/native_field_move_presentation.lua @@ -0,0 +1,133 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local _,imageFile=require('tools.sprite-runtime.cpu-gpu').install(love) +local Source=require('src.core.game3.field_move_show_mon') +local Native=require('src.world.Gen3FieldMove') +local Host=require('src.mods.Runtime') +local Renderer=require('src.render.Renderer') +T.eq(Source.isActive(),false,'requiring the bridge does not invent a native admission') +local saved={start=Source.start,step=Source.step,draw=Source.draw} +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Source.release() + local fixture=require('tools.session-runtime.fixture').new(edition) + local game=fixture.game;local stack=game.stack + game.data.pokemon={[1]={name='TEST',spriteFront='assets/generated/field-mon.png'}} + imageFile('assets/generated/field-mon.png',16,16,string.rep(string.char(17,31,47,255),256)) + local strip={width=256,height=80,indexed=string.rep('\1',256*80),palette={}} + for i=1,16 do strip.palette[i]=i end + game.data.constants.gen3FieldMovePresentation={schema=1,edition=edition,romSha1=require('src.core.GameVersion').info(edition).sha1,strips={outdoors=strip,indoors=strip}} + local completions=0 + local mon={species=1} + local starts,steps,draws=0,0,0 + Source.start=function(...)starts=starts+1;return saved.start(...)end + Source.step=function(...)steps=steps+1;local a,b=saved.step(...);saved.step(...);return a,b end + Source.draw=function(...)draws=draws+1;local a,b=saved.draw(...);saved.draw(...);return a,b end + local shown,state=Native.show(game,mon,function()completions=completions+1;return 'continued',nil,7 end) + T.eq(shown,true);T.eq(starts,1,'native entry reaches the existing public start wrapper once') + local theme=require('src.ui.Theme');local previousBox=theme.textBox + for _,box in ipairs{{tx=0,ty=0,tw=20,th=18},{tx=0,ty=0,tw=32,th=24}}do + theme.textBox=box + local width,height=state:uiSize();T.eq(width,240,'dialogue themes cannot resize a native field cut-in');T.eq(height,160) + end + theme.textBox=previousBox + T.eq(Source.isActive(),true);T.eq(fixture.player.fieldMove,true) + T.eq(Native.show(game,mon),false,'one native field-move admission cannot overlap another') + Source.release();T.eq(Source.isActive(),true,'observer teardown preserves an engine-owned field move') + local retained=state.onDone + T.eq(retained(),false,'a retained callback cannot finish before its native admission') + T.eq(completions,0) + local before=state.t;saved.step();T.eq(state.t,before,'source calls outside the phase cannot advance native logic') + stack:update(1/60);T.eq(state.t,before+1,'duplicate base calls advance the real native tick once') + T.eq(steps,1) + local age=state.frame + Renderer.frameSerial=(Renderer.frameSerial or 0)+1 + state:draw();T.eq(state.frame,age,'native drawing cannot advance animation') + T.eq(draws,1) + local cover={};stack:push(cover) + T.eq(state:finish(),false,'covered field moves cannot pop another native screen') + state:update();T.eq(state.t,before+1,'direct updates cannot advance a covered admission') + T.eq(stack:top(),cover);stack:pop() + for i=1,100 do stack:update(1/60)end + T.eq(completions,1,'real native completion calls the pending callback once') + T.eq(Source.isActive(),false);T.eq(state.sprite,nil,'native exit releases its owned image') + T.eq(fixture.player.fieldMove,nil);T.eq(fixture.player.fieldMovePose,nil) + local a,b,c=retained();T.eq(a,'continued');T.eq(b,nil);T.eq(c,7);T.eq(completions,1,'retained callbacks cannot replay') + Source.start=saved.start;Source.step=saved.step;Source.draw=saved.draw + Host.currentMod='source-owner' + T.eq(Source.start(1,{pose=false},function()completions=completions+1 end),true) + Host.currentMod=nil + state=stack:top();T.eq(state.player,nil,'source pose=false does not change the native player') + local cancelCallback=state.onDone + local payload=setmetatable({},{__mode='v'}) + do + local held={};payload[1]=held + Source.reset() + Host.currentMod='source-owner' + Source.start(1,{pose=true},(function(value)return function()return value end end)(held)) + Host.currentMod=nil + end + state=stack:top();local heldCallback=state.onDone + stack:push(cover);Source.cancelOwner('source-owner') + T.eq(stack:top(),cover,'owner retirement preserves a covering native screen') + T.eq(Source.isActive(),false);T.eq(fixture.player.fieldMove,nil) + collectgarbage('collect');collectgarbage('collect') + T.eq(payload[1],nil,'even peer-held wrappers release cancelled callback captures') + T.eq(heldCallback(),false);T.eq(cancelCallback(),false) + stack:pop();stack:update(1/60);T.eq(stack:top(),fixture.world,'cancelled covered admission retires when uncovered') + local count=completions + Native.show(game,mon,function()completions=completions+1 end) + state=stack:top();local stale=state.onDone + local oldSave=setmetatable({game.save},{__mode='v'}) + game.save={};state:update() + T.eq(stack:top(),fixture.world,'save replacement retires the exact native admission') + T.eq(stale(),false);T.eq(completions,count) + collectgarbage('collect');collectgarbage('collect') + T.eq(oldSave[1],nil,'peer-held callbacks cannot retain an entire retired save cohort') + Native.show(game,mon,function()error('deliberate continuation error')end) + state=stack:top();local failed=state.onDone + T.raises(function()state:finish()end,'deliberate continuation error','callback failure remains observable') + T.raises(failed,'deliberate continuation error','retained callback preserves error without replay') + T.eq(stack:top(),fixture.world);T.eq(state.sprite,nil) + -- The normal draw and an explicit source draw share one render admission. + Native.show(game,mon,nil,{pose=false});state=stack:top() + state.phase='in';state.t=1 + local rectangles=0;local rectangle=love.graphics.rectangle + love.graphics.rectangle=function(...)rectangles=rectangles+1;return rectangle(...)end + Renderer.frameSerial=(Renderer.frameSerial or 0)+1 + saved.draw();local external=rectangles + T.check(external>0,'explicit source draw paints the actual native effect') + state:draw();T.eq(rectangles,external,'native UI does not paint the same externally drawn cut-in again') + Renderer.frameSerial=Renderer.frameSerial+1 + state:draw();T.check(rectangles>external,'a following frame retains the ordinary native draw') + love.graphics.rectangle=rectangle + state:cancel() + local nested,again,invocations + invocations=0 + Native.show(game,mon,function()invocations=invocations+1;nested=again();return 'native-result'end) + state=stack:top() + again=state.onDone + -- Reentrant continuations retain the already-executing guard, not a new + -- native admission or a second callback execution. + state:finish();T.eq(nested,false);T.eq(invocations,1);T.eq(again(),'native-result') + game.data.pokemon[2]={name='REPLACEMENT',spriteFront='assets/generated/field-mon-two.png'} + imageFile('assets/generated/field-mon-two.png',16,16,string.rep(string.char(91,73,53,255),256)) + Source.start=function(_,_,callback)return saved.start({species=2},{pose=false},callback)end + local changed + Native.show(game,mon,function()changed=true end) + state=stack:top();T.eq(state.mon.species,2,'native start wrappers configure the actual admitted Pokemon') + T.eq(state.player,nil,'native start wrappers can disable their native pose') + T.eq(state.spritePath,'assets/generated/field-mon-two.png','configured picture uses the native art resolver') + state:finish();T.eq(changed,true) + Source.start=function()return false end + T.eq(Native.show(game,mon,function()error('vetoed field move continued')end),false,'explicit native start veto retires its provisional admission') + T.eq(stack:top(),fixture.world);T.eq(fixture.player.fieldMove,nil) + Source.start=saved.start + Host.currentMod='completed-owner';Source.start(1,{},function()return {}end);Host.currentMod=nil + state=stack:top();local completedCallback=state.onDone + state:finish();T.eq(type(completedCallback()),'table') + Source.cancelOwner('completed-owner');T.eq(completedCallback(),false,'retired source callbacks cannot replay cached results') + T.eq(Source.start(1,{noDuck=true}),true,'cry options use the authenticated native profile');Source.reset() + T.eq(Source.start({},{}),false,'missing real picture cannot create a fake successful effect') +end +Source.release();T.finish('native field-move admission, one clock, explicit draw and continuation lifetime') diff --git a/tests/engine/native_field_move_runtime.lua b/tests/engine/native_field_move_runtime.lua new file mode 100644 index 000000000..08b110242 --- /dev/null +++ b/tests/engine/native_field_move_runtime.lua @@ -0,0 +1 @@ +dofile('tools/field-move-runtime/check.lua') diff --git a/tests/engine/native_field_view.lua b/tests/engine/native_field_view.lua new file mode 100644 index 000000000..2a03227b5 --- /dev/null +++ b/tests/engine/native_field_view.lua @@ -0,0 +1,82 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +require('tools.native-atlas.cpu-gpu').install(love) +local Field=require('src.core.game3.field_view') +local Native=require('src.core.game3.tileset_native') +local Host=require('src.mods.Runtime') +local Game3=require('src.core.Game3') +local Map=require('src.core.game3.map') +local body=Field.draw +T.eq(body(),false,'no invented field when no native player is admitted') +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.field-view-runtime.fixture').new(edition) + local game,world=f.game,f.world + local retained,callbacks,frames=nil,0,0 + Host.currentMod='field_owner' + local facade=assert(Game3.forNative(game)) + local ok,why=body(facade,240,160,{endUnderActors=function(upper)retained=upper;T.eq(upper(),true);callbacks=callbacks+1 end}) + assert(ok,why);T.eq(ok,true) + T.eq(callbacks,1);T.eq(retained(),false,'upper-layer callback expires at the render boundary') + T.eq(Field._viewW,240);T.eq(Field._viewH,160) + T.check(Field._nativeBatches[f.native.pair]~=nil,'real owned bottom batches cover the native map') + T.check(Field._nativeOverBatches[f.native.neighborPair]~=nil,'connected map retains its own source atlas') + T.eq(world.map.def.width,3,'padded compatibility width cannot resize the native room') + T.eq(Map.currentDef().midLayout.width,4);T.eq(Map.currentDef().midLayout.trueWidth,3) + local batches=Field._nativeBatches;local first=batches[f.native.pair] + T.eq(body(facade,240,160),true);T.eq(Field._nativeBatches,batches);T.eq(batches[f.native.pair],first,'stationary frames reuse GPU batches') + local count=0 + for _,e in ipairs(world.entities)do e.draw=function()count=count+1 end end + body(facade,240,160,{skipActors=true});T.eq(count,0,'terrain capture excludes native actors explicitly') + body(facade,240,160,{actorsOnly=true});T.eq(count,3,'actor capture draws the actual native entities') + T.eq(Field.setCameraPanning(7,-9),true) + local x,y + world.player.draw=function(_,cx,cy)x,y=cx,cy end + body(facade,320,200,{actorsOnly=true}) + T.eq(x,math.floor(world.player.px+8-160)+7);T.eq(y,math.floor(world.player.py+8-100)-9) + T.eq(Field.setFlashLevel(4),4);T.eq(game.save.gen3FlashLevel,4);T.eq(Field.flashRadius(),24) + T.eq(Field.setFlashLevel(200),0);T.eq(Field.flashRadius(),nil) + Field.animateFlashLevel(4,1);T.eq(game.save.gen3FlashLevel,1);T.eq(Field.flashRadius(),24) + local before=game.save.gen3FlashRadius;body(facade,240,160,{actorsOnly=true});T.eq(game.save.gen3FlashRadius,before,'draw does not advance the native Flash task') + require('src.world.Gen3Flash').tick(game);T.eq(Field.flashRadius(),25) + Field.setFlashRadius(37);T.eq(Field.flashRadius(),37);Field.setFlashRadius(nil) + local words={};for i=1,16 do words[i]=31 end + local bank=game.data.map_tilesets.P.palettes[1][2][1] + T.eq(Field.setBgPaletteOverride(0,words),true) + local ts=Native.get(f.native.pair);T.check(ts.patchedSlots[0],'actual source atlas palette is patched') + T.eq(game.data.map_tilesets.P.palettes[1][2][1],bank,'borrowed native colours are untouched') + T.eq(Field.setBgPaletteOverride(0,nil),true);T.eq(ts.patchedSlots[0],nil) + Field.setBgPaletteOverride(0,words);words[1]=0 + require('src.render.Assets').invalidate() + T.eq(body(facade,240,160),true,'native asset reload reconstructs field batches') + ts=assert(Native.get(f.native.pair)) + T.eq(ts._model.paletteOverrides[0][1][1],255,'active palette override survives reload with detached input words') + Field.setBgPaletteOverride(0,nil) + local nativeTick=require('src.render.TileRenderer').tick;local ticks=0 + require('src.render.TileRenderer').tick=function(...)ticks=ticks+1;return nativeTick(...)end + Field.draw=function(view,w,h,opts)frames=frames+1;T.eq(view,facade);return body(view,w,h,opts)end + world:drawWorld();T.eq(frames,1,'actual world draw enters the source replacement');T.eq(ticks,1,'one native tile cadence per presentation') + Field.draw=function()return false,'synthetic field decline'end + world:drawWorld();T.eq(ticks,2,'declining the source renderer does not tick again in native fallback') + Field.draw=function(view,w,h)world:drawWorld();return body(view,w,h)end + world:drawWorld();T.eq(ticks,3,'nested native presentation consumes the same native tile cadence') + require('src.render.TileRenderer').tick=nativeTick + Field.draw=function()love.graphics.push();love.graphics.setColor(.2,.3,.4,.5);error('field draw failure')end + local push,pop=love.graphics.push,love.graphics.pop;local canvas=love.graphics.getCanvas() + T.raises(function()world:drawWorld()end,'field draw failure','callback errors remain visible') + T.eq(love.graphics.push,push);T.eq(love.graphics.pop,pop) + T.eq(love.graphics.getCanvas(),canvas,'failed callback restores the caller target') + Field.draw=body + Host.currentMod=nil + local held=Field._nativeBatches + require('src.core.game3.asset_stream').cancelOwner('other_owner');T.eq(Field._nativeBatches,held,'another mod cannot retire the field owner') + require('src.core.game3.asset_stream').cancelOwner('field_owner');T.check(Field._nativeBatches~=held,'owning mod teardown retires the batches') + T.eq(body(facade,240,160),true) + Field.setFlashRadius(37);world.map=f.native.neighbor + T.eq(Field.flashRadius(),25,'map replacement retires the temporary radius and observes the native Flash task') + world.map=f.native.map + local live=Field._nativeBatches;game.save={options={},flags={}} + T.eq(Field.setCameraPanning(0,0),true);T.check(Field._nativeBatches~=live,'save replacement retires the prior rendering cohort') + f:release();Game3.release() +end +T.finish('native field draw admission, source atlas ownership, options and camera') diff --git a/tests/engine/native_flash_window.lua b/tests/engine/native_flash_window.lua new file mode 100644 index 000000000..94a9ad60b --- /dev/null +++ b/tests/engine/native_flash_window.lua @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local T=require('tests.harness') +love=require('tests.love_stub') +local Flash=require('src.world.Gen3Flash') +T.same(Flash.spans(0,7,5,3,2),{{y=0,height=2,left=0,right=0},{y=2,height=1,left=3,right=3},{y=3,height=2,left=0,right=0}}) +T.same(Flash.spans(1,7,5,3,2),{{y=0,height=1,left=0,right=0},{y=1,height=3,left=2,right=4},{y=4,height=1,left=0,right=0}}) +T.same(Flash.spans(2,7,5,3,2),{{y=0,height=5,left=1,right=5}},'window follows integer scanline update order') +local calls={};local rectangle=love.graphics.rectangle +love.graphics.rectangle=function(...)calls[#calls+1]={...}end +Flash.drawMask(24,240,160,120,80);local first=calls +local published=Flash.spans(24,240,160,120,80);published[1].left=239 +calls={};Flash.drawMask(24,240,160,120,80) +T.same(calls,first,'mutable public spans cannot corrupt the native geometry cache') +love.graphics.rectangle=rectangle +local game={save={gen3FlashLevel=1},data={constants={gen3Flash={maxLevel=1,radii={200,200},screen={240,160}}}}} +require('src.core.GameVersion').set('firered') +T.eq(Flash.circle(game,240,160,120,80),nil,'a centred window covering the view needs no mask') +T.eq(Flash.circle(game,240,160,300,-20).radius,200,'camera pan cannot discard a still-visible mask') +T.finish('native integer Flash geometry and camera bounds') diff --git a/tests/engine/native_follower_callback_owner.lua b/tests/engine/native_follower_callback_owner.lua new file mode 100644 index 000000000..1df881bf8 --- /dev/null +++ b/tests/engine/native_follower_callback_owner.lua @@ -0,0 +1,55 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Version=require('src.core.GameVersion') +Version.set('firered') +local Native=require('src.core.Game') +local Facade=require('src.core.Game3') +local Source=require('src.compat.gen1.SourceActors') +Facade.release() +local run=T.sdk.loadMods({'mods/follower_owner'},{fs=T.sdk.memfs({ + ['mods/follower_owner/manifest.json']=[[{"id":"follower_owner","name":"Follower owner","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/follower_owner/main.lua']=[[ + local mod=... + local F=require('src.world.game3.Follower') + local renderer={draw=function()end} + mod.exports.renderer=renderer + mod.hooks:wrap("world.follower.spawn",function(_,game,world) + mod.exports.seenGame,mod.exports.seenWorld=game,world + F.current(world).sprite=renderer + return true + end) + mod.hooks:wrap('input.step',function(nextFn,game,dt) + if mod.exports.fail then error('owned follower callback failure')end + return nextFn(game,dt) + end) + ]], +})}) +T.eq(#run.errors,0,'native follower consumer loads') +local data=run.data +local def={};for k,v in pairs(data.maps.FIX_TOWN)do def[k]=v end +local object={index=1,sprite='SPRITE_FIX_PLAYER',x=0,y=0,movement='STILL'} +def.id,def.group,def.number,def.objects='MAP_G04_N03',4,3,{object};data.maps[def.id]=def +local npc=require('src.world.NPC').new(data,def.id,object) +npc.optionalFollower=true +local original=npc.sprite +local world={isOverworld=true,map=require('src.world.Map').new(def,data.tilesets[def.tileset]), + player=require('src.world.Player').new(data,1,0,'down'),npcs={npc},entities={npc}} +Native.data,Native.save,Native.overworld,Native.mods,Native.stack=data,{party={}},world,run.loader,{states={world}} +local api=run.loader.exports.follower_owner +T.check(Facade._usesCallbacks('follower_owner'),'follower-only namespace establishes ownership') +T.eq(run.loader.hooks:call("world.follower.spawn",function()return false end,Native,world),true) +T.eq(api.seenGame,Native,'native callback game is not converted') +T.eq(api.seenWorld,world,'native callback world identity is retained') +local renderer,owned=Source.renderer(npc) +T.check(owned and renderer==api.renderer,'actual native actor receives the mod-owned presentation') +T.eq(npc.sprite,original,'native sprite resources retain their original owner') +T.eq(Facade._callbackOwner(),nil,'successful callback restores its owner context') +api.fail=true +T.eq(run.loader.hooks:call('input.step',function(_,dt)return dt end,Native,0.25),0.25,'failed callback retains the native fallback') +T.eq(Facade._callbackOwner(),nil,'failed callback restores its owner context') +run.loader:_rollback('follower_owner') +T.check(not Facade._usesCallbacks('follower_owner'),'rollback removes follower callback ownership') +local _,stillOwned=Source.renderer(npc) +T.check(not stillOwned,'rollback retires the follower presentation') +T.finish('native follower callback ownership') diff --git a/tests/engine/native_healing_source.lua b/tests/engine/native_healing_source.lua new file mode 100644 index 000000000..01ad62450 --- /dev/null +++ b/tests/engine/native_healing_source.lua @@ -0,0 +1,30 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local G=require('src.core.Game3') +local Heal=require('src.core.game3.pokecenter_heal') +local Presentation=require('src.world.Gen3HealingPresentation') +local Assets=require('src.render.Assets');local image=Assets.image +local pictures={A={},B={},glow={}};local loads=0 +Assets.image=function(path)loads=loads+1;return assert(pictures[path])end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + G.release();local f=require('tools.session-runtime.fixture').new(edition);local g,w=f.game,f.world + T.eq(Heal.isActive(),false);T.eq(#Heal.spriteSnapshot(),0) + g.data.constants.gen3HealMachine={monitor={images={'A','B'},x=-8,y=-20},glow={image='glow',x=2,y=3,offsets={{0,0},{4,0},{0,4}}}} + local ha={px=80,py=96,visible=true,lit=2,timer=11};w.healAnim=ha + T.eq(Heal.isActive(),true) + local native=Presentation.snapshot(g,w,ha);local source=Heal.spriteSnapshot() + T.eq(#native,3);T.eq(#source,3);T.eq(source[1].image,pictures.A) + for i,s in ipairs(source)do + T.eq(s.image,native[i].image);T.eq(s.kind,native[i].kind) + T.eq(s.x+f.player.px-112,native[i].x);T.eq(s.y+f.player.py-72,native[i].y) + end + local count=loads;Heal.spriteSnapshot();T.eq(loads,count,'native borrowed art is reused') + source[1].x=999;T.eq(ha.px,80);T.eq(ha.timer,11,'snapshot cannot step native healing') + ha.visible=false;T.eq(Heal.spriteSnapshot()[1].image,pictures.B) + ha.lit=8;T.eq(#Heal.spriteSnapshot(),4,'ball list follows the actual cartridge offset count') + w.healAnim=nil;T.eq(Heal.isActive(),false);T.eq(#Heal.spriteSnapshot(),0) + G.release() +end +Assets.image=image +T.finish('native healing sprite snapshots retain anchors, art and one clock') diff --git a/tests/engine/native_map_preview_source.lua b/tests/engine/native_map_preview_source.lua new file mode 100644 index 000000000..138b48b44 --- /dev/null +++ b/tests/engine/native_map_preview_source.lua @@ -0,0 +1,26 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local M=require('src.ui.game3.map_preview_screen') +local Native=require('src.world.Gen3MapPreviewFRLG') +local original=package.loaded['src.core.Game'] +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + require('src.core.GameVersion').set(edition) + local g={data={},stack=setmetatable({},{__index=require('src.core.StateStack')})} + g.stack:init();package.loaded['src.core.Game']=g + local root={};g.stack:push(root) + T.eq(M.isActive(),false);T.eq(M.dismiss(),false) + local calls=0 + local preview=setmetatable({game=g,onDone=function()calls=calls+1 end},Native) + g.stack:push(preview) + T.eq(M.isActive(),true) + local overlay={};g.stack:push(overlay) + T.eq(M.dismiss(),false);T.eq(g.stack:top(),overlay);T.eq(calls,0) + g.stack:pop() + T.eq(M.dismiss(),true);T.eq(g.stack:top(),root);T.eq(calls,1) + T.eq(M.reset(),false);T.eq(calls,1) + local foreign=setmetatable({game={}},Native);g.stack:push(foreign) + T.eq(M.isActive(),false);T.eq(M.dismiss(),false);T.eq(g.stack:top(),foreign) +end +package.loaded['src.core.Game']=original +T.finish('native map preview admission, covered dismissal and once-only continuation') diff --git a/tests/engine/native_mod_seams.lua b/tests/engine/native_mod_seams.lua new file mode 100644 index 000000000..6d3794059 --- /dev/null +++ b/tests/engine/native_mod_seams.lua @@ -0,0 +1,234 @@ +-- Native producers, observed through a sandboxed mod loaded by the real SDK. +-- Do not emit these events from the test: a missing call site must fail here. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") +local GameVersion = require("src.core.GameVersion") +local Overworld = require("src.world.OverworldController") +local BirchSpeech = require("src.ui.BirchSpeech") +local Gen4Battle = require("src.battle.Gen4Battle") +local Pokemon = require("src.pokemon.Pokemon") + +local savedVersion = GameVersion.get() +local savedGame = {} +for _, key in ipairs({ "data", "save", "stack", "input", "overworld", "world", "renderer" }) do + savedGame[key] = { Game[key] } +end +GameVersion.set("red") +local data = T.fixtures.fresh() +local run = T.sdk.loadMods( + { "mods/seam_probe" }, + { + data = data, + fs = T.sdk.memfs({ + ["mods/seam_probe/manifest.json"] = [[{ + "id":"seam_probe", "name":"Native seam probe", "version":"1.0.0", + "api":2, "entry":"main.lua", "affects_link":false + }]], + ["mods/seam_probe/main.lua"] = [[ + local mod = ... + local seen = {} + mod.exports.seen = seen + local names = { + "intro.birch_speech.started", "intro.birch_speech.finished", + "pokemon.egg_hatched", "world.blocks_replaced", "world.tick", + } + for _, name in ipairs(names) do + seen[name] = {} + mod.events:on(name, function(payload) + local events = seen[name] + events[#events + 1] = payload + end) + end + mod.exports.hideHUD = true + mod.exports.hudCalls = 0 + mod.hooks:wrap("battle.status_hud_visible", function(nextFn, battle) + mod.exports.hudCalls = mod.exports.hudCalls + 1 + mod.exports.battle = battle + if mod.exports.hideHUD then return false end + return nextFn(battle) + end) + ]], + }), + } +) +T.eq(#run.errors, 0, "the public-API probe loads without errors") +local exports = assert(run.loader.exports.seam_probe) +local seen = exports.seen +local stack = { states = {} } +function stack:push(state) + self.states[#self.states + 1] = state +end +function stack:pop() + return table.remove(self.states) +end +function stack:top() + return self.states[#self.states] +end +Game.data, Game.stack = data, stack +Game.save = { player = { map = "FIX_TOWN", name = "TEST", id = 123 }, options = {} } +Game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, +} +Game.renderer = { + worldViewSize = function() + return 160, 144 + end, +} + +-- enter() establishes the native module's Game reference and real runner. +-- Map loading is the ROM/graphics boundary; provide a tiny idle map there. +local map = { + id = "FIX_TOWN", + def = { width = 2, height = 2 }, + blockPatch = {}, + renderer = { + rebuilds = 0, + rebuild = function(self) + self.rebuilds = self.rebuilds + 1 + end, + }, + warpAtCell = function() + return nil + end, +} +function map:blockAt(bx, by) + return self.blockPatch[by * 2 + bx + 1] or 0 +end +local world = setmetatable({ + setMap = function(self) + self.map = map + self.player = { + cellX = 0, + cellY = 0, + px = 0, + py = 0, + update = function() + return false + end, + } + self.npcs, self.ghosts, self.entities, self.cast = {}, {}, {}, {} + end, +}, { __index = Overworld }) +world:enter("FIX_TOWN", 0, 0, "down") + +-- Birch start and finish reach subscribers at their real lifecycle boundary. +GameVersion.set("emerald") +data.constants.gen3Intro = { lines = { "WELCOME", "WORLD", "PLAYER", "GENDER" } } +data.text.WELCOME, data.text.GENDER = "Welcome!", "Boy or girl?" +local done = false +local speech = BirchSpeech.new(Game, function() + done = true +end) +stack:push(speech) +speech:enter() +T.eq(#seen["intro.birch_speech.started"], 1, "enter publishes one Birch start") +T.eq(seen["intro.birch_speech.started"][1].speech, speech, "start identifies the live speech") +T.check(stack:top() ~= speech, "start advances into the first real text box") +stack:pop() +speech.answers = { name = "TEST", gender = "girl" } +speech:finish() +T.eq(#seen["intro.birch_speech.finished"], 1, "finish publishes one Birch completion") +local finished = seen["intro.birch_speech.finished"][1] +T.eq(finished.speech, speech, "finish identifies the same speech") +T.eq(finished.answers, speech.answers, "finish carries the recorded answers") +T.check(done and #stack.states == 0, "finish then closes the speech and calls its continuation") + +-- Batch block changes publish only actual writes, clipped to map dimensions. +GameVersion.set("red") +T.eq(world:replaceBlocks({ 1, 0, 2, 0, 99 }), true, "native batch changes map blocks") +T.eq(#seen["world.blocks_replaced"], 1, "batch emits one event") +T.eq(seen["world.blocks_replaced"][1].mapId, "FIX_TOWN", "batch reports the map") +T.eq(seen["world.blocks_replaced"][1].count, 2, "batch counts changed cells only") +T.eq(map.blockPatch[1], 1, "first block is written") +T.eq(map.blockPatch[3], 2, "second changed block is written") +T.eq(map.blockPatch[5], nil, "extra input cannot extend the map") +T.eq(map.renderer.rebuilds, 1, "batch rebuilds once") +T.eq(world:replaceBlocks({ 1, 0, 2, 0 }), false, "identical batch does nothing") +T.eq(world:replaceBlocks(nil), false, "missing batch does nothing") +T.eq(#seen["world.blocks_replaced"], 1, "no-op batches publish no event") + +-- A headless logic update emits even though draw() was never called. +world:update(1 / 60) +T.eq(#seen["world.tick"], 1, "native update publishes one simulation tick") +local tick = seen["world.tick"][1] +T.eq(tick.dt, 1 / 60, "tick carries the simulation dt") +T.eq(tick.mapId, "FIX_TOWN", "tick carries the active map") +T.eq(tick.overworld, world, "tick identifies the native world") +T.eq(tick.scripted, false, "an idle runner is reported as idle") +world.runner:run({ { "wait", 5 } }) +T.eq(world.runner:isRunning(), true, "the native wait command holds a script open") +world:update(1 / 30) +T.eq(#seen["world.tick"], 2, "scripted updates still publish exactly one tick") +T.eq(seen["world.tick"][2].scripted, true, "tick reports the real running script") +T.eq(seen["world.tick"][2].dt, 1 / 30, "tick does not hardcode the frame interval") + +-- Hatching is announced after the native dialogue callback, with a healed +-- ordinary Pokemon rather than an egg. Use Emerald to avoid Johto's second +-- nickname prompt; the native hatch method itself remains under test. +GameVersion.set("emerald") +local egg = Pokemon.new(data, "FIXMON_A", 5, function(a) + return a +end) +egg.isEgg, egg.eggSteps, egg.eggCycles = true, 0, 0 +egg.hp, egg.status = 1, "PSN" +T.eq(world:hatchEgg(egg), true, "native hatch accepts the egg") +T.eq(#seen["pokemon.egg_hatched"], 0, "hatch is not announced before dialogue finishes") +local box = stack:pop() +T.check(box ~= nil, "hatch opens its native text box") +box.onDone() +T.eq(#seen["pokemon.egg_hatched"], 1, "dialogue completion publishes the hatch") +T.eq(seen["pokemon.egg_hatched"][1].mon, egg, "hatch identifies the live Pokemon") +T.eq(egg.isEgg, nil, "the announced Pokemon is no longer an egg") +T.eq(egg.eggSteps, nil, "hatch clears egg step bookkeeping") +T.eq(egg.eggCycles, nil, "hatch clears egg cycle bookkeeping") +T.eq(egg.hp, egg.stats.hp, "hatch heals HP before publication") +T.eq(egg.status, nil, "hatch cures status before publication") +T.eq(egg.happiness, 120, "hatch applies base happiness before publication") +T.eq(world:hatchEgg(nil), false, "missing egg is rejected") +T.eq(#seen["pokemon.egg_hatched"], 1, "rejected hatch does not publish") + +-- A public wrapper can suppress native Sinnoh/Unova healthboxes before any +-- battler/graphics access. Returning true prevents fallback Game Boy panels. +GameVersion.set("platinum") +local battle = {} +T.eq(Gen4Battle.drawHealthboxes(battle), true, "hidden native HUD is handled") +T.eq(exports.hudCalls, 1, "native healthbox draw consults the public hook") +T.eq(exports.battle, battle, "the hook receives the live battle") +exports.hideHUD = false +T.eq( + Gen4Battle.drawHealthboxes(battle), + false, + "delegating preserves the native fallback when healthbox art is absent" +) +T.eq(exports.hudCalls, 2, "delegation still goes through the public wrapper") +exports.hideHUD = true +GameVersion.set("black") +T.eq(Gen4Battle.drawHealthboxes(battle), true, "the shared native path also hides Unova boxes") +T.eq(exports.hudCalls, 3, "Unova consults the same public wrapper") + +T.eq(#run.errors, 0, "native callbacks produce no attributed mod errors") +run.loader.events:removeOwner("seam_probe") +run.loader.hooks:removeOwner("seam_probe") +for _, name in ipairs({ + "intro.birch_speech.started", + "intro.birch_speech.finished", + "pokemon.egg_hatched", + "world.blocks_replaced", + "world.tick", +}) do + T.eq(Runtime.wants(name), false, "unloading removes subscription: " .. name) +end +T.eq(Runtime.wantsHook("battle.status_hud_visible"), false, "unloading removes the HUD hook") +run.release() +GameVersion.set(savedVersion) +for key, value in pairs(savedGame) do + Game[key] = value[1] +end +T.finish("native public mod seams") diff --git a/tests/engine/native_mon_animation_source.lua b/tests/engine/native_mon_animation_source.lua new file mode 100644 index 000000000..787eca455 --- /dev/null +++ b/tests/engine/native_mon_animation_source.lua @@ -0,0 +1,47 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Source=require('src.core.game3.mon_anim') +local Native=require('src.pokemon.MonAnim') +local Pictures=require('src.pokemon.PicAnim') +local Host=require('src.mods.Runtime') +local original=package.loaded['src.core.Game'] +T.eq(Source.enabled(),false) +T.eq(Source.newSprite(4),nil) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game;package.loaded['src.core.Game']=game + game.data.constants=game.data.constants or{} + game.data.constants.speciesOrder={[4]='CHARMANDER'} + game.data.constants.gen3MonAnim={anims={CHARMANDER={anim='H_SHAKE',delay=3}}} + local rec={sheet='unused-synthetic-strip.png',width=64,height=64,count=2, + play={{frame=1,dur=2},{frame=2,dur=3}},idle={{frame=0,dur=1}}} + game.data.pokemon.CHARMANDER.picAnim=rec + T.eq(Source.enabled(),true) + Host.currentMod='motion_owner';local sprite=assert(Source.newSprite(4));Host.currentMod=nil + T.eq(Source.summary(sprite,4,false),sprite) + local motion=assert(Native.new(game.data,'CHARMANDER'));motion:restart() + local frames=assert(Pictures.new(game.data,'CHARMANDER'));frames:start() + for tick=0,70 do + local native=motion:transform();local source=Source.transform(sprite) + T.eq(source.x2,native.dx);T.eq(source.y2,native.dy) + T.eq(source.sx,native.sx);T.eq(source.sy,native.sy);T.eq(source.rotation,native.rot) + T.eq(source.frame,frames:frame());T.eq(sprite.frames,tick) + Source.step(sprite);motion:update(1/60);frames:update(1/60) + end + T.eq(Source.done(sprite),true) + T.eq(Source.framePic(4,1,true),nil,'a missing shiny strip cannot flash the normal palette') + T.eq(Source.summary(sprite,4,true),sprite) + require('src.core.game3.asset_stream').cancelOwner('other_owner');T.eq(Source.step(sprite),true) + require('src.core.game3.asset_stream').cancelOwner('motion_owner');T.eq(Source.step(sprite),false) + T.eq(Source.done(sprite),true) + local swapped=assert(Source.newSprite(4));Source.summary(swapped,4) + require('src.core.GameVersion').set(edition=='firered'and'emerald'or'firered') + T.eq(Source.step(swapped),false,'edition replacement retires the presentation lease') + require('src.core.GameVersion').set(edition) + local retired=assert(Source.newSprite(4));Source.summary(retired,4) + game.data={pokemon={}};T.eq(Source.step(retired),false,'dataset replacement retires the presentation lease') + f.close() +end +Source._service.release();package.loaded['src.core.Game']=original +T.finish('native motion and frame-player presentation adapter; native timing only') diff --git a/tests/engine/native_object_lifecycle.lua b/tests/engine/native_object_lifecycle.lua new file mode 100644 index 000000000..74ca7172f --- /dev/null +++ b/tests/engine/native_object_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/object-runtime/lifecycle-check.lua') diff --git a/tests/engine/native_object_sheets.lua b/tests/engine/native_object_sheets.lua new file mode 100644 index 000000000..c54f794cb --- /dev/null +++ b/tests/engine/native_object_sheets.lua @@ -0,0 +1 @@ +dofile('tools/object-sheets/check.lua') diff --git a/tests/engine/native_object_views.lua b/tests/engine/native_object_views.lua new file mode 100644 index 000000000..ad890913b --- /dev/null +++ b/tests/engine/native_object_views.lua @@ -0,0 +1 @@ +dofile('tools/object-runtime/check.lua') diff --git a/tests/engine/native_pack_producers.lua b/tests/engine/native_pack_producers.lua new file mode 100644 index 000000000..9f3e2e4ec --- /dev/null +++ b/tests/engine/native_pack_producers.lua @@ -0,0 +1,150 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local F=require('tests.pack_sources.fixture') +local Lz=require('src.import.snes.Lz2') +local G=require('src.import.snes.Graphics') +local Producer=require('src.import.PackProducer') +local I=require('src.import.Importers') +local Packs=require('src.mods.AssetPacks') +local MD5=require('src.mods.StreamMD5') +local LT=require('src.import.lttp.LttpImport') +local b=string.char + +for _,v in ipairs({{b(2)..'abc'..b(255),'abc'}, {b(0x22,7,255),b(7,7,7)}, + {b(0x44,1,2,255),b(1,2,1,2,1)},{b(0x63,254,255),b(254,255,0,1)}, + {b(1)..'ab'..b(0x83,0,0,255),'ababab'}, + {b(0xe1,0)..string.rep('A',256)..'B'..b(0x80,0,1,255),string.rep('A',256)..'BB'}}) do + local out,consumed=Lz.decompress(v[1],0) + T.eq(out,v[2],'command stream matches explicit byte vector') + T.eq(consumed,#v[1],'decoder reports exact consumed source length') +end +for _,head in ipairs({0x80,0xa0,0xc0}) do + T.eq(Lz.decompress(b(0)..'Z'..b(head,0,0,255),0),'ZZ','copy opcode aliases preserve output addressing') +end +T.eq(Lz.decompress(b(0)..'Z'..b(0xfc,0,0,0,255),0),'ZZ','extended copy opcode is decoded') +for _,bad in ipairs({b(0xe0),b(4)..'x',b(0x40,1),b(0x80,0),b(0x80,0,0,255),b(0x20,1)}) do + T.eq(Lz.decompress(bad,0),nil,'truncated or invalid stream is rejected') +end +T.eq(Lz.decompress(b(0xe7,255,1,255),0,10),nil,'expansion budget is checked before output growth') +T.eq(Lz.decompress(b(255),0,math.huge),nil,'caller cannot remove expansion ceiling') +T.eq(Lz.decompress(b(255),-1),nil,'negative source offset is rejected') +T.eq(#assert(Lz.decompress(string.rep(b(0xe7,255,42),64)..b(255),0)),65536,'maximum allowed expansion succeeds') +T.eq(Lz.decompress(string.rep(b(0xe7,255,42),65)..b(255),0),nil,'expansion beyond maximum is rejected') +T.eq(G.loRomOffset(0x92,0xa0,0),0x92000,'LoROM mirrors high banks') +T.eq(G.loRomOffset(0x12,0x20,0),nil,'LoROM lower bank addresses are not cartridge data') +for _,bits in ipairs({2,3,4}) do + local bytes={};for i=1,bits*8 do bytes[i]=0 end + for plane=0,bits-1 do + local at=bits==3 and plane==2 and 17 or math.floor(plane/2)*16+plane%2+1 + bytes[at]=128 + end + local unpack=table.unpack or unpack + local tiles=assert(G.decodeTiles(b(unpack(bytes)),bits)) + T.eq(tiles[1][1],2^bits-1,'all bit planes contribute to the top-left pixel') + T.eq(tiles[1][2],0,'adjacent clear pixel stays transparent') + T.eq(tiles[1][9],0,'row stride remains independent of bit depth') +end +T.eq(G.decodeTiles('x',3),nil,'incomplete tile cannot be decoded') +T.eq(G.decodeTiles(string.rep('\0',32),8),nil,'unsupported bit depth cannot be guessed') +T.same(G.readPalette(b(31,0,0,124),0,2),{{255,0,0},{0,0,255}},'SNES palette primaries are correctly expanded') +T.eq(G.readPalette('x',0,1),nil,'truncated palette is rejected') +local synthetic=F.cartridge() +local digest=MD5.new():update(synthetic):final() +T.eq(LT.identify(synthetic),nil,'generated format bytes are not accepted as an original cartridge') +LT.SOURCES[digest]={name='Generated format fixture, not a cartridge',size=#synthetic} +local source,normalized=LT.identify(string.rep('H',512)..synthetic) +T.eq(normalized,synthetic,'copier header is removed without changing normalized bytes') +T.eq(source.md5,digest,'source hash identifies normalized bytes') +T.eq(LT.sheetOffset(synthetic,0),0x92000,'compressed sheet uses native source pointer') +T.eq(LT.sheetOffset(synthetic,115),0x93000,'raw sprite sheet uses its own source pointer') +T.eq(LT.sheetOffset(synthetic,223),nil,'out-of-table sheet is rejected') +local sheet,bits=LT.readSheet(synthetic,0) +T.eq(bits,3,'compressed graphics retain their three bit planes') +T.eq(#sheet,64,'sheet geometry derives from decoded data length') +T.eq(sheet[1][1],1,'compressed sheet decoded pixel matches format vector') +T.eq(assert(LT.readSheet(synthetic,115))[1][1],1,'raw sprite sheet bypasses decompression') +local player=assert(LT.readPlayer(synthetic)) +T.eq(#player,896,'player has its separate native tile count') +T.eq(player[1][1],8,'player uses its fourth bit plane') +local palettes=assert(LT.readPalettes(synthetic)) +T.same(palettes.armor[1][8],{0,0,255},'player selects the armor palette row') +local raster=G.sheet(player,palettes.armor[1]) +T.same({raster.width,raster.height},{128,448},'player sheet size is computed from native tile units') +T.eq(raster.rgba:sub(1,4),b(0,0,255,255),'player raster preserves the fourth-plane color') +T.eq(raster.rgba:sub(5,8),b(0,0,0,0),'palette index zero remains transparent') +local world=require('src.import.lttp.Overworld').raster(synthetic,LT.readSheet) +local function pixel(x,y)return world.rgba:sub((y*world.width+x)*4+1,(y*world.width+x+1)*4)end +T.same({world.width,world.height},{1024,1024},'overworld atlas contains 4096 sixteen-pixel metatiles') +for _,xy in ipairs({{0,0},{15,0},{0,15},{15,15}}) do + T.eq(pixel(xy[1],xy[2]),b(255,0,0,255),'metatile quadrant flip preserves the correct palette pixel') +end +T.eq(pixel(1,0),b(74,156,74,255),'transparent metatile pixels reveal the native background color') +LT.SOURCES[digest]=nil + +local fs,files=F.memory() +local provenance={name='Generated test source',md5=string.rep('0',32)} +local producer=assert(Producer.new('lttp','sprites','1.0.0',provenance,fs)) +local entry=assert(producer:put('hero','PNG!',{width=16,height=24,sprite={anchorY=24}},{frames={1,2}})) +T.eq(I.readPack('lttp','sprites',fs),nil,'native extraction stays unpublished until complete') +T.check(producer:alias('hero_female','hero')~=nil,'native provider can reuse identical gender artwork') +entry.sprite.anchorY=1 +T.eq(assert(producer:finish()).entries,2,'finished native extraction publishes its full entry set') +local api=Packs.new({required_assets={{importer='lttp',pack='sprites'}}},fs) +T.eq(api:read('lttp','sprites','hero'),'PNG!','native provider output feeds the real pack consumer') +T.eq(api:entry('lttp','sprites','hero').sprite.anchorY,24,'producer views do not mutate committed anchors') +T.same(api:metadata('lttp','sprites','hero').frames,{1,2},'provider animation metadata is literal data') +local path=I.manifestPath('lttp','sprites');local prior=files[path] +for _,fault in ipairs({'failWrite','shortWrite','failRename','throwRename','failDirectory'}) do + producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs));fs[fault]=true + T.eq(producer:put('hero','NEW!',{}),nil,'native provider propagates '..fault) + fs[fault]=false + T.eq(producer:finish(),nil,'failed native provider cannot publish a partial pack') + T.eq(files[path],prior,'native publication failure preserves the previous descriptor') + T.eq(api:read('lttp','sprites','hero'),'PNG!','native publication failure preserves previous assets') +end +producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs)) +assert(producer:put('hero','OTHER',{}));producer:cancel() +T.eq(producer:finish(),nil,'cancelled extraction cannot publish') +T.eq(files[path],prior,'cancelled extraction leaves prior descriptor untouched') +producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs)) +entry=assert(producer:put('hero','OTHER',{}));fs.remove(I.assetPath('lttp','sprites',entry.file)) +T.eq(producer:finish(),nil,'missing object prevents native publication') +T.eq(files[path],prior,'missing-object failure preserves prior descriptor') +producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs)) +T.eq(producer:put(nil,'X',{}),nil,'invalid entry identity cannot reach storage') +T.eq(producer:finish(),nil,'invalid native entry does not publish') +T.eq(Producer.new('unknown','sprites','1.0.0',provenance,fs),nil,'unknown provider identity is rejected') +producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs)) +T.eq(producer:put('hero',string.rep('x',8*1024*1024+1),{}),nil,'native extraction cannot exceed consumer read limit') +T.check(fs.maxRead<=8*1024*1024,'all physical native-provider reads stay bounded') +local pending=0;for name in pairs(files) do if name:find('.pending-',1,true) then pending=pending+1 end end +T.eq(pending,0,'native failures leave no staging files') +local publishName='src.import.PackPublish' +local originalPublisher=package.loaded[publishName] +math.randomseed(12345);local expectedRandom=math.random();math.randomseed(12345) +package.loaded[publishName]=nil;local firstPublisher=require(publishName) +T.eq(math.random(),expectedRandom,'publisher initialization does not consume the gameplay random stream') +package.loaded[publishName]=nil;local secondPublisher=require(publishName) +package.loaded[publishName]=originalPublisher +local names={};local concurrentFs=F.memory();local write=concurrentFs.write +concurrentFs.write=function(p,bytes)names[#names+1]=p;return write(p,bytes)end +T.check(firstPublisher(concurrentFs,'packs/pack.lua','FIRST'),'first independent publisher works') +T.check(secondPublisher(concurrentFs,'packs/pack.lua','SECOND'),'second independent publisher works') +T.neq(names[1],names[2],'independent Lua states do not share a numbered staging filename') +local scope='first-folder';fs.getWriteRoot=function()return scope end +producer=assert(Producer.new('lttp','sprites','2.0.0',provenance,fs)) +assert(producer:put('hero','ROOT!',{}));scope='second-folder' +T.eq(producer:finish(),nil,'folder change prevents a native descriptor spanning two roots') +T.eq(files[path],prior,'folder change leaves previous native pack descriptor intact') +scope='first-folder' +local inboxRoot='imports/test';local W=require('src.import.LuaWriter') +fs.write(inboxRoot..'/pack.lua',W.encode({format=1,importer='lttp',pack='sprites',kind='sprite',version='2.0.0', + source=provenance,entries={hero={file='a.png',size=1},other={file='b.png',size=1}}})) +fs.write(inboxRoot..'/a.png','A');fs.write(inboxRoot..'/b.png','B') +local importing=I.install('lttp','sprites',fs,inboxRoot,fs) +T.check(coroutine.resume(importing),'portable inbox can yield after an entry') +scope='second-folder' +T.check(not coroutine.resume(importing),'folder change stops a portable inbox operation too') +T.eq(files[path],prior,'portable folder change preserves previous descriptor') +T.finish('Native pack producers and SNES graphics') diff --git a/tests/engine/native_player_runtime.lua b/tests/engine/native_player_runtime.lua new file mode 100644 index 000000000..69e36203f --- /dev/null +++ b/tests/engine/native_player_runtime.lua @@ -0,0 +1 @@ +dofile('tools/player-runtime/check.lua') diff --git a/tests/engine/native_pokemon_source.lua b/tests/engine/native_pokemon_source.lua new file mode 100644 index 000000000..66fc3b836 --- /dev/null +++ b/tests/engine/native_pokemon_source.lua @@ -0,0 +1,55 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local P=require('src.core.game3.pokemon') +local original=package.loaded['src.core.Game'] +local Host=require('src.mods.Runtime') +T.eq(P.ready(),false) +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + local f=require('tools.battle-source-runtime.fixture').new(edition) + local game=f.game;package.loaded['src.core.Game']=game + local data=game.data + data.constants=data.constants or{} + data.constants.speciesOrder={[4]='CHARMANDER',[277]='SPARSE_SPECIES'} + data.pokemon.SPARSE_SPECIES={index=277,dex=252,name='SPARSE',id='SPARSE_SPECIES'} + data.constants.typeOrder={'NORMAL'};data.constants.types={NORMAL={index=0}} + data.constants.abilityOrder={'NONE','PROBE'};data.constants.abilities={NONE={index=0},PROBE={index=1}} + data.constants.moveOrder={[33]=f.ids.TACKLE} + local row=data.pokemon.CHARMANDER + row.height,row.weight,row.genderRatio,row.abilities=.7,6.9,127,{'PROBE','PROBE'} + row.level1Moves={f.ids.TACKLE};row.learnset={{level=7,move=f.ids.TACKLE}} + T.eq(P.ready(),true);T.eq(P.speciesOf{species='CHARMANDER'},4) + T.eq(P.speciesOf{species=277},277);T.eq(P.national(277),252) + T.eq(P.speciesFromNational(252),277,'cartridge slots and National Dex are distinct') + T.eq(P.speciesFromName('CHARMANDER'),4);T.eq(P.keyName(4),'CHARMANDER') + T.eq(P.name(4),'CHARMANDER');T.eq(P.name(8888),nil) + T.eq(P.types(4)[1],0);T.eq(P.types(4)[2],0) + T.eq(P.stats(4).atk,row.baseStats.attack) + local dex=P.dexEntry(4);T.eq(dex.height,7,'metres become source decimetres');T.eq(dex.weight,69) + local meta=P.speciesMeta(4);meta.genderRatio=0;T.eq(row.genderRatio,127,'observations do not lend native records') + T.eq(P.gender(4,0),'F');T.eq(P.gender(4,255),'M') + T.eq(P.abilities(4)[1],1);T.eq(P.abilityId(4,2),1) + T.eq(P.isShiny{species=4,personality=0,otId=0,otSecretId=0},true) + T.eq(P.isShiny(setmetatable({},{__index={species=4,personality=0,otId=0,otSecretId=0}})),true,'typed mon views use their declared identity fields') + T.eq(P.isShiny{species=4,personality=12,otId=0,otSecretId=0},false) + T.eq(P.isEgg{isEgg=true},true);T.eq(P.isEgg{species=4},false) + T.eq(P.displayName{species=4,nickname='NAMED'},'NAMED') + T.eq(P.displayName{species=4},'CHARMANDER') + local moves,pp,max=P.movesAtLevel(4,10) + T.eq(moves[1],33);T.eq(pp[1],10);T.eq(max[1],10) + T.eq(P.moveName(33),'TACKLE');T.eq(P.movePp(33),10) + T.eq(P.movesLearnedAt(4,7)[1],33) + T.eq(P.moveIdAt({moves={33}},1),33);T.eq(P.moveIdAt({moves={{id=f.ids.TACKLE}}},1),33) + T.eq(P.knowsMove({moves={33}},33),true);T.eq(P.knowsMove({moves={33}},34),false) + Host.currentMod='sprite_owner' + local image=assert(P.frontPic(4)) + T.eq(P.frontPic(4),image,'native source picture is cached within its dataset') + T.check(image.image~=nil and image.w>0 and image.h>0) + T.eq(P.frontPic(4,2),nil,'unsupported forms cannot silently show the base sprite') + require('src.core.game3.asset_stream').cancelOwner('other_owner') + T.eq(P.frontPic(4),image) + P.invalidate();T.neq(P.frontPic(4),image,'owned picture invalidation never reuses its released image') + Host.currentMod=nil;P.invalidate();f.close() +end +package.loaded['src.core.Game']=original +T.finish('native source Pokemon identity, metadata units and picture ownership') diff --git a/tests/engine/native_script_session.lua b/tests/engine/native_script_session.lua new file mode 100644 index 000000000..4476e608e --- /dev/null +++ b/tests/engine/native_script_session.lua @@ -0,0 +1 @@ +dofile('tools/script-stores/session-check.lua') diff --git a/tests/engine/native_script_space.lua b/tests/engine/native_script_space.lua new file mode 100644 index 000000000..3d3ff9caf --- /dev/null +++ b/tests/engine/native_script_space.lua @@ -0,0 +1 @@ +dofile('tools/script-space/check.lua') diff --git a/tests/engine/native_script_store.lua b/tests/engine/native_script_store.lua new file mode 100644 index 000000000..9db13d505 --- /dev/null +++ b/tests/engine/native_script_store.lua @@ -0,0 +1 @@ +dofile('tools/script-stores/check.lua') diff --git a/tests/engine/native_session_callbacks.lua b/tests/engine/native_session_callbacks.lua new file mode 100644 index 000000000..3755dd50b --- /dev/null +++ b/tests/engine/native_session_callbacks.lua @@ -0,0 +1 @@ +dofile('tools/session-runtime/callback-check.lua') diff --git a/tests/engine/native_session_dispatch.lua b/tests/engine/native_session_dispatch.lua new file mode 100644 index 000000000..f6bd3deb4 --- /dev/null +++ b/tests/engine/native_session_dispatch.lua @@ -0,0 +1 @@ +dofile('tools/session-runtime/dispatch-check.lua') diff --git a/tests/engine/native_session_runtime.lua b/tests/engine/native_session_runtime.lua new file mode 100644 index 000000000..7b2a23926 --- /dev/null +++ b/tests/engine/native_session_runtime.lua @@ -0,0 +1 @@ +dofile('tools/session-runtime/check.lua') diff --git a/tests/engine/native_source_warm.lua b/tests/engine/native_source_warm.lua new file mode 100644 index 000000000..8c9647d79 --- /dev/null +++ b/tests/engine/native_source_warm.lua @@ -0,0 +1 @@ +dofile('tools/native-atlas/warm-check.lua') diff --git a/tests/engine/native_sprite_cache.lua b/tests/engine/native_sprite_cache.lua new file mode 100644 index 000000000..da6ae1878 --- /dev/null +++ b/tests/engine/native_sprite_cache.lua @@ -0,0 +1 @@ +dofile('tools/sprite-runtime/cache-check.lua') diff --git a/tests/engine/native_sprite_lifecycle.lua b/tests/engine/native_sprite_lifecycle.lua new file mode 100644 index 000000000..0164d6627 --- /dev/null +++ b/tests/engine/native_sprite_lifecycle.lua @@ -0,0 +1 @@ +dofile('tools/sprite-runtime/lifecycle-check.lua') diff --git a/tests/engine/native_sprite_resources.lua b/tests/engine/native_sprite_resources.lua new file mode 100644 index 000000000..55a5f0561 --- /dev/null +++ b/tests/engine/native_sprite_resources.lua @@ -0,0 +1 @@ +dofile('tools/sprite-runtime/resources-check.lua') diff --git a/tests/engine/native_start_menu_source.lua b/tests/engine/native_start_menu_source.lua new file mode 100644 index 000000000..f4c87df6a --- /dev/null +++ b/tests/engine/native_start_menu_source.lua @@ -0,0 +1,34 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Menu=require('src.ui.game3.start_menu') +local Host=require('src.mods.Runtime') +local Stream=require('src.core.game3.asset_stream') +local G=require('src.core.Game3') +local nativeUpdate,nativeDraw=Menu.update,Menu.draw +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Stream.shutdown();G.release() + local f=require('tools.session-runtime.fixture').new(edition);local game=f.game + game.mods.mods={menu_owner={enabled=true},peer={enabled=true}} + Host.currentMod='menu_owner' + T.eq(Menu.isOpen(),false);T.eq(Menu.show({index=-1}),false) + local menu=assert(Menu.show());T.eq(getmetatable(menu),require('src.ui.Gen3StartMenu')) + T.eq(Menu.isOpen(),true);T.eq(Menu.open,true);T.eq(Menu.cursor,menu.index) + T.eq(Menu.show(),false,'duplicate native menu refused') + local before=menu.index;f.input.edges.down=true + Menu.update=function()nativeUpdate();nativeUpdate()end + game.stack:update(1/60) + T.eq(menu.index,before%#menu.rows+1,'wrapped update consumes native input once') + f.input.edges={};local draws=0 + Menu.draw=function()nativeDraw();nativeDraw()end + Menu._draw(menu,function()draws=draws+1 end);T.eq(draws,1) + nativeDraw();nativeUpdate();T.eq(draws,1,'out-of-phase calls cannot rerun a menu') + Host.currentMod='peer';T.eq(Menu.close(),false);T.eq(game.stack:top(),menu) + Stream.cancelOwner('peer');T.eq(game.stack:top(),menu,'foreign cleanup preserves the admitted menu') + Host.currentMod='menu_owner';T.eq(Menu.close(),true);T.eq(Menu.isOpen(),false);T.eq(game.stack:top(),f.world) + local second=assert(Menu.show());local flow=second.nativeUiFlow + game.stack:pop();Stream.poll();T.eq(flow.retired,true,'native close releases an exhausted menu flow') + T.eq(Menu.close(),false,'closing an absent menu cannot pop the world') + Menu.update,Menu.draw=nativeUpdate,nativeDraw;Stream.shutdown();G.release();Host.currentMod=nil +end +T.finish('native START menu ownership, input/draw phases and exact close') diff --git a/tests/engine/native_state_selection.lua b/tests/engine/native_state_selection.lua new file mode 100644 index 000000000..f9b1d8639 --- /dev/null +++ b/tests/engine/native_state_selection.lua @@ -0,0 +1,16 @@ +-- Removing exact owners under a screen must preserve screens opened on exit. +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness');local Stack=require('src.core.StateStack') +local world,cover,replacement={name='world'},{name='cover'},{name='replacement'} +local stack=setmetatable({states={}},{__index=Stack});local exits={} +local a={name='a',exit=function()exits[#exits+1]='a'end} +local b={name='b',exit=function()exits[#exits+1]='b';stack:push(replacement)end} +stack.states={world,a,cover,b} +T.eq(stack:removeAll({a,b,a}),2);T.eq(#stack.states,3);T.eq(stack.states[1],world);T.eq(stack.states[2],cover);T.eq(stack:top(),replacement) +T.eq(table.concat(exits,','),'b,a');T.eq(stack:removeAll({a,b}),0);T.eq(#exits,2) +-- A failing exit cannot leave the remaining selected states installed. +local bad={name='bad',exit=function()error('selected exit failure')end};local good={name='good',exit=function()exits[#exits+1]='good'end} +stack.states={world,good,cover,bad};T.raises(function()stack:removeAll({good,bad})end,'selected exit failure','propagate failure after retiring all selected owners') +T.eq(#stack.states,2);T.eq(stack:top(),cover);T.eq(exits[#exits],'good') +T.finish('native exact-state retirement preserves covering and replacement screens') diff --git a/tests/engine/native_transition_runtime.lua b/tests/engine/native_transition_runtime.lua new file mode 100644 index 000000000..9b304cf84 --- /dev/null +++ b/tests/engine/native_transition_runtime.lua @@ -0,0 +1 @@ +dofile('tools/transition-runtime/check.lua') diff --git a/tests/engine/native_unloaded_admission.lua b/tests/engine/native_unloaded_admission.lua new file mode 100644 index 000000000..0178e0df1 --- /dev/null +++ b/tests/engine/native_unloaded_admission.lua @@ -0,0 +1,22 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local G=require('src.core.Game3') +local Field=require('src.core.game3.field_move_show_mon') +local Transition=require('src.compat.gen1.TransitionRuntime').new() +local Party=require('src.ui.game3.party_menu') +local modules={'src.world.Gen3FieldMove','src.render.BattleTransition','src.ui.Gen3PartyMenu'} +local saved={};for _,name in ipairs(modules)do saved[name]=package.loaded[name];package.loaded[name]=nil end +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + G.release();local f=require('tools.session-runtime.fixture').new(edition);local g=f.game + local plain={game=g,isMenu=true,subIndex=4} + g.stack.states={f.world,plain} + T.eq(Field.isActive(),false,'an unloaded native class cannot match a plain mod screen') + T.eq(Field._fx,nil);T.eq(Field.draw(),false);T.eq(Field.reset(),false) + T.eq(Transition:current(),nil);T.eq(Transition:observed(),nil) + T.eq(Party.open,false);T.eq(Party._party,nil);T.eq(Party.ACTIONS,nil) + T.eq(g.stack:top(),plain);T.eq(plain.subIndex,4,'observation cannot adopt or mutate a foreign screen') +end +for _,name in ipairs(modules)do package.loaded[name]=saved[name]end +Transition:release();G.release() +T.finish('unloaded native classes never admit plain mod or world states') diff --git a/tests/engine/native_version_layout.lua b/tests/engine/native_version_layout.lua new file mode 100644 index 000000000..c2a5fcee9 --- /dev/null +++ b/tests/engine/native_version_layout.lua @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.harness') +local Version=require('src.core.GameVersion') +local layouts={firered='frlg',leafgreen='frlg',emerald='rse',ruby='rse',sapphire='rse'} +local original=Version.get() +for id,info in pairs(Version.VERSIONS)do + local prefix,suffix,manifest=info.cachePrefix,info.saveSuffix,info.manifest + T.eq(Version.layout(id),info.layout or layouts[id],id..' metadata layout') + T.eq(Version:layout(id),info.layout or layouts[id],id..' colon query') + Version.set(id);T.eq(Version.layout(),info.layout or layouts[id],id..' current query') + T.eq(Version.get(),id);T.eq(info.cachePrefix,prefix);T.eq(info.saveSuffix,suffix);T.eq(info.manifest,manifest) +end +Version.VERSIONS.QA_LAYOUT={id='QA_LAYOUT',generation=3,layout='custom'} +T.eq(Version.layout('QA_LAYOUT'),'custom','an explicit registered layout takes precedence') +Version.VERSIONS.QA_LAYOUT=nil +T.eq(Version.layout('UNKNOWN'),nil,'unknown IDs cannot inherit the current cartridge layout') +Version.set(original) +T.finish('native registered version layout queries') diff --git a/tests/engine/native_warp_source.lua b/tests/engine/native_warp_source.lua new file mode 100644 index 000000000..f18564f76 --- /dev/null +++ b/tests/engine/native_warp_source.lua @@ -0,0 +1,68 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Contract=require('tools.native-field.warp-fixture') +local Warp=require('src.core.game3.warp') +local Host=require('src.mods.Runtime') +local Stream=require('src.core.game3.asset_stream') +local Game3=require('src.core.Game3') +local saved=package.loaded['src.core.Game'] +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Game3.release();Stream.shutdown() + local f=Contract.new(edition) + package.loaded['src.core.Game']=f.game;f.game.mods=f.loader + Host.currentMod='warp' + T.eq(Warp.isBusy(),false) + for _,key in ipairs{'transitioning','flyAnim','teleportOut','whirlSpin','gen4Waterfall'}do + f.ow[key]=true;T.eq(Warp.isBusy(),true,key..' is a native warp owner');f.ow[key]=nil + end + f.ow.emote={};T.eq(Warp.isBusy(),false,'ordinary field activity is not an invented warp');f.ow.emote=nil + T.eq(Warp.request(nil,f.game,f.dest,-1,0),nil);T.eq(#f.trace,0,'bad destination is side-effect free') + local called=0;local seenOwner + T.eq(Warp.request(nil,f.game,f.dest,1,2,'up',{onDone=function(a,b,c) + called=called+1;seenOwner=Host.currentMod;T.eq(a,'native');T.eq(b,nil);T.eq(c,'tail') + end}),true) + T.eq(#f.trace,1);T.eq(f.trace[1].mapId,f.dest);T.eq(f.trace[1].x,1);T.eq(f.trace[1].y,2) + T.eq(Warp.isBusy(),true) + Host.currentMod='peer';T.eq(Warp.clear(),false);T.eq(called,0) + f:arrive();f:complete('native',nil,'tail');f:complete('again') + T.eq(called,1);T.eq(seenOwner,'warp');T.eq(Host.currentMod,'peer') + f.busy=false;f.ow.transitioning=false + Host.currentMod='warp' + T.eq(Warp.request(nil,f.game,f.dest,0,0,'down',{onDone=function()called=called+1 end}),true) + local finish=f.callback + collectgarbage('collect');Stream.cancelOwner('peer');Stream.cancelOwner('warp') + T.eq(f.ow.transitioning,true,'revocation leaves the admitted native transition alone') + f:arrive();finish();T.eq(called,1,'revoked continuation never fires') + f.busy=false;f.ow.transitioning=false + T.eq(Warp.request(nil,f.game,f.dest,0,0,'down',{onDone=function()called=called+1 end}),true) + finish=f.callback;f.game.save={};f:arrive();finish();T.eq(called,1,'save replacement retires callback') + Stream.shutdown();Game3.release();Host.currentMod=nil + f:close() + -- Observe the actual native transition, with map materialization isolated. + local n=Contract.new(edition) + package.loaded['src.core.Game']=n.game;n.game.mods=n.loader;Host.currentMod='warp' + local OW=require('src.world.OverworldController');local restore + for i=1,100 do + local key,previous=debug.getupvalue(OW.startWarpTo,i);if not key then break end + if key=='Game'then debug.setupvalue(OW.startWarpTo,i,n.game);restore=function()debug.setupvalue(OW.startWarpTo,i,previous)end;break end + end + assert(restore,'native warp Game upvalue') + setmetatable(n.game.stack,{__index=require('src.core.StateStack')}) + n.ow.startWarpTo=OW.startWarpTo + function n.ow:frlgBehaviours()return false end + function n.ow:noteGen2Spawn()end + function n.ow:setMap(id,x,y,facing)n.destination={id,x,y,facing};n:arrive()end + n.ow.arriveWarp='escalator' + T.eq(Warp.request(nil,n.game,n.dest,1,1),true) + local transition=n.game.stack:top();T.eq(getmetatable(transition),require('src.render.Transition')) + T.eq(n.ow.arriveWarp,nil);T.eq(n.ow.warpArrivalKind,'escalator') + T.eq(Warp.isEscalatorActive(),true,'native departure retains its admitted arrival kind') + for _=1,transition.frames+transition.framesIn+2 do + if n.game.stack:top()==transition then transition:update(1/60)end + end + T.eq(Warp.isEscalatorActive(),false);T.eq(Warp.isBusy(),false);T.eq(n.ow.warpArrivalKind,nil) + restore();Stream.shutdown();Game3.release();Host.currentMod=nil;n:close() +end +package.loaded['src.core.Game']=saved +T.finish('source warp requests use native admission and owner callback lifetime') diff --git a/tests/engine/native_weather_sound.lua b/tests/engine/native_weather_sound.lua new file mode 100644 index 000000000..ac5278692 --- /dev/null +++ b/tests/engine/native_weather_sound.lua @@ -0,0 +1,67 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Sound=require('src.core.Sound') +local Weather=require('src.world.Gen3WeatherAudio') +local Stream=require('src.core.game3.asset_stream') +local G=require('src.core.Game3') +local oldAudio=love.audio +local voices={} +love.audio={newSource=function(file) + local s={file=file,starts=0,stops=0} + function s:setVolume(v)self.volume=v end + function s:setLooping(v)self.loop=v end + function s:play()self.starts=self.starts+1;self.playing=true end + function s:stop()self.stops=self.stops+1;self.playing=false end + function s:isPlaying()return self.playing end + voices[#voices+1]=s;return s +end} +for _,edition in ipairs{'firered','leafgreen','emerald','ruby','sapphire'}do + Stream.shutdown();G.release();Sound.invalidate();voices={} + local f=require('tools.session-runtime.fixture').new(edition);local game,w=f.game,f.world + local data=T.fixtures.fresh();data.maps=game.data.maps + data.constants.gen3WeatherNames={[0]='NONE',[3]='RAIN',[5]='RAIN_THUNDERSTORM',[13]='DOWNPOUR'} + data.audio={songs={}} + for i,name in ipairs{'SE_RAIN','SE_THUNDERSTORM','SE_DOWNPOUR','SE_RAIN_STOP','SE_THUNDERSTORM_STOP','SE_DOWNPOUR_STOP'}do + data.audio.songs[i]={sfxName=name,index=i,file='synthetic-'..name..'.wav'} + end + local run=T.sdk.loadMods({'mods/weather_probe'},{data=data,fs=T.sdk.memfs({ + ['mods/weather_probe/manifest.json']=[[{"id":"weather_probe","name":"Weather probe","version":"1.0.0","api":2,"entry":"main.lua"}]], + ['mods/weather_probe/main.lua']=[[ + local mod=...;mod.exports.seen={};mod.exports.claimed=false + mod.hooks:wrap("field.weather.sound",function(nextFn,name) + local e=mod.exports;e.seen[#e.seen+1]=name + if e.claimed then return true end + return nextFn(name) + end) + ]], + })}) + T.eq(#run.errors,0,'native weather sound callback is accepted through API 2') + local e=run.loader.exports.weather_probe + game.mods=run.loader;game.data=run.data;game.save.gen3WeatherActive=3 + local before=require('src.link.Json').encode(game.save) + local random=math.random;math.random=function()error('weather audio cannot consume gameplay RNG')end + Weather.update(game,w) + T.eq(e.seen[1],'SE_RAIN');T.eq(#voices,1);local rain=voices[1];T.eq(rain.loop,true) + for _=1,50 do Weather.update(game,w)end + T.eq(#e.seen,1,'unchanged weather does not restart the native loop');T.eq(rain.starts,1) + T.eq(require('src.link.Json').encode(game.save),before,'sound observation never writes the save') + local foreign=Sound.playNamedEffect(game.data,'SE_RAIN');T.neq(foreign,rain,'weather has a distinct native cache voice') + game.save.gen3WeatherActive=5;Weather.update(game,w) + T.eq(rain.playing,false);T.eq(foreign.playing,true,'ambient change preserves a simultaneous scripted effect') + T.eq(e.seen[2],'SE_RAIN_STOP');T.eq(e.seen[3],'SE_THUNDERSTORM') + e.claimed=true;game.save.gen3WeatherActive=13;local count=#voices;Weather.update(game,w) + T.eq(#voices,count,'claim suppresses only native weather playback requests') + T.eq(e.seen[#e.seen],'SE_DOWNPOUR') + game.save.gen3WeatherActive=0;Weather.update(game,w);T.eq(e.seen[#e.seen],'SE_DOWNPOUR_STOP') + game.save.gen3WeatherActive=3;Weather.update(game,w);T.eq(rain.playing,false) + run.loader.hooks:removeOwner('weather_probe');Weather.update(game,w) + T.eq(rain.playing,true,'removing a claiming wrapper resumes native rain on the next field tick') + T.eq(foreign.playing,true,'removing a wrapper preserves foreign SE playback') + e.claimed=false;game.save.gen3WeatherActive=3;Weather.update(game,w) + local live=voices[1];T.eq(live.playing,true);game.save={};Stream.poll();T.eq(live.playing,false,'retired native save stops only its weather voice') + T.eq(foreign.playing,true) + math.random=random;Stream.shutdown();run.release();Sound.invalidate();G.release() +end +love.audio=oldAudio +T.finish('native weather sound requests, source suppression and independent voice lifetime') diff --git a/tests/engine/net_permission.lua b/tests/engine/net_permission.lua new file mode 100644 index 000000000..7cc94cf85 --- /dev/null +++ b/tests/engine/net_permission.lua @@ -0,0 +1,28 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Json=require('src.link.Json') +local Net=require('src.mods.Net') +local function load(permissions, entry) + local fs=T.sdk.memfs({ + ['mods/net_probe/manifest.json']=Json.encode({id='net_probe',name='Net probe',version='1.0.0',api=2,entry='main.lua',permissions=permissions}), + ['mods/net_probe/main.lua']=entry, + }) + return T.sdk.loadMod('mods/net_probe',{fs=fs}) +end +local a=load({'network'},[[local mod=...;assert(type(mod.fetch.get)=='function');assert(type(mod.fetch.poll)=='function');mod.exports.fetch=mod.fetch]]) +T.eq(#a.errors,0,'network mod loads real fetch facade') +T.eq(a.mod.state,'loaded','entry completed') +T.eq(type(a.loader.exports.net_probe.fetch.cancel),'function','cancellation exposed') +T.eq(type(a.loader.exports.net_probe.fetch.capabilities),'function','capability query exposed') +T.eq(a.loader.exports.net_probe.fetch:get('file:///private'),nil,'file URL refused') +a.release() +local b=load({},[[local mod=...;mod.exports.available=mod.fetch:available();mod.exports.get=pcall(mod.fetch.get,mod.fetch,'https://example.invalid')]]) +T.eq(#b.errors,0,'ungranted query does not fail entry') +T.eq(b.loader.exports.net_probe.available,false,'transport unavailable without grant') +T.eq(b.loader.exports.net_probe.get,false,'operation requires network grant') +b.release() +for _,bad in ipairs({'https://','https://example.invalid\nHeader: bad','ftp://host','https://host\0','http://a b'})do + T.eq(type(Net.urlDenial(bad)),'string','bad URL denied') +end +T.eq(Net.urlDenial('https://host/a%20b?x=%22&z=1'),nil,'encoded URL accepted') +T.finish('mod network permission') diff --git a/tests/engine/pack_sprite_adapter.lua b/tests/engine/pack_sprite_adapter.lua new file mode 100644 index 000000000..5353f2cb7 --- /dev/null +++ b/tests/engine/pack_sprite_adapter.lua @@ -0,0 +1,42 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Sprite=require('src.import.pmd.Sprite') +local meta={frameWidth=32,frameHeight=24,frameColumns=2,anchorX=16,anchorY=24, + animations={{1,1,1,1,1,1,1,1}},sequences={{{1,2,3,4,0,0,0},{4,3,0,0,0,0,0}}}} +local entry={file='owned.png',frames=4,sprite=meta} +local def=Sprite.definition(entry) +T.eq(def.image,'asset_packs/pmd_red/sprites/owned.png','definition selects the published pack path') +T.eq(def.frameWidth,32,'definition retains native frame dimensions') +T.eq(def.cellColumns,2,'definition retains the consumer cell layout') +T.eq(def.cells[4][1].tile,3,'definition exposes zero-based tile cells') +T.eq(Sprite.frame(meta,0,'down',1),meta.sequences[1][1],'duration preserves the first frame') +T.eq(Sprite.frame(meta,0,'down',2),meta.sequences[1][2],'duration advances on its exact boundary') +T.eq(Sprite.frame(meta,0,'down',5),meta.sequences[1][1],'duration wraps after the whole sequence') +local oldImage,oldDraw=love.graphics.newImage,love.graphics.draw +love.graphics.newImage=function(path) + if path=='asset_packs/pmd_red/sprites/owned.png'then + return {getDimensions=function()return 64,48 end,getWidth=function()return 64 end,getHeight=function()return 48 end} + end + return oldImage(path) +end +local draw +love.graphics.draw=function(...)draw={...}end -- graphics IO capture only +local sprite=Sprite.new(entry) +T.eq(sprite.frames[3].x,32,'native quads use the row-major column') +T.eq(sprite.frames[3].y,24,'native quads use the row-major row') +sprite:step(true);sprite:step(true) +local pose,flip=sprite:getPoseGeometry('down') +T.eq(pose.x,32,'pose reports the animated atlas column') +T.eq(pose.y,24,'pose reports the animated atlas row') +T.eq(pose.anchorX,16,'pose retains the native anchor') +T.eq(flip,false,'pack direction frames do not inherit a native facing flip') +sprite.anchorX=20;sprite:draw(64,64,0,0,'down') +T.check(draw~=nil,'published pack frame reaches the real native fixed-frame renderer') +T.eq(draw[3],52,'a consumer anchor edit changes the native draw offset') +sprite:step(false);T.eq(sprite.pmdTicks,0,'stopping resets pack animation time') +love.graphics.newImage,love.graphics.draw=oldImage,oldDraw +local P=require('src.core.game3.pokemon') +T.eq(P.unownLetter(0),0,'GBA personality query is zero-based') +T.eq(P.unownLetter(0x03030303),255%28,'GBA personality query keeps two bits from each byte') +T.finish('published sprite-pack renderer and follower query adapters') diff --git a/tests/engine/portable_input_hooks.lua b/tests/engine/portable_input_hooks.lua new file mode 100644 index 000000000..31c6113cb --- /dev/null +++ b/tests/engine/portable_input_hooks.lua @@ -0,0 +1,32 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/input_hooks/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 2, "native") +dofile("tests/input_hooks/cases.lua").run(f) +f.close() +-- The no-mod path bypasses Runtime.call altogether for all three hooks. +local Runtime = require("src.mods.Runtime") +local none = Fixture.new(".", "gen2", "red", 2, "native", true) +for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + T.eq(Runtime.wantsHook(name), false, "no subscriber: " .. name) +end +local call = Runtime.call +Runtime.call = function() + error("unsubscribed input entered hook bus") +end +none.game:keypressed("up") +T.eq(none.Input:isDown("up"), true, "no-mod native key press") +none.game:keyreleased("up") +T.eq(none.Input:isDown("up"), false, "no-mod native key release") +none.game:gamepadpressed(none.pad, "a") +T.eq(none.Input:isDown("a"), true, "no-mod native pad press") +none.game:gamepadreleased(none.pad, "a") +T.eq(none.Input:isDown("a"), false, "no-mod native pad release") +none.game:gamepadaxis(none.pad, "leftx", 0.9) +T.eq(none.Input:isDown("right"), true, "no-mod native axis") +none.game:gamepadaxis(none.pad, "leftx", 0) +none.game:wheelmoved(0, 1) +T.same(none.zoom, { 1 }, "no-mod native wheel") +Runtime.call = call +none.close() +T.finish("portable input hooks") diff --git a/tests/engine/portable_input_routing.lua b/tests/engine/portable_input_routing.lua new file mode 100644 index 000000000..2db2b0183 --- /dev/null +++ b/tests/engine/portable_input_routing.lua @@ -0,0 +1,125 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/input_hooks/fixture.lua") +local Touch = require("src.core.TouchControls") +local Version = require("src.core.GameVersion") +local oldVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version, 2, "native") + local g, I, api, e = f.game, f.Input, f.public, f.exports + local calls, top = {}, {} + g.stack.top = function() + return top + end + top.onKeyPressed = function(_, key) + calls[#calls + 1] = "key:" .. key + end + top.onKeyReleased = function(_, key) + T.eq(I:isDown("up"), false, "release reaches Input before capture observer: " .. version) + calls[#calls + 1] = "release:" .. key + end + local noted, note = 0, Touch.noteGamepad + Touch.noteGamepad = function() + noted = noted + 1 + end + top.onGamepadPressed = function(_, button) + calls[#calls + 1] = "pad:" .. button + end + top.onGamepadReleased = function(_, button) + if button == "a" then + T.eq(I:isDown("a"), false, "pad release before observer") + end + calls[#calls + 1] = "pad-release:" .. button + end + local unstick = 0 + g.unstick = function() + unstick = unstick + 1 + return "unstuck" + end + local held = {} + for key, value in pairs(I.axisHeld or {}) do + held[key] = value + end + e.capture(true) + g:keypressed("f9") + g:keypressed("up") + g:keyreleased("up") + g:gamepadpressed(f.pad, "a") + g:gamepadreleased(f.pad, "a") + g:gamepadaxis(f.pad, "triggerleft", 1) + T.eq(unstick, 0, "hook precedes engine emergency hotkey: " .. version) + T.eq(#calls, 0, "hook precedes every capture callback") + T.eq(noted, 0, "suppression precedes touch overlay bookkeeping") + T.same(I.axisHeld or {}, held, "suppression does not create pseudo-button state") + T.eq(#I.pressQueue, 0, "suppressed input creates no queued press") + e.capture(false) + T.eq(g:keypressed("f9"), "unstuck", "native hotkey result survives hooks") + T.eq(unstick, 1, "hotkey runs exactly once") + T.eq(#calls, 0, "native hotkey retains priority over top capture") + g:keypressed("up") + T.eq(I:isDown("up"), false, "top key capture keeps native priority over Input") + T.same(calls, { "key:up" }, "exactly one captured key") + I:keypressed("up") + g:keyreleased("up") + g:gamepadpressed(f.pad, "a") + T.eq(I:isDown("a"), false, "top pad capture does not fall through") + I:gamepadpressed(f.pad, "a") + g:gamepadreleased(f.pad, "a") + g:gamepadaxis(f.pad, "triggerleft", 1) + T.eq(calls[#calls], "pad:triggerleft", "swallowed axis did not consume later capture edge") + g:gamepadaxis(f.pad, "triggerleft", 0) + T.eq(calls[#calls], "pad-release:triggerleft", "axis release still reaches capture observer") + Touch.noteGamepad = note + -- Standard hook ordering, next() argument forwarding, and return values. + local order = {} + local a = api.hooks:wrap("input.wheel", function(next, game, dy) + order[#order + 1] = "outer" + T.eq(game, g, "original Game in outer wrapper") + T.eq(dy, 2.5, "fractional raw wheel delta") + next(game, -100) + order[#order + 1] = "after" + return false + end, 30) + local b = api.hooks:wrap("input.wheel", function(next, game, dy) + order[#order + 1] = "inner" + T.eq(dy, -100, "rewritten delta visible downstream") + return next() + end, 20) + local n = #f.zoom + T.eq(g:wheelmoved(777, 2.5), false, "outer hook return value preserved") + T.same(order, { "outer", "inner", "after" }, "descending priority and continuation") + T.eq(f.zoom[n + 1], 1, "native wheel still uses original positive delta") + a() + b() + -- Free-camera behavior (including its existing zero-delta step) is native. + local free = {} + g.zoomView = function() + return { + zoomBy = function(_, delta) + free[#free + 1] = delta + end, + } + end + for _, dy in ipairs({ 2.5, -1, 0 }) do + e.capture(true) + g:wheelmoved(0, dy) + e.capture(false) + g:wheelmoved(0, dy) + end + T.same(free, { -1, 1, 1 }, "free camera preserves native sign and zero behavior") + -- Nil and false are ordinary wrapper results; only omitting next suppresses. + local off = api.hooks:wrap("input.key", function() + return nil + end, 100) + n = #calls + T.eq(g:keypressed("up"), nil, "nil-result suppression") + T.eq(#calls, n, "nil-result wrapper prevents native callback") + off() + f.result.loader.hooks:removeOwner("portable_input") + n = #e.events + g:keypressed("up") + T.eq(#e.events, n, "owner cleanup removes public registrations") + f.close() +end +Version.set(oldVersion) +T.finish("portable input routing") diff --git a/tests/engine/portable_mod_input.lua b/tests/engine/portable_mod_input.lua new file mode 100644 index 000000000..2d1b7f3f4 --- /dev/null +++ b/tests/engine/portable_mod_input.lua @@ -0,0 +1,6 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/mod_input/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +dofile("tests/mod_input/cases.lua").run(f) +f.close() +f.T.finish("portable mod.input") diff --git a/tests/engine/portable_world_scripts.lua b/tests/engine/portable_world_scripts.lua new file mode 100644 index 000000000..ccef037d9 --- /dev/null +++ b/tests/engine/portable_world_scripts.lua @@ -0,0 +1,856 @@ +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", "warp"), false, "unsupported verb declined") + T.eq(loader.apis.legacy:supports("world", "cancelScript"), false, "legacy has no portable cancellation") + T.eq(loader.apis.legacy:supports("scripts", "wait"), false, "native format does not claim portable verbs") + end + T.eq(world:current().mapId, "FIX_TOWN", "unrelated world methods still delegate") + T.eq(world.game, game, "existing native fields still delegate") +end + +-- First admission from an input.step callback must not sort a new observer +-- into the chain while it is being traversed (which would replay the caller). +do + local world, game = boot("crystal") + local wrappers, vanilla = 0, 0 + local stop = Runtime.hooks:wrap("input.step", function(nextFn, ...) + wrappers = wrappers + 1 + if wrappers == 1 then + T.check(world:queueScript({ { "wait", 2 } }), "first queue inside input hook") + end + return nextFn(...) + end, 0, "probe") + Runtime.call("input.step", function() + vanilla = vanilla + 1 + end, game, 1 / 60) + T.eq(wrappers, 1, "first queue does not replay input callback") + T.eq(vanilla, 1, "native input boundary called once") + world:cancelScript() + stop() +end +GameVersion.set(originalVersion) +T.finish("portable world scripts") diff --git a/tests/engine/quit_thread_shutdown.lua b/tests/engine/quit_thread_shutdown.lua index a0dff35be..19cfd28ac 100644 --- a/tests/engine/quit_thread_shutdown.lua +++ b/tests/engine/quit_thread_shutdown.lua @@ -192,18 +192,77 @@ check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= n -- so the quit event exits the process outright. It has to sit after the -- love.quit() veto test, or the editor's abort-quit path would die on a quit -- it refused. -local quitBranch = mainSrc:match('if name == "quit" then(.-)\n%s*end\n') -check(quitBranch ~= nil, "love.run still handles the quit event") -quitBranch = quitBranch or "" -local vetoAt = quitBranch:find("not love%.quit()") -local osAt = quitBranch:find("os%.exit") -local androidAt = quitBranch:find('getOS() == "Android"', 1, true) -check(vetoAt ~= nil and osAt ~= nil and vetoAt < osAt, - "the process exit runs only after love.quit declined to veto") -check(androidAt ~= nil and androidAt < osAt, - "the process exit is gated on Android") -local exits = 0 -for _ in mainSrc:gmatch("os%.exit") do exits = exits + 1 end -eq(exits, 1, "os.exit appears once, at the quit event") +-- Execute the actual run-loop function in an isolated environment. Scanning +-- nested `end`s or counting os.exit in comments cannot prove veto behavior. +local runAt = assert(mainSrc:find("\nfunction love.run()", 1, true)) +local runSource = mainSrc:sub(runAt + 1) +local function quitFrame(platform, answer, code, noCallback) + local calls, exits = {}, {} + local function record(name) calls[#calls + 1] = name end + local env = setmetatable({ + BootTrace = { mark = function() end, frame = function() record("frame") end }, + Frames = { tick = function() record("tick") end }, + FrameProfile = { section = function() return function() end end }, + pacingEnabled = function() return false end, + os = { exit = function(value) record("exit"); exits[#exits + 1] = value end }, + love = { + handlers = { quit = function() record("dispatch") end }, + update = function() record("update") end, + event = { + pump = function() end, + poll = function() + local sent = false + return function() + if not sent then sent = true; return "quit", code end + end + end, + }, + }, + }, { __index = _G }) + if platform then env.love.system = { getOS = function() return platform end } end + if not noCallback then + env.love.quit = function() record("quit"); return answer end + end + local compile = loadstring or load + local chunk + if setfenv then + chunk = assert(compile(runSource, "@main.lua:love.run")) + setfenv(chunk, env) + else + chunk = assert(load(runSource, "@main.lua:love.run", "t", env)) + end + chunk() + local result = env.love.run()() + return result, table.concat(calls, ","), exits +end + +for _, platform in ipairs({ "Android", "Windows", "OS X", "Linux", "NX", false }) do + local label = tostring(platform) + for _, code in ipairs({ 0, 7, false }) do + -- false is a table-friendly sentinel for an omitted exit status. + local expected = code or 0 + for _, noCallback in ipairs({ false, true }) do + for _, answer in ipairs({ false, "nil" }) do + local callbackAnswer = answer + if answer == "nil" then callbackAnswer = nil end + local result, order, exits = quitFrame(platform, callbackAnswer, code or nil, noCallback) + local prefix = noCallback and "" or "quit" + eq(result, expected, label .. ": accepted quit returns its exit status") + if platform == "Android" then + eq(#exits, 1, "Android exits exactly once after accepting quit") + eq(exits[1], expected, "Android forwards the requested exit status") + eq(order, prefix == "" and "exit" or "quit,exit", "shutdown precedes process exit") + else + eq(#exits, 0, label .. ": accepted quit does not force a process exit") + eq(order, prefix, label .. ": no further frame work after accepted quit") + end + end + end + end + local result, order, exits = quitFrame(platform, true, 7) + eq(result, nil, label .. ": veto keeps the frame running") + eq(#exits, 0, label .. ": veto never exits the process") + eq(order, "quit,dispatch,update,frame,tick", label .. ": veto resumes event and frame processing") +end T.finish("quit thread shutdown") diff --git a/tests/engine/rebind_capture_bug510.lua b/tests/engine/rebind_capture_bug510.lua index e5d13d311..f36306d15 100644 --- a/tests/engine/rebind_capture_bug510.lua +++ b/tests/engine/rebind_capture_bug510.lua @@ -48,8 +48,13 @@ local function openMenu(game) return bm end --- rows are BindingsMenu's BUTTONS order; 5 = A, 6 = B -local ROW_A, ROW_B = 5, 6 +-- Find actions by their public menu row metadata, not by a generation's order. +local function row(menu, action) + for i, item in ipairs(menu.items) do + if item.button and item.button.id == action then return i end + end + error("missing controls row: " .. action) +end -- The mechanism, pinned so a future "just apply it immediately" revert -- fails here: a rebind overwrites whatever the key used to do, so B = Z @@ -73,14 +78,14 @@ check(game.save.options.bindings == nil, "escaping a capture writes no binding") eq(game.wroteOptions, 0, "and does not touch options on disk") -bm.index = ROW_B +bm.index = row(bm, "b") press(bm, "a") bm:onKeyPressed("z") check(game.save.options.bindings == nil, "a capture holds its press; nothing stores before the release (#589)") bm:onKeyReleased("z") eq(game.save.options.bindings.b.key, "z", "releasing the press stores B = Z") -eq(bm.items[ROW_B].right, "Z/B", "the row shows the new key straight away") +eq(bm.items[row(bm, "b")].right, "Z/B", "the row shows the new key straight away") eq(game.wroteOptions, 1, "the choice persists immediately") eq(Input.keyBindings["z"], "a", "but the live map still reads Z as A while the screen is open (#510)") @@ -90,12 +95,12 @@ eq(#game.stack.states, 1, "capturing Z does not close the screen") -- inherits B's previous key and no key serves two rows (#589) eq(game.save.options.bindings.a.key, "x", "capturing A's key for B hands A the old B key") -eq(bm.items[ROW_A].right, "X/A", "and the A row redraws with it") +eq(bm.items[row(bm, "a")].right, "X/A", "and the A row redraws with it") -- the next Z the player presses is still confirm, so the A row can be armed -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") -eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing") +eq(bm.capture, bm.items[row(bm, "a")], "the A row arms instead of the screen closing") bm:onKeyPressed("x") bm:onKeyReleased("x") eq(game.save.options.bindings.a.key, "x", "re-capturing A's own key keeps it") @@ -117,16 +122,18 @@ eq(Input.keyBindings["z"], "b", "a reload reaches the same map as the close") Input:init() local padGame = newGame() local padBm = openMenu(padGame) -padBm.index = ROW_B +padBm.index = row(padBm, "b") press(padBm, "a") padBm:onGamepadPressed("y") padBm:onGamepadReleased("y") eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores") -eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close") +eq(Input.padBindings["y"], "x", "pad Y keeps its default DS X action until close") +eq(padGame.save.options.bindings.x.pad, "b", "DS X inherits the captured row's pad B") +eq(Input.padBindings["b"], "b", "the donor swap is also deferred until close") -- a second input going down while the first is held backs the capture out -- with no keyboard in reach, the pad's Escape (#589) -padBm.index = ROW_A +padBm.index = row(padBm, "a") press(padBm, "a") padBm:onGamepadPressed("x") padBm:onGamepadPressed("b") @@ -139,6 +146,20 @@ eq(padGame.save.options.bindings.a, nil, press(padBm, "b") eq(Input.padBindings["y"], "b", "closing commits the pad half too") +eq(Input.padBindings["b"], "x", "closing commits the DS X donor half") + +-- A genuinely unbound input must also remain absent until the screen closes. +Input:init() +local spareGame = newGame() +local spareBm = openMenu(spareGame) +spareBm.index = row(spareBm, "a") +press(spareBm, "a") +spareBm:onGamepadPressed("leftstick") +spareBm:onGamepadReleased("leftstick") +eq(spareGame.save.options.bindings.a.pad, "leftstick", "unbound pad capture stores") +eq(Input.padBindings.leftstick, nil, "unbound pad remains absent while the menu is open") +press(spareBm, "b") +eq(Input.padBindings.leftstick, "a", "closing activates the newly bound pad") Input:init() T.finish("rebind_capture_bug510") diff --git a/tests/engine/rebind_swap_clear_bug589.lua b/tests/engine/rebind_swap_clear_bug589.lua index 0e49d03d0..7ccca5fe0 100644 --- a/tests/engine/rebind_swap_clear_bug589.lua +++ b/tests/engine/rebind_swap_clear_bug589.lua @@ -54,8 +54,12 @@ local function settleChoice(state) for _ = 1, Timing.YES_NO_ANSWER do state:update(1 / 60) end end --- rows are BindingsMenu's BUTTONS order -local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8 +local function row(menu, action) + for i, item in ipairs(menu.items) do + if item.button and item.button.id == action then return i end + end + error("missing controls row: " .. action) +end -- ---- (a) pad capture swaps with the row that owns the button -------------- @@ -64,9 +68,9 @@ local game = newGame() local bm = BindingsMenu.new(game) game.stack:push(bm) -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") -eq(bm.capture, bm.items[ROW_A], "A arms the A row") +eq(bm.capture, bm.items[row(bm, "a")], "A arms the A row") bm:onGamepadPressed("b") check(game.save.options.bindings == nil, "a pad capture holds its press; nothing stores before the release") @@ -75,8 +79,8 @@ local bindings = game.save.options.bindings eq(bindings.a.pad, "b", "releasing pad B stores it on the A row") eq(bindings.b.pad, "a", "and the B row, which owned pad B, inherits the A row's old pad (#589)") -eq(bm.items[ROW_A].right, "Z/B", "the A row redraws with the new pad") -eq(bm.items[ROW_B].right, "X/A", "so does the B row") +eq(bm.items[row(bm, "a")].right, "Z/B", "the A row redraws with the new pad") +eq(bm.items[row(bm, "b")].right, "X/A", "so does the B row") -- after applying, every row's effective pad is unique and none is lost Input:applyBindings(bindings) @@ -89,13 +93,21 @@ for button, action in pairs(Input.padBindings) do seen[action] = button actions[#actions + 1] = action end -eq(#actions, 8, "all eight actions still have exactly one pad button") +eq(#actions, 12, "all twelve actions still have exactly one pad button") +local unchanged = { + up = "dpup", down = "dpdown", left = "dpleft", right = "dpright", + x = "y", y = "x", start = "start", select = "back", + l = "leftshoulder", r = "rightshoulder", +} +for action, button in pairs(unchanged) do + eq(seen[action], button, "A/B swap preserves the binding for " .. action) +end Input:init() -- ---- (b) a second input while the first is held cancels ------------------- -- key then key: the straggling release of the first press writes nothing -bm.index = ROW_SELECT +bm.index = row(bm, "select") press(bm, "a") bm:onKeyPressed("q") bm:onKeyPressed("w") @@ -121,17 +133,17 @@ check(bindings.select == nil, "press alone commits nothing (#589)") eq(game.wroteOptions, writesAfterSwap, "and touches nothing on disk") bm:onKeyReleased("q") eq(bindings.select.key, "q", "the release is the commit") -eq(bm.items[ROW_SELECT].right, "Q/BACK", "the row shows the new key") +eq(bm.items[row(bm, "select")].right, "Q/BACK", "the row shows the new key") -- ---- (d) SELECT on a row forgets its rebind ------------------------------- press(bm, "select") check(bindings.select == nil, "SELECT drops the row's overlay entry (#589)") -eq(bm.items[ROW_SELECT].right, "TAB/BACK", "the row falls back to the default") +eq(bm.items[row(bm, "select")].right, "TAB/BACK", "the row falls back to the default") -- the swapped B row clears the same way; a second SELECT on the now -- default row is a no-op, not a write local writes = game.wroteOptions -bm.index = ROW_B +bm.index = row(bm, "b") press(bm, "select") eq(game.wroteOptions, writes + 1, "clearing the swapped B row writes once") check(game.save.options.bindings.b == nil, "and drops its overlay entry") @@ -141,7 +153,7 @@ eq(game.wroteOptions, writes + 1, "clearing an already-default row writes nothin -- ---- (e) START confirms, then drops the whole overlay --------------------- -- rebuild a dirty overlay to reset -bm.index = ROW_A +bm.index = row(bm, "a") press(bm, "a") bm:onKeyPressed("p") bm:onKeyReleased("p") @@ -158,7 +170,7 @@ press(box, "a") settleChoice(box) eq(game.stack:top(), bm, "answering pops the box") check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)") -eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them") +eq(bm.items[row(bm, "a")].right, "P/B", "and the rows keep showing them") -- again, flip to YES: the overlay goes away and every row reads default press(bm, "start") @@ -167,8 +179,8 @@ press(box, "up") press(box, "a") settleChoice(box) check(game.save.options.bindings == nil, "YES clears options.bindings (#589)") -eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again") -eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched") +eq(bm.items[row(bm, "a")].right, "Z/A", "the A row reads its default again") +eq(bm.items[row(bm, "b")].right, "X/B", "so does the B row the swap had touched") -- closing after the reset leaves the live map at the defaults press(bm, "b") diff --git a/tests/engine/rs_braille.lua b/tests/engine/rs_braille.lua new file mode 100644 index 000000000..eca8d5efd --- /dev/null +++ b/tests/engine/rs_braille.lua @@ -0,0 +1,59 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local X=require('src.import.RomExtractorGen3') +-- A synthetic font: interleaved dot bits, explicit nonzero tile mapping, +-- one-pixel unraised dots and 3x3 raised dots. No cartridge bytes are used. +local raw,base,map,wall={},0x2000,0x1000,0x4000 +local function tile(code,halfRow) return (code*2+1-halfRow)%256 end +for code=0,127 do + for halfRow=0,1 do raw[map+code*2+halfRow]=tile(code,halfRow) end + local value=code%64 + local half=math.floor(code/64) + for y=0,15 do + local word=0 + for x=0,7 do + local ink=false + for dot=0,5 do + local cx,cy=(dot%2==0 and 2 or 10),2+math.floor(dot/2)*5 + local dx,dy=x+half*8-cx,y-cy + local raised=math.floor(value/2^dot)%2==1 + if (dx==0 and dy==0) or (raised and math.abs(dx)<=1 and math.abs(dy)<=1) then ink=true end + end + if ink then word=word+2^(7-x) end + end + raw[base+tile(code,math.floor(y/8))*8+y%8]=word + end +end +for i,v in ipairs({9,6,19,13,12,9,1,5,3,0xFE,0x3F,0xFF}) do raw[wall+i-1]=v end +local function extractor(profile) + return setmetatable({manifest={braille=profile},symbols={gFontBrailleLatinGlyphs=base}, + rom={u8=function(_,a)return raw[a] or 0 end}, + _scriptPool={scripts={wall={{'braillemessage',0x08000000+wall}}}}, + beginStage=function()end,fontSheet=function()return nil end, + write=function(self,key,value)self.output=value end},X) +end +local profile={bits=1,glyphMap=map,halfCodeOffset=64,frameFromHeader=true,lineHeight=32} +local x=extractor(profile) +local a=x:brailleGlyph(base,1) +T.eq(a[3][3],2,'raised dot retains its common center') +T.eq(a[2][2],1,'MSB-first left half raises the first dot') +T.eq(a[2][10],3,'A does not raise the top right dot') +T.eq(a[3][11],2,'right half retains the unraised dot') +x:extractBraille() +local b=assert(x.output and x.output.gen3Braille) +local w=b.messages[0x08000000+wall] +T.eq(w.text,'ABC\n{3F}','dot identities decode letters from the actual masks') +T.eq(w.x,10,'frame left comes from header') +T.eq(w.y,7,'frame top comes from header') +T.eq(w.width,9,'frame right comes from header') +T.eq(w.height,6,'frame bottom comes from header') +T.eq(w.textX,96,'text x is independent of frame x') +T.eq(w.textY,72,'text y is independent of frame y') +T.eq(b.lineHeight,32,'RS newline includes its extra two tiles') +-- A font which violates the all-64-pattern proof must not create a wall. +raw[base+tile(63,0)*8]=1 +local broken=extractor(profile) +broken:extractBraille() +T.eq(broken.output,nil,'unrelated or corrupt font is rejected') +T.finish('RS 1bpp braille masks and header window') diff --git a/tests/engine/rs_gym_puzzles.lua b/tests/engine/rs_gym_puzzles.lua new file mode 100644 index 000000000..054db5784 --- /dev/null +++ b/tests/engine/rs_gym_puzzles.lua @@ -0,0 +1,42 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local C=require('src.script.Gen3Commands') +local J=require('src.link.Json') +local f=io.open('tools/rom_manifest_ruby.json') or assert(io.open('tools/rom_manifest_sapphire.json'));local m=J.decode(f:read('*a'));f:close() +T.eq(m.specialRemap[140],142,'RS beam toggle maps to native handler') +T.eq(m.specialRemap[141],143,'RS switch handler maps by identity') +T.eq(m.specialRemap[145],147,'RS gym deactivation maps by identity') +T.eq(m.specialRemap[146],148,'RS sliding doors map by identity') +T.eq(m.specialRemap[147],149,'RS instant doors map by identity') +T.same(m.mauvilleGym.SWITCHES,{{0,9},{8,11},{4,15}},'RS has its own three switches') +local blocks,solid={},{} +local function key(x,y)return x..':'..y end +local map={blockAt=function(_,x,y)return blocks[key(x,y)] end, + setBlock=function(_,x,y,tile,shut) blocks[key(x,y)]=tile;solid[key(x,y)]=shut end} +local ctx={save={},overworld={map=map},game={data={constants={gen3MauvilleGym=m.mauvilleGym}}}} +C.setVar(ctx.save,0x8004,1) +C.SPECIALS[143](ctx) +T.eq(blocks['0:9'],0x205,'first RS switch raised') +T.eq(blocks['8:11'],0x206,'selected RS switch pressed') +T.eq(blocks['4:15'],0x205,'third RS switch raised') +T.eq(blocks['0:15'],nil,'Emerald switch is not written into RS') +blocks['1:7']=0x220;blocks['1:8']=0x228 +T.eq(C.SPECIALS[142](ctx),2,'both beam halves toggle') +T.eq(blocks['1:8'],0x238,'bottom beam becomes unlit') +T.eq(solid['1:8'],false,'unlit bottom beam is passable') +C.SPECIALS[142](ctx) +T.eq(blocks['1:8'],0x228,'second toggle restores lit beam') +T.eq(solid['1:8'],true,'lit bottom beam blocks walking') +C.SPECIALS[147](ctx) +T.eq(blocks['1:8'],0x238,'victory disables beam') +for _,at in ipairs(m.mauvilleGym.SWITCHES) do + T.eq(blocks[key(at[1],at[2])],0x206,'victory presses every RS switch') +end +T.eq(C.mauvilleProfile({game={data={constants={}}}}),C.MAUVILLE,'existing Emerald imports retain original contract') +C.setVar(ctx.save,0x8004,1) +C.SPECIALS[149](ctx) +T.eq(blocks['1:104'],0x21C,'Petalburg upper door reaches final frame') +T.eq(blocks['1:105'],0x224,'Petalburg lower door uses its own row') +T.eq(solid['1:104'],true,'visual door cell retains native collision') +T.finish('RS gym special aliases, switches, beams and doors') diff --git a/tests/engine/ruby_registration.lua b/tests/engine/ruby_registration.lua new file mode 100644 index 000000000..465fa7bd4 --- /dev/null +++ b/tests/engine/ruby_registration.lua @@ -0,0 +1,24 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local V=require('src.core.GameVersion') +local J=require('src.link.Json') +local X=require('src.import.RomExtractorGen3') +local f=assert(io.open('tools/rom_manifest_ruby.json'));local m=J.decode(f:read('*a'));f:close() +local v=V.info('ruby') +T.eq(v.sha1,m.romSha1,'registered cartridge matches manifest') +T.eq(v.cachePrefix,'ruby/','own imported data namespace') +T.eq(v.saveSuffix,'_ruby','own save namespace') +T.eq(v.generation,3,'Ruby is a native Gen3 cartridge') +T.eq(m.specialRemap[157],159,'native starter special maps to canonical handler') +T.eq(m.specialIndexMap['159'],156,'canonical extractor reads native starter slot') +T.eq(m.specialIndexMap['158'],155,'read-only wall clock has its own handler') +T.eq(m.typeIds[2],'FIGHTING','short cartridge FIGHT name retains canonical battle type') +T.eq(m.typeIds[14],'ELECTRIC','short cartridge ELECTR name retains canonical battle type') +local raw={} +local target=m.provenance.symbolIdentities.ScrSpecial_ChooseStarter +raw[m.symbols.gSpecials+156*4]=0x08000000+target+1 +local x=setmetatable({manifest=m,symbols=m.symbols,rom={u32=function(_,a) return raw[a] or 0 end}},X) +T.eq(x:specialAddress(159),target,'extractor reads the same native special that scripts call') +T.eq(x:specialAddress(529),nil,'unmapped special never reads a different native slot') +T.finish('Ruby registration, type ids and special address spaces') diff --git a/tests/engine/ruby_save_layout.lua b/tests/engine/ruby_save_layout.lua new file mode 100644 index 000000000..dc857ce2c --- /dev/null +++ b/tests/engine/ruby_save_layout.lua @@ -0,0 +1,36 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local J=require('src.link.Json') +local f=assert(io.open('tools/rom_manifest_ruby.json'));local m=J.decode(f:read('*a'));f:close() +local S=require('src.save_convert.Gen3Save') +S.setLayout(m.save,m.substructOrders,m.saveFields,'ruby') +T.eq(S.defaultMetGame,2,'Ruby origin remains Ruby') +T.eq(S.encryptionKey(string.rep('\255',m.save.saveBlock2Size)),0,'RS bag and money are unencrypted') +local image=S.blank() +local blocks=assert(S.readBlocks(image)) +T.eq(#blocks.block1,15040,'Ruby SaveBlock1 assembled') +T.eq(#blocks.block2,2192,'Ruby SaveBlock2 assembled') +T.eq(#blocks.storage,33744,'all fourteen boxes assembled') +-- Patterned nonzero blocks catch offset/chunk/rotation errors that empty +-- templates cannot expose. No player or cartridge save is involved. +local function pattern(n,seed) + local out={};for i=1,n do out[i]=string.char((i*17+seed)%256) end + return table.concat(out) +end +blocks.block1=pattern(#blocks.block1,3) +blocks.block2=pattern(#blocks.block2,79) +blocks.storage=pattern(#blocks.storage,211) +for _,counter in ipairs{2,13,14,29} do + local encoded=S.writeSlot(image,counter%2,blocks,counter) + local decoded=assert(S.readBlocks(encoded)) + T.check(decoded.block1==blocks.block1,'rotated block1 '..counter) + T.check(decoded.block2==blocks.block2,'rotated block2 '..counter) + T.check(decoded.storage==blocks.storage,'rotated storage '..counter) +end +T.eq(m.saveFields.saveBlock1.flags+m.saveFields.flagBytes,m.saveFields.saveBlock1.vars,'own flags meet own vars') +T.eq(m.saveFields.bag.capacities[1],20,'Ruby item pocket is 20, not Emerald 30') +T.eq(m.saveFields.bag.capacities[2],20,'Ruby key item pocket is 20') +S.setLayout(m.save,m.substructOrders,m.saveFields,'sapphire') +T.eq(S.defaultMetGame,1,'shared codec retains Sapphire origin') +T.finish('Ruby save layout and unencrypted codec') diff --git a/tests/engine/sapphire_registration.lua b/tests/engine/sapphire_registration.lua new file mode 100644 index 000000000..0ac83380b --- /dev/null +++ b/tests/engine/sapphire_registration.lua @@ -0,0 +1,24 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local V=require('src.core.GameVersion') +local J=require('src.link.Json') +local X=require('src.import.RomExtractorGen3') +local f=assert(io.open('tools/rom_manifest_sapphire.json'));local m=J.decode(f:read('*a'));f:close() +local v=V.info('sapphire') +T.eq(v.sha1,m.romSha1,'registered cartridge matches manifest') +T.eq(v.cachePrefix,'sapphire/','own imported data namespace') +T.eq(v.saveSuffix,'_sapphire','own save namespace') +T.eq(v.generation,3,'Sapphire is a native Gen3 cartridge') +T.eq(m.specialRemap[157],159,'native starter special maps to canonical handler') +T.eq(m.specialIndexMap['159'],156,'canonical extractor reads native starter slot') +T.eq(m.specialIndexMap['158'],155,'read-only wall clock has its own handler') +T.eq(m.typeIds[2],'FIGHTING','short cartridge FIGHT name retains canonical battle type') +T.eq(m.typeIds[14],'ELECTRIC','short cartridge ELECTR name retains canonical battle type') +local raw={} +local target=m.provenance.symbolIdentities.ScrSpecial_ChooseStarter +raw[m.symbols.gSpecials+156*4]=0x08000000+target+1 +local x=setmetatable({manifest=m,symbols=m.symbols,rom={u32=function(_,a) return raw[a] or 0 end}},X) +T.eq(x:specialAddress(159),target,'extractor reads the same native special that scripts call') +T.eq(x:specialAddress(529),nil,'unmapped special never reads a different native slot') +T.finish('Sapphire registration, type ids and special address spaces') diff --git a/tests/engine/sapphire_save_layout.lua b/tests/engine/sapphire_save_layout.lua new file mode 100644 index 000000000..9779fd6eb --- /dev/null +++ b/tests/engine/sapphire_save_layout.lua @@ -0,0 +1,36 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local J=require('src.link.Json') +local f=assert(io.open('tools/rom_manifest_sapphire.json'));local m=J.decode(f:read('*a'));f:close() +local S=require('src.save_convert.Gen3Save') +S.setLayout(m.save,m.substructOrders,m.saveFields,'sapphire') +T.eq(S.defaultMetGame,1,'Sapphire origin remains Sapphire') +T.eq(S.encryptionKey(string.rep('\255',m.save.saveBlock2Size)),0,'RS bag and money are unencrypted') +local image=S.blank() +local blocks=assert(S.readBlocks(image)) +T.eq(#blocks.block1,15040,'Sapphire SaveBlock1 assembled') +T.eq(#blocks.block2,2192,'Sapphire SaveBlock2 assembled') +T.eq(#blocks.storage,33744,'all fourteen boxes assembled') +-- Patterned nonzero blocks catch offset/chunk/rotation errors that empty +-- templates cannot expose. No player or cartridge save is involved. +local function pattern(n,seed) + local out={};for i=1,n do out[i]=string.char((i*17+seed)%256) end + return table.concat(out) +end +blocks.block1=pattern(#blocks.block1,3) +blocks.block2=pattern(#blocks.block2,79) +blocks.storage=pattern(#blocks.storage,211) +for _,counter in ipairs{2,13,14,29} do + local encoded=S.writeSlot(image,counter%2,blocks,counter) + local decoded=assert(S.readBlocks(encoded)) + T.check(decoded.block1==blocks.block1,'rotated block1 '..counter) + T.check(decoded.block2==blocks.block2,'rotated block2 '..counter) + T.check(decoded.storage==blocks.storage,'rotated storage '..counter) +end +T.eq(m.saveFields.saveBlock1.flags+m.saveFields.flagBytes,m.saveFields.saveBlock1.vars,'own flags meet own vars') +T.eq(m.saveFields.bag.capacities[1],20,'Sapphire item pocket is 20, not Emerald 30') +T.eq(m.saveFields.bag.capacities[2],20,'Sapphire key item pocket is 20') +S.setLayout(m.save,m.substructOrders,m.saveFields,'sapphire') +T.eq(S.defaultMetGame,1,'shared codec retains Sapphire origin') +T.finish('Sapphire save layout and unencrypted codec') diff --git a/tests/engine/screen_visibility.lua b/tests/engine/screen_visibility.lua new file mode 100644 index 000000000..aa8858b16 --- /dev/null +++ b/tests/engine/screen_visibility.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/screen_visibility/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/screen_visibility/cases.lua").run(f) +f.close() +f.T.finish("portable screen.render_visible") diff --git a/tests/engine/screen_visibility_routing.lua b/tests/engine/screen_visibility_routing.lua new file mode 100644 index 000000000..f3e84272d --- /dev/null +++ b/tests/engine/screen_visibility_routing.lua @@ -0,0 +1,113 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/screen_visibility/fixture.lua") +local Cases = dofile("tests/screen_visibility/cases.lua") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Loader = require("src.mods.Loader") +local Game = require("src.core.Game") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local e, stack, base, menu, overlay = f.exports, f.stack, f.base, f.menu, f.overlay + e.hide("BagMenu", true) + local baseAnimations, menuAnimations = 0, 0 + base.animate = function() + baseAnimations = baseAnimations + 1 + end + menu.animate = function() + menuAnimations = menuAnimations + 1 + end + stack.states = { base, menu, overlay } + local polls = e.seen.calls + stack:update(1 / 60) + T.eq(baseAnimations, 0, "hidden opaque menu still covers lower simulation animation") + T.eq(menuAnimations, 1, "hidden covered menu retains native animation") + T.eq(overlay.updates, 1, "visible overlay retains update ownership") + T.eq(e.seen.calls, polls, "animation does not invoke render predicate") + base.animate, menu.animate = nil, nil + stack.states = { base, menu } + local key + menu.onKeyPressed = function(_, value) + key = value + end + f.game:keypressed("screen-contract-key") + T.eq(key, "screen-contract-key", "hidden menu retains native keyboard dispatch") + T.eq(e.seen.calls, polls, "keyboard does not invoke render predicate") + menu.onKeyPressed = nil + local worldPalettes = 0 + base.sgbWorldZones = function() + worldPalettes = worldPalettes + 1 + return "world zones" + end + local trace, zones, worldZones, clearWorld = f.frame({ base, menu }) + T.same(trace, { "Base" }, "world below hidden opaque menu is drawn") + T.eq(zones, "Base zones", "hidden menu cannot claim UI palettes") + T.eq(worldZones, "world zones", "visible world retains separate palettes") + T.eq(clearWorld, true, "visible world retains transparent UI clear") + e.hide("Base", true) + trace, zones, worldZones, clearWorld = f.frame({ base, menu }) + T.same(trace, {}, "all hidden states draw nothing") + T.eq(zones, nil, "all hidden states claim no UI palette") + T.eq(worldZones, nil, "hidden world cannot claim world palette") + T.eq(worldPalettes, 1, "hidden world palette provider is never invoked") + T.eq(clearWorld, false, "hidden world cannot claim clear ownership") + e.hide("Base", false) + -- World-backed battles can extend drawing below the opaque base. Their + -- fallback search must not select a hidden opaque state as that base. + local middle = f.state("BagMenu", true) + local battle = f.state("Battle", false) + battle.bgMode = function() + return "world" + end + overlay.isOpaque = true + stack.states = { base, middle, battle, overlay } + T.eq(Game.drawBaseInStack(stack, 4), 1, "backdrop search skips hidden opaque states") + e.hide("Battle", true) + T.eq(Game.drawBaseInStack(stack, 4), 4, "hidden battle does not extend native backdrop") + e.hide("Battle", false) + overlay.isOpaque = false + base.sgbWorldZones = nil + e.hide("BagMenu", false) + base.updates, menu.updates, menu.presses, overlay.updates = 0, 0, 0, 0 + Cases.run(f) + f.close() + end +end +local bare = Fixture.new(".", "gen2", "red", 3, "unified", true) +local originalCall, calls = Runtime.call, 0 +Runtime.call = function(name, ...) + if name == "screen.render_visible" then + calls = calls + 1 + end + return originalCall(name, ...) +end +T.eq(Runtime.wantsHook("screen.render_visible"), false, "no-mod runtime") +T.eq(bare.stack:renderVisible(nil), false, "nil remains hidden without mods") +T.eq(bare.stack:renderVisible(bare.menu), true, "no-mod default true") +T.same(bare.frame({ bare.base, bare.menu }), { "BagMenu" }, "no-mod native main render") +bare.stack:draw() +T.eq(calls, 0, "unsubscribed predicate never dispatches Runtime.call") +Runtime.call = originalCall +bare.close() +local files = { + ["mods/broken_screen/manifest.json"] = [[{"id":"broken_screen","name":"Broken Screen", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_screen/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("screen.render_visible", function() return false end) + error("intentional visibility entry failure") + ]], +} +local failed = T.sdk.loadMods( + { "mods/broken_screen" }, + { fs = T.sdk.memfs(files), data = T.fixtures.fresh() } +) +T.check(#failed.errors > 0, "failed entry reported") +T.eq(Runtime.wantsHook("screen.render_visible"), false, "rollback removes hidden-state predicate") +Loader.teardown() +T.eq(Runtime.wantsHook("screen.render_visible"), false, "teardown removes hidden-state predicate") +failed.release() +Version.set(originalVersion) +T.finish("screen.render_visible native routing") diff --git a/tests/engine/soft_reset_bug563.lua b/tests/engine/soft_reset_bug563.lua index 65fb22445..fe8fdcf7d 100644 --- a/tests/engine/soft_reset_bug563.lua +++ b/tests/engine/soft_reset_bug563.lua @@ -106,8 +106,9 @@ check(stepChord(), "reaching 16 again from the restart") -- before stack:update, so it fires from a battle, a menu or a cutscene and -- not just from the overworld (#563). local function fakeGame() - local game = { input = Input, save = {}, returned = 0 } + local game = setmetatable({ input = Input, save = {}, returned = 0 }, { __index = Game }) game.stack = { + states = { {} }, -- a live state, so the real empty-stack guard does not recover updates = 0, update = function(self) self.updates = self.updates + 1 end, } @@ -117,7 +118,23 @@ end local game = fakeGame() holdKeys("z", "x", "escape", "tab") +-- Observe the real profiling wrapper as well as its real _step body. A reset +-- returns early from _step, but must still close the outer profiling scope. +local FrameProfile = require("src.core.FrameProfile") +local savedSection = FrameProfile.section +local opened, closed = 0, 0 +FrameProfile.section = function(name) + local close = savedSection(name) + if name == " step: whole" then opened = opened + 1 end + return function() + if name == " step: whole" then closed = closed + 1 end + close() + end +end for _ = 1, HOLD_STEPS do Game.step(game, 1 / 60) end +FrameProfile.section = savedSection +eq(opened, HOLD_STEPS, "every logic step enters the real profiling wrapper") +eq(closed, HOLD_STEPS, "the reset early return closes its profiling scope") eq(game.returned, 1, "Game:step falls back to the title on the 16th step") eq(game.stack.updates, HOLD_STEPS - 1, "and the state on top never gets that step, whatever it was") @@ -131,16 +148,17 @@ local bare = fakeGame() bare.input = { step = function() end, isDown = function() return false end } for _ = 1, HOLD_STEPS * 2 do Game.step(bare, 1 / 60) end eq(bare.returned, 0, "an input with no chord bookkeeping never resets") +eq(bare.stack.updates, HOLD_STEPS * 2, "an input stand-in still updates the real state stack") -- ---- the on-screen overlay --------------------------------------------- -- Force the overlay live the way a phone would have it. img is only ever --- tested for truthiness on the input path (drawing is what reads it), so a --- placeholder keeps this suite off love.graphics.newImage. +-- checked per button on the input path. Provide each available control +-- without asking love.graphics to load actual image assets. Input:init() TouchControls:init() TouchControls.active, TouchControls.enabled = true, true -TouchControls.img = { stub = true } +TouchControls.img = { a = {}, b = {}, start = {}, select = {}, dpad = {} } local L = TouchControls:layout() -- One finger, one control: touchpressed returns on its first hit, so no diff --git a/tests/engine/summary_fallback_columns.lua b/tests/engine/summary_fallback_columns.lua new file mode 100644 index 000000000..11b78f468 --- /dev/null +++ b/tests/engine/summary_fallback_columns.lua @@ -0,0 +1,23 @@ +package.path='./?.lua;./?/init.lua;'..package.path +love=require('tests.love_stub') +local T=require('tests.harness') +local F=require('src.render.Font') +local S=require('src.ui.Gen3SummaryMenu') +local draw,width=F.draw,F.width +local rows={} +F.width=function(s)return #tostring(s)*8 end +F.draw=function(s,x,y)rows[tostring(s)]={x=x,y=y,w=F.width(s)} end +local screen=setmetatable({hasArt=false,page=2,game={data={constants={}}}, + mon={hp=999,stats={hp=999,attack=888,defense=777,spatk=666,spdef=555,speed=444},exp=0}, + statColour=function()return{0,0,0}end,expToNext=function()return 1 end}, {__index=S}) +local ok,err=pcall(function()screen:drawSkills()end) +F.draw,F.width=draw,width +assert(ok,err) +for _,pair in ipairs{{'HP','999/999'},{'ATTACK','888'},{'DEFENSE','777'}, + {'SP. ATK','666'},{'SP. DEF','555'},{'SPEED','444'}} do + local label,value=assert(rows[pair[1]]),assert(rows[pair[2]]) + T.check(label.x+label.w<=value.x,pair[1]..' does not overlap its value') + T.check(value.x+value.w<=224,pair[2]..' stays inside the native fallback panel') + if label.x<132 then T.check(value.x+value.w<132,'left value ends before right labels') end +end +T.finish('Native summary fallback column bounds') diff --git a/tests/engine/tool_mod_hooks.lua b/tests/engine/tool_mod_hooks.lua index 36f1f2a01..d92ac0aad 100644 --- a/tests/engine/tool_mod_hooks.lua +++ b/tests/engine/tool_mod_hooks.lua @@ -8,6 +8,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") local Hooks = require("src.mods.Hooks") local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks, Runtime.errors @@ -19,14 +20,14 @@ Runtime.hooks = hooks -- ordering rather than merely checking that a hook was registered. do local order = {} - local fake = { + local fake = setmetatable({ input = { step = function() order[#order + 1] = "input" end }, - stack = { update = function(_, dt) + stack = { states = { {} }, update = function(_, dt) order[#order + 1] = "world" T.eq(dt, 1 / 60, "the world receives the fixed-step dt") end }, save = {}, - } + }, { __index = Game }) hooks:wrap("input.step", function(nextFn, game, dt) T.check(game == fake, "input.step receives the live Game object") T.eq(dt, 1 / 60, "input.step receives the fixed-step dt") @@ -71,10 +72,10 @@ end -- the live snapshot in preparation for disk IO. do local captured = false - local fake = { + local fake = setmetatable({ save = {}, overworld = { captureSave = function() captured = true end }, - } + }, { __index = Game }) hooks:wrap("save.write", function(nextFn, game) T.check(game == fake, "save.write receives the live Game object") return false @@ -112,14 +113,14 @@ do -- Game:draw iterates stack.states and calls each state's draw (it no -- longer goes through stack:draw), so the fixture must put a drawable -- state on the stack to observe composition order. - local fake = { + local fake = setmetatable({ overworld = {}, stack = { states = { { draw = function() order[#order + 1] = "states" end }, }, }, - } + }, { __index = Game }) function fake.stack:visibleBase() return 1 end function fake.stack:top() return self.states[#self.states] end diff --git a/tests/engine/ui_actions.lua b/tests/engine/ui_actions.lua new file mode 100644 index 000000000..8103d644f --- /dev/null +++ b/tests/engine/ui_actions.lua @@ -0,0 +1,792 @@ +-- Real native screens, Loader facades and ROM-free generation fixtures. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Loader = require("src.mods.Loader") +local GameVersion = require("src.core.GameVersion") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local Runtime = require("src.mods.Runtime") +local Json = require("src.link.Json") +local Intents = require("src.mods.BattleIntents") +local original = GameVersion.get() +local versions = GameVersion.ORDER +local function boot(version, apiLevel) + GameVersion.set(version) + local data = T.fixtures.fresh() + local files = {} + for _, id in ipairs({ "probe", "other" }) do + files["mods/" .. id .. "/manifest.json"] = + Json.encode({ id = id, name = id, version = "1.0.0", api = apiLevel or 2, entry = "main.lua" }) + files["mods/" .. id .. "/main.lua"] = [[return function(mod) + assert(mod.ui:snapshot()==nil) + assert(type(mod.ui.insertBefore)=="function") + if mod.supports then + assert(mod:supports("ui", "snapshot")) + assert(mod:supports("ui", "submit")) + assert(not mod:supports("ui", "unknown")) + end + end]] + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe", "other" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" or p == "mods/other" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + T.check(loader:load(data), "fixture mods load") + local game = { data = data, stack = { states = {} }, save = SaveData.newGame() } + loader.game = game + Font.load(data) + game.save.party = { Pokemon.new(data, "FIXMON_A", 20), Pokemon.new(data, "FIXMON_B", 12) } + game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, + } + function game.stack:top() + return self.states[#self.states] + end + function game.stack:push(state) + self.states[#self.states + 1] = state + end + function game.stack:pop() + return table.remove(self.states) + end + return loader.apis.probe.ui, game, loader.apis.other.ui, loader +end +local function button(game, screen, name) + local old = game.input + game.input = { + wasPressed = function(_, key) + return key == name + end, + isDown = function() + return false + end, + } + screen:update(1 / 60) + game.input = old +end +local function submit(ui, id, kind, fields) + local snap, err = ui:snapshot() + assert(snap, err) + local intent = { id = id, revision = snap.revision, kind = kind } + for k, v in pairs(fields or {}) do + intent[k] = v + end + return ui:submit(intent) +end +local partyClass = { + red = "PartyMenu", + crystal = "PartyMenu", + emerald = "Gen3PartyMenu", + platinum = "Gen4PartyMenu", + heartgold = "Gen4PartyMenu", + black = "Gen4PartyMenu", +} +local bagClass = { + red = "BagMenu", + crystal = "BagMenu", + emerald = "Gen3BagMenu", + platinum = "Gen4BagMenu", + heartgold = "HgssBagMenu", + black = "Gen5BagMenu", +} +for _, version in ipairs(versions) do + local gen = GameVersion.VERSIONS[version].generation + partyClass[version] = partyClass[version] + or (gen <= 2 and "PartyMenu" or gen == 3 and "Gen3PartyMenu" or "Gen4PartyMenu") + bagClass[version] = bagClass[version] + or ( + gen <= 2 and "BagMenu" + or gen == 3 and "Gen3BagMenu" + or version == "soulsilver" and "HgssBagMenu" + or gen == 5 and "Gen5BagMenu" + or "Gen4BagMenu" + ) + local ui, game, other = boot(version) + local chosen, cancelled = 0, 0 + local screen = require("src.ui." .. partyClass[version]).new(game, { + pickOnly = true, + onSwitch = function(mon) + T.eq(mon, game.save.party[2], "native picker target") + chosen = chosen + 1 + end, + onCancel = function() + cancelled = cancelled + 1 + end, + }) + game.stack:push(screen) + local snap = assert(ui:snapshot()) + T.eq(snap.kind, "party", version .. " party recognized") + T.eq(snap.rows[2].species, "FIXMON_B", "portable party ID") + T.eq(ui:snapshot().revision, snap.revision, "unchanged read retains revision") + snap.rows[1].hp = -999 + T.neq(game.save.party[1].hp, -999, "snapshot detached") + local stale = other:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "native party selection accepted") + T.eq(chosen, 1, "picker callback once") + T.eq(#game.stack.states, 0, "picker popped") + T.eq( + other:submit({ id = 1, revision = stale.revision, kind = "back" }), + nil, + "other facade cannot use departed picker" + ) + game.stack:push(require("src.ui." .. partyClass[version]).new(game, { + onCancel = function() + cancelled = cancelled + 1 + end, + })) + T.check(submit(ui, 2, "back"), "native party back") + T.eq(cancelled, 1, "onCancel delivered") + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local bagSnap = assert(ui:snapshot()) + T.eq(bagSnap.kind, "bag", version .. " bag recognized") + if bagSnap.canPocket then + T.check(submit(ui, 3, "pocket", { direction = 1 }), "native pocket switch") + end + T.check(submit(ui, 4, "back"), "native bag back") + T.eq(#game.stack.states, 0, "bag popped") +end + +-- Walk raw entries, preserving identity and cycles, to detect any gameplay +-- writes from observations and rejected requests (T.same is not cycle-safe). +local function freeze(root) + local graph = {} + local function visit(t) + if type(t) ~= "table" or graph[t] then + return + end + local entry = { mt = getmetatable(t), values = {} } + graph[t] = entry + for k, v in pairs(t) do + entry.values[k] = v + visit(k) + visit(v) + end + end + visit(root) + return function(label) + local same = true + for t, entry in pairs(graph) do + same = same and getmetatable(t) == entry.mt + for k, v in pairs(entry.values) do + same = same and rawget(t, k) == v + end + for k, v in pairs(t) do + same = same and entry.values[k] == v + end + end + T.check(same, label) + end +end +local function refusal(ui, game, intent, message) + local unchanged = freeze(game) + local ok, err = ui:submit(intent) + T.eq(ok, nil, message) + T.check(type(err) == "string", message .. " explains refusal") + unchanged(message .. " leaves game untouched") + return err +end +for _, version in ipairs(versions) do + -- Compare native A/B to the facade for each party submenu and its back path. + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("party snapshot is observational") + T.check(snap ~= nil, "native screen available") + if native then + button(game, party, "a") + else + T.check(submit(ui, 1, "select", { index = 1 }), "open party submenu") + end + local stableSubmenu = freeze(game) + snap = ui:snapshot() + stableSubmenu("submenu snapshot remains observational") + T.eq(snap.kind, "party_actions", "party action rows offered") + local swap + for i, r in ipairs(snap.rows) do + if r.action == "switch" or r.action == "switchOrder" then + swap = i + end + end + T.check(swap ~= nil, "native swap row exposed") + if native then + if GameVersion.VERSIONS[version].generation <= 3 then + party.subIndex = swap + else + party.submenu = swap + end + button(game, party, "a") + party.index = 2 + button(game, party, "a") + else + T.check(submit(ui, 2, "select", { index = swap }), "start swap") + T.check(submit(ui, 3, "select", { index = 2 }), "finish swap") + end + traces[#traces + 1] = { + first = game.save.party[1].species, + second = game.save.party[2].species, + depth = #game.stack.states, + index = party.index, + swapFrom = party.swapFrom, + switchFrom = party.switchFrom, + } + if native then + button(game, party, "b") + else + T.check(submit(ui, 4, "back"), "back after swap") + end + T.eq(#game.stack.states, 0, "native party cancellation") + end + T.same(traces[1], traces[2], version .. " swap matches native A/B") + + local ui, game, other, loader = boot(version) + local party = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack:push(party) + local snap = ui:snapshot() + for _, bad in ipairs({ + false, + 1, + "select", + {}, + { id = 1, revision = snap.revision, kind = "unknown" }, + { id = 1, revision = snap.revision, kind = "select", index = 0 }, + { id = 1, revision = snap.revision, kind = "select", index = 1.5 }, + { id = math.huge, revision = snap.revision, kind = "back" }, + { id = 1, revision = snap.revision, kind = "back", extra = true }, + setmetatable({ id = 1, revision = snap.revision, kind = "back" }, {}), + }) do + refusal(ui, game, bad, "invalid intent") + end + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 999 }, "missing row") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "party cannot advance text") + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "pocket", direction = 1 }, + "party has no pockets" + ) + game.save.party[1].hp = game.save.party[1].hp - 1 + T.eq( + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "HP change stales snapshot"), + "stale UI context", + "stale reason" + ) + snap = ui:snapshot() + local replacement = {} + for k, v in pairs(game.save.party[1]) do + replacement[k] = v + end + game.save.party[1] = replacement + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "back" }, + "identical replacement mon stales snapshot" + ) + -- Different screen identity, even with identical values. + snap = ui:snapshot() + local replacementScreen = require("src.ui." .. partyClass[version]).new(game, {}) + game.stack.states[1] = replacementScreen + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "replacement screen stales snapshot") + party = replacementScreen + -- Whitelist native ownership; never infer an unrelated menu from its rows. + game.stack:push({ game = game, kind = "party", items = { { label = "fake" } } }) + T.eq(ui:snapshot(), nil, "unrelated overlay unsupported") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "covered native menu refused") + game.stack:pop() + for _, flag in ipairs({ "heal", "noInput", "script", "closed" }) do + party[flag] = true + snap = ui:snapshot() + T.eq(snap.locked, true, flag .. " locks public UI") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, flag .. " blocks action") + party[flag] = nil + end + -- Shared battle/UI guard, cross-mod epoch, consumed IDs and partial failures. + snap = ui:snapshot() + Intents.state(game).busy = true + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "in-progress battle/UI work blocked") + T.eq(ui:snapshot().locked, true, "busy snapshot locked") + Intents.state(game).busy = false + snap = ui:snapshot() + Intents.state(game).epoch = Intents.state(game).epoch + 1 + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle work expires UI snapshot") + local calls, during, observed = 0 + party.onCancel = function() + calls = calls + 1 + game.stack:push(party) -- keep an identical screen for the epoch test + during = other:snapshot() + observed = other:submit({ id = 9, revision = during.revision, kind = "back" }) + error("native callback failure") + end + local otherSnap = other:snapshot() + snap = ui:snapshot() + Runtime.currentMod = "probe" + local ok, err = ui:submit({ id = 10, revision = snap.revision, kind = "back" }) + T.eq(ok, nil, "partial native failure reported") + T.check(err:find("intent consumed", 1, true) ~= nil, "partial failure consumes id") + T.eq(Runtime.currentMod, "probe", "engine attribution restored after error") + Runtime.currentMod = nil + T.eq(calls, 1, "callback invoked once") + T.eq(observed, nil, "callback cannot submit reentrantly") + T.eq(during.locked, true, "snapshot during callback is locked") + refusal(other, game, { id = 9, revision = otherSnap.revision, kind = "back" }, "other mod snapshot expired") + refusal(other, game, { id = 9, revision = during.revision, kind = "back" }, "mid-callback snapshot expired") + refusal( + ui, + game, + { id = 10, revision = ui:snapshot().revision, kind = "back" }, + "failed native action cannot replay" + ) + game.stack.states = { require("src.ui." .. partyClass[version]).new(game, {}) } + T.check(submit(ui, 11, "back"), "guard released after failure") + T.eq(loader.apis.probe.ui.insertBefore, require("src.ui.ModUI").insertBefore, "builder helpers retained") + T.neq(ui, other, "per-mod facade identity") +end + +local TextBox = require("src.render.TextBox") +local ChoiceBox = require("src.ui.ChoiceBox") +local function tickUntil(game, box, predicate) + for _ = 1, 1000 do + if predicate() then + return + end + box:update(1 / 60) + end + error("native UI did not reach expected state") +end +for _, version in ipairs(versions) do + local ui, game = boot(version) + local done = 0 + local box = TextBox.new(game, "First\fSecond", function() + done = done + 1 + end) + game.stack:push(box) + local snap = ui:snapshot() + T.eq(snap.kind, "text", "text recognized") + T.eq(snap.locked, true, "typing text locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "advance" }, "typing cannot be skipped") + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.check(submit(ui, 1, "advance"), "manual page advances") + T.check((box.holdFrames or 0) > 0, "page clear hold preserved") + snap = ui:snapshot() + refusal(ui, game, { id = 2, revision = snap.revision, kind = "advance" }, "page hold cannot be skipped") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 2, "advance"), "final text closes") + T.eq(done, 1, "text callback once") + T.eq(game.stack:top(), nil, "text popped") + for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + box = TextBox.new(game, "Ready", nil, opts) + game.stack:push(box) + box.done = true + snap = ui:snapshot() + T.eq(snap.canAdvance, false, "automatic/stay/choice text not manually dismissed") + refusal(ui, game, { id = 3, revision = snap.revision, kind = "advance" }, "script-owned text protected") + game.stack:pop() + end + -- Native text -> choice -> delayed answer -> both boxes popped. + local answers = {} + box = TextBox.new(game, "Question?", nil, { + defaultNo = true, + choice = function(yes) + answers[#answers + 1] = yes + end, + }) + game.stack:push(box) + tickUntil(game, box, function() + return box.done + end) + box:update(1 / 60) + local choice = game.stack:top() + T.eq(getmetatable(choice), ChoiceBox, "native choice appears") + T.eq(ui:snapshot().index, 2, "default NO retained") + T.check(submit(ui, 4, "select", { index = 1 }), "YES accepted") + T.eq(#answers, 0, "answer callback waits for native hold") + T.eq(#game.stack.states, 2, "both boxes retained during answer hold") + snap = ui:snapshot() + refusal(ui, game, { id = 5, revision = snap.revision, kind = "back" }, "pending answer locked") + tickUntil(game, choice, function() + return #answers == 1 + end) + T.eq(answers[1], true, "YES delivered") + T.eq(#game.stack.states, 0, "choice closes original text too") + choice = ChoiceBox.new(game, function(yes) + answers[#answers + 1] = yes + end) + game.stack:push(choice) + T.check(submit(ui, 5, "back"), "B selects NO") + T.eq(choice.index, 2, "NO cursor retained while pending") + tickUntil(game, choice, function() + return #answers == 2 + end) + T.eq(answers[2], false, "NO delivered") +end + +-- Pick an actual item through each native bag. API and physical A/B must +-- deliver the same callback and row, including HGSS pages and BW cursors. +for _, version in ipairs(versions) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = { + id = "POTION", + name = "POTION", + pocket = "ITEM", + fieldPocket = 0, + bwPocket = 0, + fieldUse = 1, + fieldUseFunc = 1, + battlePocket = 4, + } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local picked = {} + local bag = require("src.ui." .. bagClass[version]).new(game, { + pick = true, + onPick = function(id) + picked[#picked + 1] = id + end, + }) + game.stack:push(bag) + local unchanged = freeze(game) + local snap = ui:snapshot() + unchanged("bag snapshot does not rebuild, sort or load graphics") + local itemRow + for i, row in ipairs(snap.rows) do + if row.item == "POTION" then + itemRow = i + end + end + T.check(itemRow ~= nil, version .. " item exposed") + if native then + if bagClass[version] == "HgssBagMenu" then + bag.page, bag.slot = math.floor((itemRow - 1) / 6), (itemRow - 1) % 6 + 1 + elseif bagClass[version] == "Gen5BagMenu" then + bag.cursor = itemRow + else + bag.index = itemRow + if bag.clampScroll then + bag:clampScroll() + end + end + button(game, bag, "a") + else + T.check(submit(ui, 1, "select", { index = itemRow }), "native bag selection accepted") + end + -- GB's bag has no generic pick option: it opens USE/GIVE/TOSS instead. + local top = game.stack:top() + if bagClass[version] == "BagMenu" then + T.eq(ui:snapshot().kind, "bag_actions", "only factory-owned bag action menus offered") + if native then + button(game, top, "b") + else + T.check(submit(ui, 2, "back"), "back from native item submenu") + end + else + T.same(picked, { "POTION" }, "native pick callback exactly once") + end + traces[#traces + 1] = { picked = picked, count = game.save.inventory.POTION, depth = #game.stack.states } + end + T.same(traces[1], traces[2], version .. " bag confirm matches native input") +end +-- Real medicine effect, target picker and HP animation through the public +-- boundary. The native flow owns consumption and its delayed completion. +for _, version in ipairs({ "red", "crystal", "emerald" }) do + local ui, game = boot(version) + game.data.items.POTION = { id = "POTION", name = "POTION", pocket = "ITEM" } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local mon = game.save.party[1] + mon.hp = mon.stats.hp - 25 + local before = mon.hp + local bag = require("src.ui." .. bagClass[version]).new(game, {}) + game.stack:push(bag) + local snap = ui:snapshot() + local slot + for i, r in ipairs(snap.rows) do + if r.item == "POTION" then + slot = i + end + end + T.check(submit(ui, 1, "select", { index = slot }), "select medicine") + snap = ui:snapshot() + if snap.kind == "bag_actions" then + local use + for i, r in ipairs(snap.rows) do + if r.action == "use" or r.label == "USE" then + use = i + end + end + T.check(submit(ui, 2, "select", { index = use }), "native USE action") + end + local picker = game.stack:top() + T.eq(ui:snapshot().kind, "party", "medicine opens native target picker") + T.check(submit(ui, 3, "select", { index = 1 }), "select medicine target") + T.eq(mon.hp, before + 20, "real native potion restores 20 HP") + T.eq(game.save.inventory.POTION, 1, "real native potion consumes once") + T.eq(picker.heal ~= nil, true, "native heal animation retained") + snap = ui:snapshot() + refusal( + ui, + game, + { id = 4, revision = snap.revision, kind = "select", index = 1 }, + "cannot use again during HP fill" + ) + tickUntil(game, picker, function() + return picker.heal == nil + end) + local box = game.stack:top() + T.eq(getmetatable(box), TextBox, "native result text appears after fill") + tickUntil(game, box, function() + return box.done + end) + T.check(submit(ui, 4, "advance"), "native medicine result dismissed") + T.eq(game.save.inventory.POTION, 1, "result dismissal cannot consume again") +end +-- The GBA action grid keeps its blank cells and script input lock. +do + local ui, game = boot("emerald") + local chosen + local menu = require("src.ui.Gen3ItemMenu").new(game, { + entries = { + { label = "USE", kind = "use" }, + { label = "", kind = "blank" }, + { label = "CANCEL", kind = "cancel" }, + }, + onPick = function(kind) + chosen = kind + end, + }) + game.stack:push(menu) + local snap = ui:snapshot() + T.eq(snap.rows[2].enabled, false, "blank grid cell disabled") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "select", index = 2 }, "blank cell refused") + T.check(submit(ui, 1, "select", { index = 1 }), "native item grid confirm") + T.eq(chosen, "use", "native item action callback") +end +-- DS battle bags admit their native delayed press, never call its callback +-- early. Page slots, absent items and return buttons preserve native indices. +for _, version in ipairs({ "platinum", "heartgold", "black", "white" }) do + local traces = {} + for _, native in ipairs({ true, false }) do + local ui, game = boot(version) + game.data.items.POTION = + { id = "POTION", name = "POTION", fieldPocket = 0, battlePocket = 4, battleMask = 4 } + game.save.inventory, game.save.bagOrder = { POTION = 2 }, { "POTION" } + local ds = GameVersion.VERSIONS[version].generation == 5 and "Gen5BattleBag" or "Gen4BattleBag" + local bag = require("src.ui." .. ds).new(game, {}) + game.gen4CursorActive = true + game.stack:push(bag) + local snap = ui:snapshot() + refusal( + ui, + game, + { id = 1, revision = snap.revision, kind = "select", index = 5 }, + "unavailable last-used row" + ) + local function select(id, index) + if native then + bag.cursor.index = index + button(game, bag, "a") + else + T.check(submit(ui, id, "select", { index = index }), "battle bag native press admitted") + end + T.check(bag.press ~= nil, "native press pending") + T.eq(ui:snapshot().locked, true, "pending press locks UI") + tickUntil(game, bag, function() + return bag.press == nil + end) + end + select(1, 1) + T.eq(bag.screen, "pocket", "native pocket opens after animation") + snap = ui:snapshot() + T.eq(snap.rows[1].item, "POTION", "native battle item row") + refusal( + ui, + game, + { id = 2, revision = snap.revision, kind = "select", index = 6 }, + "empty item slot refused" + ) + select(2, 1) + T.eq(bag.screen, "use", "native use question") + select(3, 2) + T.eq(bag.screen, "pocket", "use back returns to pocket") + select(4, 9) + T.eq(bag.screen, "menu", "pocket back returns to categories") + if native then + button(game, bag, "b") + else + T.check(submit(ui, 5, "back"), "battle bag cancel") + end + T.check(bag.press ~= nil, "cancel animation pending") + tickUntil(game, bag, function() + return bag.press == nil + end) + traces[#traces + 1] = { + depth = #game.stack.states, + count = game.save.inventory.POTION, + page = bag.screen, + closed = bag.closed, + } + end + T.same(traces[1], traces[2], version .. " battle bag matches native A/B") +end + +-- opts.auto/stay/choice govern the final page, not the manual paragraph +-- breaks before it. Do not strand a question that spans several pages. +for _, opts in ipairs({ { auto = {} }, { stay = {} }, { choice = function() end } }) do + local ui, game = boot("crystal") + local box = TextBox.new(game, "First\fLast", nil, opts) + game.stack:push(box) + tickUntil(game, box, function() + return box.waiting and (box.preWait or 0) <= 0 + end) + T.eq(ui:snapshot().canAdvance, true, "script tail still allows manual intermediate page") + T.check(submit(ui, 1, "advance"), "intermediate question/automatic/stay page advances") + tickUntil(game, box, function() + return box.done + end) + T.eq(ui:snapshot().canAdvance, false, "final script-owned page stays protected") +end +-- Wrong Game, unknown overlays, script-owned bags and pending controllers. +do + local ui, game = boot("platinum") + local bag = require("src.ui.Gen4BagMenu").new(game, {}) + game.stack:push(bag) + for _, key in ipairs({ "moving", "trash", "script", "noInput" }) do + bag[key] = true + local snap = ui:snapshot() + T.eq(snap.locked, true, "busy bag locked") + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "busy bag refusal") + bag[key] = nil + end + bag.battle = { demo = true } + local snap = ui:snapshot() + refusal(ui, game, { id = 1, revision = snap.revision, kind = "back" }, "battle tutorial owns bag") + bag.battle = nil + bag.game = {} + T.eq(ui:snapshot(), nil, "wrong game screen unavailable") +end +-- Existing helpers remain usable with their original dot-call contract. +do + local ui, _, other = boot("red") + local rows = { { label = "a" }, { label = "c" } } + ui.insertBefore(rows, "c", { label = "b" }) + T.eq(rows[2].label, "b", "legacy builder still works") + T.eq(rawget(other, "snapshot") ~= nil, true, "each mod owns its facade methods") +end + +-- API 1 gets the same additive surface without changing its content format. +for _, version in ipairs({ "red", "emerald", "black" }) do + local ui, game, _, loader = boot(version, 1) + game.stack:push(ChoiceBox.new(game, function() end)) + T.eq(ui:snapshot().kind, "choice", "API 1 UI reader") + T.check(submit(ui, 1, "back"), "API 1 UI action") + if loader.apis.probe.supports then + T.eq(loader.apis.probe:supports("ui", "snapshot"), true, "optional reader capability") + T.eq(loader.apis.probe:supports("ui", "submit"), true, "optional action capability") + T.eq(loader.apis.probe:supports("ui", "unknown"), false, "unknown capability declined") + end +end +-- A real battle opens its picker via mod.battle:submit. Targeting the picker +-- consumes the current UI context, then native callbacks resume the battle. +do + local ui, game, _, loader = boot("crystal") + local BattleState = require("src.battle.BattleState") + local b = BattleState.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.queue, b.menuIndex = "menu", {}, 1 + game.stack:push(b) + local api = loader.apis.probe.battle + local snapshot = api:snapshot() + T.check( + api:submit({ id = 1, revision = snapshot.revision, kind = "menu", choice = "party" }), + "battle opens native party UI" + ) + for _ = 1, 100 do + if game.stack:top() ~= b then + break + end + b:updateQueue() + end + T.eq(ui:snapshot().kind, "party", "battle picker exposed by UI facade") + local oldBattle = api:snapshot() + T.check(submit(ui, 1, "select", { index = 2 }), "choose switching party slot") + T.eq(ui:snapshot().kind, "party_actions", "native battle switch submenu") + local switch + for i, row in ipairs(ui:snapshot().rows) do + if row.action == "battle_switch" then + switch = i + end + end + T.check(submit(ui, 2, "select", { index = switch }), "native switch callback dispatched") + T.eq(game.stack:top(), b, "native battle resumes") + T.check(#b.queue > 0, "native switch queues battle work") + T.eq( + api:submit({ id = 2, revision = oldBattle.revision, kind = "menu", choice = "fight" }), + nil, + "UI work expires battle context" + ) +end + +-- Link/temporary party arrays can differ from save.party. Track the actual +-- native target, including changes made while its action submenu is open. +do + local ui, game = boot("crystal") + local copy = { Pokemon.new(game.data, "FIXMON_C", 15), Pokemon.new(game.data, "FIXMON_B", 12) } + local party = require("src.ui.PartyMenu").new(game, { party = copy }) + game.stack:push(party) + T.check(submit(ui, 1, "select", { index = 1 }), "open temporary party submenu") + local snapshot = ui:snapshot() + copy[1].hp = copy[1].hp - 1 + T.eq( + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party HP invalidates submenu" + ), + "stale UI context", + "actual native party tracked" + ) + snapshot = ui:snapshot() + local replacement = {} + for k, v in pairs(copy[1]) do + replacement[k] = v + end + copy[1] = replacement + refusal( + ui, + game, + { id = 2, revision = snapshot.revision, kind = "back" }, + "temporary party object identity tracked" + ) +end +GameVersion.set(original) +T.finish("UI actions") diff --git a/tests/engine/unified_content.lua b/tests/engine/unified_content.lua new file mode 100644 index 000000000..b7695caa9 --- /dev/null +++ b/tests/engine/unified_content.lua @@ -0,0 +1,470 @@ +-- Synthetic extractor-shaped datasets; all mod changes go through Loader. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Version = require("src.core.GameVersion") +local Manifest = require("src.mods.Manifest") +local Merge = require("src.mods.Merge") +local DELETE = Merge.DELETE +local Registry = require("src.mods.Registry") +local Runtime = require("src.mods.Runtime") +local original = Version.get() +local function raises(fn, fragment) + return T.raises(fn, fragment, "rejects " .. fragment) +end +local versions = { "red", "crystal", "emerald", "platinum", "black" } +local function fixture(gen) + local ds = gen >= 4 + local keys = { mon = ds and 25 or gen == 2 and "SPECIES_025" or "PIKACHU", + into = ds and 26 or gen == 2 and "SPECIES_026" or "RAICHU", + move = ds and 33 or gen == 2 and "MOVE_033" or "TACKLE", + item = ds and 17 or gen == 2 and "ITEM_017" or "POTION", + trainer = ds and 7 or "OPP_PROBE" } + local stats = { hp = 35, attack = 55, defense = 40, speed = 90, special = 50 } + if gen > 1 then stats.spatk, stats.spdef = 50, 60 end + if ds then stats.spAttack, stats.spDefense = 50, 60 end + local mon = { id = keys.mon, name = "Pikachu", dex = 25, types = { "NORMAL" }, + baseStats = stats, catchRate = 190, baseExp = 82, growthRate = "MEDIUM_FAST", + level1Moves = { keys.move }, learnset = { { level = 9, move = keys.move } }, tmhm = { keys.move }, + evolutions = { ds and { method = "level", target = keys.into, param = 20, methodId = 4, paramKind = "level" } + or { method = "LEVEL", species = keys.into, level = 20 } }, + spriteFront = "synthetic-front.png", spriteBack = "synthetic-back.png", frontSize = 7, + customNative = { sentinel = "retained" } } + if gen >= 3 then mon.evYield = { speed = 2 }; mon.abilities = { "STATIC" } end + local into = Merge.deepCopy(mon); into.id, into.name, into.dex, into.evolutions = keys.into, "Raichu", 26, {} + local move = { id = keys.move, name = "Tackle", type = "NORMAL", power = 35, accuracy = 95, pp = 35, + effect = "NO_ADDITIONAL_EFFECT", priority = 0, untouchedTarget = 6 } + if ds then move.class, move.classId, move.typeId, move.gen4Effect = "physical", 0, 0, 0 end + local item = { id = keys.item, name = "Potion", price = 300, tossable = true, heldEffect = 27 } + local party = { { species = keys.mon, level = 12, item = gen > 1 and keys.item or nil, + moves = { keys.move }, customIV = 31 } } + local trainer = { id = keys.trainer, name = "Probe", parties = { party }, baseMoney = 10, aiMods = { 9 } } + if ds then trainer.party = party; trainer.nativeFlag = 17 end + local grass = { rate = 20, slots = { { species = keys.mon, level = 5 }, { species = keys.into, level = 7 } }, + buckets = { 128, 256 } } + if gen == 2 then + grass.byTime = { morn = Merge.deepCopy(grass), nite = Merge.deepCopy(grass) } + grass.byTime.nite.rate = 10 + elseif gen == 3 then + grass.rateMax, grass.bucketSpan, grass.buckets = 180, 100, { 50, 100 } + grass.slots[1].level, grass.slots[1].min, grass.slots[1].max = nil, 4, 6 + end + local data = { pokemon = { [keys.mon] = mon, [keys.into] = into }, moves = { [keys.move] = move }, + items = { [keys.item] = item }, trainers = { [keys.trainer] = trainer }, + encounters = { ROUTE_PROBE = { grass = grass } }, constants = { generation = gen }, audio = {} } + if ds then + data.items.ITEM_017 = item + data.encounters = { [1] = { grassRate = 20, grass = { { species = keys.mon, level = 5, chance = 100 } } } } + end + return data, keys +end +local function run(gen, body, format, extra, supplied, selected) + Version.set(selected or versions[gen]) + local data, keys = fixture(gen) + data = supplied or data + local mods = { probe = { entry = body, format = format or "unified" } } + for id, spec in pairs(extra or {}) do mods[id] = spec end + local loader + local fs = { + read = function(path) + local id = path:match("^mods/([^/]+)/manifest.json$") + local mod = id and mods[id] + if mod then return ('{"id":"%s","name":"Probe","version":"1.0.0","api":2,"entry":"main.lua","content_format":"%s","priority":%d}') + :format(id, mod.format or "unified", mod.priority or 0) end + end, + getDirectoryItems = function(path) + if path ~= "mods" then return {} end + local out = {}; for id in pairs(mods) do out[#out + 1] = id end; return out + end, + getInfo = function(path) + if path:match("main.lua$") or path:match("manifest.json$") then return { type = "file" } end + if path == "mods" or path:match("^mods/[^/]+$") then return { type = "directory" } end + end, + load = function(path) + local id = path:match("^mods/([^/]+)/main.lua$") + if mods[id] then return function(api) return mods[id].entry(api, loader) end end + end, + } + loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() error("content must not load Game") end + loader:load(data) + return loader.apis.probe, loader, data, keys +end +local function ok(value, reason) assert(value, reason) end +local function ops(registry) local n = 0; for _, rows in pairs(registry.ops) do n = n + #rows end; return n end +local function dataOnly(v) + if type(v) == "function" then return "" end + if type(v) ~= "table" then return v end + local out = {}; for k, sub in pairs(v) do out[k] = dataOnly(sub) end; return out +end + +-- Native defaults, manifest rejection and registry numeric-ID confinement. +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 1, + content_format = "unified" }, "mods/bad") end, "unified content requires mod API 2") +raises(function() Manifest.validate({ id = "bad", name = "bad", version = "1", entry = "main.lua", api = 2, + content_format = "unifed" }, "mods/bad") end, "content_format must be native or unified") +raises(function() Registry.new("legacy"):patch(25, {}) end, "id is required") + +local savedLove = love; love = nil +for gen = 1, 5 do + local api, loader, data, keys = run(gen, function(mod) + T.eq(mod.contentFormat, "unified", "manifest selects unified content in entry") + if mod.supports then + T.eq(mod:supports("content", "encounters"), gen <= 3, "host query follows selected content format") + T.eq(mod:supports("hooks", "battle.damage"), true, "other capability surfaces retain host query") + end + local pokemon = assert(mod.content.pokemon:get("PIKACHU")) + T.eq(pokemon.id, "PIKACHU", "canonical species identity") + T.eq(pokemon.level1Moves[1], "TACKLE", "canonical starting move") + T.eq(pokemon.evolutions[1].species, "RAICHU", "canonical evolution target") + T.eq(pokemon.baseStats.special, 50, "canonical Special") + T.eq(pokemon.baseStats.specialDefense, gen > 1 and 60 or nil, "split Special retained") + pokemon.baseStats.hp = 99 + pokemon.native.customNative.sentinel = "local" + T.eq(mod.content.pokemon:get("PIKACHU").baseStats.hp, 35, "read is detached") + local _, why = mod.content.pokemon:patch("PIKACHU", pokemon) + T.check(why and why:find("read%-only"), "native diagnostics cannot be written") + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 70 } })) + ok(mod.content.moves:patch("TACKLE", { power = 45 })) + ok(mod.content.items:patch("POTION", { price = 100 })) + local trainerId = gen >= 4 and "7" or "OPP_PROBE" + local trainer = assert(mod.content.trainers:get(trainerId)) + T.eq(trainer.parties[1][1].species, "PIKACHU", "canonical trainer species") + T.eq(trainer.parties[1][1].moves[1], "TACKLE", "canonical trainer moves") + trainer.parties[1][1].level = 18 + ok(mod.content.trainers:patch(trainerId, { parties = trainer.parties })) + local _, whyMissing = mod.content.moves:patch("ABSENT", { power = 5 }) + T.check(whyMissing ~= nil, "absent patch declines with reason") + local encounter = mod.content.encounters + if gen <= 3 then + T.check(encounter:supports(), "GB/GBA encounter adapter available") + local row = assert(encounter:get("ROUTE_PROBE")) + row.grass.slots[1].species = "RAICHU" + ok(encounter:patch("ROUTE_PROBE", { grass = { slots = row.grass.slots } })) + else + local available, why = encounter:supports() + T.check(available == false and type(why) == "string", "DS encounters explicitly unsupported") + local result, err = encounter:patch("1", { grass = { rate = 10 } }) + T.check(result == nil and type(err) == "string", "DS encounter writes decline safely") + end + end) + T.eq(loader.mods.probe.state, "loaded", versions[gen] .. ": real entry/merge succeeds") + T.eq(#loader.errors, 0, versions[gen] .. ": no cross-reference errors") + T.eq(data.pokemon[keys.mon].baseStats.special, 70, "native Special alias updated") + T.eq(data.pokemon[keys.mon].baseStats.spdef, gen > 1 and 70 or nil, "native Special Defense updated") + T.eq(data.pokemon[keys.mon].customNative.sentinel, "retained", "native extensions survive") + T.eq(data.moves[keys.move].power, 45, "native move power updated") + T.eq(data.moves[keys.move].untouchedTarget, 6, "native move targeting preserved") + T.eq(data.items[keys.item].price, 100, "native item price updated") + T.eq(data.items[keys.item].heldEffect, 27, "native held effect preserved") + T.eq(data.trainers[keys.trainer].parties[1][1].level, 18, "native trainer party updated") + T.eq(data.trainers[keys.trainer].parties[1][1].customIV, 31, "native trainer metadata preserved") + T.eq(api.content.pokemon:get("PIKACHU").baseStats.special, 70, "read works after merge/freeze") + if gen >= 4 then + T.eq(data.items.ITEM_017.price, 100, "DS item string alias updated together with numeric ID") + T.eq(api.content.items:get("POTION").price, 100, "DS alias identity survives merge") + T.same(data.trainers[keys.trainer].party, data.trainers[keys.trainer].parties[1], "DS party aliases stay synchronized") + end + raises(function() api.content.moves:patch("TACKLE", { power = 99 }) end, "frozen") + local count = 0 + for id, row in api.content.items:each() do count = count + 1; T.eq(id, row.id, "iterator canonical ID") end + T.eq(count, 1, "iterator coalesces native aliases") +end +love = savedLove or require("tests.love_stub") + +-- No-op canonical round trips preserve every native field and native format. +for gen = 1, 5 do + local _, baseline = run(gen, function() end, "native") + local _, canonical, data = run(gen, function(mod) + local kinds = { "pokemon", "moves", "items", "trainers" } + if gen <= 3 then kinds[#kinds + 1] = "encounters" end + for _, kind in ipairs(kinds) do + for id, record in mod.content[kind]:each() do ok(mod.content[kind]:override(id, record)) end + end + end) + T.eq(canonical.mods.probe.state, "loaded", "round-trip load succeeds") + T.same(dataOnly(data), dataOnly(baseline.baseData), versions[gen] .. ": full native round-trip parity") +end + +-- Unsupported/malformed operations must append nothing and preserve input. +for gen = 1, 5 do + local _, loader = run(gen, function(mod) + local pokemon = mod.content.pokemon + local before = mod.content.pokemon:get("PIKACHU") + local invalid = { baseStats = { hp = 0 } } + raises(function() pokemon:patch("PIKACHU", invalid) end, "invalid") + T.eq(invalid.baseStats.hp, 0, "invalid caller payload untouched") + raises(function() pokemon:patch("PIKACHU", { base_stats = {} }) end, "unknown") + raises(function() pokemon:patch("PIKACHU", { baseStats = DELETE }) end, "required") + raises(function() pokemon:patch("PIKACHU", { learnset = { [2] = { level = 4, move = "TACKLE" } } }) end, "contiguous") + local cyclic = {}; cyclic.foo = cyclic + raises(function() pokemon:patch("PIKACHU", cyclic) end, "cyclic") + raises(function() mod.content.moves:patch("TACKLE", { power = 0 / 0 }) end, "non-finite") + local result, reason = pokemon:patch("PIKACHU", { level1Moves = { "MISSING" } }) + T.check(result == nil and reason:find("unknown moves ID", 1, true), "unknown cross-generation reference refuses") + T.same(pokemon:get("PIKACHU"), before, "failed operations leave effective record intact") + end) + T.eq(loader.mods.probe.state, "loaded", "caught refusals do not disable mod") + T.eq(ops(loader.content.pokemon), 0, "refused operations leave no Pokemon op log") +end + +-- Empty lists, append/prepend, DELETE and stacking with native mods. +local _, lists, data = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = {} })) + T.eq(#mod.content.pokemon:get("PIKACHU").learnset, 0, "empty list clears immediately") + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __append = { { level = 10, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { learnset = { __prepend = { { level = 5, move = "TACKLE" } } } })) + ok(mod.content.pokemon:patch("PIKACHU", { tmhm = mod.DELETE })) +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, + later = { format = "native", priority = 1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { defense = 75 } }) + end }, +}) +T.eq(lists.mods.probe.state, "loaded", "list and native stacking load") +T.eq(data.pokemon.SPECIES_025.learnset[1].level, 5, "prepend before append") +T.eq(data.pokemon.SPECIES_025.learnset[2].level, 10, "append retains order") +T.eq(#data.pokemon.SPECIES_025.tmhm, 0, "DELETE materializes empty native list") +T.eq(data.pokemon.SPECIES_025.baseStats.attack, 80, "earlier native patch preserved") +T.eq(data.pokemon.SPECIES_025.baseStats.defense, 75, "later native patch preserved") + +-- An entry failure rolls every canonical/native alias operation back. +for gen = 1, 5 do + local _, loader, data, keys = run(gen, function(mod) + ok(mod.content.items:patch("POTION", { price = 1 })) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 100 } })) + error("intentional entry failure") + end) + T.eq(loader.mods.probe.state, "failed", "entry failure attributed") + T.eq(data.items[keys.item].price, 300, "failed item edit rolled back") + T.eq(data.pokemon[keys.mon].baseStats.hp, 35, "failed species edit rolled back") + if gen >= 4 then T.eq(data.items.ITEM_017.price, 300, "failed DS alias edit rolled back") end +end + +-- Native consumers see the result, rather than merely passing schema checks. +local _, nativeLoader, data, keys = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 90, specialDefense = 95 } })) + ok(mod.content.trainers:patch("OPP_PROBE", { parties = { { { species = "RAICHU", level = 18, moves = { "TACKLE" } } } } })) + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 32, + slots = { { species = "RAICHU", level = 8 }, { species = "PIKACHU", level = 9 } } } })) +end) +T.eq(nativeLoader.mods.probe.state, "loaded", "consumer probe loads") +local Pokemon = require("src.pokemon.Pokemon") +local mon = Pokemon.new(data, keys.mon, 10, function(a) return a end) +T.check(mon.stats.spatk ~= nil and mon.stats.spdef ~= nil, "native Pokemon constructor keeps split stat model") +local Encounter = require("src.world.Encounter") +for _, time in ipairs({ "MORNING", "DAY", "NIGHT" }) do + local tableAtTime = Encounter.atTime(data.encounters.ROUTE_PROBE.grass, time) + local encountered = Encounter.rollTable(tableAtTime, function(a) return a end) + T.eq(encountered.species, keys.into, time .. ": native encounter chooses translated species") + T.eq(encountered.level, 8, time .. ": native encounter uses translated level") +end +local Battle = require("src.battle.BattleState") +local game = { data = data, save = { options = {}, player = {}, party = {} } } +local party = Battle.buildTrainerParty(game, keys.trainer, 1, data.trainers[keys.trainer].parties[1]) +T.eq(party[1].species, keys.into, "native trainer builder receives translated species") +T.eq(party[1].level, 18, "native trainer builder receives translated level") + +-- Every registered version selects the same format using its own host +-- generation. The facade captures that generation before entry execution. +for _, version in ipairs(Version.ORDER) do + local gen = Version.generation(version) + local _, loader, data, key = run(gen, function(mod) + T.eq(mod.gameVersion, version, "selected host identity: " .. version) + mod.generation, mod.host.generation = 99, 99 + Version.set("red") + ok(mod.content.moves:patch("TACKLE", { power = 42 })) + end, "unified", nil, nil, version) + T.eq(loader.mods.probe.state, "loaded", version .. ": captured host format works") + T.eq(data.moves[key.move].power, 42, version .. ": same portable patch reaches native data") +end + +-- Canonical IDs are based on this ROM's names, not official numeric IDs. +local hacked = fixture(2) +hacked.pokemon.SPECIES_777 = hacked.pokemon.SPECIES_025 +hacked.pokemon.SPECIES_025 = nil +local _, hackLoader, hackData = run(2, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 65 } })) +end, "unified", nil, hacked) +T.eq(hackLoader.mods.probe.state, "loaded", "relocated ROM-hack species ID resolves") +T.eq(hackData.pokemon.SPECIES_777.baseStats.hp, 65, "no hard-coded National Dex lookup") + +local ambiguous = fixture(2) +ambiguous.pokemon.SPECIES_999 = Merge.deepCopy(ambiguous.pokemon.SPECIES_025) +local _, ambiguity = run(2, function(mod) + local value, why = mod.content.pokemon:get("PIKACHU") + T.check(value == nil and why:find("ambiguous", 1, true), "ambiguous name has a diagnostic") + local changed, reason = mod.content.pokemon:patch("PIKACHU", { catchRate = 1 }) + T.check(changed == nil and reason:find("ambiguous", 1, true), "ambiguous writes cannot choose a record") + raises(function() mod.content.pokemon:each() end, "ambiguous") +end, "unified", nil, ambiguous) +T.eq(ops(ambiguity.content.pokemon), 0, "ambiguity leaves no ops") + +-- New string-key records can be registered without changing legacy schema +-- behavior. Existing numeric records can be patched, not allocated blindly. +for gen = 1, 5 do + local _, loader, data = run(gen, function(mod) + local new = { id = "CUSTOM_ITEM", name = "Custom item", price = 42 } + local saved = Merge.deepCopy(new) + local changed, why = mod.content.items:register("CUSTOM_ITEM", new) + if gen < 4 then + T.check(changed == true, "string-key item registration succeeds") + T.eq(mod.content.items:get("CUSTOM_ITEM").price, 42, "registration immediately readable") + raises(function() mod.content.items:register("CUSTOM_ITEM", new) end, "already registered") + else T.check(changed == nil and why:find("storage", 1, true), "DS allocation declines without a native slot") end + T.same(new, saved, "registration input remains detached") + local payload = { price = 123 } + ok(mod.content.items:patch("POTION", payload)) + payload.price = 999 + T.eq(mod.content.items:get("POTION").price, 123, "later caller mutation cannot edit op log") + local record = assert(mod.content.pokemon:get("PIKACHU")) + local changed, reason = mod.content.pokemon:patch("PIKACHU", { baseStats = { specialAttack = 99 } }) + if gen == 1 then + T.check(changed == nil and reason:find("split", 1, true), "Gen 1 split stat rejected atomically") + T.same(mod.content.pokemon:get("PIKACHU"), record, "rejected split retains native record") + else T.eq(changed, true, "later generation accepts split stat") end + raises(function() mod.content.pokemon:patch("PIKACHU", { types = mod.DELETE }) end, "cannot be deleted") + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { specialDefense = mod.DELETE } }) end, "cannot delete") + end) + T.eq(loader.mods.probe.state, "loaded", "registration/refusal cases survive load") + T.eq(data.items.CUSTOM_ITEM and data.items.CUSTOM_ITEM.price, gen < 4 and 42 or nil, "only supported registrations reach Data") +end + +-- Probability units are uniform (a fraction out of 256) and lossless. +local _, rates, rateData = run(3, function(mod) + local before = mod.content.encounters:get("ROUTE_PROBE") + T.eq(before.grass.rate, 20 * 256 / 180, "GBA rate is projected into common units") + local changed, why = mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 31 } }) + T.check(changed == nil and why:find("representable", 1, true), "unrepresentable GBA rate rejected") + T.same(mod.content.encounters:get("ROUTE_PROBE"), before, "rejected rate leaves slots/weights intact") + ok(mod.content.encounters:patch("ROUTE_PROBE", { grass = { rate = 30 * 256 / 180 } })) +end) +T.eq(rateData.encounters.ROUTE_PROBE.grass.rate, 30, "representable rate maps exactly to native span") + +-- Native DS parser output is edited through the same facade. Its class +-- selector and numeric evolution target remain consistent for real consumers. +local parsed = fixture(4) +local bytes = { 0, 0, 0, 35, 0, 95, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0 } +local chars = {}; for i, byte in ipairs(bytes) do chars[i] = string.char(byte) end +parsed.moves[33] = assert(require("src.import.Gen4Moves").parse(table.concat(chars))) +parsed.moves[33].id, parsed.moves[33].name = 33, "Tackle" +local _, parsedLoader, parsedData = run(4, function(mod) + ok(mod.content.moves:patch("TACKLE", { category = "special", power = 50 })) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = { { method = "LEVEL", level = 15, species = "RAICHU" } } })) +end, "unified", nil, parsed) +T.eq(parsedLoader.mods.probe.state, "loaded", "native parser-produced data accepts canonical edits") +T.eq(parsedData.moves[33].class, "special", "native DS class updated") +T.eq(parsedData.moves[33].classId, 1, "native DS class selector updated") +local Evolution = require("src.pokemon.Evolution") +local target = Evolution.pendingFor({ data = parsedData, save = { options = {} } }, + { species = 25, level = 15 }, { kind = "levelup" }) +T.eq(target, 26, "native evolution consumer follows translated numeric target") + +-- Removing a target retains post-merge reference checks, including numeric +-- trainer references and the otherwise hidden Gen 2 night slot table. +for _, gen in ipairs({ 2, 4 }) do + local _, loader = run(gen, function(mod) ok(mod.content.pokemon:remove("RAICHU")) end) + T.check(#loader.errors > 0, "removed native evolution/encounter target is diagnosed") + T.check(table.concat(loader.errors, "\n"):find("probe", 1, true) ~= nil, "reference failure attributed to removing mod") +end +local _, removal, removedData = run(4, function(mod) + local trainer = mod.content.trainers:get("7") + trainer.parties[1][1].item = nil + ok(mod.content.trainers:patch("7", { parties = trainer.parties })) + ok(mod.content.items:remove("POTION")) + T.eq(mod.content.items:has("POTION"), false, "tombstone immediately hidden in canonical view") +end) +T.eq(#removal.errors, 0, "valid alias removal has no dangling reference") +T.check(removedData.items[17] == nil and removedData.items.ITEM_017 == nil, "numeric/string aliases tombstoned together") + +-- Preferences must still choose original patch operations, not snapshots of +-- earlier mods' changes captured into an adapter's replacement record. +local _, arbitration, arbitrationData = run(2, function(mod, loader) + ok(mod.content.pokemon:patch("PIKACHU", { baseStats = { hp = 75 } })) + loader.content.pokemon:preferOwner("SPECIES_025", "probe") +end, "unified", { + earlier = { format = "native", priority = -1, entry = function(mod) + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 80 } }) + end }, +}) +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.hp, 75, "preferred canonical patch remains") +T.eq(arbitrationData.pokemon.SPECIES_025.baseStats.attack, 55, "set-aside native patch does not leak through a snapshot") + +-- The extractor keeps abbreviated GBA type keys, not the Gen 1 keys. +local types = fixture(3) +types.type_chart = { types = { NORMAL = { category = "physical" }, PSYCHC = { category = "special" } }, matchups = {} } +types.moves.TACKLE.type = "PSYCHC" +local _, typeLoader, typeData = run(3, function(mod) + T.eq(mod.content.moves:get("TACKLE").type, "PSYCHIC_TYPE", "abbreviated GBA type is canonical on read") + ok(mod.content.moves:patch("TACKLE", { type = "PSYCHIC_TYPE" })) + ok(mod.content.pokemon:patch("PIKACHU", { types = { "PSYCHIC_TYPE" } })) +end, "unified", nil, types) +T.eq(#typeLoader.errors, 0, "canonical type resolves to actual native registry") +T.eq(typeData.moves.TACKLE.type, "PSYCHC", "native abbreviated move type retained") +T.eq(typeData.pokemon.PIKACHU.types[1], "PSYCHC", "species type references translated") + +-- A pre-existing unmapped native move selector must not become a dangling +-- registry ID merely because a balance mod changes its power. +local opaque = fixture(4) +opaque.moves[33].effect, opaque.moves[33].gen4Effect = 999, 999 +local _, opaqueLoader, opaqueData = run(4, function(mod) + local record = mod.content.moves:get("TACKLE") + T.check(record.effect == nil and record.native.effect == 999, "opaque selector exposed only in native diagnostics") + ok(mod.content.moves:patch("TACKLE", { power = 65 })) +end, "unified", nil, opaque) +T.eq(#opaqueLoader.errors, 0, "untouched numeric effect selector has no false reference error") +T.eq(opaqueData.moves[33].gen4Effect, 999, "opaque native selector survives patch") + +-- Default/explicit native mode continues to expose live native records. +local default = Manifest.validate({ id = "native", name = "Native", version = "1", entry = "main.lua", api = 2 }, "mods/native") +T.eq(default.content_format, "native", "omitted format remains native") +local _, native = run(2, function(mod) + T.eq(mod.contentFormat, "native", "native manifest uses original facade") + T.check(mod.content.pokemon:get("PIKACHU") == nil, "native facade does not invent aliases") + mod.content.pokemon:patch("SPECIES_025", { baseStats = { attack = 99 } }) +end, "native") +T.eq(native.baseData.pokemon.SPECIES_025.baseStats.attack, 99, "native patch still has original behavior") + +-- Full read/modify/write of one time must not flatten the others to DAY. +local timeData = fixture(2) +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.rate = 5 +timeData.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level = 2 +local _, timeLoader, editedTimes = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.times.NITE.slots[1].level = 11 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end, "unified", nil, timeData) +T.eq(timeLoader.mods.probe.state, "loaded", "time-specific detached edit succeeds") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.rate, 20, "time edit preserves day rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.rate, 5, "time edit preserves distinct morning rate") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.morn.slots[1].level, 2, "time edit preserves distinct morning slots") +T.eq(editedTimes.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 11, "time edit changes only requested night slot") + +local _, mixedTimeLoader, mixedTimeData = run(2, function(mod) + local record = mod.content.encounters:get("ROUTE_PROBE") + record.grass.rate = 30 + record.grass.times.NITE.slots[1].level = 12 + ok(mod.content.encounters:override("ROUTE_PROBE", record)) +end) +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.rate, 30, "inherited night rate cannot undo shared edit") +T.eq(mixedTimeData.encounters.ROUTE_PROBE.grass.byTime.nite.slots[1].level, 12, "explicit night slot edit survives shared rate") + +local _, conflictingStats = run(2, function(mod) + raises(function() mod.content.pokemon:patch("PIKACHU", { baseStats = { special = 50, specialAttack = 80 } }) end, "conflicting") +end) +T.eq(ops(conflictingStats.content.pokemon), 0, "conflicting stat aliases leave no ops") + +-- The unsupported DS writer must not disable native reference auditing. +local seasonal = fixture(5) +seasonal.encounters[1].seasons = { winter = { grass = { { species = 26, level = 8, chance = 100 } } } } +local _, seasonalLoader = run(5, function(mod) + ok(mod.content.pokemon:patch("PIKACHU", { evolutions = {} })) + ok(mod.content.pokemon:remove("RAICHU")) +end, "unified", nil, seasonal) +T.check(table.concat(seasonalLoader.errors, "\n"):find("seasons.winter.grass", 1, true) ~= nil, + "DS seasonal encounter target is included in removal diagnostics") + +Version.set(original) +T.finish("unified content") diff --git a/tests/engine/unified_mod_callbacks.lua b/tests/engine/unified_mod_callbacks.lua new file mode 100644 index 000000000..a4059d663 --- /dev/null +++ b/tests/engine/unified_mod_callbacks.lua @@ -0,0 +1,634 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local Manifest = require("src.mods.Manifest") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Catalog = require("tests.modkit.catalog") +local C = require("src.mods.UnifiedCallbacks") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +local function manifest(format, api) + return { + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + api = api or 2, + callback_format = format, + } +end +T.eq(Manifest.validate(manifest()).callback_format, "native", "existing manifests keep native callbacks") +T.eq(Manifest.validate(manifest("unified")).callback_format, "unified", "opt-in accepted") +T.raises(function() + Manifest.validate(manifest("unified", 1)) +end, "requires mod API 2", "unified requires API 2") +T.raises(function() + Manifest.validate(manifest("other")) +end, "callback_format", "unknown format rejected") +for _, invalid in ipairs({ false, 42, {} }) do + T.raises(function() + Manifest.validate(manifest(invalid)) + end, "callback_format", "malformed format rejected") +end +local function set(names) + local out = {} + for _, n in ipairs(names) do + out[n] = true + end + return out +end +T.same(C.hooks, set(Catalog.hooks()), "every native hook has an explicit boundary decision") +T.same(C.events, set(Catalog.events()), "every native event has an explicit boundary decision") +local function boot(version, format, entry) + Version.set(version) + local numeric = Version.generation() >= 4 + local a, b, m, n, item = + numeric and 501 or "ALPHA", + numeric and 502 or "BETA", + numeric and 601 or "TACKLE", + numeric and 602 or "EMBER", + numeric and 701 or "POTION" + local data = { + pokemon = { + [a] = { + id = a, + name = "Alpha", + baseStats = { hp = 40, attack = 45, defense = 40, speed = 30, special = 35 }, + types = { + "NORMAL", + }, + }, + [b] = { id = b, name = "Beta", baseStats = { hp = 50 }, types = { "NORMAL" } }, + }, + moves = { + [m] = { id = m, name = "Tackle", pp = 35, power = 40 }, + [n] = { + id = n, + name = "Ember", + pp = 25, + power = 40, + }, + }, + items = { [item] = { id = item, name = "Potion" } }, + trainers = {}, + encounters = {}, + } + if numeric then + data.pokemon.SPECIES_501 = data.pokemon[a] + data.moves.MOVE_601 = data.moves[m] + end + local json = require("src.link.Json") + local files = { + ["mods/probe/manifest.json"] = json.encode(manifest(format)), + ["mods/probe/main.lua"] = entry or "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + loader._game = function() + error("callback opt-in must not construct Game") + end + local loaded = loader:load(data) + if not entry then + assert(loaded) + assert(loader.apis.probe, tostring(loader.errors.probe)) + end + return loader.apis.probe, loader, data, { a, b, m, n, item } +end +local function mon(ids) + return { + species = ids[1], + item = ids[5], + level = 10, + hp = 25, + status = "PSN", + stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24, spatk = 24, spdef = 25 }, + moves = { { id = ids[3], pp = 12 } }, + ivs = { hp = 17 }, + personality = 123, + custom = { keep = true }, + } +end +for _, version in ipairs(Version.ORDER) do + local api, loader, data, ids = boot(version, "unified") + local label = version .. ": " + T.eq(api.callbackFormat, "unified", label .. "format exposed before Game construction") + local raw = mon(ids) + if Version.generation() == 1 then + raw.stats.spatk = nil + raw.stats.spdef = nil + end + local unchanged = require("src.mods.Merge").deepCopy(raw) + local noop = api.hooks:wrap("battle.damage", function(next) + return next() + end) + loader.hooks:call("battle.damage", function() + return 5 + end, { user = { mon = raw } }) + T.same(raw, unchanged, label .. "no-op unified callback leaves native record exactly unchanged") + T.eq(raw.isEgg, nil, label .. "false view flag does not materialize native field") + noop() + local originalStats, originalMoves, originalSlot = raw.stats, raw.moves, raw.moves[1] + local battler = + { mon = raw, isPlayer = true, curStats = raw.stats, curMoves = raw.moves, stages = { attack = 0 } } + local battle = { tag = "native handle" } + local count = 0 + local off = api.hooks:wrap("battle.damage", function(next, ctx) + T.eq(ctx.battle, battle, label .. "battle stays native") + T.eq(ctx.user.mon, ctx.target.mon, label .. "shared mon preserves public alias") + T.eq(ctx.user.mon.species, "ALPHA", label .. "canonical species") + T.eq(ctx.user.mon.item, "POTION", label .. "canonical held item") + T.eq(ctx.user.mon.moves[1].id, "TACKLE", label .. "canonical moves") + T.eq(ctx.user.mon.status, "poison", label .. "canonical status") + T.eq(ctx.user.mon.maxHp, 40, label .. "maxHp reads stats.hp") + T.eq(ctx.user.mon.stats.specialAttack, 24, label .. "canonical special attack") + ctx.user.mon.hp = 24 + ctx.user.mon.status = "burn" + ctx.user.mon.nickname = "Test" + ctx.user.mon.stats.attack = 26 + ctx.user.mon.moves[1].id = "EMBER" + ctx.user.mon.moves[1].pp = 20 + local n, hole, last = next() + T.eq(ctx.user.mon.hp, 19, label .. "native downstream changes refreshed") + T.eq(select("#", next()), 3, label .. "cached next retains nil holes") + ctx.user.mon.hp = 18 + return n + 1, hole, last + end) + local n, hole, last = loader.hooks:call("battle.damage", function(ctx) + count = count + 1 + T.eq(ctx.user, battler, label .. "native battler identity restored") + T.eq(ctx.user.mon, raw, label .. "native mon identity restored") + T.eq(raw.hp, 24, label .. "pre-next mutation committed") + T.eq(raw.status, "BRN", label .. "status written in native vocabulary") + T.eq(raw.moves[1].id, ids[4], label .. "move ID restored for host") + raw.hp = 19 + return 5, nil, "tail" + end, { battle = battle, user = battler, target = battler, move = data.moves[ids[3]] }) + T.eq(n, 6, label .. "scalar result postprocessing") + T.eq(last, "tail", label .. "result tail preserved") + T.eq(hole, nil, label .. "nil preserved") + T.eq(count, 1, label .. "vanilla runs once") + T.eq(raw.hp, 18, label .. "post-next edit committed") + T.eq(raw.stats, originalStats, label .. "stats table identity preserved") + T.eq(raw.moves, originalMoves, label .. "moves list identity preserved") + T.eq(raw.moves[1], originalSlot, label .. "move slot identity preserved") + T.eq(raw.personality, 123, label .. "hidden personality retained") + T.eq(raw.ivs.hp, 17, label .. "hidden IVs retained") + off() + + -- Mutations across separate bound records preflight as one transaction. + local other = mon(ids) + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.hp = 4 + ctx.target.mon.level = 101 + return 99 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + return 3 + end, { user = battler, target = { mon = other } }), + 3, + label .. "invalid edit skips wrapper" + ) + T.eq(raw.hp, 18, label .. "earlier record edit rolled back") + T.eq(other.level, 10, label .. "invalid record unchanged") + T.eq(count, 1, label .. "failure before next falls through once") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + local n = next() + ctx.user.mon.hp = -1 + return n + 20 + end) + count = 0 + T.eq( + loader.hooks:call("battle.damage", function() + count = count + 1 + raw.hp = 16 + return 7 + end, { user = battler }), + 7, + label .. "bad post-next edit keeps downstream result" + ) + T.eq(raw.hp, 16, label .. "downstream mutation retained") + T.eq(count, 1, label .. "bad post-next never retries") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + next() + ctx.user.mon.hp = 2 + error("after next") + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 8 + end, { user = battler }), + 8, + label .. "throw after next preserves result" + ) + T.eq(raw.hp, 16, label .. "throw after next discards late edits") + off() + + local sentinel = { error = "vanilla" } + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + pcall(next) + return 999 + end) + count = 0 + local ok, err = pcall(function() + loader.hooks:call("battle.damage", function() + count = count + 1 + error(sentinel) + end, { user = battler }) + end) + T.check(not ok and err == sentinel, label .. "vanilla error identity survives swallowed next failure") + T.eq(count, 1, label .. "throwing vanilla never replayed") + off() + off = api.hooks:wrap("battle.damage", function(next) + pcall(next) + return 999 + end) + local savedMoves = raw.moves + local okBroken, errBroken = pcall(function() + loader.hooks:call("battle.damage", function() + raw.moves = 42 -- a failed native operation can leave an unreadable record + error(sentinel) + end, { user = battler }) + end) + T.check(not okBroken and errBroken == sentinel, label .. "refresh cannot mask a native error") + raw.moves = savedMoves + off() + + -- Event records are detached. The native handles deliberately remain handles. + local seen = 0 + off = api.events:once("battle.exp_gained", function(payload) + seen = seen + 1 + T.eq(payload.mon.species, "ALPHA", label .. "event mon normalized") + payload.mon.hp = 1 + payload.mon.native.ivs.hp = 1 + end) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + loader.events:emit("battle.exp_gained", { mon = raw, battle = battle, gained = 20 }) + T.eq(seen, 1, label .. "once retained") + T.eq(raw.hp, 16, label .. "event mutation detached") + T.eq(raw.ivs.hp, 17, label .. "native diagnostic snapshot detached") + off() + local gift = { species = ids[1], level = 5, ctx = { opaque = true } } + off = api.events:on("pokemon.before_give", function(p) + p.species = "BETA" + p.level = 15 + p.nickname = "Gift" + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "gift ID restored") + T.eq(gift.level, 15, label .. "gift level committed") + T.eq(gift.nickname, "Gift", label .. "gift nickname committed") + off() + off = api.events:on("pokemon.before_give", function(p) + p.species = "ALPHA" + p.level = 101 + end) + loader.events:emit("pokemon.before_give", gift) + T.eq(gift.species, ids[2], label .. "invalid gift atomic species") + T.eq(gift.level, 15, label .. "invalid gift atomic level") + off() + + off = api.hooks:wrap("battle.turn_order", function(next, p, pm, e, em, ctx) + T.eq(pm.id, "TACKLE", label .. "move definition canonical ID") + T.eq(next(), p, label .. "native result maps to original public battler") + return e + end) + local enemy = { mon = other, isPlayer = false } + T.eq( + loader.hooks:call("battle.turn_order", function(p) + return p + end, battler, data.moves[ids[3]], enemy, data.moves[ids[4]], {}), + enemy, + label .. "selected battler restores identity" + ) + off() + off = api.hooks:wrap("encounter.species", function(next, enc) + enc.species = "BETA" + enc.level = 20 + return next() + end) + local encounter = { species = ids[1], level = 10, form = 2 } + T.eq( + loader.hooks:call("encounter.species", function(enc) + return enc + end, encounter, {}), + encounter, + label .. "encounter identity retained" + ) + T.eq(encounter.species, ids[2], label .. "encounter species converted") + T.eq(encounter.form, 2, label .. "encounter hidden field retained") + off() + off = api.hooks:wrap("trainer.party", function(next, cl, index, party) + T.eq(party[1].species, "ALPHA", label .. "trainer slot canonical species") + T.eq(party[1].moves[1], "TACKLE", label .. "trainer slot moves are IDs") + party[1].species = "BETA" + party[1].moves = { "EMBER" } + party[1].level = 20 + return next() + end) + local party = { { species = ids[1], level = 10, moves = { ids[3] }, dvs = { attack = 15 } } } + T.eq( + loader.hooks:call("trainer.party", function(_, _, p) + return p + end, "CLASS", 1, party), + party, + label .. "trainer party native identity" + ) + T.eq(party[1].species, ids[2], label .. "trainer species restored") + T.eq(party[1].moves[1], ids[4], label .. "trainer move restored") + T.eq(party[1].dvs.attack, 15, label .. "trainer hidden DVs retained") + off() + + count = 0 + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + local p = ctx.alive[1] + p.hp = 15 + ctx.applyShare(p, 2, false) + T.eq(p.level, 11, label .. "applyShare refreshes public mon") + p.hp = 14 + end) + loader.hooks:call("battle.exp_award", function() + error("wrapper replaces vanilla") + end, { + battle = battle, + alive = { raw }, + applyShare = function(p, divisor, announce) + count = count + 1 + T.eq(p, raw, label .. "helper gets native identity") + T.eq(p.hp, 15, label .. "helper sees pending edits") + T.eq(divisor, 2, label .. "helper scalar args") + T.eq(announce, false, label .. "helper false arg") + p.level = 11 + end, + }) + T.eq(count, 1, label .. "helper called once") + T.eq(raw.hp, 14, label .. "helper postprocessing committed") + off() + + off = api.hooks:wrap("fieldmove.eligibility", function(next, id) + T.eq(id, "TACKLE", label .. "field move argument normalized") + local p = next() + T.eq(p.species, "ALPHA", label .. "field move result normalized") + return p + end) + T.eq( + loader.hooks:call("fieldmove.eligibility", function(id) + T.eq(id, ids[3], label .. "field move argument restored") + return raw + end, ids[3], {}), + raw, + label .. "field move mon result identity restored" + ) + off() + off = api.hooks:wrap("battle.enemy_action", function(next) + local action = next() + T.eq(action.id, "TACKLE", label .. "enemy action normalized") + action.id = "EMBER" + return action + end) + local action = { id = ids[3], index = 1 } + T.eq( + loader.hooks:call("battle.enemy_action", function() + return action + end, battle, battler), + action, + label .. "enemy action identity restored" + ) + T.eq(action.id, ids[4], label .. "enemy action ID restored") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "BETA", level = 30 } + end) + local enc = loader.hooks:call("encounter.roll", function() + error("suppressed") + end, nil, {}) + T.eq(enc.species, ids[2], label .. "new encounter result native species") + T.eq(enc.level, 30, label .. "new encounter level") + off() + off = api.hooks:wrap("encounter.roll", function() + return { species = "MISSING", level = 30 } + end) + T.eq( + loader.hooks:call("encounter.roll", function() + return "fallback" + end, nil, {}), + "fallback", + label .. "unknown species rejected" + ) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.move.power = 999 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 4 + end, { move = data.moves[ids[3]] }), + 4, + label .. "definition mutation rejected" + ) + T.eq(data.moves[ids[3]].power, 40, label .. "definition unchanged") + off() + off = api.hooks:wrap("battle.damage", function(next, ctx) + ctx.user.mon.maxHp = 50 + ctx.user.mon.stats.specialAttack = 28 + next() + return 0 + end) + loader.hooks:call("battle.damage", function() + T.eq(raw.stats.hp, 50, label .. "maxHp writes native stats.hp") + T.eq(raw.stats.special, 28, label .. "special alias kept") + T.eq( + Version.generation() == 1 and raw.stats.special or raw.stats.spatk, + 28, + label .. "split special stat writes correctly" + ) + end, { user = battler }) + off() + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.native.ivs.hp = 9 + ctx.user.mon.hp = 2 + return 88 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 3 + end, { user = battler }), + 3, + label .. "native snapshot mutation rejected" + ) + T.eq(raw.hp, 14, label .. "snapshot rejection atomic") + off() + local nativeCalls = 0 + local undoNative = loader.hooks:wrap("battle.damage", function(next, ctx) + nativeCalls = nativeCalls + 1 + T.eq(ctx.user, battler, label .. "native mod sees native battler") + return next() + end, -5, "native") + off = api.hooks:wrap("battle.damage", function(next, ctx) + -- Explicit argument packs accept bound records inside a bound context. + ctx.extra = 123 + return next(ctx) + end, 5) + loader.hooks:call("battle.damage", function(ctx) + T.eq(ctx.extra, 123, label .. "explicit next args committed") + end, { user = battler }) + T.eq(nativeCalls, 1, label .. "mixed native/unified chain") + off() + undoNative() + local order = {} + local a = api.events:on("battle.started", function() + order[#order + 1] = "low" + end, -1) + local b = api.events:on("battle.started", function() + order[#order + 1] = "high" + end, 1) + loader.events:emit("battle.started", {}) + T.same(order, { "high", "low" }, label .. "event priority retained") + a() + b() + local actionView + off = api.events:on("battle.turn_started", function(p) + actionView = p.playerAction + end) + loader.events:emit("battle.turn_started", { playerAction = { id = ids[3], index = 1 } }) + T.eq(actionView.id, "TACKLE", label .. "turn event action ID normalized") + off() + + off = api.hooks:wrap("battle.damage", function(_, ctx) + ctx.user.mon.moves = { [2] = { id = "TACKLE", pp = 1, ppUps = 0 } } + ctx.user.mon.hp = 1 + return 99 + end) + T.eq( + loader.hooks:call("battle.damage", function() + return 2 + end, { user = battler }), + 2, + label .. "sparse move list rejected" + ) + T.eq(raw.hp, 14, label .. "sparse move list fails atomically") + off() + local retainedNext, retainedShare + off = api.hooks:wrap("battle.exp_award", function(next, ctx) + retainedNext = next + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() + error("must not run") + end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedNext() + end, "only valid during", label .. "retained next cannot run after callback") + T.raises(function() + retainedShare(raw) + end, "bound Pokemon", label .. "helper rejects native mon") + off() + local heldMon + off = api.hooks:wrap("battle.exp_award", function(_, ctx) + heldMon = ctx.alive[1] + retainedShare = ctx.applyShare + end) + loader.hooks:call("battle.exp_award", function() end, { + alive = { raw }, + applyShare = function() + error("must not run") + end, + }) + T.raises(function() + retainedShare(heldMon) + end, "closed", label .. "retained helper cannot write after callback") + off() + + local custom = { mon = raw } + local got + off = api.events:on("mod.other.ready", function(p) + got = p + end) + loader.events:emit("mod.other.ready", custom) + T.eq(got, custom, label .. "custom payload identity untouched") + off() + T.raises(function() + api.events:emit("mod.other.ready", {}) + end, "only emit", label .. "cannot spoof other mod") + T.raises(function() + api.events:emit("mod.probe.", {}) + end, "only emit", label .. "empty custom suffix rejected") + T.raises(function() + api.hooks:wrap("invented.hook", function() end) + end, "unknown unified", label .. "unknown hook rejected") + T.raises(function() + api.events:on("invented.event", function() end) + end, "unknown unified", label .. "unknown event rejected") +end +-- The native path must not install any wrapper or copy its payloads. +local api, loader = boot("crystal") +T.eq(api.callbackFormat, "native", "default API native") +local raw = { mon = { species = 42 } } +api.events:on("custom.engine", function(p) + T.eq(p, raw, "native event identity unchanged") +end) +loader.events:emit("custom.engine", raw) +api.hooks:wrap("custom.hook", function(next, ctx) + T.eq(ctx, raw, "native hook identity unchanged") + return next() +end) +T.eq( + loader.hooks:call("custom.hook", function(ctx) + return ctx + end, raw), + raw, + "native result identity unchanged" +) +-- Entry rollback removes unified subscriptions using the same native owner. +local _, failed = boot( + "crystal", + "unified", + [[return function(mod) + mod.events:on('battle.started',function() end) + mod.hooks:wrap('battle.damage',function() end) + error('rollback probe') +end]] +) +T.check(#failed.errors > 0, "failed entry attributed") +T.check( + not failed.events.listeners["battle.started"] or #failed.events.listeners["battle.started"] == 0, + "failed entry event removed" +) +T.check( + not failed.hooks.chains["battle.damage"] or #failed.hooks.chains["battle.damage"] == 0, + "failed entry hook removed" +) +Version.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +T.finish() diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index 698c5c9f7..4df8fec72 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -13,22 +13,36 @@ local Json = require("src.link.Json") -- releaseUrl is the fixed public landing page the UI links on needs_full eq(Check.releaseUrl(), - "https://github.com/bryanthaboi/gen1recomp/releases/latest", + "https://github.com/UNDERdecoded/Gen2Recomped/releases/latest", "releaseUrl points at the repo's latest release") -- parseRelease: a well-formed release with the .love payload and its sums local body = Json.encode({ tag_name = "v1.4.2", assets = { - { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, - { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "Gen2Recomped-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "Gen2Recomped-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, }, }) +-- A same-version Gen1 payload is not a fallback for a missing Gen2 asset. +local foreign = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2-macos.zip",browser_download_url="http://x/zip",size=99}, +}})) +check(foreign ~= nil, "a release without a matching game payload still parses") +eq(foreign.payload,nil,"Gen1 and platform archives never substitute for the Gen2 payload") +local competing = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2.love",browser_download_url="http://x/correct",size=123}, +}})) +check(competing.payload ~= nil,"exact Gen2 payload wins among same-version assets") +eq(competing.payload and competing.payload.url,"http://x/correct","foreign game asset is ignored even when listed first") + local rel = Check.parseRelease(body) check(rel ~= nil, "parseRelease accepts a valid release") eq(rel.version, "1.4.2", "leading v stripped from tag_name") -eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version") +eq(rel.payloadName, "Gen2Recomped-1.4.2.love", "payload name derived from version") eq(rel.payload.url, "http://x/love", "payload asset url picked") eq(rel.payload.size, 12345, "payload asset size picked") eq(rel.sums.url, "http://x/sums", "sums asset url picked") @@ -50,13 +64,13 @@ eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected -- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './' -- prefix and CRLF line endings; unrelated lines are skipped local sums = - "aaaa1111 gen1recomp-1.4.2.love\n" .. + "aaaa1111 Gen2Recomped-1.4.2.love\n" .. "BBBB2222 *./sha256sums.txt\r\n" .. "not a checksum line\n" local map = Check.parseSums(sums) -eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "bare-name sum parsed") +eq(map["Gen2Recomped-1.4.2.love"], "aaaa1111", "bare-name sum parsed") eq(map["sha256sums.txt"], "bbbb2222", "* marker and ./ prefix stripped, lowered") -eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") +eq(Check.parseSums(sums, "Gen2Recomped-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash") eq(Check.parseSums(sums, "missing.love"), nil, "targeted lookup misses cleanly") -- pickAsset guards a non-table assets field diff --git a/tests/engine/update_tests.lua b/tests/engine/update_tests.lua index 9bf67171e..d150e43f6 100644 --- a/tests/engine/update_tests.lua +++ b/tests/engine/update_tests.lua @@ -193,11 +193,11 @@ end -- --------------------------------------------------------------------------- local assets = { - { name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, - { name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, + { name = "Gen2Recomped-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 }, + { name = "Gen2Recomped-1.4.2.love", browser_download_url = "http://x/love", size = 12345 }, { name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 }, } -local picked = Check.pickAsset(assets, "gen1recomp-1.4.2.love") +local picked = Check.pickAsset(assets, "Gen2Recomped-1.4.2.love") check(picked ~= nil, "pickAsset finds an asset by exact name") eq(picked.url, "http://x/love", "pickAsset returns the download url") eq(picked.size, 12345, "pickAsset returns the numeric size") @@ -223,10 +223,24 @@ local body = Json.encode({ tag_name = "v1.4.2", assets = assets, }) +-- A same-version Gen1 payload is not a fallback for a missing Gen2 asset. +local foreign = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2-macos.zip",browser_download_url="http://x/zip",size=99}, +}})) +check(foreign ~= nil, "a release without a matching game payload still parses") +eq(foreign.payload,nil,"Gen1 and platform archives never substitute for the Gen2 payload") +local competing = Check.parseRelease(Json.encode({tag_name="v1.4.2", assets={ + {name="gen1recomp-1.4.2.love",browser_download_url="http://x/foreign",size=88}, + {name="Gen2Recomped-1.4.2.love",browser_download_url="http://x/correct",size=123}, +}})) +check(competing.payload ~= nil,"exact Gen2 payload wins among same-version assets") +eq(competing.payload and competing.payload.url,"http://x/correct","foreign game asset is ignored even when listed first") + local rel = Check.parseRelease(body) check(rel ~= nil, "parseRelease accepts a valid release") eq(rel.version, "1.4.2", "parseRelease strips the leading v from tag_name") -eq(rel.payloadName, "gen1recomp-1.4.2.love", "parseRelease derives the payload name from the version") +eq(rel.payloadName, "Gen2Recomped-1.4.2.love", "parseRelease derives the payload name from the version") eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url") eq(rel.payload.size, 12345, "parseRelease picks the payload asset size") eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url") @@ -268,18 +282,18 @@ eq(literal, nil, "parseRelease returns nil on a non-object JSON literal") -- standard " " lines, the '*' binary marker, a './' prefix, CRLF -- endings and mixed hash case; junk lines are skipped local sums = - "aaaa1111 gen1recomp-1.4.2.love\n" .. + "aaaa1111 Gen2Recomped-1.4.2.love\n" .. "BBBB2222 *./sha256sums.txt\r\n" .. "deadBEEF ./nested.love\n" .. "not a checksum line at all\n" local map = Check.parseSums(sums) -eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") +eq(map["Gen2Recomped-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line") eq(map["sha256sums.txt"], "bbbb2222", "parseSums strips the * marker and ./ prefix and lowercases") eq(map["nested.love"], "deadbeef", "parseSums lowercases a mixed-case hash and strips ./") eq(map["not a checksum line at all"], nil, "parseSums skips lines that are not checksums") -- the target form returns just that file's hash (hit / miss) -eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") +eq(Check.parseSums(sums, "Gen2Recomped-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash") eq(Check.parseSums(sums, "missing.love"), nil, "parseSums(target) misses cleanly on an unknown file") -- degenerate inputs: empty text yields an empty map, a targeted miss is nil, @@ -294,7 +308,7 @@ eq(Check.parseSums(nil, "anything"), nil, "parseSums(nil, target) returns nil wi -- --------------------------------------------------------------------------- eq(Check.releaseUrl(), - "https://github.com/bryanthaboi/gen1recomp/releases/latest", + "https://github.com/UNDERdecoded/Gen2Recomped/releases/latest", "releaseUrl points at the repo's latest release") T.finish("update") diff --git a/tests/engine/wide_battle_layout.lua b/tests/engine/wide_battle_layout.lua index 5571af399..dadf125cf 100644 --- a/tests/engine/wide_battle_layout.lua +++ b/tests/engine/wide_battle_layout.lua @@ -38,13 +38,38 @@ T.eq(WideBattle.navigate(1, 4, pressing("right")), 2, T.eq(WideBattle.navigate(1, 4, pressing("a")), nil, "navigate leaves A / B / SELECT to the battle engine") --- animation frames shift as one rigid group toward the side they play on -local px, py = WideBattle.animationOffset({ { x = 24 }, { x = 64 } }) -T.eq(px, 20, "a player-side frame lands on the player anchor") -T.eq(py, 8, "a player-side frame lands on the player baseline") -local ex, ey = WideBattle.animationOffset({ { x = 104 }, { x = 144 } }) -T.eq(ex, 136, "an enemy-side frame lands on the enemy anchor") -T.eq(ey, 0, "an enemy-side frame lands on the enemy baseline") +-- OAM x is the right edge of an 8px sprite. These groups are centered on +-- the authored player (26) and enemy (124) anchors, rather than merely +-- falling somewhere on their half of the 160px screen. +local px, py = WideBattle.animationOffset({ { x = 14 }, { x = 46 } }) +T.eq(px, 20, "the player anchor maps to its wide-layout offset") +T.eq(py, 8, "the player anchor maps to its wide-layout baseline") +local ex, ey = WideBattle.animationOffset({ { x = 112 }, { x = 144 } }) +T.eq(ex, 136, "the enemy anchor maps to its wide-layout offset") +T.eq(ey, 0, "the enemy anchor maps to its wide-layout baseline") + +local function offset(x, wantX, wantY, label) + local dx, dy = WideBattle.animationOffset({ { x = x } }) + T.eq(dx, wantX, label .. " horizontal offset") + T.eq(dy, wantY, label .. " vertical offset") +end +offset(30, 20, 8, "single sprite at the player anchor") +offset(128, 136, 0, "single sprite at the enemy anchor") +offset(79, 78, 4, "midpoint between anchors") +-- Wobble must continue across the anchor instead of sticking at it. +offset(29, 19, 8, "one pixel before the player anchor") +offset(31, 21, 8, "one pixel after the player anchor") +offset(127, 135, 0, "one pixel before the enemy anchor") +offset(129, 137, 0, "one pixel after the enemy anchor") +-- Bounded overshoot lets balls leave the picture without unbounded offsets. +offset(-1000, -3, 10, "far left overshoot") +offset(1000, 159, -2, "far right overshoot") +local rx, ry = WideBattle.animationOffset({ { x = 46 }, { x = 14 } }) +T.eq(rx, px, "sprite ordering does not change the rigid frame offset") +T.eq(ry, py, "sprite ordering does not change the baseline") +local emptyX, emptyY = WideBattle.animationOffset({}) +T.eq(emptyX, 0, "an empty frame has no horizontal offset") +T.eq(emptyY, 0, "an empty frame has no vertical offset") -- the surface: a request outside the bounds falls back to the GB screen, -- and the canvas is only reallocated when the size actually changes diff --git a/tests/engine/world_actions_api.lua b/tests/engine/world_actions_api.lua new file mode 100644 index 000000000..bd029b204 --- /dev/null +++ b/tests/engine/world_actions_api.lua @@ -0,0 +1,582 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Loader = require("src.mods.Loader") +local WorldAPI = require("src.world.WorldAPI") +local Actions = require("src.world.WorldActions") +local GameVersion = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Merge = require("src.mods.Merge") +local Json = require("src.link.Json") +local G4Fly = require("src.world.Gen4Fly") +local Gen3Show = require("src.world.Gen3FieldMove") +local saved = { GameVersion.get(), Runtime.events, Runtime.hooks, Runtime.errors, love, Gen3Show.show } +love = nil +-- Deterministic no-picture native fallback for the cross-version cases. +Gen3Show.show = function() + return false +end +local function boot(version) + GameVersion.set(version) + local gen = GameVersion.generation() + local data = { + pokemon = {}, + moves = {}, + items = {}, + constants = { badges = {} }, + tilesets = { TEST = { blockCells = gen < 3 and 2 or 1 } }, + maps = { + HOME = { + id = "HOME", + width = 20, + height = 20, + tileset = "TEST", + outdoor = true, + mapType = "ROUTE", + allowFly = true, + gen5Flags = 0x2000, + }, + DEST = { id = "DEST", width = 20, height = 20, tileset = "TEST", outdoor = true, mapType = "CITY" }, + }, + field = { flyOrder = { "DEST" }, flyWarps = { DEST = { x = 3, y = 4 } } }, + } + for i = 1, 16 do + data.constants.badges[i] = { id = "BADGE_" .. i } + end + local save = { + party = { + { species = "A", hp = 1, moves = { { id = gen >= 4 and 19 or "FLY" } } }, + { + species = "B", + hp = 0, + moves = {}, + }, + }, + visited = { DEST = true }, + lastHeal = { map = "DEST", x = 3, y = 4 }, + flags = {}, + badges = {}, + } + for i = 1, 16 do + save.badges["BADGE_" .. i] = true + end + if gen == 4 then + data.gen4_version = + { family = (version == "heartgold" or version == "soulsilver") and "hgss" or "platinum" } + local row = { index = 0, firstArrival = 1, fly = { map = "DEST", x = 3, y = 4 } } + data.field.healLocations = { row } + save.flags[G4Fly.rowFlagKey(data, row)] = true + elseif gen == 5 then + data.maps.Z001 = data.maps.DEST + data.gen5_town_map = + { locations = { { zone = 1, flyPoint = true, spawn = { main = true, x = 3, z = 4 } } } } + save.gen5FlyZones = { ["1"] = true } + end + local calls = {} + local ow = { + isOverworld = true, + map = { id = "HOME", def = data.maps.HOME }, + player = { cellX = 2, cellY = 3, facing = "up" }, + scriptMoves = {}, + pendingScripts = {}, + runner = { + isRunning = function() + return false + end, + }, + } + ow.player.facingCell = function(p) + return p.cellX, p.cellY - 1 + end + local game + ow.partyKnows = function(_, move) + if move == "FLY" then + return game.save.party[1] + end + end + ow.flyTo = function(_, id, mon) + calls[#calls + 1] = { kind = "fly", id = id, mon = mon } + ow.flyAnim = {} + end + ow.gen4FlyTo = function(_, dest, mon) + calls[#calls + 1] = { kind = "fly", id = dest.map, x = dest.x, y = dest.y, mon = mon } + ow.flyAnim = {} + end + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + function stack:push(state) + self.states[#self.states + 1] = state + end + function stack:pop() + return table.remove(self.states) + end + game = { data = data, save = save, overworld = ow, stack = stack } + local files = { + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + api = 2, + entry = "main.lua", + }), + ["mods/probe/main.lua"] = "return function(mod) mod.exports.api=mod end", + } + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(files[p], p) + end, + getDirectoryItems = function(p) + return p == "mods" and { "probe" } or {} + end, + getInfo = function(p) + if p == "mods" or p == "mods/probe" then + return { type = "directory" } + end + if files[p] then + return { type = "file" } + end + end, + } + local loader = Loader.new({ fs = fs, dev = false }) + local resolved = 0 + loader._game = function() + resolved = resolved + 1 + return game + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + return loader.apis.probe, game, ow, calls, function() + return resolved + end +end +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow, calls, resolved = boot(version) + local label = version .. ": " + T.eq(resolved(), 0, label .. "building API does not construct Game") + if api.supports then + for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", + }) do + T.eq(api:supports("world", name), true, label .. "capability " .. name) + end + T.eq(resolved(), 0, label .. "capability probes do not construct Game") + end + local world = api.world + local before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), true, label .. "free-roam party can reorder") + T.eq(world:canFly(), true, label .. "native flight checks pass") + T.same(game, before, label .. "availability does not alter save/world") + local p = game.save.party + local a, b = p[1], p[2] + T.eq(world:reorderParty(1, 2), true, label .. "swap accepted") + T.eq(game.save.party, p, label .. "party list identity retained") + T.eq(p[1], b, label .. "fainted party member may move") + T.eq(p[2], a, label .. "Pokemon identity retained") + T.eq(world:reorderParty(1, 2), true, label .. "swap back accepted") + T.eq(world:reorderParty(1, 1), true, label .. "same-slot no-op accepted") + for _, slot in ipairs({ 0, -1, 3, 7, 1.5, "1", math.huge, 0 / 0 }) do + before = Merge.deepCopy(game) + local ok, reason = world:reorderParty(slot, 1) + T.check(ok == nil and reason == "invalid party slot", label .. "invalid slot refused") + T.same(game, before, label .. "bad swap atomic") + end + local locks = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", + } + for _, lock in ipairs(locks) do + ow[lock] = true + before = Merge.deepCopy(game) + T.eq(world:canReorderParty(), false, label .. lock .. " blocks reorder probe") + T.eq(world:canFly(), false, label .. lock .. " blocks fly probe") + T.eq(world:reorderParty(1, 2), nil, label .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. lock .. " blocks flight") + T.eq(world:useFieldAction("surf"), nil, label .. lock .. " blocks field use") + T.same(world:availableFieldActions(), {}, label .. lock .. " suppresses actions") + T.same(game, before, label .. lock .. " refusal leaves game unchanged") + ow[lock] = nil + end + for _, lock in ipairs({ "moving", "inputLocked", "stairExit", "spinning", "fieldMove" }) do + ow.player[lock] = true + before = Merge.deepCopy(game) + T.eq(world:reorderParty(1, 2), nil, label .. "player " .. lock .. " blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "player " .. lock .. " blocks fly") + T.same(game, before, label .. "player " .. lock .. " no mutation") + ow.player[lock] = nil + end + game.stack:push({ isBattle = true }) + T.eq(world:reorderParty(1, 2), nil, label .. "battle/screen above world blocks swap") + T.eq(world:flyTo("DEST"), nil, label .. "battle/screen blocks fly") + game.stack:pop() + ow.runner.isRunning = function() + return true + end + T.eq(world:reorderParty(1, 2), nil, label .. "running VM blocks swap") + ow.runner.isRunning = function() + return false + end + ow.pendingScripts[1] = {} + T.eq(world:flyTo("DEST"), nil, label .. "pending scene blocks flight") + ow.pendingScripts = {} + ow.scriptMoves[1] = {} + T.eq(world:reorderParty(1, 2), nil, label .. "scripted movement blocks swap") + ow.scriptMoves = {} + game.linkSession = {} + T.eq(world:flyTo("DEST"), nil, label .. "link lock blocks flight") + game.linkSession = nil + game.linkNet = { closed = false } + T.eq(world:reorderParty(1, 2), nil, label .. "live link net blocks swap") + game.linkNet = nil + local destination = GameVersion.generation() == 5 and "Z001" or "DEST" + before = Merge.deepCopy(game) + T.eq(world:flyTo("MISSING"), nil, label .. "unknown destination refused") + T.eq(world:flyTo({}), nil, label .. "non-string destination refused") + T.same(game, before, label .. "invalid destinations leave save/world unchanged") + T.eq(#calls, 0, label .. "no gameplay handlers ran on refusal") + T.eq(world:flyTo(destination), true, label .. "flight accepted") + T.eq(#calls, 1, label .. "one native departure") + T.eq(calls[1].id, destination, label .. "native destination selected") + T.eq(calls[1].mon, a, label .. "native carrier identity retained") + T.eq(world:flyTo(destination), nil, label .. "in-flight repeat refused") + T.eq(#calls, 1, label .. "repeat does not replay departure") + ow.flyAnim = nil + a.isEgg = true + T.eq(world:canFly(), false, label .. "egg cannot carry flight") + a.isEgg = nil + if GameVersion.generation() == 4 then + game.save.flags.FLAG_G4_0961 = true + T.eq(world:canFly(), false, label .. "native partner restriction retained") + game.save.flags.FLAG_G4_0961 = nil + if version == "heartgold" or version == "soulsilver" then + game.save.hgssRocketCostume = true + T.eq(world:canFly(), false, label .. "Rocket costume restriction retained") + game.save.hgssRocketCostume = nil + end + end + game.save = nil + T.eq(world:canReorderParty(), false, label .. "no save probe safe") + T.eq(world:reorderParty(1, 2), nil, label .. "no save swap safe") + T.eq(world:canFly(), false, label .. "no save fly probe safe") + T.eq(world:flyTo(destination), nil, label .. "no save fly safe") +end + +-- Exercise the actual native carrier/badge lookup and departure methods, +-- including their input lock and animation/landing state, without graphics. +local OW = require("src.world.OverworldController") +love = {} -- native sound entry points expect a host table, but need no audio device +local gameUpvalue, oldGame +for i = 1, 30 do + local name, value = debug.getupvalue(OW.flyTo, i) + if name == "Game" then + gameUpvalue = i + oldGame = value + break + end +end +assert(gameUpvalue, "native Game binding") +for _, version in ipairs(GameVersion.ORDER) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + ow.partyKnows = OW.partyKnows + ow.flyTo = OW.flyTo + ow.gen4FlyTo = OW.gen4FlyTo + ow.closeToMap = OW.closeToMap + ow.clearBikeFlags = function() end + ow.hasFlyBird = function() + return false + end + ow.syncSurfingPikachu = function() end + local gen = GameVersion.generation() + if gen <= 2 then + game.data.constants.hmBadges = { FLY = { badge = "FLY_ALLOWED" } } + game.save.flags.FLY_ALLOWED = true + elseif gen == 3 then + game.data.constants.gen3FieldMoves = { FLY = { badgeFlag = 0x820 } } + game.save.flags.FLAG_G3_0820 = true + end + local destination = gen == 5 and "Z001" or "DEST" + T.eq(api.world:canFly(), true, version .. " actual native eligibility") + local before = Merge.deepCopy(game) + if gen <= 2 then + game.save.flags.FLY_ALLOWED = nil + elseif gen == 3 then + game.save.flags.FLAG_G3_0820 = nil + elseif gen == 4 then + for k in pairs(game.save.badges) do + game.save.badges[k] = nil + end + end + if gen < 5 then + T.eq(api.world:canFly(), false, version .. " native missing badge refuses flight") + T.eq(api.world:flyTo(destination), nil, version .. " missing badge has no departure") + T.eq(ow.flyAnim, nil, version .. " denied departure has no animation") + end + game.save = Merge.deepCopy(before.save) + if gen <= 3 then + game.save.visited.DEST = nil + elseif gen == 4 then + game.save.flags = {} + else + game.save.gen5FlyZones = {} + end + T.eq(api.world:canFly(), false, version .. " unvisited/locked destinations unavailable") + T.eq(api.world:flyTo(destination), nil, version .. " locked destination refuses native handler") + game.save = Merge.deepCopy(before.save) + -- Validate the landing before any departure-side state is changed. + if gen <= 3 then + game.data.field.flyWarps.DEST.x = -1 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = -1 + else + game.data.gen5_town_map.locations[1].spawn.x = -1 + end + T.eq(api.world:flyTo(destination), nil, version .. " invalid landing refused") + T.eq(ow.flyAnim, nil, version .. " invalid landing leaves animation absent") + if gen <= 3 then + game.data.field.flyWarps.DEST.x = 3 + elseif gen == 4 then + game.data.field.healLocations[1].fly.x = 3 + else + game.data.gen5_town_map.locations[1].spawn.x = 3 + end + game.save.onBike = true + ow.player.surfing = true + T.eq(api.world:flyTo(destination), true, version .. " native departure accepted") + T.eq(ow.flyAnim.frames, 48, version .. " native flight timing preserved") + T.eq(ow.flyDest.map, destination, version .. " native landing map preserved") + T.eq(ow.flyDest.x, 3, version .. " native landing x preserved") + T.eq(ow.flyDest.y, 4, version .. " native landing y preserved") + T.eq(ow.player.inputLocked, true, version .. " native input lock applied") + T.eq(ow.player.surfing, false, version .. " native surfing cleared") + T.eq(game.save.onBike, false, version .. " native bicycle state cleared") +end +for _, version in ipairs({ "red", "gold", "emerald" }) do + local api, game, ow = boot(version) + debug.setupvalue(OW.flyTo, gameUpvalue, game) + local gen = GameVersion.generation() + ow.partyKnows = OW.partyKnows + ow.beginTeleportOut = OW.beginTeleportOut + ow.syncSurfingPikachu = function() end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + game.save.party[1].moves = { { id = "DIG" }, { id = "TELEPORT" } } + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(api.world:useFieldAction("dig"), true, version .. " actual native Dig starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Dig animation timing") + T.eq(ow.teleportOut.escape, gen >= 2 and true or nil, version .. " native Dig escape destination semantics") + T.eq(ow.player.inputLocked, true, version .. " native Dig input lock") + ow.teleportOut = nil + ow.player.inputLocked = false + ow.player.spinning = false + ow.map.def.tileset = "OVERWORLD" + ow.map.def.environment = 1 + T.eq(api.world:useFieldAction("teleport"), true, version .. " actual native Teleport starts") + T.eq(ow.teleportOut.frames, 48, version .. " native Teleport animation timing") + T.eq(ow.teleportOut.escape, nil, version .. " native Teleport uses last heal point") +end +debug.setupvalue(OW.flyTo, gameUpvalue, oldGame) +love = nil + +-- Field dispatch is tested separately from presentation, using native IDs +-- and the exact overworld methods called by the existing party menus. +for _, version in ipairs({ "red", "gold", "emerald", "platinum", "black" }) do + local api, game, ow, calls = boot(version) + local world = api.world + local gen = GameVersion.generation() + local mon = game.save.party[1] + ow.partyKnows = function() + return mon + end + ow.useSurfFieldMove = function() + return "ok" + end + ow.useCutFieldMove = function() + return "ok" + end + ow.trySurf = function(_, x, y, close, carrier) + calls[#calls + 1] = { kind = "surf", carrier = carrier, x = x, y = y } + game.stack:push({ isText = true }) + end + ow.tryCut = function(_, x, y) + calls[#calls + 1] = { kind = "cut", x = x, y = y } + game.stack:push({ isText = true }) + return true + end + ow.beginTeleportOut = function(_, done, opts) + calls[#calls + 1] = { kind = "escape", escape = opts.escape } + ow.teleportOut = {} + end + ow.escapePoint = function() + return { map = "DEST", x = 3, y = 4 } + end + local before = Merge.deepCopy(game) + local list = world:availableFieldActions() + T.same(game, before, version .. " field availability is data-only with native pure gates") + local names = {} + for _, row in ipairs(list) do + names[row.id] = true + end + T.eq(names.surf, gen <= 3 and true or nil, version .. " verified surf coverage") + T.eq(names.cut, gen <= 2 and true or nil, version .. " verified cut coverage") + T.eq(names.teleport, gen <= 3 and true or nil, version .. " verified teleport coverage") + if list[1] then + list[1].id = "MUTATED" + T.check(world:availableFieldActions()[1].id ~= "MUTATED", version .. " action rows detached") + end + T.eq(world:useFieldAction("unknown"), nil, version .. " unknown action refused") + T.eq(world:useFieldAction("surf", { sourceSlot = 1 }), nil, version .. " unsupported options refused") + if gen <= 3 then + T.eq(world:useFieldAction("surf"), true, version .. " native surf started") + T.eq(calls[#calls].carrier, mon, version .. " surf uses validated carrier") + T.eq(world:useFieldAction("surf"), nil, version .. " active dialogue prevents replay") + game.stack:pop() + ow.player.surfing = true + T.eq(world:useFieldAction("surf"), nil, version .. " no invented dismount action") + ow.player.surfing = false + T.eq(world:useFieldAction("teleport"), true, version .. " native Teleport started") + T.eq(calls[#calls].escape, nil, version .. " Teleport keeps heal-point semantics") + ow.teleportOut = nil + ow.map.def.tileset = "CAVERN" + ow.map.def.environment = 4 + ow.map.def.allowEscaping = true + T.eq(world:useFieldAction("dig"), true, version .. " native Dig started") + T.eq( + calls[#calls].escape, + gen >= 2 and true or nil, + version .. " Dig keeps generation-specific destination" + ) + ow.teleportOut = nil + if gen <= 2 then + T.eq(world:useFieldAction("cut"), true, version .. " native Cut started") + game.stack:pop() + end + else + T.eq(world:useFieldAction("surf"), nil, version .. " unsupported script adapter is explicit") + T.eq(#calls, 0, version .. " unsupported field dispatch has no effects") + end +end + +-- Deferred Gen 3 cut-ins own the request until completion; callbacks may be +-- invoked twice or outlive a replaced map/save, but effects must not replay. +local api, game, ow, calls = boot("emerald") +local world = api.world +local finish +Gen3Show.show = function(g, mon, done) + finish = done + g.stack:push({ isCutin = true }) + return true +end +T.eq(world:flyTo("DEST"), true, "cut-in queued") +T.eq(#calls, 0, "no departure before cut-in finishes") +T.eq(world:reorderParty(1, 2), nil, "cut-in blocks competing action") +game.stack:pop() +finish() +finish() +T.eq(#calls, 1, "cut-in completion departs once") +ow.flyAnim = nil +T.eq(world:flyTo("DEST"), true, "second independent request queued") +game.stack:pop() +game.save = Merge.deepCopy(game.save) +finish() +T.eq(#calls, 1, "save replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "cancelled request releases its lock") +T.eq(world:flyTo("DEST"), true, "request can resume after cancellation") +game.stack:pop() +ow.map = { id = "OTHER", def = game.data.maps.HOME } +finish() +T.eq(#calls, 1, "map replacement cancels pending effect") +T.eq(world:canReorderParty(), true, "map cancellation releases lock") +T.eq(world:flyTo("DEST"), true, "abandoned presentation queued") +game.stack:pop() -- simulate native UI being dismissed without completion +T.eq(world:canReorderParty(), true, "abandoned cut-in does not leave a permanent API lock") +finish() +T.eq(#calls, 1, "abandoned callback cannot later run") +Gen3Show.show = function(g, mon, done) + done() + error("after completion") +end +local ok, err = pcall(function() + world:flyTo("DEST") +end) +T.check( + not ok and tostring(err):find("after completion", 1, true) ~= nil, + "native presentation error is propagated" +) +T.eq(#calls, 2, "presentation failure never retries a completed departure") +Gen3Show.show = function() + return false +end +ow.flyAnim = nil +-- Hooks cannot recursively start a second action or move the source unnoticed. +ow.partyKnows = function() + T.eq(world:reorderParty(1, 2), nil, "nested mutation during eligibility is refused") + return game.save.party[1] +end +T.eq(world:canFly(), true, "recursive capability probe terminates") +ow.partyKnows = function() + ow.player.cellX = ow.player.cellX + 1 + return game.save.party[1] +end +T.eq(world:flyTo("DEST"), nil, "eligibility hook changing source position cancels action") +T.eq(#calls, 2, "changed source never reaches native handler") + +local nestedApi, nestedGame, nestedWorld = boot("red") +nestedWorld.flyTo = function() + T.eq(nestedApi.world:reorderParty(1, 2), nil, "native dispatch callback cannot reorder concurrently") + T.eq(nestedApi.world:flyTo("DEST"), nil, "native dispatch callback cannot start a second flight") + nestedWorld.flyAnim = {} +end +T.eq(nestedApi.world:flyTo("DEST"), true, "guarded native dispatch still starts once") +nestedWorld.flyAnim = nil +local nativeError = { native = true } +nestedWorld.flyTo = function() + error(nativeError) +end +local okNative, errNative = pcall(function() + nestedApi.world:flyTo("DEST") +end) +T.check(not okNative and errNative == nativeError, "native error identity retained") +T.eq(nestedApi.world:canReorderParty(), true, "throwing handler releases only the API dispatch lock") + +local empty = WorldAPI.new(nil, "empty") +T.eq(empty:reorderParty(1, 2), nil, "missing game mutation safe") +T.eq(empty:canFly(), false, "missing game probe safe") +T.same(empty:availableFieldActions(), {}, "missing game has no field actions") +for _, name in ipairs({ + "canReorderParty", + "reorderParty", + "availableFieldActions", + "useFieldAction", + "canFly", + "flyTo", +}) do + T.check(Actions.supports(name), "action capability " .. name) +end +T.eq(Actions.supports("party"), false, "reader capability remains separate") +GameVersion.set(saved[1]) +Runtime.install(saved[2], saved[3], saved[4]) +love = saved[5] +Gen3Show.show = saved[6] +T.finish() diff --git a/tests/engine/world_draw_hot.lua b/tests/engine/world_draw_hot.lua new file mode 100644 index 000000000..a80c4a2dc --- /dev/null +++ b/tests/engine/world_draw_hot.lua @@ -0,0 +1,135 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Performance = require("src.core.Performance") +local World = require("src.world.OverworldController") +local Fixture = require("tests.world_draw.fixture") +local originalPcall = pcall +for _, version in ipairs({ "red", "gold", "emerald", "platinum" }) do + Version.set(version) + local f = Fixture.new(32) + local world = f.world + Performance.worldDrawHoist = false + world:drawWorld() + f.reset() + local beforeCalls = 0 + _G.pcall = function(...) + beforeCalls = beforeCalls + 1 + return originalPcall(...) + end + world:drawWorld() + _G.pcall = originalPcall + local before = f.trace() + f.reset() + Performance.worldDrawHoist = true + local afterCalls = 0 + _G.pcall = function(...) + afterCalls = afterCalls + 1 + return originalPcall(...) + end + world:drawWorld() + _G.pcall = originalPcall + T.same(f.trace(), before, version .. " keeps tile/reflection/entity/top-layer/FX draw order and coordinates") + T.check(beforeCalls > afterCalls, version .. " reduces protected calls for the entity pass") + T.eq(afterCalls, 1, version .. " has one protected ordinary entity loop") + local list, cover = world._worldReflectors, world._worldCover + world:drawWorld() + T.eq(world._worldReflectors, list, "reflector list is private and reused") + T.eq(world._worldCover, cover, "parallel cover columns are private and reused") + world.entities = { world.player } + world.ripples = {} + world:drawWorld() + T.eq(list.n, 0, "shrinking frame clears reflector count") + T.eq(list[1], nil, "shrinking frame releases stale entity references") +end +Version.set("red") +local f = Fixture.new(3) +local world = f.world +local retained = {} +world.rippleCells = function(self, out, seen) + retained[#retained + 1] = out + T.check(out ~= self._worldCover, "public replacement never receives a private pool") + return World.rippleCells(self, out, seen) +end +world:drawWorld() +world:drawWorld() +T.check(retained[1] ~= retained[2], "public ripple wrapper keeps fresh cell records") +T.check(retained[1][1] ~= retained[2][1], "public cell pairs remain detached") +local ended = 0 +world.map.renderer.gen4Ground = { + rise = function() + return 0 + end, + billboardDepth = function() + return nil + end, + beginSprites = function() + return true + end, + endSprites = function() + ended = ended + 1 + end, +} +world.player.draw = function() + error("synthetic entity failure") +end +local ok, err = pcall(World.drawWorld, world) +T.eq(ok, false, "entity errors are propagated") +T.check(tostring(err):find("synthetic entity failure", 1, true) ~= nil, "original entity error remains visible") +T.eq(ended, 1, "sprite depth pass closes exactly once on failure") +-- Nested cover drawing must not overwrite the outer borrowed columns. +local nested = false +local visited = {} +world.rippleCells = World.rippleCells +world.map.renderer.drawReflectionCover = function(_, cx, cy) + visited[#visited + 1] = cx .. ":" .. cy + if not nested then + nested = true + world:_worldReflectionCover({}, 0, { drawReflectionCover = function() end }, world.camera, 0) + end +end +world:_worldReflectionCover({ world.entities[2] }, 1, world.map.renderer, world.camera, 0) +T.same( + visited, + { "2:3", "3:3", "2:4", "3:4", "0:1", "1:1", "0:2", "1:2" }, + "nested cover work cannot clobber the outer columns" +) +-- A pipeline may retain its no-argument FX callbacks after a later frame. +local P = require("src.render.Pipelines") +local saved = + { worldPipeline = P.worldPipeline, drawWorld = P.drawWorld, worldPresent = P.worldPresent, level = P.level } +local frames = {} +P.worldPipeline = function() + return "synthetic", {} +end +P.drawWorld = function(_, ctx) + frames[#frames + 1] = ctx + return {} +end +P.worldPresent = function(canvas) + return canvas +end +P.level = function() + return 1 +end +local scene = Fixture.new(2) +scene.world:drawWorld() +scene.world.camera = { x = 67, y = 5 } +scene.world:drawWorld() +T.check(frames[1] ~= frames[2] and frames[1].fx ~= frames[2].fx, "public pipeline frames and FX tables remain detached") +T.check(frames[1].fx.water ~= frames[2].fx.water, "public FX callbacks keep their own frame binding") +local positions = {} +local rectangle = love.graphics.rectangle +love.graphics.rectangle = function(_, x) + positions[#positions + 1] = x +end +frames[1].fx.water() +local oldX = positions[1] +positions = {} +frames[2].fx.water() +T.eq(oldX - positions[1], 64, "retained zero-argument callback keeps the original camera") +love.graphics.rectangle = rectangle +for name, value in pairs(saved) do + P[name] = value +end +T.finish("world draw hot paths") 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/firered_launcher_driver.lua b/tests/firered_launcher_driver.lua index 0c766ccf5..1e6594ac9 100644 --- a/tests/firered_launcher_driver.lua +++ b/tests/firered_launcher_driver.lua @@ -1,15 +1,16 @@ -- Regression: FireRed must be reachable from the launcher as an official --- Gen 3 cartridge, immediately before Emerald. +-- Gen 3 cartridge, immediately before its paired LeafGreen cartridge. return function() local versions = require("src.core.GameVersion") - local fireRedAt, emeraldAt + local fireRedAt, leafGreenAt, emeraldAt for i, id in ipairs(versions.ORDER) do if id == "firered" then fireRedAt = i end + if id == "leafgreen" then leafGreenAt = i end if id == "emerald" then emeraldAt = i end end assert(fireRedAt, "FireRed is missing from the launcher game order") - assert(emeraldAt and fireRedAt + 1 == emeraldAt, - "FireRed must precede Emerald in the launcher game order") + assert(leafGreenAt == fireRedAt + 1 and emeraldAt == leafGreenAt + 1, + "FireRed/LeafGreen must precede Emerald in the launcher game order") assert(versions.info("firered").importable, "FireRed must remain importable from the launcher") print("[driver] PASS FireRed launcher registration") diff --git a/tests/fixtures/upstream_mods/README.md b/tests/fixtures/upstream_mods/README.md new file mode 100644 index 000000000..66c1677bc --- /dev/null +++ b/tests/fixtures/upstream_mods/README.md @@ -0,0 +1,43 @@ +# External compatibility fixtures + +This directory contains an owned synthetic dataset, a loader probe, public +callback names and SHA-256 digests. It contains no reference implementation or +package source. The probe reads unchanged packages from a caller-supplied, +read-only checkout at the commit in `index.json`. + +Run from the repository root: + +``` +python3 tools/check_external_mod_fixtures.py --reference /absolute/reference \ + --lua /absolute/luajit --report .local/qa/mod-fixtures/report.json +``` + +The optional local Python runner requires the QA storage guard and its companion +modules. Supply their directory with `--qa-tools` when they are maintained outside +the checkout. It never falls back to unguarded local execution. + +The command authenticates every file before loading it. It also checks that the +eight shipped gallery packages match the external package bytes. Each package +gets an isolated worker running the production Loader, manifest validation, +registry merge and callback registration. The synthetic pack filesystem and CPU +image objects exercise entrypoints without providing cartridge art or claiming +rendered-image correctness. Writes stay in the worker's memory. + +A missing package, changed file, unknown hook, unloaded entrypoint or worker +failure makes the gate fail. The initial probe deliberately fails on +`mystery_dungeon_follower` at `src.import.pmd.Sprite`; retain that failed report as +the negative control. This is an executable compatibility gap, not a skipped or +successful test. After aliases are available, the requested intermediate +acceptance may name unsupported modules explicitly; complete acceptance requires +all entrypoints to load. ROM extraction is outside this gate. + +The gallery test additionally uses the synthetic dataset whenever no imported +cache is present, so `tests/mod_examples_tests.lua` runs its original effect and +hook assertions in a ROM-free checkout. It validates the legacy API-1 manifest +separately from the retired production example path. No compatibility fixtures +are placed under `mods/`. + +Use `--phase aliases` only for the explicitly defined intermediate acceptance: the +one sprite-interface gap must be reported with its module and affected consumer. +It is recorded as an expected gap, never as a loaded mod. Default complete +acceptance remains red until the entrypoint actually loads. diff --git a/tests/fixtures/upstream_mods/data.lua b/tests/fixtures/upstream_mods/data.lua new file mode 100644 index 000000000..db0b5d609 --- /dev/null +++ b/tests/fixtures/upstream_mods/data.lua @@ -0,0 +1,31 @@ +-- Synthetic data for exercising loader contracts without an imported cartridge. +-- Identifiers match consumer queries; records and art are test-owned stand-ins. +local M = {} +local function copy(value) + if type(value) ~= 'table' then return value end + local out = {}; for k,v in pairs(value) do out[k]=copy(v) end; return out +end +function M.fresh() + local data=require('tests.modkit.fixtures').fresh() + local seed=data.pokemon.FIXMON_A + for index,id in ipairs({'MEW','VENUSAUR','CHARIZARD','BLASTOISE','PIDGEY','RATTATA','SPEAROW','CHARMANDER'}) do + local mon=copy(seed);mon.id=id;mon.name=id;mon.index=index;mon.dex=index + data.pokemon[id]=mon + end + data.pokemon.MEW.dex=151 + for _,id in ipairs({'TACKLE','GROWL','QUICK_ATTACK','CUT','SURF'}) do + data.moves[id]=copy(data.moves.FIX_TACKLE);data.moves[id].id=id + end + data.items.POTION={id='POTION',name='Potion',price=300,tossable=true} + data.items.TM_TOXIC={id='TM_TOXIC',name='TM test',price=4000,machine={kind='TM',move='TACKLE'}} + data.tilesets.OVERWORLD=copy(data.tilesets.FIX_OUT) + for _,id in ipairs({'PALLET_TOWN','OAKS_LAB','ROUTE_1','VIRIDIAN_CITY','PEWTER_CITY'}) do + local map=copy(data.maps.FIX_TOWN);map.id=id;map.tileset='OVERWORLD';map.objects={};map.warps={};map.connections={} + data.maps[id]=map + end + data.encounters.ROUTE_1={grass={rate=10,slots={{level=3,species='PIDGEY'}}}} + data.sprites.SPRITE_RED=copy(data.sprites.SPRITE_FIX_PLAYER) + data.sprites.SPRITE_RED_BIKE=copy(data.sprites.SPRITE_RED) + return data +end +return M diff --git a/tests/fixtures/upstream_mods/index.json b/tests/fixtures/upstream_mods/index.json new file mode 100644 index 000000000..e36106415 --- /dev/null +++ b/tests/fixtures/upstream_mods/index.json @@ -0,0 +1,239 @@ +{ + "reference": "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91", + "fixtures": [ + { + "path": "example_mew_starter", + "files": { + "README.md": "7f2a710b80723aa71c1040d3c00bd2e5d153c0e8d75edcfe088f71da0b8a35de", + "assets/mew_back_inverted.png": "e860dc4ba66efdc3f8d8d1b8fe5c17cb008fdcb57fbd435f15c0e0a2d7b93ec8", + "assets/mew_back_inverted_64.png": "924f6f50eabab8cc2b405ae318b6b9beb033568c461c4bad82692467001201c3", + "assets/mew_front_inverted.png": "a785e64760bdcf790bf1fc6005524ed3352b9ced922275b68ce4a8944cb00e71", + "assets/mew_front_inverted_64.png": "4dedded623ade2ed07c3d9de3fb34d6b9456fc3a56aaeaf812afd55a877240a9", + "main.lua": "d3cda8d3f402e8b36d0546267d0008e071e40f9112847125957898dc85c198b4", + "manifest.json": "835590b79e2bb31fc3666f5e27469b67c7313f83037f284524a7f3f52b3dea0f", + "mod.card": "c342c8e59fb75e52091e7a19ed346c7048672c46414d61e34d828660806f7372" + } + }, + { + "path": "example_lttp_overhaul", + "files": { + "main.lua": "c206fc886986a1ed8daf47a4c1f1ca01cb86ece7224dc5d1601ea84a8f14e1aa", + "manifest.json": "5d43ad9518455facebb3ec9c9230bd856cc9a6cb072a13c531306113bbb9cc69", + "town.lua": "a7688fce525725a65bd4cfccb545972ad87b29e4bc24608eee738a3496a165fb" + } + }, + { + "path": "mystery_dungeon_follower", + "files": { + "README.md": "5e384571580ec21aecdc334d35671bbd45ae1ef77d7d47393d73c21a86343b4f", + "main.lua": "2009b76ba5bcfb7c459adc6de93ecc5a265216c0a45f49174cf4203bb55e65db", + "manifest.json": "15f24259f6f7ce963a34ccdc57c5f89b53ec685b4b9d3a95e7d45c2b5cc12f85" + } + }, + { + "path": "examples/portable_api", + "files": { + "main.lua": "d7920469c0fc6e7908cd0aca84e93e2cc7187df75702d775fb8a86034764a8e8", + "manifest.json": "c054ede45667d98e47a5f1a311271089c291b08e17ac3a847db2653675da9550" + } + }, + { + "path": "examples/gen5_battle_sprites", + "files": { + ".modkitignore": "08c5cc7829fa520a00e4a997e93e17ab886ea5a208b7d29087f6432af372ae81", + "CHANGELOG.md": "1f27fd3d6e9c713a9fe39f48b7c52955b5f1f0e17852ba314be769ae2734c84c", + "README.md": "c8cd82a339c7868401ca72a31cd73dc62ecb41f4cdc6f57870374e29545da4a9", + "main.lua": "1ef5a2d247ad080a424848da25e2049e5734ae3906f2c4aa600dc5e4ae840480", + "manifest.json": "ba05e72bc933548503a9ee4f6518239a467a6adb4843589365851db8da310e1c", + "mod.card": "bfafadaae24328f0f3aa4698377e0901160857d4c6e675f4348c4b893cc9ab6f", + "tests/provider_test.lua": "93a253b06437673b8e3b4d44e4acfd6956c310cdba4030eac10facd10a0cb608" + } + }, + { + "path": "examples/example_balance_tweaks", + "files": { + "CHANGELOG.md": "c2bf004d49a2430aa4a1b2a2cb5775f4e0f5095c028b6873db11d07837a454a1", + "README.md": "2f08f7cd51a4f5ef07a2a7fa94dc6e487d776eca374260fba47cb82b9e6ce2a7", + "main.lua": "7965513d3b57938f048469f6335b62fdfd799662902813517a51d255eb8dfda1", + "manifest.json": "9950d8fdaf8322aa16cefad086ef37f6983a8d81ab555f90fe63664ade20c7bb", + "mod.card": "d4fed4416a71281e7ed8b7ddf4a27122c83a8e63d9cf11827afecbd8ab56df70", + "tests/example_balance_tweaks_test.lua": "3859fe203dc1774c4bff7e928edb604567a8707eb4336ddad629335737dcd1fe" + } + }, + { + "path": "examples/example_dexnav", + "files": { + "CHANGELOG.md": "6ceadfb09065d86e07426d71dfb03e124b463272ba42ad364eed490774e9b843", + "README.md": "910bcca17506755696a21fb72c807a2b113a53e259332f1b9968eb48cbea3344", + "main.lua": "1ba6989142555077a65c6083f5cb691c2b350e4df3721dc710972a32b96290cb", + "manifest.json": "eb0f394097f5f767778521a02378557c283fbb678b932319ea1bb53fddde72df", + "mod.card": "d749d034ec5397b124bc96f7ec6e4a051dda49f03faf5fcad868aa73a97019fb", + "tests/example_dexnav_test.lua": "3f6cf884fcd27ce793153a30409d238fc60f2e6ffdefc5cfd4de6631cc6ca643" + } + }, + { + "path": "examples/example_jukebox", + "files": { + "CHANGELOG.md": "c4e3f09e38ba72888140ee0a27b94453dfa138ab62f40026dbf9c42ceb0a6af3", + "README.md": "417a37a730f80d05b9948b22cb1d842e1e3ada4475fd32be98f7022ef2fa0307", + "main.lua": "d2f153f3aee094d154c6f223cb1c6efaf458de5b3714c642953868537c3711e6", + "manifest.json": "3691e9b41f51733c8449854187285fbc0aa94e836e01739858485b3181243d6c", + "mod.card": "3260bc14e18049ea0a1a4c299e1d80690a02bda8c0b1a292a591a39e82a8772f", + "song.lua": "9ace700345831a45f6a69f158ec21e518923a81cf533ddfca1a39c608e2085dc", + "tests/example_jukebox_test.lua": "e391cc36ab539ea19678f4a6a6d9296bcaf0c2d5de9c33e7bdaccc74a9fb1468" + } + }, + { + "path": "examples/example_lost_parcel", + "files": { + "CHANGELOG.md": "874c37283a2966bf7cc8c84d188c3236923a3c9be1e34405804d5d4bed4e77e3", + "README.md": "03d5a96346f2eb0d4abf7d089993b13d072aedf808bf8efe2dd9c7e699f1c1ba", + "main.lua": "c12780da1f628393beffe164e8fd5b936c3097153a245a9b77b8a146b2d40b72", + "manifest.json": "fbce2a37050a22b8eba63c9b4d2b83ca858a2a3647181f3dc89776bcdee0e77b", + "mod.card": "de071623e46b7399dfc2b4806914800e98c9c87d1cb0e6fb8ec16d16b4268873", + "tests/example_lost_parcel_test.lua": "830c1b568ddb8fc8936c4ddccae0dbe570e84f132394c08bcec922520d627204" + } + }, + { + "path": "examples/example_mini_conversion", + "files": { + "CHANGELOG.md": "5b2859552307c79a6c2c48a77adfa8428894fcc18c2a6d2028e39edad7aeb384", + "README.md": "b72557832443bf0e6a046c4a0f5e09b710051db46f88137ca8e5632dbfb916aa", + "assets/emberkit_back.png": "c43583aa7236ba089a584a17a823ee118d026168a2f924c8a1cfa35bb3156f2e", + "assets/emberkit_front.png": "615300dd5ae8adedffe0d291599d8f2e563cb4a618503590fd1ac7d8ed3b02a7", + "assets/emberkit_icon.png": "de25a5bc5d21c69a11c60302acb1ba80442024e2f524343d9921de6dc3d848ea", + "assets/mossling_back.png": "2c7318680d922f7d4e6faed28c7ada90d0fcc85b99959d4881c0e740d82b1c25", + "assets/mossling_front.png": "dc345925977760209798525e2803f8b8e026001e570b7eeb84ff1e30f55763ec", + "assets/mossling_icon.png": "4dce52410e5b363860c0cbdb89b551716a8edb27a3d884f57c63b6fdfe75ca4c", + "assets/tidepup_back.png": "62f9285c9140cfd4860edc9ac62c4def3a665b1ef71e0b798c7b300245995ca7", + "assets/tidepup_front.png": "d40b73febd54792fab4786ead1647d925e0d9485ccfab8264de0ba0119dc733c", + "assets/tidepup_icon.png": "c9f5fc63e6d6e1e44456e2fa3269baa91f7a6e5c4a7d6707fd58362fc44ff800", + "main.lua": "a6422be98ef0bd836256492f3db56f43320097b6112e843362b203a030f68e04", + "manifest.json": "db5243b57e3a644457499022e70b77f3812f01b9336fd81b8f742dce11d1dcfb", + "mod.card": "f78ab29616d631ea8035c5a755c48794b89443df427be418c1743955e6e966ef", + "tests/example_mini_conversion_test.lua": "141cca215c97b2dcc8daedcf5bc11b5e1ae0d5d5ce3e821b467441724c649b54", + "tools/make_assets.py": "e9ee0d9ce949ee52b2b141e2ac2c82a48f45247a2003b56672f8cd223ffc16ec" + } + }, + { + "path": "examples/example_shiny_palette", + "files": { + "CHANGELOG.md": "bda0e3903af2e0cc65fa6866091da6c6f8625a4a8e42606f9e0f9f11a16d3461", + "README.md": "46bfda385d398fe3f81e655a92686913b1e54f43c820d2929960c3ab2844d650", + "assets/accent_sparkle.png": "bfbc34694108fa6501cc1b1bba1ddcacc5010d3d752290ae473fa33a888a23a2", + "main.lua": "9071e23785aa0c9fcd2fc87df303958573689bf4fa1be939e29b610437549a0e", + "manifest.json": "263f8626e8a875f502251d71edb3908fb8f6d8d833d6257e2c2c7fb66056ad9e", + "mod.card": "5b40433baaf4a5ba82b855f067f8e565ee75a3a9e67879973bc5a94dd97dec60", + "tests/example_shiny_palette_test.lua": "ffba892ce7390f8ec99fdb84fac3e31b797251dee86582933b2d03868b90944c", + "transforms.lua": "66bb07464003969402abe5c0b6fdfe43b20a93f578e06f48b96de75b2f2594eb" + } + }, + { + "path": "examples/example_silly_oak", + "files": { + "CHANGELOG.md": "0077f49b1a8415edaf278e7da0f7ac924af886dbbba92795a22903d3150e9481", + "README.md": "5d3d65f536bd9b5bb195cfc3dda2b2fa7bf8ecac3ead1abd066a802d066b8df1", + "assets/toast_kid.png": "02963b7c5c55e1e80d7ec4eaf274776170dd924ae421f29af14020bc3f32bc7d", + "main.lua": "2f7fbc5c6555f6d20ffa27c20233f0532368cca165a0afa7d393afd75f23de48", + "manifest.json": "cac8e5d240261cca5e6c02ff9e9446a74162200c2534e44589549a29d4a92026", + "mod.card": "0affa0fb359320b5b043731458d59eb92de4330bd4d3913ce9c35496b7ef3539", + "tests/example_silly_oak_test.lua": "3be13592fc90452edcc8c8589ab7f14b5690a5a721b5617871d17d699eea6eeb" + } + }, + { + "path": "examples/example_weather", + "files": { + "CHANGELOG.md": "82e88c93893ae5375a0151c9522cc8b082694bb53af5839f89cc55f9249acd37", + "README.md": "3c3490d87f38f37104ed46d22bdefa7aeed6815ecbb6a69834f9738a81fe7b13", + "main.lua": "469e663f3eaaaa9557253e2a7022e65d332731f438ef532a49a5715f354dd2eb", + "manifest.json": "7ddb5dd1f9c09b19b9cfbf61e37b1df48fa7321c93728495fbe778d00ab6f1d8", + "mod.card": "02a4949b455ccf12a3f00b33e39f8c0327f4544179de59c5c45108d3aae79e46", + "tests/example_weather_test.lua": "77fd2dd1fdc737b65511c6e89bca5b1077c0de27a2254168427f253aef3f0a1f" + } + } + ], + "hooks": [ + "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" + ] +} diff --git a/tests/fixtures/upstream_mods/worker.lua b/tests/fixtures/upstream_mods/worker.lua new file mode 100644 index 000000000..13ad0c942 --- /dev/null +++ b/tests/fixtures/upstream_mods/worker.lua @@ -0,0 +1,76 @@ +-- Read-only external packages; production Loader, manifests, registries and buses. +package.path='./?.lua;./?/init.lua;'..package.path +local reference,fixture,version=assert(arg[1]),assert(arg[2]),arg[3] or 'red' +love=require('tests.love_stub') +local Json=require('src.link.Json') +local Files=require('tests.fs_io').new(reference) +local Writer=require('src.import.LuaWriter') +local I={ + manifestPath=function(importer,pack)return 'asset_packs/'..importer..'/'..pack..'/pack.lua' end, + assetPath=function(importer,pack,file)return 'asset_packs/'..importer..'/'..pack..'/'..file end, +} +local raw=assert(Files.read('mods/'..fixture..'/manifest.json')) +local manifest=assert(Json.decode(raw));local id=manifest.id +local mount='mods/'..id +local overlay={['options.lua']=Writer.encode({mods={[id]=true}})} +local function pack(importer,kind,name,entry) + local p={format=1,importer=importer,pack=kind,kind=kind=='tiles' and 'tileset' or 'sprite',version='1.1.0',source={name='Synthetic fixture',md5=string.rep('0',32)},entries={[name]=entry}} + overlay[I.manifestPath(importer,kind)]=Writer.encode(p) + overlay[I.assetPath(importer,kind,entry.file)]='PNG!' +end +pack('lttp','sprites','player',{file='synthetic.png',size=4,width=16,height=24}) +pack('lttp','tiles','kakariko',{file='synthetic.png',size=4,width=1024,height=1024}) +local pmdMeta={dexNumber=25,monsterId=25,frameWidth=16,frameHeight=24,frameColumns=1, + anchorX=8,anchorY=24,animations={{1,1,1,1,1,1,1,1}},sequences={{{1,2,0,0,0,0,0}}}} +local pmdBytes=Writer.encode(pmdMeta) +pack('pmd_red','sprites','pikachu',{file='synthetic.png',size=4,width=16,height=24,frames=1, + sprite=pmdMeta,metadata={file='synthetic-meta.lua',size=#pmdBytes}}) +overlay[I.assetPath('pmd_red','sprites','synthetic-meta.lua')]=pmdBytes +local function remap(path) + if path==mount then return 'mods/'..fixture end + if path:sub(1,#mount+1)==mount..'/' then return 'mods/'..fixture..path:sub(#mount+1) end + return path +end +local fs={root=reference} +function fs.read(path)if path:match('^assets/generated/')then return nil end;return overlay[path] or Files.read(remap(path))end +function fs.write(path,bytes)overlay[path]=bytes;return true end +function fs.remove(path)overlay[path]=nil;return true end +function fs.rename(a,b)if not overlay[a]then return false end;overlay[b]=overlay[a];overlay[a]=nil;return true end +function fs.createDirectory()return true end +function fs.load(path)local bytes=fs.read(path);if not bytes then return nil,'missing '..path end;return loadstring(bytes,'@'..path)end +function fs.getInfo(path) + if overlay[path]then return {type='file',size=#overlay[path]}end + if path=='mods'then return {type='directory'}end + if path:match('^assets/generated/')then return nil end + return Files.getInfo(remap(path)) +end +function fs.getDirectoryItems(path)if path=='mods'then return {id}end;return Files.getDirectoryItems(remap(path))end +-- CPU image objects exercise the real assembling consumer, without claiming pixels. +love.image={newImageData=function(w,h) + if type(w)=='string'then w,h=1024,1024 end + return {getWidth=function()return w end,getHeight=function()return h end, + getDimensions=function()return w,h end,paste=function()end,setPixel=function()end, + getPixel=function()return 0.5,0.5,0.5,1 end,getString=function()return 'synthetic' end, + release=function()end,encode=function()return {getString=function()return 'PNG!' end}end} +end} +require('src.core.GameVersion').set(version) +local data=require('tests.fixtures.upstream_mods.data').fresh() +local Loader=require('src.mods.Loader') +local loader=Loader.new({fs=fs,dev=false}) +local ok,result=pcall(loader.load,loader,data) +local names,known={},{} +local handle=assert(io.open('tests/fixtures/upstream_mods/index.json','rb')) +local index=assert(Json.decode(handle:read('*a')));handle:close() +for _,name in ipairs(index.hooks)do known[name]=true end +for name in pairs(loader.hooks.chains)do assert(known[name],'unknown hook '..name);names[#names+1]=name end +table.sort(names) +local loaded=ok and result==true and loader.mods[id] and loader.mods[id].state=='loaded' +local errorText=ok and table.concat(loader.errors,'; ') or tostring(result) +-- Execute a registered gift handler, without substituting any loader/API method. +if loaded and id=='example_mew_starter' and version=='red' then + local gift={species='CHARMANDER',level=5,ctx={overworld={map={id='OAKS_LAB'}},save={flags={},party={}}}} + loader.events:emit('pokemon.before_give',gift) + assert(gift.species=='MEW' and gift.level==20,'legacy starter event did not run') +end +print('FIXTURE_RESULT '..Json.encode({fixture=fixture,version=version,loaded=loaded and true or false,hooks=names,error=errorText})) +if not loaded then os.exit(1)end diff --git a/tests/fs_io.lua b/tests/fs_io.lua index 688bb7908..571e18855 100644 --- a/tests/fs_io.lua +++ b/tests/fs_io.lua @@ -99,6 +99,14 @@ function FsIo.new(rootDir) local function abs(path) if path == nil or path == "" then return base end + -- The SDK aliases mods/ to the selected mod directory, which + -- may live outside this checkout. Joining an absolute alias to base + -- turns it into a different, usually missing path (.//home/...) + -- and silently leaves the loader with no mod to test. + if path:sub(1, 1) == "/" + or (FsIo.isWindows and (path:sub(1, 1) == "\\" or path:match("^%a:[/\\]"))) then + return path + end return base .. "/" .. path end diff --git a/tests/gba_layout/connections.lua b/tests/gba_layout/connections.lua new file mode 100644 index 000000000..c1591f04c --- /dev/null +++ b/tests/gba_layout/connections.lua @@ -0,0 +1,56 @@ +-- Identical consumer for the public reference and native compatibility module. +return function(C) + local out, checks = {}, 0 + local function check(ok) checks = checks + 1; assert(ok, "connection check " .. checks) end + local function record(...) + local row = {} + for i = 1, select("#", ...) do row[i] = tostring(select(i, ...)) end + out[#out + 1] = table.concat(row, ":") + end + check(C.cardinal("up") == "north" and C.cardinal("right") == "east") + check(C.cardinal("dive") == nil and C.cardinal("emerge") == nil) + local rows = { + { direction = "up", mapId = "A", offset = "-3" }, + { dir = "north", map = "B", offset = 2 }, + north = { map = "C", offset = 1 }, up = "D", east = { mapId = "E", offset = 0 }, + dive = { map = "UNUSED" }, nonsense = "UNUSED", + } + local normalized = C.each({ connections = rows }) + check(#normalized == 5 and normalized[1].map == "A" and normalized[2].map == "B") + check(normalized[3].map == "E" and normalized[4].map == "C" and normalized[5].map == "D") + check(normalized[1] ~= rows[1] and normalized[1].dir == "north" and normalized[1].offset == -3) + check(rows[1].direction == "up" and rows[1].offset == "-3", "source unchanged") + for _, row in ipairs(normalized) do record(row.dir, row.map, row.offset) end + check(#C.each(nil) == 0 and #C.each({ connections = false }) == 0) + local w, h = C.sizeOf({ width = "3", height = "5" }); check(w == 6 and h == 10) + w, h = C.sizeOf({ width = 3, height = 5, midLayout = { width = 4, height = 6, trueWidth = 3, trueHeight = 5 } }) + check(w == 4 and h == 6, "authored legacy layout extent retained") + local a = { midLayout = { width = 5, height = 6 } } + local b = { width = 2, height = 2 } + local defs = { A = a, B = b, C = a, D = b, E = a } + for _, direction in ipairs({ "up", "down", "left", "right", "north", "south", "west", "east", "dive" }) do + for _, offset in ipairs({ -3, 0, 2 }) do + local canonical = C.cardinal(direction) + local def = { midLayout = { width = 4, height = 3 }, connections = { + { dir = direction, map = "MISSING", offset = offset }, + { dir = direction, map = "A", offset = offset }, + { dir = direction, map = "B", offset = offset + 2 }, + } } + local row = { dir = canonical or direction, map = "A", def = a, offset = offset } + local list = { { dir = row.dir, map = "MISSING", offset = offset }, row, + { dir = row.dir, map = "B", def = b, offset = offset + 2 } } + for y = -5, 9 do for x = -5, 9 do + local incoming, target = C.incoming(def, direction, x, y, function(id) return defs[id] end) + record(direction, offset, x, y, incoming and incoming.map, target == a and "A" or target == b and "B" or nil) + local lx, ly = C.localPos(row, x, y, 4, 3) + record(lx, ly) + local placed, px, py = C.atPos(list, x, y, 4, 3) + record(placed and placed.map, px, py) + check(not placed or placed.def ~= nil) + check(not incoming or incoming.map ~= "MISSING") + end end + end + end + check(C.localPos({ dir = "dive", def = a }, 0, 0, 4, 3) == nil) + return checks, table.concat(out, "\n") +end diff --git a/tests/gba_layout/consumer.lua b/tests/gba_layout/consumer.lua new file mode 100644 index 000000000..5c85a2c6f --- /dev/null +++ b/tests/gba_layout/consumer.lua @@ -0,0 +1,87 @@ +-- One unmodified Gen1 module consumer, also run on the original public host. +return function(Layout) + local checks, signature = 0, {} + local function check(ok, label) + checks = checks + 1; assert(ok, label or ("layout check " .. checks)) + end + local oldView = package.loaded["src.core.game3.field_view"] + local oldAssets = package.loaded["src.render.Assets"] + local events = {} + local view = { invalidateLayoutCell = function(layout, x, y) events[#events + 1] = { layout, x, y } end } + package.loaded["src.core.game3.field_view"] = view + package.loaded["src.render.Assets"] = nil + local base = { { mid = 10, coll = 0, elev = 15 }, { mid = 11, coll = 7, elev = 0 }, { mid = 12 } } + local blob = string.rep("\0", 14) .. string.char(2, 2) .. string.rep("\0", 100) + local decoded = { width = 4, height = 2, trueWidth = 3, trueHeight = 1, + borderWidth = 2, borderHeight = 2, borderMids = { 21, 22, 23, 24 }, cells = base } + local layout = Layout.fromDecoded(decoded, "CUSTOM", "PAIR", blob) + check(layout.cells == base and layout.borderMids == decoded.borderMids, "decoded source retains identity") + check(layout.mapId == "CUSTOM" and layout.pair == "PAIR") + check(layout:cellAt(0, 0) == base[1], "base cells are borrowed") + check(layout:midAt(0, 0) == 10 and layout:collAt(0, 0) == 0 and layout:elevAt(0, 0) == 15) + check(layout:collAt(2, 0) == nil and layout:elevAt(2, 0) == nil, "present partial cells keep missing fields") + for y = -3, 3 do for x = -3, 6 do + local cell = layout:cellAt(x, y) + check(layout:midAt(x, y) == cell.mid, "border/padding mid") + check(layout:collAt(x, y) == cell.coll, "border/padding collision") + check(layout:elevAt(x, y) == cell.elev, "border/padding elevation") + signature[#signature + 1] = tostring(cell.mid) .. ":" .. tostring(cell.coll) .. ":" .. tostring(cell.elev) + end end + local source = layout:workerPacked() + check(source.blob == blob and source.off == 25 and source.width == 4 and source.trueWidth == 3) + check(source.borderMids ~= layout.borderMids and source.borderMids[4] == 24, "worker border snapshot is detached") + layout:applyOverride("0", "0", "99", 0, 0) + check(layout:midAt(0, 0) == 99 and layout:collAt(0, 0) == 0 and layout:elevAt(0, 0) == 0) + check(base[1].mid == 10 and base[1].elev == 15, "override never mutates decoded source") + check(layout._revision == 1 and #events == 1 and events[1][1] == layout) + local packed = layout:workerPacked() + check(packed.overrides[0].mid == 99 and packed.overrides[0] ~= layout.overrides[0]) + layout:applyOverride(0, 0, 100, 1, 2) + check(packed.overrides[0].mid == 99, "published worker snapshot does not follow writes") + local before = layout._revision + check(layout:setMetatiles({ { x = 1, y = 0, mid = 90 }, { x = 1, y = 0, coll = 0 }, { x = 1, y = 0, elev = 15 } }) == 3) + check(layout._revision == before + 1, "one batch revision") + check(layout:midAt(1, 0) == 90 and layout:collAt(1, 0) == 0 and layout:elevAt(1, 0) == 15, "duplicate rows compose in order") + before = layout._revision; check(layout:setMetatiles({}) == 0 and layout._revision == before) + layout.overrides[2] = { mid = 30 } + check(layout:collArray()[3] == 255, "missing source and override collision stays blocked") + layout.overrides[1] = { mid = 31 } + check(layout:collArray()[2] == 7, "partial override falls back to base collision array") + layout:applyOverride(3, 0, 40, 0, 15) + check(layout:cellAt(3, 0).coll == 0 and layout:collArray()[4] == 255, "outside override never admits padded collision") + local originalCellAt = layout.cellAt + layout.cellAt = function() return { mid = 301, coll = 302, elev = 303 } end + check(layout:midAt(-8, -8) == 301 and layout:collAt(-8, -8) == 302 and layout:elevAt(-8, -8) == 303) + check(layout:workerPacked() == nil, "custom sampling disables original packed source") + layout.cellAt = originalCellAt + local midAt = layout.midAt; layout.midAt = function() return 7 end + check(layout:workerPacked() == nil, "custom mid sampler disables packed source") + layout.midAt = midAt + layout.trueWidth = 2; check(layout:workerPacked() == nil); layout.trueWidth = 3 + local cells = layout.cells; layout.cells = {}; check(layout:workerPacked() == nil); layout.cells = cells + package.loaded["src.render.Assets"] = { loader = { overrideOrder = function() return { "custom" } end } } + check(layout:workerPacked() == nil, "asset overrides disable original packed bytes") + package.loaded["src.render.Assets"] = nil + view._nativeDirty = nil; events = {} + local rows = {}; for x = 1, 64 do rows[x] = { x = x, y = 2, mid = x } end + layout:setMetatiles(rows); check(#events == 64 and not view._nativeDirty, "64 writes invalidate individual cells") + rows[65] = { x = 65, y = 2, mid = 65 }; events = {}; layout:setMetatiles(rows) + check(#events == 0 and view._nativeDirty == true, "larger batch requests full redraw") + view._nativeDirty = nil + local stamp = { width = 3, height = 2, trueWidth = 2, trueHeight = 1, + cells = { { mid = 400, coll = 0, elev = 15 }, { mid = 401, coll = 7 }, { mid = 999 } } } + before = layout._revision + check(layout:stamp(stamp, 0, 0) == 2 and layout._revision == before + 1) + check(layout:midAt(0, 0) == 400 and layout:elevAt(0, 0) == 15 and layout:elevAt(1, 0) == 0) + check(stamp.cells[1].mid == 400 and layout.overrides[0] ~= stamp.cells[1], "stamped cells are copied") + check(view._nativeDirty == true) + before = layout._revision; layout:clearOverrides() + check(layout._revision == before + 1 and next(layout.overrides) == nil and layout:midAt(0, 0) == 10) + before = layout._revision; layout:clearOverrides(); check(layout._revision == before, "empty clear preserves revision") + local empty = Layout.fromDecoded({}, "EMPTY") + check(empty.pair == "sevii_outdoor" and empty:collArray() == nil and empty:workerPacked() == nil) + check(empty:cellAt(4, 2).coll == 255 and empty:midAt(-4, -2) == 0) + package.loaded["src.core.game3.field_view"] = oldView + package.loaded["src.render.Assets"] = oldAssets + return checks, table.concat(signature, ",") +end diff --git a/tests/gba_modal/consumer.lua b/tests/gba_modal/consumer.lua new file mode 100644 index 000000000..a68f2b93e --- /dev/null +++ b/tests/gba_modal/consumer.lua @@ -0,0 +1,20 @@ +-- One unchanged consumer runs against both actual native implementations. +return function(T,S) + local out={};local clears=0;S.onClear(function()clears=clears+1 end) + S.clear();T.eq(S.depth(),0,'empty modal depth');T.eq(S.top(),nil,'empty modal top');T.eq(S.busy(),false,'empty modal busy');T.eq(S.pop(),false,'empty pop');T.eq(clears,1,'empty clear dispatches callback') + local first={isMenu=true};local second={};S.push('first',first,{hideBelow=false}) + T.eq(S.top().mod,first,'native widget identity');T.eq(S.top().isMenu,true,'native menu marker');T.eq(S.depth(),1,'first layer depth');T.eq(S.busy(),true,'native modal blocks field') + S.push('second',second) + T.eq(S.depth(),2,'nested native depth');T.eq(S.has('first'),true,'lower native layer exists');T.eq(S.top().mod,second,'top native widget');T.eq(S.top().isMenu,false,'nonmenu marker') + T.eq(#S.drawOrder(),1,'hideBelow default');T.eq(S.drawOrder()[1].id,'second','visible top');T.eq(S.fullscreen(),false,'hideBelow does not imply fullscreen') + local top=S.top();top.drawUnder=true;T.eq(#S.drawOrder(),2,'retained lower layer');top.fullscreen=function()return true end;T.eq(S.fullscreen(),true,'live fullscreen predicate');top.fullscreen=false + local lease=S._layers;S._layers={};T.eq(S.depth(),0,'native preview suspension');T.eq(clears,1,'suspension does not clear native owners');T.eq(#lease,2,'original lease retained') + S.push('preview',{}, {fullscreen=true,isMenu=true});T.eq(S.fullscreen(),true,'preview fullscreen');T.eq(S.pop('preview'),true,'preview closes');S._layers=lease + T.eq(S.top(),top,'restored exact native layer');T.eq(S.top().mod,second,'restored native widget');T.eq(clears,1,'restoration does not clear native owners') + S.push('first',first,{isMenu=false,hideBelow=false});T.eq(S.depth(),2,'reopen replaces same id');T.eq(S.top().isMenu,false,'explicit native menu override') + T.eq(S.pop('second'),true,'targeted native pop');T.eq(S.top().id,'first','targeted pop preserves top');T.eq(S.pop('absent'),false,'missing named pop');T.eq(S.depth(),1,'missing pop leaves depth') + S.clear();T.eq(clears,2,'native clear once');T.eq(S.busy(),false,'native clear retires modal state');T.eq(#S.drawOrder(),0,'clear releases visible list');T.eq(S.fullscreen(),false,'clear releases fullscreen state') + for _,key in ipairs{'push','pop','clear','top','has','busy','fullscreen','drawOrder','depth','onClear'}do T.eq(type(S[key]),'function','native operation '..key)end + out.callbacks=clears;out.finalDepth=S.depth();out.finalBusy=S.busy();out.operations=10 + return out +end diff --git a/tests/gba_modal/worker.lua b/tests/gba_modal/worker.lua new file mode 100644 index 000000000..2d3c2e676 --- /dev/null +++ b/tests/gba_modal/worker.lua @@ -0,0 +1,10 @@ +local engine,backend,consumer,version=assert(arg[1]),assert(arg[2]),assert(arg[3]),assert(arg[4]) +package.path=engine..'/?.lua;'..engine..'/?/init.lua;'..package.path +local T=require('tests.modkit');require('src.core.GameVersion').set(version) +if backend=='gen2'then + local Game=require('src.core.Game');Game.stack=setmetatable({},{__index=require('src.core.StateStack')});Game.stack:init();Game.save={};Game.sessionStartedAt=1 + Game.stack:push{isOverworld=true} +end +local out=assert(loadfile(consumer))()(T,require('src.ui.game3.stack')) +print('GBA_MODAL_CONTRACT '..require('src.link.Json').encode(out)) +T.finish('shared native GBA modal contract') diff --git a/tests/gen3_dex_entry_stride.lua b/tests/gen3_dex_entry_stride.lua new file mode 100644 index 000000000..e2c7413bc --- /dev/null +++ b/tests/gen3_dex_entry_stride.lua @@ -0,0 +1,37 @@ +-- Run locally with a legally supplied cartridge and its native manifest. +-- No ROM data, extracted text, cache or graphics are written by this test. +local romPath,manifestPath=assert(arg[1]),assert(arg[2]) +local function read(path)local f=assert(io.open(path,'rb'));local b=f:read('*a');f:close();return b end +local bytes=read(romPath) +local J=require('src.link.Json');local manifest=J.decode(read(manifestPath)) +local X=require('src.import.RomExtractorGen3') +local stride=manifest.pokedexEntrySize or(manifest.frlgItemMenu and 36 or 32) +local poseAt=stride==36 and 26 or 22 +local base=assert(manifest.symbols.gPokedexEntries) +local function word(at)local a,b=bytes:byte(at+1,at+2);return a+b*256 end +local function signed(at)local w=word(at);return w>=32768 and w-65536 or w end +local function extract(m) + local x=X.new(bytes,'dex-layout-regression',m) + x._pokemon={};for dex=1,386 do x._pokemon[dex]={id='DEX_'..dex,dex=dex}end + x.write=function(self,name,value)assert(name=='pokemon'and value==self._pokemon)end + x:extractDexEntries();return x._pokemon +end +local actual=extract(manifest);local fields=0 +for dex=1,386 do + local row=actual[dex];local at=base+dex*stride + assert(type(row.category)=='string'and #row.category>0,'Missing category at '..dex) + assert(row.height==word(at+12)/10 and row.weight==word(at+14)/10,'Wrong size at '..dex) + local pose=assert(row.dexPose,'Missing dex pose at '..dex) + assert(pose.monScale==word(at+poseAt)and pose.monOffset==signed(at+poseAt+2) + and pose.trainerScale==word(at+poseAt+4)and pose.trainerOffset==signed(at+poseAt+6),'Wrong affine pose at '..dex) + fields=fields+6 +end +local negative=0 +if manifest.pokedexEntrySize==36 and not manifest.frlgItemMenu then + local legacy={};for k,v in pairs(manifest)do legacy[k]=v end;legacy.pokedexEntrySize=nil + local wrong=extract(legacy) + for dex=1,386 do if wrong[dex].height~=actual[dex].height or wrong[dex].weight~=actual[dex].weight then negative=negative+1 end end + assert(negative>300,'Legacy 32-byte negative control did not reproduce corruption') + assert(actual[129].height==.9 and actual[321].height==14.5,'Measured cartridge sentinel sizes changed') +end +print('PASS_GEN3_DEX_LAYOUT',stride,386,fields,'raw size/pose fields; legacy corrupt species',negative) diff --git a/tests/gen3_move_learn_hm_return.lua b/tests/gen3_move_learn_hm_return.lua new file mode 100644 index 000000000..019027bc7 --- /dev/null +++ b/tests/gen3_move_learn_hm_return.lua @@ -0,0 +1,33 @@ +-- Actual native menu methods and HM-set reader; presentation is stubbed. +-- Rendered native controller/input follow-ups are separate evidence. +local function native(path,modules) + local f=assert(loadfile(path));setfenv(f,setmetatable({require=function(n)return assert(modules[n],n)end},{__index=_G}));return f() +end +local Strings=setmetatable({source=function(s)return s end},{__call=function(_,s,...)return s:format(...)end}) +local modules={['src.render.Font']={},['src.core.Strings']=Strings} +modules['src.ui.MoveLearnMenu']=native('src/ui/MoveLearnMenu.lua',modules) +modules['src.render.TextBox']={new=function(game,text,done)return{game=game,text=text,onDone=done}end} +local Learn=native('src/ui/Gen3MoveLearnMenu.lua',modules) +for _,case in ipairs{'summary','fallback','list','retired'}do + local stack={states={}};function stack:push(s)self.states[#self.states+1]=s end + function stack:pop()return table.remove(self.states)end;function stack:top()return self.states[#self.states]end + local Game={data={constants={machines={{kind='HM',move='CUT'},{kind='TM',move='GROWL'}}}},stack=stack} + local cut={id='CUT',pp=13};local mon={moves={cut,{id='GROWL',pp=20}}} + local screen=Learn.new(Game,mon,'EMBER');stack:push(screen) + screen.selecting=case=='list';local asks=0 + screen.askOnSummary=function() + asks=asks+1 + if case=='fallback'then return false end + stack:push({summary=true});return true + end + assert(screen:forget(1)==false and mon.moves[1]==cut and cut.pp==13) + local box=stack:pop();assert(type(box.onDone)=='function','HM refusal has no return callback') + if case=='retired'then stack:pop();stack:push({unrelated=true})end + box.onDone() + assert(mon.moves[1]==cut and cut.pp==13,'HM refusal mutated native moves/PP') + if case=='summary'then assert(asks==1 and stack:top().summary) + elseif case=='fallback'then assert(asks==1 and screen.selecting and stack:top()==screen) + elseif case=='list'then assert(asks==0 and screen.selecting and stack:top()==screen) + else assert(asks==0 and stack:top().unrelated)end +end +print('PASS_NATIVE_GEN3_HM_RETURN actual menu/HM reader; chooser reopen; unavailable-summary list; existing list; retired screen; no PP/move mutation. Presentation stub only.') diff --git a/tests/gen3_rs_ball_anims.lua b/tests/gen3_rs_ball_anims.lua new file mode 100644 index 000000000..ba26f622e --- /dev/null +++ b/tests/gen3_rs_ball_anims.lua @@ -0,0 +1,47 @@ +-- Actual ROM extractor and native ball object; no generated cache/art is saved. +local function read(path)local f=assert(io.open(path,'rb'));local s=f:read('*a');f:close();return s end +love={image={newImageData=function(w,h) + local image={w=w,h=h,pixels={}} + function image:getWidth()return self.w end;function image:getHeight()return self.h end + function image:setPixel(x,y,r,g,b,a)assert(x>=0 and x=0 and y0 then visible=visible+1 end end + assert(visible>100,'Actual native art empty: '..name) +end +local timing=assert(actual.timing.sendOut) +assert(timing.from.x==24 and timing.from.y==68 and timing.frames==25 and timing.arc==-30 and timing.riseStep==-288) +assert(timing.targetOffsetY==24 and timing.foeOffsetY==24 and timing.foeWait==16 and timing.fadeFrames==14) +assert(actual.sounds.caught==371 and actual.sounds.click==nil,'RS caught sound inherited another edition') +local Ball=require('src.battle.Gen3BallAnim') +for _,side in ipairs{'player','opponent'}do + local anim=Ball.new({constants={gen3BallAnim=actual}},{sendOut=side,to={x=176,y=40}}) + assert(anim:estimate()==(side=='player'and 39 or 30)) + for frame=1,anim:estimate()do anim:update();assert(anim.frame==frame)end + assert(anim:isDone()and anim.monScale==1 and not anim.monHidden) +end +local function clone()return Json.decode(Json.encode(manifest))end +local bad=clone();bad.stageOverrides.BALL_ANIM.SEND.AT_HAND_X=bad.stageOverrides.BALL_ANIM.SEND.AT_HAND_X+2 +local rejected=assert(extract(bad));assert(not rejected.timing.sendOut,'Invalid Thumb instruction was accepted as send-out') +bad=clone();bad.stageOverrides.BALL_ANIM.SHEETS=bad.stageOverrides.BALL_ANIM.SHEETS+8 +assert(not extract(bad),'Shifted sheet/tag table was accepted') +bad=clone();bad.stageOverrides.BALL_ANIM=nil;assert(not extract(bad),'Legacy RS missing-art negative did not reproduce') +X.new(bytes,'restore-pristine-regression',{}) +assert(X.BALL_ANIM.SHEETS==0x32C400 and X.BALL_ANIM.SOURCE==nil and X.BALL_ANIM.SOUNDS.caught==531 and X.BALL_ANIM.SOUNDS.click==254,'RS overrides leaked into later imports') +print('PASS_RS_BALL_ANIMS',manifest.version,'actual 12x3 ball atlas, native particles/send-out, RS sounds, invalid-site/table/legacy negatives, pristine restore') diff --git a/tests/gen3_rs_ball_pngs.py b/tests/gen3_rs_ball_pngs.py new file mode 100644 index 000000000..0d39d13a6 --- /dev/null +++ b/tests/gen3_rs_ball_pngs.py @@ -0,0 +1,39 @@ +"""Independent RGBA check of fresh native RS PNGs against raw cartridge tiles. + +Requires Pillow for PNG decoding. Prints only numeric/hash evidence; no art is +written. Usage: python tests/gen3_rs_ball_pngs.py ROM manifest.json QA_PROFILE. +""" +from pathlib import Path +import hashlib,json,re,struct,sys +from PIL import Image +sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools')) +from gen3_discover import Rom,lz77_at +rom=Path(sys.argv[1]).read_bytes();manifest=json.loads(Path(sys.argv[2]).read_text());profile=Path(sys.argv[3]) +assert manifest['version']in ('ruby','sapphire') +assert re.fullmatch(r'gen2recomp-qa-(?:ruby|sapphire)-[0-9]+',profile.name) +reader=Rom(rom);b=manifest['stageOverrides']['BALL_ANIM'] +def palette(raw): + return [bytes(((c&31)*255//31,((c>>5)&31)*255//31,((c>>10)&31)*255//31,255))for c in struct.unpack('<16H',raw)] +def tile(dst,width,raw,pal,tid,cx,cy): + for y in range(8): + for x in range(8): + value=raw[tid*32+y*4+x//2];index=(value>>(4*(x%2)))&15 + if index: + offset=((cy+y)*width+cx+x)*4;dst[offset:offset+4]=pal[index] +balls=bytearray(192*48*4);opened=lz77_at(reader,b['OPEN_FRAME']) +for i in range(12): + gp=struct.unpack_from('0 for a in actual[3::4]))) +print(json.dumps(dict(status='pass',edition=manifest['version'],identity=profile.name,cartridgeSHA1=hashlib.sha1(rom).hexdigest(),art=checks))) diff --git a/tests/gen3_rs_field_window.lua b/tests/gen3_rs_field_window.lua new file mode 100644 index 000000000..9475e745b --- /dev/null +++ b/tests/gen3_rs_field_window.lua @@ -0,0 +1,23 @@ +-- Actual cartridge extractor metadata; no generated cache or artwork written. +local function read(path)local f=assert(io.open(path,'rb'));local b=f:read('*a');f:close();return b end +local bytes=read(assert(arg[1]));local manifest=require('src.link.Json').decode(read(assert(arg[2]))) +require('src.core.GameVersion').set(manifest.version) +local X=require('src.import.RomExtractorGen3');local native=assert(manifest.rsFieldMessageWindow) +assert(native.kind=='rs-field-code'and native.evidence.printerCallCount==2) +local function extract(m) + local x=X.new(bytes,'rs-field-window-regression',m) + x.write=function(self,name,value)assert(name=='constants');self.captured=value end + x.saveImage=function()error('Data-only field-window regression does not write artwork')end + x:extractConstants();return assert(x.captured) +end +local actual=extract(manifest);local w=assert(actual.gen3FieldMessageWindow) +assert(w.left==2 and w.top==15 and w.width==26 and w.height==4) +assert(w.source==native.source and w.source:find('PrintFieldMessage',1,true)) +assert(not actual.gen3MessageWindow,'Field coordinates became a battle-window scan anchor') +local legacy={};for k,v in pairs(manifest)do legacy[k]=v end;legacy.rsFieldMessageWindow=nil +local old=extract(legacy);assert(not old.gen3FieldMessageWindow and not old.gen3MessageWindow,'Old missing-field metadata negative did not reproduce') +local B=require('src.battle.Gen3Battle') +local before,after=B.strip({data={constants=old}}),B.strip({data={constants=actual}}) +for k,v in pairs(before)do assert(after[k]==v,'Field metadata altered native battle strip '..k)end +for k,v in pairs(after)do assert(before[k]==v,'Field metadata added native battle strip field '..k)end +print('PASS_RS_FIELD_WINDOW actual extractor 26x4 at 2,15; legacy missing negative; native battle strip unchanged') diff --git a/tests/gen3_rs_field_window_driver.lua b/tests/gen3_rs_field_window_driver.lua new file mode 100644 index 000000000..920df7b78 --- /dev/null +++ b/tests/gen3_rs_field_window_driver.lua @@ -0,0 +1,51 @@ +-- Native RS field layout/input fixture; no mod and no story-completion claim. +return function(game) + io.stdout:setvbuf('no');local fs=love.filesystem + assert(fs.getIdentity():match('^gen2recomp%-qa%-')or fs.getIdentity():match('^oasc%-gen2recomp%-qa%-full109%-')) + local U=require('tests.drivers.util');local V=require('src.core.GameVersion');local edition=V.get() + assert(edition=='ruby'or edition=='sapphire');assert(not require('src.core.SaveData').saveFileExists(edition),'Fresh QA unexpectedly loaded a save') + local J=require('src.link.Json');local Font=require('src.render.Font');local Theme=require('src.ui.Theme') + local TextBox=require('src.render.TextBox');local Choice=require('src.ui.ChoiceBox') + local w=assert(game.data.constants.gen3FieldMessageWindow) + assert(w.left==2 and w.top==15 and w.width==26 and w.height==4) + assert(not game.data.constants.gen3MessageWindow,'RS field record became an Emerald battle-window anchor') + assert(game.data.font and #game.data.font.pages.normal.widths==254,'RS font is not its own extracted 254-character face') + local bw,bh=Theme.uiSize();assert(bw==240 and bh==160,'RS field dialogue still uses Game Boy dimensions') + local t=Theme.textBox;assert(t.tx==1 and t.ty==14 and t.tw==28 and t.th==6 and t.maxCols==26) + local Cache=require('src.import.CacheFs');local Importer=require('src.import.RomImporter') + assert(Cache.rootReport().kind~='portable','QA must not purge a portable user cache') + assert(Importer.readyReport(edition).ok,'Fresh RS cache is not ready') + local raw=assert(fs.read(edition..'/rom-cache.complete'));assert(raw:find('rs%-item%-menu%-v4:')) + local old,n=raw:gsub('rs%-item%-menu%-v4:','rs-field-window-v3:',1);assert(n==1) + local read=Cache.read;Cache.read=function(path,...)if path=='rom-cache.complete'then return old end;return read(path,...)end + local ok,report=pcall(Importer.readyReport,edition);Cache.read=read + assert(ok and report.ok==false,'Old RS cache was accepted without current action metadata');assert(Importer.readyReport(edition).ok) + U.teleport(game,'MAP_G00_N16',10,12,'up');local world=game.stack:top() + local function press(key)game.input:overlayPressed(key);U.wait(1);game.input:overlayReleased(key);U.wait(1)end + local function until_(fn,seconds,label)local endAt=love.timer.getTime()+seconds;repeat U.wait(1);assert(love.timer.getTime()144 and Font.width(line)<=208) + local text=line..'\n'..line;local legacy=TextBox.paginate(text,18) + assert(#legacy==1 and #legacy[1]>2,'Old narrow-wrap negative did not reproduce') + local done=0;local box=TextBox.new(game,text,function()done=done+1 end);game.stack:push(box) + assert(box.maxCols==26 and #box.pages==1 and #box.pages[1]==2 and box.pages[1][1]==line and box.pages[1][2]==line) + until_(function()return box.done and #box.shown==2 end,10,'Native wide dialogue did not finish') + local function shot(name,state) + local g=love.graphics;local canvas=g.newCanvas(240,160,{dpiscale=1});local depth=g.getStackDepth() + g.push('all');local ok,why=xpcall(function()g.setCanvas(canvas);g.origin();g.setShader();g.setScissor();g.clear(0,0,0,0);state:draw()end,debug.traceback) + while g.getStackDepth()>depth do g.pop()end;assert(ok,why) + local image=canvas:newImageData();assert(fs.write(name..'.png',image:encode('png')));image:release();canvas:release() + end + shot('rs-field-window-dialogue',box);press('a');assert(done==1 and game.stack:top()==world,'Native dialogue completion/input changed') + local picked;local choice=Choice.new(game,function(yes)picked=yes end,{noSound=true});game.stack:push(choice) + press('down');assert(choice.index==2);shot('rs-field-window-choice',choice);press('a') + until_(function()return picked~=nil end,3,'Native choice callback did not return');assert(picked==false and game.stack:top()==world) + assert(fs.write('edition-result.json',J.encode({status='pass',edition=edition,nativeNoMods=true, + fieldWindow={width=bw,height=bh,tx=t.tx,ty=t.ty,tw=t.tw,th=t.th,maxCols=t.maxCols}, + nativeFontCharacters=254,explicitWidthFixturePixels=Font.width(line),oldNarrowFixtureLines=#legacy[1],newFixtureLines=2, + actualDialogueInput=true,actualChoiceInput=true,oldCacheRevisionRejected=true,freshCacheReady=true, + fieldMetadataSeparateFromBattle=true,originalRSDialogueFrameArtPorted=false,fullRSUI=false,fullStory=false,physicalIOSDeviceTested=false}))) + print('PASS_NATIVE_RS_FIELD_WINDOW '..edition);love.event.quit(0) +end diff --git a/tests/gen3_rs_item_effects.lua b/tests/gen3_rs_item_effects.lua new file mode 100644 index 000000000..54424ed10 --- /dev/null +++ b/tests/gen3_rs_item_effects.lua @@ -0,0 +1,39 @@ +-- Actual extractor against a user's local cartridge; writes no cache/assets. +local function read(path)local f=assert(io.open(path,'rb'));local b=f:read('*a');f:close();return b end +local bytes=read(assert(arg[1]));local manifest=require('src.link.Json').decode(read(assert(arg[2]))) +local X=require('src.import.RomExtractorGen3') +local config=assert(manifest.stageOverrides and manifest.stageOverrides.ITEM_EFFECTS) +assert(config.FIRST==13 and config.COUNT==163) +local function word(at)local a,b,c,d=bytes:byte(at+1,at+4);return a+b*256+c*65536+d*16777216 end +local function extract(m) + local x=X.new(bytes,'item-effect-layout-regression',m) + local order={};for i=0,348 do order[i]='ITEM_'..i end + x._constants={itemOrder=order};x.write=function(self,name,value)assert(name=='constants'and value==self._constants)end + x:extractItemEffects();return x._constants.gen3ItemEffects or{} +end +local actual=extract(manifest);local nonnull,fields=0,0 +local function flag(value,mask)return math.floor(value/mask)%2==1 end +for i=0,config.COUNT-1 do + local pointer=word(config.TABLE+i*4);local row=actual['ITEM_'..(13+i)] + if pointer==0 then assert(not row,'Null ROM entry acquired an effect') + else + assert(pointer>=0x08000000 and pointer<0x08000000+#bytes) + local at=pointer-0x08000000;nonnull=nonnull+1;assert(row,'Real ROM item effect omitted') + local _,_,_,cure,amount,extra=bytes:byte(at+1,at+6) + assert((row.levelUp==true)==flag(cure,64)) + assert((row.heal==true)==flag(amount,4)) + assert((row.revive==true)==flag(amount,64)) + assert((row.ppUp==true)==flag(amount,32)) + assert((row.stone==true)==flag(amount,128)) + assert((row.ppMax==true)==flag(extra,16)) + assert(row.source:find(string.format('%08X',pointer),1,true));fields=fields+7 + end +end +assert(actual.ITEM_13.heal and actual.ITEM_13.amount==20,'Potion is not the cartridge 20 HP') +assert(actual.ITEM_64.ev=='attack'and actual.ITEM_64.amount==10,'Protein lost the actual attack EV effect') +assert(actual.ITEM_68.levelUp,'Rare Candy lost its native level-up bit') +local legacy={};for k,v in pairs(manifest)do legacy[k]=v end;legacy.stageOverrides=nil +local wrong=extract(legacy) +assert(not wrong.ITEM_13 or not wrong.ITEM_13.heal or wrong.ITEM_13.amount~=20,'Emerald-address negative control did not reproduce') +assert(X.ITEM_EFFECTS.TABLE~=config.TABLE,'Extractor did not restore stage constants for another edition') +print('PASS_RS_ITEM_EFFECTS',config.COUNT,nonnull,fields,'raw ROM fields; potion/protein/candy and legacy-address negative control') diff --git a/tests/gen3_rs_item_menu.lua b/tests/gen3_rs_item_menu.lua new file mode 100644 index 000000000..de7bf05a0 --- /dev/null +++ b/tests/gen3_rs_item_menu.lua @@ -0,0 +1,50 @@ +-- Actual RS cartridge action records; no assets or generated cache written. +local function read(path)local f=assert(io.open(path,'rb'));local b=f:read('*a');f:close();return b end +local bytes=read(assert(arg[1]));local m=require('src.link.Json').decode(read(assert(arg[2]))) +local X=require('src.import.RomExtractorGen3');local config=assert(m.rsItemMenu) +local function extract(manifest) + local x=X.new(bytes,'rs-item-menu-regression',manifest);local c={};x:itemMenuActions(c);return c.gen3ItemMenu +end +local menu=assert(extract(m));assert(menu.columns==2 and menu.blank==9 and menu.cancel==3 and menu.register==4 and not menu.deselect) +local names={'USE','TOSS','CANCEL','REGISTER','USE','CANCEL','GIVE','CHECK TAG','','CONFIRM'} +local kinds={'use','toss','cancel','register','use','cancel','give','checkTag','blank','other'} +for i=1,10 do assert(menu.labels[i]==names[i],i..': '..tostring(menu.labels[i]));assert(menu.kinds[i]==kinds[i])end +local expected={ITEM={1,7,2,3},BALL={7,9,2,3},TM_HM={1,7,9,3},BERRY={8,9,1,7,2,3},KEY_ITEM={1,4,9,3}} +local checked=0 +for key,list in pairs(expected)do + assert(#menu.pockets[key]==#list) + for i,v in ipairs(list)do assert(menu.pockets[key][i]==v,key..' position '..i);checked=checked+1 end + -- Independent raw ROM check: source index is column*rows+row. + local spec=config.pockets[key];local rows=spec.count/2 + for row=0,rows-1 do for col=0,1 do + local value=bytes:byte(config.choices+spec.row*6+col*rows+row+1)+1 + assert(menu.pockets[key][row*2+col+1]==value) + end end +end +local old={};for k,v in pairs(m)do old[k]=v end;old.rsItemMenu=nil +assert(not extract(old),'Legacy Emerald scanner unexpectedly recognized RS fixed-stride actions') +local bad={};for k,v in pairs(m)do bad[k]=v end +bad.rsItemMenu={};for k,v in pairs(config)do bad.rsItemMenu[k]=v end;bad.rsItemMenu.sourceOrder='row-major' +assert(not pcall(extract,bad),'Unknown source grid order was accepted') +-- Feed the actual extracted cartridge items into the unchanged native +-- consumer and grid navigator. This is CPU evidence, not a rendered run. +local x=X.new(bytes,'rs-item-menu-consumer',m);local items +x.write=function(_,name,value)if name=='items'then items=value end end +x:extractItems();assert(items and items.REPEL and items.MACH_BIKE) +local Bag=require('src.ui.Gen3BagMenu');local Action=require('src.ui.Gen3ItemMenu') +local game={data={items=items,constants={gen3ItemMenu=menu}},save={inventory={REPEL=2},bagOrder={'REPEL'}}} +local bag=Bag.new(game,{});assert(bag:selected().id=='REPEL') +local selectedByPocket={ITEM='REPEL',BALL='POKE_BALL',TM_HM='TM01',BERRY='CHERI_BERRY',KEY_ITEM='MACH_BIKE'} +local nativeCells=0 +for key,id in pairs(selectedByPocket)do + local entries=assert(bag:actionsFor(id));assert(entries.columns==2) + local got;local action=Action.new(game,{entries=entries.entries,columns=entries.columns,onPick=function(kind)got=kind end}) + game.stack={pop=function()end} + for i,cell in ipairs(expected[key])do assert(action.entries[i].kind==kinds[cell]);nativeCells=nativeCells+1 end + if key=='BALL'then action:keypressed('down');assert(action.index==3);action:keypressed('right');assert(action.index==4) + elseif key=='BERRY'then action:keypressed('down');assert(action.index==3);action:keypressed('right');assert(action.index==4) + elseif key=='KEY_ITEM'then action:keypressed('right');assert(action.index==2)end + action:keypressed('a');assert(got==action.entries[action.index].kind) +end +assert(Bag.canRegister(game,'MACH_BIKE')and not Bag.canRegister(game,'REPEL')) +print('PASS_RS_ITEM_MENU actual 10 labels/handlers, '..checked..' raw pocket positions; '..nativeCells..' actual native consumer cells/grid/callbacks; legacy/unknown-order negatives') diff --git a/tests/gen3_rs_item_menu_driver.lua b/tests/gen3_rs_item_menu_driver.lua new file mode 100644 index 000000000..b41e5ec26 --- /dev/null +++ b/tests/gen3_rs_item_menu_driver.lua @@ -0,0 +1,71 @@ +-- Native RS bag action grids, actual inputs and explicit inventory fixtures. +return function(game) + io.stdout:setvbuf('no');local fs=love.filesystem + assert(fs.getIdentity():match('^gen2recomp%-qa%-')) + local U=require('tests.drivers.util');local V=require('src.core.GameVersion');local edition=V.get() + assert(edition=='ruby'or edition=='sapphire');local J=require('src.link.Json') + local Bag=require('src.ui.Gen3BagMenu');local ItemMenu=require('src.ui.Gen3ItemMenu') + local menu=assert(game.data.constants.gen3ItemMenu);assert(menu.columns==2 and menu.register==4 and not menu.deselect) + local Cache=require('src.import.CacheFs');local Importer=require('src.import.RomImporter') + assert(Cache.rootReport().kind~='portable'and Importer.readyReport(edition).ok) + local raw=assert(fs.read(edition..'/rom-cache.complete'));assert(raw:find('rs%-item%-menu%-v4:')) + local old,n=raw:gsub('rs%-item%-menu%-v4:','rs-field-window-v3:',1);assert(n==1) + local read=Cache.read;Cache.read=function(path,...)if path=='rom-cache.complete'then return old end;return read(path,...)end + local ok,ready=pcall(Importer.readyReport,edition);Cache.read=read;assert(ok and not ready.ok and Importer.readyReport(edition).ok) + U.teleport(game,'MAP_G00_N16',10,12,'up');local world=game.stack:top() + game.save.party={require('src.pokemon.Pokemon').new(game.data,'TREECKO',12)} + local tm;for key,def in pairs(game.data.items)do if def.pocket=='TM_HM'and def.machine then tm=key;break end end + assert(tm and game.data.items.CHERI_BERRY and game.data.items.MACH_BIKE,'Missing native item fixture') + game.save.inventory={REPEL=2,POKE_BALL=3,MACH_BIKE=1,CHERI_BERRY=2,[tm]=1} + game.save.bagOrder={'REPEL','POKE_BALL',tm,'CHERI_BERRY','MACH_BIKE'};game.save.repelSteps=0;game.save.registeredItem=nil + local initial=J.encode(game.save.inventory);local party=J.encode(game.save.party) + local function press(key)game.input:overlayPressed(key);U.wait(1);game.input:overlayReleased(key);U.wait(1)end + local function until_(fn,seconds,label)local stop=love.timer.getTime()+seconds;repeat U.wait(1);assert(love.timer.getTime()depth do g.pop()end;assert(yes,why) + local im=c:newImageData();assert(fs.write(name..'.png',im:encode('png')));im:release();c:release() + end + local screen=Bag.new(game,{});game.stack:push(screen);local grids={};local expected={ITEM={'use','give','toss','cancel'},BALL={'give','blank','toss','cancel'},TM_HM={'use','give','blank','cancel'},BERRY={'checkTag','blank','use','give','toss','cancel'},KEY_ITEM={'use','register','blank','cancel'}} + for _=1,5 do + local key=screen.pockets[screen.pocket].key;assert(screen:selected().id) + local rng=love.math.getRandomState();press('a');local action=game.stack:top();assert(getmetatable(action)==ItemMenu,'Native bag skipped action menu for '..key) + assert(#action.entries==#expected[key]);local kinds={} + for i,row in ipairs(action.entries)do assert(row.kind==expected[key][i],key..' action cell '..i);kinds[i]=row.kind end + assert(action.columns==2 and action.rows==#expected[key]/2) + if key=='BALL'then + press('down');assert(action.index==3 and action.entries[action.index].kind=='toss') + press('right');assert(action.index==4 and action.entries[action.index].kind=='cancel') + elseif key=='BERRY'then + press('down');assert(action.index==3 and action.entries[action.index].kind=='use') + press('right');assert(action.index==4 and action.entries[action.index].kind=='give') + end + shot('rs-item-actions-'..key,action);grids[key]=kinds;press('b');assert(game.stack:top()==screen) + assert(J.encode(game.save.inventory)==initial and J.encode(game.save.party)==party,'Native action cancellation changed items/team') + assert(love.math.getRandomState()==rng,'Drawing/input-only action menu consumed game RNG') + press('right') + end + local function pocket(key) + for _=1,5 do if screen.pockets[screen.pocket].key==key then return end;press('right')end + error('Native pocket not found '..key) + end + pocket('KEY_ITEM');press('a');local action=game.stack:top();press('right');assert(action.index==2 and action.entries[2].kind=='register');press('a') + assert(game.stack:top()==screen and game.save.registeredItem=='MACH_BIKE'and Bag.canRegister(game,'MACH_BIKE'),'Native REGISTER did not store key item') + press('a');action=game.stack:top();assert(action.entries[2].label==menu.labels[menu.register],'RS changed REGISTER into an invented DESELECT label') + press('right');press('a');assert(not game.save.registeredItem and game.stack:top()==screen,'Native REGISTER did not toggle off') + pocket('BALL');press('a');press('down');assert(game.stack:top().entries[game.stack:top().index].kind=='toss');press('a') + assert(getmetatable(game.stack:top())==require('src.ui.QuantityBox'));press('b');assert(game.stack:top()==screen and J.encode(game.save.inventory)==initial,'Native quantity cancellation consumed balls') + pocket('ITEM');assert(screen:selected().id=='REPEL');press('a');assert(game.stack:top().entries[1].kind=='use');press('a') + assert(game.save.inventory.REPEL==1 and game.save.repelSteps==100,'Native USE did not consume exactly one Repel') + until_(function()local top=game.stack:top();if top==screen then return true end;if top.done then press('a')end;return game.stack:top()==screen end,20,'Native USE message did not return to bag') + assert(J.encode(game.save.party)==party and game.save.inventory.POKE_BALL==3 and game.save.inventory[tm]==1) + press('b');assert(game.stack:top()==world) + local w,h=require('src.ui.Theme').uiSize();assert(w==240 and h==160 and game.data.constants.gen3FieldMessageWindow.width==26) + assert(fs.write('edition-result.json',J.encode({status='pass',edition=edition,nativeNoMods=true, + actualPocketActionGrids=grids,nativeColumnMajorPositions=true,nativeDirectionalInput=true,nativeRegisterToggle=true, + rsRegisterLabelPreserved=true,nativeQuantityCancel=true,nativeRepelUse=true,unchangedOtherItemsAndTeam=true, + oldV3CacheRejected=true,freshV4CacheReady=true,fieldWindowPreserved=true, + originalRSBagArtworkPorted=false,berryTagScreenImplemented=false,fullRSUI=false,fullStory=false,physicalIOSDeviceTested=false}))) + print('PASS_NATIVE_RS_ITEM_MENU '..edition);love.event.quit(0) +end diff --git a/tests/gen3_walking_friendship.lua b/tests/gen3_walking_friendship.lua new file mode 100644 index 000000000..bc609ba8b --- /dev/null +++ b/tests/gen3_walking_friendship.lua @@ -0,0 +1,32 @@ +-- Executes the controller's actual completed-step friendship branch and +-- real Gen3Friendship routine. No cartridge, extracted data or renderer. +local function read(path)local f=assert(io.open(path,'rb'));local s=f:read('*a');f:close();return s end +local source=read('src/world/OverworldController.lua') +local first=assert(source:find(' self.todSteps = (self.todSteps or 0) + 1',1,true)) +local last=assert(source:find(' -- re-evaluate day/night',first,true)) +local V=require('src.core.GameVersion');local F=require('src.pokemon.Gen3Friendship') +local real=F.step;local calls,legacy=0,0 +local Game={data={pokemon={TEST={friendship=70}}},save={}} +local native={['src.pokemon.Gen3Friendship']=F, + ['src.world.PikachuFollower']={onStep=function()end},['src.world.HgssFollowMon']={onStep=function()end}, + ['src.pokemon.Gen2Friendship']={isVanilla=function()return false end}, + ['src.pokemon.Evolution']={changeHappiness=function()legacy=legacy+1 end}} +local chunk=assert(loadstring('return function(self)\n'..source:sub(first,last-1)..'end')) +setfenv(chunk,setmetatable({Game=Game,GameVersion=V,require=function(n)return assert(native[n],n)end},{__index=_G})) +local step=chunk();F.step=function(...)calls=calls+1;return real(...)end +local originalRandom=math.random;local editions=0 +for _,edition in ipairs{'firered','leafgreen','ruby','sapphire','emerald'}do + if V.VERSIONS[edition] then + editions=editions+1;V.set(edition) + for _,coin in ipairs{0,1}do + local mon={species='TEST',happiness=70};local egg={species='TEST',happiness=70,isEgg=true} + Game.save={party={mon,egg}};local world={map={def={regionMapSection=5}}};calls,legacy=0,0 + math.random=function(a,b)assert(a==0 and b==65535);return coin end + for i=1,256 do step(world);assert(Game.save.gen3FriendshipSteps==i%128)end + assert(calls==256 and legacy==0,'Gen3 step omitted or used generic legacy friendship: '..edition) + assert(mon.happiness==(coin==0 and 72 or 70)and egg.happiness==70) + end +end end +assert(editions>=3) +math.random=originalRandom +print('PASS_GEN3_WALKING_FRIENDSHIP',editions,editions*512,'actual controller branch steps, both coin outcomes, eggs excluded') diff --git a/tests/goldens/fixture_fingerprint.md b/tests/goldens/fixture_fingerprint.md new file mode 100644 index 000000000..55c09281f --- /dev/null +++ b/tests/goldens/fixture_fingerprint.md @@ -0,0 +1,47 @@ +# Fixture link fingerprint audit + +The previous `7de3051a7b3c108e` pin was already stale at Gen2Recomped's +`0b8c9c4` import: that checkout computes `8c94d595cd60ca69` against its own +ROM-free fixtures. It is not a baseline that current Gen2 data can satisfy. + +At upstream `13af468b97dc37015d14ad5c71d7f067c3ee810d`, the same production +serializer and digest compute `41e69caf4afa35ab`. Comparing the canonical +`Fingerprint.surface(data, {})` strings against `0b8c9c4` changes **only** +`move_effects`: commit `3252aec` adds these four full-handler registry records: + +- `POWER_BASED_ON_LOW_SPEED` (Gyro Ball) +- `INCREASE_POWER_WITH_MORE_HP` (Wring Out / Crush Grip) +- `DOUBLE_POWER_WHEN_BELOW_HALF` (Brine) +- `DOUBLE_POWER_HEAL_SLEEP` (Wake-Up Slap) + +No records were removed or altered in the serialized surface comparison. +The current surface has 198 effect records. Species, moves, type chart, +statuses, constants and mod list are byte-identical to the imported checkout. +An independent Python implementation using integer XOR, multiplication by +16777619 and modulo 2**32 in both lanes also produces the current digest. +No fingerprint algorithm, allowlist, cache or production registry is changed +by this test repair. + +`fixture_fingerprint_sections.txt` pins each populated section separately so +future intentional registry additions cannot hide changes in another section. +`gate_fingerprint.lua` retains all prior mutation and determinism assertions, +and additionally proves that removing any of the four Sinnoh registrations +changes compatibility. An empty `link_fields` registry has no serialized +section; a future nonempty one requires a deliberate new section pin. + +To audit a future change, load a fresh `tests.modkit` fixture with +`T.sdk.loadNone({data=data})`, compare `Fingerprint.surface(data,{})` section +by section, and then update only reviewed pins. Run the gate again and keep +the mutation assertions enabled. The ROM-dependent vanilla golden is separate +and was not re-blessed by this update. + +## Current upstream Gen4 effect registrations + +Upstream `8d5b4387` adds 53 native Gen4 effect registrations through +`Gen4MoveEffects` and the existing non-destructive registry merge. Comparing +canonical fixture surfaces against `ed5189c2` changes only `move_effects`: +53 additions, no removals, and no changed existing kind/revision records. +The other six populated sections remain byte-identical. The whole fingerprint +is now `884a4b379d3e2f83`, and the effect section is `1c01a759e13618d5`. +The production serializer, digest and all gate mutation assertions remain +unchanged. These pins acknowledge the new upstream registry surface. diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 3ab0752dc..d43cb64a0 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -7de3051a7b3c108e +884a4b379d3e2f83 diff --git a/tests/goldens/fixture_fingerprint_sections.txt b/tests/goldens/fixture_fingerprint_sections.txt new file mode 100644 index 000000000..4437d24e9 --- /dev/null +++ b/tests/goldens/fixture_fingerprint_sections.txt @@ -0,0 +1,7 @@ +pokemon 0ad4d8a59c0daa59 +moves 54b3cb2c1776ed48 +type_chart cc5bd80d003bd459 +statuses 68247ced2ed19a21 +move_effects 1c01a759e13618d5 +constants e4cf6f91ff122fa5 +mods 6a9ee9de68f78902 diff --git a/tests/input_hooks/cases.lua b/tests/input_hooks/cases.lua new file mode 100644 index 000000000..006b8694c --- /dev/null +++ b/tests/input_hooks/cases.lua @@ -0,0 +1,131 @@ +local M = {} +function M.run(f) + local T, g, I, e = f.T, f.game, f.Input, f.exports + local rows = {} + local function state(label) + rows[#rows + 1] = { + label = label, + up = I:isDown("up"), + a = I:isDown("a"), + right = I:isDown("right"), + zoom = #f.zoom, + events = #e.events, + } + end + local function last(name, expected) + local seen = e.events[#e.events] + T.eq(seen.name, name, "hook name") + T.eq(seen.game, g, "original Game identity") + T.same(seen.event, expected, "exact raw payload") + end + g:keypressed("up") + last("input.key", { phase = "pressed", key = "up" }) + T.eq(I:isDown("up"), true, "forwarded keyboard press") + state("key-down") + e.capture(true) + T.eq(g:keyreleased("up"), "captured", "release may be swallowed") + last("input.key", { phase = "released", key = "up" }) + T.eq(I:isDown("up"), true, "swallowed release does not clear held input") + state("key-release-captured") + e.capture(false) + g:keyreleased("up") + T.eq(I:isDown("up"), false, "forwarded release clears held input") + e.capture(true) + g:keypressed("up") + T.eq(I:isDown("up"), false, "swallowed press never reaches Input") + state("key-press-captured") + e.capture(false) + g:gamepadpressed(f.pad, "a") + last("input.gamepad", { phase = "pressed", joystick = f.pad, button = "a" }) + T.eq(e.events[#e.events].event.joystick, f.pad, "opaque joystick identity") + T.eq(I:isDown("a"), true, "forwarded pad press") + e.capture(true) + g:gamepadreleased(f.pad, "a") + last("input.gamepad", { phase = "released", joystick = f.pad, button = "a" }) + T.eq(I:isDown("a"), true, "swallowed pad release retains held input") + state("pad-release-captured") + e.capture(false) + g:gamepadreleased(f.pad, "a") + T.eq(I:isDown("a"), false, "forwarded pad release clears held input") + e.capture(true) + g:gamepadpressed(f.pad, "a") + T.eq(I:isDown("a"), false, "swallowed pad press") + g:gamepadaxis(f.pad, "leftx", 0.875) + last("input.gamepad", { phase = "axis", joystick = f.pad, axis = "leftx", value = 0.875 }) + T.eq(I:isDown("right"), false, "swallowed axis does not create direction") + state("axis-captured") + e.capture(false) + g:gamepadaxis(f.pad, "leftx", 0.875) + T.eq(I:isDown("right"), true, "native axis produces direction") + state("axis-forwarded") + g:gamepadaxis(f.pad, "leftx", 0) + last("input.gamepad", { phase = "axis", joystick = f.pad, axis = "leftx", value = 0 }) + T.eq(I:isDown("right"), false, "zero axis releases direction") + for _, dy in ipairs({ -1, 2.5, 0 }) do + local n = #f.zoom + e.capture(true) + T.eq(g:wheelmoved(99, dy), "captured", "wheel suppression result") + last("input.wheel", dy) + T.eq(#f.zoom, n, "captured wheel does not zoom") + e.capture(false) + g:wheelmoved(99, dy) + last("input.wheel", dy) + T.eq(#f.zoom, n + (dy == 0 and 0 or 1), "native wheel delta") + end + state("wheel-forwarded") + local api = f.public + if api.supports then + for _, name in ipairs({ "input.key", "input.gamepad", "input.wheel" }) do + T.eq(api:supports("hooks", name), true, "public capability " .. name) + end + T.eq(api:supports("hooks", "input.not_a_hook"), false, "unknown hook still declines") + end + -- Rewriting next's arguments changes downstream observations, not the + -- original event captured by the native Game body (the Gen1 contract). + local off = api.hooks:wrap("input.key", function(next, game, ev) + return next(game, { phase = ev.phase, key = "down" }) + end, 100) + g:keypressed("up") + T.eq(e.events[#e.events].event.key, "down", "downstream sees replacement payload") + T.eq(I:isDown("up"), true, "native callback retains original key") + T.eq(I:isDown("down"), false, "replacement does not synthesize another key") + off() + g:keyreleased("up") + -- Errors before/after next must not lose or replay a native event. + for _, after in ipairs({ false, true }) do + local offError = api.hooks:wrap("input.wheel", function(next) + if after then + next() + end + error("intentional input contract failure") + end, 100) + local n = #f.zoom + g:wheelmoved(0, 1) + T.eq(#f.zoom, n + 1, "wrapper error preserves exactly one native call") + offError() + end + local original = g.zoomStep + local calls = 0 + g.zoomStep = function() + calls = calls + 1 + error("native zoom failure") + end + T.raises(function() + g:wheelmoved(0, 1) + end, "native zoom failure", "native failure propagates") + T.eq(calls, 1, "native failure is never retried") + g.zoomStep = original + local n = #e.events + e.stop() + g:keypressed("up") + g:keyreleased("up") + g:gamepadpressed(f.pad, "a") + g:gamepadreleased(f.pad, "a") + g:gamepadaxis(f.pad, "leftx", 0) + g:wheelmoved(0, 1) + T.eq(#e.events, n, "unsubscribe removes every fixture callback") + T.eq(I:isDown("up"), false, "empty chains preserve native release") + T.eq(I:isDown("a"), false, "empty chains preserve native pad release") + return rows +end +return M diff --git a/tests/input_hooks/fixture.lua b/tests/input_hooks/fixture.lua new file mode 100644 index 000000000..4f4f9a133 --- /dev/null +++ b/tests/input_hooks/fixture.lua @@ -0,0 +1,74 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + require("src.core.GameVersion").set(version) + local Input = require("src.core.Input") + local name = backend == "gen1" + and (version == "gold" and "Game2" or (version == "firered" or version == "emerald") and "Game3" or "Game") + or "Game" + local Game = require("src.core." .. name) + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/portable_input/" .. path, "rb")) + files["mods/portable_input/" .. path] = f:read("*a") + f:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_input/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_input/manifest.json"] = Json.encode(manifest) + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_input" }, { + data = T.fixtures.fresh(), + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + Input:init() + local f = { + T = T, + result = result, + Input = Input, + Game = Game, + version = version, + api = api, + public = result.loader.exports.portable_input and result.loader.exports.portable_input.api, + exports = result.loader.exports.portable_input, + } + local zoom = {} + local game = setmetatable({ + input = Input, + mods = result.loader, + data = result.data, + save = { options = {} }, + phase = "world", + stack = { + top = function() + return nil + end, + }, + zoomStep = function(_, delta) + zoom[#zoom + 1] = delta + end, + zoomView = function() + return nil + end, + }, { __index = Game }) + f.game, f.zoom = game, zoom + result.loader.game = game + f.pad = { + getName = function() + return "Contract Gamepad" + end, + isGamepadDown = function() + return false + end, + getGamepadAxis = function() + return 0 + end, + } + function f.close() + result.release() + end + return f +end +return M diff --git a/tests/input_hooks/worker.lua b/tests/input_hooks/worker.lua new file mode 100644 index 000000000..a892d9679 --- /dev/null +++ b/tests/input_hooks/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/input_hooks/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/input_hooks/cases.lua").run(f) +f.close() +print("INPUT_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable input contract") diff --git a/tests/leafgreen_native_driver.lua b/tests/leafgreen_native_driver.lua new file mode 100644 index 000000000..5ff580bef --- /dev/null +++ b/tests/leafgreen_native_driver.lua @@ -0,0 +1,71 @@ +-- Native rendered smoke after an actual ROM import, with all mods disabled. +return function() + local G = require('src.core.Game') + local V = require('src.core.GameVersion') + local U = require('tests.drivers.util') + local function frames(n) for _=1,n do coroutine.yield() end end + assert(V.get() == 'leafgreen') + assert(G.data.constants.gen == 3 or G.data.constants.gen3Bag) + local title = assert(G.data.constants.gen3FRLGTitle) + assert(title.effect == 'leaves' and title.cry == 'VENUSAUR') + assert(#title.streakY == 8) + assert(title.images.flames.frames == 11) + assert(G.data.pokemon.BULBASAUR and G.data.pokemon.VENUSAUR) + assert(G.data.maps and next(G.data.maps)) + print('[driver] LeafGreen imported native dataset and title') + U.tap(G,'start') -- leave the native boot movie + frames(220) + assert(getmetatable(G.stack:top()) == require('src.ui.Gen3TitleFRLG')) + love.graphics.captureScreenshot('leafgreen-title.png') + frames(3) + U.tap(G,'a');frames(120) + assert(getmetatable(G.stack:top()) == require('src.ui.Gen3MainMenu')) + U.tap(G,'a') + for _=1,1500 do + U.tap(G,'a');frames(2) + if G.overworld and G.stack:top()==G.overworld then break end + end + assert(G.overworld and G.stack:top()==G.overworld,'Oak speech/naming did not reach field') + love.graphics.captureScreenshot('leafgreen-new-game.png');frames(3) + local beforeX,beforeY=G.overworld.player.cellX,G.overworld.player.cellY + U.hold(G,'down',30);frames(20) + assert(beforeX~=G.overworld.player.cellX or beforeY~=G.overworld.player.cellY,'field movement did not advance') + local S=require('src.core.SaveData') + assert(S.save(G.save,G.mods)) + assert(S.saveFileExists('leafgreen')) + assert(not S.saveFileExists('firered'),'LeafGreen wrote another edition save') + local loaded=assert(S.load('leafgreen')) + assert(loaded.player.name==G.save.player.name and loaded.version=='leafgreen') + print('[driver] PASS new game, Oak speech, naming, movement, isolated save/reload') + -- A real map-script interaction and native battle/reward chain. The party + -- is a deliberate QA fixture so the test does not need hours of grinding. + local C=require('src.script.Gen3Commands') + local Pokemon=require('src.pokemon.Pokemon') + local mon=Pokemon.new(G.data,'MEWTWO',100) + mon.moves={{id='PSYCHIC',pp=99,maxPp=99}} + G.save.party={mon} + U.teleport(G,'MAP_G06_N02',6,6,'up');frames(30) + local battled,completed=false,false + for frame=1,14000 do + if frame%12==1 then U.tap(G,'a') else frames(1) end + local top=G.stack:top() + local name=top and top.trainer and tostring(top.trainer.name):upper() + if top and top.enemy and name and name:find('BROCK',1,true) and not battled then + battled=true;frames(180) + love.graphics.captureScreenshot('leafgreen-brock-battle.png');frames(3) + end + if battled and G.save.flags[C.flagKey(0x254)] and top==G.overworld and not G.overworld.runner:isRunning() then + assert(G.save.flags[C.flagKey(0x820)],'Boulder Badge missing') + assert(G.save.inventory.TM39==1 and G.save.inventory.TM_CASE==1,'native TM reward chain failed') + completed=true;break + end + end + assert(completed,'Brock battle/reward script did not complete') + love.graphics.captureScreenshot('leafgreen-brock-reward.png');frames(3) + local result={status='pass',edition=V.get(),stage='native-import-and-title', + allModsDisabled=true,newGame=true,movement=true,saveReload=true,brockBattleAndReward=true, + physicalIOSDeviceTested=false,fullStoryTested=false} + love.filesystem.write('edition-result.json',require('src.link.Json').encode(result)) + print('[driver] PASS LeafGreen native import and rendered title') + love.event.quit(0) +end diff --git a/tests/logic_speed/cases.lua b/tests/logic_speed/cases.lua new file mode 100644 index 000000000..0ed362199 --- /dev/null +++ b/tests/logic_speed/cases.lua @@ -0,0 +1,196 @@ +local M = {} +function M.run(f) + local T, g, e, api = f.T, f.game, f.exports, f.public + local rows = {} + local function observe(label, expected) + local value = g:logicSpeed() + T.eq(value, expected, label) + rows[#rows + 1] = { label = label, speed = value } + end + if api.supports then + T.eq(api:supports("hooks", "core.logic_speed"), true, "native speed capability") + end + for _ = 1, 3 do + observe("native saved option", 4) + end + T.eq(e.seen.calls, 3, "one hook call per unlocked resolution") + T.eq(g.resolves, 3, "one native resolver per forwarded resolution") + T.eq(e.seen.game, g, "original opaque Game identity") + T.eq(e.seen.extra, 0, "no invented arguments") + T.eq(e.seen.vanilla, 4, "next returns native resolution") + for key in pairs(g.save.options) do + g.save.options[key] = 10 + end + observe("live saved option", 10) + for key in pairs(g.save.options) do + g.save.options[key] = 4 + end + e.setSpeed(20) + local resolves = g.resolves + observe("mod override", 20) + T.eq(g.resolves, resolves, "omitting next skips native resolver") + e.setSpeed(nil) + + -- Clamp at the producer boundary, including nil/false and non-finite values. + for _, case in ipairs({ + { name = "nil", expected = 1 }, + { name = "false", value = false, expected = 1 }, + { name = "NaN", value = 0 / 0, expected = 1 }, + { name = "infinity", value = math.huge, expected = 1 }, + { name = "negative infinity", value = -math.huge, expected = 1 }, + { name = "zero", value = 0, expected = 1 }, + { name = "negative", value = -20, expected = 1 }, + { name = "table", value = {}, expected = 1 }, + { name = "string", value = "20", expected = 20 }, + { name = "large", value = 1e9, expected = 200 }, + }) do + local off = api.hooks:wrap("core.logic_speed", function() + return case.value + end, 100) + observe(case.name .. " hook output", case.expected) + off() + end + -- Each host retains its own ladder (Gen1 includes 3X; Gen2 does not). + local off = api.hooks:wrap("core.logic_speed", function() + return 3 + end, 100) + T.eq(g:logicSpeed(), f.backend == "gen1" and 3 or 2, "native ladder preserved") + off() + local other = f.owner(10) + off = api.hooks:wrap("core.logic_speed", function(next, game) + T.eq(game, g, "outer wrapper sees original Game") + return next(other) + end, 100) + resolves = g.resolves + observe("replacement next target", 10) + T.eq(other.resolves, 1, "native resolver uses replacement Game") + T.eq(g.resolves, resolves, "original Game is not resolved after replacement") + T.eq(e.seen.game, other, "replacement reaches downstream wrapper") + off() + local order = {} + local outer = api.hooks:wrap("core.logic_speed", function(next) + order[#order + 1] = "outer" + local result = next() + order[#order + 1] = "after" + return result * 5 + end, 20) + local inner = api.hooks:wrap("core.logic_speed", function(next, game) + order[#order + 1] = "inner" + T.eq(game, g, "next() retains current owner") + return next() + end, 10) + observe("priority and postprocessing", 20) + T.same(order, { "outer", "inner", "after" }, "descending wrapper priority") + outer() + inner() + + e.setSpeed(200) + g.speedOverride = 20 + local calls = e.seen.calls + resolves = g.resolves + observe("explicit override wins", 20) + T.eq(e.seen.calls, calls, "explicit override bypasses hook") + T.eq(g.resolves, resolves, "explicit override bypasses saved option") + for _, kind in ipairs({ "link", "fixed" }) do + f.lock(kind) + T.eq(g:speedLocked(), true, kind .. " native lock detected") + observe(kind .. " wins over explicit override", 1) + g.speedOverride = nil + observe(kind .. " wins over hook", 1) + T.eq(e.seen.calls, calls, kind .. " hook is never called") + T.eq(g.resolves, resolves, kind .. " saved option is not resolved") + f.lock(nil) + observe(kind .. " unlock restores mod", 200) + calls = e.seen.calls + g.speedOverride = 20 + end + g.speedOverride = nil + f.lock(nil) + e.setSpeed(nil) + + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("core.logic_speed", function(next) + if after then + next() + end + error("intentional speed wrapper failure") + end, 100) + resolves, calls = g.resolves, e.seen.calls + observe(after and "error after next" or "error before next", 4) + T.eq(g.resolves, resolves + 1, "wrapper failure resolves native exactly once") + T.eq(e.seen.calls, calls + 1, "wrapper failure traverses consumer once") + off() + end + local original = g._resolveLogicSpeed + local attempts = 0 + g._resolveLogicSpeed = function() + attempts = attempts + 1 + error("native speed resolver failure") + end + T.raises(function() + g:logicSpeed() + end, "native speed resolver failure", "native errors escape") + T.eq(attempts, 1, "native error is not retried") + g._resolveLogicSpeed = original + if f.api >= 3 then + off = api.hooks:wrap("core.logic_speed", function(next) + next() + return next() + end, 100) + resolves = g.resolves + observe("API-3 cached continuation", 4) + T.eq(g.resolves, resolves + 1, "API-3 repeated next caches native result") + off() + end + -- Gen1's GBA boot phases are a separate native restriction. Gen2's + -- titles/attract screens retain their own timing; do not invent that host. + if f.backend == "gen1" and f.generation == 3 then + local Boot = require("src.ui.game3.boot") + g.phase = "boot" + calls = e.seen.calls + for _, phase in ipairs({ + Boot.PHASE.INTRO, + Boot.PHASE.TITLE, + Boot.PHASE.TITLE_CRY, + Boot.PHASE.TITLE_RESTART, + }) do + g.boot = { phase = phase } + T.eq(g:logicSpeed(), 1, "Gen1 GBA boot cinematic lock") + T.eq(e.seen.calls, calls, "Gen1 GBA boot bypasses hook") + end + g.speedOverride = 20 + T.eq(g:logicSpeed(), 20, "Gen1 GBA explicit override retains boot precedence") + g.speedOverride, g.phase = nil, "field" + end + e.stop() + e.stop() + calls = e.seen.calls + observe("unsubscribed native fallback", 4) + T.eq(e.seen.calls, calls, "unsubscribe is idempotent") + -- The actual unwrapped resolver and launch override keep host-native + -- levels too. The shared contract does not add a 3X rung to Gen2. + for key in pairs(g.save.options) do + g.save.options[key] = 3 + end + T.eq(g:logicSpeed(), f.backend == "gen1" and 3 or 2, "unsubscribed native ladder") + g.speedOverride = 3 + T.eq(g:logicSpeed(), f.backend == "gen1" and 3 or 2, "explicit native ladder") + g.speedOverride = nil + for key in pairs(g.save.options) do + g.save.options[key] = 4 + end + local peerCalls = 0 + api.hooks:wrap("core.logic_speed", function() + return 200 + end, 100) + local peer = f.result.loader.hooks:wrap("core.logic_speed", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_speed_owner") + f.result.loader.hooks:removeOwner("portable_logic_speed") + observe("owner cleanup restores native option", 4) + T.eq(peerCalls, 1, "owner cleanup preserves peer wrapper") + peer() + return rows +end +return M diff --git a/tests/logic_speed/fixture.lua b/tests/logic_speed/fixture.lua new file mode 100644 index 000000000..66c7b4732 --- /dev/null +++ b/tests/logic_speed/fixture.lua @@ -0,0 +1,95 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_logic_speed/" .. path, "rb")) + files["mods/portable_logic_speed/" .. path] = file:read("*a") + file:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_logic_speed/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_logic_speed/manifest.json"] = Json.encode(manifest) + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_logic_speed" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local module = "src.core.Game" + if backend == "gen1" and generation > 1 then + module = module .. generation + end + local Game = require(module) + local function owner(speed) + local opts = { speed = speed, speedOverworld = speed, speedBattle = speed, speedMenu = speed } + local game = setmetatable({ + generation = generation, + phase = "field", + data = data, + mods = result.loader, + save = { options = opts }, + options = opts, + stack = { states = { { isOverworld = true } } }, + resolves = 0, + }, { __index = Game }) + function game:_resolveLogicSpeed() + self.resolves = self.resolves + 1 + return Game._resolveLogicSpeed(self) + end + return game + end + local g = owner(4) + -- Host-specific state fixtures exercise the actual native speedLocked method. + local keys = { "src.core.game3.link", "src.core.game3.minigames.common" } + local saved = {} + for _, key in ipairs(keys) do + saved[key] = package.loaded[key] + end + local function lock(kind) + g.linkSession, g.linkNet = nil, nil + g.stack.states = { { isOverworld = true } } + if backend == "gen1" and generation == 3 then + package.loaded[keys[1]] = { link = kind == "link" and true or nil } + package.loaded[keys[2]] = { + isActive = function() + return kind == "fixed" + end, + } + elseif kind == "link" then + g.linkSession = true + elseif kind == "fixed" then + g.stack.states = { { isFixedSpeed = true }, { isMenu = true } } + end + end + local exports = result.loader.exports.portable_logic_speed + return { + T = T, + result = result, + game = g, + owner = owner, + lock = lock, + backend = backend, + generation = generation, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + close = function() + for _, key in ipairs(keys) do + package.loaded[key] = saved[key] + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/logic_speed/worker.lua b/tests/logic_speed/worker.lua new file mode 100644 index 000000000..1b1d47663 --- /dev/null +++ b/tests/logic_speed/worker.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/logic_speed/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/logic_speed/cases.lua").run(f) +f.close() +local bare = dofile(root .. "/tests/logic_speed/fixture.lua").new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("core.logic_speed"), false, "no-mod runtime") +bare.T.eq(bare.game:logicSpeed(), 4, "no-mod native resolver") +bare.close() +print("LOGIC_SPEED_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable core.logic_speed contract") diff --git a/tests/love_stub.lua b/tests/love_stub.lua index b7fc25fa9..c3b191a09 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -53,6 +53,10 @@ stub.graphics = { function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end function batch:clear() self.sprites = {} end function batch:setTexture(tex) self.texture = tex end + function batch:getTexture() return self.texture or self.image end + function batch:getBufferSize() return size end + function batch:getCount() return #self.sprites end + function batch:release() self.released = true end return batch end, draw = noop, rectangle = noop, clear = noop, diff --git a/tests/mod_examples_tests.lua b/tests/mod_examples_tests.lua index 64b84b2d7..0dbff0a1a 100644 --- a/tests/mod_examples_tests.lua +++ b/tests/mod_examples_tests.lua @@ -141,25 +141,16 @@ for _, id in ipairs(IDS) do end end --- the legacy entry keeps its v1 manifest and gains only a card +-- API-1 manifest validation remains covered even when the old demonstration +-- is not installed in the production mod directory. The pinned external +-- fixture gate loads the untouched original separately. do - local cardChunk = loadfile("mods/example_mew_starter/mod.card") - if check(cardChunk ~= nil, "example_mew_starter: ships a mod.card") then - local ok, card = pcall(cardChunk) - check(ok and type(card) == "table", "example_mew_starter: the card parses") - check(ok and card.compat and card.compat.modApi == 1, - "example_mew_starter: the card declares api 1, not 2") - end - local body = io.open("mods/example_mew_starter/manifest.json", "rb") - if check(body ~= nil, "example_mew_starter: manifest is readable") then - local manifest = require("src.link.Json").decode(body:read("*a")) - body:close() - check(manifest.api == nil, "example_mew_starter: stays an api 1 manifest") - eq(manifest.category, "GAMEPLAY", - "example_mew_starter: keeps the legacy category value") - local ok = pcall(Manifest.validate, manifest, "mods/example_mew_starter") - check(ok, "example_mew_starter: the v1 manifest still validates") - end + local ok, manifest = pcall(Manifest.validate, { + id = "legacy_gallery_probe", name = "Legacy gallery probe", version = "1.0.0", + entry = "main.lua", category = "GAMEPLAY", + }, "tests/fixtures/upstream_mods/legacy_gallery_probe") + check(ok, "legacy api-1 manifest still validates") + eq(ok and manifest.api, 1, "omitted api selects legacy semantics") end -- ------- disabled by default @@ -176,18 +167,13 @@ do for _, id in ipairs(IDS) do check(not top[id], id .. " is not discoverable at the mods/ root") end - check(top.example_mew_starter, - "the legacy example is still discovered at the mods/ root") + check(not top.example_mew_starter, + "external compatibility fixtures are not installed as player mods") end -- ------- the gallery loads -local data = freshImported() -if not data then - print("modkit: example gallery load skipped -- no imported dataset in " - .. "data/generated/ to fold the examples against") - return S.finish() -end +local data = freshImported() or require("tests.fixtures.upstream_mods.data").fresh() -- The transform in example_shiny_palette reads the imported cache and -- writes under save/mod-derived/. The io-backed harness filesystem has no diff --git a/tests/mod_input/cases.lua b/tests/mod_input/cases.lua new file mode 100644 index 000000000..ae4944eb0 --- /dev/null +++ b/tests/mod_input/cases.lua @@ -0,0 +1,130 @@ +local M = {} +function M.run(f) + local T, I, own, peer = f.T, f.Input, f.primary, f.peer + local buttons = { "up", "down", "left", "right", "a", "b", "start", "select" } + local rows = {} + local function state(label) + local held, edge = {}, {} + for _, button in ipairs(buttons) do + held[button], edge[button] = I:isDown(button), I:wasPressed(button) + end + rows[#rows + 1] = { label = label, held = held, edge = edge } + end + T.raises(function() + own.tap("a") + end, "mod.input needs the live game", "requires game.ready") + T.eq(#I.pressQueue, 0, "early call has no queued input") + f.ready() + for _, button in ipairs(buttons) do + I:reset() + T.eq(own.tap(button), nil, "tap has no success return") + T.eq(I:isDown(button), false, "tap has no held state before step") + T.eq(I:wasPressed(button), false, "tap edge waits for fixed step") + I:step() + T.eq(I:wasPressed(button), true, "tap produces one edge") + T.eq(I:isDown(button), false, "tap does not resurrect held state") + state(button .. "-tap") + I:step() + T.eq(I:wasPressed(button), false, "tap edge expires after one step") + local a, b = own.press(button), own.press(button) + T.eq(type(a), "table", "opaque press token") + T.neq(a, b, "independent tokens for same button") + T.eq(next(a), nil, "token contains no mutable input details") + I:step() + T.eq(I:wasPressed(button), true, "press queues native edge") + T.eq(I:isDown(button), true, "press holds immediately and across steps") + I:step() + T.eq(I:wasPressed(button), false, "holding does not repeat edges") + T.eq(I:isDown(button), true, "holding survives further steps") + T.eq(peer.release(a), false, "other mod cannot release token") + T.eq(own.release({}), false, "copied/forged token is refused") + T.eq(own.release(a), true, "own token released") + T.eq(I:isDown(button), true, "second token still holds") + T.eq(own.release(a), false, "release is idempotent") + T.eq(own.release(b), true, "second own token released") + T.eq(I:isDown(button), false, "last source releases held state") + state(button .. "-released") + end + I:reset() + own.tap("a") + own.tap("a") + I:step() + T.eq(I:wasPressed("a"), true, "multiple taps before step coalesce into one edge") + I:step() + T.eq(I:wasPressed("a"), false, "coalesced taps do not spill into another step") + -- Physical sources and other mods keep independent ownership. + local a, b = own.press("b"), peer.press("b") + I:keypressed("x") + I:gamepadpressed(nil, "b") + I:overlayPressed("b") + I:step() + T.eq(own.release(a), true, "primary release") + T.eq(I:isDown("b"), true, "other sources keep button down") + T.eq(peer.release(b), true, "peer releases only its own source") + I:keyreleased("x") + T.eq(I:isDown("b"), true, "pad and overlay still hold") + I:gamepadreleased(nil, "b") + T.eq(I:isDown("b"), true, "overlay still holds") + I:overlayReleased("b") + T.eq(I:isDown("b"), false, "all physical sources released") + I:keypressed("z") + own.tap("a") + I:step() + T.eq(I:wasPressed("a"), true, "tap coexists with physical hold") + T.eq(I:isDown("a"), true, "tap release preserves physical hold") + I:keyreleased("z") + -- Logical input ignores user keyboard remapping. + I:applyBindings({ a = { key = "v" } }) + own.tap("a") + I:step() + T.eq(I:wasPressed("a"), true, "tap uses logical action after rebind") + T.eq(I:isDown("a"), false, "rebound tap has no held state") + I:applyBindings(nil) + state("sources-and-rebind") + I:reset() + a = own.press("select") + T.eq(own.release(a), true, "release before first step") + I:step() + T.eq(I:wasPressed("select"), true, "press-release still has its queued edge") + T.eq(I:isDown("select"), false, "press-release does not become a hold") + I:step() + state("released-before-step") + for _, value in ipairs({ "", "A", "quit", "l", "r", "x", "y", false, 1, {}, function() end }) do + for _, method in ipairs({ "tap", "press" }) do + T.raises(function() + own[method](value) + end, "unknown GB button", "reject invalid logical button") + end + end + T.raises(function() + own.tap(nil) + end, "unknown GB button", "missing logical button") + T.eq(#I.pressQueue, 0, "validation does not queue input") + for _, value in ipairs({ "token", false, 1, {}, function() end }) do + T.eq(own.release(value), false, "malformed token declines") + end + T.eq(own.release(nil), false, "missing token declines") + -- Recovery invalidates only this loader's tokens and preserves keyboard. + a, b = own.press("a"), peer.press("a") + I:keypressed("z") + f.result.loader:releaseModInput("portable_mod_input") + T.eq(own.release(a), false, "owner recovery retires token") + T.eq(I:isDown("a"), true, "owner recovery preserves peer/keyboard") + T.eq(peer.release(b), true, "peer token still valid") + T.eq(I:isDown("a"), true, "keyboard remains after both mods release") + f.result.loader:releaseModInput() + T.eq(I:isDown("a"), true, "global mod recovery leaves keyboard alone") + I:keyreleased("z") + -- Recovery permits new presses, without reviving old tokens. + a = own.press("a") + f.result.loader:releaseModInput() + b = own.press("a") + T.eq(own.release(a), false, "stale token cannot release new hold") + T.eq(I:isDown("a"), true, "new hold is live") + T.eq(own.release(b), true, "replacement hold releases normally") + I:step() + I:step() + state("recovery") + return rows +end +return M diff --git a/tests/mod_input/fixture.lua b/tests/mod_input/fixture.lua new file mode 100644 index 000000000..9c4fe5b0f --- /dev/null +++ b/tests/mod_input/fixture.lua @@ -0,0 +1,60 @@ +local M = {} +function M.new(root, backend, version, api, format) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local Input = require("src.core.Input") + local files, paths = {}, {} + local Json = require("src.link.Json") + local function read(path) + local f = assert(io.open(root .. "/examples/portable_mod_input/" .. path, "rb")) + local bytes = f:read("*a") + f:close() + return bytes + end + local manifest = assert(Json.decode(read("manifest.json"))) + for _, id in ipairs({ "portable_mod_input", "portable_mod_input_peer" }) do + paths[#paths + 1] = "mods/" .. id + manifest.id, manifest.api, manifest.callback_format = id, api, format + files["mods/" .. id .. "/manifest.json"] = Json.encode(manifest) + files["mods/" .. id .. "/main.lua"] = read("main.lua") + end + local data = backend == "gen1" and generation == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = + T.sdk.loadMods(paths, { generation = generation, data = data, fs = T.sdk.memfs(files), dev = false }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + Input:init() + local game = { + input = Input, + mods = result.loader, + data = data, + save = { options = {} }, + stack = { + states = {}, + top = function() + return nil + end, + }, + } + result.loader.game = game + local f = { + T = T, + result = result, + Input = Input, + game = game, + primary = assert(result.loader.exports.portable_mod_input), + peer = assert(result.loader.exports.portable_mod_input_peer), + files = files, + paths = paths, + } + function f.ready() + result.loader.events:emit("game.ready", { game = game }) + end + function f.close() + result.loader:releaseModInput() + result.release() + end + return f +end +return M diff --git a/tests/mod_input/worker.lua b/tests/mod_input/worker.lua new file mode 100644 index 000000000..bbabd4b0a --- /dev/null +++ b/tests/mod_input/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local f = dofile(root .. "/tests/mod_input/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/mod_input/cases.lua").run(f) +f.close() +print("MOD_INPUT_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable mod.input contract") diff --git a/tests/native_http/BoundedHttpCheck.java b/tests/native_http/BoundedHttpCheck.java new file mode 100644 index 000000000..fdbb82c46 --- /dev/null +++ b/tests/native_http/BoundedHttpCheck.java @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package org.love2d.android; +import java.io.*; +import java.net.*; +import java.util.*; +import org.json.JSONObject; +public final class BoundedHttpCheck { + static int checks=0;static final List seen=Collections.synchronizedList(new ArrayList<>()); + static void check(boolean value,String label) {checks++;if(!value)throw new AssertionError(label);} + static final class Connection extends HttpURLConnection { + final ByteArrayOutputStream sent=new ByteArrayOutputStream();boolean closed; + Connection(URL url) {super(url);seen.add(this);} + public void connect() {} public boolean usingProxy(){return false;} + public synchronized void disconnect(){closed=true;notifyAll();} + public OutputStream getOutputStream(){return sent;} + public int getResponseCode(){return url.getPath().startsWith("/redirect") || url.getPath().equals("/downgrade") ? 307:200;} + public String getHeaderField(String name) { + if(name.equals("Location"))return (url.getPath().equals("/downgrade")?"http":"https")+"://fixture.invalid/target"; + if(name.equals("Content-Length"))return url.getPath().equals("/partial")?"100":null; + return null; + } + public InputStream getInputStream() { + if(url.getPath().equals("/slow"))return new InputStream(){public int read()throws IOException{ + synchronized(Connection.this){while(!closed){try{Connection.this.wait(1000);}catch(InterruptedException e){throw new IOException(e);}}} + throw new IOException("closed by deadline"); + }}; + byte[] bytes=url.getPath().equals("/post")?"private server reply".getBytes():new byte[]{0,(byte)255,13,10}; + if(url.getPath().equals("/chunked"))bytes=new byte[100]; + return new ByteArrayInputStream(bytes); + } + } + static JSONObject request(String method,String path)throws Exception { + JSONObject r=new JSONObject();r.put("boundedVersion",1);r.put("method",method);r.put("url","https://fixture.invalid"+path); + r.put("httpsOnly",true);r.put("discardBody",method.equals("POST"));r.put("maxSeconds",1);r.put("maxBytes",1024); + r.put("userAgent","gen2recomp-mod/native_probe");r.put("accept","*/*");r.put("contentType","text/plain; charset=utf-8"); + if(method.equals("POST"))r.put("bodyBase64",java.util.Base64.getEncoder().encodeToString(new byte[]{0,(byte)255,13,10})); + return r; + } + static JSONObject run(JSONObject r)throws Exception{return new JSONObject(BoundedHttp.request(r));} + public static void main(String[] args)throws Exception { + URL.setURLStreamHandlerFactory(protocol -> new URLStreamHandler(){protected URLConnection openConnection(URL u){return new Connection(u);}}); + JSONObject result=run(request("GET","/binary")); + check(result.getInt("status")==200,"GET status"); + check(Arrays.equals(java.util.Base64.getDecoder().decode(result.getString("bodyBase64")),new byte[]{0,(byte)255,13,10}),"binary response"); + result=run(request("POST","/post")); + check(!result.has("bodyBase64") && !result.has("error"),"one-way response hidden"); + Connection post=seen.get(seen.size()-1); + check(Arrays.equals(post.sent.toByteArray(),new byte[]{0,(byte)255,13,10}),"POST bytes"); + check(post.getRequestProperty("User-Agent").equals("gen2recomp-mod/native_probe"),"fixed identity"); + check(post.getRequestProperty("Accept-Encoding").equals("identity"),"no implicit compression"); + result=run(request("POST","/redirect-post"));check(result.has("error"),"POST redirect refused"); + check(seen.stream().noneMatch(c -> c.getURL().getPath().equals("/target")),"POST never forwarded"); + check(!run(request("GET","/redirect-get")).has("error"),"GET redirect allowed"); + check(run(request("GET","/downgrade")).has("error"),"HTTPS downgrade refused"); + for(String path:new String[]{"/chunked","/partial"}) { + JSONObject r=request("GET",path);r.put("maxBytes",path.equals("/partial")?1024:16);result=run(r); + check(result.has("error") && !result.has("bodyBase64"),"bounded complete response"); + } + JSONObject slow=request("GET","/slow");slow.put("maxSeconds",0.1);long start=System.nanoTime();result=run(slow); + check(result.has("error") && System.nanoTime()-start<700000000L,"absolute deadline closes connection"); + for(String key:new String[]{"boundedVersion","maxBytes","httpsOnly"}) { + JSONObject bad=request("GET","/binary");bad.put(key,key.equals("httpsOnly")?1:true); + check(run(bad).has("error"),"typed wire fields"); + } + JSONObject bad=request("POST","/post");bad.put("url","http://fixture.invalid/post");check(run(bad).has("error"),"POST HTTPS required"); + bad=request("GET","/binary");bad.put("url","file:///secret");check(run(bad).has("error"),"local file rejected"); + System.out.println("Native HTTP Java: "+checks+" PASS; URLConnection fixture, not Android TLS"); + } +} diff --git a/tests/native_http/android/util/Base64.java b/tests/native_http/android/util/Base64.java new file mode 100644 index 000000000..4908d9de3 --- /dev/null +++ b/tests/native_http/android/util/Base64.java @@ -0,0 +1,8 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +// Host-only codec adapter. Android builds use the platform implementation. +package android.util; +public final class Base64 { + public static final int DEFAULT=0,NO_WRAP=2; + public static byte[] decode(String input,int flags) {return java.util.Base64.getMimeDecoder().decode(input);} + public static String encodeToString(byte[] input,int flags) {return java.util.Base64.getEncoder().encodeToString(input);} +} diff --git a/tests/native_http/main.swift b/tests/native_http/main.swift new file mode 100644 index 000000000..59b808b4d --- /dev/null +++ b/tests/native_http/main.swift @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import Foundation +final class HTTPFixture: URLProtocol { + static let gate=NSLock() + static var seen: [URLRequest]=[] + static var bodies: [Data?]=[] + private var stopped=false + private let state=NSLock() + override class func canInit(with request: URLRequest) -> Bool {request.url?.host=="fixture.invalid"} + override class func canonicalRequest(for request: URLRequest) -> URLRequest {request} + override func stopLoading() {state.lock();stopped=true;state.unlock()} + override func startLoading() { + var body=request.httpBody + if body==nil, let stream=request.httpBodyStream { + stream.open();defer{stream.close()} + var collected=Data();var chunk=[UInt8](repeating:0,count:4096) + while true {let count=stream.read(&chunk,maxLength:chunk.count);if count<=0{break};collected.append(contentsOf:chunk.prefix(count))} + body=collected + } + Self.gate.lock();Self.seen.append(request);Self.bodies.append(body);Self.gate.unlock() + let path=request.url!.path + if path=="/redirect-post" || path=="/redirect-get" || path=="/downgrade" { + let target=URL(string:(path=="/downgrade" ? "http" : "https")+"://fixture.invalid/target")! + var next=request;next.url=target + let response=HTTPURLResponse(url:request.url!,statusCode:307,httpVersion:"HTTP/1.1",headerFields:["Location":target.absoluteString])! + client?.urlProtocol(self,wasRedirectedTo:next,redirectResponse:response) + return + } + if path=="/slow" { + DispatchQueue.global().asyncAfter(deadline:.now()+0.8) { + self.state.lock();let stopped=self.stopped;self.state.unlock() + if !stopped {self.reply(Data([1,2,3]),length:3)} + } + return + } + if path=="/oversized" {reply(Data(repeating:1,count:100),length:100);return} + if path=="/chunked" {reply(Data(repeating:2,count:100),length:nil);return} + if path=="/partial" {reply(Data([1,2]),length:100);return} + reply(path=="/post" ? Data("private server reply".utf8) : Data([0,255,13,10]),length:nil) + } + private func reply(_ bytes: Data,length: Int?) { + let headers=length.map{["Content-Length":String($0)]} ?? [:] + let response=HTTPURLResponse(url:request.url!,statusCode:200,httpVersion:"HTTP/1.1",headerFields:headers)! + client?.urlProtocol(self,didReceive:response,cacheStoragePolicy:.notAllowed) + client?.urlProtocol(self,didLoad:bytes);client?.urlProtocolDidFinishLoading(self) + } +} +var checks=0 +func check(_ ok: @autoclosure () -> Bool,_ label: String) {checks+=1;precondition(ok(),label)} +func wire(_ method: String="GET",_ path: String="/binary",_ extra: [String:Any]=[:]) -> String { + var req: [String:Any] = ["boundedVersion":1,"method":method,"url":"https://fixture.invalid"+path, + "httpsOnly":true,"discardBody":method=="POST","userAgent":"gen2recomp-mod/native_probe", + "accept":"*/*","contentType":"text/plain; charset=utf-8","maxSeconds":1.0,"maxBytes":1024] + if method=="POST" {req["bodyBase64"]=Data([0,255,13,10]).base64EncodedString()} + for (key,value) in extra {req[key]=value} + return String(data:try! JSONSerialization.data(withJSONObject:req),encoding:.utf8)! +} +func run(_ json: String) -> [String:Any] { + let config=URLSessionConfiguration.ephemeral;config.protocolClasses=[HTTPFixture.self] + let result=GRBoundedHTTP.perform(json,configuration:config) + return try! JSONSerialization.jsonObject(with:Data(result.utf8)) as! [String:Any] +} +let binary=run(wire()) +check((binary["status"] as? Int)==200,"GET status") +check(Data(base64Encoded:binary["bodyBase64"] as! String)==Data([0,255,13,10]),"binary reply preserved") +let post=run(wire("POST","/post")) +check((post["status"] as? Int)==200 && post["bodyBase64"]==nil && post["error"]==nil,"one-way POST discards reply") +HTTPFixture.gate.lock();let sent=HTTPFixture.seen.last!;let sentBody=HTTPFixture.bodies.last!;HTTPFixture.gate.unlock() +check(sent.httpMethod=="POST" && sentBody==Data([0,255,13,10]),"request bytes preserved") +check(sent.value(forHTTPHeaderField:"User-Agent")=="gen2recomp-mod/native_probe","fixed identity") +check(sent.value(forHTTPHeaderField:"Accept-Encoding")=="identity","no implicit transfer compression") +let redirected=run(wire("POST","/redirect-post")) +check(redirected["error"] != nil,"POST redirect refused") +HTTPFixture.gate.lock();let targets=HTTPFixture.seen.filter{$0.url?.path=="/target"}.count;HTTPFixture.gate.unlock() +check(targets==0,"POST is never forwarded to another destination") +let getRedirect=run(wire("GET","/redirect-get")) +check((getRedirect["status"] as? Int)==200,"GET follows allowed redirects") +check(run(wire("GET","/downgrade"))["error"] != nil,"HTTPS downgrade refused") +for path in ["/oversized","/chunked","/partial"] { + let result=run(wire("GET",path,["maxBytes":path=="/partial" ? 1024 : 16])) + check(result["error"] != nil && result["bodyBase64"]==nil,"bounded and complete response required") +} +let began=Date();let slow=run(wire("GET","/slow",["maxSeconds":0.1])) +check(slow["error"] != nil && Date().timeIntervalSince(began)<0.7,"absolute native deadline") +for change in [["boundedVersion":true],["maxBytes":true],["httpsOnly":1],["method":"DELETE"],["url":"file:///secret"], + ["maxSeconds":31],["userAgent":"bad\nheader"],["method":"POST","url":"http://fixture.invalid/post"]] as [[String:Any]] { + check(run(wire("GET","/binary",change))["error"] != nil,"invalid native wire refused") +} +print("Native HTTP Swift: \(checks) PASS; URLProtocol fixture, not physical TLS") diff --git a/tests/native_pack_runtime/conf.lua b/tests/native_pack_runtime/conf.lua new file mode 100644 index 000000000..74500596c --- /dev/null +++ b/tests/native_pack_runtime/conf.lua @@ -0,0 +1,6 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +function love.conf(t) + t.identity=assert(os.getenv('PACK_QA_IDENTITY'),'an isolated PACK_QA_IDENTITY is required') + assert(t.identity:match('^oasc%-') and t.identity:find('%-qa'), 'only an owned QA identity is allowed') + t.window=false;t.modules.graphics=false;t.modules.audio=false;t.modules.sound=false +end diff --git a/tests/native_pack_runtime/main.lua b/tests/native_pack_runtime/main.lua new file mode 100644 index 000000000..f8f44862c --- /dev/null +++ b/tests/native_pack_runtime/main.lua @@ -0,0 +1,101 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root=assert(os.getenv('PACK_ENGINE_ROOT'),'PACK_ENGINE_ROOT is required') +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +function love.load() + local ok,err=pcall(function() + local F=require('tests.pack_sources.fixture') + local LT=require('src.import.lttp.LttpImport') + local I=require('src.import.Importers') + local Packs=require('src.mods.AssetPacks') + local Inbox=require('src.import.PackInbox') + local MD5=require('src.mods.StreamMD5') + local Data=require('src.import.PackData') + local source=F.cartridge() + local hash=MD5.new():update(source):final() + assert(not LT.identify(source),'format fixture must not identify as a real cartridge') + LT.SOURCES[hash]={name='Generated format fixture, not cartridge evidence',size=#source} + local fs=I.fs() + assert(fs.getWriteRoot()==love.filesystem.getSaveDirectory(),'QA data must stay in its owned identity') + assert(not fs.getInfo(Inbox.SOURCES..'/lttp.sfc'),'use a fresh empty QA identity; never overwrite unrelated source files') + for _,pack in ipairs({'tiles','sprites','palettes'}) do + assert(not fs.getInfo(I.manifestPath('lttp',pack)),'use a fresh empty QA identity for full extraction evidence') + end + assert(fs.createDirectory(Inbox.SOURCES)) + local input=Inbox.SOURCES..'/lttp.sfc' + assert(fs.write(input,source)) + local function drain(co) + local yielded=0 + while true do + local resumed,value=coroutine.resume(co);assert(resumed,value) + if coroutine.status(co)=='dead' then return value,yielded end + yielded=yielded+1;assert(value.total>=value.done and value.done>=0) + end + end + local result,yields=drain(Inbox.start(fs)) + assert(result.found==1 and #result.installed==3 and #result.failed==0) + assert(yields>=231,'extraction must yield progress') + local api=Packs.new({required_assets={{importer='lttp',pack='sprites'}, + {importer='lttp',pack='tiles'},{importer='lttp',pack='palettes'}}},fs) + assert(api:info('lttp','sprites').entries==109) + assert(api:info('lttp','tiles').entries==116) + assert(api:info('lttp','palettes').entries==6) + local function image(pack,id,width,height) + local bytes=assert(api:read('lttp',pack,id)) + local file=love.filesystem.newFileData(bytes,id..'.png') + local decoded=love.image.newImageData(file);file:release() + assert(decoded:getWidth()==width and decoded:getHeight()==height) + return decoded + end + local sheet=image('sprites','sheet_115',128,32) + local r,g,b,a=sheet:getPixel(0,0);assert(r==1 and g==0 and b==0 and a==1);sheet:release() + sheet=image('tiles','sheet_000',128,32) + r,g,b,a=sheet:getPixel(0,0);assert(r==0 and g==1 and b==0 and a==1);sheet:release() + sheet=image('sprites','player',128,448) + r,g,b,a=sheet:getPixel(0,0);assert(r==0 and g==0 and b==1 and a==1) + r,g,b,a=sheet:getPixel(1,0);assert(a==0);sheet:release() + sheet=image('tiles','kakariko',1024,1024) + for _,xy in ipairs({{0,0},{15,0},{0,15},{15,15}}) do + r,g,b,a=sheet:getPixel(xy[1],xy[2]);assert(r==1 and g==0 and b==0 and a==1) + end + sheet:release() + local palettes=assert(Data.decode(assert(api:read('lttp','palettes','armor')))) + assert(#palettes==5 and #palettes[1]==15 and palettes[1][8][3]==255) + assert(fs.read(input)==source,'source file must be preserved') + local before={} + for _,pack in ipairs({'tiles','sprites','palettes'}) do before[pack]=fs.read(I.manifestPath('lttp',pack)) end + local reused,count=drain(LT.job(source,fs)) + assert(reused.reused and count==0,'existing complete packs must skip extraction') + for _,pack in ipairs({'tiles','sprites','palettes'}) do assert(fs.read(I.manifestPath('lttp',pack))==before[pack]) end + local failing=setmetatable({rename=function(from,to) + if to:match('/pack%.lua$') then return nil,'injected descriptor failure' end + return fs.rename(from,to) + end},{__index=fs}) + local failed=LT.job(source,failing,{force=true}) + local failure + while true do + local resumed,value=coroutine.resume(failed) + if not resumed then failure=value;break end + assert(coroutine.status(failed)~='dead','injected descriptor failure must propagate') + end + assert(tostring(failure):find('injected descriptor failure',1,true)) + for _,pack in ipairs({'tiles','sprites','palettes'}) do assert(fs.read(I.manifestPath('lttp',pack))==before[pack]) end + assert(api:read('lttp','sprites','player')) + local Writer=require('src.import.LuaWriter') + local override=Inbox.ROOT..'/lttp/palettes' + local paletteBytes=Writer.encode({{{1,2,3}}}) + assert(fs.createDirectory(override));assert(fs.write(override..'/armor.lua',paletteBytes)) + assert(fs.write(override..'/pack.lua',Writer.encode({format=1,importer='lttp',pack='palettes',kind='palette',version='9.0.0', + source={md5=string.rep('0',32),name='Generated explicit override'},entries={armor={file='armor.lua',size=#paletteBytes}}}))) + result=drain(Inbox.start(fs)) + assert(result.found==2 and #result.installed==3 and #result.failed==0,'source plus explicit override must count distinct packs') + assert(api:info('lttp','palettes').version=='9.0.0' and api:read('lttp','palettes','armor')==paletteBytes) + assert(fs.write(input,'Unsupported generated source')) + result=drain(Inbox.start(fs)) + assert(#result.failed==1 and #result.installed==1,'bad source must not prevent independent explicit pack installation') + assert(api:read('lttp','sprites','player') and fs.read(input)=='Unsupported generated source') + LT.SOURCES[hash]=nil + print('Native SNES extractor: 223 sheets, player, 4096 metatiles, 6 palettes, 3 packs, native PNG round trips, reuse and atomic failure: PASS') + print('Evidence uses generated format data. Real-cartridge pixel parity and device gameplay remain unverified.') + end) + if ok then love.event.quit(0) else io.stderr:write(tostring(err)..'\n');love.event.quit(1) end +end diff --git a/tests/pack_sources/fixture.lua b/tests/pack_sources/fixture.lua new file mode 100644 index 000000000..6c00d5127 --- /dev/null +++ b/tests/pack_sources/fixture.lua @@ -0,0 +1,62 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Generated format vectors; no cartridge bytes or extracted graphics. +local F={} +function F.memory(initial) + local data,times={},{};local clock=0 + for p,b in pairs(initial or {}) do data[p]=b;times[p]=0 end + local fs={maxRead=0,writes=0} + function fs.getInfo(p,kind) + if data[p] then return {type='file',size=#data[p],modtime=times[p]} end + if kind~='file' then for key in pairs(data) do if key:sub(1,#p+1)==p..'/' then return {type='directory'} end end end + end + function fs.read(p,n)return data[p] and data[p]:sub(1,n or -1)end + function fs.readRange(p,at,n)fs.maxRead=math.max(fs.maxRead,n);return data[p] and data[p]:sub(at+1,at+n)end + function fs.write(p,b) + fs.writes=fs.writes+1 + if fs.failWrite then return nil,'injected write failure' end + clock=clock+1;data[p]=fs.shortWrite and b:sub(1,1) or b;times[p]=clock;return true + end + function fs.rename(a,b) + if fs.throwRename then error('injected replacement exception') end + if fs.failRename then return nil,'injected replacement failure' end + if not data[a] then return nil,'missing staging data' end + clock=clock+1;data[b],times[b]=data[a],clock;data[a],times[a]=nil,nil;return true + end + function fs.createDirectory()return not fs.failDirectory end + function fs.remove(p)data[p],times[p]=nil,nil;return true end + return fs,data +end +function F.cartridge() + local patches={} + local function add(at,bytes)patches[#patches+1]={at=at,bytes=bytes}end + local function word(n)return string.char(n%256,math.floor(n/256))end + local banks,highs,lows={},{},{} + for i=0,222 do + local at=i>=115 and i<=126 and 0x93000 or 0x92000 + banks[#banks+1]=string.char(math.floor(at/32768)) + highs[#highs+1]=string.char(128+math.floor(at%32768/256)) + lows[#lows+1]=string.char(at%256) + end + add(0x4f80,table.concat(banks));add(0x505f,table.concat(highs));add(0x513e,table.concat(lows)) + local raw=string.char(128)..string.rep('\0',1535) + add(0x92000,string.char(0xe3,255)..raw:sub(1,1024)..string.char(0xe1,255)..raw:sub(1025)..string.char(255)) + add(0x93000,raw) + add(0x80000,string.rep('\0',17)..string.char(128)..string.rep('\0',0x7000-18)) + for _,spec in ipairs({{0xdd218,60,31},{0xdd290,60,31},{0xdd308,75,31}, + {0xdd39e,84,31},{0xdd734,240,992},{0xde604,84,31}}) do + local colors={} + for i=1,spec[2] do colors[i]=word(spec[1]==0xdd308 and i==8 and 31744 or spec[3]) end + add(spec[1],table.concat(colors)) + end + add(0xde86c,word(31)..string.rep('\0',40)) + add(0x78000,word(0x0800)..word(0x4800)..word(0x8800)..word(0xc800)) + table.sort(patches,function(a,b)return a.at=at,'overlapping generated test vectors') + chunks[#chunks+1]=string.rep('\0',p.at-at);chunks[#chunks+1]=p.bytes;at=p.at+#p.bytes + end + chunks[#chunks+1]=string.rep('\0',1048576-at) + return table.concat(chunks) +end +return F diff --git a/tests/palette_programs/fixture.lua b/tests/palette_programs/fixture.lua new file mode 100644 index 000000000..ea676df13 --- /dev/null +++ b/tests/palette_programs/fixture.lua @@ -0,0 +1,75 @@ +-- Original synthetic GBA data and tiny Thumb leaf initializers. No cartridge +-- code, palette bytes, ROM images or player files are distributed by this test. +local F={} +local Json=require("src.link.Json") +local Rom=require("src.import.RomGba") +local function le(n,count) + local s={};for i=1,count do s[i]=string.char(n%256);n=math.floor(n/256) end + return table.concat(s) +end +function F.new(game) + local file=assert(io.open("tools/rom_manifest_"..game..".json","rb")) + local manifest=Json.decode(file:read("*a"));file:close() + -- This synthetic cartridge contains palette programs, not the independent + -- retail terrain-flag table. Its source-byte/hash contract is tested by + -- gen3_source_flags and the real-cartridge integration host. + manifest.metatileBehaviorFlags=nil + local chunks={} + local out={manifest=manifest,frames={},initializers={},sets={}} + local function put(at,bytes) chunks[at]=bytes end + local function init(at,target,readCounter) + -- LDR literal / MOV immediate / counter halfword store; optional unknown + -- RAM read mirrors initializers that inherit the primary counter. + local h={0x4907,0x2000,0x8008,0x4907,0x2080,0x0080,0x8008, + 0x4906,0x4806,0x6008,0x4770} + if readCounter then h[2]=0x8808 end + local bytes={};for i,w in ipairs(h) do bytes[i]=le(w,2) end + put(at,table.concat(bytes)) + -- Pools reflect each LDR's aligned PC, not its apparent instruction PC. + put(at+32,le(0x03000010,4)) + put(at+36,le(0x03000012,4)) + put(at+40,le(0x03000020,4)) + put(at+44,le(target,4)) + out.initializers[#out.initializers+1]=at + return 0x08000000+at+1 + end + local profile=manifest.terrainPalettePrograms + local keys={};for key in pairs(profile.programs) do keys[#keys+1]=key end + table.sort(keys,function(a,b) return tonumber(a)=position,"overlapping synthetic writes") + pieces[#pieces+1]=string.rep("\0",at-position);pieces[#pieces+1]=chunks[at];position=at+#chunks[at] + end + pieces[#pieces+1]=string.rep("\0",0x530000-position) + out.rom=Rom.new(table.concat(pieces)) + function out.change(at,bytes) + out.rom=Rom.new(out.rom.data:sub(1,at)..bytes..out.rom.data:sub(at+#bytes+1)) + end + out.init=init + return out +end +F.le=le +return F diff --git a/tests/qa_extended_storage_test.py b/tests/qa_extended_storage_test.py new file mode 100644 index 000000000..8c3632efe --- /dev/null +++ b/tests/qa_extended_storage_test.py @@ -0,0 +1,150 @@ +"""Regression checks for pruning QA copies while retaining exact source/model bytes.""" +import fcntl +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch +sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tools')) +from qa_storage import Storage +from qa_preserved import Vault + + +class ExtendedStorage(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup) + self.home=Path(self.temp.name).resolve();self.root=self.home/'Documents/repo';self.root.mkdir(parents=True) + self.state=self.home/'registry' + + def manager(self,**options): + m=Storage(self.root,home=self.home,state=self.state,min_free=0,**options) + self.addCleanup(m.close);return m + + def put(self,path,data=b'payload'*2000): + path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(data);return path + + def profile(self,name='oasc-gen2recomp-qa-full109-20261025000000-firered'): + return self.home/'Library/Application Support/LOVE'/name + + def runpath(self,name='oasc-gen2recomp-qa-full109-20261025000000-firered'): + return self.root/'.local/qa/gen2-edition-mod'/name + + def show(self,proof,path): + return subprocess.check_output(['git','--git-dir='+proof['git'],'show',proof['commit']+':snapshot/'+path]) + + def test_ula_only_removes_compiled_cache(self): + m=self.manager(ula_limit=0) + profile=self.home/'Library/Application Support/LOVE/ula-gen2-native-qa' + compiled=m.ula(profile/'modstorage/german_rom_source/compiled') + old=self.put(compiled/'key/payload.bin') + protected=[self.put(profile/name) for name in ['red/save.lua','options.lua','mods/german_rom_source/main.lua','mods/german_rom_source/baseroms/rom.gbc','modstorage/german_rom_source/preferences.bin']] + m.close();self.assertFalse(old.exists());self.assertTrue(all(p.exists() for p in protected)) + + def test_ula_rejects_player_profile_and_symlink(self): + m=self.manager() + with self.assertRaises(ValueError):m.ula(self.home/'Library/Application Support/LOVE/player/modstorage/german_rom_source/compiled') + source=self.put(self.home/'original/source.lua') + target=self.home/'Library/Application Support/LOVE/ula-test-qa/modstorage/german_rom_source/compiled' + target.parent.mkdir(parents=True);target.symlink_to(source.parent,target_is_directory=True) + with self.assertRaises(ValueError):m.ula(target) + self.assertTrue(source.exists()) + + def test_ula_adopts_legacy_keys_before_starting_a_child(self): + path=self.home/'Library/Application Support/LOVE/ula-test-qa/modstorage/german_rom_source/compiled' + old=self.put(path/'legacy/assets/1.bin') + m=self.manager(ula_limit=0);m.ula(path) + self.assertFalse(old.exists()) + + def test_live_ula_lease_blocks_other_collectors(self): + first=self.manager(ula_limit=0) + target=first.ula(self.home/'Library/Application Support/LOVE/ula-test-qa/modstorage/german_rom_source/compiled') + file=self.put(target/'key/file.bin');other=self.manager(ula_limit=0) + other.sweep();self.assertTrue(file.exists());first.close();self.assertFalse(file.exists()) + + def test_installation_removed_after_source_and_models_are_preserved(self): + m=self.manager(import_limit=0) + profile=m.destination(self.profile());mod=profile/'mods/ORIGAMI_ASCENDANT_GEN2_PREP' + self.put(mod/'manifest.json',b'{"id":"ORIGAMI_ASCENDANT_GEN2_PREP"}') + self.put(mod/'main.lua',b'return "source"');self.put(mod/'models/one.bin',b'model\x00bytes') + original=self.put(self.home/'authored/source.lua',b'original') + (mod/'outside').symlink_to(original) + save=self.put(profile/'firered/save/save.lua');custom=self.put(profile/'mods/custom/source.lua') + m.close();self.assertFalse(mod.exists());self.assertTrue(save.exists() and custom.exists() and original.exists()) + proof=json.loads((profile/'qa-preserved-sources.json').read_text())['mods/ORIGAMI_ASCENDANT_GEN2_PREP'] + self.assertEqual(self.show(proof,'main.lua'),b'return "source"') + self.assertEqual(self.show(proof,'models/one.bin'),b'model\x00bytes') + self.assertEqual(self.show(proof,'outside'),os.fsencode(original)) + subprocess.run(['git','--git-dir='+proof['git'],'fsck','--full'],check=True,capture_output=True) + + def test_frozen_copy_retains_evidence_and_exact_recoverable_sources(self): + m=self.manager(copy_limit=0) + run=m.cache(self.runpath()) + self.put(run/'engine/source.lua',b'engine source');self.put(run/'engine/model.png',b'model') + self.put(run/'input-mod.zip',b'archived input') + reports=[self.put(run/name) for name in ['driver.lua','verification.json','native.log','view.png','result.json']] + m.close();self.assertFalse((run/'engine').exists());self.assertFalse((run/'input-mod.zip').exists()) + self.assertTrue(all(p.exists() for p in reports)) + proof=json.loads((run/'qa-preserved-sources.json').read_text()) + self.assertEqual(self.show(proof['engine'],'source.lua'),b'engine source') + self.assertEqual(self.show(proof['input-mod.zip'],'input-mod.zip'),b'archived input') + + def test_profile_input_lease_protects_the_corresponding_engine_copy(self): + owner=self.manager(copy_limit=10**8);run=owner.cache(self.runpath()) + source=self.put(run/'engine/source.lua');owner.close() + reader=self.manager(copy_limit=10**8);reader.source(self.profile()) + cleaner=self.manager(copy_limit=0);cleaner.sweep();self.assertTrue(source.exists()) + reader.close();cleaner.sweep();self.assertFalse(source.exists()) + + def test_legacy_group_lease_protects_copies_from_older_runners(self): + owner=self.manager(copy_limit=10**8);run=owner.cache(self.runpath()) + source=self.put(run/'engine/source.lua');owner.close() + with (self.state/(owner._key(run.parent)+'.lock')).open('a+') as lock: + fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB) + self.manager(copy_limit=0).sweep();self.assertTrue(source.exists()) + self.manager(copy_limit=0).sweep();self.assertFalse(source.exists()) + + def test_preservation_failure_keeps_runtime_copies(self): + m=self.manager(copy_limit=0);run=m.cache(self.runpath());source=self.put(run/'engine/source.lua') + with patch('qa_preserved.Vault.capture',side_effect=OSError('simulated archive failure')):m.close() + self.assertTrue(source.exists()) + + def test_source_checkouts_and_keep_markers_are_not_disposable(self): + m=self.manager(copy_limit=0) + run=m.cache(self.runpath());source=self.put(run/'engine/source.lua') + (run/'.qa-keep').touch();m.close();self.assertTrue(source.exists()) + other=self.manager(copy_limit=0);other.cache(self.root/'.local/qa/reference');original=self.put(self.root/'.local/qa/reference/engine/main.lua') + other.close();self.assertTrue(original.exists()) + + def test_geometry_collection_does_not_treat_engine_models_as_payloads(self): + m=self.manager(geometry_limit=0) + path=m.cache(self.root/'.local/qa/reference') + model=self.put(path/'engine/models'/('a'*64+'.data')) + generated=self.put(path/'bake'/('b'*64+'.data')) + m.close();self.assertTrue(model.exists());self.assertFalse(generated.exists()) + + def test_vault_deduplicates_without_mutable_source_aliases(self): + a=self.put(self.home/'a/model.bin',b'unique-model');b=self.put(self.home/'b/model.bin',b'unique-model') + with Vault(self.home) as vault: + first=vault.capture(a.parent);second=vault.capture(b.parent) + oid1=vault.git('rev-parse',first['commit']+':snapshot/model.bin') + oid2=vault.git('rev-parse',second['commit']+':snapshot/model.bin') + self.assertEqual(oid1,oid2) + a.write_bytes(b'changed');self.assertEqual(self.show(first,'model.bin'),b'unique-model');self.assertEqual(b.read_bytes(),b'unique-model') + + def test_native_hashing_preserves_raw_bytes_names_and_executable_mode(self): + folder=self.home/'raw' + self.put(folder/'.gitattributes',b'* text eol=lf\n') + name='Grüße "quoted"\nmodel.bin' + self.put(folder/name,b'raw\r\nbytes\x00') + executable=self.put(folder/'run.sh',b'#!/bin/sh\n');executable.chmod(0o755) + with Vault(self.home) as vault:proof=vault.capture(folder) + self.assertEqual(self.show(proof,name),b'raw\r\nbytes\x00') + mode=subprocess.check_output(['git','--git-dir='+proof['git'],'ls-tree',proof['commit'],'snapshot/run.sh']) + self.assertTrue(mode.startswith(b'100755 ')) + + +if __name__=='__main__':unittest.main() diff --git a/tests/qa_legacy_storage_test.py b/tests/qa_legacy_storage_test.py new file mode 100644 index 000000000..86c1f6f35 --- /dev/null +++ b/tests/qa_legacy_storage_test.py @@ -0,0 +1,187 @@ +import importlib.util +import json +import os +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +TOOLS=Path(__file__).resolve().parents[1]/'tools';sys.path.insert(0,str(TOOLS)) +from qa_storage import Storage +import qa_legacy_storage as legacy +from install_qa_maintenance import install +REAL_BUSY=legacy.LiveUse.busy + + +class Retention(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory();self.addCleanup(self.tmp.cleanup) + self.home=Path(self.tmp.name).resolve();self.root=self.home/'Documents/project';self.root.mkdir(parents=True) + self.activity=patch('qa_legacy_storage.LiveUse.busy',return_value=False);self.activity.start();self.addCleanup(self.activity.stop) + + def manager(self,**options): + m=Storage(self.root,home=self.home,min_free=0,**options);self.addCleanup(m.close);return m + + def put(self,p,data=b'source'): + p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data);return p + + def show(self,parent,name,rel): + proof=json.loads((parent/'qa-preserved-sources.json').read_text())[name] + return subprocess.check_output(['git','--git-dir='+proof['git'],'show',proof['commit']+':snapshot/'+rel]) + + def test_baseline_preserves_exact_models_sources_and_external_symlink(self): + m=self.manager(copy_limit=0);p=m.copy(self.root/'.local/main-baseline') + self.put(p/'main.lua',b'code\x00');self.put(p/'models/model.png',b'model') + external=self.put(self.home/'original/model.bin',b'original');(p/'external').symlink_to(external) + m.close();self.assertFalse(p.exists());self.assertEqual(external.read_bytes(),b'original') + self.assertEqual(self.show(p.parent,p.name,'models/model.png'),b'model') + self.assertEqual(self.show(p.parent,p.name,'external'),os.fsencode(external)) + + def test_test_mod_roms_preferences_saves_and_custom_mod_remain(self): + m=self.manager(copy_limit=0);profile=self.home/'Library/Application Support/LOVE/ula-live-switch-suite-0' + mod=m.copy(profile/'mods/german_rom_source');self.put(mod/'manifest.json',b'{"id":"german_rom_source"}');self.put(mod/'code.lua') + protected=[self.put(mod/'baseroms/original.gba'),self.put(mod/'baseroms/preferences.txt'),self.put(mod/'nested/saves/a.bin'),self.put(mod/'nested/game.sav'),self.put(profile/'red/save/player.lua'),self.put(profile/'mods/custom/model.bin')] + m.close();self.assertTrue(all(p.exists()for p in protected));self.assertFalse((mod/'code.lua').exists()) + self.assertEqual(self.show(mod.parent,mod.name,'code.lua'),b'source') + + def test_nested_keep_marker_keeps_subtree(self): + m=self.manager(copy_limit=0);p=m.copy(self.root/'.local/baseline') + self.put(p/'old.lua');kept=self.put(p/'kept/source.lua');self.put(p/'kept/.qa-keep',b'') + m.close();self.assertTrue(kept.exists());self.assertFalse((p/'old.lua').exists()) + + def test_real_or_nested_git_checkouts_never_pruned(self): + m=self.manager(copy_limit=0);p=m.copy(self.root/'.local/engine-parity/reference') + source=self.put(p/'main.lua');self.put(p/'nested/.git/HEAD',b'ref: refs/heads/main') + m.close();self.assertTrue(source.exists()) + + def test_native_engine_copy_keeps_reports_host_and_saves(self): + m=self.manager(copy_limit=0);run=self.root/'.local/qa/gen2recomp-qa-ruby-20261025000000' + m.cache(run);source=self.put(run/'engine/main.lua');report=self.put(run/'verification.json');host=self.put(run/'host/conf.lua') + save=self.put(self.home/'Library/Application Support/LOVE'/run.name/'ruby/save/player.lua') + m.close();self.assertFalse(source.exists());self.assertTrue(report.exists()and host.exists()and save.exists()) + + def test_source_lease_protects_baseline(self): + p=self.root/'.local/main-baseline';writer=self.manager(copy_limit=100000);writer.copy(p);source=self.put(p/'main.lua');writer.close() + reader=self.manager();reader.source(p/'main.lua') + cleaner=self.manager(copy_limit=0);cleaner.sweep();self.assertTrue(source.exists()) + reader.close(collect=False);cleaner.sweep();self.assertFalse(source.exists()) + + def test_profile_and_compiled_cache_leases_protect_mod_installation(self): + profile=self.home/'Library/Application Support/LOVE/ula-test-qa';mod=profile/'mods/german_rom_source' + writer=self.manager(copy_limit=100000);writer.copy(mod);source=self.put(mod/'code.lua');writer.close() + owner=self.manager();owner.ula(profile/'modstorage/german_rom_source/compiled') + cleaner=self.manager(copy_limit=0);cleaner.sweep();self.assertTrue(source.exists()) + owner.close(collect=False);cleaner.sweep();self.assertFalse(source.exists()) + + def test_inherited_legacy_lease_survives_parent_close(self): + owner=self.manager(copy_limit=0);p=owner.copy(self.root/'.local/baseline');source=self.put(p/'main.lua') + child=owner.popen([sys.executable,'-c','import time;time.sleep(30)']) + try: + owner.close();cleaner=self.manager(copy_limit=0);cleaner.sweep();self.assertTrue(source.exists()) + finally:child.terminate();child.wait() + cleaner.sweep();self.assertFalse(source.exists()) + + def test_combined_budget_for_modern_and_additional_copies(self): + clock=[time.time()];options=dict(copy_limit=100000,clock=lambda:clock[0]) + old=self.manager(**options);run=old.cache(self.root/'.local/qa/gen2-edition-mod/oasc-gen2recomp-qa-full109-20261025000000-ruby');a=self.put(run/'engine/a.lua');old.close() + clock[0]+=1;new=self.manager(**options);p=new.copy(self.root/'.local/main-baseline');b=self.put(p/'b.lua');new.close() + cleaner=self.manager(copy_limit=6000,clock=lambda:clock[0]);cleaner.sweep() + self.assertFalse(a.exists());self.assertTrue(b.exists()) + + def test_failed_archive_retains_copy(self): + m=self.manager(copy_limit=0);p=m.copy(self.root/'.local/main-baseline');source=self.put(p/'main.lua') + with patch('qa_preserved.Vault.capture',side_effect=OSError('archive unavailable')):m.close() + self.assertTrue(source.exists()) + + def test_new_reader_after_archiving_prevents_deletion(self): + p=self.root/'.local/main-baseline';source=self.put(p/'main.lua') + with self.assertRaises(RuntimeError):legacy.preserve(p,self.home,active=lambda path:True) + self.assertTrue(source.exists()) + + def test_new_immutable_copy_is_not_discovered_as_old(self): + p=self.root/'.local/main-baseline';source=self.put(p/'main.lua');os.utime(source,(100,100));os.utime(p,(100,100)) + self.manager(copy_limit=0).sweep();self.assertTrue(source.exists()) + + def test_periodic_discovery_removes_old_unregistered_copy(self): + p=self.root/'.local/engine-parity/old';source=self.put(p/'main.lua') + self.manager(copy_limit=0,clock=lambda:time.time()+7200).sweep();self.assertFalse(source.exists()) + + def test_unmanaged_live_process_cwd_is_protected(self): + p=self.root/'.local/main-baseline';p.mkdir(parents=True) + child=subprocess.Popen([sys.executable,'-c','import time;time.sleep(30)'],cwd=p) + try:self.assertTrue(REAL_BUSY(legacy.LiveUse(),p)) + finally:child.terminate();child.wait() + + def test_process_inspection_failure_fails_closed(self): + with patch('qa_legacy_storage.subprocess.check_output',side_effect=OSError('ps unavailable')): + self.assertTrue(REAL_BUSY(legacy.LiveUse(),self.root/'.local/baseline')) + + def test_arbitrary_sources_players_releases_and_aliases_are_rejected(self): + m=self.manager() + for p in [self.root/'src',self.home/'Library/Application Support/LOVE/player/mods/german_rom_source',self.root/'dist/official-release.zip']: + with self.assertRaises(ValueError):m.copy(p) + original=self.put(self.home/'original/a');link=self.root/'.local/main-baseline';link.parent.mkdir();link.symlink_to(original.parent) + with self.assertRaises(ValueError):m.copy(link) + + def test_candidate_zip_is_archived_but_official_release_retained(self): + m=self.manager(copy_limit=0);p=m.copy(self.root/'dist/Origami-Ascendant-Gen2Recomped-test-candidate.zip');self.put(p,b'zip bytes') + release=self.put(p.parent/'official-release.zip');m.close() + self.assertFalse(p.exists());self.assertTrue(release.exists());self.assertEqual(self.show(p.parent,p.name,p.name),b'zip bytes') + + def test_native_qa_import_cleanup_preserves_original_rom_and_save(self): + m=self.manager(import_limit=0);p=m.destination(self.home/'Library/Application Support/LOVE/gen2recomp-qa-ruby-20261025000000') + payload=self.put(p/'ruby/data/generated/maps.lua');original=self.put(p/'mods/german_rom_source/baseroms/original.gba');save=self.put(p/'ruby/save/player.lua') + m.close();self.assertFalse(payload.exists());self.assertTrue(original.exists()and save.exists()) + + def test_environment_reference_is_pinned_before_first_collection(self): + writer=self.manager(copy_limit=100000);p=writer.copy(self.root/'.local/main-baseline');source=self.put(p/'a.lua');writer.close() + reader=self.manager(copy_limit=0) + reader.run([sys.executable,'-c','pass'],env=dict(os.environ,QA_REFERENCE_ROOT=str(p)),check=True) + self.assertTrue(source.exists());self.manager(copy_limit=0).sweep();self.assertTrue(source.exists()) + reader.close(collect=False);self.manager(copy_limit=0).sweep();self.assertFalse(source.exists()) + + def test_unmanaged_rewrite_protects_an_old_discovered_record(self): + m=self.manager(copy_limit=0);p=self.root/'.local/main-baseline';source=self.put(p/'a.lua') + m._write(m._key(p),dict(kind='legacy',path=str(p),root=str(self.root),updated=time.time()-90000,running=False,discovered=True)) + m.sweep();self.assertTrue(source.exists()) + + def test_same_named_foreign_mod_and_empty_save_folders_remain(self): + m=self.manager(copy_limit=0);p=m.copy(self.home/'Library/Application Support/LOVE/ula-test-qa/mods/german_rom_source') + source=self.put(p/'main.lua');self.put(p/'manifest.json',b'{"id":"foreign"}');m.close();self.assertTrue(source.exists()) + n=self.manager(copy_limit=0);base=n.copy(self.root/'.local/baseline');self.put(base/'a.lua');empty=base/'baseroms/empty';empty.mkdir(parents=True) + n.close();self.assertTrue(empty.is_dir()) + + def test_gen1_generated_payloads_are_bounded_without_touching_saves(self): + m=self.manager(import_limit=0);profile=m.destination(self.home/'Library/Application Support/LOVE/ula-test-qa') + payload=self.put(profile/'gold/assets/generated/sprite.png');save=self.put(profile/'gold/save/player.lua') + m.close();self.assertFalse(payload.exists());self.assertTrue(save.exists()) + + def test_legacy_zip_argument_is_pinned_before_destination_collection(self): + writer=self.manager(copy_limit=100000);p=writer.copy(self.root/'dist/Origami-Ascendant-Gen2Recomped-test-candidate.zip');self.put(p);writer.close() + with patch.object(sys,'argv',['qa.py','--zip',str(p)]):reader=self.manager(copy_limit=0) + reader.sweep();self.assertTrue(p.exists()) + reader.close(collect=False);self.manager(copy_limit=0).sweep();self.assertFalse(p.exists()) + + def test_unmanaged_native_identity_protects_sibling_host_engine(self): + writer=self.manager(copy_limit=100000);run=self.root/'.local/qa/gen2recomp-qa-ruby-20261025000000' + writer.cache(run);source=self.put(run/'engine/main.lua');writer.close() + profile=self.home/'Library/Application Support/LOVE'/run.name + self.activity.stop() + with patch('qa_legacy_storage.LiveUse.busy',side_effect=lambda p:p==profile): + self.manager(copy_limit=0).sweep();self.assertTrue(source.exists()) + self.activity.start() + + def test_maintenance_install_uses_stable_package_and_smokes_without_real_home(self): + plist,package=install([self.root],home=self.home,load=False,python=sys.executable) + cfg=plistlib.loads(plist.read_bytes());self.assertEqual(cfg['StartInterval'],900) + self.assertTrue(str(package).startswith(str(self.home/'.local/lib/'))) + completed=subprocess.run(cfg['ProgramArguments'],env=dict(os.environ,HOME=str(self.home)),capture_output=True,text=True) + self.assertEqual(completed.returncode,0,completed.stderr) + self.assertTrue((self.home/'.cache/origami-qa-storage/maintenance.log').is_file()) + + +if __name__=='__main__':unittest.main() diff --git a/tests/qa_storage_test.py b/tests/qa_storage_test.py new file mode 100644 index 000000000..9c111455f --- /dev/null +++ b/tests/qa_storage_test.py @@ -0,0 +1,359 @@ +"""Filesystem and process-level safety checks for disposable QA caches.""" +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +TOOLS = Path(__file__).resolve().parents[1] / "tools" +sys.path.insert(0, str(TOOLS)) +from qa_storage import Storage + + +class CacheLifecycle(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + self.root = self.home / "Documents/repo" + self.root.mkdir(parents=True) + self.state = self.home / "registry" + self.managers = [] + + def manager(self, **options): + manager = Storage(self.root, home=self.home, state=self.state, + min_free=0, **options) + self.managers.append(manager) + self.addCleanup(manager.close) + return manager + + def profile(self, name="oasc-test-qa-fixture"): + return self.home / "Library/Application Support/LOVE" / name + + def payload(self, path, size=12000): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x" * size) + return path + + def test_finish_removes_only_generated_imports(self): + manager = self.manager(import_limit=0) + profile = manager.destination(self.profile()) + generated = self.payload(profile / "emerald/data/generated/maps.lua") + assets = self.payload(profile / "emerald/assets/generated/tile.png") + marker = self.payload(profile / "emerald/rom-cache.complete") + protected = [self.payload(profile / p) for p in + ["emerald/save/save.lua", "options.lua", "result.json", "shot.png", + "mods/custom/main.lua"]] + manager.close() + self.assertFalse(generated.exists()) + self.assertFalse(assets.exists()) + self.assertFalse(marker.exists()) + self.assertTrue(all(p.exists() for p in protected)) + + def test_player_and_unsafe_identity_are_rejected(self): + manager = self.manager() + player = self.payload(self.profile("oasc-private-work-1-0-1") / + "emerald/data/generated/maps.lua") + with self.assertRaises(ValueError): + manager.destination(player.parents[3]) + self.assertTrue(player.exists()) + with self.assertRaises(ValueError): + manager.identity("../oasc-test-qa-escape") + + def test_symlinked_generated_cache_never_changes_target(self): + manager = self.manager(import_limit=0) + profile = manager.destination(self.profile()) + original = self.payload(self.home / "original/maps.lua") + link = profile / "emerald/data/generated" + link.parent.mkdir(parents=True) + link.symlink_to(original.parent, target_is_directory=True) + manager.close() + self.assertTrue(original.exists()) + self.assertTrue(link.is_symlink()) + + def test_foreign_nested_link_and_readonly_cache(self): + manager = self.manager(import_limit=0) + profile = manager.destination(self.profile()) + file = self.payload(profile / "emerald/data/generated/maps.lua") + original = self.payload(self.home / "source.txt") + (file.parent / "foreign").symlink_to(original) + file.parent.chmod(0o500) + manager.close() + self.assertFalse(file.exists()) + self.assertTrue(original.exists()) + + def test_active_profile_cannot_be_evicted_by_another_manager(self): + first = self.manager(import_limit=0) + profile = first.destination(self.profile()) + data = self.payload(profile / "emerald/data/generated/maps.lua") + second = self.manager(import_limit=0) + second.sweep() + self.assertTrue(data.exists()) + first.close() + self.assertFalse(data.exists()) + + def test_borrowed_cache_stays_pinned_until_reader_finishes(self): + owner = self.manager(import_limit=10**8) + profile = owner.destination(self.profile()) + data = self.payload(profile / "emerald/data/generated/maps.lua") + owner.close() + reader = self.manager(import_limit=10**8) + reader.source(profile) + cleaner = self.manager(import_limit=0) + cleaner.sweep() + self.assertTrue(data.exists()) + reader.close() + cleaner.sweep() + self.assertFalse(data.exists()) + + def test_default_import_aliases_pin_all_source_editions_before_collection(self): + owner = self.manager(import_limit=10**8) + profile = owner.destination(self.profile()) + data = self.payload(profile / 'ruby/data/generated/map.lua') + owner.close() + imports = self.root / '.local/imports' + imports.mkdir(parents=True) + (imports / 'ruby').symlink_to(profile / 'ruby', target_is_directory=True) + reader = self.manager(import_limit=0) + reader.cache(self.root / '.local/qa/new-run') + self.assertTrue(data.exists()) + reader.close() + self.assertTrue(data.exists(), 'persistent .local/imports template must remain reusable') + (imports / 'ruby').unlink() + self.manager(import_limit=0).sweep() + self.assertFalse(data.exists()) + + def test_cache_budget_and_age_keep_reports_and_installed_cache(self): + manager = self.manager(geometry_limit=0) + output = manager.cache(self.root / ".local/qa/bake") + old = self.payload(output / "ruby" / ("a" * 64 + ".data")) + installed = self.payload(output / "bundle" / ("b" * 64 + ".data")) + report = self.payload(output / "result.json") + index = self.payload(output / "ruby/index.data") + (self.root / "gen3").mkdir() + (self.root / "gen3/render-cache").symlink_to(installed.parent, + target_is_directory=True) + manager.close() + self.assertFalse(old.exists()) + self.assertTrue(installed.exists()) + self.assertTrue(report.exists()) + self.assertTrue(index.exists()) + + def test_explicit_output_outside_qa_is_not_disposable(self): + manager = self.manager(geometry_limit=0) + folder = self.home / "release" + self.assertEqual(manager.cache(folder), folder) + file = self.payload(folder / ("c" * 64 + ".data")) + manager.close() + self.assertTrue(file.exists()) + + def test_keep_marker_preserves_reference_payload(self): + manager = self.manager(geometry_limit=0, max_age=0) + folder = manager.cache(self.root / '.local/qa/reference') + file = self.payload(folder / ('d' * 64 + '.data')) + (folder / '.qa-keep').touch() + manager.close() + self.assertTrue(file.exists()) + (folder / '.qa-keep').unlink() + self.manager(geometry_limit=0).sweep() + self.assertFalse(file.exists()) + + def test_installed_cache_in_another_worktree_remains_protected(self): + manager = self.manager(geometry_limit=0) + folder = manager.cache(self.root / '.local/qa/bake') + file = self.payload(folder / ('f' * 64 + '.data')) + other = self.home / 'Documents/consumer' + (other / 'gen3').mkdir(parents=True) + (other / 'gen3/render-cache').symlink_to(folder, target_is_directory=True) + consumer = Storage(other, home=self.home, state=self.state, + min_free=0, geometry_limit=0) + consumer.close() + manager.close() + self.assertTrue(file.exists()) + (other / 'gen3/render-cache').unlink() + self.manager(geometry_limit=0).sweep() + self.assertFalse(file.exists()) + + def test_budget_is_shared_across_worktrees(self): + owner = self.manager(geometry_limit=10**8) + file = self.payload(owner.cache(self.root / '.local/qa/bake') / ('e' * 64 + '.data')) + owner.close() + other_root = self.home / 'Documents/other-repo' + other_root.mkdir() + cleaner = Storage(other_root, home=self.home, state=self.state, + min_free=0, geometry_limit=0) + self.addCleanup(cleaner.close) + cleaner.sweep() + self.assertFalse(file.exists()) + + def test_old_idle_entry_expires_even_below_budget(self): + manager = self.manager(import_limit=10**8, max_age=1) + profile = manager.destination(self.profile()) + data = self.payload(profile / "ruby/data/generated/maps.lua") + manager.close() + cleaner = self.manager(import_limit=10**8, max_age=1, + clock=lambda: time.time() + 5) + cleaner.sweep() + self.assertFalse(data.exists()) + + def test_low_disk_refuses_child_before_it_writes(self): + manager = self.manager() + manager.identity("oasc-test-qa-fixture") + manager.min_free = 100 + marker = self.home / "child-started" + with patch("qa_storage.shutil.disk_usage", return_value=(100, 99, 1)): + with self.assertRaises(RuntimeError): + manager.run([sys.executable, "-c", + "from pathlib import Path;Path(%r).touch()" % str(marker)]) + self.assertFalse(marker.exists()) + + def test_timeout_releases_after_child_has_stopped(self): + manager = self.manager(import_limit=0) + profile = manager.destination(self.profile()) + data = self.payload(profile / "ruby/data/generated/maps.lua") + with self.assertRaises(subprocess.TimeoutExpired): + manager.run([sys.executable, "-c", "import time;time.sleep(5)"], timeout=.1) + manager.close() + self.assertFalse(data.exists()) + + def test_running_child_is_stopped_before_disk_is_exhausted(self): + manager = self.manager(import_limit=0) + manager.identity("oasc-test-qa-fixture") + manager.min_free = 100 + finished = self.home / "finished" + readings = iter([(1000, 0, 1000), (1000, 999, 1)]) + with patch("qa_storage.shutil.disk_usage", side_effect=lambda _: next(readings)): + with self.assertRaisesRegex(RuntimeError, "safety reserve"): + manager.run([sys.executable, "-c", + "import time;from pathlib import Path;time.sleep(5);Path(%r).touch()" % str(finished)]) + self.assertFalse(finished.exists()) + + def test_subprocess_output_input_and_failure_contract_is_preserved(self): + manager = self.manager() + result = manager.run([sys.executable, "-c", "import sys;print(sys.stdin.read().upper())"], + input="hello", capture_output=True, text=True, check=True) + self.assertEqual(result.stdout, "HELLO\n") + self.assertEqual(result.returncode, 0) + with self.assertRaises(subprocess.CalledProcessError) as error: + manager.run([sys.executable, "-c", "import sys;print('proof');sys.exit(3)"], + capture_output=True, text=True, check=True) + self.assertEqual(error.exception.returncode, 3) + self.assertEqual(error.exception.stdout, "proof\n") + + def test_popen_guard_reports_failure_to_polling_runner(self): + manager = self.manager() + manager.min_free = 100 + readings = iter([(1000, 0, 1000), (1000, 999, 1)]) + with patch('qa_storage.shutil.disk_usage', side_effect=lambda _: next(readings)): + process = manager.popen([sys.executable, '-c', 'import time;time.sleep(10)']) + with self.assertRaisesRegex(RuntimeError, 'safety reserve'): + process.wait(timeout=5) + with self.assertRaises(RuntimeError): + process.poll() + + def test_actual_shell_launcher_preserves_child_leases(self): + manager = self.manager(import_limit=0) + profile = manager.destination(self.profile()) + self.payload(profile / 'ruby/data/generated/map.lua') + # Use the real production shell entrypoint, with a fake executable. + launcher = TOOLS.parent / 'Start.command' + (self.root / 'Start.command').write_bytes(launcher.read_bytes()) + (self.root / 'Start.command').chmod(0o700) + local = self.root / '.local' + local.mkdir() + fake = self.root / 'fake-love' + fake.write_text('#!' + sys.executable + '\nimport os,json\n' + "data=json.loads(os.environ['ORIGAMI_QA_LEASES'])\n" + "assert all(os.fstat(x['fd']) for x in data['leases'])\n" + "assert os.environ['POKEPORT_IDENTITY']=='oasc-test-qa-fixture'\n" + "print('leases-survived-shell')\n") + fake.chmod(0o700) + (local / 'love-path.txt').write_text(str(fake)) + result = manager.run([str(self.root / 'Start.command'), 'ruby'], + env=dict(os.environ, OA_QA_IDENTITY=profile.name), + capture_output=True, text=True, check=True, timeout=5) + self.assertEqual(result.stdout, 'leases-survived-shell\n') + + def test_nested_python_runner_reuses_parent_lease_without_deadlock(self): + manager = self.manager(import_limit=0) + profile = manager.destination(self.profile()) + data = self.payload(profile / "ruby/data/generated/maps.lua") + code = ("import sys;from pathlib import Path;sys.path.insert(0,%r);" + "from qa_storage import Storage;" + "s=Storage(Path(%r),home=Path(%r),state=Path(%r),min_free=0,import_limit=0);" + "s.destination(Path(%r));s.close()" % + (str(TOOLS), str(self.root), str(self.home), str(self.state), str(profile))) + manager.run([sys.executable, "-c", code], check=True, timeout=5) + self.assertTrue(data.exists()) + manager.close() + self.assertFalse(data.exists()) + + def test_love11_identity_does_not_create_a_second_love12_cache(self): + manager = self.manager() + profile = self.home / "Library/Application Support/oasc-test-qa-lua11" + manager.destination(profile) + manager.run([sys.executable, "-c", "pass"], + env=dict(os.environ, POKEPORT_IDENTITY=profile.name), check=True) + records = [json.loads(p.read_text()) for p in self.state.glob("*.json")] + self.assertEqual([p["path"] for p in records], [str(profile.resolve())]) + + def test_gen2_cli_identity_is_managed_without_environment_override(self): + manager = self.manager(import_limit=0) + identity = 'oasc-gen2recomp-qa-native_fixture' + data = self.payload(self.profile(identity) / 'ruby/data/generated/map.lua') + process = manager.popen([sys.executable, '-c', 'import time;time.sleep(.1)', + '--qa-identity', identity]) + cleaner = self.manager(import_limit=0) + cleaner.sweep() + self.assertTrue(data.exists()) + self.assertEqual(process.wait(timeout=5), 0) + manager.close() + self.assertFalse(data.exists()) + + def test_child_retains_lease_if_parent_is_killed(self): + script = self.home / "owner.py" + ready = self.home / "ready" + child_pid = self.home / "child.pid" + script.write_text("import sys,time,os\nfrom pathlib import Path\n" + "sys.path.insert(0,%r)\nfrom qa_storage import Storage\n" % str(TOOLS) + + "s=Storage(Path(%r),home=Path(%r),state=Path(%r),min_free=0,import_limit=0)\n" % + (str(self.root), str(self.home), str(self.state)) + + "p=s.destination(Path(%r))/'ruby/data/generated/map.lua'\n" % str(self.profile()) + + "p.parent.mkdir(parents=True);p.write_text('cache')\n" + "s.run([sys.executable,'-c',%r])\n" % + ("import os,time;from pathlib import Path;Path(%r).write_text(str(os.getpid()));Path(%r).touch();time.sleep(30)" % + (str(child_pid), str(ready)))) + owner = subprocess.Popen([sys.executable, str(script)]) + self.addCleanup(lambda: owner.poll() is None and owner.kill()) + deadline = time.monotonic() + 10 + while not ready.exists() and time.monotonic() < deadline: + time.sleep(.02) + self.assertTrue(ready.exists()) + pid = int(child_pid.read_text()) + self.addCleanup(lambda: self.kill_if_alive(pid)) + owner.kill(); owner.wait() + cleaner = self.manager(import_limit=0) + cleaner.sweep() + data = self.profile() / "ruby/data/generated/map.lua" + self.assertTrue(data.exists(), "live child lost its imported data") + os.kill(pid, 15) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + cleaner.sweep() + if not data.exists(): break + time.sleep(.02) + self.assertFalse(data.exists(), "abandoned data was not reclaimed") + + @staticmethod + def kill_if_alive(pid): + try: os.kill(pid, 9) + except ProcessLookupError: pass + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/rs_ball_native_driver.lua b/tests/rs_ball_native_driver.lua new file mode 100644 index 000000000..8aba5128d --- /dev/null +++ b/tests/rs_ball_native_driver.lua @@ -0,0 +1,67 @@ +-- Fresh native RS import with every mod disabled. Only own explicit fixtures. +return function(game) + io.stdout:setvbuf('no');assert(love.filesystem.getIdentity():match('^gen2recomp%-qa%-')) + local Json=require('src.link.Json');local edition=require('src.core.GameVersion').get() + assert(edition=='ruby'or edition=='sapphire');assert(next(game.mods.exports)==nil,'Native ball test loaded mod exports') + local P=require('src.render.Pipelines');for _,r in ipairs(P.list())do if not P.isInternal(r.id)then P.setLevel(r.id,0)end end + local record=assert(game.data.constants.gen3BallAnim);assert(record.timing.sendOut and record.source:find('matching RS',1,true)) + local Assets=require('src.render.Assets');local balls=Assets.image(record.images.balls);local particles=Assets.image(record.images.particles) + assert(balls:getWidth()==48 and balls:getHeight()==192 and particles:getWidth()==64 and particles:getHeight()==8) + assert(record.sounds.caught==371 and record.sounds.click==nil) + assert(love.window.setMode(1200,800,{resizable=true,highdpi=false,vsync=0})) + local observe=function()end + local function wait(n)for _=1,n do coroutine.yield();observe()end end + local function press(k)game.input:overlayPressed(k);wait(1);game.input:overlayReleased(k);wait(1)end + game.stack:init();game.stack:push(game.overworld,'MAP_G00_N17',40,11,'up');wait(2) + local wanted={};for id,tr in pairs(game.data.trainers)do local p=tr.parties and tr.parties[1] + if tr.picIndex==2 and not tr.doubleBattle and p and #p>0 then wanted[#wanted+1]=id end + end + local function level(id)local l=0;for _,m in ipairs(game.data.trainers[id].parties[1])do l=math.max(l,m.level)end;return l end + table.sort(wanted,function(a,b)local x,y=level(a),level(b);return x=6 and not views[ball.battler]then + local g=love.graphics;local frame=b.frame;local age=ball.frame + local prefix='native-ball-'..gender..'-'..(ball.battler==b.player and'player'or'enemy') + local function capture(name) + local c=g.newCanvas(240,160,{dpiscale=1});local depth=g.getStackDepth() + g.push('all');local ok,why=xpcall(function()g.setCanvas(c);g.origin();g.setShader();g.clear(0,0,0,0);b:draw()end,debug.traceback) + while g.getStackDepth()>depth do g.pop()end;if not ok then c:release();error(why)end + local data=c:newImageData();local bytes=data:getString();assert(love.filesystem.write(prefix..name..'.png',data:encode('png')));data:release();c:release() + assert(b.frame==frame and ball.frame==age,'Native drawing advanced the actual ball');return bytes + end + local positive=capture('') + for _,entry in ipairs{{'ball',balls},{'particles',particles}}do + local draw=g.draw;local suppressed=0 + g.draw=function(image,...)if image==entry[2]then suppressed=suppressed+1;return end;return draw(image,...)end + local ok,negative=xpcall(function()return capture('-'..entry[1]..'-negative')end,debug.traceback);g.draw=draw;assert(ok,negative) + assert(suppressed>0 and positive~=negative,'Actual native '..entry[1]..' texture was invisible') + assert(capture('-'..entry[1]..'-restored')==positive,'Native texture did not restore exactly') + end + views[ball.battler]=true + end + end + local deadline=love.timer.getTime()+45 + repeat + assert(love.timer.getTime()depth do g.pop()end;assert(ok,err) + local image=target:newImageData();local raw=image:getString() + if name then assert(love.filesystem.write(name..'.png',image:encode('png')))end + image:release();target:release() + assert(frame==b.frame and age==b.gen3Anim.frame and queue==#b.queue and save==Json.encode(game.save)and rng==love.math.getRandomState(),'Draw advanced native state') + return raw + end + for _,key in ipairs{'HARDEN','IRON_DEFENSE'}do for _,player in ipairs{true,false}do + local shape=assert(game.data.moves[key].anim.flourish);assert(shape.life==64 and shape.laps==2 and shape.alpha==.5) + local anim=assert(Move.new(game.data));b.gen3Anim=anim;b.gen3AnimPlaying=true + assert(anim:start(key,player,player and 0 or 1,player and 1 or 0,{},b)) + for _=1,32 do anim:update()end + local actor=player and b.player or b.enemy;assert(anim:flourishLayer(actor)) + local name='stat-'..key:lower()..'-'..(player and'player'or'enemy') + local positive=pixels(name);assert(pixels()==positive,'Native draw not idempotent') + local live=anim.flourish;anim.flourish=nil;local negative=pixels(name..'-layer-off');anim.flourish=live + assert(negative~=positive,'Native stat layer invisible');assert(pixels(name..'-restored')==positive,'Native stat layer did not restore') + for _=33,64 do anim:update()end;assert(anim:flourishLayer(actor)==nil,'RS played a third lap') + rows[#rows+1]={move=key,player=player,life=shape.life,laps=shape.laps,visible=true,restoreExact=true,source=shape.source} + anim:release();b.gen3Anim=host;b.gen3AnimPlaying=false;wait(1) + end end + game.stack:pop();assert(love.filesystem.write('edition-result.json',Json.encode({edition=edition,status='pass',modsEnabled=false,cases=rows,physicalIOSTested=false}))) + print('PASS_NATIVE_RS_STAT_FLOURISH',edition,#rows,'real ROM import, both sides, visible negative, exact restore, 64-frame terminal');love.event.quit(0) +end diff --git a/tests/rs_stat_flourish_runtime.lua b/tests/rs_stat_flourish_runtime.lua new file mode 100644 index 000000000..6298c235f --- /dev/null +++ b/tests/rs_stat_flourish_runtime.lua @@ -0,0 +1,21 @@ +-- Real native flourish sampler and cache marker, independent RS C task oracle. +local function read(p)local f=assert(io.open(p));local s=f:read('*a');f:close();return s end +local raw=read('src/battle/Gen3MoveAnim.lua');local a=assert(raw:find('function Gen3MoveAnim:flourishLayer(battler)',1,true)) +local z=assert(raw:find('-- THE POKEMON THAT SHRINKS',a,true));local source=raw:sub(a,z-1) +local Class={};assert(loadstring('local Gen3MoveAnim=...\n'..source))(Class) +local player,enemy={},{};function Class:matchesBattler(b)return b==player end +local anim=setmetatable({flourish={image='native-rs',life=64,drift=4,lap=128,laps=2,alpha=8/16,centreX=96,centreY=32,width=256,height=256}}, {__index=Class}) +local shift,counter,laps=0,0,0 +for frame=0,63 do + if frame>0 then counter=counter+4;shift=shift-4;if counter==128 then counter=0;shift=shift+128;laps=laps+1 end end + anim.frame=frame;local layer=assert(anim:flourishLayer(player));assert(layer.x==shift and layer.alpha==.5 and layer.centreX==96 and layer.centreY==32) + assert(anim:flourishLayer(enemy)==nil,'Flourish leaked to the defender') +end +anim.frame=64;assert(anim:flourishLayer(player)==nil,'RS retained the third lap') +anim.flourish.life=96;anim.frame=64;assert(anim:flourishLayer(player),'Existing three-lap sampler changed') +local importer=read('src/import/RomImporter.lua');a=assert(importer:find('local function markerFor(version)',1,true));z=assert(importer:find('-- AND WHAT THAT IMPORT',a,true)) +local marker=assert(loadstring('local GameVersion,CACHE_FORMAT=...\n'..importer:sub(a,z-1)..'\nreturn markerFor'))({info=function(v)return{sha1=v..'-sha'}end,isFRLG=function(v)return v=='firered'or v=='leafgreen'end},'format:') +assert(marker('ruby')=='format:rs-stat-flourish-v6:ruby-sha') +assert(marker('sapphire')=='format:rs-stat-flourish-v6:sapphire-sha') +assert(marker('firered')=='format:firered-trade-scene-v1:firered-sha'and marker('emerald')=='format:emerald-trade-scene-v3:emerald-sha','Unrelated cache invalidated') +print('PASS_RS_STAT_FLOURISH 64 C-model frames, exact owner, stop at two laps, existing three-lap sampler, only RS cache v6') diff --git a/tests/ruby_native_driver.lua b/tests/ruby_native_driver.lua new file mode 100644 index 000000000..af41c0fe8 --- /dev/null +++ b/tests/ruby_native_driver.lua @@ -0,0 +1,96 @@ +return function(game) + local U=require('tests.drivers.util') + local V=require('src.core.GameVersion') + assert(V.get()=='ruby') + assert(game.data.pokemon.TREECKO) + assert(game.data.font and #game.data.font.pages.normal.widths==254) + print('[driver] Ruby native dataset loaded') + U.newGame(game) + assert(game.overworld and game.stack:top()==game.overworld,'Ruby new game did not reach field') + love.graphics.captureScreenshot('ruby-new-game.png');U.wait(3) + -- Walk out through the cartridge's dynamic warp and let the normal + -- Littleroot/Mom script take the player into the new house. + U.hold(game,'right',48);U.wait(60) + for i=1,4000 do + if i%8==1 then U.tap(game,'a') else U.wait(1) end + if game.overworld.map.id=='MAP_G01_N00' and game.stack:top()==game.overworld + and not game.overworld.runner:isRunning() then break end + end + assert(game.overworld.map.id=='MAP_G01_N00','truck dynamic warp/Mom scene failed') + assert(not game.overworld.runner:isRunning(),'opening scene stayed locked') + local S=require('src.core.SaveData') + assert(S.save(game.save,game.mods)) + assert(S.saveFileExists('ruby') and not S.saveFileExists('emerald')) + local loaded=assert(S.load('ruby')) + assert(loaded.version=='ruby' and loaded.player.name==game.save.player.name) + love.graphics.captureScreenshot('ruby-opening-house.png');U.wait(12) + print('[driver] PASS actual truck exit, Mom scene, own save/reload') + local C=require('src.script.Gen3Commands') + local Pokemon=require('src.pokemon.Pokemon') + assert(game.data.constants.gen3Starters.var==0x4023,'starter choice variable was not read from Ruby') + U.teleport(game,'MAP_G00_N16',7,15,'up');U.wait(30) + local firstBattle=false + for frame=1,14000 do + if frame%8==1 then U.tap(game,'a') else U.wait(1) end + local top=game.stack:top() + if top and top.enemy and top.enemy.mon and top.enemy.mon.species=='POOCHYENA' then + if not firstBattle then + firstBattle=true + love.graphics.captureScreenshot('ruby-first-battle.png');U.wait(12) + end + end + if firstBattle and game.overworld.map.id=='MAP_G01_N04' and top==game.overworld + and not game.overworld.runner:isRunning() then break end + end + assert(firstBattle,'real bag interaction did not start the first Poochyena battle') + assert(game.overworld.map.id=='MAP_G01_N04','starter rescue script did not reach Birch lab') + local choice=C.getVar(game.save,0x4023) + assert(game.save.party[1].species==game.data.constants.gen3Starters.species[choice+1], + 'remembered choice and actual starter disagree') + print('[driver] PASS native bag, starter choice, first battle and Birch lab warp') + local mon=Pokemon.new(game.data,'MEWTWO',100) + mon.moves={{id='PSYCHIC',pp=99,maxPp=99}} + game.save.party={mon} + U.teleport(game,'MAP_G11_N03',5,3,'up');U.wait(30) + local battled,complete,shown=false,false,false + for frame=1,14000 do + if frame%12==1 then U.tap(game,'a') else U.wait(1) end + local top=game.stack:top() + local name=top and top.trainer and tostring(top.trainer.name):upper() + if top and top.enemy and name and name:find('ROXANNE',1,true) then + battled=true + if top.phase=='menu' and not top.showEnemyTrainer and not shown then + love.graphics.captureScreenshot('ruby-roxanne-battle.png');U.wait(12);shown=true + end + end + if battled and game.save.flags[C.flagKey(0xA5)] and top==game.overworld and not game.overworld.runner:isRunning() then + assert(game.save.flags[C.flagKey(0x807)],'Stone Badge missing') + assert(game.save.inventory.TM39==1,'native TM39 reward missing') + complete=true;break + end + end + assert(complete,'Roxanne battle/reward did not finish') + love.graphics.captureScreenshot('ruby-roxanne-reward.png');U.wait(12) + local braille=assert(game.data.constants.gen3Braille,'RS braille was not extracted') + local walls=0 + for _,wall in pairs(braille.messages) do + walls=walls+1 + assert(not wall.text:find('{',1,true),'unreadable cartridge braille cell') + end + assert(walls>=20 and braille.lineHeight==32,'incomplete RS braille profile') + U.teleport(game,'MAP_G24_N71',5,7,'up');U.wait(30) + U.tap(game,'a');U.wait(20) + local wall=assert(game.overworld.brailleBox,'real sign did not open braille') + assert(wall.wall.text=='ABC','wrong cartridge wall was opened') + assert(wall.wall.textX==96 and wall.wall.textY==72,'RS text origin was ignored') + love.graphics.captureScreenshot('ruby-braille-wall.png');U.wait(12) + U.tap(game,'b');U.wait(20) + assert(not game.overworld.brailleBox and not game.overworld.runner:isRunning(), + 'closing braille did not return control') + print('[driver] PASS cartridge braille, real sign and close/resume') + love.filesystem.write('edition-result.json',require('src.link.Json').encode({status='pass',edition='ruby',newGame=true, + truckExitAndMom=true,saveReload=true,starterAndFirstBattle=true,roxanneBattleAndReward=true,brailleWall=true, + allModsDisabled=true,fullStoryTested=false})) + print('[driver] PASS Ruby native new game') + love.event.quit(0) +end diff --git a/tests/sapphire_native_driver.lua b/tests/sapphire_native_driver.lua new file mode 100644 index 000000000..cd78a6a95 --- /dev/null +++ b/tests/sapphire_native_driver.lua @@ -0,0 +1,96 @@ +return function(game) + local U=require('tests.drivers.util') + local V=require('src.core.GameVersion') + assert(V.get()=='sapphire') + assert(game.data.pokemon.TREECKO) + assert(game.data.font and #game.data.font.pages.normal.widths==254) + print('[driver] Sapphire native dataset loaded') + U.newGame(game) + assert(game.overworld and game.stack:top()==game.overworld,'Sapphire new game did not reach field') + love.graphics.captureScreenshot('sapphire-new-game.png');U.wait(3) + -- Walk out through the cartridge's dynamic warp and let the normal + -- Littleroot/Mom script take the player into the new house. + U.hold(game,'right',48);U.wait(60) + for i=1,4000 do + if i%8==1 then U.tap(game,'a') else U.wait(1) end + if game.overworld.map.id=='MAP_G01_N00' and game.stack:top()==game.overworld + and not game.overworld.runner:isRunning() then break end + end + assert(game.overworld.map.id=='MAP_G01_N00','truck dynamic warp/Mom scene failed') + assert(not game.overworld.runner:isRunning(),'opening scene stayed locked') + local S=require('src.core.SaveData') + assert(S.save(game.save,game.mods)) + assert(S.saveFileExists('sapphire') and not S.saveFileExists('emerald')) + local loaded=assert(S.load('sapphire')) + assert(loaded.version=='sapphire' and loaded.player.name==game.save.player.name) + love.graphics.captureScreenshot('sapphire-opening-house.png');U.wait(12) + print('[driver] PASS actual truck exit, Mom scene, own save/reload') + local C=require('src.script.Gen3Commands') + local Pokemon=require('src.pokemon.Pokemon') + assert(game.data.constants.gen3Starters.var==0x4023,'starter choice variable was not read from Sapphire') + U.teleport(game,'MAP_G00_N16',7,15,'up');U.wait(30) + local firstBattle=false + for frame=1,14000 do + if frame%8==1 then U.tap(game,'a') else U.wait(1) end + local top=game.stack:top() + if top and top.enemy and top.enemy.mon and top.enemy.mon.species=='POOCHYENA' then + if not firstBattle then + firstBattle=true + love.graphics.captureScreenshot('sapphire-first-battle.png');U.wait(12) + end + end + if firstBattle and game.overworld.map.id=='MAP_G01_N04' and top==game.overworld + and not game.overworld.runner:isRunning() then break end + end + assert(firstBattle,'real bag interaction did not start the first Poochyena battle') + assert(game.overworld.map.id=='MAP_G01_N04','starter rescue script did not reach Birch lab') + local choice=C.getVar(game.save,0x4023) + assert(game.save.party[1].species==game.data.constants.gen3Starters.species[choice+1], + 'remembered choice and actual starter disagree') + print('[driver] PASS native bag, starter choice, first battle and Birch lab warp') + local mon=Pokemon.new(game.data,'MEWTWO',100) + mon.moves={{id='PSYCHIC',pp=99,maxPp=99}} + game.save.party={mon} + U.teleport(game,'MAP_G11_N03',5,3,'up');U.wait(30) + local battled,complete,shown=false,false,false + for frame=1,14000 do + if frame%12==1 then U.tap(game,'a') else U.wait(1) end + local top=game.stack:top() + local name=top and top.trainer and tostring(top.trainer.name):upper() + if top and top.enemy and name and name:find('ROXANNE',1,true) then + battled=true + if top.phase=='menu' and not top.showEnemyTrainer and not shown then + love.graphics.captureScreenshot('sapphire-roxanne-battle.png');U.wait(12);shown=true + end + end + if battled and game.save.flags[C.flagKey(0xA5)] and top==game.overworld and not game.overworld.runner:isRunning() then + assert(game.save.flags[C.flagKey(0x807)],'Stone Badge missing') + assert(game.save.inventory.TM39==1,'native TM39 reward missing') + complete=true;break + end + end + assert(complete,'Roxanne battle/reward did not finish') + love.graphics.captureScreenshot('sapphire-roxanne-reward.png');U.wait(12) + local braille=assert(game.data.constants.gen3Braille,'RS braille was not extracted') + local walls=0 + for _,wall in pairs(braille.messages) do + walls=walls+1 + assert(not wall.text:find('{',1,true),'unreadable cartridge braille cell') + end + assert(walls>=20 and braille.lineHeight==32,'incomplete RS braille profile') + U.teleport(game,'MAP_G24_N71',5,7,'up');U.wait(30) + U.tap(game,'a');U.wait(20) + local wall=assert(game.overworld.brailleBox,'real sign did not open braille') + assert(wall.wall.text=='ABC','wrong cartridge wall was opened') + assert(wall.wall.textX==96 and wall.wall.textY==72,'RS text origin was ignored') + love.graphics.captureScreenshot('sapphire-braille-wall.png');U.wait(12) + U.tap(game,'b');U.wait(20) + assert(not game.overworld.brailleBox and not game.overworld.runner:isRunning(), + 'closing braille did not return control') + print('[driver] PASS cartridge braille, real sign and close/resume') + love.filesystem.write('edition-result.json',require('src.link.Json').encode({status='pass',edition='sapphire',newGame=true, + truckExitAndMom=true,saveReload=true,starterAndFirstBattle=true,roxanneBattleAndReward=true,brailleWall=true, + allModsDisabled=true,fullStoryTested=false})) + print('[driver] PASS Sapphire native new game') + love.event.quit(0) +end diff --git a/tests/screen_visibility/cases.lua b/tests/screen_visibility/cases.lua new file mode 100644 index 000000000..c169023b6 --- /dev/null +++ b/tests/screen_visibility/cases.lua @@ -0,0 +1,150 @@ +local M = {} +function M.run(f) + local T, stack, e, api = f.T, f.stack, f.exports, f.public + local base, menu, overlay = f.base, f.menu, f.overlay + local rows = {} + local function draw(label, states, expected, palette) + local trace, zones = f.frame(states) + T.same(trace, expected, label .. " native draw order") + if f.palettePath then + T.eq(zones, palette, label .. " visible palette owner") + end + T.same(stack.states, states, label .. " rendering does not rewrite stack") + rows[#rows + 1] = { label = label, drawn = trace, base = stack:visibleBase() } + end + if api.supports then + T.eq(api:supports("hooks", "screen.render_visible"), true, "native capability") + end + T.eq(stack:renderVisible(nil), false, "nil is not a renderable state") + draw("native opaque menu", { base, menu }, { "BagMenu" }, "BagMenu zones") + T.eq(e.seen.extra, 0, "opaque state signature has no invented args") + T.eq(e.seen.state.screenId, menu.screenId, "native screen identifier reaches public hook") + T.eq(e.seen.state == menu, f.stateIdentity, "host-native state identity semantics") + e.hide("BagMenu", true) + draw("mirrored menu", { base, menu }, { "Base" }, "Base zones") + T.same(f.stackFrame({ base, menu }), { "Base" }, "native StateStack:draw hides mirrored menu") + T.eq(stack:top(), menu, "hidden menu remains the native top") + local before, baseUpdates, presses = e.seen.calls, base.updates, menu.presses + f.input:keypressed("z") + f.input:step() + stack:update(1 / 60) + T.eq(menu.updates, 1, "hidden menu keeps update ownership") + T.eq(base.updates, baseUpdates, "world below does not steal update ownership") + T.eq(menu.presses, presses + 1, "hidden menu receives native Input press") + T.eq(e.seen.calls, before, "simulation never queries the render predicate") + f.input:keyreleased("z") + f.input:step() + draw("overlay above mirrored menu", { base, menu, overlay }, { "Base", "TextBox" }, "TextBox zones") + local hiddenChild = f.state("BagMenu", true) + draw("multiple mirrored menus", { base, menu, hiddenChild }, { "Base" }, "Base zones") + local replacement = {} + for key, value in pairs(base) do + replacement[key] = value + end + replacement.contractMarker = "replacement" + + local off = api.hooks:wrap("screen.render_visible", function(next, state) + T.eq(state.screenId, base.screenId, "outer predicate sees actual state fields") + return next(replacement) + end, 100) + T.eq(stack:renderVisible(base), true, "next(replacement) reaches downstream predicate") + T.eq(e.seen.state.contractMarker, "replacement", "explicit next forwards replacement fields") + off() + base.contractMarker = nil + -- Only literal false hides. No boolean coercion or special consume value. + for _, case in ipairs({ + { name = "nil" }, + { name = "true", value = true }, + { name = "zero", value = 0 }, + { name = "empty string", value = "" }, + { name = "table", value = {} }, + }) do + off = api.hooks:wrap("screen.render_visible", function() + return case.value + end, 100) + T.eq(stack:renderVisible(menu), true, case.name .. " remains visible") + off() + end + off = api.hooks:wrap("screen.render_visible", function() + return false, true + end, 100) + T.eq(stack:renderVisible(base), false, "only first result controls visibility") + off() + local order = {} + local outer = api.hooks:wrap("screen.render_visible", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("screen.render_visible", function(next, state) + order[#order + 1] = "inner" + T.eq(state.screenId, base.screenId, "next() keeps current state fields") + return next() + end, 10) + T.eq(stack:renderVisible(base), true, "default true passes through public wrappers") + T.same(order, { "outer", "inner", "after" }, "priority and next ordering") + outer() + inner() + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("screen.render_visible", function(next) + if after then + next() + end + error("intentional screen predicate failure") + end, 100) + T.eq(stack:renderVisible(menu), false, "wrapper failure preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("screen.render_visible", function(next) + next() + return next() + end, 100) + before = e.seen.calls + T.eq(stack:renderVisible(base), true, "API-3 repeated next result") + T.eq(e.seen.calls, before + 1, "API-3 next caches downstream predicate") + off() + end + draw("hidden sole menu", { menu }, {}, nil) + draw("empty stack", {}, {}, nil) + local nativeDraw, attempts = menu.draw, 0 + menu.draw = function() + attempts = attempts + 1 + error("native screen draw failure") + end + f.frame({ base, menu }) + T.eq(attempts, 0, "hidden native drawer is never invoked") + e.hide("BagMenu", false) + T.raises(function() + f.frame({ base, menu }) + end, "native screen draw failure", "native draw errors escape") + T.eq(attempts, 1, "native draw failure is not retried") + menu.draw = nativeDraw + e.hide("BagMenu", true) + + e.hide("Base", true) + e.hide("TextBox", true) + draw("all hidden", { base, menu, overlay }, {}, nil) + e.hide("Base", false) + e.hide("TextBox", false) + e.stop() + e.stop() + before = e.seen.calls + draw("unsubscribed", { base, menu }, { "BagMenu" }, "BagMenu zones") + T.eq(e.seen.calls, before, "unsubscribe is idempotent") + api.hooks:wrap("screen.render_visible", function() + return false + end, 100) + local peers = 0 + local peer = f.result.loader.hooks:wrap("screen.render_visible", function(next, ...) + peers = peers + 1 + return next(...) + end, 10, "other_screen_owner") + f.result.loader.hooks:removeOwner("portable_screen_visibility") + T.eq(stack:renderVisible(menu), true, "owner removal restores default") + T.eq(peers, 1, "owner removal preserves another mod") + peer() + return rows +end +return M diff --git a/tests/screen_visibility/fixture.lua b/tests/screen_visibility/fixture.lua new file mode 100644 index 000000000..4ea71a75a --- /dev/null +++ b/tests/screen_visibility/fixture.lua @@ -0,0 +1,148 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local generation = Version.generation() + local files = {} + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_screen_visibility/" .. path, "rb")) + files["mods/portable_screen_visibility/" .. path] = file:read("*a") + file:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_screen_visibility/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_screen_visibility/manifest.json"] = Json.encode(manifest) + local data = T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_screen_visibility" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local StateStack = require("src.core.StateStack") + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local module = backend == "gen1" and generation == 2 and "src.core.Game2" or "src.core.Game" + local Game = require(module) + local g = setmetatable({ + stack = stack, + mods = result.loader, + data = data, + save = { options = {} }, + inFillBoot = function() + return false + end, + letterbox = function() end, + paintBattleSurround = function() end, + }, { __index = Game }) + local trace, zones, worldZones, clearWorld = {}, nil, nil, nil + local externalDraws = 0 + local Renderer = require("src.render.Renderer") + local Touch = require("src.core.TouchControls") + local saved = { Renderer.setUISize, Renderer.uiSize, Renderer.beginFrame, Renderer.endFrame, Touch.draw } + local width, height = Renderer.WIDTH, Renderer.HEIGHT + Renderer.setUISize = function(_, w, h) + width, height = w, h + end + Renderer.uiSize = function() + return width, height + end + Renderer.beginFrame = function(_, world) + clearWorld = world + end + Renderer.endFrame = function(_, z, wz) + zones, worldZones = z, wz + return {} + end + Touch.draw = function() end + local Input = require("src.core.Input") + Input:init() + local function state(id, opaque) + return { + screenId = id, + isOpaque = opaque, + updates = 0, + presses = 0, + palettes = 0, + draw = function() + trace[#trace + 1] = id + end, + update = function(self) + self.updates = self.updates + 1 + if Input:wasPressed("a") then + self.presses = self.presses + 1 + end + end, + sgbPalettes = function(self) + self.palettes = self.palettes + 1 + return id .. " zones" + end, + } + end + local base, menu, overlay = state("Base", true), state("BagMenu", true), state("TextBox", false) + g.overworld = base + g.world = { + map = {}, + fitScale = function() + return 4 + end, + draw = function() + externalDraws = externalDraws + 1 + end, + } + local function frame(states) + trace, zones, worldZones, clearWorld = {}, nil, nil, nil + externalDraws = 0 + stack.states = states + if backend == "gen1" and generation == 2 then + g:drawScene(640, 576) + elseif backend == "gen1" then + g:draw() + else + g:_draw() + end + return trace, zones, worldZones, clearWorld + end + local exports = result.loader.exports.portable_screen_visibility + return { + T = T, + result = result, + game = g, + stack = stack, + base = base, + menu = menu, + overlay = overlay, + stackFrame = function(states) + trace = {} + stack.states = states + stack:draw() + return trace + end, + frame = frame, + state = state, + input = Input, + backend = backend, + generation = generation, + palettePath = backend ~= "gen1" or generation == 1, + stateIdentity = backend ~= "gen1" or api < 3, + externalDraws = function() + return externalDraws + end, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + close = function() + Renderer.setUISize, Renderer.uiSize, Renderer.beginFrame, Renderer.endFrame, Touch.draw = + saved[1], saved[2], saved[3], saved[4], saved[5] + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/screen_visibility/worker.lua b/tests/screen_visibility/worker.lua new file mode 100644 index 000000000..77c34ef3d --- /dev/null +++ b/tests/screen_visibility/worker.lua @@ -0,0 +1,13 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = arg[1], arg[2], arg[3], tonumber(arg[4]), arg[5] +local fixture = dofile(root .. "/tests/screen_visibility/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/screen_visibility/cases.lua").run(f) +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("screen.render_visible"), false, "no-mod Runtime") +bare.T.eq(bare.stack:renderVisible(bare.menu), true, "no-mod visibility default") +bare.T.same(bare.frame({ bare.base, bare.menu }), { "BagMenu" }, "no-mod native composition") +bare.close() +print("SCREEN_VISIBILITY_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable screen.render_visible contract") diff --git a/tests/test_gen3_palette_profiles.py b/tests/test_gen3_palette_profiles.py new file mode 100644 index 000000000..b8f39ef38 --- /dev/null +++ b/tests/test_gen3_palette_profiles.py @@ -0,0 +1,43 @@ +import importlib.util +import json +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("palette_profiles", ROOT / "tools/gen3_palette_profiles.py") +PROFILES = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PROFILES) + + +class PaletteProfiles(unittest.TestCase): + def test_generated_manifest_profiles_match_catalog(self): + for game in ("emerald", "firered"): + manifest = json.loads((ROOT / f"tools/rom_manifest_{game}.json").read_text()) + profile = PROFILES.profile_for_sha1(manifest["romSha1"]) + self.assertEqual(profile, manifest["terrainPalettePrograms"]) + self.assertEqual(profile["game"], game) + self.assertEqual(profile["romSha1"], manifest["romSha1"]) + self.assertIn("github.com/pret/", profile["source"]) + self.assertIn("terrainPalettePrograms", (ROOT / "tools/gen3_discover.py").read_text()) + + def test_exact_hash_and_detached_recipe(self): + sha1 = "f3ae088181bf583e55daf962a92bb46f4f1d07b7" + self.assertIsNone(PROFILES.profile_for_sha1("0" * 40)) + profile = PROFILES.profile_for_sha1(sha1) + profile["programs"].clear() + self.assertEqual(len(PROFILES.profile_for_sha1(sha1)["programs"]), 20) + + def test_pinned_cartridge_program_semantics(self): + emerald = PROFILES.profile_for_sha1("f3ae088181bf583e55daf962a92bb46f4f1d07b7") + firered = PROFILES.profile_for_sha1("41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc") + palette = emerald["programs"][str(0xA1658)] + self.assertEqual((palette["table"], palette["first"], palette["count"], palette["frames"], palette["step"]), + (0x52487C, 128, 16, 4, 4)) + self.assertEqual(sum(p["kind"] == "frames" for p in emerald["programs"].values()), 1) + self.assertEqual(sum(p["kind"] == "static" for p in emerald["programs"].values()), 19) + self.assertEqual(len(firered["programs"]), 6) + self.assertTrue(all(p["kind"] == "static" for p in firered["programs"].values())) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/world_draw/fixture.lua b/tests/world_draw/fixture.lua new file mode 100644 index 000000000..d7f8f773c --- /dev/null +++ b/tests/world_draw/fixture.lua @@ -0,0 +1,118 @@ +-- Owned synthetic draw scene: no ROM, profile, asset extraction or game progress. +local F = {} +function F.new(count, options) + local World = options and options.worldModule or require("src.world.OverworldController") + local Game = require("src.core.Game") + -- The normal enter lifecycle supplies this shared engine reference. The + -- isolated rendering fixture has no map loader or save lifecycle to enter. + for index = 1, 100 do + local name = debug.getupvalue(World.drawWorld, index) + if not name then + break + end + if name == "Game" then + debug.setupvalue(World.drawWorld, index, Game) + break + end + end + Game.data = { field = { overworldFx = {} }, constants = {}, sprites = {} } + Game.save = { options = {} } + local trace = {} + local function mark(name, ...) + if options and options.quiet then + return + end + local row = { name, ... } + for i = 1, #row do + row[i] = tostring(row[i]) + end + trace[#trace + 1] = table.concat(row, ":") + end + local renderer = {} + for _, name in ipairs({ "drawBorderFill", "draw", "drawMapOnly", "drawAbove", "drawReflectionCover" }) do + renderer[name] = function(_, ...) + mark(name, ...) + end + end + local world = setmetatable({ + camera = { x = 3, y = 5 }, + map = { + id = "SYNTHETIC", + renderer = renderer, + isGrassCell = function() + return false + end, + }, + neighbors = {}, + ghosts = {}, + entities = {}, + gen4Shadows = false, + waterAnim = { x = 1, y = 1, frames = 12, total = 40 }, + ripples = { { px = 15, py = 31, clock = 0 } }, + }, { __index = World }) + for i = 1, count or 3 do + local e = { + px = i * 16, + py = i * 16, + cellX = i, + cellY = i, + facing = "down", + above = i % 7 == 0, + draw = function(self, cx, cy) + mark("entity", self.cellX, cx, cy) + end, + drawReflection = function(self, cx, cy) + mark("reflection", self.cellX, cx, cy) + end, + } + world.entities[i] = e + end + world.player = world.entities[1] + world.reflectionSearch = function(_, e) + return e.cellX % 2 == 0 and { { e.cellX, e.cellY + 1 } } or nil + end + world.stillReflection = function() + return false + end + world.reflectionRect = function(_, e) + return e.cellX, e.cellY + 1, e.cellX + 1, e.cellY + 2 + end + world.gen3AboveTopLayer = function(_, e) + return e.above + end + world.drawRipples = function(_, cx, cy) + mark("ripples", cx, cy) + end + world.drawGen3Gates = function() + mark("gates") + end + world.drawGen3Flash = function() + mark("flash") + end + world.drawGen3Decorate = function() + mark("decoration") + end + world.placeFreeCamera = function() end + world.reflectProbe, world.reflectProbe2 = 1, 1 + Game.renderer = { + worldViewSize = function() + return 160, 144 + end, + fitScale = function() + return 1 + end, + setWorldOverride = function() end, + } + return { + world = world, + game = Game, + trace = function() + return trace + end, + reset = function() + trace = {} + end, + mark = mark, + } +end +return F diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py new file mode 100644 index 000000000..8730c8e92 --- /dev/null +++ b/tools/api3-contract-check/check.py @@ -0,0 +1,84 @@ +#!/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_REFS = ("79ea62f45fb2ea8f65d081a109431e4eb1bbdb36", + "535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91") +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() + reference_ref = git(gen1, "rev-parse", "HEAD") + if reference_ref not in GEN1_REFS: + parser.error("reference must be pinned to one of " + ", ".join(GEN1_REFS)) + 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"): + 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": reference_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/async-get-check/conf.lua b/tools/async-get-check/conf.lua new file mode 100644 index 000000000..b58b4efe7 --- /dev/null +++ b/tools/async-get-check/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity = assert(os.getenv('GET_TEST_IDENTITY')) + t.version = love.getVersion() >= 12 and '12.0' or '11.5' + t.window = false + t.modules.graphics, t.modules.audio, t.modules.joystick, t.modules.physics = false, false, false, false +end diff --git a/tools/async-get-check/download_test_worker.lua b/tools/async-get-check/download_test_worker.lua new file mode 100644 index 000000000..71f586de1 --- /dev/null +++ b/tools/async-get-check/download_test_worker.lua @@ -0,0 +1,12 @@ +-- Hold only the final OS publication call, to exercise the real worker's +-- cancellation/commit boundary while the main thread keeps pumping. +require("love.thread") +require("love.filesystem") +require("love.timer") +require("love.system") +local publish = require("src.net.DownloadPublish") +package.loaded["src.net.DownloadPublish"] = function(source, target) + if target:sub(-18) == "/delay-publish.bin" then love.timer.sleep(.25) end + return publish(source, target) +end +assert(love.filesystem.load("src/net/download_worker.lua"))(...) diff --git a/tools/async-get-check/main.lua b/tools/async-get-check/main.lua new file mode 100644 index 000000000..52e141c8a --- /dev/null +++ b/tools/async-get-check/main.lua @@ -0,0 +1,225 @@ +function love.errorhandler(err) + io.stderr:write(debug.traceback(tostring(err)).."\n");io.stderr:flush() + return function()return 1 end +end +local Fetch, Net, base +local checks, pulses = 0, 0 +local function check(ok, message) + checks = checks + 1 + assert(ok, message) +end +local function await(api, handle) + local at = love.timer.getTime() + while true do + local before = love.timer.getTime() + local state = api:poll(handle) + check(love.timer.getTime() - before < .1, 'poll must not perform transport IO') + if state.status ~= 'pending' then return state end + check(love.timer.getTime() - at < 10, 'request completes within test deadline') + pulses = pulses + 1; love.timer.sleep(.005) + end +end +function love.load() + Fetch = require('src.net.Fetch'); Net = require('src.mods.Net') + base = assert(os.getenv('GET_TEST_URL')) + local newThread = love.thread.newThread + love.thread.newThread = function(source, ...) + if source == 'src/net/download_worker.lua' then source = 'download_test_worker.lua' end + return newThread(source, ...) + end +end +function love.run() + local ok, err = xpcall(function() + love.load() + local loader = {} + local function mod(id, network) return Net.forMod(loader, {manifest={id=id, permissionSet={network=network}}}) end + local a, b, denied = mod('a', true), mod('b', true), mod('denied', false) + if os.getenv('GET_TEST_UNTRUSTED')=='1' then + local record={manifest={id='untrusted',permissionSet={network=true},log_url=base..'/must-not-send'}} + local logs=Net.forMod(loader,record) + local h=assert(Net.logReporter(loader,record)(nil,'untrusted certificate probe')) + local reply=await(logs,h) + check(reply.status=='error' and reply.body==nil,'untrusted TLS certificate must be refused') + logs:release(h);Net.unloadAll();Fetch.shutdown();return + end + if base:sub(1,8)=='https://' then + local function reporter(id,path) + local record={manifest={id=id,permissionSet={network=true},log_url=base..path}} + return Net.forMod(loader,record),Net.logReporter(loader,record) + end + local logs,send=reporter('log_text','/post-text') + local h=assert(send(nil,'literal\0\255\r\n$(printf injected) "quote"')) + local reply=await(logs,h) + check(reply.status=='ok' and reply.body==nil and reply.httpStatus==200,'TLS log hides real server reply') + logs:release(h) + logs,send=reporter('log_json','/post-json') + h=assert(send(nil,string.rep('\0',65536),{format='json'}));reply=await(logs,h) + check(reply.status=='ok' and reply.body==nil,'expanded JSON envelope preserves the input byte ceiling') + logs:release(h) + logs,send=reporter('log_redirect','/redirect-post') + h=assert(send(nil,'fixed destination'));reply=await(logs,h) + check(reply.status=='error' and reply.body==nil and reply.httpStatus==307,'POST cannot change its declared endpoint via redirect: '..tostring(reply.status)..' HTTP '..tostring(reply.httpStatus)) + logs:release(h) + end + check(not denied:available(), 'network permission required') + check(not pcall(denied.get, denied, base), 'ungranted GET refuses') + check(a:available(), 'real thread workers available') + check(a:capabilities().maxResponseBytes >= 4194304, 'bounded receive ceiling advertised to package consumers') + check(a:get('file:///etc/hosts') == nil, 'non HTTP URL denied') + check(a:get(base:gsub('^https:','http:')..'/binary', {boundedHttpsGet=1}) == nil, 'bounded HTTPS rejects HTTP') + local started = love.timer.getTime() + local slow = assert(a:get(base..'/slow', {maxSeconds=3})) + check(love.timer.getTime()-started < .1, 'GET starts without waiting for server') + check(b:poll(slow).status == 'error', 'handles isolated between mods') + check(not b:cancel(slow) and not b:release(slow), 'foreign cancellation denied') + local binary = assert(a:get(base..'/binary?encoded=%22%26%25', {maxBytes=256})) + local result = await(a, binary) + check(result.status=='ok' and result.body==string.char(0,255,13,10)..'GET binary', 'binary bytes and encoded URL preserved: '..tostring(result.status)..' '..tostring(result.err)..' bytes='..tostring(result.body and #result.body)) + check(result.receivedBytes==14, 'received byte count exact') + a:release(binary) + local state = await(a, slow) + check(state.status=='ok' and state.body=='slow', 'delayed request completes: '..tostring(state.status)..' '..tostring(state.err)..' bytes='..tostring(state.body and #state.body)) + a:release(slow) + check(pulses>20, 'caller keeps pumping during slow response') + local missing=assert(a:get(base..'/missing'));state=await(a, missing) + check(state.status=='error' and state.httpStatus==404, 'HTTP errors retained');a:release(missing) + local big=assert(a:get(base..'/big', {maxBytes=64}));state=await(a,big) + check(state.status=='error' and state.body==nil, 'oversize body refused');a:release(big) + local chunk=assert(a:get(base..'/chunk', {maxBytes=64}));state=await(a,chunk) + check(state.status=='error' and state.body==nil, 'body without content length bounded');a:release(chunk) + local cancelled=assert(a:get(base..'/slow'));a:cancel(cancelled) + check(a:poll(cancelled).status=='cancelled','cancel terminal');a:release(cancelled) + local timeout=assert(a:get(base..'/slow', {maxSeconds=1}));state=await(a,timeout) + check(state.status=='error','deadline enforced');a:release(timeout) + local held={} + for i=1,4 do held[i]=assert(a:get(base..'/slow')) end + check(a:get(base..'/slow')==nil,'per-mod pending ceiling') + Net.releaseAll(loader,'a') + check(a:poll(held[1]).status=='error','unload invalidates handles') + Net.unloadAll();Fetch.shutdown() + check(Fetch.available(),'worker service can restart') + local restart=assert(b:get(base..'/binary'));state=await(b,restart) + check(state.status=='ok','GET after shutdown works');b:release(restart) + local inFilePoll = false + local getInfo = love.filesystem.getInfo + love.filesystem.getInfo = function(...) + assert(not inFilePoll, 'file poll must not perform filesystem IO') + return getInfo(...) + end + local fileApi = {poll = function(_, id) + inFilePoll = true + local ok, result = pcall(Fetch.poll, id) + inFilePoll = false + assert(ok, result) + return result + end} + local expected = string.char(0,255,13,10)..'GET binary' + love.filesystem.createDirectory('downloads') + local target = 'downloads/target.bin' + local function oldFile() + assert(love.filesystem.write(target, 'previous complete file')) + end + local function preserved(label) + check(love.filesystem.read(target) == 'previous complete file', label) + end + oldFile() + started = love.timer.getTime() + local fileJob = assert(Fetch.download(base..'/stream', target, {size=262144})) + check(love.timer.getTime()-started < .1, 'file transfer starts without waiting for server') + local observedProgress = false + local pattern = {}; for i=0,255 do pattern[#pattern+1] = string.char(i) end + pattern = table.concat(pattern) + local completeFile = pattern:rep(1024) + while true do + local before = love.timer.getTime() + state = fileApi:poll(fileJob) + check(love.timer.getTime()-before < .1, 'file poll never waits on HTTP') + if state.status ~= 'pending' then break end + -- Publication can finish after poll returns pending. Bracket the read + -- with a fresh control snapshot: only an unclaimed transfer still + -- promises the old file. At every phase the bytes must be one complete + -- version, never an in-place/partial overwrite. + local contents = love.filesystem.read(target) + local boundary = fileApi:poll(fileJob) + check(contents == 'previous complete file' or contents == completeFile, + 'concurrent publication exposes only a complete old or new file') + if boundary.status == 'pending' and boundary.cancellable then + check(contents == 'previous complete file', + 'unclaimed transfer preserves previous complete file') + end + if state.receivedBytes > 0 and state.receivedBytes < 262144 then observedProgress = true end + pulses = pulses + 1; love.timer.sleep(.005) + end + check(state.status == 'ok' and state.receivedBytes == 262144, 'streamed file completes: '..tostring(state.err)) + check(observedProgress, 'progress available before transfer finishes') + check(love.filesystem.read(target) == pattern:rep(1024), 'streaming preserves all binary bytes') + Fetch.release(fileJob) + for _, case in ipairs({ + {'/missing', {size=14}, 'HTTP failure'}, + {'/partial', {}, 'truncated response'}, + {'/binary', {size=13}, 'expected-size mismatch'}, + {'/binary', {maxBytes=3}, 'known-length bound'}, + {'/chunk', {maxBytes=64}, 'unknown-length bound'}, + {'/slow', {maxSeconds=.1}, 'deadline'}, + }) do + oldFile();fileJob=assert(Fetch.download(base..case[1], target, case[2])) + state=await(fileApi,fileJob) + check(state.status=='error',case[3]..' reported: '..tostring(state.err)) + if case[1]=='/missing' then check(state.httpStatus==404,'file HTTP status retained') end + preserved(case[3]..' preserves previous file');Fetch.release(fileJob) + end + oldFile();fileJob=assert(Fetch.download(base..'/slow',target)) + Fetch.cancel(fileJob);state=Fetch.poll(fileJob) + check(state.status=='error' and state.err=='cancelled','legacy file cancellation remains terminal') + preserved('cancel preserves previous file');Fetch.release(fileJob) + local binaryFile=assert(Fetch.download(base..'/redirect','downloads/redirect.bin',{size=14})) + state=await(fileApi,binaryFile) + check(state.status=='ok' and love.filesystem.read('downloads/redirect.bin')==expected,'redirect and encoded binary output') + Fetch.release(binaryFile) + local empty=assert(Fetch.download(base..'/empty','downloads/empty.bin',{size=0})) + state=await(fileApi,empty) + check(state.status=='ok' and love.filesystem.getInfo('downloads/empty.bin').size==0,'zero-byte HTTP success') + Fetch.release(empty) + local large=assert(Fetch.download(base..'/large','downloads/large.bin',{size=17*1024*1024,maxBytes=20*1024*1024})) + state=await(fileApi,large) + check(state.status=='ok' and state.receivedBytes==17*1024*1024,'file can exceed in-memory GET ceiling') + check(love.filesystem.read('downloads/large.bin')==pattern:rep(69632),'large file bytes exact') + Fetch.release(large) + local delayedPath='downloads/delay-publish.bin' + assert(love.filesystem.write(delayedPath,'previous complete file')) + local finishing=assert(Fetch.download(base..'/binary',delayedPath,{size=14})) + local observedPublication=false + local finishingAt=love.timer.getTime() + while true do + state=fileApi:poll(finishing) + if state.status~='pending' then break end + if state.phase=='publishing' then + observedPublication=true + check(not Fetch.cancel(finishing),'cancellation after publication claim is refused without waiting') + check(state.cancellable==false,'committing request reports cancellation boundary') + end + check(love.timer.getTime()-finishingAt<5,'delayed publication completes') + love.timer.sleep(.005) + end + check(observedPublication and state.status=='ok','main thread pumps during slow filesystem publication') + check(love.filesystem.read(delayedPath)==expected,'claimed publication completes with exact bytes') + Fetch.release(finishing) + local invalidTarget=assert(Fetch.download(base..'/binary','downloads',{size=14})) + state=await(fileApi,invalidTarget) + check(state.status=='error' and love.filesystem.getInfo('downloads').type=='directory','publish error preserves existing directory') + Fetch.release(invalidTarget) + local heldFiles={} + for i=1,8 do heldFiles[i]=assert(Fetch.download(base..'/slow','downloads/held'..i)) end + check(Fetch.download(base..'/slow','downloads/overflow')==nil,'file queue bounded') + for _, handle in ipairs(heldFiles) do Fetch.cancel(handle);Fetch.release(handle) end + check(Fetch.download(base..'/slow','downloads/overflow')==nil,'release cannot bypass occupied file workers') + Fetch.shutdown() + for _, child in ipairs(love.filesystem.getDirectoryItems('net-download')) do + check(#love.filesystem.getDirectoryItems('net-download/'..child)==0,'shutdown cleans file worker staging data') + end + preserved('late cancellation cannot publish after shutdown') + print(('async GET and file downloads: %d checks, %d pump ticks PASS'):format(checks,pulses)) + end, debug.traceback) + if not ok then io.stderr:write(tostring(err).."\n");io.stderr:flush(); if Fetch then Fetch.shutdown() end end + return function() return ok and 0 or 1 end +end diff --git a/tools/battle-animation-runtime/coords-check.lua b/tools/battle-animation-runtime/coords-check.lua new file mode 100644 index 000000000..9401a88d9 --- /dev/null +++ b/tools/battle-animation-runtime/coords-check.lua @@ -0,0 +1,42 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional local contract comparison. The oracle is supplied by the caller; +-- no external engine, private package or cartridge is distributed here. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local C=require('src.core.game3.battle.anim_coords') +local reference=assert(loadfile(assert(os.getenv('BATTLE_COORDS_ORACLE'))))() +for _,double in ipairs{false,true}do + local st={double=double,battlers={}} + C.setDouble(double);reference.setDouble(double) + T.same(C.monDrawOrder(st),reference.monDrawOrder(st)) + T.same(C.ids(st),reference.ids(st)) + for id=0,3 do + T.same(C.coords(st,id),reference.coords(st,id)) + T.eq(C.subpriority(id),reference.subpriority(id)) + T.eq(C.bgPriorityRank(id),reference.bgPriorityRank(id)) + T.eq(C.monBehindZ(id,st),reference.monBehindZ(id,st)) + T.eq(C.partner(id),reference.partner(id)) + for _,hp in ipairs{0,1}do + st.battlers[id]={mon={hp=hp}} + T.eq(C.spritePresent(st,id),reference.spritePresent(st,id)) + end + end + for k=0,5 do + local a,b=C.particleBand(k,st);local x,y=reference.particleBand(k,st) + T.eq(a,x);T.eq(b,y) + end + for sub=0,255 do + for pri=0,3 do + for _,vm in ipairs{{},{_monbg={[0]=true,[3]=true},_bgPrio={[1]=1,[2]=3}}}do + T.eq(C.zFor(pri,sub,vm,st),reference.zFor(pri,sub,vm,st)) + end + end + end +end +C.setDouble(nil);reference.setDouble(nil) +for attacker=0,3 do for target=0,3 do + C.bind(attacker,target);reference.bind(attacker,target) + for _,side in ipairs{'player','enemy'}do T.eq(C.idOf(side),reference.idOf(side))end +end end +C.bind(nil);reference.bind(nil) +T.finish('authored battler coordinate contract') diff --git a/tools/battle-animation-runtime/host/conf.lua b/tools/battle-animation-runtime/host/conf.lua new file mode 100644 index 000000000..7aea1d29a --- /dev/null +++ b/tools/battle-animation-runtime/host/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.width,t.window.height=480,320;t.window.vsync=0;t.window.highdpi=false + t.modules.audio,t.modules.joystick,t.modules.physics=false,false,false + t.graphics=t.graphics or{};t.graphics.renderers={assert(os.getenv('POKEPORT_GRAPHICS_BACKEND'))} +end diff --git a/tools/battle-animation-runtime/host/main.lua b/tools/battle-animation-runtime/host/main.lua new file mode 100644 index 000000000..a6381f06b --- /dev/null +++ b/tools/battle-animation-runtime/host/main.lua @@ -0,0 +1,159 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +io.stdout:setvbuf('no') +local root=assert(os.getenv('BATTLE_SOURCE_ENGINE')) +package.path=root..'/?.lua;'..root..'/?/init.lua;./?/init.lua;'..package.path +function love.errorhandler(err)io.stderr:write(debug.traceback(tostring(err),2)..'\n');return function()return 1 end end +function love.load() + local T=require('tests.harness').suite('native battle animation GPU') + local A=require('src.core.game3.battle.anim') + local Battle=require('src.core.game3.battle') + local Scope=require('src.render.GraphicsScope') + local g=love.graphics + local nativeSource=debug.getinfo(require('src.battle.BattleState').update,'S').source + T.eq(nativeSource,'@'..root..'/src/battle/BattleState.lua') + local renderer,version,vendor,device=g.getRendererInfo() + local backend=assert(os.getenv('POKEPORT_GRAPHICS_BACKEND')) + T.check(renderer:lower():find(backend,1,true)~=nil,'actual requested GPU backend') + T.eq(love.filesystem.getIdentity(),os.getenv('POKEPORT_IDENTITY')) + love.filesystem.createDirectory('tests/fixture_data/assets') + local pixels=love.image.newImageData(64,64) + pixels:mapPixel(function(x,y) + if x<7 or y<5 or x>56 or y>57 then return 0,0,0,0 end + if y<20 and x<37 then return 1,0,91/255,1 end + if x>40 and y>35 then return 0,1,125/255,1 end + return .2,.3,.9,1 + end) + for _,side in ipairs{'back','front'}do pixels:encode('png','tests/fixture_data/assets/fixmon_a_'..side..'.png')end + pixels:release() + local comparisons,readbacks=0,0 + local function read(canvas) + local data=g.readbackTexture and g.readbackTexture(canvas)or canvas:newImageData() + local bytes=data:getString();data:release();readbacks=readbacks+1;return bytes + end + local function frame(canvas,draw) + local ok,why=Scope.call(function() + g.setCanvas(canvas);g.origin();g.setShader();g.setScissor();g.clear(0,0,0,0);g.setColor(1,1,1,1);draw() + end) + assert(ok,why);return read(canvas) + end + local function same(a,b,label,tolerance) + comparisons=comparisons+1 + if not tolerance then return T.eq(a,b,label)end + local error=0 + for i=1,#a do error=math.max(error,math.abs(a:byte(i)-b:byte(i)))end + if error>tolerance then + local count=0 + for i=1,#a,4 do + local bad=false;for c=0,3 do if math.abs(a:byte(i+c)-b:byte(i+c))>tolerance then bad=true end end + if bad then + print('PIXEL_ORACLE_DIFFERENCE',label,((i-1)/4)%160,math.floor((i-1)/4/160), + string.format('%d,%d,%d,%d',a:byte(i,i+3)),string.format('%d,%d,%d,%d',b:byte(i,i+3)));count=count+1;if count==4 then break end + end + end + print('MAX_PIXEL_ERROR',error) + end + T.check(#a==#b and error<=tolerance,label) + end + -- Independent pixel oracle: inverse-map output pixel centres into the + -- already rendered native image, without using graphics transforms. + local function remap(bytes,width,height,map,alpha) + local rows={} + for y=0,height-1 do + local row={} + for x=0,width-1 do + local sx,sy=map(x+.5,y+.5);sx,sy=math.floor(sx),math.floor(sy) + local value='\0\0\0\0' + if sx>=0 and sx=0 and sy