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/battle-auxiliary-contract.yml b/.github/workflows/battle-auxiliary-contract.yml new file mode 100644 index 000000000..612f4ced7 --- /dev/null +++ b/.github/workflows/battle-auxiliary-contract.yml @@ -0,0 +1,50 @@ +name: portable battle.menu_auxiliary contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: battle-auxiliary-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 auxiliary battle dispatch and SDK contracts + run: | + luajit tests/engine/battle_auxiliary.lua + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 auxiliary battle contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/battle-auxiliary-contract + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/battle-auxiliary-contract/api1-native.json + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/battle-auxiliary-contract/api2-native.json + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/battle-auxiliary-contract/api2-unified.json + python3 tools/battle-auxiliary-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/battle-auxiliary-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: battle-auxiliary-contract + path: /tmp/battle-auxiliary-contract + if-no-files-found: ignore diff --git a/.github/workflows/battle-bottom-ui-contract.yml b/.github/workflows/battle-bottom-ui-contract.yml new file mode 100644 index 000000000..38754d2d6 --- /dev/null +++ b/.github/workflows/battle-bottom-ui-contract.yml @@ -0,0 +1,49 @@ +name: portable battle.bottom_ui_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: battle-bottom-ui-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 bottom UI, inherited overlays and native battle composition + run: | + luajit tests/engine/battle_bottom_ui.lua + luajit tests/engine/battle_bottom_ui_routing.lua + luajit tests/engine/battle_bottom_ui_composition.lua + - name: Validate pinned native Gen1 GBA battle UI + working-directory: .contract-reference/gen1 + run: luajit tests/modkit/cases/unified_gen3_battle_hooks.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/battle-bottom-ui-contract + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/battle-bottom-ui-contract/api1-native.json + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/battle-bottom-ui-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/battle-bottom-ui-contract/api2-unified.json + python3 tools/battle-bottom-ui-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/battle-bottom-ui-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: battle-bottom-ui-contract + path: /tmp/battle-bottom-ui-contract + if-no-files-found: ignore diff --git a/.github/workflows/battle-grid-contract.yml b/.github/workflows/battle-grid-contract.yml new file mode 100644 index 000000000..8ff91ed2b --- /dev/null +++ b/.github/workflows/battle-grid-contract.yml @@ -0,0 +1,51 @@ +name: portable battle.move_grid_navigation contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: battle-grid-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 move navigation dispatch and SDK contracts + run: | + luajit tests/engine/battle_grid_navigation.lua + luajit tests/engine/battle_auxiliary.lua + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 move navigation contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/battle-grid-contract + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/battle-grid-contract/api1-native.json + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/battle-grid-contract/api2-native.json + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/battle-grid-contract/api2-unified.json + python3 tools/battle-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/battle-grid-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: battle-grid-contract + path: /tmp/battle-grid-contract + if-no-files-found: ignore 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/follower-contract.yml b/.github/workflows/follower-contract.yml new file mode 100644 index 000000000..0aecf2e29 --- /dev/null +++ b/.github/workflows/follower-contract.yml @@ -0,0 +1,45 @@ +name: portable follower predicate contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: follower-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 followers and compatibility + run: | + luajit tests/engine/follower_spawn.lua + luajit tests/engine/gen2_follower_predicate.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/unified_mod_callbacks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/follower-contract + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/follower-contract/api1-native.json + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/follower-contract/api2-native.json + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/follower-contract/api2-unified.json + python3 tools/follower-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/follower-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: follower-contract + path: /tmp/follower-contract + if-no-files-found: ignore 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/level-visible-contract.yml b/.github/workflows/level-visible-contract.yml new file mode 100644 index 000000000..0c9d7e72a --- /dev/null +++ b/.github/workflows/level-visible-contract.yml @@ -0,0 +1,49 @@ +name: portable pokemon.level_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: level-visible-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 level rendering and SDK contracts + run: | + luajit tests/engine/level_visible.lua + luajit tests/engine/level_visible_contract.lua + luajit tests/engine/level_visible_renderers.lua + - name: Validate pinned native Gen1 level contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/level-visible-contract + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/level-visible-contract/api1-native.json + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/level-visible-contract/api2-native.json + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/level-visible-contract/api2-unified.json + python3 tools/level-visible-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/level-visible-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: level-visible-contract + path: /tmp/level-visible-contract + if-no-files-found: ignore diff --git a/.github/workflows/live-maps-contract.yml b/.github/workflows/live-maps-contract.yml new file mode 100644 index 000000000..a1b8e83bb --- /dev/null +++ b/.github/workflows/live-maps-contract.yml @@ -0,0 +1,44 @@ +name: portable live-map lifecycle contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: live-maps-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 lifecycle, pixel reader and legacy readers + run: | + luajit tests/engine/live_maps_lifecycle.lua + luajit tests/engine/api3_compat.lua + luajit tests/engine/world_read_api.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/live-maps-contract + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --format native --report /tmp/live-maps-contract/api1-native.json + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format native --report /tmp/live-maps-contract/api2-native.json + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 2 --format unified --report /tmp/live-maps-contract/api2-unified.json + python3 tools/live-maps-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/live-maps-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: live-maps-contract + path: /tmp/live-maps-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/party-grid-contract.yml b/.github/workflows/party-grid-contract.yml new file mode 100644 index 000000000..d686447ce --- /dev/null +++ b/.github/workflows/party-grid-contract.yml @@ -0,0 +1,51 @@ +name: portable ui.party.grid_navigation contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: party-grid-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 party navigation dispatch and SDK contracts + run: | + luajit tests/engine/party_grid_navigation.lua + luajit tests/engine/battle_auxiliary.lua + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 party navigation contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare both engines in every SDK mode + run: | + mkdir -p /tmp/party-grid-contract + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/party-grid-contract/api1-native.json + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/party-grid-contract/api2-native.json + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/party-grid-contract/api2-unified.json + python3 tools/party-grid-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/party-grid-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: party-grid-contract + path: /tmp/party-grid-contract + if-no-files-found: ignore 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/.github/workflows/shader-import.yml b/.github/workflows/shader-import.yml new file mode 100644 index 000000000..ac28e8721 --- /dev/null +++ b/.github/workflows/shader-import.yml @@ -0,0 +1,62 @@ +name: shader import +on: + pull_request: + paths: + - '.github/workflows/shader-import.yml' + - 'tools/slang-import/**' + - 'tools/slang-import-check/**' + - 'src/render/ShaderPresets.lua' + - 'src/render/ShaderGraph.lua' + - 'tools/shader-graph-check/**' + - 'tests/engine/shader_graph.lua' + - 'tests/engine/slang_presets.lua' + push: + branches: [main, dev] + paths: + - '.github/workflows/shader-import.yml' + - 'tools/slang-import/**' + - 'tools/slang-import-check/**' + - 'src/render/ShaderPresets.lua' + - 'src/render/ShaderGraph.lua' + - 'tools/shader-graph-check/**' + - 'tests/engine/shader_graph.lua' + - 'tests/engine/slang_presets.lua' +permissions: + contents: read +concurrency: + group: shader-import-${{ github.ref }} + cancel-in-progress: true +jobs: + importer: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - name: install Rust + run: rustup toolchain install 1.99.1 --profile minimal --component rustfmt --component clippy + - name: format + run: cargo +1.99.1 fmt --manifest-path tools/slang-import/Cargo.toml --check + - name: lint + run: cargo +1.99.1 clippy --manifest-path tools/slang-import/Cargo.toml --locked --release --all-targets -- -D warnings + - name: compiler, rejection and publication tests + run: cargo +1.99.1 test --manifest-path tools/slang-import/Cargo.toml --locked --release + - name: install LÖVE and virtual display + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y love xvfb + - name: import fixtures and compare GPU pixels + if: runner.os == 'Linux' + shell: bash + env: + LIBGL_ALWAYS_SOFTWARE: '1' + run: | + destination="$RUNNER_TEMP/slang-presets" + mkdir -p "$destination" + for name in probe wrap graph; do + xvfb-run -a tools/slang-import/target/release/gen2-slang-import \ + "tools/slang-import/fixtures/$name.slangp" "$destination" --name "${name^^}" + done + SLANG_IMPORT_CHECK_DIR="$destination" xvfb-run -a love tools/slang-import-check + SHADER_GRAPH_CHECK_DIR="$destination" xvfb-run -a love tools/shader-graph-check diff --git a/.github/workflows/status-hud-contract.yml b/.github/workflows/status-hud-contract.yml new file mode 100644 index 000000000..d6c8f6c31 --- /dev/null +++ b/.github/workflows/status-hud-contract.yml @@ -0,0 +1,49 @@ +name: portable battle.status_hud_visible contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: status-hud-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 status HUD and native battle composition + run: | + luajit tests/engine/status_hud.lua + luajit tests/engine/status_hud_composition.lua + luajit tests/engine/status_hud_renderers.lua + - name: Validate pinned native Gen1 GBA battle UI + working-directory: .contract-reference/gen1 + run: luajit tests/modkit/cases/unified_gen3_battle_hooks.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/status-hud-contract + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/status-hud-contract/api1-native.json + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/status-hud-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/status-hud-contract/api2-unified.json + python3 tools/status-hud-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/status-hud-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: status-hud-contract + path: /tmp/status-hud-contract + if-no-files-found: ignore diff --git a/.github/workflows/storage-contract.yml b/.github/workflows/storage-contract.yml new file mode 100644 index 000000000..cb3634656 --- /dev/null +++ b/.github/workflows/storage-contract.yml @@ -0,0 +1,44 @@ +name: portable API-3 storage contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: storage-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 runtime + run: sudo apt-get update && sudo apt-get install -y luajit + - name: Check Gen2 native storage, save lifecycle and legacy cache + run: | + luajit tests/engine/api3_storage_transactions.lua + luajit tests/engine/api3_playthrough_storage.lua + luajit tests/engine/save_slots.lua + luajit tests/mod_storage_tests.lua + - name: Validate pinned Gen1 storage and identity contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/storage.lua + luajit tests/engine/playthrough_identity.lua + - name: Compare unchanged storage consumer and native save lifecycle + run: | + mkdir -p /tmp/storage-contract + python3 tools/storage-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/storage-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: storage-contract + path: /tmp/storage-contract + if-no-files-found: ignore diff --git a/.github/workflows/trainer-preparation-contract.yml b/.github/workflows/trainer-preparation-contract.yml new file mode 100644 index 000000000..afab720ef --- /dev/null +++ b/.github/workflows/trainer-preparation-contract.yml @@ -0,0 +1,52 @@ +name: portable trainer.before_battle contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: trainer-preparation-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 trainer admission, scripts and party scope + run: | + luajit tests/engine/trainer_preparation.lua + luajit tests/engine/trainer_preparation_native.lua + luajit tests/engine/trainer_party_scope.lua + - name: Validate pinned native Gen1 trainer contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/trainer_before_battle.lua + luajit tests/modkit/cases/unified_gen2_hooks.lua + luajit tests/modkit/cases/unified_gen3_hooks.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/trainer-preparation-contract + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/trainer-preparation-contract/api1-native.json + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/trainer-preparation-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/trainer-preparation-contract/api2-unified.json + python3 tools/trainer-preparation-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/trainer-preparation-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: trainer-preparation-contract + path: /tmp/trainer-preparation-contract + if-no-files-found: ignore diff --git a/.github/workflows/world-talk-contract.yml b/.github/workflows/world-talk-contract.yml new file mode 100644 index 000000000..d364dcbb2 --- /dev/null +++ b/.github/workflows/world-talk-contract.yml @@ -0,0 +1,51 @@ +name: portable world.talk contract +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: world-talk-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 NPC dispatch and dialogue + run: | + luajit tests/engine/world_talk.lua + luajit tests/engine/world_talk_routing.lua + luajit tests/engine/world_talk_dialogue.lua + - name: Validate pinned native Gen1 NPC contracts + working-directory: .contract-reference/gen1 + run: | + luajit tests/modkit/cases/world_talk.lua + luajit tests/modkit/cases/unified_npcs.lua + - name: Compare native API 1 and 2 + run: | + mkdir -p /tmp/world-talk-contract + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 1 --report /tmp/world-talk-contract/api1-native.json + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --report /tmp/world-talk-contract/api2-native.json + - name: Compare unified callbacks and API 3 + run: | + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --format unified --report /tmp/world-talk-contract/api2-unified.json + python3 tools/world-talk-contract-check/check.py --gen1 .contract-reference/gen1 \ + --api 3 --format unified --report /tmp/world-talk-contract/api3.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: world-talk-contract + path: /tmp/world-talk-contract + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 0ba18515f..19c16fb8e 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,10 @@ 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/fixtures/legacy_npc_controls/ +!/tests/engine/ diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..8931eb41f --- /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, third-party engine 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/README.md b/README.md index 4cd31e7c0..8711558c1 100644 --- a/README.md +++ b/README.md @@ -605,6 +605,11 @@ platform, renderer, and tooling are theirs; the Generation II import, script VM, world, and battle work are this fork's. See [LICENSE.MD](LICENSE.MD) for the full copyright split. +Contributions by **Roxas2712** — . Original native +asset-producer additions are covered by [the contribution terms](LICENSE-ROXAS2712.md), +including the specific integration permission for Cedric / UNDERdecoded. Earlier +grants and other contributors' rights remain effective. + ## Licence in one paragraph Two parts. **Everything inherited from Gen1Recomp, and every shared engine diff --git a/docs/api3-live-maps.md b/docs/api3-live-maps.md new file mode 100644 index 000000000..520ac1ac0 --- /dev/null +++ b/docs/api3-live-maps.md @@ -0,0 +1,85 @@ +# Portable live-map placements and lifecycle + +API 3 `mod.world:liveMaps()` returns detached placements with the Gen1Recomp +contract. The active map is first, followed by the native loaded-neighbor order: + +```lua +{ + { mapId = "ROOT", ox = 0, oy = 0, active = true }, + { mapId = "EAST", ox = 320, oy = -32, active = false }, +} +``` + +`ox`/`oy` are **world pixels**, relative to the active map. Negative offsets, +native ordering and repeated placements are retained. The query reads maps +already held by the world: it does not load maps, spawn NPCs, rebuild neighbors, +consume RNG or fire events. No game/world returns the existing refusal result. +Changing the returned records cannot change native residency or placement. +The shared native Gen2 overworld supports this API-3 reader on all Gen1–5 hosts. +Other API-3 world methods keep their own generation/availability limits. + +API 1/2 retain the enriched `WorldReads.liveMaps` records documented in +`world-read-api.md`: `x`/`y` are walk cells, with geometry metadata and duplicate +placement collapsing. API 3 previously inherited that Gen2 shape; this corrects +its pixel-field mismatch with Gen1. API-3 consumers must use `ox`/`oy` rather +than infer offsets from the legacy cell fields. + +## Native lifecycle events + +The shared overworld dispatches these around its actual connected-map ghost +rebuild: + +- `world.live_maps_preparing`: maps have been loaded and retained, before their + neighbor NPCs/ghosts are rebuilt. The internal preparation phase is active + during synchronous listeners. +- `world.live_maps_updated`: neighbor NPCs and cast invalidation have completed; + the preparation phase is over. + +Both payloads are `{ mapId = "ROOT", maps = }` in all +SDK formats. They contain fresh data records, not native Map handles. No return +value vetoes a rebuild. Listener failure follows the event bus's normal recovery +and does not stop native construction. Recursive rebuilds during preparation +suppress nested notifications and restore the outer preparation flag. Ordinary +rebuilds, including ones whose map IDs happen to stay the same, still notify. +When nobody subscribes, the native path constructs no event snapshots. + +```lua +local mod = ... +if mod:supports("events", "world.live_maps_updated") then + mod.events:on("world.live_maps_updated", function(payload) + for _, placement in ipairs(payload.maps) do + -- Refresh this mod's map-dependent state from mapId and pixel offsets. + end + end) +end +``` + +Gen1's pinned GB/Gold native paths provide these events. Its GBA field provides +the pixel reader but has no native producer for these event names. Capability +checks must therefore gate event-dependent behavior; registration alone does +not prove a producer exists. Gen2's shared native world provides both events +across all fifteen supported hosts. This does not provide a follower entity or +change the separate `world.follower.spawn` hook. + +## Verification + +The unchanged `portable_live_maps` consumer runs through both real SDKs and +native GB/Gold rebuild methods for Red, Blue, Yellow, Gold, Silver and Crystal, +in API 1 native, API 2 native/unified and API 3. The API-3 reader is also compared +with Gen1's real FireRed/Emerald world facade. Gen1 remains clean and pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`; its world producers are unchanged at +current dev `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`. + +Gen2 additionally tests all host versions, real NPC ghost construction after a +preparing listener, legacy cell-reader availability during preparation, native +placement duplicates, safe missing-world reads, once/unsubscribe, entry rollback, +listener failures, nested rebuilds and the unsubscribed allocation-free path. +Fixtures replace asset-loading boundaries while retaining the native graph walk, +Map/NPC objects and rebuild/SDK/event logic. These are ROM-free contracts, not +GPU pixel or full-playthrough validation. + +```sh +luajit tests/engine/live_maps_lifecycle.lua +python3 tools/live-maps-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/live-maps.json +``` diff --git a/docs/api3-npc.md b/docs/api3-npc.md new file mode 100644 index 000000000..3a38e387b --- /dev/null +++ b/docs/api3-npc.md @@ -0,0 +1,142 @@ +# API-3 public NPC actions + +Generations 1–3 support the same public creation, lookup and removal calls as +Gen1Recomp. Check availability and retain the world facade at mod entry: + +```lua +local mod = ... +local world = mod.world + +local function greet(mapId, spriteId) + if not mod:supports("world", "spawnNpc") then return end + local id, why = world:spawnNpc(mapId, { + name = "GREETER", sprite = spriteId, x = 3, y = 4, facing = "down", + }) + if not id then return nil, why end + local npc, reason = world:npc(mapId, id) + if not npc then return nil, reason end + return npc:scriptMove("down", 2, function() + npc:face("left") + end) +end +``` + +Invoke this later from an idle world. Reading the facade never bootstraps Game. +Gen4/5 report these three calls unavailable. API-1/2 retain their native behavior; +this does not add portable `queueScript` verbs. + +## Creation, lookup and ownership + +- `spawnNpc(mapId, definition)` returns the native runtime ID or `nil, reason`. + It can add a runtime definition to a known inactive map; an actor appears when + the native map lifecycle instantiates it. The return value promises a recorded + definition, not immediate live lookup or a common lifecycle event order. +- `npc(mapId, reference)` returns an opaque handle or `nil, reason`. References + are the live actor's string ID, its definition's numeric index or its name. + Only the active map and currently live neighboring map actors can be queried. +- `removeNpc(id)` returns `true` or `nil, reason`. Only runtime objects owned by + the calling mod may be removed. Imported objects and other owners are refused. + A mod may still look up and control an imported live actor. + +Map, sprite and object identifiers remain dataset-specific. Gen3 recognizes the +current version's `FR_`, `EM_`, `RU_` or `SA_` map prefix when an exact map ID is +absent. IDs/names are nonempty strings of at most 256 bytes without control +characters; numeric references are finite nonnegative integers. Use the ID +returned by `spawnNpc` for removal. + +Definitions are copied before native construction. Required `x` and `y` are +whole nonnegative walk-cell coordinates, at most 1,000,000. Optional `facing` is +`up`, `down`, `left` or `right`; optional `name` and `sprite` are identifiers. +A supplied sprite must exist in the active sprite registry. Native generation +fields remain supported as data; this API does not invent a canonical sprite, +movement or NPC definition schema. The host assigns runtime ownership and +identity, ignoring caller-supplied ownership/index markers. + +The copy accepts plain acyclic tables, strings, finite numbers and booleans. +Functions, userdata, metatables and cycles are refused before native writes. +Limits are depth 16, 4096 visited values/keys, 256 entries per table, 4096 bytes +per string and 64 KiB total string bytes. Refusal returns `nil, reason`. + +## Handles + +Handles expose `id` and these methods; they never expose the native actor: + +| Method | Result and behavior | +| --- | --- | +| `position()` | `x, y` in zero-based 16-pixel walk cells, in the actor's own map frame. | +| `isMoving()` | Boolean native movement state. | +| `canStep(direction)` | Boolean native collision permission, including the host's current seam cast for connected maps. It is a query, not a reservation. | +| `face(direction)` | Set facing. | +| `stepNow(direction)` | Begin one authored step; refuse an already moving actor. | +| `placeAt(x, y, facing?)` | Place at whole nonnegative coordinates up to 1,000,000, clear in-flight movement and synchronize pixel position. | +| `setPassable(boolean)` | Set the live actor's passability. | +| `setAppearance(spriteId)` | Refresh a live actor using an existing sprite registry entry. | +| `scriptMove(direction, tiles?, onDone?)` | Queue native movement, default one cell; `tiles` must be an integer from 1 to 1024. | +| `marchInPlace(onDone?)` | Queue one native stationary march. | + +Directions are the four lowercase names above. Mutations return `true` or +`nil, reason`; a stale handle returns `nil, "NPC handle is stale"`. Handles are +bound to the original actor, map instance, Game, save, data and version. Reused +native IDs or editing the public `id` field cannot redirect a retained handle. +The facade follows the current Game, while old actor handles do not. + +Spawn, removal and handle mutations use the shared API-3 idle-world guard: +a save and topmost overworld are required; menus, native/portable/parallel +scripts, player movement, controller locks, transitions and link sessions refuse +admission. Reads remain usable during overlays. Synchronous reentry into NPC or +object/block mutations is refused. Movement tracks require the active map; +other handle calls also work for live neighboring actors. + +`stepNow` and `placeAt` are authored operations, matching the native distinction +from `canStep`: they do not automatically perform collision validation or clamp +to map bounds. Choose coordinates and query collision as appropriate for the +scene. Appearance changes construct a renderer before committing the actor +change, retain its cell position and leave the native template sprite intact; +they are not persistent content edits. + +## Movement and mod lifetime + +Completion callbacks are optional, one-shot and attributed to the initiating +mod. Native synchronous completion is deferred until admission finishes and +the shared mutation guard is released. Native callback arguments, including +nil holes, are preserved. Callback errors are contained and reported once. + +Pending completions are permanently retired on observed Game/save/data/world, +map, stack, version, actor or runtime-bus replacement, and on native map lifecycle +events. The input-step boundary observes replacements before and after native +updates. Retiring removes only the movement entries owned by that operation; +it does not pop screens or cancel another mod's tracks. + +Rollback, reload and teardown retire the mod's facade and pending tracks and +remove its recorded runtime definitions/actors, including definitions spawned +on inactive maps and instantiated later as neighbors. Foreign runtime objects +and imported actors remain. Controls already applied to imported actors are +ordinary native scene changes, not a transactional patch with automatic undo. +Retained handles/facades return `nil, "world facade is retired"` after retirement. + +## Verification + +```sh +luajit tests/engine/api3_npc.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The clean Gen1 reference stays pinned to +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same public-only NPC mod runs +through both production loaders for Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald. Its eight cases extend the full checker to 48 comparisons +and 96 isolated runs. The shared fixture compares creation/lookup, detached +definitions, collision/passability, appearance, authored/queued movement, +callback replay/arguments, optional callbacks and ownership-aware removal. + +Local tests additionally cover bounded validation, busy worlds, reentry, stale +sessions/handles, callback errors, lifecycle cancellation and scoped cleanup. +They exercise the host's actual NPC constructor, native runtime add/remove, +scriptMove/marchInPlace/updateScriptMoves, NPC updates and seam collision casts. +Synthetic datasets and a deterministic SpriteRenderer construction seam keep +these ROM-free. The tests verify the documented shared subset, not retail-ROM +playthroughs, GPU artwork or identical native internals/event timing. Host +validation and lifecycle guards are intentionally stricter than some reference +native paths. diff --git a/docs/api3-portability.md b/docs/api3-portability.md new file mode 100644 index 000000000..7a11b30a6 --- /dev/null +++ b/docs/api3-portability.md @@ -0,0 +1,162 @@ +# 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; +- [`warpTo`](api3-warp.md) for validated native map transitions; +- [`toggleObject` and `replaceBlock`](api3-world-edits.md) for validated native + visibility and active block/metatile edits; +- [`spawnNpc`, `removeNpc` and `npc`](api3-npc.md) for runtime NPC creation, + ownership-aware removal and opaque live actor controls. + +Check `mod:supports("world", name)` before relying on a call. The other +Gen1 world method names remain callable but explicitly return +`nil, "unsupported: ..."`: `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, +`battle.caught_marker_visible` can be subscribed to but reports unavailable here. Unknown +names remain errors. An accepted subscription is not proof that a feature +runs. Source-specific engine handles in lifecycle/UI payloads remain native. + +For scripts, use `mod:supports("scripts", verb)` to test Gen2Recomped's current +bounded vocabulary. A `queueScript` method does not imply support for every +Gen1 script verb. See [portable world scripts](portable-world-scripts.md). + +## Reproducible contract checks + +```sh +luajit tests/engine/api3_entry.lua +luajit tests/engine/api3_compat.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The external reference must be a clean checkout at +`bryanthaboi/gen1recomp@79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. +Use a separate checkout; do not reset a working installation. No Gen1 engine +implementation is vendored into this repository. Only public callback names +are recorded as a compatibility catalog. + +The exact same manifest and mod entry run on both engines for Red, Blue, +Yellow, Gold, Silver, Crystal, FireRed and Emerald, in both success and +intentional-rollback cases. Each engine/version/case has its own LuaJIT +process. The shared harness constructs native-shaped synthetic datasets; +the mod fixtures contain no engine imports or engine-specific branches. +The runner also generates an API-3 mod through the real scaffold CLI and +loads those same generated files on both engines for each version. This adds +eight comparisons to the sixteen normal/rollback cases. Eight additional warp +cases use the same public mod on both engines and native-shaped timing seams. +Eight object/block cases and eight NPC cases extend this to 48 comparisons +and 96 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. Warp cases also compare validation errors, native dispatch +arguments, destination events, boundary coordinates, arrival options, callback +argument holes/replay, stale sessions/owners, synchronous completion and error +attribution. Object/block cases compare native reference routing, visibility/flag +changes, block geometry/bounds, native reads and one event per successful edit. +NPC cases compare runtime creation/lookup, movement/appearance, optional +completion callbacks and ownership-aware removal using the same public handles. +Separate Gen2 tests check API-1/2 behavior, contradictory +manifest fields, honest availability and malformed flag stores. CI pins the +same external commit and uploads the report. + +These are ROM-free loader/runtime-boundary tests, not retail-ROM gameplay +or a proof of every exposed native callback's payload. Native action details +remain covered by the prerequisite PR suites. Extend these shared fixtures +whenever another API-3 service becomes portable; do not make a failing +comparison pass by changing only one engine's mod fixture. + +The [portable follower predicate](portable-follower.md) controls native companion +existence on Gen1–3 without replacing a private controller. Artwork remains a +separate concern; check `mod:supports("hooks", "world.follower.spawn")`. diff --git a/docs/api3-storage.md b/docs/api3-storage.md new file mode 100644 index 000000000..2614b295a --- /dev/null +++ b/docs/api3-storage.md @@ -0,0 +1,90 @@ +# API-3 playthrough storage + +API 3 uses the Gen1Recomp public `mod.storage` contract: data-only records and +opaque bytes belong to a mod, a game version and an opaque playthrough identity. +The handle contains only `context`, `selected`, `read`, `write`, `readBytes`, +`writeBytes`, `list` and `delete`; private paths and mutable engine objects are +not exposed. The transactional implementation follows the pinned Gen1Recomp +`Storage.lua` at `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +```lua +local mod = ... +local context, code = mod.storage:context(mod.game) +if context then + local state, reason = mod.storage:read(mod.game, "progress/visited") + if not state and reason == "not_found" then state = { maps = {} } end + -- Other errors require handling; they do not mean an empty namespace. +end +``` + +`context(game)` returns `engineVersion`, `gameVersion` and `playthroughId`. +The identity is opaque, stable across normal save/load and independent of +slot names. A new game has a distinct requested identity; duplicate imported +slots receive distinct scopes. The engine allocates it lazily without consuming +gameplay RNG. Unused vanilla saves remain identity-free. Legacy backfill writes +routing metadata to options; requesting storage alone does not rewrite normal +progress. The next ordinary save stamps the identity into its metadata. + +Records must be serializable data tables. Bytes are strings, including empty +strings and NULs, up to 512 MiB per key. Keys use nonempty letters/numbers/dash/ +underscore segments separated by `/`. Absolute paths, `..`, dots, empty segments +and backslashes are rejected. A key cannot change between records and bytes +until explicitly deleted. Lists are sorted, de-duplicated logical keys; a prefix +selects an exact key or a directory. Reads return decoded copies. + +Writes stage and verify data, retain a verified backup, replace and verify the +main copy, then remove the staging file. Reads recover from valid staging or +backup data. Reads return `nil, code, message` on failure; writes/deletes return +`false, code, message`. Important codes include `not_found`, `invalid_key`, +`encode_failed`, `type_conflict`, `type_mismatch`, `write_failed`, `verify_failed`, +`storage_unavailable` and `not_in_playthrough`. Recovery is best-effort; this is +not a multi-key transaction or a concurrent-writer database. + +## Selected storage at the title + +`mod.storage:selected(mod.game)` is available only while a native TitleState is +in the stack. It resolves the engine-selected durable playthrough without +allocating a new identity. The returned handle binds that scope: + +```lua +local selected, reason = mod.storage:selected(mod.game) +if selected then + local context = selected:context() -- may include normalSavedAt + local state = selected:read("progress/visited") -- no game argument +end +``` + +The title handle offers the same read/write/list/delete methods without a game +argument. Returned contexts are detached copies; callers cannot reroute the +bound handle by changing them. Init-time reads against a fresh boot skeleton +cannot replace an existing real save's selection. A successfully committed +normal save is authoritative if a later options update fails. Merely browsing +a fresh title without durable state reports `no_selected_playthrough`. + +## Existing Gen2 mods and cache data + +API 1/2 retain the existing `src.mods.Storage` behavior and `modstorage/` data +unchanged. API 3 uses a separate playthrough namespace. No automatic copy guesses +which save owns an old installation-wide payload. ROM-derived, reproducible +assets belong in the existing `mod.cache` service. Authors upgrading a legacy +mod must explicitly export/migrate authored bulk state while using its old API, +or rebuild derived caches through their dedicated cache/import service. Changing +the manifest to API 3 does not automatically assign old shared records to a save. + +## Verification + +The unchanged API-3 example runs through real SDKs for the eight games both +engines support: Red/Blue/Yellow, Gold/Silver/Crystal, FireRed and Emerald. +Shared checks cover data/byte transactions, isolation, recovery/failures, +listing, title handles and native New Game/save/load. Gen2 additionally tests +all fifteen supported hosts and slot promotion, boot skeletons, duplicate slot +imports, failed saves, deletion, legacy API retention and RNG independence. +These are ROM-free persistence/controller contracts; native cartridge smoke +tests are separate and do not establish full playthrough compatibility. + +```sh +luajit tests/engine/api3_storage_transactions.lua +luajit tests/engine/api3_playthrough_storage.lua +python3 tools/storage-contract-check/check.py --gen1 /path/to/gen1 \ + --report /tmp/storage-contract.json +``` diff --git a/docs/api3-warp.md b/docs/api3-warp.md new file mode 100644 index 000000000..6e4c1f055 --- /dev/null +++ b/docs/api3-warp.md @@ -0,0 +1,130 @@ +# Portable API-3 world warps + +API-3 mods can use the same public call on Gen1Recomp and Gen2Recomped: + +```lua +local mod = ... +local world = mod.world -- Safe to retain at entry; does not create Game. + +-- Call later, from an idle overworld interaction/update. +local function goToDestination(mapId, cellX, cellY) + if not mod:supports("world", "warpTo") then return end + local accepted, reason = world:warpTo(mapId, cellX, cellY, "down", { + keepMusic = true, + onDone = function() + mod.log:info("Arrived") + end, + }) + if not accepted then mod.log:warn("Warp declined: %s", reason) end +end +``` + +`warpTo(mapId, x, y, facing, options)` returns `true` after native admission, +or `nil, reason`. Admission does not mean arrival. It uses the existing native +`OverworldState:startWarpTo` transition, map-entry events, spawn bookkeeping +and arrival-tile guard. No direct player/save relocation bypasses that path. +This API-3 adapter does not change API-1/2 facade defaults or validation. +Generations 1–3 advertise support; API 3 still refuses this world operation on +Gen4/5. Availability is a capability, not a promise that the current world is +idle or the named map exists. Reading the facade or querying support does not +bootstrap Game. + +## Admission and coordinates + +The current Game must have a save and a live, topmost overworld. Screens, +native/portable scripts, queued/parallel scripts, movement, transitions, +field/controller locks, link sessions and another pending API-3 warp decline. +Gen2Recomped reuses its existing world-action admission guard and checks its +additional native foreground owners. Idle-state details differ between the +engines; both keep their own native readiness checks. + +Map IDs are nonempty strings of at most 256 bytes, without control characters. +The map must exist in the current merged `game.data.maps`; custom merged maps +are supported. Gen3 also accepts unprefixed names when the current version's +prefixed map exists (`FR_`, `EM_`, `RU_`, `SA_`). An exact ID wins over an alias. +There is no cross-game map-name translation: the mod selects a real map in +the active dataset. + +`x` and `y` are finite, nonnegative whole numbers in zero-based **16-pixel +walk cells**. Gen1/2 dimensions are twice the map's block dimensions; Gen3 +uses the map's metatile dimensions directly. The final valid cell is +`widthInCells - 1, heightInCells - 1`. Invalid/missing map dimensions refuse +before dispatch. Coordinates are bounded, not clamped; terrain passability is +not part of this contract. + +Facing is `up`, `down`, `left` or `right`. Omitted facing follows Gen1's common +contract: `down` on generation 1, current player facing on generations 2/3, +with `down` as fallback. Explicit facing is recommended for portable scenes. + +Options must be a plain table. Only these fields are accepted: + +| Field | Meaning | +| --- | --- | +| `arrive` | `"fly"` or `"teleport"`, using native arrival presentation | +| `keepMusic` | Boolean; forwarded to native map loading | +| `onDone` | Function, called at most once after accepted native completion | +| `fade` | Boolean, generation 3 only; false uses the same native midpoint/completion without pushing a fade screen | + +Unknown options, invalid types and unsupported `fade` use decline the whole +request before native dispatch. Options are copied; later caller edits cannot +change an accepted request. Invalid admission does not set arrival effects, +change maps/coordinates/saves, raise map-entry events or call `onDone`. +Representative reasons include `no game`, `no overworld`, `no save`, +`world is busy`, `invalid map id`, `unknown map: `, `invalid coordinates`, +`coordinates out of bounds`, `invalid facing`, `invalid options`, +`invalid arrival`, `invalid callback`, `unsupported fade` and +`unknown warp option: `. These are admission failures, not exceptions. +Native failures after dispatch can already have native effects; there is no +transactional rollback of an entered transition. + +## Callback ownership + +`onDone` runs only for an accepted request whose Game, save, data, stack, +overworld, player, version, loader buses and mod lease are still current, at +the requested destination. It preserves the native callback arguments, +including nil holes. Synchronous native completion is held until the native +return confirms admission. A rejected/throwing native dispatch cannot invoke +the mod callback, and duplicate native completions are ignored. + +A replaced session/owner observed at input-step boundaries, another map entry +or leaving the destination retires the completion permanently. Returning to an +older session cannot revive a completion already retired. Loader rollback, +reload and teardown retire the facade and its pending callback; a retained +facade returns `nil, "warp facade is retired"`. Retirement does not pop screens +or undo work belonging to the native controller. + +The callback is attributed to its mod, errors enter the loader's runtime error +list once, and the previous runtime owner is restored. An error never retries +a warp or the callback. A completion may start a new request after the native +world becomes idle. The adapter adds no synthetic map-entry event; normal +native map lifecycle remains in control. + +The portable `queueScript` language is unchanged: adding this direct method +does not enable a `warp` script row. + +## Verification + +```sh +luajit tests/engine/api3_warp.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin remains +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same manifest/entry is loaded by +both real loaders for Red, Blue, Yellow, Gold, Silver, Crystal, FireRed and +Emerald. The shared driver builds each engine's native-shaped Game/world data +and replaces only native transition timing seams. It compares validation +reasons, dispatch arguments, public destination events and callback results. +The mod has no engine imports or backend-specific branches. Together with the +existing normal, rollback, generated-starter and object/block cases, CI now +performs 48 comparisons / 96 isolated loader runs. + +The local suite additionally drives Gen2Recomped's real native +`startWarpTo` and `Transition` using synthetic map materialization, including +fade/no-fade, music options, native bookkeeping, lock release and one-shot +completion. Further cases cover host-specific locks, malformed maps, callback +reentry, native refusal/exception, lease retirement and API-2 behavior. These +are ROM-free runtime-boundary tests; no retail-ROM playthrough or rendered +arrival-effect equivalence is claimed. diff --git a/docs/api3-world-edits.md b/docs/api3-world-edits.md new file mode 100644 index 000000000..1e66a6fa1 --- /dev/null +++ b/docs/api3-world-edits.md @@ -0,0 +1,124 @@ +# API-3 object visibility and block edits + +API-3 mods can call the same public methods on both engines: + +```lua +local mod = ... +local world = mod.world -- Retain at entry; invoke later from an idle world. + +local function openGate(mapId, objectRef, blockX, blockY, openBlock) + if not mod:supports("world", "toggleObject") then return end + local ok, why = world:toggleObject(mapId, objectRef, false) + if not ok then return nil, why end + if mod:supports("world", "replaceBlock") then + return world:replaceBlock(blockX, blockY, openBlock) + end +end +``` + +Each call returns `true` or `nil, reason`. Calls are independent, not an atomic +multi-command transaction. Support is advertised for generations 1–3; API-3 +Gen4/5 still refuse both methods. A captured facade follows the current Game +without bootstrapping one. Rollback, reload and teardown retire its mutation +lease; retained calls return `nil, "world facade is retired"`. API-1/2 behavior +and the portable script vocabulary are unchanged. + +Both methods require a save and an idle, topmost overworld. Native scripts, +portable scripts, queued/parallel scripts, menus, movement, transitions, +controller locks and link sessions refuse admission. Reentrant object/block +edits from an edit's own event/native callback return `nil, "world is busy"`; +the guard is released on success and native exceptions. + +## `toggleObject(mapId, objectRef, visible)` + +`mapId` and string references are nonempty strings of at most 256 bytes without +control characters. Numeric references must be finite nonnegative integers; +`visible` must be an actual boolean. The map must exist in the active merged +dataset. Gen3 also resolves unprefixed map names using the current version's +prefix when an exact ID is absent, as `warpTo` does. + +Reference routing and persistence deliberately retain the Gen1 API-3/native +contracts, rather than treating all generations' object numbers as identical: + +| Generation | Reference | Scope and effects | +| --- | --- | --- | +| 1 | Native visibility key, normally the object's `name`; numeric keys are also accepted | May name an inactive map or a key with no currently defined actor. Writes `save.objectToggles`. An active map refresh uses the native seamless reload, retaining player cell/facing and music. | +| 2 | 1-based position in `map.def.objects`, or its `name` | Active map only. Resolves the public slot to its native template and uses the same effect helper as native appear/disappear. Writes the object's event flag and visit-scoped visibility, synchronizing only that actor. | +| 3 | Native local object ID, `name` or `id` | Active map only. Uses native add/remove commands, including event flags, spawned-object state and flagless visit state; synchronizes only the selected actor. | + +This API does not make map/object IDs portable between cartridges: select a +reference from the active dataset. On Gen2/3, an unknown reference returns +`nil, "no such object: "`; an inactive map returns +`nil, "map is not active: "`. Missing object lists and malformed native +object/store metadata refuse before native writes. Gen1's key store keeps its +existing ability to prepare a later map's visibility. + +Flagged Gen2/3 objects use their native persistent flags. Flagless objects +retain native visit-scoped behavior. A targeted sync does not respawn, relocate +or re-derive unrelated live actors, even when several templates share a flag. +Other native visibility gates (time, story flags, script-spawn rules) still +apply; acceptance is not a promise to override every native gate. + +A successful call emits one `world.object_toggled` event with +`mapId`, `objName = objectRef` and the boolean `visible`. Repeating an accepted +value still follows the native call/event contract. Gen1's active refresh may +also produce its normal native map lifecycle events. Gen2/3 do not reload the +whole map for this operation. + +## `replaceBlock(blockX, blockY, blockId)` + +This changes only the active map. Coordinates are zero-based whole **block** +coordinates, with `0 <= x < map.width` and `0 <= y < map.height`: + +| Generation | One coordinate unit | Valid ID range | +| --- | --- | --- | +| 1/2 | 32-pixel block, 2×2 walk cells | 0–255, and the block must exist in the active tileset | +| 3 | 16-pixel metatile, one walk cell | 0–1023 | + +Coordinates/IDs must be finite integers. Gen3 range validation does not promise +that every ID has artwork in a particular tileset. The adapter checks the +current block, runtime geometry and redraw entry before mutation. Unknown +blocks, unavailable geometry/renderers, fractions, NaN/infinity, negatives and +out-of-bounds coordinates are declined without writing a patch or emitting an +event. Typical reasons are `invalid block id`, `unknown block id`, +`invalid block coordinates`, `block coordinates out of bounds`, +`block unavailable`, `unsupported block geometry` and +`block renderer unavailable`. + +Gen1/2 use the existing native world block replacement. Gen3 uses the host's +native metatile command with its default non-impassable setting, including +clearing old collision/shut-cell overrides, and redraws the runtime map. +Terrain/collision tables remain native; there is no invented shared collision +registry. `activeBlockAt` immediately sees the change. One successful operation +emits one `world.block_replaced` event with `mapId`, `bx`, `by` and `block`. + +On Gen2Recomped this is an instance patch, leaving generated map data intact. +It is not a persistent content edit: permanent layout changes belong in the +map registry. Native execution failures can occur after a native write and do +not promise transactional rollback; preflight refusals have no such effects. + +## Verification + +```sh +luajit tests/engine/api3_world_edits.lua +python3 tools/api3-contract-check/check.py \ + --gen1 /path/to/clean/pinned/gen1recomp --lua luajit \ + --report /tmp/api3-contract-report.json +``` + +The existing Gen1 pin stays at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same new fixture manifest/source +runs under both production loaders for Red, Blue, Yellow, Gold, Silver, +Crystal, FireRed and Emerald. Engine-specific setup stays in the shared +driver, never in the mod. Native-shaped map/render/object seams let the driver +compare refusal reasons, visibility/flag effects, native block reads, geometry +and event payloads. The full checker now runs 48 comparisons / 96 isolated +processes, including all previous entry, rollback, generated-starter and warp +cases. + +Local tests additionally exercise the actual Gen2Recomped native Map, +OverworldState block replacement and object synchronization with synthetic +pooled actors, including shared flags, retained positions, flagless resets, +packed metatiles, collision clearing, malformed stores, reentrancy and retired +owners. These are ROM-free runtime-boundary tests; they do not establish +rendered retail-ROM equivalence or full scene-playthrough coverage. diff --git a/docs/audio-workers-p6.md b/docs/audio-workers-p6.md new file mode 100644 index 000000000..c0283cef6 --- /dev/null +++ b/docs/audio-workers-p6.md @@ -0,0 +1,106 @@ +# PCM and audio workers (P6) + +`src.core.Performance.audioWorkerOptimization` defaults to `true`. Set it to +`false` to disable this task independently of the other performance changes. +The live chip-music worker receives flag changes; existing unused prewarm +entries are discarded. New DS players use the legacy four-buffer transport +when disabled, and active worker players materialize their queued state. + +## PCM writes + +`AudioPcm` returns ordinary 16-bit `SoundData`. It acquires a signed PCM pointer +once per buffer, with `setSample` as the fallback. FFI writes require an active +JIT: interpreted FFI was slower on the tested LÖVE 11 build. + +The installed `SoundData.setSample` implementation determines conversion: +the C method takes and multiplies floats, while LÖVE's LuaJIT wrapper multiplies +numbers directly. The independently written converter follows that distinction. +See the official [C implementation](https://github.com/love2d/love/blob/11.5/src/modules/sound/SoundData.cpp) +and [LuaJIT wrapper](https://github.com/love2d/love/blob/11.5/src/modules/sound/wrap_SoundData.lua). +Boundary, mono/stereo and array tests compare complete output bytes. + +## Effects and cries + +`Sound.update`, serviced by `Music.update`, submits at most one speculative job +per frame, with two jobs in flight. The existing synthesis thread returns PCM +on `chipaudio_fx`; only the main thread creates Sources. Music buffers and effect +jobs share the worker fairly. Jobs retain their own stereo/channel mix and +restore the live music mix after rendering. + +Completed Sources enter the existing private `Sound.lua` cache without playing, +ducking music or emitting `sound.played`. Unused speculative audio is limited to +16 MiB. First use rechecks the exact definition, derived cry resolution, audio +input and mix. Changed entries are discarded. Used entries retain the existing +cache behavior. Invalidation, cancellation and audio-session suspension reject +late results. Failed speculative jobs do not mark a sound as permanently bad. +Custom/metatable/cyclic definitions use the existing source constructors. + +A cold cue, unavailable worker or disabled prewarm uses the original synchronous +constructor and logs the fallback once per synthesis kind. This keeps the +existing immediate Source return, playback and callback timing. + +## DS transport and its compatibility boundary + +Eligible SSEQ players share `chip_worker` with the other synthesizers. The source +has 16 buffers, each with up to 8192 stereo frames at 32768 Hz. Worker output is +bounded to two blocks per player; at most 16 worker players and four archive +registrations are retained. Inactive jobs expire after five seconds. One-shots +retain the legacy 512-frame end padding instead of adding an 8192-frame tail. + +Worker samples use signed 16-bit ByteData and private pointers. PCM8, PCM16 and +ADPCM retain their decoded values; PSG square/noise amplitudes have an explicit +scale. Mixing proceeds by voice between sequencer tick boundaries, preserving +phase addition, event-before-sample ordering and voice accumulation order. +Normal main-thread updates transfer completed buffers and state snapshots. + +Sequencer state belongs to the worker while this path is active. Ordinary reads +or writes of `tracks`, `voices`, `samples`, `variables`, `tempo`, `tickPhase`, +`master`, `ended`, `arc`, `seq`, `bytes` or `start` retire the worker, restore the +legacy metatable and materialize the last **queued** snapshot. PCM arrays are +then ordinary Lua tables again. Direct sequencer-method overrides also select +the legacy path. Already queued audio remains queued; no hidden replay occurs. +The normal volume, pitch, looping, pause, play, stop and variable methods remain. + +This is an explicit compatibility boundary: raw table enumeration (`pairs`, +`rawget`) and inspection of the temporary transport metatable bypass ordinary +field access. Consumers that depend on those operations should disable P6 before +constructing a DS player. Snapshots describe queued synthesis, potentially ahead +of audible output. A full 16-block queue represents four seconds of lookahead; +control changes affect future synthesis. A paused player whose worker lease +expires resumes through the logged legacy fallback. + +The conservative instruction walker keeps sequences using the main RNG or shared +variables on the legacy transport. Argument bytes are parsed by opcode shape, +not searched as raw opcode values. Worker errors, expired jobs and disabling the +flag also select the logged legacy renderer. + +## Reproduction and scope + +Run `python3 tools/check_audio_profile.py --love /absolute/path/to/love +--report .local/qa/audio-workers/profile.json` (one shell line). The checker uses +`qa_storage`, an isolated identity, complete source hashes and owned compact +GB/M4A/SDAT programs. It tests actual native SoundData and threads. Source hardware +is a double, with no output device or player save/profile involved. + +The DS comparison checks every PCM byte across all three encodings, PSG voices, +looping and one-shot tails, exact phase and live voice fields, independent players, +variable methods and compatibility fallbacks. FrameProfile uses 120 warmup and +120 measured frames per case: PCM writes, cold/prewarmed first cue and legacy/worker +DS updates under 512-frame-per-frame simulated consumption. These are scoped CPU +measurements, not a game FPS, audio-device recovery or complete playthrough claim. +Mobile/handheld measurements are not available. + +Measured on desktop macOS ARM64, LÖVE 12 / Metal with JIT enabled, using the +owned fixture and Source double described above: + +| Main-thread section | Legacy/cold | P6 | +| --- | ---: | ---: | +| 8192 stereo PCM frames | 0.148 ms | 0.065 ms | +| First effect cue | 0.235 ms | 0.019 ms after prewarm | +| DS update, simulated 512 frames consumed per frame | 0.089 ms | 0.010 ms | + +The [source-bound validation record](qa/audio-workers-p6.json) retains the native +report and acceptance summary. The native checker passes 358 scoped checks. These values are source-bound to +its local report; rerun the checker for another host/build. They exclude actual +Source-device cost. Interpreted LÖVE 11 keeps `setSample`; a native comparison +must not be presented as a JIT FFI speedup on that build. 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/decoded-script-callbacks.md b/docs/decoded-script-callbacks.md new file mode 100644 index 000000000..7941a0580 --- /dev/null +++ b/docs/decoded-script-callbacks.md @@ -0,0 +1,84 @@ +# Per-mod decoded script callbacks (M5) + +The host can select a callback profile without editing a mod's manifest or Lua +files. Native callbacks remain the default. For example, the normal options +record can include: + +```lua +modCallbackProfiles = { + example_mew_starter = "decoded", +} +``` + +`Loader.new({ callbackProfiles = { example_mew_starter = "decoded" } })` is +an alternative for an embedding host. Select `"native"` or remove the entry to +roll back. The profile applies to Gen 2/3 and requires the mod's native callback +format. Unified callback records remain a separate existing choice. + +The selected mod registers `script.command` on a private, loader-owned bus. +Native subscribers retain their existing bus, names, operand arrays, ordering +and context. The original compiled script table remains identical until a mod +actually changes a decoded operation. Compiler provenance and tracing stay in +private side tables; no scratch records are placed in public script rows. +Provenance holds weak source references: retained rows cannot keep a retired +dataset alive, and replaced source pools explicitly retire their old provenance. + +The profile presents `(next, ctx, op, row)` for Gen 3 and +`(next, ctx, op, args, cmd)` for Gen 2. Numeric operand slots and the supported +named aliases describe the source operation, before native lowering. A single +source operation may have zero, one or several native rows. `next` runs that +whole native group; code after `next` observes its native effects. Rewriting an +opcode or operand recompiles a private overlay using the same native compiler. +Calls keep their active return addresses. Gen-2 call hooks remain open until +the called subroutine returns, including across native waits. Each loop visit offers the original +source operation again, so a previous rewrite or veto cannot rewrite the +imported source. Vetoes also remove compile-time text state. A retained `next` +cannot replay a completed operation. + +Gen-2 numeric `b/w/m` layouts in the stock editions support lossless raw byte +edits. Normalized pointers, rich menu/queue records and hack-specific operand +layouts do not have a lossless raw byte reconstruction here. Use supported +decoded fields; unsupported byte edits fail explicitly. This does not add +missing native opcode implementations or a separate foreign VM. Manually +replaced compiled row lists cannot be paired with stale source provenance and +are explicitly declined by the decoded profile. Hand-authored ordinary runner +lists keep their native callbacks. + +`pokemon.before_give` uses its existing event call site once. Only the selected +listener receives a separate view with symbolic species, `speciesId` when the +native species order provides it, and its profile context. Species changes +(including numeric IDs), bounded level changes and nicknames commit to the +native gift. A variable-backed Gen-3 gift also retains the edited species in +that native variable. Existing native listeners receive their original record. +Priorities, unsubscribe, once and failed-entry cleanup use the existing buses. + +The current context translation has one explicit map alias: the verified +FireRed/LeafGreen native Oak's Lab definition (group 4, number 3) appears as +`OAKS_LAB`. The definition must belong to that live map registry; conflicting +slot definitions decline translation. Other maps retain their existing IDs. +Shared map objects, storage keys and saves are never renamed. Broader map-name +compatibility is a separate existing integration. + +## Qualification + +The authenticated, unchanged starter package at pinned reference `535a5ec` runs +through the production Loader, compiler, runner, gift event and Pokémon +constructor over owned synthetic data. Its Gen-3 hook changes variable `0x4002` +to 151 and creates exactly one level-20 Mew named `HOGHEAD`; native map identity +remains `MAP_G04_N03`. This is a runtime contract check, not cartridge gameplay +acceptance. The fixture authentication manifest and mod package bytes are +unchanged. + +The focused suite also covers native command order and script identity, +post-next effects, multi-row calls, opcode replacements, return remapping, +zero-row commands, vetoed text state, repeated visits, Gen-2 raw/named operands, +retained callbacks, once/rollback cleanup and native gift record identity. + +`tools/check_decoded_profiles.py` runs an owned LÖVE window, the actual Loader +profile and the real ScriptRunner. It compares a pre-change runner, the current +native path and the selected decoded path over 64 writes plus an end per frame, +with 120 warm-up and 120 measured frames per phase. F11 reports +`script.exec (64 writes + end)` and each hook's own time. This deliberately +stresses script dispatch every frame; it does not measure a world/battle scene, +GPU improvements or handheld performance. The enabled translation has a cost; +no general FPS improvement is claimed. See the PR for fingerprinted results. 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/gbc-color-correction.md b/docs/gbc-color-correction.md new file mode 100644 index 000000000..10f7851b1 --- /dev/null +++ b/docs/gbc-color-correction.md @@ -0,0 +1,49 @@ +# GBC colour correction + +Gold, Silver and Crystal can display cartridge RGB values through an optional +GBC display-response model. In **OPTIONS -> GBC COLOR**, choose **OFF**, +**ACCURATE** (SameBoy's Modern Accurate) or **HARSH** (Harsh Reality). The +launcher also offers **GBC COLOR (GEN 2)** under game defaults, including +per-generation overrides. The selection takes effect immediately in game. + +OFF is the default and leaves the original render path unchanged. The option +does nothing on other generations or in OG, OG INV and CLASSIC monochrome +modes. The saved preference is retained when switching those modes. + +ACCURATE corrects the GBC brightness curve and green/blue mixing while keeping +black and white. HARSH additionally models the original LCD's reduced contrast +and tinted black/white points. Neither is a calibration for a particular +physical screen or its lighting. Both use the CGB model with neutral light +temperature; this does not emulate an AGB panel or SameBoy's light-temperature +slider. + +The colour mapping is adapted from +[SameBoy Core/display.c](https://github.com/LIJI32/SameBoy/blob/26a56bd6a122a6318a456abd80558408742d5adf/Core/display.c#L339), +`GB_convert_rgb15`, revision `26a56bd6a122a6318a456abd80558408742d5adf`. +Its Expat notice is retained in [licenses/SameBoy.txt](../licenses/SameBoy.txt). +See also [SameBoy's mode documentation](https://github.com/LIJI32/SameBoy/wiki/GB_set_color_correction_mode). + +The renderer uses a cached 256x128 lookup texture per profile and a simple +two-sample shader. Interpolation between RGB555 entries keeps true-colour mod +gradients smooth. It processes the native world/UI composite before mod +post-processing and the independent GBC FX grid/glare. Launcher chrome and +touch controls are outside that composite. Mods taking over `render.compose` +own their presentation and bypass this native pass. + +Enabling correction adds one screen-sized colour pass. Shader failure falls +back to the original image. Changing it does not modify caches, ROM-derived +palettes or gameplay state and needs no reimport. + +## Verification + +`luajit tests/engine/gbc_color_correction.lua` compares all 32768 RGB555 inputs +per profile with digests produced by compiling the pinned upstream conversion +function (CGB_E, neutral temperature). The hash starts at zero and processes +RGB bytes in increasing RGB555 order, `hash = (hash * 33 + byte) mod 2^32`. + +`love tools/gbc-color-check` runs GPU checks with synthetic colours: desktop +and GLES shader validation, the entire RGB555 gamut, interpolated colours, +alpha/state preservation, DPI/resize handling and the native renderer path. +It uses a separate save-directory identity and no cartridge data. These +checks do not substitute for a real-ROM visual comparison or mobile hardware +testing. 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/gen2-lifecycle-callbacks.md b/docs/gen2-lifecycle-callbacks.md new file mode 100644 index 000000000..d089bc097 --- /dev/null +++ b/docs/gen2-lifecycle-callbacks.md @@ -0,0 +1,58 @@ +# Native Gen2 lifecycle callbacks + +Gen2 callbacks now observe production state transitions through the ordinary +mod buses. Existing clock, phone, radio, apricorn and fixed-destination log +implementations remain the source of those notifications. + +| Boundary | Public contract | +| --- | --- | +| Day Care compatibility | `(next, {data, mon1, mon2, dayCare})`, byte 0..255 | +| Pending egg creation | `{egg, mother, father, compatibility, stepsToEgg}` | +| Hatch UI completion | `{mon, egg, slot, species, nickname}` | +| Friendship write | `{mon, event, reason, delta, from, to}` | +| Pokérus infection write | `{party, slot, mon, strain, days, source}` | +| Successful roaming encounter | `{index, slot, species, level, mapId}` | +| Actual roaming route change | `{index, slot, species, from, to, reason}` | +| First Unown form capture | `{letter, name, word, count}` | +| Completed contest judging | `{mon, score, place, results}` | +| Catch nickname decision | `(next, mon, {battle, name, game})` | +| Full-party catch disposition | `(next, {battle, mon, name, game})` | +| Trainer replacement style | `(next, battle)`, `shift` or `set` | +| Enemy switch/item decision | `(next, battle)`, canonical decision or claim | +| Return to launcher | `(next)` | +| Gen2 boot stages | `{screen, game}`, with `{skipped, frames}` at movie end | + +The native Day Care text-tier result remains unchanged. A private weak side +table carries an observed probability to the real egg roll; public method +patches and the existing `EGG_ODDS` table keep their unobserved behavior. Hatch +notifications retain an original egg snapshot before native in-place changes. +The existing `pokemon.egg_hatched` emission keeps its position and payload. +Friendship emits on actual changes after clamping. Duplicate Unown captures, +failed encounters and repeated movie completion do not announce new work. + +Pokérus previously existed only as a saved byte. Native GSC battle wins now +perform the party infection/spread transition, including the Goldenrod gate, +the rare de novo roll, cured immunity and the four-day spread stop. Daily +polling seeds its own stamp and advances counters by the saved RTC calendar. +The event runs after the byte is written. This contribution concerns these +party-byte transitions; it does not claim an audit of all Pokérus consumers. + +`force_enable_env` retains a manifest variable name. Only a value of `1` +overrides a saved disable. Native generation targeting and no-mod discovery remain in +effect. Existing `log_url` and `mod:postLog` keep their network permission, +ownership and bounded transport behavior. + +Published sprite packs can be consumed through `src.import.pmd.Sprite` using +native fixed-frame rendering, row-major quads, timing and mutable anchors. +Follower, species and Unown query adapters cover the unchanged follower +consumer. A concrete live player provider takes precedence; other hosts use +a small live-field fallback. No ROM reader +is introduced. Authenticated follower entrypoints, spawn and animation hooks +run unchanged for Red, Gold, Crystal and FireRed over owned synthetic pack data. + +`tools/check_gen2_lifecycle_profile.py` runs native LÖVE and FrameProfile over +192 owned friendship/roamer/contest transitions per frame, comparing the prior +producer files, guarded native code and an ordinary registered observer. +It measures transition CPU work; it does not measure cartridge gameplay, art +correctness or handheld FPS. No performance rollback flag is added for this +compatibility task. ARM handheld performance has not been measured. diff --git a/docs/gen2-registry-contracts.md b/docs/gen2-registry-contracts.md new file mode 100644 index 000000000..435b17f30 --- /dev/null +++ b/docs/gen2-registry-contracts.md @@ -0,0 +1,30 @@ +# Gen2 registry contracts + +The `apricorns`, `decorations`, `landmarks`, `phone_contacts` and +`radio_channels` record descriptors match the pinned `535a5ec` Gen2 contract. +Their native adapters, imported built-ins, merge ownership and rollback already +exist; this change aligns the remaining descriptor bounds without replacing +those implementations. Gen3 decoration and landmark adapters are outside this +Gen2 contract. + +Record validation and native execution have separate constraints. Native phone +indices remain 1..255 with original rows and holes reserved; landmark indices +remain bytes. Native apricorn event operands and decoration ownership events +remain 0..65534; 65535 has a variable-operand meaning in native scripts. +Apricorn ordering indices remain 1..65535. The adapters reject these unsupported +values before recording the contribution, so errors remain attributable and +earlier writes by the same failed owner roll back. Finite-value, reference, +collision and canonical-ID checks remain in place. + +Landmark coordinates retain their existing native OAM units. This change does +not reinterpret previously authored records. Public record fields, merge +semantics and existing valid registrations keep their behavior. + +Run `tools/check_gen2_registry_contract.py --reference +--lua --report /fields.json` to compare the complete Gen2 +field descriptors, including optionality, bounds and referenced registries. +The same owned inspector runs unchanged in both checkouts. Native content +suites separately exercise the real Loader, merge targets and consumers. + +This is load-time validation. It adds no per-frame operation or performance +flag. ARM performance has not been measured. 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/legacy-npc-controls.md b/docs/legacy-npc-controls.md new file mode 100644 index 000000000..6459e74e7 --- /dev/null +++ b/docs/legacy-npc-controls.md @@ -0,0 +1,55 @@ +# API-2 native NPC controls (M6) + +Legacy `mod.world:npc(mapId, indexOrName)` handles now expose `stepNow`, +`canStep`, `placeAt`, `isMoving`, `setPassable` and `setAppearance`. Existing +`ow`, `npc` and `id` fields, scripted movement, facing, position and elevation +remain unchanged. + +The operations share the existing API-3 native actor implementation through +`src/world/NpcControls.lua`. API 3 keeps its original admission, validation, +ownership and cleanup policies. API 2 adds controls to its existing handle and +retains immediate replay semantics: `stepNow(direction)` deliberately skips +collision and the scripted movement queue. Call `canStep(direction)` first +when the map should decide whether that move is legal. + +`placeAt(x, y, facing)` clears the active step, its target and phase, and aligns +native cell/pixel coordinates. Coordinates must be finite nonnegative integers +up to 1,000,000. The optional facing uses `up/down/left/right`. `isMoving()` +reads native motion; `setPassable(value)` uses legacy boolean coercion. +`setAppearance(id)` resolves the native sprite on a private definition copy +before committing it to the actor. A missing sprite or a rendering error leaves +the actor and imported template unchanged. + +New controls decline stale handles after a map/world/save/data/version change, +actor removal or mod retirement. Failed-entry rollback, disable and replacement +of the loader's bus retire the new controls. Previously applied controls remain +native scene changes. Existing legacy handle methods keep their behavior. +Caller permission attribution is restored on both success and failure. + +The extension is available without changing a mod manifest, API level or public +hook. It is an additive compatibility feature; reverting the M6 commit removes +the extension while restoring the original shared implementation. + +## Qualification + +The unchanged API-2 consumer fixture runs through both production loaders +against pinned reference `535a5ec` on Red, Blue, Yellow, Gold, Silver, Crystal, +FireRed and Emerald. Its public movement, passability, placement, motion and +appearance results match in all eight comparisons. The fixture declares those +three generations explicitly and is test-owned. + +The native suite also uses real Map collision and NPC updates, checks queue-free +motion and cancellation of a running step, preserves old fields/methods, checks +peer passability and sprite failure atomicity, and exercises stale ownership. +The existing API-3 NPC and portable movement suites qualify the shared helper. +Sprite construction uses a deterministic test seam; rendered artwork equivalence +and retail gameplay are outside these checks. + +`tools/check_decoded_profiles.py --npc-controls` uses the same guarded LÖVE host +as M5. The first two phases measure 64 identical legacy handle lookups, position +and facing calls per frame before and after M6. A third phase exercises all six +new controls and 1,024 native NPC updates per frame. It reports separate F11 +sections for lookup and new-control workloads, with 120 warm-up and 120 measured +frames per phase. These are owned CPU contract workloads with deterministic +sprite construction. The PR records the measured cost and source fingerprints; +no world-scene or handheld FPS claim is made. diff --git a/docs/low-tier-p9.md b/docs/low-tier-p9.md new file mode 100644 index 000000000..599a127ef --- /dev/null +++ b/docs/low-tier-p9.md @@ -0,0 +1,101 @@ +# LOW presentation budget and UI quads (P9) + +`require("src.core.Performance").lowTierBudget` defaults to `true`. Set it to +`false` to disable P9 independently: the previous AUTO heuristic and presentation +policy return, palette refresh uses full native ticks, and the affected UI paths +use their previous quad behavior. Existing P1–P8 flags remain independent. + +## Additive caps and live behavior + +| Cap | HIGH / BALANCED | LOW | +| --- | --- | --- | +| shaderPresets | true | false | +| colorCorrection | true | false | +| gen3PaletteCycle | full | quarter | +| reflections | true | false | +| discordPresence | true | false | + +`Performance.extraCap(name)` reads these added live caps and returns nil with P9 +disabled. Existing CAPS rows, methods and identities remain. Shader preset entries, +parameters and the selected correction mode stay intact while their actual passes +are masked; raising the tier restores them. Ordinary mod-owned pipelines remain +available. Game's Discord section skips work at LOW; applying LOW retires an +already loaded connection. Presence initialization keeps the same subscriptions, +but skips connection, flush and polling under the cap. Raising the tier resumes +through the existing reconnect path. No real IPC/network operation is used in QA. + +The ordinary native world pass and owned field capture omit actor reflections +under LOW while retaining terrain, actors, ripples and FX. Other tiers restore the +same draw trace. Existing entity records are not replaced with cache records. + +Gen-3 tile sheets sample palette ticks at floor(nativeTick/4)*4 under LOW. The +native tile animation clock keeps advancing at full speed; this reduces visual +refresh frequency without slowing the animation or changing gameplay. A private +weak record table avoids repeated sampling/recoloring during held ticks. Explicit +public palette-reader queries retain their requested ticks. + +## Physical pixel policy + +AUTO still recognizes ARM handhelds, mobile OS and the desktop core-count floor. +With usable `love.graphics.getPixelDimensions` data it also selects LOW for mobile +frames above 1920×1080 pixels and dual-core frames above 1280×720, and BALANCED for +other desktop frames at or above 3840×2160. These are area comparisons, so portrait +and landscape agree. Missing/invalid dimensions preserve the earlier decision. +Explicit HIGH/BALANCED/LOW selections bypass detection. Apple Silicon retains the +existing desktop exemption, including ordinary Retina-sized windows. Detection is +resolved through the existing applyOptions lifecycle, not a per-frame policy loop. + +## Native UI reuse + +`src/render/UiQuad.lua` owns weak, private records by menu/textbox instance and draw +site. Immediate native draws reuse a Quad and update its viewport, including source +extent. Bag pocket/item images, box icons/FRLG crops/party panel and the animated +text prompt use this path. Separate owners/sites do not share scratch Quads. +Existing box-cache fields and rollback paths remain. No hook payload, asset record, +option-row signature or require path changes; `ui.options.rows` stays in place. + +## Reproduction and measurements + +`tests/engine/performance_low_budget.lua` exercises real policy consumers: native +palette-sheet cadence, preset and correction masks with retained selections, the +world producer's draw trace, live restoration, a connection-state double, source +size/viewport changes and owner isolation. It passes 86 checks. Existing tier, +correction and world-hot-path regressions also pass. + +Run `python3 tools/check_low_tier.py --love /absolute/path/to/love --report +.local/qa/low-tier/profile.json` on one shell line. The guarded runner uses an +isolated identity and owned asymmetric artwork. Actual bag, box and textbox draw +producers render four exact full-RGBA comparisons across prompt animation phases; +separate native ownership/allocation checks bring each backend to six scoped +checks. The loop draws 64 bag/item/icon/crop groups plus the prompt per frame, +with 120 warmup and 120 measured frames in each flag mode. + +| Desktop macOS ARM64 | Previous UI draws | Cached UI draws | +| --- | ---: | ---: | +| LÖVE 11.5 OpenGL, FrameProfile CPU submission | 0.635 ms | 0.521 ms | +| LÖVE 12 Metal, FrameProfile CPU submission | 0.291 ms | 0.173 ms | +| New Quads / 120 measured frames | 15,480 | 0 | + +An equal one-pixel GPU completion fence runs outside the CPU section. Presentation, +ROM artwork, ordinary game/menu usage frequency and full-game FPS are outside this +fixture. Mobile/handheld and long-session measurements are unavailable. These UI +numbers do not quantify the savings from disabled presets, correction, reflection +or presence. Source hashes and acceptance are in [low-tier-p9.json](qa/low-tier-p9.json). + +The public stack reuses #89 and #100/#101/#125 for the existing color/shader +features, #259 for native palette programs/caches, plus the desktop ARM policy already offered in #243. P9 adds their LOW +policy rather than republishing those features as a separate implementation. + +Integrated acceptance: 480/480 engine suites, SDK 3/3, gallery 253/253, +200 API-3 comparisons / 400 isolated workers and all 17 unchanged external +fixture entrypoints in complete mode pass. Public candidate: 167/178 engine +suites versus 166/177 on composed baseline 3bd33cb3, with exactly the same +29 FAIL lines across eleven inherited suites. Public SDK 3/3, gallery 253, +API-3 48/96 and all 17 fixture entrypoints pass; its new 86-check suite passes. + +The public Metal branch separately passes the same six native checks. Its UI CPU +measurement is 0.386 → 0.222 ms during concurrent acceptance work; its own source +hashes are retained separately. Its TextBox lacks the fork's unrelated Gen-2 prose +substitution repair, and its world has no owned field-capture API; P9 does not add +that separate API. Existing correction/preset merge order, private native viewport +and window-UI guard, sheet pinning and profiler sections are retained in the stack. 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..26a71f6b4 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1,5 +1,29 @@ # Native modding +## 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 +42,103 @@ Regenerate the reference straight into a wiki checkout: luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Shared API 3 entry + +The [API-3 entry and cross-engine contracts](api3-portability.md) describe +using the same manifest and mod source on Gen1Recomp and Gen2Recomped. +API 3 selects the unified adapters automatically; API 1/2 keep their defaults. +See [API-3 world warps](api3-warp.md) for validated map transitions and callback lifetime, +[API-3 object/block edits](api3-world-edits.md) for native visibility and terrain changes, +and [API-3 NPC actions](api3-npc.md) for runtime actors and movement handles. + +## 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 1–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). + +## Trainer battle preparation + +The [portable trainer preparation contract](portable-trainer-preparation.md) +documents `trainer.before_battle(next, game, context, continueBattle)`, deferred +native admission, cancellation and battle-local player-party selection. + +## NPC conversation hook + +The [portable NPC talk contract](portable-world-talk.md) documents +`world.talk(next, owner, target)`: claim a native NPC conversation or forward +to the engine, with explicit handle and Gen1 Gold producer boundaries. + +## Battle status HUD visibility + +The [portable battle status HUD contract](portable-status-hud.md) documents +`battle.status_hud_visible(next, state)` for GB/GBA status panels and DS +healthboxes, independently of battle text/menu drawing and native logic. + +## Battle bottom UI visibility + +The [portable battle bottom UI contract](portable-battle-bottom-ui.md) documents +`battle.bottom_ui_visible(next, state)` for battle text/menu drawing and +inherited text/YES-NO overlays, without changing native input/update ownership. + +## 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 +191,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,8 +216,17 @@ 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 +Player-selectable local effects are described in [Optional shader presets](shader-presets.md). + Most registries hand the engine *content*. `render_pipelines` hands it *drawing*: a pipeline is a display mode a mod owns, which may replace the overworld's world pass with geometry of its own and/or post-process the @@ -152,6 +290,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 +341,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 +376,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 +420,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 +451,16 @@ 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. + +See [portable follower predicate](portable-follower.md) for the native +`world.follower.spawn` lifecycle hook and its generation/artwork boundaries. diff --git a/docs/native-gen2-apricorn-selection.md b/docs/native-gen2-apricorn-selection.md new file mode 100644 index 000000000..58d3b969e --- /dev/null +++ b/docs/native-gen2-apricorn-selection.md @@ -0,0 +1,93 @@ +# Native ApricornBalls and Kurt selection + +The GSC importer reads the actual `ApricornBalls` pairs in cartridge order, +retaining native apricorn/ball item IDs and row indices. A single `$ff` ends +the table. Missing symbols leave no invented recipe data; explicit empty or +malformed/truncated tables never fall back to retail rows. Reads are bounded +and cannot cross a cartridge bank boundary. The map-script output includes +recipe records and completeness metadata. Stock Gold/Silver/Crystal cache +markers require reimport; other edition markers are unchanged. + +Kurt's actual native special now consumes the imported order, including +cartridge-specific table changes. Historical stock caches retain the original +native item order85/89/92/93/97/99/101 until reimport, without fabricating their +missing ball mappings. The earlier colour-based fallback reordering was +incorrect and is corrected here; the ROM-table import itself was unaffected. + +Gold/Silver take one apricorn without opening a quantity menu. Crystal asks +for quantity, including a one-item stack; cancelling quantity returns to the +apricorn list with its cursor retained and consumes nothing. An explicit +CANCEL row and bounded scrolling retain access to the complete list. The +quantity is capped at99 and the native inventory is rechecked before removal, +so a changed bag cannot mint a paid job. Unreviewed hack layouts retain their +previous quantity-cancel protocol and are not given a guessed table import. + +The native script variable still receives the actual selected item byte. +The existing `g2KurtApricorns` field holds its confirmed quantity. Native +script branches, event/working flags, daily resets and ball choice remain +script-owned. This does not add a competing Kurt job model or rewrite a ROM +script's ball operand merely because its selection table contains a pair. + +Validation:216 synthetic checks use actual importer/map-script serialization, +Gen2ScriptVM, ScriptRunner, StateStack, Menu input and Bag operations. They +cover order/bounds, malformed data, cache readiness, Gold/Silver single-item +handover, Crystal confirmation/reselection/cancellation/scrolling and a real +`verbosegiveitemvar` collection path: a full ball stack preserves the event, +successful delivery grants the confirmed quantity and event clearing prevents +duplicate collection. Only text/audio IO is substituted. A private read-only +Crystal Rev0 table probe finds seven complete rows, agrees with an independent +byte decoder and leaves the original ROM unchanged. It is not a full import, +GPU, original-story or Gold/Silver actual-ROM acceptance claim. + +The `apricorns` registry now projects authored recipe records into the actual +decoded native Kurt programs. The importer queues four named roots and their +reachable scripts; incomplete or home-bank bindings are unavailable rather +than guessed. An additional stock-GSC cache revision requires that import. +The adapter derives each base recipe's event from its native selection and +collection branches, rather than assuming an event/colour order. For example: + +```lua +mod.content.apricorns:patch("RED_APRICORN", {ball="ULTRA_BALL"}) +``` + +All fields are `apricorn`, `ball`, `event` and `index`, as in the reference +registry. Items must resolve to real indexed GSC item bytes. Additional recipes +can use previously indexed native items and new word-sized events, excluding +the `$ffff` indirect-event sentinel. The selector still returns the real item +byte; it is never replaced with a fabricated seven-colour ordinal. Actual +native membership and event-dispatch branches, including overlapping roots, +consume the resulting table. Working/day flags, quantity, dialogue and +full-bag refusal stay in native scripts. Removed/rekeyed recipes stop new +jobs while unclaimed original pending events retain their original output. + +The observational `apricorn.converted` event is emitted only after a native +gift succeeds and its real event is cleared. API1/2 callbacks retain native +item IDs. The fork separately tests its API3 semantic callback boundary. It cannot veto or redirect +the gift. Recipe record views use semantic IDs through upstream API1/2 native content. The fork also tests its API2/3 +unified content modes; those additional APIs are not imported by this PR. Later item registrations resolve before +projection; invalid owners and affected consumers roll back before any native +script mutation. Unload restores unchanged owned rows/scripts, preserving +external edits. A new script table invalidates the VM's compiled-root cache. + +Synthetic integration checks cover actual Loader, VM, Menu, daily resets, +bag operations, observers, invalid/forward references, removal, unload and +generation isolation. A private read-only Crystal Rev0 probe also decodes +31 reachable native programs (264 instructions, zero desyncs), registers an +eighth recipe and executes the native selection, map reentry, working branch, +daily reset, full-bag refusal, three-ball delivery and exactly-once completion. +Only external text/audio IO is substituted; the original ROM is unchanged. +These checks do not establish full GPU/story or Gold/Silver actual-ROM +acceptance. The legacy Apricorns facade, full native daily/tree lifecycle and +remaining Gen1 feature parity remain open. + +Behavior was checked against the read-only Gen1 reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91` and primary disassembly sources: +[Gold selection/table](https://github.com/pret/pokegold/blob/master/engine/menus/menu_2.asm), +[Gold handover](https://github.com/pret/pokegold/blob/master/engine/events/specials.asm), +[Crystal selection/quantity](https://github.com/pret/pokecrystal/blob/master/engine/events/kurt.asm), +[Crystal recipe table](https://github.com/pret/pokecrystal/blob/master/data/items/apricorn_balls.asm). +These links identify behavior/provenance; no ROM or extracted table bytes are +included in the contribution. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-clock.md b/docs/native-gen2-clock.md new file mode 100644 index 000000000..27fa48ccb --- /dev/null +++ b/docs/native-gen2-clock.md @@ -0,0 +1,63 @@ +# Shared native Gen2 RTC + +The Pokégear previously displayed elapsed play time while world palettes, +object time masks and script variables used the host clock. The native +`Gen2Clock` now provides the same saved RTC-base reads to these consumers, +the native happiness evolution gate and observational encounter summaries. +The existing `src.core.gen2.Clock` name resolves to this native module. +`world.hour()`, `world.minute()` and `world.weekday()` read that same save. + +`setTime(save, hour, minute)` anchors the chosen minute, current game +weekday and calendar date to elapsed RTC seconds. Hours, weekdays and daily +calendar identity cross the same in-game midnight, including after a saved +session has been closed for days. Automatic host DST and time-zone changes do +not add another hour to a clock the player already set. Explicit system clock +adjustments still affect the host RTC; this is not a monotonic stopwatch. + +The legacy `rtc.startMinute`, `rtc.startDay` and `g2DayOffset` projections are +retained for older readers. New writes also store `anchorTime`, `anchorMinute`, +`anchorDay` and `anchorDate` in the same `save.rtc` record. Consumers should use +the native setters/readers instead of mutating these projections directly. +`setWeekday` edits the current game weekday without changing elapsed calendar +age. Editing time reanchors the clock without resetting its current date. + +Old saves without the elapsed anchor retain their original host-hour and +weekday-offset semantics, including their old midnight relationship, until +the clock is explicitly set. Read calls never create or migrate clock data. +Invalid/non-finite writes are rejected. The normal native save codec retains +both formats; elapsed play time is separate. + +`calendarDate(save, now)` and `dateKey(save, now)` give native daily and weekly +services the calendar belonging to that RTC. Daily flags/fruit use and the +lottery now expire on game midnight and the selected game Friday instead of +an unrelated host-calendar boundary. The optional timestamp on clock reads +uses the same snapshot for minute, weekday and calendar queries. + +World palettes, object-load period bits and `VAR_HOUR` share the saved hour; +the normal object-load freeze still applies. An explicit palette-query hour +retains precedence. The existing `world.tod` hook remains a presentation +override and does not rewrite the saved RTC. A raw scene-clock query uses +the current native save, independently of a room's forced palette. + +`clock.day_changed` is produced by actual weekday reads, including the live +world clock poll. It reports `day`, `previous` and `reason` (`rollover` or +`set`). A save's first observation is silent. Observations are per save, +reentrant-safe, subscription-aware and retired on mod/edition reload. An +observed offline whole week still produces one change, even when the weekday +number is the same; its calendar date is different. +Native API 1/2 and unified API 2/3 use the normal event dispatch boundaries; +other generations do not advertise this native GSC seam. + +The clock card, native script readers, evolution time gate and world methods +are exercised directly in synthetic engine tests, including minute wrap, +weekly rollover, save roundtrip, RTC-versus-play-time display, both callback +paths and actual mod loading. Graphics IO alone is replaced for the clock +card. These checks do not establish a complete original-story clock-setting +sequence, manual DST-setting/check dialogs, battery-backed SRAM RTC +conversion, all daily services or fresh GPU acceptance for this change. +Automatic host DST isolation is covered; the original story clock-setting +screen and remaining manual DST lifecycle still need native implementation. + +The public contract is compared with the read-only Gen1Recomp reference; +the implementation is native fork code. No VASC implementation, private +assets, game text or ROM-derived clock data is included. diff --git a/docs/native-gen2-decorations.md b/docs/native-gen2-decorations.md new file mode 100644 index 000000000..702920fc3 --- /dev/null +++ b/docs/native-gen2-decorations.md @@ -0,0 +1,127 @@ +# Native GSC bedroom decorations + +The engine imports decoration attributes, display names, default placed bytes, +gift ordinals and room-description entry points from the user's current +Gold/Silver/Crystal cartridge. No retail attribute catalogue, dialogue or room +art is bundled. The virtual `decorations` registry writes the same DECO-byte +records that native room callbacks and the player's bedroom PC consume. + +```lua +local decorations = mod.content.decorations +local available, reason = decorations:supports() +if available then + decorations:patch("deco:2", { name = "CUSTOM" }) +end +``` + +Native API 1/2 use `deco:0` through `deco:255`. The number is +the cartridge's DECO byte, not a sorted list index. Records contain `type` +(1..6), `name`, optional `action`, `flag` (native event word) and `sprite` +(native object-sprite or map-block byte). Only the fourteen known +`SET_UP_`/`PUT_AWAY_` actions are accepted. Cancel and put-away rows do not grant +ownership. API views are detached; invalid/failed mod entries roll back and +reload retires owned writes individually while retaining external edits. +Removing a record removes both numeric and string aliases of its byte. + +The importer derives the attribute count from the adjacent native names-table +boundary and reads six bytes per row. Invalid/truncated attributes or names +publish no partial table. Species-backed names keep their native species ID, +so later species renames remain visible unless the decoration itself is +renamed. Native initialization is recognized only when the actual instruction +operands store valid bed/poster IDs to the corresponding WRAM symbols. Missing +initialization does not invent retail defaults. Existing placed state, +including an explicitly empty table, is retained. + +The house-PC option appears only with an imported native table. Owned records +form the native categories; their put-away headers and cancel choice follow +the records, with eight visible rows and the native right/left/cancel order +for ornaments. Feedback and side prompts enter the normal ROM text importer +and `TextBox` path. A replacement uses buffers three/four for old/new names. +State changes commit after feedback closes. Moving an ornament clears an +opposite duplicate; selecting the same ornament does not mutate the opposite +side. Ordinary item-storage PCs do not gain this option. + +`PlayersHousePC` waits until LOG OFF/B and returns the accumulated change bit. +The exact GSC `warp NONE` pair reloads the current map after the script ends, +without relocating the player to cell zero. Unknown map pairs are not reloads. +The existing native callback queue then applies placed object visibility, +variable sprites and room blocks. Description bytecode branches into the +actual imported poster/ornament/big-doll scripts rather than being skipped. +Block zero is valid for a nonzero DECO byte; carpet offsets wrap as native +bytes. An empty slot writes no block, leaving the newly loaded bare room. + +Raw SRAM conversion reads/writes the eight placed bytes using the selected +edition's WRAM-to-SRAM layout. The physical order ends with the big doll and +is distinct from the public slot list. Absent legacy decoration state leaves +the imported template intact; invalid placed bytes fail instead of wrapping. +Unreviewed hack layouts receive neither guessed retail tables nor guessed +save offsets. + +## Legacy module contract + +`require("src.core.gen2.Decorations")` resolves to a signature adapter, not +directly to `src.world.Gen2Decorations`. It exposes `state(save)`, +`attributes(id)`, `name(id, monName)`, `idFor(id)`, `idForFlag(ordinal)`, +`registerInto(registry, data, owner)`, ownership/gift methods, +`ownedCategories(events)`, `rows(events, category)`, `apply(state, id, side, +monName)`, `clearOtherSide`, `visibility`, `tiles` and `posterVisible`. +The action/slot/description constants retain the legacy shapes. `ATTRIBUTES`, +`CATEGORIES` and `NOTHING_TO_CHOOSE` are read projections of the current +native dataset. Attribute rows remain the actual native records; registering +them into another registry passes copies. + +Data initialization and Loader entry bind the current native dataset. Reads +follow subsequent registry folding and rollback. Teardown or an edition +change retires the binding. Before a complete import, the facade returns no +catalogue/defaults instead of fabricating retail content. Removed records do +not fall back to a second catalogue. Both numeric bytes and numeric-string +bytes resolve and placements store numbers. + +Ownership accepts the legacy numeric `events:get/set` protocol or a native +save directly. `events(save)` supplies a numeric view backed by the same +canonical flags used by scripts and SRAM, with no parallel bitfield. That +view expires on rebinding/unload. Cancel/header rows cannot grant event zero. +Categories are rebuilt from native action identities; added rows participate +and removed headers are not invented. Existing empty placement is preserved. + +`apply` follows the legacy two-result contract `(changed, pages)` and does +not check ownership; the menu owns that check. Ornament side cancellation +does nothing, and `clearOtherSide` remains a separate operation for these +callers. The pages use the actual imported feedback text and buffers 3/4, +including authored replacements and explicit script-text removal. The native +PC and facade share placement planning and tile/object layout routines; the +PC still commits after feedback closes. A valid block zero and wrapped +carpet offsets retain native behavior rather than the old facade's filter. +This adapter does not turn a detached external event bitfield into the live +world, nor does wrapping a facade method patch every native module method. + +## Evidence and remaining scope + +ROM-free tests exercise actual importers, Loader, native script compiler and +runner, menus, TextBox, StateStack, ownership flags, callback commands and save +codec. The eight save offsets are independently checked against each shipped +GSC symbol manifest. Test cartridges and dialogue are authored synthetic data. +The callback/warp tests use controlled overworld presentation/queue boundaries; +they are not GPU-render or complete-game acceptance tests. + +A separate private read-only Crystal probe independently compares all 53 +attribute rows' numeric bytes, imports 45 gift ordinals and the native defaults, +executes the original house-PC/description scripts and room callbacks, and +round-trips all eight placed bytes. Original ROM hashes are checked unchanged. +The local font/audio/render harness is used; no visual acceptance is inferred. + +The public-module tests load a synthetic mod through the actual Loader, +change and remove records, grant ownership, place decorations and execute +the native room callback VM. They verify feedback pages, byte identity, +retirement on reload/edition changes, and rollback without a second catalogue. + +Mom’s native shopping caller is covered separately in [native-gen2-mom-shopping.md](native-gen2-mom-shopping.md). +The remaining gift lifecycle, legacy MomShopping/phone contracts, +new graphical acceptance and a full unchanged VASC playthrough remain separate +open work. Importing the gift-ordinal table alone does not implement those +callers. The existing preview is not refreshed by this source change. + +Contract references: [Crystal decoration routines](https://github.com/pret/pokecrystal/blob/master/engine/overworld/decorations.asm), +[Gold bedroom script](https://github.com/pret/pokegold/blob/master/maps/PlayersHouse2F.asm). +Original fork additions: [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/docs/native-gen2-landmarks.md b/docs/native-gen2-landmarks.md new file mode 100644 index 000000000..66898dff0 --- /dev/null +++ b/docs/native-gen2-landmarks.md @@ -0,0 +1,70 @@ +# Native GSC landmark records + +`mod.content.landmarks` projects semantic records into the native numeric +`field.townMap.landmarks` table. Pokegear, TownMap and the native +`getcurlandmarkname` / `getlandmarkname` script commands read that same table. +It does not create a competing map or name service. + +```lua +mod.content.landmarks:patch("LANDMARK_ROUTE_29", {name="CUSTOM ROAD", x=80}) +mod.content.landmarks:register("LANDMARK_EXTRA", { + name="CUSTOM COVE", x=100, y=104, index=200, +}) +``` + +The shape matches the reference: optional `id`, required `name`, `x`, `y` +and `index`. The native map-header index is a byte, including zero. +Coordinates retain the cartridge's hardware OAM units. They are not tile +cells: the existing TownMap marker-centre and Pokegear sprite-origin +corrections still apply once at drawing time. Changing a display name does +not rename the landmark identity. + +The import records constant identities and the actual native row indices. +Gold/Silver have95 rows; Crystal has96, including byte95 for the ship. The +previous fixed95-row reader omitted Crystal's last row. Crystal's Battle +Tower also shifts the first Kanto byte from46 to47. Pokegear no longer uses +Crystal's literal boundary for Gold/Silver; both map readers consume +the authored native Pallet landmark index, retaining imported-map-header +fallbacks for older caches. An explicit manifest identity order overrides +the stock order. Unreviewed hack layouts do not acquire guessed stock names. +Stock caches require the `gen2-landmark-identities-v1` revision and reimport. + +Valid native names survive a failed/absent map-picture decode without +inventing map artwork. Missing coordinate data is not replaced with guessed +positions. A new indexed authored name can also serve native scripts in a +dataset with no town-map art. + +Upstream API1/2 native content uses detached record views. The fork separately +tests API2/3 unified content and its radio helper; those layers are not +imported by this standalone PR. +Failed entries roll back before native projection; content freezes after +loading. Numeric/string native aliases cannot resurrect a removed slot. +Index collisions follow the reference's deterministic rules: the native +identity wins its original slot, otherwise the lexically earlier ID wins. +The reference order remains a fallback for unclaimed original slots after +moving a record. Native map-header bytes are not implicitly rewritten. +Unload restores unchanged owned slots and removes exclusively created +namespaces, preserving external edits. Other generations report this native +target unavailable instead of mutating a similar-looking table. + +Validation exercises the real mod loader, ROM reader/text decoder, native VM, +TownMap controller and Pokegear draw commands. Image loading/drawing uses an +authored IO fixture; the independent existing town-map pixel decoder suite +still runs. A private read-only Crystal Rev0 probe reads96 actual records, +compares every raw coordinate pair with an independent byte reader and +executes the ship's authored name through the real VM. The original ROM is +unchanged. This is not fresh GPU, full original-story, Windows hardware, +actual Gold/Silver ROM or complete Gen1 parity acceptance. + +Reference behavior: read-only Gen1 source +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, `Schemas.landmarks` and +`core/gen2/Nests.lua`. Constant identities and order were checked against +[pokegold landmark constants](https://github.com/pret/pokegold/blob/master/constants/landmark_constants.asm) +and [pokecrystal landmark constants](https://github.com/pret/pokecrystal/blob/master/constants/landmark_constants.asm). +Only compatibility identifiers/order and original adapter logic are included; +no cartridge coordinates, dialogue, graphics or private mod content is shipped. +Original contributions by Roxas2712; retain existing engine/third-party rights +and credits. The [specific permission for Cedric / UNDERdecoded, with visible +credit](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md) +applies to offered contributions; it does not license cartridge data or +other authors' work. diff --git a/docs/native-gen2-mom-shopping.md b/docs/native-gen2-mom-shopping.md new file mode 100644 index 000000000..9a3277427 --- /dev/null +++ b/docs/native-gen2-mom-shopping.md @@ -0,0 +1,87 @@ +# Native GSC Mom accounts and purchases + +The existing native banking screen changed a separate saving boolean, while +trainer rewards went entirely to the wallet. The cartridge's shopping tables +and notification scripts were not imported. This connects the banking choice, +native flags, trainer reward accounts, ROM purchase rows, original phone +programs, decoration ownership and raw-save state. + +## Cartridge data and account state + +`map_scripts.momShopping` contains the user's cartridge's random and ladder +lists. Each row is eight bytes: big-endian three-byte trigger and price, then +kind and item/DECO bytes. End symbols determine the count. The random threshold +increment is read from the native instruction operands that populate +`hMoneyTemp`; no retail catalogue, purchase dialogue or guessed hack layout is +bundled. Both original item/doll phone programs enter the shared script/text +importer. Invalid or incomplete bindings publish no partial lists and do not +partially enqueue programs. The new GSC cache marker requires reimport. + +The native account fields are `g2MomMoney`, `g2MomSavingMode` and +`g2MomBankOpen`, alongside the existing boolean `g2MomSaving`. Saving bit zero +and the active bit agree with the same engine flags original scripts read. +The bank UI uses those shared fields; an imported active account is recognized +instead of silently opening a new account. Explicit start/stop choices select +SOME/none. Imported HALF/ALL settings retain their native low-bit meanings. + +Trainer rewards deal four quarters between the accounts. SOME sends one, +HALF two and ALL four to Mom; an already maxed Mom account sends everything to +the wallet. Each account saturates independently, so near-cap overflow is not +redirected. Held-item reward modification happens before this split. Original +reward-text entry points select the current mode and receive the real player +name and full prize total. Other generations and unreviewed hacks retain their +existing reward paths, including Prism's separate bank. + +## Purchase and phone lifecycle + +`g2MomShopping` retains the native ladder index, selected random-row byte and +three-byte trigger balance. The current ladder row has priority whenever its +trigger is met. Otherwise the threshold advances to/past the balance and only +an exact landing selects a random row; that threshold is consumed even when a +full PC refuses delivery. A random purchase does not advance the ladder. + +Items resolve by the current dataset's native index and enter the existing PC +store with the native stack/capacity rule. Dolls set the actual imported DECO +ownership event; they do not place themselves in the room. Successful delivery +precedes deduction and ladder advancement. No reception or an occupied native +reentry queue prevents a purchase before selection state changes. Missing +original phone programs do not grant an unannounced purchase. + +The common native trainer-return path attempts the purchase once. Its original +phone program waits behind the trainer's queued post-battle script and UI +overlays, then enters the existing native FIFO with Mom's current contact ID. +Caller state, ring/hang-up audio, and receive-delay reset use the existing native call path. +Where the host exposes an incoming-call notification seam, it is also used. Repeated battle +callbacks cannot duplicate the purchase, and a pending call from another +save/dataset cannot execute in the current session. Link and Battle Tower +returns do not trigger shopping. + +Raw SRAM conversion uses each GSC edition's actual WRAM-derived account, +mode, ladder, row-selection and threshold offsets. The account/threshold use +big-endian triples; surrounding mode bits and absent legacy template values +are retained. Unsupported layouts receive no guessed new offsets. + +## Verification and limits + +ROM-free tests exercise actual table/instruction readers, banking VM/UI, +trainer battle win, native world return, script FIFO, flags, capacity refusal, +threshold selection and SRAM conversion. Save addresses are independently +compared with the shipped GSC manifests. Synthetic cartridges, texts and +records are authored fixtures rather than retail data. + +A private read-only Crystal probe independently compares every numeric byte +of both shopping lists, executes all ten original ladder phone notifications, +checks actual item and DECO ownership delivery, resolves native reward slots +and round-trips the account/progress fields. It uses controlled dataset and +font/render/audio IO, not a GPU acceptance or full natural-story playthrough. +Original ROM hashes are checked unchanged. No ROM/private mod/save data is +included in the contribution. + +The legacy `src.core.gen2.MomShopping` facade, full legacy phone parity, +new GPU acceptance and a complete unchanged VASC playthrough remain open. +This source change does not refresh public preview assets. + +Contract references: [native purchase routine](https://github.com/pret/pokecrystal/blob/master/engine/events/mom_phone.asm), +[native trainer reward split](https://github.com/pret/pokecrystal/blob/master/engine/battle/core.asm). +Original additions use the [Roxas2712 contribution permission](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md); +existing upstream and third-party rights remain unchanged. diff --git a/docs/native-gen2-phone-book.md b/docs/native-gen2-phone-book.md new file mode 100644 index 000000000..e5d1868ca --- /dev/null +++ b/docs/native-gen2-phone-book.md @@ -0,0 +1,35 @@ +# Native ordered phone book + +The native script verbs and Pokegear now share ten ordered phone slots. +The existing `save.g2Phone` registration set stays available; the additive +`g2PhoneSlots` array retains insertion order and script-deletion holes. +Older sets receive deterministic initial order. External set edits are +adopted without resurrecting deletions or renumbering contact IDs. Reads do +not discard overflow entries from historical oversized sets. + +Missing permanent numbers reserve slots, so eight ordinary contacts cannot +lock out Mom and Elm. Their imported non-trainer identities are authoritative; +explicit metadata and relocated rows take precedence over legacy retail +positions. The number being added never reserves against itself. Duplicate, +zero, unknown imported and full-book additions are refused. Native +checkcellnum retains the cartridge's empty-byte membership behavior. + +Script deletion leaves a hole. Pokegear DELETE compacts the actual slots, +while the incoming selector walks registration order. The native card shows +ten slots, uses explicit blank rows instead of invented Mom/Elm entries, +refuses calls on blanks and scrolls slots nine and ten into view. + +Validation:277 focused synthetic checks use actual Gen2ScriptVM/ScriptRunner +add/check/delete/ask opcodes, Pokegear input and drawing with only prompt, +audio and graphics IO substituted. Gold/Silver/Crystal exercise reservations, +ordering, external edits, slot deletion, empty rows, native ask result codes, +relocated permanent identities and visible slot-ten dispatch. This establishes +model/VM/UI behavior, not a hardware phone call or original story acceptance. +The existing content-bridge tests also check ten-slot native consumption. + +The display hook is covered separately in [native-gen2-phone-list-hook.md](native-gen2-phone-list-hook.md). +The public legacy Phone facade, full special call/rematch contracts, new authored phone scripts/forward references, and +fresh-import/GPU/story acceptance remain open. Full Gen1 parity is unproven. + +Original implementation by Roxas2712; existing upstream/third-party rights and +credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-phone-call-selection.md b/docs/native-gen2-phone-call-selection.md new file mode 100644 index 000000000..a380ac381 --- /dev/null +++ b/docs/native-gen2-phone-call-selection.md @@ -0,0 +1,23 @@ +# Native incoming caller selection + +The native incoming-call path now consumes one unsigned random byte for +ChooseRandomCaller, swaps its nibbles, masks to five bits and reduces it by +the eligible caller count. Previously it drew a uniform Lua list index, +which changed the native byte-to-contact mapping. Candidates retain saved +phone-slot order, independently of the display hook. + +The timer expiry remains consumed before the coin flip; service and empty +book gates consume no caller-choice byte. A successful choice still compiles +and executes the selected contact's imported receive program and resets the +receive delay. The entrance gate leaves both the timer and RNG untouched. + +The algorithm and gate order were compared with the read-only reference +`535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/core/gen2/Phone.lua` (ChooseRandomCaller and CheckPhoneCall). +Synthetic tests exhaust all256 byte values for each book size1..10 against +an independent bitwise oracle and exercise actual native call selection, +Gen2ScriptVM and ScriptRunner in Gold/Silver/Crystal. This does not establish +complete radio/phone parity, a hardware call or original-story acceptance. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-phone-content.md b/docs/native-gen2-phone-content.md new file mode 100644 index 000000000..01ff19004 --- /dev/null +++ b/docs/native-gen2-phone-content.md @@ -0,0 +1,32 @@ +# Semantic contacts in the native Gen2 phone book + +`phone_contacts` presents imported numeric records through explicit phone and +trainer identity metadata. Changes reach `map_scripts.phone`, the actual +Pokegear, native incoming-call selector and Gen2ScriptVM phone scripts. The +non-trainer `number` is kept separate from native map group/number fields. +Names remain imported or authored text; localized trainer names never select +trainer identities. Ambiguous map spellings are not guessed. + +New contacts append after the full imported row bound, including reserved +holes. Existing indices cannot move; removed records retain reservations +within the load. Explicit indices are recommended when changing enabled mod +sets. Saved numeric slots are never rewritten. Reads and iterator rows are +detached; finite/cycle, identity, index, mask and native-script validation +happens before journaling the operation. Failed entries roll back, reload +retires unchanged owned rows, and external native edits survive. Other +generations report an unavailable target and perform no native writes. + +Caller/callee names and `callerKey`/`calleeKey` currently address existing +imported native script keys. Native VM execution remains authoritative. +Authored new scripts, forward references, the complete legacy Phone facade, +rematch-event semantics and actual story/hardware acceptance remain open. +This is a native content bridge, not a complete phone parity claim. + +The focused synthetic suite runs the actual Loader, Pokegear saved book, +incoming selector, Gen2ScriptVM and ScriptRunner across Gold/Silver/Crystal, +API1/2 native. It also checks invalid-entry rollback, +index ownership, detached reads, reload, external changes and target isolation. +No GPU or complete playthrough claim follows from these tests. + +Original implementation by Roxas2712; existing upstream and third-party rights +and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-phone-identities.md b/docs/native-gen2-phone-identities.md new file mode 100644 index 000000000..d0e3f3ce5 --- /dev/null +++ b/docs/native-gen2-phone-identities.md @@ -0,0 +1,43 @@ +# Native Gen2 phone identities and Buena import + +The importer used a Gold-sized four-name list for all cartridges. Actual +Crystal contact37 uses non-trainer identity5, so Buena and both of her phone +script pointers were discarded. Read the name range referenced by the actual +native contact rows instead. Native outgoing/incoming pointers and masks stay +in their original directions. Empty and reserved rows remain uncallable. + +Each non-trainer row now preserves `contactNumber` independently of `number`, +which remains the native **map number**. `map_scripts.phoneInfo` carries the +full reserved table size, actual rows read and imported non-trainer names. +Even a completely empty book retains its reserved bound. + +Native constants also retain explicit phone-contact and trainer class/member +orders. These are constant identifiers from reference535a5ec0, not localized +display-name guesses or ROM dialogue. Existing authored identity metadata is +authoritative; explicit manifest metadata supports alternative cartridges. +A partial authored identity set does not silently inherit unrelated retail +roster rows. Retail fallback is limited to Gold/Silver/Crystal. Legacy +`PHONE_OAK` is retained as a compatibility spelling for slot2; the actual +imported display name remains authoritative. + +The `gen2-phone-identities-v1` marker rebuilds only stock G/S/C imports once. +Existing saves are not modified by this change. Other edition cache markers +are unchanged. + +Validation:138 synthetic checks exercise both native directions through +Gen2ScriptVM/ScriptRunner, Buena's actual name consumer, reserved/empty rows, +separate map/contact numbers, copied metadata, authored precedence, generation +isolation and the real cache-readiness gate. A read-only actual Crystal table +probe changes32 to33 callable rows (28 trainer /5 non-trainer),38 reserved +rows and62 to64 queued phone scripts; all trainer members resolve through +explicit metadata. The original ROM is unchanged. This is table extraction, +not full-import, GPU, story-call or complete phone-facade acceptance. + +The semantic `phone_contacts` registry writer, stable added-contact allocation, +rollback/reload and the remaining legacy Phone facade still need implementation. +These fields are prerequisites for connecting those APIs to native consumers; +the native numeric saved phone book remains authoritative. + +Original implementation by Roxas2712. Existing upstream and third-party +licenses/credits remain applicable; see the specific contribution permission and credits: +. diff --git a/docs/native-gen2-phone-list-hook.md b/docs/native-gen2-phone-list-hook.md new file mode 100644 index 000000000..da8fbf286 --- /dev/null +++ b/docs/native-gen2-phone-list-hook.md @@ -0,0 +1,33 @@ +# Native phone list display hook + +Gold, Silver and Crystal now dispatch `phone.contact_list(save, list)` when +the native Pokegear reads its contacts. The signature follows the read-only +Gen1 reference at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`, +`src/core/gen2/Phone.lua`: the save remains an opaque native handle and the +list is a detached array of ten numeric contact IDs, with zero for blanks. +Native API 1/2 share this signature. Other generations do not dispatch this +native Pokegear hook. + +Mods can reorder or hide display rows and show contacts registered through +`phone_contacts`. A return value of the wrong type or length retains the +native display. Invalid, fractional, non-finite and unknown contact IDs +become blanks without shifting slots. Mutating the display list does not +register contacts, reorder the saved phone book or filter incoming callers. + +Each displayed contact retains its actual physical slot. The native DELETE +action therefore removes the selected contact even after display reordering; +an injected non-member cannot delete an unrelated saved slot. Unsubscribing, +failed-entry rollback and loader teardown restore the native presentation. + +Validation:focused checks use the actual Loader, content registry, +Pokegear contacts/input and incoming selector across all three editions and +two native API levels. They cover invalid returns, detached arrays, +saved-book isolation, reordered deletion, injected non-members, rollback and +other-generation dispatch isolation. This is synthetic behavioral +acceptance; no new hardware phone-call or original-story claim is made. + +The complete legacy Phone facade, special-call/rematch contracts, authored +phone scripts/forward references and full story acceptance remain open. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-phone-received-event.md b/docs/native-gen2-phone-received-event.md new file mode 100644 index 000000000..e05e5cd82 --- /dev/null +++ b/docs/native-gen2-phone-received-event.md @@ -0,0 +1,39 @@ +# Native incoming phone notification + +Gold, Silver and Crystal now emit `phone.call_received` after the actual +overworld controller queues an admitted incoming call. Both random calls and +queued special calls use this boundary. Rejected candidates, outgoing calls +and other generations do not emit it. The callback is an observation, not a +veto; returning false or editing its descriptor does not redirect the queued +receive program or change the saved caller. + +The notification follows the read-only reference's PhoneRing.script boundary +at `535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91`: +`call`, `contact`, `name`, `className`, `special` and `scriptKey`. +Names come from the actual native contact/trainer roster or authored contact +name. Renaming a trainer contact preserves its actual trainer-class label. The detached call +descriptor has incoming direction, native numeric contact ID and actual +script key; canonical trainer class/member metadata is included when known. +This engine retains native extracted script keys rather than inventing +unavailable symbol labels. Special notifications use the queued wrapper key, +which can differ from the contact's ordinary receive script. + +The actual native controller dispatches this event only for Gen2. Native +API1/2 share delivery. Unsubscribe, entry rollback and teardown use the +existing event-owner journal. + +Focused synthetic checks run actual controller admission and queueScript, the real +incoming selector, Gen2ScriptVM and ScriptRunner in all three editions and +two native API levels. Only audio IO, RTC-facing time and RNG are +controlled. The controller's process-local Game binding is installed and +restored by the test; protected player instances are never used. Tests cover +random/special descriptors, actual queued program execution/completion, +observer mutations and false returns, rejected candidates, outgoing calls, +unsubscribe, rollback and other-generation isolation. + +This does not reproduce the complete PhoneRing dialogue/choreography, add the +legacy Phone facade, establish Mom shopping-call production, or prove full +story/hardware acceptance. Those remain separate work. + +Original contributions by Roxas2712; existing reference, upstream and +third-party rights and credits remain applicable. See the [specific PR contribution permission and credits](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). diff --git a/docs/native-gen2-pokegear-presentation.md b/docs/native-gen2-pokegear-presentation.md new file mode 100644 index 000000000..854c5c481 --- /dev/null +++ b/docs/native-gen2-pokegear-presentation.md @@ -0,0 +1,45 @@ +# Native Pokegear state and window-resolution presentation + +Actual unchanged Johto GPU inspection found a mismatch that the native +radio command/input assertions alone did not detect. The native card was +RADIO, but a source renderer asked for `card()` (or its legacy cursor), +fell back to CLOCK and painted the wrong panel. It also received the small +cartridge canvas rather than a window-resolution drawing boundary. + +The native Pokegear now exposes `card()`, `cardLabel()`, `stations()` and +`currentStation()` over its existing native selection and tuner. Station +rows carry the real native identity and knob-derived frequency. Its `radio` +view follows native program text advancement and retuning; leaving or closing +the card clears that view. This is presentation of the existing native +program text, not the complete cartridge show or song state machine. + +`drawsWidescreen()` / `drawWidescreen(width, height)` are an optional +screen-space drawing boundary. A replacement of the Pokegear's fallback +drawer opts into it; the unmodified native menu retains its original canvas. +The normal world/compositor runs first. Only the selected wide owner's small +UI drawing is omitted; native overlays above it remain on their own existing +canvas and are composited after the high-resolution owner. This works even +when a mod's `render.compose` hook owns the ordinary world composite. +Graphics state is restored around the window drawer and overlay. + +Focused synthetic tests cover native card navigation, retuning, text +advancement, borrowed-record isolation, ordinary no-mod behavior, window +owner selection, overlay ordering and graphics-state isolation. Real +unchanged-mod GPU acceptance of this correction is still pending. + +The earlier exact `e435f5ff` package passed 36 automatic checks per OpenGL +and Metal backend with actual Crystal and the unchanged Johto package. +All eight fresh OS images were inspected. Wall-radio names were upright, +but the four Pokegear images showed CLOCK instead of RADIO, so that set was +rejected as complete radio visual acceptance and was not published. Metal +deprecation overlays also remain visible at the lower edge. + +Full radio shows/audio, source phone/map facade completion, ordinary story +admission and Windows/Intel hardware GPU gameplay remain open. In particular, +the pre-existing native Snorlax special checks only EXPN; a real native VM +probe incorrectly returns true without flute music or adjacency. That story +gate requires a separate audio/ownership correction and is not fixed here. + +This standalone upstream composition has not received unchanged-mod GPU acceptance. +Original contributions by Roxas2712 are offered under the specific permission in +[LICENSE-ROXAS2712.md](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). Existing project and third-party rights remain effective. diff --git a/docs/native-gen2-radio-audio.md b/docs/native-gen2-radio-audio.md new file mode 100644 index 000000000..e494dd9dd --- /dev/null +++ b/docs/native-gen2-radio-audio.md @@ -0,0 +1,34 @@ +# Native Gen2 radio music and Snorlax admission + +The native tuner and wall-radio script now start the imported channel song +through `src.core.Music`. Retuning starts the new station once; advancing +text or drawing never restarts it. Leaving the card or removing its screen +transfers the actual playing song to the native map owner, so battle music +can restore it. A new map replaces that override normally. Unavailable or +failed channel audio produces silence and restores map music on exit; it +cannot accidentally leave a previously selected flute song active. + +The EXPN Card still controls availability of the flute station. The native +`SnorlaxAwake` special now checks actual flute playback and the player's +local map cell against the five cartridge proximity cells. Owning EXPN, +stale `g2RadioChannel`/`g2MapMusic` fields and rendering coordinates cannot +substitute for those conditions. A music selection hook replacing the flute +is respected and does not manufacture flute admission. + +`tests/engine/gen2_radio_audio.lua` exercises actual Music with a fake audio +device, native Pokegear input/StateStack and compiled Gen2 scripts through +ScriptRunner: 167 checks across Gold, Silver and Crystal. It covers all five +cells, wrong-cell refusal, real wall-radio suspension/return, retuning, +failed audio, close, battle restoration, map replacement, external music +ownership, mod-selected audio and generation isolation. + +This change covers station entry/exit and the native story gate. Complete +ROM-driven show text, timing, schedules, intermediate jingles and music-show +choices are still open. Device-free tests do not establish audible hardware +playback, original story-trigger admission, unchanged-mod GPU acceptance or +a complete playthrough. Exact-source runtime/GPU qualification is required +before inclusion in the existing preview. + +Original implementation by Roxas2712; existing upstream/third-party credits +and licenses remain applicable. Specific contribution permission and required credits: +. diff --git a/docs/native-gen2-radio.md b/docs/native-gen2-radio.md new file mode 100644 index 000000000..844d9df63 --- /dev/null +++ b/docs/native-gen2-radio.md @@ -0,0 +1,49 @@ +# Native Gen2 radio content and wall-radio lifecycle + +The native `MapRadio` special previously only assigned a successful check +result. It now opens a native radio overlay, holds its station announcement +for 100 update frames, and resumes the suspended native script once on A/B. +Closing a covered or already closed overlay cannot pop another state or +resume the script again. The existing `setval` result is preserved. + +`mod.content.radio_channels` provides the shared native station records for +Gold, Silver and Crystal. Records accept an optional `channel` integer +0–255 and optional authored display `name`. Positive wall-radio channel +numbers must be unique. Channel zero remains the automatic Pokemon Channel: +Johto morning selects Pokedex, other Johto hours select Oak, and Kanto +selects Places & People. Region detection uses the loaded map definition +and imported Pallet landmark boundary when available. + +API 1/2 native mods can inspect detached records, patch, +override, register and remove them. The native Pokegear uses the same +records for its existing program slots; a new numbered station is available +to wall radios without inventing a Pokegear tuning frequency. An explicitly +empty dataset remains empty. Built-in names retain engine translation, +whereas authored names are displayed literally. + +Content writes are frozen after load. Failed entries roll back their earlier +writes and subscriptions. Reusing the mounted dataset on reload retires old +mod writes while preserving unrelated external edits and removals. A mod-free +load does not materialize an extra namespace. Other generations receive an unavailable content handle and cannot create +the native target. This standalone patch does not add the fork capability +query or unified API prerequisites. + +`radio.channel` is emitted on tuning, not on drawing or advancing text: +`station` is the stable station ID, `name` is its display name, `channel` is +the wall script byte or native Pokegear knob position, and `source` is +`map_radio` or `pokegear`. + +Focused synthetic acceptance: 113 content/loader checks and 73 native +VM/UI/input checks. These tests use the actual loader, bytecode lowering, +coroutine runner, native state stack and Pokegear input path. They do not +establish GPU or unchanged-mod gameplay acceptance. + +The existing native Pokegear program text is reused. Full cartridge show +state machines, schedule-dependent program data, songs and map-music +restoration are still open parity work. This change is not complete radio +parity and must receive new runtime/package/GPU qualification before release. + +Original contributions by Roxas2712 are offered to Cedric / UNDERdecoded +under the specific contribution permission in +[LICENSE-ROXAS2712.md](https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md). +Existing project, third-party and previously granted rights remain effective. diff --git a/docs/native-gen2-unown.md b/docs/native-gen2-unown.md new file mode 100644 index 000000000..2706cc7c2 --- /dev/null +++ b/docs/native-gen2-unown.md @@ -0,0 +1,42 @@ +# Native GSC Unown state + +`src.pokemon.Gen2Unown` shares native Gold/Silver/Crystal form identity and +catch-order state between sprite selection, battle captures, script gifts, +VAR_UNOWNCOUNT, the native Unown dex and public consumers. Stored reference +letter overrides and DV-derived forms use the same sprite/counter decision. +The byte calculation follows the existing native GetUnownLetter arithmetic. + +`src.core.gen2.Unown` is an explicit adapter to this service. `word(letter)` +reads the active native `Game.data.unown_dex.words`; an explicit native dataset +may be supplied as a second argument. No retail word catalogue or picture +paths are embedded in the adapter. `forms(pokemon)` maps A–Z names to the +actual numeric native form records; `formSprite` resolves the same records. +Reads follow the current dataset instead of retaining an old edition's data. + +The native save remains `save.pokedex.unownDex` and `unownForms`. Public `dex` +reads return a detached list. Known ordered forms keep their order; missing +valid entries from an older set-only save are appended alphabetically. Reads +do not migrate saves. A new form writes the normalized ordered list and set; +no separate `save.unownDex` is created. New party and PC captures and script +gifts register only after successful transfer. Eggs and refused transfers do +not register a form. + +`unown.unlocked` reports a newly recorded form once, after native state is +committed. Payload fields are `letter`, `name`, imported `word` and `count`. +Native API1/2 consumers use this same producer. Repeated catches, +reentrant duplicate writes and another species do not fire the event. + +## Verification and limits + +ROM-free coverage includes all 65,536 packed DV byte combinations, native +sprite and stored-letter selection, actual party/PC capture and gift paths, +old save reads, refused full storage, native counter/menu agreement, imported +word replacement, actual Loader consumers and event rollback boundaries. +Real-ROM checks are separate private receipts; ROM data is never committed. + +This does not establish the complete reference Unown API: unlocked-set/wild +DV selection, puzzle/encounter gates, direct mutation of reference-shaped view +tables and the remaining legacy Pokédex screen protocol need further work. +Polished Crystal's 28 forms and other editions retain their previous native +catch paths. Full Unown story/GPU/hardware and complete VASC/Gen1 parity are +not claimed. VASC files and protected Gen1/player instances are unchanged. 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/new-features.md b/docs/new-features.md index 532cbf3ae..d4612474d 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -143,6 +143,15 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from its launcher because the device reports `"Linux"` while its GPU is in the phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)). +## GBC colour correction + +Gold, Silver and Crystal offer **OPTIONS -> GBC COLOR -> OFF / ACCURATE / +HARSH**, independently of GBC FX. The profiles adapt SameBoy's Modern Accurate +and Harsh Reality GBC display response. OFF preserves the previous rendering; +the selection is saved and does not require a ROM reimport. Details, scope, +source attribution and verification are in +[GBC colour correction](gbc-color-correction.md). + ## Performance tier (low-end devices) The Options **PERFORMANCE** row scales the port's optional presentation diff --git a/docs/palette-zone-cache.md b/docs/palette-zone-cache.md new file mode 100644 index 000000000..912434625 --- /dev/null +++ b/docs/palette-zone-cache.md @@ -0,0 +1,65 @@ +# P5: palette uploads and cropped zone quads + +`Performance.paletteZoneCache` defaults to `true`; set it to `false` to restore +four fresh uniform vectors per zone and the original full-canvas/scissor blit. +This switch is independent of P1–P4. + +Native uploads compute effective palette row indices without allocating the +intermediate inverted/shade-mapped palette tables. The public `effectiveColors` +and `permute` methods retain their existing return shape/identity behavior. +The names `c0`, `c1`, `c2`, `c3` remain unchanged; no uniform packing is introduced. + +Each shader has a weakly held private last-value record and four reusable +vectors. All twelve normalized floats are compared; only changed rows are sent +inside one contiguous native blit. Every later blit starts with a full upload, +so direct raw shader writes between blits cannot leave stale colors. Public +`sendColors` calls always upload. Custom table send providers still receive fresh +vectors. Replacing public palette methods, graphics draw or shader dispatch +disables batch assumptions and preserves the original call seam. + +The native nearest-filtered texture path uses one private cached Quad with +`setViewport`, clipped to the source canvas and the old pixel coverage. It is +limited to identity transforms, positive whole-framebuffer-pixel magnification +and origins, matching X/Y target DPI and nonnegative clipping boxes. Fractional +zoom/origins, flipped/scaled/rotated views, anisotropic DPI, linear filtering, +high-DPI source textures and compound render targets use the original path. +LÖVE 12 additionally requires target DPI 1/2/4 for exact half-pixel rounding. +Zones and their palettes are never changed or pooled. + +LÖVE 11 truncates logical scissor arguments before DPI conversion; LÖVE 12 uses +float arguments and rounds physical scissor values. The crop deliberately +matches the existing behavior in each supported path. Primary API/backend +sources: [11.5 Lua API](https://github.com/love2d/love/blob/11.5/src/modules/graphics/wrap_Graphics.cpp#L524), +[11.5 backend](https://github.com/love2d/love/blob/11.5/src/modules/graphics/opengl/Graphics.cpp#L968), +[12 Lua API](https://github.com/love2d/love/blob/main/src/modules/graphics/wrap_Graphics.cpp), +[12 Metal backend](https://github.com/love2d/love/blob/main/src/modules/graphics/metal/Graphics.mm). + +The guarded `tools/check_frame_pacing.py --palette-zones --love /path/to/love +--report /owned/qa/palette.json` first compares actual GPU readbacks byte for +byte. Each backend passes 432 cases across Red/Gold, four display modes, +shade-register remapping, overlapping/true-color zones, clipping, transforms, +three target densities, fractional origins/zoom and source filtering/density. +OpenGL exercises 48 optimized and 384 fallback cases; Metal 16 optimized and +416 fallback cases. First versions of the crop failed these tests and were +restricted/corrected before qualification. Uniform behavior has 491 focused +checks, including in-place palette mutations, raw shader writes between blits, +public wrappers and fresh consumer-owned vectors. + +The timed workload draws 96 equal-palette zones over owned generated source +pixels, with 120 warm-up + 120 measured frames per switch phase, macOS arm64, +60 Hz/VSync 1. GC is stopped during each measured window to expose allocation +growth, then restarted. Heap growth includes host/profiler work and is not +retained memory or a leak measurement. Shader:send calls fall from 384 to four +per contiguous identical-palette blit; later blits still resynchronize. + +| Tree / backend | F11 zone blit, ms/frame | Raw draw mean, ms | CPU, ms/frame | Heap growth / 120 frames, KiB | +| --- | ---: | ---: | ---: | ---: | +| Fork / LÖVE 11.5 OpenGL | 0.34 → 0.42 | 0.358 → 0.451 | 0.956 → 1.210 | 4368 → 49 | +| Fork / LÖVE 12 Metal | 16.39 → 16.40 | 16.397 → 16.407 | 0.630 → 0.613 | 4369 → 49 | +| Public adaptation / LÖVE 11.5 OpenGL | 0.41 → 0.22 | 0.445 → 0.243 | 0.968 → 0.705 | 4368 → 49 | +| Public adaptation / LÖVE 12 Metal | 16.37 → 16.35 | 16.376 → 16.364 | 0.759 → 0.735 | 4368 → 49 | + +Timing/CPU results are mixed across short runs; no general FPS/CPU gain is +established. Metal queue submission can include GPU/VSync waiting. Full-game, +Android, dedicated ARM handheld and long session: not measured. Reports retain +the exact measured source fingerprints and raw F11 logs. diff --git a/docs/performance-program-status.md b/docs/performance-program-status.md new file mode 100644 index 000000000..acfbba626 --- /dev/null +++ b/docs/performance-program-status.md @@ -0,0 +1,142 @@ +# Performance and mod compatibility programme + +Final concurrent integration: `8e336d45` includes the additional friend/suspend +and native Gen2 output commits through `fc46bd85`. Its **22/22 affected +engine suites**, all required compatibility gates and **14,520 native +checkpoint assertions per LÖVE host** pass. Full-engine **561/561** evidence +remains pinned to `a6f947a5`; it is not relabeled as a full rerun of this merge. + +A1/A2, M1–M8 and P1–P9 are implemented in the composed fork and offered through +their existing upstream PRs. Existing acceptance repairs are reused. + +| Task | Existing upstream PR | +| --- | --- | +| A1 — FrameProfile sections | [#272](https://github.com/UNDERdecoded/Gen2Recomped/pull/272) | +| A2 — pinned external fixtures | [#102](https://github.com/UNDERdecoded/Gen2Recomped/pull/102) | +| M1 — module aliases | [#274](https://github.com/UNDERdecoded/Gen2Recomped/pull/274) | +| M2 — selected storage | [#155](https://github.com/UNDERdecoded/Gen2Recomped/pull/155) | +| M3 — datasets | [#122](https://github.com/UNDERdecoded/Gen2Recomped/pull/122) | +| M4 — actor/battle checkpoints | [#279](https://github.com/UNDERdecoded/Gen2Recomped/pull/279) | +| M5 — per-mod callback profiles | [#303](https://github.com/UNDERdecoded/Gen2Recomped/pull/303) | +| M6 — API-2 NPC controls | [#305](https://github.com/UNDERdecoded/Gen2Recomped/pull/305) | +| M7 — native registries | [#306](https://github.com/UNDERdecoded/Gen2Recomped/pull/306) | +| M8 — lifecycle producers/followers | [#312](https://github.com/UNDERdecoded/Gen2Recomped/pull/312) | +| P1 — VSync deadline | [#283](https://github.com/UNDERdecoded/Gen2Recomped/pull/283) | +| P2 — idle GC budget | [#288](https://github.com/UNDERdecoded/Gen2Recomped/pull/288) | +| P3 — hot-path dependencies/scratch | [#290](https://github.com/UNDERdecoded/Gen2Recomped/pull/290) | +| P4 — world drawing | [#291](https://github.com/UNDERdecoded/Gen2Recomped/pull/291) | +| P5 — palette/zone uploads | [#293](https://github.com/UNDERdecoded/Gen2Recomped/pull/293) | +| P6 — PCM/prewarming/DS worker | [#316](https://github.com/UNDERdecoded/Gen2Recomped/pull/316) | +| P7 — anchored shadows | [#319](https://github.com/UNDERdecoded/Gen2Recomped/pull/319) | +| P8 — world/water budgets | [#321](https://github.com/UNDERdecoded/Gen2Recomped/pull/321) | +| P9 — LOW/AUTO/UI quads and QA starter | [#323](https://github.com/UNDERdecoded/Gen2Recomped/pull/323) | + +## Acceptance + +The composed fork includes concurrent friend/trade and Gen-2 display changes. +Runtime revision 9532ee36 passes **493/493 engine suites**, 3 SDK suites, 253 +gallery checks, 200 API-3 comparisons and all 17 complete external entrypoints. + +The public M4 runtime 13808f9d passes **168/168 engine suites** and all remaining +gates. The public performance stack a620d0c7 passes **179/179**, 3 SDK suites, +253 gallery checks, 48 API-3 comparisons and all 17 complete entrypoints. +Repairs reuse #128–#134 and #294; the eleven inherited failing suites are +resolved on both final public stacks. + +LÖVE 11.5 and 12 each pass **14,520 checkpoint assertions** on the fork and public +M4 stack. CPU coverage spans all host selections (18 fork, 15 public), actual +native trainer/static producers, data-only story continuations and rollback. +That host uses synthetic content and substituted graphics; it is separate from +the actual GPU measurements below. See [checkpoint admission](https://github.com/Roxas2712/Gen2Recomped/blob/codex/upstream-native-checkpoints/docs/native-checkpoints.md) +and [exact qualification](https://github.com/Roxas2712/Gen2Recomped/blob/codex/upstream-native-checkpoints/docs/qa/checkpoint-runtime-m4.json). + +## Full-game reproduction + +Run tools/check_full_game_performance.py with --love, --emerald, --backend +(opengl or metal) and a new --out directory under this checkout's .local/qa. +A public checkout without the shared guard can pass --qa-tools pointing to the +fork's tools directory. Use a supported user-owned Emerald ROM. + +The guarded starter freezes the committed engine and unmodified mod manifest, +imports fresh content into an isolated QA identity and blocks legacy migration, +portable saves and external import roots. It uses native Game/Overworld and the +ordinary mod loader on Pacifidlog Town. Inputs are pinned before cleanup; the +shared free-space reserve is 20 GiB. + +Each flag is measured off/on over 180 warm-up and 240 measured frames, with two +120-frame FrameProfile windows. Simulation stays at 60 Hz. P1 uses cap 60 and +VSync 1; other phases use the highest normalized native cap (160 on these hosts) and VSync off. P7 applies a controlled +quarter-world-pixel camera pan after the native update. Voxel measurement waits +for quiescent native meshes and rejects missing sun sections/passes. + +One disclosed test-only change enables the existing native pacing/idle-GC body +in the frozen main file, because scripted drivers normally bypass it. No timing +implementation, callbacks or installed mod permissions change. Every measured +phase must contain the actual pacing section. Results retain the original +runtime/source hashes and driver hash. + +The desktop is an Apple M2 (ARM64). Metal completes every card; the additional +OpenGL replay completes the fourteen native phases, then stops at the shared +20 GiB reserve during voxel measurement. No complete OpenGL voxel qualification +is claimed for that replay. Separate earlier OpenGL component proofs remain. Sequential +FrameProfile wall/submission samples are not GPU completion timings or a +universal speedup claim; slower sections stay visible. P7 cast counts separately +show avoided static work. Component pixel, allocation, PCM/worker and rollback +proofs remain in the individual P1–P9 docs. + +Emerald does not exercise DS Nitro synthesis, GB palette uniforms or UI +quad-heavy menus; their separate component acceptance remains authoritative. +Physical Android/Switch/handheld performance: **not measured**, with no such +device available. No full-story checkpoint playthrough is claimed. Unsafe or +opaque checkpoint phases retain their documented named refusals. + +Every performance rollback flag remains available. Approved DS snapshots/ +fallbacks and per-mod callback profiles remain. Existing copyright and licence +notices are preserved. + +## Recorded FrameProfile samples + +Mean ms/frame, optimization off → on. Each state has two real windows. These +are measured samples, including regressions/noise, not attributed FPS gains. + +| Card / section | OpenGL native | Metal | +| --- | ---: | ---: | +| P1 / frame pacing: sleep | 0.3952 → 0.2675 | 10.7076 → 0.3172 | +| P2 / update: gc step | 0.0038 → 0.0004 | 0.0081 → 0.0038 | +| P3 / draw: whole frame | 0.6132 → 0.5207 | 2.4563 → 2.8336 | +| P4 / world: entities | 0.1381 → 0.1587 | 0.2993 → 0.1190 | +| P5 / renderer: zone blit | 0.0253 → 0.0269 | 0.0215 → 0.0227 | +| P6 / update: music | 0.0155 → 0.0352 | 0.0381 → 0.0445 | +| P7 / voxel: sun pass | not qualified in this replay | 0.9418 → 0.7756 | +| P8 / voxel: water | not qualified in this replay | 0.0913 → 0.0474 | +| P9 / draw: whole frame | 0.5573 → 0.5189 | 3.4081 → 3.9999 | + +P1 includes P2 idle GC within the pacing section. P7 replaces 240 legacy static +casts with 9 static and 240 small dynamic casts in the controlled moving-camera +Metal scene. P9 world timing does not exercise quad-heavy menus. See +[full source hashes and all samples](qa/performance-full-game-p1-p9.json). + +## Current upstream and fork integration + +Upstream `8d5b4387` and concurrent fork `8a362c6c` are integrated. The full +engine suite passes **561/561** at `a6f947a5`. Subsequent runtime +`86f9e593` passes **12/12 targeted engine suites**, every SDK/gallery/contract/ +unchanged-fixture gate and **14,520 actual-LÖVE assertions on each of 11.5 +and 12**. Its complete external entrypoint count is **17/17**, using an actual +native FireRed NPC and checking owned presentation, unchanged callback +arguments and owner retirement. The full engine count is not relabeled as +a full rerun of this later revision. + +Public M4 `d2551170` passes **168/168 engine suites**, all four other gates +and **14,520 assertions per native host**. Public P9 `094d8627` passes +**180/180** and all four other gates. The programme's 19 existing upstream +PRs are ready and mergeable; the callback-owner integration fix is also +offered on existing companion draft #327. + +Earlier standalone PRs retain explicit dependency boundaries: P3–P5 have +green engine/SDK suites but lack later acceptance tooling/fixtures; M5–M7 +have green engine/SDK/gallery/contract gates and require the existing +asset-pack/follower offers for complete external fixture entrypoints. M8 +and P6–P9 pass all five gates. See [exact revisions and outcomes](qa/program-current-upstream.json). +FrameProfile samples above remain evidence of their recorded revisions; +this integration does not invent or relabel measurements. diff --git a/docs/portable-battle-auxiliary.md b/docs/portable-battle-auxiliary.md new file mode 100644 index 000000000..f84b49cf2 --- /dev/null +++ b/docs/portable-battle-auxiliary.md @@ -0,0 +1,65 @@ +# Portable auxiliary battle action + +`battle.menu_auxiliary` lets a mod handle START at a settled native player +command menu. It avoids replacing a generation-specific battle controller just +to open an auxiliary tool. Native menu navigation continues when no subscriber +exists or the first hook result is anything other than literal `true`. + +```lua +local mod = ... +mod.hooks:wrap("battle.menu_auxiliary", function(next, game, context) + if myToolIsAvailable(context.kind) then + openMyTool(game) + return true + end + return next(game, context) +end) +``` + +Arguments are the Game handle and `{kind = "wild" | "trainer"}`. Gen2 retains +the opaque Game handle in all SDK modes; Gen1 API 3 can provide its existing +forwarding context view. The small context uses the normal unified record +adapter. Consumers should use public APIs and supported UI methods rather than +inspect private Game/controller fields. + +Native dispatch covers Gen2Recomped's GB, GBA and DS command menu paths. +Availability does not promise every battle variant. The boundary excludes +covered screens, link/safari/tutorial/automatic/facility variants, forced +moves, pending double choices, faint/replacement states, queued work, and +unfinished HP/status/animation presentation. Numerical counters at zero are +idle in Gen2's controller. Eligible double battles use the current player +actor and allow absent slots while requiring a live opponent. + +Eligibility is sampled before native presentation catch-up. A START press +cannot become eligible halfway through an update just because HP/status was +snapped to its final value. Dispatch rechecks the current owner/session. If a +hook changes the session, opens an overlay or invalidates the menu, that +update stops using the old menu input even if the wrapper returns false or +raises. A normal false/default result retains native navigation exactly once. +This is an input boundary, not a checkpoint/reconstruction API. + +## Validation + +The unchanged `examples/portable_battle_auxiliary` consumer runs through both +real SDKs and native command-menu update methods for Red, Yellow, Gold, +FireRed and Emerald. Native API 1/2 and API 3 are compared directly. Gen2's +additional unified API 2 mode is compared against Gen1's native API 2; Gen1 +does not implement the `callback_format` manifest extension. The reference is +pinned to clean Gen1 commit `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +The shared fixture reuses native trainer construction from the previous +trainer contract with a different consumer; it constructs native battle +objects and settles their command menus. GBA uses the real owned battle +runtime and presentation state, not a replacement hook producer. Native Gen1 +safety regressions also run unchanged. The Gen2 suite tests every game and +SDK mode, including native fallthrough, wrapper failures, stale sessions, +covered/unsafe states and rejection before presentation catch-up. + +```sh +luajit tests/engine/battle_auxiliary.lua +python3 tools/battle-auxiliary-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/battle-auxiliary.json +``` + +These are ROM-free behavior tests. Native cartridge boot and GPU frame checks +are separate from the shared command-menu contract. diff --git a/docs/portable-battle-bottom-ui.md b/docs/portable-battle-bottom-ui.md new file mode 100644 index 000000000..004aa9de3 --- /dev/null +++ b/docs/portable-battle-bottom-ui.md @@ -0,0 +1,99 @@ +# Portable battle bottom UI visibility + +`battle.bottom_ui_visible(next, state)` controls native battle text and menu +presentation on every Gen2Recomped host, through native API 1/2, unified API 2 +and API 3. It complements `screen.render_visible`: a mod can hide the bottom +UI without hiding the battle scene, sprites, health/status panels or effects. + +```lua +local mod = ... +local replacingBattleUI = false +mod.hooks:wrap("battle.bottom_ui_visible", function(next, state) + if replacingBattleUI then return false end + return next(state) +end) +``` + +Keep the flag active only while the mod owns the intended presentation. +Native TextBox also uses this hook outside battles, so a global false can hide +field dialogue too. Mods may instead select specific handles or state +properties. Battle metadata/layout internals differ between engines: Gen2's +native class uses `isBattleState`, while Gen1's classic classes use `isBattle`. +The Gen2 inheritance helper recognizes both without altering those classes. + +The callback receives the original opaque state, with no extra arguments or +record/ID conversion. The default is true; only a literal first result of +false hides UI. Nil, zero, strings and tables remain visible. `next()` keeps +the current arguments; explicit `next(otherState)` changes downstream +predicate arguments, not the actual state or drawing target. The default +ignores the argument. Treat the callback as a pure predicate: rendering and +menu clipping may query it repeatedly during one frame. + +## Native routing + +The primary gate runs before BattleState dispatches its text area to classic, +GBA or DS renderers. WideBattle's separate bottom draw also honors it. On DS, +this gates the text/menu path that owns both compact and device-screen action, +move and target controls; it does not hide independent device content or +change input ownership. The Game Boy move/Mimic row clip is removed while +that menu is hidden, retaining existing unrelated field/side clips. + +TextBox and ChoiceBox inherit a false decision from the nearest enclosing +battle below them on the native stack. A hidden battle short-circuits before +text frames, choice backings, glyphs and UI anchors are written. When the +battle is visible, TextBox also has its own individual predicate. ChoiceBox +only inherits: a field YES/NO prompt never becomes a battle-hook state. +A battle above an overlay is not its owner. + +The predicate does not remove states, consume input or pause logic. Typewriter +updates and delayed YES/NO confirmation remain native. It does not modify +existing rendering-driven presentation counters or their timing when a drawer +is skipped. `battle.status_hud_visible` is a [separate status-panel surface](portable-status-hud.md) +with GB/GBA and DS producers. `screen.render_visible` may independently hide an entire state. + +`mod:supports("hooks", "battle.bottom_ui_visible")` reports the native producer +before Game creation. With no subscriber the helper returns true directly; +the default function is hoisted and the unhooked path allocates no predicate +closure/payload. Existing priorities, unsubscribe, owner cleanup and failed- +entry rollback apply. Gen2's existing `claimBottom`/`releaseBottom`, claimant +introspection, reset and failing-claim removal remain intact. A legacy claim +that denies drawing wins before the public hook; a hook returning true cannot +force another owner's hidden strip visible. A wrapper error before forwarding falls through; an +error after forwarding preserves downstream results without replay. Unified +callbacks retain existing cached-continuation rules. Native renderer error +handling is unchanged, including DS's existing guarded text-area drawing. + +## Verification + +```sh +luajit tests/engine/battle_bottom_ui.lua +luajit tests/engine/battle_bottom_ui_routing.lua +luajit tests/engine/battle_bottom_ui_composition.lua +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp --api 1 +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp --format unified +python3 tools/battle-bottom-ui-contract-check/check.py --gen1 /path/to/gen1recomp --api 3 --format unified +``` + +The checker rejects dirty/unexpected Gen1 sources and pins +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. The same committed example runs +through both native loaders on Red, Gold, FireRed and Emerald. It calls the +real native predicates and bottom render routes: Gen1 BattleState, Gold's +`drawBottom`, and GBA battle Ui.draw's caught-Pokemon scene, compared with +Gen2's native BattleState dispatch. GBA inheritance is exercised through its +own Ui helper; the shared classic TextBox helper is not presented as Gen1 +GBA's native message implementation. Gen1 API 2 ignores the unified opt-in; +that run compares native Gen1 API 2 against unified Gen2 API 2. API 3 is +unified on both hosts, with opaque state identity preserved for this hook. + +The Gen2 routing matrix covers every supported version in all four modes, +actual inherited TextBox/ChoiceBox drawers and input/update behavior, native +move clipping, cleanup and the no-mod fast path. The composition suite builds +real native wild battles across every version in classic/wide options. It +runs their compositors with asset-layer spies, retaining the actual bottom +render path, and checks field/actor/HUD/animation dispatch plus identical +native menu input and logic ticks. It also covers the GB palette-canvas path. +The CI workflow also runs the reference engine's unchanged native GBA battle +UI contract suite, covering its regular menus, HUD and native confirmations. +These are ROM-free/headless dispatch tests, not GPU pixel comparisons, live +mirrored-device output or cartridge playthroughs. diff --git a/docs/portable-battle-navigation.md b/docs/portable-battle-navigation.md new file mode 100644 index 000000000..2de4a3ef2 --- /dev/null +++ b/docs/portable-battle-navigation.md @@ -0,0 +1,61 @@ +# Portable battle move navigation + +`battle.move_grid_navigation` selects native move-menu navigation without +replacing a battle update method. The callback receives the actual opaque +battle/screen handle. Only a first result of literal `true` enables the grid; +`false`, `nil`, zero and other values select the list. `next(battle)` asks for +the host layout's normal mode. Unsubscribing restores that mode. + +```lua +local mod = ... +mod.hooks:wrap("battle.move_grid_navigation", function(next, battle) + if myOptions.useMoveGrid then return true end + return next(battle) +end) +``` + +The native default is a list for classic GB and a grid for GBA/DS and Gen2's +wide layout. As in Gen1Recomp's original GB battle, the Gen1 wide layout is a +fixed grid and bypasses the predicate. Gen2's Gold-family wide screen allows +an explicit list override; its unwrapped native default remains a grid, while +Gen1's distinct Gold screen defaults to a list. Portable custom UIs should +choose their desired mode explicitly rather than infer host-private fields. + +Grid slots are ordered `1 2 / 3 4`. Missing cells retain the current slot. +Single-battle grids wrap between row/column peers. GBA doubles use the native +clamped player controller: left at the left edge and up at the top edge stay +put. This corrects Gen2's previous generic wrapping at GBA doubles edges and +matches Gen1's native GBA controller. DS retains its existing grid behavior. +The list uses native up/down wrapping. A/B/SELECT stay with their normal +handlers; the same decision also applies to Gen2's Mimic copy menu. + +Both native and unified callback formats keep the battle handle opaque. +Subscribing does not normalize engine-private controller fields. Use public +snapshots/actions for gameplay data and input actions. Availability is reported +for the actual shared native producer in Gen1–5. + +## Validation + +The unchanged `portable_battle_grid` example is loaded through both real SDKs. +Shared cases drive native move-menu updates for Red, Yellow, Gold, FireRed and +Emerald: all four directions, every occupied slot and every move count from +one to four, in explicit list/grid modes. They also drive the native GBA +doubles input controller and compare its edge and missing-cell behavior. +The Gen1 reference is pinned to clean +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +Native API 1/2 and API 3 are compared directly. Gen2's optional unified API 2 +is compared with Gen1's native API 2, since Gen1 has no `callback_format` +extension. Gen2 additionally tests all fifteen games, native defaults, wide +layout rules, Mimic, wrapper failure recovery, API-3 next caching, unsubscribe +and empty move grids. + +```sh +luajit tests/engine/battle_grid_navigation.lua +python3 tools/battle-grid-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/battle-grid.json +``` + +These are ROM-free native-controller and SDK tests, not cartridge playthroughs +or GPU pixel goldens. This predicate changes navigation; custom grid/list art +remains the mod's responsibility. 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-follower.md b/docs/portable-follower.md new file mode 100644 index 000000000..3efebd839 --- /dev/null +++ b/docs/portable-follower.md @@ -0,0 +1,78 @@ +# Portable follower predicate + +`world.follower.spawn(next, game, world)` controls whether the native optional +companion exists. It receives the original opaque Game/world handles in every +SDK mode. Return `next(game, world)` to keep the host's native decision, or a +Lua truthy/falsey result to enable/disable it. In particular, `0` and `""` enable +it; `false` and `nil` disable it. This is a lifecycle predicate, not a once-only +spawn notification: do not depend on a callback count per frame. + +```lua +local mod = ... +if mod:supports("hooks", "world.follower.spawn") then + mod.hooks:wrap("world.follower.spawn", function(next, game, world) + if myOptions.companion then return true end + return next(game, world) + end) +end +``` + +The producer runs on native map entry and normal follower updates. A denial +removes the companion from its native update/draw lists; a later allowance +recreates it. Removing the wrapper restores the native predicate. Wrapper +errors retain the normal hook bus recovery rules, including keeping a +completed downstream decision after a post-`next` failure. API 3 repeated +`next` evaluates the downstream chain once per invocation. + +## Native scope + +- Gen 1 uses the existing Pikachu controller. The unchanged Yellow default + still checks the starter event, healthy party Pikachu, biking/surfing and + available artwork. Other GB games default to no follower. Existing scripted + Yellow scenes retain their own update ownership. +- Gen 2 and GBA default to no companion. An allowance creates a passable native + NPC which follows committed player steps; the ordinary world NPC loop owns + its interpolation and rendering. Gold-family mid-map spawns start behind + the player when walkable; map loads and GBA spawns start under the player. + Connection carry preserves the NPC and buffered trail. GBA same-map long + teleports reset the companion to the player. These optional companions do + not enter Yellow's emotion/dialogue controller. +- DS reports the hook unavailable. Scripted escorts and HGSS's walking Pokemon + keep their native controllers; this hook does not take ownership of them. + +Artwork and actor internals are not normalized by this predicate. Gen2Recomped +uses `SPRITE_PIKACHU` when present, otherwise the player's sheet (then the native +placeholder fallback); each companion owns a renderer. The fallback never +writes a registry alias. The pinned Gen1 GBA controller instead leaves its +optional renderer to the mod. An artwork mod that imports or replaces private +controllers still needs a public artwork/actor contract before it is portable. +Yellow's existing native eligibility and scene mechanics also differ between +the engines; this PR preserves their defaults rather than changing them. + +## Validation + +The unchanged `examples/portable_follower` entry and manifest are loaded through +both real SDKs. Shared tests drive the real GB, Gold and GBA follower modules +and native NPC interpolation, with fixture assets/session data only. No fake +follower or hand-called Runtime hook substitutes for the native controllers. +The reference is a clean Gen1 checkout at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +The checker covers Red/Blue/Yellow/Gold/Silver/Crystal/FireRed/Emerald in API 1 +native, API 2 native/unified and API 3. Gen1 API 2 has no optional unified mode; +that comparison exercises Gen2's opt-in format against Gen1's native format. +Cases include native defaults, original handles, allow/deny, every interpolated +frame of a four-direction walk, map entry, GB/Gold connection carry/rebase, GBA +teleport, Lua truthiness, wrapper failure, removal, API-3 repeated `next`, and +failed-entry cleanup. These are controller/SDK contracts, not identical artwork, +Yellow scene parity or full-playthrough validation. + +Gen2 unit tests additionally check every host version, DS exclusions, unrelated +NPC preservation, fallback rendering ownership, cast invalidation and legacy +SDK behavior. + +```sh +luajit tests/engine/follower_spawn.lua +python3 tools/follower-contract-check/check.py --gen1 /path/to/clean/gen1 \ + --api 3 --format unified --report /tmp/follower-contract.json +``` 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-level-visibility.md b/docs/portable-level-visibility.md new file mode 100644 index 000000000..1dce40ad1 --- /dev/null +++ b/docs/portable-level-visibility.md @@ -0,0 +1,73 @@ +# Portable Pokémon level visibility + +`pokemon.level_visible` lets a mod hide the native level label and digits +without replacing a battle, party or summary screen. It uses Gen1Recomp's +existing GB/GBA contract: + +```lua +local mod = ... +if mod:supports("hooks", "pokemon.level_visible") then + mod.hooks:wrap("pokemon.level_visible", function(next, mon, context) + if context.where == "battle.enemy" then return false end + return next(mon, context) + end) +end +``` + +The default is `true`; only a first return value of literal `false` hides the +label. `nil`, `0` and other values leave it visible. A caller only asks where +the native screen would otherwise print a level. The decision can be queried +several times per frame and should be deterministic and free of side effects. +Unsubscribing or unloading the mod restores the native display. + +`context.where` is one of `battle.enemy`, `battle.player`, `party`, or `summary`. +The native callback receives the actual Pokémon and `{where, game}`. Gen2's unified +API 2 and the common API 3 use their existing Pokémon/context record adapters, including +normal ID conversion, `next` forwarding, validation and failure recovery. API 3 +is recommended for portable consumers. The Game value is an engine handle; +Gen1's unified context can expose a forwarding view while Gen2 retains the +native handle. Consumers should not inspect engine-private Game fields. + +The native call sites cover Gen2Recomped's Gen1/2 games (including Prism and +Polished Crystal) and FireRed/Emerald. Classic and wide GB healthboxes, GBA +single/double healthboxes, party rows and summary variants use the same hook. +GB summaries also omit the printed next-level number and its `TO` word/arrow. +Status/faint labels, egg eligibility, names, HP, EXP values/bars, and historical +met-level text retain their native behavior. GBA move-detail pages retain their +existing level eligibility. The level, stats and progression are not changed by +hiding their display. + +The DS renderers are outside this change. `supports` returns `false` for +Gen4/5; accepting the API-3 hook name alone is not a support claim. + +## Validation + +The unchanged example in `examples/portable_level_visible` is loaded by both +engines' actual SDKs. The checker pins a clean Gen1 reference at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36` and compares Red, Yellow, Gold, +FireRed and Emerald in native API 1/2 and API 3. Gen2's additional unified API 2 +mode is compared against Gen1's native API 2: Gen1 does not implement the +`callback_format` manifest extension. API 3 is the common unified contract. +Workers compare +native battle level omission/restoration and all four predicate contexts. The +Gen1 GBA worker uses Healthbox's intact private level producer with a native +session context; GB and Gen2 GBA workers invoke native HUD methods. Only +font/tile leaves are recorded. + +```sh +luajit tests/engine/level_visible.lua +luajit tests/engine/level_visible_contract.lua +luajit tests/engine/level_visible_renderers.lua +python3 tools/level-visible-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/level-visible.json +``` + +Gen2 renderer tests exercise actual party/summary draw methods and native +healthbox variants across every supported version and SDK mode. They check +labels and digits together, status/egg eligibility, per-side hiding, level 100, +next-level arrows, native context, restoration, and a distinct doubles partner. +Leaf-spy tests disable JIT compilation only for the HUD function whose captured +leaf is replaced through debug upvalues. Predicate and SDK behavior remain +covered separately. CI also runs the pinned Gen1 Gen2/Gen3 native hook suites +unchanged. These are ROM-free draw-command and behavior tests, not GPU pixel +goldens or full cartridge playthroughs. 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-party-navigation.md b/docs/portable-party-navigation.md new file mode 100644 index 000000000..e2014cbc5 --- /dev/null +++ b/docs/portable-party-navigation.md @@ -0,0 +1,68 @@ +# Portable battle-party navigation + +`ui.party.grid_navigation` lets a mod select the battle party controller's +native grid policy without replacing a screen's update method. The hook +receives the original opaque party-menu handle in every SDK format. Only +literal `true` opts in; `next(menu)` returns the normal `false` decision. +Removing the wrapper restores the host's existing navigation. + +```lua +local mod = ... +if mod:supports("hooks", "ui.party.grid_navigation") then + mod.hooks:wrap("ui.party.grid_navigation", function(next, menu) + if myOptions.usePartyGrid then return true end + return next(menu) + end) +end +``` + +The predicate is battle-only. Field menus, submenus, healing animations, +scripted no-input screens and swap/HP-transfer selection retain their existing +input ownership. Confirm and cancel use the native handlers. GBA also retains +its separate confirm/cancel processing when direction and A/B arrive together. +This hook changes navigation, not the menu art or battle legality checks. + +## Native policies + +GB/Gold-family opt-in navigation treats occupied party entries as a two-column, +row-major grid (`1 2 / 3 4 / 5 6`). Horizontal input chooses the peer in its +row; a missing peer retains the current slot. Vertical input wraps in the same +column, skipping missing cells. Native host menus still differ when the hook +returns false, including whether a Cancel row is present. + +GBA uses spatial navigation from the imported slot icon centers. Horizontal +input chooses the closest vertical peer in the requested column; vertical +input chooses the nearest entry in the same column. No vertical neighbor +selects Cancel. From Cancel, up selects the last occupied entry and down the +first. No horizontal neighbor retains the cursor. On old caches without +imported icon positions, the rendered panel positions provide the fallback. +The active battle's party scope determines occupied entries and Cancel; +internal cursor numbering remains engine-specific and opaque. + +The common single-layout fixture matches Gen1's actual GB, Gold and GBA +controllers. This does not add missing GBA double-layout artwork or require +identical internal screen objects. Other imported GBA geometries are navigated +according to their own positions. DS party screens retain their existing +controller and report this hook unsupported until a native seam is supplied. + +## Validation + +The unchanged `portable_party_grid` example is loaded through both real SDKs. +The checker drives native menu input for Red, Yellow, Gold, FireRed and Emerald, +covering one to six party members, every occupied slot, every direction and +GBA Cancel. Gen1 is pinned clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. API 1/2 native, Gen2 unified API 2 +and API 3 are compared; Gen1 API 2 has no optional unified format. + +The Gen2 suite additionally checks every GB/GBA host version in all four SDK +modes, native defaults, wrapper errors and removal, repeated API-3 `next`, +field/submenu/heal/no-input exclusions, imported and old-cache geometry, +empty parties, battle-local party scope and native input-handler routing. +These are ROM-free SDK/controller checks, not GPU pixel or full-playthrough +validation. + +```sh +luajit tests/engine/party_grid_navigation.lua +python3 tools/party-grid-contract-check/check.py --gen1 /path/to/gen1 \ + --api 3 --format unified --report /tmp/party-grid.json +``` 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-status-hud.md b/docs/portable-status-hud.md new file mode 100644 index 000000000..8312c45d1 --- /dev/null +++ b/docs/portable-status-hud.md @@ -0,0 +1,76 @@ +# Portable battle status HUD visibility + +`battle.status_hud_visible(next, state)` is a synchronous render predicate. +Only the first result being literal `false` hides the native status panels: +name, level, status, HP/EXP bars, numbers and their panel art. `nil`, `true`, +zero, strings and tables leave the panels visible. The default is true. + +```lua +local mod = ... +mod.hooks:wrap("battle.status_hud_visible", function(next, state) + if customBattleUIOwnsStatus(state) then return false end + return next(state) +end) +``` + +The argument is the original opaque native battle owner, with no extra +arguments. API 1/2 retain their native callback format; unified API 2 and +API 3 deliberately keep this hook's owner opaque, too. This hook does not +provide a portable battle record: use the public battle snapshot for that. +Predicates should read state and return a decision; renderers can query them +repeatedly, so the callback must not advance simulation or consume input. + +Gen2Recomped dispatches it from classic GB/GBC status rows, the wide GB/GBC +panels, GBA singles and all four doubles panels, and the existing DS healthbox +route. Native GB palette rendering and GBA widened surfaces use the same gate. +The standalone renderers accept partial battle objects. Existing DS custom +`statusHUDVisible` overrides retain their veto and error fallback; the shared +native method invokes the public hook once per query, rather than once in +the method and again in the DS helper. Hidden DS boxes count as handled and +cannot trigger a fallback GB panel. `mod:supports("hooks", +"battle.status_hud_visible")` reports the producer on all supported hosts. + +This does not hide the battlefield, battlers, effects, intro party summaries, +replacement party rows or bottom text/menu layer in Gen2Recomped. The latter +has its own [bottom UI predicate](portable-battle-bottom-ui.md). Input, +updates, HP/EXP presentation state and battle ownership remain native. +Legacy overrides, hook priorities, unsubscribe, owner cleanup and failure +fallback still apply. With no subscriber the default path returns true +without allocating a per-query closure or payload. + +## Reproducible contracts + +`examples/portable_status_hud` is the unchanged consumer loaded by both +engines. The worker uses each engine's real Loader, hook bus and native +predicate/renderer, from that engine's working directory. The GB/GBC test +spies on HUD leaf drawing; the Gen1 GBA worker uses its real Ui compositor +with a healthbox leaf spy. The pinned reference's unmodified native GBA suite +also tests actual healthbox art, text, singles/doubles and Oak relighting. +The reference tree must be clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. + +```sh +luajit tests/engine/status_hud.lua +luajit tests/engine/status_hud_renderers.lua +luajit tests/engine/status_hud_composition.lua +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 --api 1 +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 --format unified +python3 tools/status-hud-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified +``` + +The cross-engine matrix covers Red, Gold, FireRed and Emerald with API 1, +native/unified API 2 and unified API 3. Gen1 ignores API-2 `callback_format`; +that row compares its native API-2 callback to Gen2's unified callback, using +the same consumer. Observations check native/hidden/restored/unsubscribed +status drawing, false-only semantics, identity, forwarding, priorities, +failure recovery, API-3 cached continuation and owner cleanup. + +Gen2-only tests cover all 15 hosts, both layout options, palette composition, +GBA native panel art and doubles, independent bottom UI, unchanged menu input, +logic clocks, DS delegation and legacy overrides. These are ROM-free render +routing/composition checks, not pixel goldens or imported-ROM playthroughs. +Gen1 Gold's native HUD also owns its party-ball rows and hides those with +its status predicate; Gen2 keeps its existing separate party-row ownership. +The shared observations intentionally compare status panels, not identical +pixels or generation-specific intro presentation. diff --git a/docs/portable-trainer-preparation.md b/docs/portable-trainer-preparation.md new file mode 100644 index 000000000..090f7c581 --- /dev/null +++ b/docs/portable-trainer-preparation.md @@ -0,0 +1,114 @@ +# Portable trainer battle preparation + +`trainer.before_battle(next, game, context, continueBattle)` runs before the +native trainer battle is constructed. Return literal `true` to defer it; +returning `false`, `nil`, zero or another value lets native admission continue. +`next(game, context, continueBattle)` delegates to other wrappers/default false. +The retained `continueBattle` is a separate one-shot admission function, not +an asynchronous use of the hook bus's synchronous `next`. + +```lua +local mod = ... +mod.hooks:wrap("trainer.before_battle", function(next, game, context, continueBattle) + -- A preparation UI can retain this function until its decision is ready. + openPreparationUI(function(indices) + continueBattle(indices and {playerPartyIndices = indices} or {cancel = true}) + end) + return true +end) +``` + +`continueBattle()` starts normally; `{cancel=true}` releases the encounter +without constructing a battle or granting victory. A valid first invocation +returns true, including cancellation; duplicates and invalidated requests +return false. A callback can also continue immediately and return true. +Wrappers failing before/after forwarding fall through without duplicating +native admission. Omitting a deferred decision keeps the encounter pending; +mods must offer a decision or cancellation in their UI. + +## Scope and native behavior + +The context has native `trainerClass`, `partyIndex`, and optional `mapId` and +`npcId`. Identifiers are not a newly canonicalized cross-generation trainer +registry. Gen2 keeps Game opaque and exposes the context through its ordinary +unified context adapter. Pinned Gen1 API 3 also creates a shallow context view +for Game; table identity/arbitrary Game edits are not portable guarantees. +API 1/2 keep their callback defaults; Gen1 ignores API-2 `callback_format`. + +The Gen2 producer covers the existing OverworldController trainer challenge +completion and `Commands.start_battle` trainer scripts, including their GBA/DS +wrappers. Trainer sight uses these native routes after its approach. It does +not wrap constructors globally. Direct facility constructors or mod calls to +private constructors do not become new preparation producers. Wild, link, +first-battle and tutorial-marked script entries retain their original routes. +`mod:supports("hooks", "trainer.before_battle")` reports these native producers +on all 15 supported hosts, not interception of every private constructor. + +The ordinary challenge TextBox remains before preparation. Victory text, +flags, rewards, post-battle flows and transition ownership remain native. +Cancellation does not defeat the trainer and suppresses immediate same-tile +sight retriggering. Script preparation parks the real ScriptRunner before +construction; its continuation resumes that coroutine, then the normal battle +completion resumes it again. Cancelled scripts receive `lastCheck=false` and +`lastBattleResult="cancel"`, then retire their owning coroutine and release +native script locks/caller completion. Post-battle rows and deferred rewards +never run, including for immediate cancellation and strict mod scripts. Native per-engine event +placement/cancellation details are not promised identical. + +A deferred Gen2 request is tied to the original Game/save/data/stack/session, +party table/member order, hook provider/subscriptions and native actor/map or +script coroutine/context. Retained callbacks cannot start against a replaced +session, removed NPC or changed trainer. Native core.update retires invalid +requests; removal of a subscriber cancels a still-current encounter and frees +its pending guard. Stale contexts are retired without running an old completion +against a replacement session. Core-update watcher cleanup preserves other +subscribers. Registration rollback, unsubscribe and owner cleanup still apply. + +## Battle-local player party + +`{playerPartyIndices={3,2}}` selects save-party members three and two, in that +order, for this battle only. It copies the index list and keeps references to +the selected Pokémon. It never replaces/reorders `save.party` or clones the +Game. HP/PP/status/EXP changes to selected members remain real save changes. +The scope governs the lead, doubles partner, switch/replacement eligibility, +native party menus, EXP participation/EXP.ALL, party-based move/AI queries, +party-row rendering and scope exhaustion. Public battle snapshots already read +the battle-local party. Catch storage and tutorial party restoration continue +to use the actual save. + +An empty, sparse, duplicate, fractional, out-of-range or non-table selection +falls back to the full party, matching the pinned reference's bounded behavior. +Selected fainted/egg members are not silently replaced by unselected healthy +ones: native eligibility applies within the selected party. All selected mons +fainting can end the battle even if an excluded bench mon is healthy. + +## Verification + +```sh +luajit tests/engine/trainer_preparation.lua +luajit tests/engine/trainer_preparation_native.lua +luajit tests/engine/trainer_party_scope.lua +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 --api 1 +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 --format unified +python3 tools/trainer-preparation-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified +``` + +The clean pinned Gen1 reference is +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. One unchanged example consumer runs +on Red, Yellow, Gold, FireRed and Emerald through both real Loaders. Classic +Gen1 and Gen2 workers use their native prepareTrainerBattle and real battle +constructors; native Gen1 Gold uses startScriptedBattle and its own battle +engine; Gen1 GBA uses the real BattleBridge and native state. The worker compares +native construction, party order, deferral, cancellation, immediate continuation, +true-only claiming, wrapper failures, cleanup, rollback and invalid-scope fallback. +No reference sources are modified. + +Gen2-only tests exercise actual challenge TextBoxes, script coroutines, victory +flags/rewards, cancellation, stale references and one-shot admission on all 15 +hosts/four callback modes. Party-scope tests exercise actual doubles, native +party screens, EXP and defeat logic. Fixtures supply ROM-free data/asset seams; +the native producer, constructors and gameplay methods are not replaced. +The workflow additionally runs Gen1's unmodified trainer_before_battle, +unified_gen2_hooks and unified_gen3_hooks suites. These checks do not claim +an imported-ROM playthrough 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/portable-world-talk.md b/docs/portable-world-talk.md new file mode 100644 index 000000000..23c9b78d6 --- /dev/null +++ b/docs/portable-world-talk.md @@ -0,0 +1,95 @@ +# Portable NPC conversation hook + +`world.talk(next, owner, target)` runs when native A-button interaction resolves +a talkable NPC, before the native talk dispatcher acquires its freeze, faces +the actor or starts its dialogue/script. Call `next()` or `next(owner, target)` +to continue. Omit `next` to answer instead. A return value, including `false` +or `nil`, does not request fallthrough; this is a dispatch hook, not a boolean +visibility predicate. + +```lua +local mod = ... +mod.hooks:wrap("world.talk", function(next, owner, target) + if target and target.id == myRuntimeNpcId then + -- Start this mod's public dialogue/script flow here. + return + end + return next(owner, target) +end) +``` + +`examples/portable_world_talk` is an unchanged consumer loaded by both engines. +It can claim a selected actor ID, observe the original handles and unsubscribe. +It does not patch engine classes or require internal modules. + +## Native boundaries + +Both arguments are original opaque engine handles in API 1, native/unified +API 2 and API 3. They are not canonical NPC or Game records. Gen2Recomped's +owner is its OverworldController; Gen1's classic owner is its overworld, while +its separate native GBA field supplies Game. Use public mod APIs for game +operations and do not depend on a common owner layout. + +Gen2's shared producer covers all 15 supported hosts, including native GBA +and DS worlds. It retains the existing counter lookup, hidden/moving/blocker +and GBA elevation rules before dispatch. Special Pikachu/HGSS follower talk +keeps its separate native route. Signs, field tiles, ordinary script calls +to `talkTo` and trainer sight approaches are not new hook producers. +`world.interacted` still reports the original resolved actor once for the NPC +interaction, including when a mod claims it. This hook does not replace the +notification or the separate trainer-start contract. + +The default continuation matches Gen1's classic void `talkTo` dispatch. +Explicit `next` arguments reach downstream hooks unchanged. Redirecting the +native conversation to a different actor is not a portable promise: Gen1's +GBA script continuation closes over the originally resolved actor. Hook +priorities, unsubscribe, entry rollback, owner cleanup and failure recovery +retain their bus contracts. A failing wrapper before forwarding falls through; +a failure after forwarding does not repeat the completed native conversation. +Native talk errors propagate without replay. + +API 3 caches repeated continuation calls on both engines. Gen2 rejects a +continuation after its callback returns; pinned Gen1 can return the cached +result later. A portable mod calls `next` synchronously and does not retain it. +API 1/2 keep their existing native/unified continuation rules. + +The pinned Gen1 Gold engine **does not call `world.talk` from its native +`src.world.gen2.World:interactBody`**, despite the name being accepted by its +catalog. The shared checker exercises this real native boundary and expects +no callback there. Gen2 Gold does execute the common contract. The checker +does not substitute the classic Gen1 controller for Gold or modify reference +sources to force a match. + +## Verification + +```sh +luajit tests/engine/world_talk.lua +luajit tests/engine/world_talk_routing.lua +luajit tests/engine/world_talk_dialogue.lua +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 --api 1 +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 --format unified +python3 tools/world-talk-contract-check/check.py --gen1 /path/to/gen1 --api 3 --format unified +``` + +The checker requires a clean Gen1 checkout at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Each worker runs from its own +engine's working directory, using the real Loader, hook bus and native +interaction method. Red, Yellow, FireRed and Emerald have matching shared +observations; Gold has the explicit producer boundary above. Gen1 ignores +API-2 `callback_format`, so that row compares native Gen1 API 2 against unified +Gen2 API 2 with the same consumer. + +The common fixture spies on the native conversation/script destination after +real object lookup. Gen1 GBA uses its actual event-object pool, counter lookup, +Field.interact and freeze/face calls. Gold uses its own native interactBody. +Gen2 routing covers all 15 hosts and four callback modes; a separate native +runtime-NPC test exercises real spawn, NPC construction, talkTo, TextBox +confirmation, freeze release, unsubscribe and removal. It binds the same +module-local Game/script dependencies normally installed by enter(), avoiding +an imported map/GPU. Its spawn uses the native public WorldAPI, independently +of the separately stacked API-3 NPC adapters. + +The workflow also runs Gen1's unmodified `world_talk` and `unified_npcs` suites, +including real GBA runtime dialogue/registered scripts and cancellation. These +are ROM-free behavioral tests, not imported-ROM playthroughs or pixel goldens. diff --git a/docs/qa/audio-workers-p6.json b/docs/qa/audio-workers-p6.json new file mode 100644 index 000000000..937b2b72c --- /dev/null +++ b/docs/qa/audio-workers-p6.json @@ -0,0 +1,80 @@ +{ + "runtimeCommit": "d7bbf97d03cc7af1070a4ac909c93f5f60882287", + "native": { + "phases": [ + { + "frames": 120, + "mode": "setSample", + "meanMs": 0.1483236111110712 + }, + { + "frames": 120, + "mode": "automatic PCM", + "meanMs": 0.0648465277777325 + }, + { + "frames": 120, + "mode": "cold effect trigger", + "meanMs": 0.23535000000003414 + }, + { + "frames": 120, + "mode": "prewarmed effect trigger", + "meanMs": 0.01920000000016723 + }, + { + "frames": 120, + "mode": "legacy DS update", + "meanMs": 0.08887118055559048 + }, + { + "frames": 120, + "mode": "worker DS update", + "meanMs": 0.010109027777686444 + } + ], + "renderer": "Metal", + "jitEnabled": true, + "scope": "Native SoundData/worker, owned sample programs and Source hardware double; no device playback or cartridge music", + "checks": 358, + "arch": "arm64", + "os": "OS X", + "sha256": { + "src/core/AudioPcm.lua": "02f0e7f94f2195e1044b3013762705f7a5e6fb8abf2ee292df2c2965e815848f", + "src/core/ChipSynth.lua": "ec359285be3e9bf4b015d2e3a9ff08527793719a489a89bdf2d1c6a0e4bac179", + "src/core/M4ASynth.lua": "0d60dcd9cb91a44f40186b9f1eb55c28c400b28960f5a4a21eb19d19c3c10a77", + "src/core/chip_worker.lua": "c58df4dc639d4d8427527fc1fac8671014ccadbca81238060183523f723eaf38", + "src/core/ChipAudio.lua": "e7e5cde5da08915386269450d822d027d17afab961b0db8e6016010d17717541", + "src/core/Sound.lua": "7473527636d2198916c16ee40322af12805439925dd4ae5b61f594d3190b4eec", + "src/core/Music.lua": "349a213316df07dd293a272d0e91131438c68e2b18286310af755c4a962a6432", + "src/core/Performance.lua": "a66d32858cb1226f81182654f9048a116693b3a5c6838d740d31f36faeb1b257", + "src/audio/NitroAudio.lua": "1d81f278653ce90e5521d9b89d9a75ad24a9d860927e00e9ce87a715137366cf", + "src/audio/NitroTransport.lua": "bc2e6e0f3aae8020bcf06a8d5591f59a03955e05d02b7e355fe02e82ee9fbbd2", + "src/audio/NitroSequence.lua": "5a93a96afebe4945e951fe0cd204924f4e350cc1aa8fa8c348774dc69ca1463a", + "src/audio/NitroWorker.lua": "6ae969b5bc6171060aee2226a2ea536d6a5223d87ee86be43ed2d46067e8a4ab", + "tools/audio-profile-check/NitroFixture.lua": "6b9c147ddfcb0bd33fd401549cc4108a505da362c964851ab703d1a2d839ca67", + "tools/audio-profile-check/NitroCheck.lua": "a18c2772663147a5337af334e3b4e61fca98cdfc52898f0d5f034f76cd5a14ce", + "tools/audio-profile-check/main.lua": "0a73735704bc199f03bee47be68abc23a711a9ddaa0d1679e24bd7f2b3d1c20f", + "tools/audio-profile-check/conf.lua": "58c1fc954bc54b6e07cd13e989ff812bd86d34cdd0df70fcb17d05d50bbcdaa4" + }, + "playerProfilesUsed": false + }, + "acceptance": { + "baseline": "9dac33b1ca38b1c3855ef52dd3bcbcdc6aa3ec62", + "engine": { + "passed": 156, + "total": 167, + "inheritedFailedSuites": 11, + "baselineFailLines": 29, + "candidateFailLines": 29, + "identicalFailureCounter": true + }, + "sdkSuites": 3, + "galleryChecks": 253, + "api3Comparisons": 48, + "isolatedApi3Workers": 96, + "externalFixtureEntrypoints": 17, + "externalFixturePhase": "complete", + "hostedCI": "not established" + } +} diff --git a/docs/qa/low-tier-p9.json b/docs/qa/low-tier-p9.json new file mode 100644 index 000000000..777cf8d6b --- /dev/null +++ b/docs/qa/low-tier-p9.json @@ -0,0 +1,122 @@ +{ + "runtimeCommit": "455f911e81a1a176615578935e5e430f77275ab2", + "native": { + "forkMetal": { + "renderer": "Metal", + "phases": [ + { + "warmupFrames": 120, + "meanMs": 0.29062048611111596, + "quadAllocations": 15480, + "mode": "legacy", + "frames": 120 + }, + { + "warmupFrames": 120, + "meanMs": 0.17250069444444496, + "quadAllocations": 0, + "mode": "cached", + "frames": 120 + } + ], + "checks": 6, + "scope": "Actual bag, box and textbox draw producers with owned artwork; CPU draw submission, no ROM or full-game FPS", + "sha256": { + "src/core/Performance.lua": "def2c6b3d5e93ce8a10c10f03b40f30ac05997a1330f3ed49b8d1395399b4877", + "src/render/UiQuad.lua": "8d7715e0bbfc21d2408c6652def9e01474a7dedcbc5bc9e15f0cf39b60aa73b3", + "src/render/TextBox.lua": "ba2c8e0203d6b039d88e3224186722c1065894488896f3dba39a9b4572240d8c", + "src/ui/Gen3BagMenu.lua": "98c15a587096e342f052605dc2dac3b03ebb420b39812dfd9a45316fc7701bae", + "src/ui/Gen3BoxMenu.lua": "b56c3829b9f82d4c01d278ef6745e8cec3a3cc7ce111151ecf5c3db0c72478fd", + "tools/low-tier-check/main.lua": "79600f553d50494af156caeb3351448180b8e869281f1b60e55c65c5cf42472b", + "tools/low-tier-check/conf.lua": "322a7089a908d54dad4c6b13ccad4fec62400433c436e54eed401930ada7e44b" + }, + "playerProfilesUsed": false + }, + "forkOpenGL": { + "scope": "Actual bag, box and textbox draw producers with owned artwork; CPU draw submission, no ROM or full-game FPS", + "renderer": "OpenGL", + "phases": [ + { + "warmupFrames": 120, + "meanMs": 0.6349649305555677, + "quadAllocations": 15480, + "mode": "legacy", + "frames": 120 + }, + { + "warmupFrames": 120, + "meanMs": 0.5209718749999889, + "quadAllocations": 0, + "mode": "cached", + "frames": 120 + } + ], + "checks": 6, + "sha256": { + "src/core/Performance.lua": "def2c6b3d5e93ce8a10c10f03b40f30ac05997a1330f3ed49b8d1395399b4877", + "src/render/UiQuad.lua": "8d7715e0bbfc21d2408c6652def9e01474a7dedcbc5bc9e15f0cf39b60aa73b3", + "src/render/TextBox.lua": "ba2c8e0203d6b039d88e3224186722c1065894488896f3dba39a9b4572240d8c", + "src/ui/Gen3BagMenu.lua": "98c15a587096e342f052605dc2dac3b03ebb420b39812dfd9a45316fc7701bae", + "src/ui/Gen3BoxMenu.lua": "b56c3829b9f82d4c01d278ef6745e8cec3a3cc7ce111151ecf5c3db0c72478fd", + "tools/low-tier-check/main.lua": "79600f553d50494af156caeb3351448180b8e869281f1b60e55c65c5cf42472b", + "tools/low-tier-check/conf.lua": "322a7089a908d54dad4c6b13ccad4fec62400433c436e54eed401930ada7e44b" + }, + "playerProfilesUsed": false + }, + "publicMetal": { + "checks": 6, + "scope": "Actual bag, box and textbox draw producers with owned artwork; CPU draw submission, no ROM or full-game FPS", + "renderer": "Metal", + "phases": [ + { + "quadAllocations": 15480, + "frames": 120, + "mode": "legacy", + "warmupFrames": 120, + "meanMs": 0.38583715277779407 + }, + { + "quadAllocations": 0, + "frames": 120, + "mode": "cached", + "warmupFrames": 120, + "meanMs": 0.22241388888885977 + } + ], + "sha256": { + "src/core/Performance.lua": "def2c6b3d5e93ce8a10c10f03b40f30ac05997a1330f3ed49b8d1395399b4877", + "src/render/UiQuad.lua": "8d7715e0bbfc21d2408c6652def9e01474a7dedcbc5bc9e15f0cf39b60aa73b3", + "src/render/TextBox.lua": "6ccdc8b5b4622502e3b3036a935d98f9e2ca14fbb37c0d92a0ffe7a2920732e2", + "src/ui/Gen3BagMenu.lua": "98c15a587096e342f052605dc2dac3b03ebb420b39812dfd9a45316fc7701bae", + "src/ui/Gen3BoxMenu.lua": "b56c3829b9f82d4c01d278ef6745e8cec3a3cc7ce111151ecf5c3db0c72478fd", + "tools/low-tier-check/main.lua": "79600f553d50494af156caeb3351448180b8e869281f1b60e55c65c5cf42472b", + "tools/low-tier-check/conf.lua": "322a7089a908d54dad4c6b13ccad4fec62400433c436e54eed401930ada7e44b" + }, + "playerProfilesUsed": false + } + }, + "acceptance": { + "enginePassed": 167, + "engineSuites": 178, + "baselinePassed": 166, + "baselineSuites": 177, + "inheritedFailingSuites": 11, + "unchangedFailureLines": 29, + "baselineCommit": "3bd33cb3fd2a813ff9cd7c6289f7e89fc8bc5b48", + "sdkSuites": 3, + "galleryChecks": 253, + "api3Comparisons": 48, + "isolatedApi3Workers": 96, + "externalFixtureEntrypoints": 17, + "externalFixturePhase": "complete", + "focusedChecks": 86, + "head": "455f911e81a1a176615578935e5e430f77275ab2", + "exitCodes": { + "engine": 1, + "sdk": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + } + } +} diff --git a/docs/qa/performance-full-game-p1-p9.json b/docs/qa/performance-full-game-p1-p9.json new file mode 100644 index 000000000..3d481540e --- /dev/null +++ b/docs/qa/performance-full-game-p1-p9.json @@ -0,0 +1,5194 @@ +{ + "schema": 1, + "date": "2026-10-26", + "tasks": [ + "A1", + "A2", + "M1", + "M2", + "M3", + "M4", + "M5", + "M6", + "M7", + "M8", + "P1", + "P2", + "P3", + "P4", + "P5", + "P6", + "P7", + "P8", + "P9" + ], + "forkAcceptance": { + "head": "9532ee36ac914cbe0e5b0dcee19b085013bdb27c", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": 493, + "sdkSuites": 3, + "galleryChecks": 253, + "contractComparisons": 200, + "completeFixtures": 17 + }, + "publicPerformanceAcceptance": { + "head": "a620d0c761dcb7bd23df99e8e1ad3d45015f187e", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": 179, + "sdkSuites": 3, + "galleryChecks": 253, + "contractComparisons": 48, + "completeFixtures": 17 + }, + "reusedRepairPrs": [ + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 294 + ], + "metalFullGame": { + "schema": 1, + "sourceCommit": "79ff56cb4e471713d244255bbb64aa6512ec063a", + "romSha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7", + "backend": "metal", + "sourceSha256": { + "src/battle/UIVisibility.lua": "600197ada84c7fbd9bfa58fcdc221ccd38cce80f9e7043bc21ff683b53115ee0", + "src/battle/Gen5BallAnim.lua": "ceb53c18784778ff2c1991586bcd92335a30f9bf5c9c9f3452e3ea9330513569", + "src/battle/StatusHUDVisibility.lua": "b73ac63ea643d0e2ea937708478951da7148d21bc62e028ce27e7d16e3d612b1", + "src/battle/HeldItems.lua": "bde5344bba6956e248e79505976bfa71c5b7dc4a80447b8540fb1f0f12e694aa", + "src/battle/CatchTutorialDemo.lua": "b95dc2505c94b340f0c1a2f3d392668589a9d9d9d0498f15d3b6eb59b3a4d6ba", + "src/battle/BattleState.lua": "1e3595acf43a90ecd594b7b34047fde1474f1f996a713c8614cbc9d91a7f2171", + "src/battle/Gen5MoveRules.lua": "2ebe0de70ffbac510b54499273daabb3a79d70c299c79bf45b0209319aa2973a", + "src/battle/Gen5BagUse.lua": "0e18cdc0f7f7e1da16416800847b31e444bc029b303ed8ad9dc2083b24f62173", + "src/battle/Gen4ParticleSystem.lua": "105a8256ed5b5551d4f79f94c487c0b7dc69c5c6c200ac0a9fc86c73f5dc8eb2", + "src/battle/Experience.lua": "fd7ec2e6d5277aa0370440a07c6cb8f70dff5004b3df9722f08af9836abdd01e", + "src/battle/StatusRegistry.lua": "600832eb249b43c9b32a1f3f65170b2d9e2987db45703ffcf61f69e84fe831c4", + "src/battle/Gen4AnimMath.lua": "cd164c00821c9b0234858d92d1e974cff37e2496ec4055f8cef41638e0ddb53c", + "src/battle/Gen3Catching.lua": "399ce221feee2bf3cc81a54d4d33c135d9f9527b7a25b09478f2a657df32f128", + "src/battle/Gen3MoveAnim.lua": "e7a3435053b290ae630a9be13587251c26daabb1f3fdd37658e49e57993d3985", + "src/battle/Gen5BattleText.lua": "b7b238d4d48fbb97159f2dae12b1277340f0751c49065f2c0c65c3e27751bdd7", + "src/battle/Gen4Safari.lua": "303c46e10d53ac229b6543dd8b5fb091d66a33fb381f7b4fa53b2867cf23cc54", + "src/battle/MoveEffects.lua": "5dc6bb2796a9b1bb21e57ade45a48f82476df87f97e046a5ae68be48f9f0aac8", + "src/battle/Gen3BallAnim.lua": "a10f6b4bf16d4ee54904830886f115604575f5cda2f5fdee0c2593db1287b0f3", + "src/battle/Gen5BattleLayout.lua": "51195926a2d74680783f591a56e0cf0bf255e2625c32bcc69849dd6d11a5cf98", + "src/battle/Gen5BattleHud.lua": "46524b4552ab2c36671a134d7fad0919bda6f5722a207214a1ec6ea152372f38", + "src/battle/Gen3AI.lua": "2d944f72b2d6275319ff4c251794f029e629ad1397238ce2291da659ee33e0f2", + "src/battle/Catching.lua": "164cf1a6b00ad9958d83c1214f33b5781022ed8e75b29d8abe500f0e0322852f", + "src/battle/Abilities.lua": "53079a2b82727a9989820784f623a7a50b179be1409d34eee37c663a727934c2", + "src/battle/WideBattle.lua": "7e35a27babdc39e0428756687b2c1b64a8d0183cd501cdf31709c766bf9b09a1", + "src/battle/HoldItems.lua": "48887eeb4197c7217e55901e1f91fe2e4a87c415f0c5a6a5c36d6357e09b91a0", + "src/battle/MoveNavigation.lua": "c8e4fd7c551d30cd75970deed68cc5620dfe148c565c6bcd5067435ec8e4f8bf", + "src/battle/BattleCommands.lua": "00a4e73f863079cd9bee2d2dd744aab21b9d73be34223369dcee7c2af4a9751f", + "src/battle/Gen4EffectProjection.lua": "ba668d7eb6825cfdf0b34a269c9e76964b6ed794c4ee19e733f7e9a08e3c940a", + "src/battle/Gen4MoveAnimExtras.lua": "4ce596c6362b60804173e2d867311bcf7e9395217aed97d801a612d5740af0b1", + "src/battle/Gen5Catching.lua": "d9be0d52d2a53ad6802df83292bb935bfbb448bd14ee1af7d2301ae666ef7363", + "src/battle/Weather.lua": "7665d191df57714632dc83a29f6e966fa7b3fec41129a622ab3c23d931fec76d", + "src/battle/Damage.lua": "c55d7242134721caf6cca5be1c5fcf4c5c97e054f51727e9c6c00269c5c5dc58", + "src/battle/TurnOrder.lua": "f9c841ad20f22bc7eb828478bbff04fbe8e96ff692572c374d4902245a190a11", + "src/battle/Gen4Battle.lua": "7cd9bc8a24c04f013ddba6b2c42ff0e032f69ff7683badc960bc6f224984d506", + "src/battle/Gen4MoveAnimFuncsD.lua": "c5fad0ce87c3bca7f1e7180f849c0062d527cee3e1533d982a0de4e513e05e09", + "src/battle/Status.lua": "8692890ce34a182719059aa2212c6a52868b4e255bb16535a6f23f5ff45e7589", + "src/battle/Gen5Transform.lua": "fa4a03f55df9bbbda75848cc5113aa722b2cacf371ea6d33ae19c79bfa7ed86f", + "src/battle/Gen5BallArt.lua": "a8396ae0756aee84b3459bd671675e4d01f7fb83788aeaf8f6fdeb0c455d5049", + "src/battle/Targeting.lua": "1183c91394f2471ef7220d9be363d377f3b0e430c5a2b72ed15057ffd908df00", + "src/battle/Gen3Palace.lua": "38be0dedb25b586325345416717e2852375b89d7d80083b2d0c1d226e8493a09", + "src/battle/Gen2Wild.lua": "4eb811a4cd092abf7172ad636c42634343913e751a4b340300f12561937cc10e", + "src/battle/Gen4MoveAnimFuncsA.lua": "a9b3c80c1ed811a78e511915f97d1895696ed8fb624fc554a80d64cf1e36e33a", + "src/battle/Gen5HoldItems.lua": "02bb95f906c6c73ae1310d5c58690f2f1a71e95b4235283914038185ed250d87", + "src/battle/Gen4MoveAnimFuncsC.lua": "dca77cbaab4c992279ce088d6d813de96262743540461a768c2f88966c8147a1", + "src/battle/Gen4MoveAnimFuncsB.lua": "a4eefe96e3cb5fb835e4f16f7e5898762a54a85b281b75f10cf88da756e98525", + "src/battle/MenuAuxiliary.lua": "beccc76f06351d49fe8f1e1eff5895776022b2bf4dea1c13c398d8d55aa667bc", + "src/battle/Gen4BallAnim.lua": "4a74e0e38c2ac001bf9d92ddd105d5e01b8438c4f4cf086c1c7049751d1771c2", + "src/battle/TrainerPreparation.lua": "1b87ea363dada79c0a98ae3c428ded4d41c0f629d0086d750a3d3f0bfe6ac25b", + "src/battle/Gen5MoveAnimPlayer.lua": "5ada5221eb4fb9240d7841cd8471fe28ff15271146009b80b56a73f68d5e0b25", + "src/battle/Gen5MoveEffects.lua": "d04d5d17f2a4baff753c3aceb0f6db21b4a508c40c5b81a677fbd2792887f7e4", + "src/battle/Gen4Catching.lua": "a15ad440bf7ead2b5a66601d45254bd6b5ff4980ba349a82b3466d380a55a2de", + "src/battle/Gen4PalParkBattle.lua": "4d977d8821bf077706036e1bd70992261815dc1125c1b56a6c61b291aa56f40b", + "src/battle/PlayerPartyScope.lua": "61809f0bf8174d54b1e05a555a7fd3163ebfbc56b47a1c2886f7d6be813865a7", + "src/battle/EffectRegistry.lua": "d93b6be875e51d72b117b314abe796953c7f7e901787dda6d500dfb2ef3575fd", + "src/battle/Gen3Arena.lua": "88869a0930196459bcd397d4a8abc8683ed9c9e562e697815d9f1627911d323e", + "src/battle/TrainerAI.lua": "3b63e78374ca6fa67e94f16efaac69f474719533aed24c6ac1e992b7bed32225", + "src/battle/Gen3Battle.lua": "36ba133fb7958e3b34f19c1bf1b0bfc6c34b9a7e8947e6f5fcf9af3545083e79", + "src/battle/Gen4MoveAnimPlayer.lua": "9497c82bd1d4cdd37605bbcf33a68d84bbd6899702d105a3504eaf2a7691ee32", + "src/battle/Gen3MoveEffects.lua": "5e928cd27a981ef6b329dc57139b4ccee290ed16abf5e7f61b1a72206c8e92ef", + "src/battle/RulesetDefaults.lua": "bcae500de922d091bfb82ef019d9f0b1d8367947405f68e92d467f76b15b0c67", + "src/battle/AllyAI.lua": "020bda4fe688b6eb1ef4d3c3049ec442f568f05ec3ac499f59171248ea78c5c8", + "src/battle/TypeChart.lua": "c5590e777cda7866ad0c2f72ca07adbafadf86c72d93152f18ea855c74a76e18", + "src/battle/Gen2AnimPlayer.lua": "6649cb41ff65a36e922d4393193f74c22d9a51d560a007a769ad7ff2b00c7a7f", + "src/battle/AnimPlayer.lua": "1d661eb11bef56bee1397c289945f2cf3c4b6b4f93f4ddb9af09ee4e30ff7d64", + "src/battle/rulesets/gen5_bw.lua": "7097df1ebc7acba315ff2d846dcf9546896cec6cf009b980c4fce949fb72c790", + "src/battle/rulesets/gen4_platinum.lua": "d9c3cf93ff2dce9999042b14e406545ab7a15ee99f641678580462bd0a0aa249", + "src/battle/rulesets/gen3_emerald.lua": "949b57868329802122f24c311e7bf767f2c08b7a180ea2dd27e658d26bdf5749", + "src/battle/rulesets/gen1_faithful.lua": "ac29b54316cdda6f9cd0ba1fc1171cf33f0fde89a11a9c4c81dfcd3d6a861ab7", + "src/battle/rulesets/modern_clean.lua": "6214b30d23d4d694b40e403ed7d421bdd56c55013ab7c5568adb210867a905df", + "src/ui/EvolutionState.lua": "0d1440ab94daf5d6e5034c5a10a5efbbaf631e0b7938c6737a71e6b5d1034741", + "src/ui/Gen4ContestArt.lua": "e63767ea7fc3d86821cf280e0a5fcf7aeeddd838a8cbb198db14d1802f49cc49", + "src/ui/LauncherSecondScreen.lua": "9314468d38ca5414aef5d13cf65ddaa5509db8fd6ecb9ec8c66af87b6bedd49e", + "src/ui/LevelDisplay.lua": "3a410d4015a90f47a7b3c12268717e4d37e5bd8fdc5dd7339ecfe6de45502f05", + "src/ui/HgssClock.lua": "cd1395b2167b443e05c1f02897dd34a456e3c1be5a2effc39ccfb7de4924f25f", + "src/ui/Gen3StorageMenu.lua": "90ad2a4a68aeb03514a9d834f57e586efd88eed0205829ddd39ca41bb9a6573e", + "src/ui/Gen4SubMenuCursor.lua": "ad1cd35235513699dbc9ff258f0a25bf0122ee44012263037c62b58452ead8ff", + "src/ui/Gen4TrainerCard.lua": "f071467b45c87a79b0050f02ee3ad7226f0d16b7e3a2c725d70360e734943437", + "src/ui/Gen4CastleScreen.lua": "9ca25fd73061211055d0b44599084402c032bca6817c39e751253c83b2332e55", + "src/ui/HgssPokeathlonMedalList.lua": "b34eabb8a8f87e56b4e2ebb81e1338c9eecc156c9a7fb7b3b3e8aba053d89c4e", + "src/ui/HgssGearRadio.lua": "b6fcaaaefccf1885ba0b4ec9b88ef30b5073a449290936d1795140ebb0ed0c39", + "src/ui/Gen4EggHatchState.lua": "1f5f6e00c28f46a63184d2228a3ac2d03500ac22dc994b31e5cf46498beb2aaa", + "src/ui/HgssPokegear.lua": "80fdd1c0df89da536986a644c1c219451ccd48305ef7ae20a4cf27201af97e30", + "src/ui/Gen4MailView.lua": "54ea34b4e14e7973672555dba79601b582fb32b968ea6f475c725add301a824a", + "src/ui/Gen3ShopMenu.lua": "c6ae370e3ca1f057c1d7fc65d0f1a05c0d7199a6a852d061dd356c60935917ee", + "src/ui/Gen5Pokedex.lua": "a1f45d413732ad9abe900de04bbc38f45c0f5ac88779809db0a5352634d7ea34", + "src/ui/Gen4StorageMenu.lua": "faa6814972236d979889d70bee12fa98ecf4930a9b0e1dd84b3a4a67aaa8297b", + "src/ui/HgssDexData.lua": "6504809cb4f78ebda7891a1911ad64a9c97706d68941e3bb1a33dcea17e47b58", + "src/ui/TradeAnimKit.lua": "af4b12d22a1fca3e6fc760f348eadde155b82cabddb68ead012bdff5b04ac004", + "src/ui/Gen2Intro.lua": "79aa8477682ede51840cb5bfe5f1d86eedc09eb44a6e4768c87304fe67903a20", + "src/ui/Gen3Intro.lua": "7fc072529e039f498f6c998acfe9bf6a69246b502f7dbf2798233e085ee27ae2", + "src/ui/PokegearMenu.lua": "67ca68a227f1d5fec3382828ae8a430ae05f9eea7a832fed47c9a2b96886cdbf", + "src/ui/QuarantineReport.lua": "6f7295cbee9e45eba8fe257a4ad41f2bd6e4656f90d6c89f9fb1b87ea7421d0e", + "src/ui/PartyMenu.lua": "f0af6351146638a5a03cf3a00550d05dddfb89a0326ab52540c41935454c16c7", + "src/ui/Gen5XtransceiverCall.lua": "f68dec9948b07012936d9f9096089dbd0ac1a224070f18e032b85a5edba4511d", + "src/ui/HgssPokeathlonCountdown.lua": "3bf025bdb835838191451acb6a142f59384cc0fe44ea4588ccc7e4f3fc750d97", + "src/ui/Gen3TrainerCard.lua": "042fdba44664419e29f9ab60791c032540e61f77affcdc8325746a5b3f320dc9", + "src/ui/Gen3Wide.lua": "0d7af8363f415c6fd1abd7433f637e380de715fa7da53883970b8de29ba28c6a", + "src/ui/Gen5Opening.lua": "d93af8c0b8a353beec7137b91c451e5d5344410fd20c50d20b7bc06441e01011", + "src/ui/Gen2Slots.lua": "bee3134ed204d4d960f377fde0830438768e6ca99d7c8fefc668ab51db19b11c", + "src/ui/Gen3Slots.lua": "48af28af17389ed498c3b162eff8492c942617c9595b89bccc27c36b993dc9e3", + "src/ui/PlayerPC.lua": "19c8f6725a2c9c64e205a38b5478e88c9516095c4b6f262a8faa3c6697fd9346", + "src/ui/HgssBoxMenu.lua": "2ee8db418f55033f2dada3032e3b3549432f434d1bc32aab455d7cbecfe3a95a", + "src/ui/Gen3ItemMenu.lua": "30e406a301534c99a90ca609a3f92459e08c266ac23da7be88cd826e9ab613f5", + "src/ui/HallOfFame.lua": "34fe33aca758ce93eb3c03dcb0beadc09cad9ae8f4e83409f3a491870e1e0ebf", + "src/ui/Gen4TownMap.lua": "c03e2ff69c97cefcdd7fb975fe21ee1a6a7984c1561b77bff7a85ba6a352ebb0", + "src/ui/TownMap.lua": "065a23f2af1d921f9381d10f8d682f46f72fb528b8a3de7a089389331e435294", + "src/ui/Gen4PcHallOfFame.lua": "79bd36a93761528c51f01f9515064fc187f703885de24934a19186897d6d5e24", + "src/ui/Gen3FactoryScreen.lua": "e1e9988116bb329f0a47055883be9d31baed6db7eed6e17c0d04ac135d2ec205", + "src/ui/Gen3BagMenu.lua": "98c15a587096e342f052605dc2dac3b03ebb420b39812dfd9a45316fc7701bae", + "src/ui/NamingScreen.lua": "b6252722e9fe04c53ab26889c8ab9667c1f39b9bb3e0c8d432547e583ddf2f3e", + "src/ui/Diploma.lua": "cdce8ff10af233319bdc972d380c3409bfc753d8a27a9dffa1ed6a732711345c", + "src/ui/Gen3ItemPcSubmenu.lua": "d7b31e9b49c1bf8df3ccc978ca43c518fcc6bdc1be5e6ac0aabd85b10fc3e8b1", + "src/ui/Gen3IntroFRLG.lua": "ed6ff6e71f28623baaa596022ea0b24c1d931d6f9e6e1be5cd0a8668ebd51d30", + "src/ui/HgssTrainerCard.lua": "d3bf771c1960a9c9cbc2850731d6d8a9e2682a38c9467f65905d41c5d413c789", + "src/ui/HgssPokeathlonEventRecord.lua": "9f5c010a7e28096b662504e2d496a436daed99f15a0b12d91b7e0de243555650", + "src/ui/Gen5Title.lua": "080b218c14ea20296ec364f8ac2b781b84f1d2bd3ae740caa315af641915f66c", + "src/ui/Gen4Title.lua": "5432cfff6858b836f2cfabe0420d86163aece29cf7a30ad810d4fd6318c634e2", + "src/ui/Gen5TradeSequence.lua": "55fe6bc6cb426da9f40894f58fe3120a6db6957eba9a9d3d596fe1ea4ba8403f", + "src/ui/Gen4DistortionWarp.lua": "26d51974edca157b74ac369de3733321458a2ac8049fa0098fcbeb2a0a0bae2c", + "src/ui/Gen4UndergroundMenu.lua": "67fe58f627c3595218e96dc727c49fbec9e1fbb6cdb67b80b7a9493ee042afd8", + "src/ui/MoneyEntry.lua": "681af23091010c5b803c752a6645ffc54e553a44551b288fc3cd06d6c5f80fb4", + "src/ui/Screens.lua": "43b8487dd4ab6a1bf571e7c6ee724ba8c33963f1a163e5d2593ea9094dd2b2c2", + "src/ui/Gen3DexEntry.lua": "b7d1e489b282c2aaf982864f016754808c1a152c695a18f829d09edeb7bab440", + "src/ui/Gen5MusicalStage.lua": "c48e8f742a1a11fa112ee1914b5455dd3ebba66f02c921808c46de9ea4732525", + "src/ui/HgssOakIntro.lua": "a3ee433ae3691c77cea5e64631dc144501e63d567e86c91b904180fb0f10c47a", + "src/ui/PrismMemoryGame.lua": "b5ebf99db179d5d9f9ff4cf5eafb6e5265174f8c4664326f9969ae422316fd86", + "src/ui/StartMenu.lua": "c5b66a46b7a913cc7ae9529f41aba8097c29a7ba3f894c825aeb49a03a96475a", + "src/ui/PokedexMenu.lua": "317a6800ab386af0272ba43831424050d722f0ccb9732313b14ee41062162654", + "src/ui/HgssOpeningTimeline.lua": "5588442de6c5004a3953b7f74e112ada9ac2ee17b9fbf9c4bef99a23a12619e3", + "src/ui/Gen5NamingScreen.lua": "e9e495a34b409d704ee2a8a4bc930c9645737b8106f9078b0f27a2d6a65a6a80", + "src/ui/UnownPuzzle.lua": "11f334b82ca84053823c0da2aee45bcfaf20d18c7482465f44c5cf472f123d47", + "src/ui/Gen5TrainerCard.lua": "8349c6cb15e6ce4175c3cf0ae8b11cd7aa43b7c6cdf2e18d0b0df45e653246ca", + "src/ui/Gen4AccessoryShop.lua": "9c98c225665d1c381f27051cc946484776712ad85f43ca65ebd7766ac29ef8f6", + "src/ui/Gen3MoveLearnMenu.lua": "acc7bb74a9161cd734f03339b3819556a058d122f8dc9fe21e3ec8210c02b8b9", + "src/ui/Gen3DomeScreen.lua": "6d08a7facc76254c54f98aa503791b348938ea8f60af14fd079f1bd804e5ca7d", + "src/ui/HgssPokeathlonDataView.lua": "0de0aaf8a6dd6f6eaa4aca70e2c608eead049bb5de9911f58b2035129bdf3fac", + "src/ui/HgssSummaryMenu.lua": "d54024223eb2ad4985574f7e710e4c4969eee355c3a9a2dc1bc4f99662f33195", + "src/ui/HgssPokeathlonCirclePush.lua": "f2f7e6f12b0ef7a1e6d0f28cc64f0ee45720810576ba242f946ca2d877e1b890", + "src/ui/Gen5EggHatchState.lua": "4b6e79ed27e9ece3745bd6aea4d0e5adc6031d29ca9bfd3688f4b711ae3dbc43", + "src/ui/HgssPokeathlonLampJump.lua": "f17fc2d55819dcc3e048c881581810662c2f2a0c2c13e7a7388663e4cbfa6082", + "src/ui/Gen2CardFlip.lua": "70dd4c2feece53da7fce0eaf7d053da7db765cd79b454f532fd2efad5b9c85bb", + "src/ui/QuantityBox.lua": "f6f40911c453649c2c4fcf037549972e6a8437425c6f2e4eceda8cf945437597", + "src/ui/TradeAnim.lua": "5249809def089df91dd4ea0333bedf5071b022a635ad53db9ecb0ff106c9c551", + "src/ui/Gen3Roulette.lua": "4059523b8aa5507bba05a73501fb989c6d27b5607925e34f5b6f1c5766a93aad", + "src/ui/Gen3HallOfFameFRLG.lua": "74c6850229076e8ac4b7e4ecdb72e1c9c6def730c59a6948f3ccda2dc192d824", + "src/ui/HgssFlyMap.lua": "c0596df64cb61d1dafdba999b02d91a0cf70078d1f2164dfa9e04ecaddf8a3f3", + "src/ui/Gen4Migrate.lua": "327b2b0dce2697edf4cca4bbe7570d5e38bb4c0975b7091ba9123840425f0fff", + "src/ui/Gen5SummaryMenu.lua": "fbf5dad1baf126a76133f70c6a355bfbb7c7a9649e9246c0720767bd12991b6c", + "src/ui/Gen4HallTypeScreen.lua": "ea4d0fd087aa976b945b66662f032c4111291f20b64349dcb1d0e433e7f1509f", + "src/ui/Gen3Title.lua": "40996d8bb5d297969832a9321771972b4320f5e81799bfca82694bb66a229a1a", + "src/ui/Gen4MainMenu.lua": "a773f94cabb24b95d55b09c4401e86936c3e99fcde4ccd43e82892a472290fdb", + "src/ui/Gen4RowanIntro.lua": "682ef66ebca4df41ba1f128612d6541a24a63bd17582240d9ad31084baaea415", + "src/ui/TouchControlsEditor.lua": "5552115f87de8c7db46990b86810b1a100229082ebdc218eec3ce74cd0919747", + "src/ui/SurfingMinigame.lua": "494556db226f16e7d2fcee7c6e77d33f9e56b63333dcc8b3acce48466c476479", + "src/ui/HgssGearConfig.lua": "4be091831bba6af667874f10e9d37d70ab181e99c953cd28a61f95de6a851e90", + "src/ui/Gen4BagMenu.lua": "c4811c441252f15be357afa7d91cd9ca9548e6f5d6f5794de1bde541e9bf334b", + "src/ui/Gen4Poketch.lua": "f9f138777360c551a99e0be919b538cef8daa47473a45535536ded80c5965587", + "src/ui/Theme.lua": "cf3bbc6fe503972806430559c65fc98ef087993c98c046781daf190491b90969", + "src/ui/Gen3PokedexFRLG.lua": "c8c08bb23ea345f5bd0c49744b8b8432fa5160460dea7208a01928c6959ae440", + "src/ui/Gen3ItemPcFRLG.lua": "81397c395694d0b30e589123231db80a24eda784c4d3cdbbeadd81271651d9b8", + "src/ui/Gen4NamingScreen.lua": "778261bd7882d85eec2de1f9883961c525b0be78ad491583615c6a13de23225e", + "src/ui/WalkerEconomyPages.lua": "576f9f139896b3f1d4898f4801c64b64a3cba7ff7568cefbc3290027f8e5e810", + "src/ui/WalkerEventsPage.lua": "54909c9deca7badd0c080edcef74c86721a9a29a51356d5f7063c4e18d153653", + "src/ui/Gen3WallClock.lua": "b4054a21c8f34bbdbf784f1f8c336025fdb5453f0226e823e6a064ab0a659d1c", + "src/ui/HgssPhoneCall.lua": "0fa4b0e2657adac22750ce37ef9baffcb63404f2191188a78ecb1a819d2f9e57", + "src/ui/Gen2DecorationMenu.lua": "a807dbf7d58174bc51685df2b25f1abb1e44b88573848f53920906d562b4805f", + "src/ui/HgssDexDetails.lua": "96d15c04814e10f434fc7dfbc705b6cc543e59312cc5152c83cb4a99a440e999", + "src/ui/BagMenu.lua": "b7f6d8d9e9ccf739039d97f430760d0c25adbdca8ab3b61e63e42391c5650436", + "src/ui/HgssPokedex.lua": "c514532e2ccbf8cdae9173876a45108fa495fc3f0db7283c9e5f3a9d19bed6d0", + "src/ui/HgssTouchList.lua": "ee17e99118c43c6eb1b7826df561560d7326460a7bae92835a3115bfbd94d1f3", + "src/ui/IntroMovie.lua": "e06835504e3a20fe620c3a6b47fc996ac23a97da0ab94888236313909e12689d", + "src/ui/Gen4SummaryMenu.lua": "37f12989318534e4aee26bcadb21b4556df5e461d20893a0b25cb3f54fd22cf0", + "src/ui/HgssPhoneOutgoing.lua": "c776ee4325d546eec96ea950a8ca1523eab7dcc1d8a0ad373a6b3ad678b4a5ae", + "src/ui/ShopMenu.lua": "729bb3cd5162b07fe8490cb4f3662171e0d3b7edfbd54172434818f00e3cb6cf", + "src/ui/HgssTitleCamera.lua": "2022ed24115487166933271046d869c6cf45eca9c6ea29bb65cf3dcbd6240560", + "src/ui/HgssOpeningCities.lua": "a6067716772f446aecdc9974bc4da0876a23f1e3916d74e86e7f6103f387c6d6", + "src/ui/UnownWall.lua": "0148d186cc6c68e87f450cc5b89cb812d2e51a1fe46a42ac9a4ca5ad8507f1bd", + "src/ui/Gen5Intro.lua": "88a8968585b5cecbce0081610d0cbd3bcd6c3ec196ed8058257b6278ba024434", + "src/ui/Gen4HgssStarterSelect.lua": "e928b2262919ebc409510144b4cf8277d0ef92c200fce6d7b5216066ab756db9", + "src/ui/Gen4Intro.lua": "73b347c90f7dad958074ff87f48eddcb39f4add8b6f3ba47b9ff0d14e49a2713", + "src/ui/Gen4ArcadeScreen.lua": "33f34445512018d29dfb3c371836f5e1c26d3ee4f5165a0eaff09926ca1f1e8c", + "src/ui/PokedexUnownMode.lua": "36cf47201b7ec8d636a01c04deb37d0185a73329dc3549683317e38d20d46ea0", + "src/ui/Gen4BattleParty.lua": "35bbe52afd1782cd22bf1f39358d23f8cea8079033f5b096a8fd1eb8a80c16bb", + "src/ui/HgssFieldPanel.lua": "0f29dcd4d6a9d07465df9a3f14e00bf02aded5fbe59c9351bdf37d9bde335a89", + "src/ui/Gen3TeachyTV.lua": "0f84cf2d1d178a93935c6254daf11bda35cffd460e10814799e50284a0f8c433", + "src/ui/HgssPennantCapture.lua": "8361b8341bc2dbad5b37c82f4a4ea2f5b2808c891055f81070776077265f7536", + "src/ui/Gen3StarterSelect.lua": "02b74396e7bfc4edcf77e5abb61a12a35cf4ba2799fa846604743e1f9c6f4771", + "src/ui/Gen3EasyChat.lua": "c1295899b07604d02db1187a8efebc188ee83b0c0c9c79c6f52a6b51755122e4", + "src/ui/Gen4MiningScreen.lua": "88ec4922a3f31cc9749ab3a72162af291b75456d62d3c5e62c546840a3b545b0", + "src/ui/HgssOpeningMontage.lua": "aba21294f98a70cdd00a2ce7d42b40d52b96c910787c71877cd80a211946ee85", + "src/ui/Gen3SummaryMenu.lua": "179ce00d6c8fb7571327c799b13a1343f578aea64f891d7e146ad71d6f06b1d5", + "src/ui/HgssPokeathlonTeamInfo.lua": "205bde0fc63eda97f9e11cc61dc0f7383774de0c22bb8e32026da4f7e041771b", + "src/ui/Gen3PlayerPC.lua": "d04b3ae8b394b2e49cc83d87aa1f150348d2eeb1a687424eb89bb38c4e8f46f3", + "src/ui/Gen3Pokenav.lua": "1aa5c7c54256adc42d31f79da0c08de9370152e25e87f7eba86022bef4dabeda", + "src/ui/Gen5BoxMenu.lua": "77252728f818bc6ed6f4be89d41e44a82ee387c941786b505e6e1245e179bff5", + "src/ui/HgssPokeathlonRelayRun.lua": "7ad08a8b8d1277c5561db5065e87e74a485659f442ba0d0d6612a99968094813", + "src/ui/Gen4TradeSequence.lua": "4dff0507617b86fe93d5bf343c85c13dca9e6d1cd566d3207f2a653329aa0930", + "src/ui/Gen4RibbonData.lua": "c578bb1a0d7f028d8349e569ff1b3d7169329f53275905d6b3da10630ff7bac0", + "src/ui/Gen4ContestScreen.lua": "e0aa401a163c9ef1ae4dd7bd3f87a36e4ec523ff5b525ec5bfab84e148147878", + "src/ui/Gen3ItemPcQuantity.lua": "833ee03d79a95bc26375f0ed33508405f9b6a8f1d0bf19dde6697ca835a76f91", + "src/ui/ShaderPresetMenu.lua": "18792a77d28d91019bc9ca148e1a3625d04ac521065f6d0adf1c6b3ce4659adb", + "src/ui/HgssPokeathlonGoalRoll.lua": "8a199944077bc47a2b89716ef2e82381adb41748111824c3021c96917fb4705f", + "src/ui/Gen4Options.lua": "be509ee4d9f3985940032c848696c623007f20375661d05e7457bf16e1f3bb0d", + "src/ui/Gen4EvolutionState.lua": "46e260b5b90bb3ecf0ecd773c7568f1eeb5b0c70febd7a6b624b895b2f1d61db", + "src/ui/Gen3Credits.lua": "c9764cc224fa9a291e52d5073baa326c3e7ecd9763d140c0acba8e5bd57ce385", + "src/ui/HgssDexSearch.lua": "a3423bfcab8b612bb0b7590f2df9844738b415186b2b3314116467e2103c88a8", + "src/ui/Gen4PartyText.lua": "510c8adbced81f4f90598f838c36dd7097d8326dc492246ebe0085f319da6de3", + "src/ui/Gen5MusicalArt.lua": "82a186f3777d9dd7f586002dfee6d22fa1fe314edb73fa60143b242960569b00", + "src/ui/Menu.lua": "863e4207239a41021e339844c142011c7d8844f319bec8a29fe299d112cdabb3", + "src/ui/HgssAlphPuzzle.lua": "8e34cb25680bff6a4a1b3be66048de045f16ee3ff55c6f2dd6156e28656804a8", + "src/ui/Gen2MapRadio.lua": "b782279704778a43f62483dee65a9cbce24db3219fd4ea08919eb87d73ba2614", + "src/ui/Gen3BerryPouch.lua": "92fc90d00cbe34f24a503bd3f6aea7ee2e97a1c8a9e13d93facd445d74f8189f", + "src/ui/HgssTitle.lua": "5275939e552d7c3325a909b6e741ba3757fb2349ca41384b7dfe30727ac2187b", + "src/ui/HgssBagMenu.lua": "1d5413f9a42bb3934939b49a7e3671f92af1f0fa0b8deac32909051dde6e218e", + "src/ui/Gen3TMCase.lua": "7e992d68db63ae9e811378b2624e74d4ca9ddb93372bdffeea595fec52d27fec", + "src/ui/Gen4BattleBag.lua": "8d2cdd45d191935da322c3d04db2c443f1087d449ed550a5ae49d331a3e1800a", + "src/ui/Gen2TradeAnim.lua": "3c931b12efd082a03ce3529641db5007843846f3927d8250660c24c4c0e85f28", + "src/ui/Gen4StarterSelect.lua": "c8634bef9ea6f94bfecc0992605afacf462c5c38c75d1fb0767c1adf63047e65", + "src/ui/HgssOpeningMontageView.lua": "87fd48ecd297830ed04164f9985adab41294c060b5c0e7477dd60c78cafe29e0", + "src/ui/OakSpeech.lua": "c16a09eeb311b5179365dde8453a0ce9d99fb53617ff9c6e049202ec5b6b8668", + "src/ui/HgssCertificate.lua": "6d82df59254068fbe2174ffe2765e32436fb7e7cb7d9db8aef81447a50d42f85", + "src/ui/Gen4MoneyWindow.lua": "e9e1a3bcb9667c42fbfc62e51150e0475e87d1e5886686300091cbdf86ce0e29", + "src/ui/Gen3MainMenu.lua": "ac18f602a36940e86d75df9a532775a4e041c946a1e9dfe2aff868e17330dd50", + "src/ui/Gen3BoxMenu.lua": "b56c3829b9f82d4c01d278ef6745e8cec3a3cc7ce111151ecf5c3db0c72478fd", + "src/ui/OptionsMenu.lua": "c86b5a0fd748ca23388c92f2a17cb0f1af9cd30c89ee97c00d2471f7397f1980", + "src/ui/Gen3Cutscene.lua": "594924b8c77189f20893859abd50f1b57f254230c021b7d1a45ce34a3931e2fc", + "src/ui/PokewalkerScreen.lua": "28e71f44cd8f612502cf0586cc5f45bf8b9603f2e76b3696f6f937bd3e17e556", + "src/ui/PicBox.lua": "94b34c0d6294a556905e1cdc50677430d3f21e2d306c0998003c6bb7b4a2c7e7", + "src/ui/Gen3Contest.lua": "efc798dac760c3dc68b5cb4e92df47d7fcd343ff2f85589d9b250040b4a755c9", + "src/ui/Gen5EvolutionState.lua": "0d5a71bfa8f5ebd1212499ae6f2dac1a4c7d61da5fb4829f6d432ff4b590c357", + "src/ui/Gen4Pokedex.lua": "a6b73daa89b1ded03be44882fb95ebaa47eba0fbcdf82e8a038a682234b2b238", + "src/ui/WalkerMinigames.lua": "b970ad35e8cb26f01d055e53989be0631013d5a02a4b4645688c47010fd30155", + "src/ui/Gen4ScratchOffCards.lua": "cde782d137ed00a1a7fb3d3a661eff2e7048741f860eda679efb364bb2b63a35", + "src/ui/Gen3DexSearch.lua": "841894ee565391b94cc14af24525a06f8c3f4bcd7b5feae53c448b150201e101", + "src/ui/ChoiceBox.lua": "1f60d3c77f444650e5a13f20f5339fd1e7769d8873ea9d562db0d43a17d52dc8", + "src/ui/Gen4DexSearch.lua": "3b76665a24e243d29294f83ac30d54739ba600d01778d788e13f2ced1e64df85", + "src/ui/HgssGearPhone.lua": "7c0ce609c686338bbabc039a03958bafc50d878048826c0596cf4e33b1aed7ad", + "src/ui/MoveLearnMenu.lua": "c40eb3652c5754ea1469089ef819bfef1d13ecacf5d24cc1337043fcf79362ed", + "src/ui/Gen5BattleBag.lua": "e16d1ae40cdeb6a0184e4f2e6c57405ffd66a178c35efc18d35d50e5a8b2d32a", + "src/ui/Gen4Opening.lua": "e0c971dec219ca967744e22a97d4992e9a2ee3c93ca6d7be09eeb5da3ecf92ca", + "src/ui/HgssPokeathlonCourse.lua": "3b20b79fb5addb8b3b500c31be6af41a1e47cd7e1089f74a726f906eb42b58f2", + "src/ui/Gen2ContestComparison.lua": "d8ab913d7dd49d99789ae731e2eb4233fa5065c0fe923254cfa36280a62892c0", + "src/ui/Gen3TradeAnim.lua": "8a919282d50e8578e3ae6b466c06289fd1973de0954f8528c3d70df279b10022", + "src/ui/HgssPokeathlonRoomView.lua": "641838be687f6267d759a8f3193b103493ff4d250df362ad31b2689f22c472e0", + "src/ui/Gen3CreditsFRLG.lua": "0bc37ab2fd7a98be57deb3bb770e88e72ad7c3726ac351b01c60948256561c30", + "src/ui/Gen5TownMap.lua": "dd2aba3972cc7172ff31dd589825ec455595d2f749d21d888def459132b363a6", + "src/ui/TrainerCard.lua": "48315148aaffc2c2c7c5c37e837eb9150f301c10a18cafe97fb954ea6b057d3e", + "src/ui/Gen3BerryBlender.lua": "6d60232e2fa1be575334bc7d071249efba807e3e0c0eed0e104e196b0a329d6d", + "src/ui/Gen5MusicalDressUp.lua": "65f583c2931a31b11488d198f57ecca0fe3e1b79c6334499d70422da49af6637", + "src/ui/Gen4WalkerConnect.lua": "10431e89b283ab18bc6c9a5e953672bd006b454401e8f888dc1abe3d06cfe7e3", + "src/ui/Gen3FRLGScenes.lua": "049dac8ba3b8217a4ff3cfdc3104a7ef15c304cef8cba1be626d25ffc00b7dc6", + "src/ui/Gen4Journal.lua": "5b57ba4ebf6bf180f80688190da4dfe887733ccb9c47604eb9af0e4e58955e98", + "src/ui/HgssMartBuy.lua": "e322ee49e0d2cc497fde12362e837b88a64efc081a2b0cb00f3c7bc70f50469a", + "src/ui/HgssPokeathlonBlockSmash.lua": "24dbb732fba05d7da848005419e9e94f11ec78e6622ce9e1da6e77562fbf7263", + "src/ui/HgssAlphInscription.lua": "12066f922807082b882ca9d39d80172cb9b0fe016133e9156e349cf00030f5b5", + "src/ui/ListMenu.lua": "24bac8ea7278f0b2da111717c21541618fc6165acbab4d38dc01c87d0da0781a", + "src/ui/HgssPokeathlonSnowThrow.lua": "1922c223e021690456f5134318d5927c6b435af395095e7d28595128622960f5", + "src/ui/HgssVoltorbFlip.lua": "fd4c10e13a96bdf85d84fa813521fba69d31f68a198ffd9f80ce8142d7e879b1", + "src/ui/FlyMenu.lua": "bdc11a42cdb0e32369fd4a0764baf2e73a6808f55cccbc23b17801b694287dfa", + "src/ui/Gen3StartMenu.lua": "d4ee792db03e79e6151d50b1d03370cf67a70f389fbf1798677f29da19c8dc96", + "src/ui/Credits.lua": "f720b9a78d52c4eef5319f8ffe1ef9f468689af8575ec6e7937c74bffff94599", + "src/ui/Gen4StartMenu.lua": "3934a213e7d86dfc9197a361ab39199d5632f4b2ee144e744ee80be3c3e88ea0", + "src/ui/HgssBoxMove.lua": "89b1065ab200574e26854e2bbb9cd1ebf6c68fb26b8be4bd7605f5e3e6d450e8", + "src/ui/PrismCustomization.lua": "f1e2d1e869519fc017826d3d618a95100509d609db049f43837b31f5a27e010a", + "src/ui/DexEntryMenu.lua": "9cffbceb3bab7b2bcde2bcc6dcc9d3d0a42b70fbfa5824c27916b20e87f01af8", + "src/ui/Gen3PokeblockCase.lua": "ebd540b1913d91ebc0ac4ef5417c98fc090f8ea9ca135cdc799d4b50d9586705", + "src/ui/Gen4HallOfFame.lua": "2980892573d8b4b92db9be17d406a599c0b71ffd88857534e672589f641825c4", + "src/ui/Gen4ContestActing.lua": "3f78727a609a283c497b91b7a60ec85bfe58daf9388b8d10e552d3b60c55fe6c", + "src/ui/Gen5StarterSelect.lua": "41c3b5db701dc3ab1ea77694bac71f408f7f5cd67bacc62225316c22a9bfdca6", + "src/ui/Gen4PoffinFeed.lua": "498ba053c674d5d0bea66834cca558f1d34cdfa504a4afb299518b5eeec80a13", + "src/ui/BindingsMenu.lua": "3b2858144fa31361f4db6db1175002a9f6fd80deafca1419b9eb2ec727b10cfb", + "src/ui/SummaryMenu.lua": "f014172b3ee93c93a732be9d03d52b25b6ab8591274241d7337f9abf70282fc9", + "src/ui/SecondScreen.lua": "a728bcd7ea1f823db411b88884d61bf7d1a531a78d6bd7f85991e28f2b6e6e90", + "src/ui/Gen4FrontierScene.lua": "9096b387eefc33d2d25ce6352e45c834a78c9f28b2dbfd6d29be99d4039901c2", + "src/ui/Gen3Pokedex.lua": "42acd8745692338b592b1306f7bb248d2e4053045798c9ed0403bfa313f1e62e", + "src/ui/Gen3RegionMap.lua": "0d2034ba087b0e015c50a32cf18cdc2893bdd8969573a55c6199e020327285a8", + "src/ui/Gen4BoxMenu.lua": "e50a3f05facc2a520fecdf2754aeebdf75f91f4156721821aee29c3588590df9", + "src/ui/Gen4ShardCostWindow.lua": "df42ea1017d2ea64ab025576e0e825a9a426980ad1d453afcb38d89baa848743", + "src/ui/HgssGearMap.lua": "c3f787f46d4200c0987c7b894858b3779d271e3af00c1503b52e4bf9d8eef26f", + "src/ui/HgssIntro.lua": "fcb139d8bbd971ef0847364a09a31cdc6743557becceb69f119f56d5b045c86a", + "src/ui/Gen3EvolutionState.lua": "1dc2114072edc6b75b6930a1765a68323aa6ead2e5b476f3e80d4df05bb636ec", + "src/ui/Gen5PokeTransferScreen.lua": "1aee38660616c9353ec5f0b998a7ff8b4223303f765372883adaec1c8ff65258", + "src/ui/HgssPokeathlonTrophies.lua": "f9fe63e173f238289fdc09f7f2ae92b1c0060d6cb46642ba32255fc43012b283", + "src/ui/Gen3ItemPcMessage.lua": "458a268dae7059403506829dd3c3caff4861806a7dc9bcdb1fd5123fd43e6713", + "src/ui/BirchSpeech.lua": "24e40adf075f0d2257e18e39f9df50f1535ca5576b348e1c9c3a03565e68358c", + "src/ui/UICommands.lua": "fa135cb2b1ebf141b090766f2f8c0d8b5c0bacc951e666ebd71326fe321952cd", + "src/ui/SlotMachine.lua": "0aceaee28a607742f64dc5fc64cac3850013891021d154b5ccf89cfa29125481", + "src/ui/Gen5StartMenu.lua": "9b3ce8251c6b4c87ffc523748a7aa31940d7a048bd745193f5d0acbf075917bf", + "src/ui/HgssPokeathlonRingDrop.lua": "6398b107500f3b811182cf058062527713b7d4d743a50dec59718e64754012b6", + "src/ui/SaveCompanion.lua": "1263bf267d2b1912cdaa773415a175467ed5e677405e55f4d13ddfaeb1ed0c3c", + "src/ui/HgssPokeathlonCourseRecord.lua": "d399e3907ad85afa8e681760fa9a54c39698ce72e53bb3cc26a30bd8f984abd0", + "src/ui/Gen3TitleFRLG.lua": "c46254e45b77a7ea3a229d8902653654598c1343d80e4714c5ce7b3ae73d22bf", + "src/ui/HgssPokeathlonDiscCatch.lua": "56a7b387ab2fb2d082e891cf094d4d4f54d3b2bb2e9a56a2354ad4677ec77ae4", + "src/ui/TitleState.lua": "106de4b502d05177332d09381e99c998ac04f09a7593df3e131061a5688959c3", + "src/ui/WalkerTransferPage.lua": "79073f02787b5db6037ddda9b02848d55b3324fe13a49355db717867da2bfc4b", + "src/ui/Gen5MoveLearnMenu.lua": "5c49f3787b2744d2ce3e6a4180f2107e7730f9d7cae93405c1e68d13e7c95388", + "src/ui/Gen3MysteryGift.lua": "c444c43813ee3a87d99d70bc690cb56e6b22933a823f555cdaf2525a7b9a70fa", + "src/ui/BoxMenu.lua": "191454836526c59348cb0e685798014f29c8317c550f0481eabaa25a614e5b4d", + "src/ui/OptionRows.lua": "56d4b42fd4b3f0fe41423bf38d86c5c9d1652df685017402cfee18a3999ab5cf", + "src/ui/Gen4PartyMenu.lua": "8772be0b466aaecd75c2c087e17d42bdb6f939b0c04350647cae870a6454be8b", + "src/ui/HgssStartMenu.lua": "1715b637d40196fc9ffc79ace82ad717c1d1ea15e15030eeaf163993ca594284", + "src/ui/Gen3PartyMenu.lua": "094dd19022c7ad14ff9a82b72edc4f8a2a044831417e7eb258c62a1f7d5f2ebf", + "src/ui/HgssOpeningStarters.lua": "b8b213b9199556d009d61cf05720b3649992203381ca05cc48ee4820f14fbf42", + "src/ui/Gen4PoffinCase.lua": "efc7a43abde953b18ebffc523d3e286b834c9e04eed390f2ae08f60b6683c94d", + "src/ui/Gen5JuniperIntro.lua": "64c8457d211b3bb1507fb852d2e5b03a137e2a7c54c8bf3bffcc1eb7921885d4", + "src/ui/Gen4PoffinCooking.lua": "b1dee6a913efa2a2ea553a4e7cd12667558c33301c0e85ad693a2c10f35dc9bd", + "src/ui/HgssHurdleDash.lua": "5a60939facafa40e714d44cc101b57657ed5a33aca91d8782a9700e98b1657c8", + "src/ui/Gen5MainMenu.lua": "83cbd10e1506395cf85b4617e3be2a1dd814deff18e93382d9b042b337c42b0b", + "src/ui/Gen3PokeblockFeed.lua": "293f097cee17664ad804d221e670dc19fe2638d2eec21646817d7782be4c56f0", + "src/ui/Gen4ContestDance.lua": "720bbfa3ff782d5ffa3fd0b2abdf8ab9e2072e1f6eab811bae938c7b88bc0bdc", + "src/ui/Gen3OakSpeechFRLG.lua": "0baca0b700fe0eada7757e3d37f877d4f9b45e3b0ab8dc44ee4031dfd93c9c62", + "src/ui/PartyNavigation.lua": "836b85304e7a179874b9a11a4f56f1953229a9a09234c8636333b0da2dc57cfb", + "src/ui/Gen4Credits.lua": "a2046fd3d165edb8cb6c77f93f9755f457d792746737c8cb64c129b342364e3f", + "src/ui/Gen3Options.lua": "914cd7abb3464cb5eeb10927ce047091885ce100f26b86260b1679ee4ba2bde4", + "src/ui/Gen3HallOfFame.lua": "c9d3deebef87f988068f5bc5daa9c4992502548493606f0862ac7ef81a34f82d", + "src/ui/ModUI.lua": "931c6df3e57da87cc76ff39c84ce3d672dc763d7a612c970120ca8dbcd62baf9", + "src/ui/HgssAthleteShop.lua": "c9a06c6ceab242a11145fc8028a0e4ea9d7aa38ccbb05b3aadfb42a46bb05a01", + "src/ui/Gen4ShopMenu.lua": "caebbfa8806a310cc41c8928916f3cc8e7749acdef1bedcf01235e03569fa12b", + "src/ui/YellowIntro.lua": "c7851836241741a65832095bd238029c47fdc5ecdc6697db48b954dfbaa4811a", + "src/ui/Gen5BagMenu.lua": "e6cc1316c29fc47f941bb3b8cc66eba7d265f601df6bb603fa6788a3325dfcea", + "src/ui/Gen4ContestDressUp.lua": "88dbfac2f014fe9fb416d24d9ab3a6a1208c654cae68a0734b05d52201e349dc", + "src/save_convert/GenSave.lua": "63dca97d05aa168af19255314dce09c2faa1634d4c8ed24aa9706dc6ca707c27", + "src/save_convert/Gen4Save.lua": "2bb314f0d7d63cccc98770f124112f11047b44e2fb6ca90921a62a39009f06d0", + "src/save_convert/SaveConvert.lua": "b0e29d96689cba01676771dacfb8ae5e8253fb90dc2e8cb262837fe8f7bbadc6", + "src/save_convert/Gen5Save.lua": "a2c0ed364b2a65a52df1996a603dd2f7fc8a26d047c969f46837c64197e049b2", + "src/save_convert/SaveVariants.lua": "462aca96b045623e78b02f75d0e0206928ac186a14930f3d745af0477054d21d", + "src/save_convert/NdsSave.lua": "f0b4b1d0648510d88bf8edcc683a60d575332a05b73d4970fea305bb0f322376", + "src/save_convert/Gen3Save.lua": "53749665d5fdb324c346a783a9e5b1eadd0968ae89cbb069add000dd78c00f3c", + "src/save_convert/Gen2Save.lua": "b445a38b4cca31243f3688c09468e214dd54fbce16aea98fa049c1179fc33296", + "src/save_convert/data/event_flags.lua": "a3ba7b8a78ecd25544c9a7458490ac950522c5433d7be5a45ae2477bab047d82", + "src/save_convert/data/charmap.lua": "4493a302d288f3f58deb4f1ef6263e3f478b3cf48d397214d6b8f91714e53679", + "src/compat/gen2/Decorations.lua": "90ad63d1af5327db26ff6051177a5f1c9a0de3bfa9b91cf037f2ffb67bed94bc", + "src/compat/gen2/Unown.lua": "9a7a279701aa93a65c7506880f0f0cd1337c9ec20d22f4ca778844094c8219f5", + "src/compat/game3/Pokemon.lua": "149cd6bf9c157211cc33873efd32b6b59e797443c55386e80dce2ef6eda873d4", + "src/compat/game3/Follower.lua": "be2802a3f6df2b8f1af7acdb1e06fb88613149432ecee2792baaace2ec64d438", + "src/compat/game3/Player.lua": "ab9dda23fc4a567e0709165543f372e7745e1d1c52dbfd7822f32fc49c05fbae", + "src/net/download_worker.lua": "c564e6c10d860ac80a1dda00b57f15aab2998c1b20d44a3c9c68634872c3dfa2", + "src/net/get_worker.lua": "ed10f3cfdcb4d6a1511895358560d9e0e4f917e633ff5df3759cba0db70f3d56", + "src/net/AsyncDownload.lua": "e4fc65812d3e2f10a42b525831f297ce600ba42a26a772d48bd3060cf3caf6eb", + "src/net/DownloadTransport.lua": "dc04ccebd710987804d99f000aa78a0457069004d61daa1c98b9697325f5b7cd", + "src/net/DownloadPublish.lua": "8f03058feb3ab8a8275727c9441f5da326733ec8617adebaa5511ad7c9b33d3e", + "src/net/BoundedHttp.lua": "65f4d828b8230facbb0af12fa35384a57fe2ae9325468d9c4398699cd9939516", + "src/net/Fetch.lua": "b24ae3e02f56d3101eb515d599c7afa4acf3b74cf3596fc0bada2d9c13378fdb", + "src/net/AsyncGet.lua": "50548cdb6b9a99af09d34164e558613ff27bcbe7acc49e8c783f936c0f783c01", + "src/core/SaveSafety.lua": "25cd1b2e41a0701791af7df3de374b1d2f2045b57ac78ca89aac20060ab0060d", + "src/core/FrameProfile.lua": "d4b78fee874116094b6c4eaf41a992c72c4a09b034455868d1df2aabc7fc83ff", + "src/core/Logger.lua": "5fda12d50031d1546bd3c139b6ca03c922b28df1355b2704d961141196e349b9", + "src/core/ModCompat.lua": "e9387e6e3785101785402b5336e473e15ac99a7150ebf047cae0c77015ef73a9", + "src/core/Game.lua": "38519435a2df34c1b2a421d390d15084bd664978973d3b0ca9c2047a17cda701", + "src/core/SwitchAudioCheck.lua": "237729a8ca83950763cad050657fea4a93dbdcb5d59bdc8b62f0af39d03bbcff", + "src/core/Timing.lua": "cb435e57baa9b31e4505b9e833fb437975b47ea121375d0d4370b12dd6b56828", + "src/core/FaithfulRes.lua": "0f83f5799c4266886b66dbd7ff72fdcf38ecb8a368a0a09bfdc72cde6f670e20", + "src/core/ChipSynth.lua": "ec359285be3e9bf4b015d2e3a9ff08527793719a489a89bdf2d1c6a0e4bac179", + "src/core/FrameCap.lua": "6fdbfb74b2bc6aa703c1bf459f5512dd24ecb10f790a25ce58b4533f89748985", + "src/core/M4ASynth.lua": "0d60dcd9cb91a44f40186b9f1eb55c28c400b28960f5a4a21eb19d19c3c10a77", + "src/core/FixedStep.lua": "8661197ec7c8b56249d6d22aa4a4450344bbbbfb0eaeae69ffce8598e80ce1fc", + "src/core/GameVersion.lua": "647f64abeeba74c4919988194dd72ad72bfa64b554d5ca5c35b45025467eaef4", + "src/core/TouchControls.lua": "74f9bd28f6ca9f15bff6f40e409d31d645bc24147139acfaa7b578f02b083d81", + "src/core/Music.lua": "349a213316df07dd293a272d0e91131438c68e2b18286310af755c4a962a6432", + "src/core/Platform.lua": "8004601cc72f53a3172ac5cbed60e7ba7eee2660b71a308409cf5b72ae730817", + "src/core/PlatformHooks.lua": "26b0400455351b5ab7a62ff07e87eadf4c7e9cc5404ac2bbfb7c244eba20de62", + "src/core/Sound.lua": "7473527636d2198916c16ee40322af12805439925dd4ae5b61f594d3190b4eec", + "src/core/GCFrameBudget.lua": "c04ebd1f405e9a44b2d4736bad879be18cd20cde8d6d6f7e21c1fb8289a1a6fa", + "src/core/Probe.lua": "6337c006fc69641a82365a59de0533a8c3387c4101d0365eda5d69a5659fd3a9", + "src/core/Strings.lua": "bac84f31a3936077eb33657d73860f70e1fb1c2b687eb8e3fff22e9121b12438", + "src/core/Version.lua": "7cedb981ac1af4d7a5be48760ec132e76ac05e4fa119b9fa51ca6652ebf30ff9", + "src/core/GenOptions.lua": "ed70d3e154c68ed1ce6bd27ceca6368f239b6a9f9ad1d8cee724907d8a5026b8", + "src/core/VideoMode.lua": "7c3ba205bf4c72b9c0e781ba83349730461084bd248b19d68e17414daef140ba", + "src/core/chip_worker.lua": "c58df4dc639d4d8427527fc1fac8671014ccadbca81238060183523f723eaf38", + "src/core/Data.lua": "46d654a0d75a63c78ccc93cf78f2190747c60b907f5edce58d6b9eb1ee1ad578", + "src/core/ChipAudio.lua": "e7e5cde5da08915386269450d822d027d17afab961b0db8e6016010d17717541", + "src/core/Frames.lua": "61a196f05bb3bd6f9e81f82932d7d255894cb5632049a74e54471736df6658b4", + "src/core/BootTrace.lua": "d452fa775acda243b88be3d81cac241cb5b64fd24d30e8ba7aaf2504c85e4a1f", + "src/core/WorkerFs.lua": "3371e316eefbda6d0c0853ff29d48a07fd0838801316cd72486dd3154e605bda", + "src/core/Gen4FieldBgm.lua": "1b9555acf9ae2be8fedf320d10c6a22e313ae8acf782298a957b61e679b87888", + "src/core/SaveSerializer.lua": "d514673f891d2b0031e9d3be1188f12251df38b174c2f1f2702d906b4c5a40f7", + "src/core/SaveData.lua": "0cba28aee3289d5365d0741a4f04037ecab09b2c0c0851703796e20d7a852b6d", + "src/core/PlaythroughIdentity.lua": "0684db29075e4da99cbd982b6774b5a6d64b6b08dd314c9df0cb327db5436e49", + "src/core/Input.lua": "9611aa6ee452f2fe8c375ca9e63c537142a4d835d5a1dd8361b4f417d1287946", + "src/core/HostShell.lua": "4d1040763ea11afcc34897c10e93338fde4e63f7b1465387ce91a8b1056728a5", + "src/core/Gen2Clock.lua": "c945b2e022ac474cbc89e738eac17c1cf12a1534e5355de857a0a4d39ff88954", + "src/core/Performance.lua": "def2c6b3d5e93ce8a10c10f03b40f30ac05997a1330f3ed49b8d1395399b4877", + "src/core/AudioPcm.lua": "02f0e7f94f2195e1044b3013762705f7a5e6fb8abf2ee292df2c2965e815848f", + "src/core/DiscordPresence.lua": "34e085bbdc684848456740ff161946cddc33937be2ec9539a2a0f84d190e68b6", + "src/core/SaveIdentity.lua": "c29abc5c2fbc2e2bd63f45bff26df0c8c165fa57079f1c2ba5d3280e8301075f", + "src/core/GameSpeed.lua": "0937c6163ff30d52b0a73f473bdf0c39baa49b05095802b1e27a4d561be9ff33", + "src/core/StateStack.lua": "3bbc4cb728616456cf217a613695832b42d7f5e32172327a117d7ba60375d0c2", + "src/core/Printer.lua": "f3bf9159d4e55efd2ebff9a5a2058c2f240ba4ea9741c44c26bcd1a3f522a1d4", + "src/core/SafeArea.lua": "ebd6f51d0ba4ea8084d780ef6cb8e38607fb045b40116539870dd870752e7f9c", + "src/core/HotModules.lua": "b01daa4828d61b49e04e3e644fc12436649870c620a5ac5a37f07b20d8afd74c", + "src/core/GamepadMap.lua": "4165d796d291bce1b92c65db14f7e3713d2e64edfe93dab3cb7f758441f803ab", + "src/update/Check.lua": "2dafe9f4e55013bed77fbf2b98824977cc8c97f4e8a35228ea75e0542d42756b", + "src/update/check_worker.lua": "1b0a04a4d454f5cf9fb935d924ab00b8e666f3880e5bb00b13db2cc572d20c2b", + "src/update/Sideload.lua": "6a638755037e6350cdd1eaddd568b5f7e65a4afc4c0547b61769d524af946c54", + "src/update/Boot.lua": "9f69847735eaa61ba6159deb401cf6329d039b592445bff465ae5562a25f2d0e", + "src/update/Semver.lua": "747bd2c36e5ee5f21962c90f1b92ae9cbff81fe02595cc3230c63ee03d9ca009", + "src/update/Payload.lua": "0578193aa8cb207dcb4aac09f6c9c46b4fb429ea8a53d43a26e3f2fa6dfda2cd", + "src/translation/DesktopProcess.lua": "7e3809ecf8cb1969be84c486b0bbd3646147b37a106896bdc36f29937da55e22", + "src/translation/Progress.lua": "e1d3ca95125cd523d3e41a3dad97fe0a61896fe71fe1544673965ff901744ee9", + "src/translation/Sources.lua": "3921e20e615831e20d86332703839391104fb16020b066a4d7a8f948533144b2", + "src/translation/Languages.lua": "adcee1b322a0b87b2d8ba6cb77cc4d08a304f44fb2059bd8456cc4a9ee76d2e2", + "src/translation/Service.lua": "bc98fef6ae105f422e8bd7b0a2d0bc6a051715c20ab275d4ac565dba7d199706", + "src/translation/worker.lua": "c98246c6fc7ce9b01cdde65817a30c5b2679a4bdecaa2bc7c4f81e29c1226bb5", + "src/translation/Glossary.lua": "6f4d4847f1a03ad3efd834fa9bd6077a04de876a3b35f1140ad3cee8b542ab84", + "src/translation/Plan.lua": "492470c2f0149327833b2b3f887684a3fe784789a45e08cf33da95374fac73de", + "src/translation/UnicodeFont.lua": "0a423fa4e7dacad9fbda93fc349a3daff8f6b9f3dfbf30c6fa9a2caa21cf2755", + "src/pokemon/HgssPokeathlonStats.lua": "31628816a5a56ca109a7439a4b951cbee5106c3994907d231bac8ad3d050e1ec", + "src/pokemon/Gen3Friendship.lua": "6d446e11174a0c1ed6e982cfdb01613df817820595089e565d5f3307fad1a291", + "src/pokemon/Gen4Origin.lua": "cb22737b53d6efbba112e2843407927936c6cc3ba651ad323b0583711af5450a", + "src/pokemon/Evolution.lua": "d194acd7ab28d1b5c5b9e760d202c565d14dde6427b1ab1bc801cc68b9d34b12", + "src/pokemon/MonAnim.lua": "59691a65169bc9b0ce930470f12a0da21f1f47363fb630025503d745e326380e", + "src/pokemon/Gen4Friendship.lua": "2807a1adaebf6e01722fe3749457e3de98d2e6e396f68ae5c423c4b7e8a46c56", + "src/pokemon/Gen5DayCare.lua": "2a1cd040f745caf2b631bd83738f1cf50198bd381eddaf0fbe03d36686dd8731", + "src/pokemon/Gen3EggCycles.lua": "cbd9383936bc84d86e0e1e05101c02b06583425255853a1692ce474dd2240fe6", + "src/pokemon/Pokemon.lua": "53f1ff84914e7aebc8bfcbdb7faea1ed61736d7b4dc344b28fefdc3ee51c9a94", + "src/pokemon/Gen4GameRecords.lua": "a31088992c3722ea1778cb6a165933dbe85b9f6d3f649c1790a0961bb6fb63b3", + "src/pokemon/SpeciesRecords.lua": "1af8e1cc2eed08346ee2eeefe9349d998456e172e160c07a1b620f7879d773e6", + "src/pokemon/Gen2Breeding.lua": "371d8c281a97ad9331f57cc193579b9de76b40fccbfaf941b05642d196793cfc", + "src/pokemon/Party.lua": "8064594c12048242e0a61632ece302f41582e4c12a09fa6ea74e708b8c79783c", + "src/pokemon/PicAnim.lua": "26ff5f4e3146ebe240b373c6fd45134e25b5be11fcc6adabc46cffea454c4298", + "src/pokemon/Boxes.lua": "6ea0047fe392271b443d867d941b219912433bb973a212203f36e62aab448344", + "src/pokemon/PolishedUnown.lua": "60d8b72428b7eae321f5c5b0293384d6fce2e8d9aa9ef2cf41f0c97b58c9aab7", + "src/pokemon/HgssPokeathlonRecords.lua": "6ac8f1066292cd29f8b23ecb5ed93d38d42fcc9a2fb84d2adf084b44fe840d46", + "src/pokemon/Gen4PoffinStir.lua": "0b975f69f83a3d33cdaa44c40d34c7b4be8e833fbf28c64c714aea49f280c025", + "src/pokemon/Gen4EasyChat.lua": "b9768bcec271f43aec8ffb34d8824c3e4773a380dab9d63a4e1570224d292cc6", + "src/pokemon/Gen45Evolution.lua": "348adf147879b7f94407873217febca64eafffe95583c9781b824bde6f3932cc", + "src/pokemon/Stats.lua": "31e3b8f78ed066ed0e01444c006c0026a7ab1d8f0a465344e1498693dcf2b8aa", + "src/pokemon/CrossGen.lua": "9e5e540dfe0496ed71cfb8cf01a53fe9a2a1540cd0042020114bcda2c32742f0", + "src/pokemon/Gen4DayCare.lua": "43086444bf7a1f4ee7eaa39f3388bacf19dc127ff8d1b0bf1b2dfcf85b6b7eef", + "src/pokemon/HgssAprijuice.lua": "c15ebba14ec8d884428c9532053d86dfa0a0a3a321bbeb0a421df6f7a32d64ee", + "src/pokemon/DayCare.lua": "cd1a932861cfa66bda9143b52b259f7b3c0314fee6a7407f1b7419a625b1e5dd", + "src/pokemon/Gen5MonAnimPlayer.lua": "86cb49032a5cfc4ca473fb5fe1171373408c93e26110392918cac9019400dff3", + "src/pokemon/Gen2Pokerus.lua": "94fe1ecb7945435d3cdafe43ac0896d4c7687009968b42a07d21ae137b4614c4", + "src/pokemon/Gen4BattleTower.lua": "b874980ae561b28c0a6a4db2c5c8e7b40472f52065223ff3cd136343f73af4a8", + "src/pokemon/Growth.lua": "4b9b8baf993e3776f8dfeaba6725e3ffe78a86749d39f67da1c357355f1a2f7a", + "src/pokemon/Gen45Hatch.lua": "ad8f22ff848b05fe706de9bea0fd9fa8348c0cbd9c80a6c4695f533c165c9c9a", + "src/pokemon/Gen4PoketchState.lua": "8007aac4c6b414b9237f866a17c9134c07cc8fee7841050587389adab449bc04", + "src/pokemon/Gen2Unown.lua": "ede45a06d81d3f20eda5fbcaa5a64a75dfdb1fc44be3cda405b81f963ddc918f", + "src/pokemon/Gen4Journal.lua": "8cf25a548b4aef57e629bc640a7ba81058e58d764ed6b8be7a1b00b99a1efa3d", + "src/pokemon/Gen4MonMotion.lua": "df0972c8c62720286ad3a39aa2690357094a491375ac58313927b24b37f2fe36", + "src/pokemon/Gen5MysteryGift.lua": "e4c9d1fc8e3f0fa5cde670c868e05e9eb5d945cf50f022af3b41c412dc761632", + "src/pokemon/Gen4ContestActing.lua": "0c6b53c1e3084ec2739c09e8f547904cd0aff21f70530b9a7fbac860a2724520", + "src/pokemon/Gen4Contest.lua": "45278d3f1b721d7859847eee1bd336c20cf2672a0b9e5a4e60be732083a40c95", + "src/pokemon/Gen4MachineLearnsets.lua": "5ebc355efc5f61fa88dfad9cd34a90ae4cee105ea12c786637f2dc5d82f75aeb", + "src/pokemon/Sprites.lua": "95d309add02e174d0588624902cccd5a2f0bc0424cff7e630bbd78d14edc1f0d", + "src/pokemon/Gen3GameStats.lua": "ec0d9b7ab50f70ee7fbfd84f5a7c38a8f03e87082da0ff508803ae9a4a1e17e1", + "src/pokemon/Gen2Friendship.lua": "6e2036a747919ef5d5cdd9300887ecce7918f50d4ba21711d7ae801b7d056338", + "src/pokemon/Gen3Breeding.lua": "2434c32ca4dae4013824c5d5adfbae57052f2cb4e5b9bbdd16f44f49e344ee1d", + "src/pokemon/Gen4Forms.lua": "a4b3a243e9aeb8453bd9d64a255eaf37b020f415c4d736f146fdb76c266f5fc5", + "src/pokemon/Contest.lua": "ecd763b0d2e9d6e1acff06e62353224280c4c10ebcc36b3f8dd355acf45e5549", + "src/pokemon/VoltorbFlipGame.lua": "0005e75f654864e3a18cfe2e3d723505452e821b1c769ec34bae783570d40c84", + "src/pokemon/Gen4ContestDance.lua": "d55a9d2eb5ff919b0ba752208aa30deea6d0b50eff562336b61e395c7d3b3b4d", + "src/pokemon/HgssSizeRecord.lua": "1cb097e1c2fa9a13c0720b458668aceea82797d08595f7791d3fba2fc7a6588c", + "src/pokemon/Gen4MysteryGift.lua": "2e20bace544bff4c4883dcd345a780971fed99444d7ce0f1629897df4e3f850d", + "src/pokemon/Gen4Poffin.lua": "b168af7131c896314044ae7757b630e8e6bf3fccf3717b6a8969665363981d9d", + "src/pokemon/PartyAside.lua": "c1c615312cbe135d39a3cd9622e19da66b24b0ff1ced85971b917185619ac617", + "src/pokemon/HgssPokeathlonPerformance.lua": "626adfb86e15a5adbfa0ae92e1601c88930f594c7b96b1e07eb6c41c15199cc2", + "src/render/ShaderGraph.lua": "a3d20dbdefe342d408a7cbafc76a6973c4b5dce9de85a0bb68e610eaa49f3827", + "src/render/SmoothScale.lua": "ef088406cf4bd3b7c4ede5ec144a195fe910af9588b66df00bacbf43069ed0a7", + "src/render/Gen4Palettes.lua": "7ae2b70ca2b5c8f1577e54d8d767d3a5e1517104f3fcd78468eb2da98f5a17f8", + "src/render/TileRenderer.lua": "f977403edf3f7c38dee0f01b85ab8a8602a1680a4d9ba81e891476c67691b732", + "src/render/Gen5MeshWipes.lua": "d3fc2fe4b4723ba419b9e8f0e9eda8a2e776fd667f75ac6a8f306a0089c096e4", + "src/render/Gen4TexAnim.lua": "cdbb68351a591838311e5e77eb24938801ccf318b06187bf35ee27e6893c4c56", + "src/render/ShaderPresets.lua": "a3613779438a7a8961ec2d8590146213a1479641897e19cc7b762473ec8a56aa", + "src/render/TextBox.lua": "6ccdc8b5b4622502e3b3036a935d98f9e2ca14fbb37c0d92a0ffe7a2920732e2", + "src/render/Gen4RangeFile.lua": "4c7d0d0e51f2b8cc07d84d7dc680d684c6c7c711fb459bfcd6fcac0061cbae9c", + "src/render/FontHD.lua": "301f2c75771f5b6800bf3ffd719081397b60baa666ca644295996cde259b433e", + "src/render/Gen4View.lua": "6c7e374cc956796712366edf70538728e9dfb71b728a185915e5a9bce79d438e", + "src/render/StadiumArt.lua": "4b86edfb96f75977c9a18392ac7bdf4e34e726069b8e24e8f179166c565730d5", + "src/render/GBCFX.lua": "c36b867d809493928632cf64db6c8b605a8b80ee3f1536ed1fa587740ba0c6e8", + "src/render/Gen4DepthFog.lua": "67d89d19a168066b8761310ca9228a9d2f22514b5fbc5adf55cf8cf24a380420", + "src/render/Renderer.lua": "21fe4903a2e4907ecf84917f081a45a0cfa37c5ea91248126f8def91e61ed63a", + "src/render/Zoom.lua": "b908263bd8c8716c201085758bab90c1ca76dfeb6c0acf4c11d7f7488957d25d", + "src/render/Tilt.lua": "72d9ca5048e4e1025157e1962401cf3cd54687d07e5d465ddb520f91b7bfa351", + "src/render/GbcColorCorrection.lua": "0a96ee109bc9d01d46ed2ef944a4b7777b4b61ed2a0505796129c361ac937dc4", + "src/render/Gen3PaletteFrameCache.lua": "244ff347dfb5d9a565fb5f8e821c7b3d6408e0ebbff92b29a70ec6d387f33e04", + "src/render/Gen5CutIn.lua": "35dfa373d8baddb3a84a52460445216ffc5907cc9a9d3647fab208e749fa22da", + "src/render/Gen4Shade.lua": "304ee2b1f64dacb11bcba3840b2acdd04b71260f3ff7238c2499a2657f651921", + "src/render/Gen4Decals.lua": "41fa92005c990bf5bcafae89082b3298925bd4501f4e71001fcdf7ded1527da8", + "src/render/Gen4SnowView.lua": "aeea7785df118f7c16a58a656d454bda288884cde6ccbcf17590a630c0380389", + "src/render/Gen3BadgeArt.lua": "ca5362ca7b3b4f70aa7a833acaa4c6a4ac0c0bd2441687f23a3579c041d7ba1e", + "src/render/Gen4Model.lua": "0c647dfb1c1330a8bef196fa2d4c620ce4e17b62ee8a0e1bd636e02dff0248e6", + "src/render/ClipSpaceCompat.lua": "84738259f75eb74b6464ea49e1c910b3d990af1f85ab2b49efd91131bdafb1d7", + "src/render/Font.lua": "b17fa2c8cc363e0dabbf9fe70a34ef9e35ef2e2ca8968bdfe5e97a409b470676", + "src/render/Gen3Scene.lua": "2c54b2b4a82e9d5f807b5c381f0d63fb8d51379f0d67e1bd67f3195ba5319458", + "src/render/Gen5Seasons.lua": "ce34c8ee03adedd73309380f8e159f0bf324b58d65b175ad812bca9c3eb5d69e", + "src/render/Gen4Camera.lua": "0fcc80363e5ea3b01df7bd3e506240fe1cc494bac0ae3e807416ee4e567e3d81", + "src/render/HgssBattleTransition.lua": "967ba591e97cf4b15b15105f6c21c4677e6bf156612c254e326c83fa5e5c7de6", + "src/render/Gen3RegionMapArt.lua": "dffa3cec761c834a504be92eb300e37633cc846dbd8d6e85902ee4932776b7c3", + "src/render/Gen3PaletteSamples.lua": "8000353f6260a0ab2712b8e27899c8b2a4442f7143b22e0f6a6ec020a67e9e42", + "src/render/PlayerPalette.lua": "7d6d2c3dc2c5839a8a23e9258e2c4b4fc58ca158fc05ebae71ad163fd0767dc4", + "src/render/Gen3PaletteCycles.lua": "3f898dfe6d823b16555b8b11793c632e093223bf8c7d851fe8210d4426a26e2c", + "src/render/Gen3Tiles.lua": "918f7a94773a79ada6a9f8d25599a2f8c88c46ac6e07622fdd0b318ca4258fc9", + "src/render/SpriteRenderer.lua": "47a2ea002e6509dda539ccea295ccaf8d70efa63504f85a0401183cf2906068a", + "src/render/Gen4DistSkyView.lua": "dc33ce269e895283fb2ca69856bf8b5e4536df89619cf412de1ed817322ecb40", + "src/render/NativeOverlay.lua": "cac9a9b8cd82dfd3d8e0fed466260db8766206a98810a5df571909dee3ad4b8e", + "src/render/Gen4DistNeighbour.lua": "c473e65eae5fbaee93640419890ee9206c2b9ea582e50546ca127bbbe67888b2", + "src/render/SuperSample.lua": "27204884f78b1c62703967d1cb5ba7c67e1c7fa873b9e71cf9afb66d5171eee8", + "src/render/Transition.lua": "9fe68f8d52ef1c205ffb81ac3c9593924b35c68be271384e071d65c94ff6bc7b", + "src/render/Gen5Particles.lua": "83ddee32b398e9d7f7a338b161e411994568ef9d1955ed0cd1dc1725f9b03359", + "src/render/Gen4Ground.lua": "60d20d82661baeefa1595fd5936794fbd982aeeffe43213b939e62f4c6999e07", + "src/render/Gen4PropClock.lua": "970ec21f1b8feca24daa90de2e174bafc23d24cd18a5a28458886a725223dc39", + "src/render/Gen5BattleTransition.lua": "853e31b5996f308742a278cbc32c82f71adf0eb0372c198fb962e005265da88e", + "src/render/SecondScreen.lua": "a517cb5c9e8793f1c906cd2a30f5bfc0092224210fbb9d81f27eeb3e7231d814", + "src/render/HudTiles.lua": "70001ac9a15847163791daf7713541c5bad407ae81ea6ade0e8a2ff1776c11cd", + "src/render/Gen4BattleTransition.lua": "671cb91b85b97812b9cfa3c321f1a3597bed0619a5cdfbf297c5d5d1be547dfa", + "src/render/UiQuad.lua": "8d7715e0bbfc21d2408c6652def9e01474a7dedcbc5bc9e15f0cf39b60aa73b3", + "src/render/Gen4FlashDark.lua": "d4f015b425ad0b22a8a3037f48fc90ed3d0321fd45f7a3d3a3ec8b228151b1a6", + "src/render/Camera.lua": "93034192070d121064b46586ebcd01ed078d849fea5e57e56790eb73f29f4764", + "src/render/PaletteFX.lua": "e0710ed3342eb954b9bbf8d38524a9f0a0599fe686995e5b29616b135dab0350", + "src/render/Gen5BattleSceneView.lua": "e1c2baa488e6abad9578e17d5a90211c584f352c2f4f83096519e139f45c4792", + "src/render/ScreenSpaceUI.lua": "80b89a94dd82f7c23bf3be6a9c560a5df924cda5bd27ca6b7a498e25846abeae", + "src/render/GraphicsStack.lua": "90d91dadb97db351b827b902ad1e244657dc3b6f6f68fd003778a80e9b93428f", + "src/render/PixelCanvas.lua": "9aab73def33ea50dad0be046b603f4ddfc47a4bea2dc7fd9163b250ee92a6fce", + "src/render/Gen4Shadows.lua": "9faa161fd00cbf268cb44b7fb4528ec359acfd628ce6aec0f41a5286952c7960", + "src/render/Gen4TerrainAnim.lua": "013a73a61e6e2771c8542e3aaa6b787d1ff90d91f0e2c5c87790d0fb6738cfc5", + "src/render/Pipelines.lua": "0a66ecfaa64a26c6acf73d8d723d2da8f174736a848ce2a4a39f7742294e1c24", + "src/render/BattleTransition.lua": "04ee9e9d57b0ecaf6f20b56cf22738ba9c0b0f23443dd80aacde834c7d1df3cf", + "src/render/Assets.lua": "61e8bfaf95d02dc747a5bba2b2f04c2193cc44d49dd47844c695eb413c9ad966", + "src/world/Gen4MiningDig.lua": "ab421310dd873b2e1dc2b89cf7cfb2e088e50a45cf5a6ba534577ecb9807311a", + "src/world/FieldDefaults.lua": "e933384c57fe9bd41b9126e816d8ffb6eb7d06060675df2843eed781ed3a08ac", + "src/world/NPC-1.lua": "f5afef5669d5a093d3ff87f5d80a7b67304030503789d79164e78e1e8c9c40a1", + "src/world/Gen4Follower.lua": "28720d093a702fa1d7de5db1595384df525e2885b95b3193005a1bb108717f92", + "src/world/Gen3PartyFieldMoves.lua": "f79ab45abb018e21691dbbae167328438a44fc65140067c2037ea9542a39a53f", + "src/world/Gen4BikeRamp.lua": "480c7aa07b0dd804d87f748ab8ddd2b451594146283dcb731c0c112382f6940a", + "src/world/Gen3MirageTower.lua": "54deab7eb1d3c3a5cc6aa0aadf58a129249bc5fdc161395ec8a9446526ae84a9", + "src/world/Gen5PostWarp.lua": "1ecc290dc41a3479f5ebb577dcf847582727e8efedd4261ebd3ea085c1abab4a", + "src/world/HgssDowsing.lua": "c80ac3504cca1f7a5ceb3e07a121f4de33b01061dab0b73d7c26147c0d9ce0a0", + "src/world/Gen5DriftveilGym.lua": "53390d35e563a9d189f4207389c18027a0aaa2e11b788c673b0e8c7b2f8dbd4d", + "src/world/BattleTower.lua": "7dce1d7969883a7b72bd938c67c64f85fb414bcdc91ec035f461ba1b6ec1ed55", + "src/world/Gen4WaterfallRide.lua": "ad5451023c10ae105cbdb44f9a574368deec165cadc3a1079f2825dd72b8b999", + "src/world/Gen2PhoneService.lua": "911f896bbecd7ec9f9bffcfb28869b081caf8ddf60b4f54f3090d94690319768", + "src/world/Gen3Regi.lua": "19846fa019db07aacee64aa7102d7ea12bfad263edad341b11f8096da8f56eac", + "src/world/Gen4MiningWall.lua": "5671157dbb2df3598d1870312b15b69e6f7c93241f821b7554d41b1382200e7b", + "src/world/Warp.lua": "301f46eb37e99387f27f52e4c3abb7080e213c445144e21711d48063fb6f3004", + "src/world/HgssFollowMon.lua": "97d46c8269253063147d59d8161ddb153f5aa51f530e215dfc5579802c9c8f1c", + "src/world/Gen4EmoteDraw.lua": "e2fbe11bd963cdec98bef0a11b33a275729d58334d25239ccef63680ae508609", + "src/world/ElevatorShake.lua": "165b745105f3eda0bd2fcd6a99133bbff7773dc8f4bc7e56315205bdecb47378", + "src/world/Gen3MapPreviewFRLG.lua": "0b5a4c7eeeb9729f2f0ab71354f2a9021762d6bd61746ab2799f9f60d580e943", + "src/world/Gen2MomShopping.lua": "10a38333800908257a3babe12c7dc7a6fa87840f1a20ee4a78e8d24863b119ff", + "src/world/Gen4DistPlatforms.lua": "7e3f2889b6f74df570ea95f7295826818f58b1c896416a3f380ea92097f63d02", + "src/world/Gen3ScreenFx.lua": "a81221b5798231c9c8862cd2574d8853a34a20f578337dbf8a7671db7144b714", + "src/world/Gen3Gates.lua": "60e8f9ed4d902a02df2185033afd1b313e36630ea101098c75192c984642d1c0", + "src/world/Collision.lua": "1cd1823f40449f39e2ea9d50e5b77af5baeace3349eefafa1027069bd69b6675", + "src/world/HgssRadioMusic.lua": "31abb2d994f3e7c5178f2ed8667ed9a3213d00b1215396067ed4dc8c5c8cf863", + "src/world/Gen4BerryPatches.lua": "5086bbdc8dfe100ddcdf20cb5a26fa155c0c384b62d4dbe2a480107f771d8986", + "src/world/Gen4HeightRule.lua": "201316ce02b80ea6286e4d17c571ff56d0c1594f0941b9275bc2e9442bbd1a3f", + "src/world/Gen4DistSurface.lua": "e6cd12c7d71b4ff986dc787d44abed654e3d3d7fb06917ad3db0891b701ed3fd", + "src/world/Gen3Weather.lua": "fb58d9b356c1657618ee7f456d5bfa2b079c256c43c3789a36fe1283b180e09d", + "src/world/HgssHeadbuttTrees.lua": "17f2afdc706896a6ee82a7fbd52dc77cdd27a11c086da43761fe6b3efe4f262c", + "src/world/Gen3Roamers.lua": "c3839b47fc45cf85c1640aaac073ffb5dfc9b33fdfdd1c8c27d81877e2c4a217", + "src/world/Gen4Blackout.lua": "6df473b487071fc9fcf10f2517cd2f9a94c7a494ceabd11a1f519145fb7d18e1", + "src/world/Gen4SurfMount.lua": "3b1acc82ad42e2ff5c2c92fe96a0c89867ab7cfecd881eaa24c22275af6a2ac1", + "src/world/HgssEcruteakGym.lua": "bb39781e6ab9091a204c4a542e72b490ea00fb5e77cfeb182fe9199f48ea9e24", + "src/world/Gen4Roamers.lua": "2cf121c4dcd00c7d3892da2aaaa755a8618c10a33fb08bdb567d92343b46ac4b", + "src/world/Gen4RockClimbRide.lua": "5397973226ade97ae619842c0ea8569dfd9b75625cf581b780d95e90ac800773", + "src/world/Gen4FogWeather.lua": "03ff596d9313e0098c2a85fb94ebe159d20926acb705c79636befda3db8db3ac", + "src/world/HgssLegendCutscene.lua": "55ae12d9fb590facd7c947810f4a63009e18effc0abdd424d0b1a90278129a08", + "src/world/Gen4VsSeeker.lua": "8c7dbdccda4eecf756eff50d00984e9733345d6894b582fd19bfc093e885e203", + "src/world/HgssAzaleaGym.lua": "cd29cdc598bdc272f5e68c36dca9ec58d33b277b025599733fc65d20a043bd25", + "src/world/Map.lua": "44353af9b8af816430eb4c84e5dfcbed090eb9745c48e007bc650f794bca2639", + "src/world/Gen4Fishing.lua": "c0f6623612bf88f336a0214c78b9107d91739e0f72ae8b92d0262fe6c0352d9c", + "src/world/Gen4Radar.lua": "cd698b32d6b5be85efcd5f25644f66fb32bac883ae2eae8fbba4807518763928", + "src/world/Gen5WildTables.lua": "da6fb0ec87df07dc00c0cb3efc703a3d2fe66f423f5202589a87035cfce590a6", + "src/world/Gen3WindowMetatiles.lua": "a86fa613a0dd72ced1e227a0c0c6df53cde2a663e2c4a9a6a97cc3f1e7134240", + "src/world/MapLoader.lua": "cd2c61efb9fba2a909ff0f37d71432a20a468fee4e6cfd97a19d65fbbf0befe9", + "src/world/Gen2RadioAudio.lua": "552fc687f9952925fc62483b1204c49b2649bf6743e7d1b4f5840afaef9bf7df", + "src/world/Gen3SecretBase.lua": "d64d3267223d99feba94d08f8ca3d28af15562ba8644c268e87795c5279ca564", + "src/world/Gen4CyclingRoad.lua": "14c2d8ff5afd8f2bd82ddd553413c302c334630385af67373e33bb4c2e1e0af8", + "src/world/OverworldController.lua": "236d822c2f17f90a66297700b337a58b27824b7833600889da27c8e2a868de0c", + "src/world/Gen4HoneyTrees.lua": "b8b1d6d9bb3be4f08d23567b2c6587693ac9ee408fb0cd65e6ea7dcf9178aa64", + "src/world/HgssAlphChambers.lua": "7e13d21f52a6440850c832d0da6d87bd7eb7653f5913c116a1552fe5971f6497", + "src/world/Gen2Radio.lua": "7c1ea177b1db24f9d7eee3b4506d4b7c97078e19ce9d4226a92982c119e7b6be", + "src/world/WorldAPI.lua": "4aad5cdbca1af2ec1973401e7658fae2ababea9188ca47b88afb5a3275b342ed", + "src/world/Gen3Flash.lua": "74976c9a31386a8e5bfed8eb5b7fefc2818737ea80ffe9b9045d9a372816cebe", + "src/world/Gen5Phenomena.lua": "0d3eb2c6021acb52523fc4a0a67083e5a2508dd7a2f614d7f997526f08f71c63", + "src/world/HgssSafariZone.lua": "79ef6ea09d1e0f304b99c01714fdb89492834e9d17707e61d2e111019bc33489", + "src/world/Gen5Dowsing.lua": "4eaac34726a0b104feb775e51da20cacd53ac7829b5f0b9d4757923197d0f70f", + "src/world/Gen5Fly.lua": "2547903dc15272479f9585134f7221f7739f1d39148682e42ade5c1ba11c70e5", + "src/world/HgssModelObjects.lua": "0ff2fc75483bfdd220fc9d24d9924ae0ed5c8443b58acd1ffc38fb7167a46643", + "src/world/PikachuFollower.lua": "2230cd1a5d1947a8d082d880bc4f047b8cd4561b03b8d6feb86643b5bf8f3103", + "src/world/Gen4DynamicMapFeatures.lua": "2a84a05714b149e3a2d95701dd3b314a7c09c8d9cfcb3283e2b08cdcfaa741ea", + "src/world/HgssBugContest.lua": "8d139882d36542d35af894366df9f2a38e725bcc17d9d00f5a520c0505d98dbb", + "src/world/Gen5FieldRules.lua": "df672840abe99fc305414f580b3329356c69677c051926ad70460cf5c46994be", + "src/world/Gen2Decorations.lua": "0628ed321b3e44c051b35fa99f7dbdf5186044e4191058847b5a7ef8a05df04e", + "src/world/Gen5Quicksand.lua": "8a3d0485031784181ebba7eacb660190fe7a175ab1d5456e76650e9f672c1c44", + "src/world/HgssRoamers.lua": "d1886475de202a8f18a8da37150a24856b2638c461b2d6be737ad83c6bf2e8ad", + "src/world/Gen4FieldFx.lua": "6a9587ee8450b47ee49fe657b5e2b7db25ccbca6e3f48b6977dc07d5633a4949", + "src/world/Gen4EncounterEffect.lua": "9d266f523046d0aa27047366dc72dfa814ae9f7a632774764a5020c60532bb7a", + "src/world/Gen2Swarms.lua": "1a04e392c24160be40a547fa15f9091e90f36c943f6bc72d7a2899b8acb1e12a", + "src/world/Gen2ApricornPrograms.lua": "0c85d4ee533f00c98d0de6e58db80c07bfb077b8b22dfdf8347983fcf71b1f8f", + "src/world/Gen4AreaPopup.lua": "2e9e5beeaee8166306a4c7d5150da06754c3bd5b949d213872871a0af7104c76", + "src/world/Gen5Warps.lua": "7ec56009932ae400d5fda85e01a177c456e354b09c1482a27ae9d5eef2a52b8a", + "src/world/Gen5FishingLure.lua": "e9c0ec18d7686979936aa96e47114c5b77efc4332459d38b292705d7c97417c3", + "src/world/Gen4DailySlots.lua": "40c6d21c629cf60bf7fb0f18b32e6ccd1c308c4e49eb7011db39821eb90c920d", + "src/world/Gen4MarshLookout.lua": "3db4957bc039fd5823d43d5b829cbf9b557d11804577c0aad897bf41799565bd", + "src/world/Gen4Swarms.lua": "873709b72fae71c615c8bbe8390dc1d02a27e56d182cc71bbc8867c9ded33480", + "src/world/Encounter.lua": "200602b2f9842cd2d06d15988a534c9366d4d4f91395ddf27f6daacd4bc0b2f6", + "src/world/Gen2Apricorns.lua": "253c130f2402618af5fd90f27b7484ca8fbed7d2f13232ed05fa020aa0a7baf8", + "src/world/Gen5DragonGym.lua": "ae0d5378022dd2ce4fa154c2575a87c49d3b14231d52c6fcc6cdda8be7758a79", + "src/world/NPC.lua": "75c6214ebf2c3b7a75fca60b14b30b650a31376c15143ca76e3ea320975c5b99", + "src/world/Gen4Jump.lua": "a9b827fb8d355a0192a1e0f50b565724feb2377ef5a6cb3009862efb438b79aa", + "src/world/Gen3FieldRules.lua": "0839556137cf6c121a3e9cf47e3ef331cc9389dea6bf76c384ec0b450dd7bcd4", + "src/world/HgssBlackthornGym.lua": "74241df5827d4602a6c6da7d60875cd9b91d075b68b731a50eb26a9293eb141c", + "src/world/HgssApricorns.lua": "141906cdc3edc16d68b2b87e79aac174ac9cdf24caf7080d07e0be7ee42f2ae8", + "src/world/Gen5MovementTypes.lua": "f05eb4b1ebd9b1b6cb2c32217487c592991acc8effd46c03fe0e3dbddd321e39", + "src/world/Gen4PlatformLiftRide.lua": "4e1ea45c88d341b943bf2891a47fb5aef80be89b90246f0aa3b46d9deed2756b", + "src/world/HgssOutbreaks.lua": "d218d17862ab50870051574816b39463f34ea66fac5497680400212837161f2a", + "src/world/Gen4Underground.lua": "9e82756c8943b9265563a623d16145c2cafa2c82aa7dea40bb8045f1b92b0ad6", + "src/world/LiveMaps.lua": "d705c4df72fa59bd562efefee9312781e71c0e59034eac6fec0862f32e0cf2f8", + "src/world/HgssPartnerFacings.lua": "1b3681d43d3ddda79643863dbd07fb292264544c45f8f8eb0897f72b36030d16", + "src/world/Gen4TileScripts.lua": "3461827e93e67794b03b9144e6df87fc7fc9177081ad07996578b4292ce2e558", + "src/world/Gen3Decorations.lua": "22a7892a2903064929c956f7909fba943b65c15e1b69a96a7791fb2e98d7e8a9", + "src/world/RoamMons.lua": "7e261ac504cba92d4376238436c4c3b9c885c375a236da7c390b74544de4790e", + "src/world/Gen3MapPopup.lua": "9fc0e8e842be3a25ec986d2b96830cc3cda6afcc102217c7a32d7874c7241829", + "src/world/Gen4FieldMoves.lua": "b09c78454ceb725cb9e6cbc675480d96eceb01dbb25db448f7bc85b4e58316f1", + "src/world/Gen2Landmarks.lua": "91e272ebd51806d81902fde91d8eb61b8d414f2839b5f9a7d34483a6546e85d4", + "src/world/Gen5PokeTransfer.lua": "9f656e8582a289971d718aaf7fdbd90febdca79c9fa91c19f0cdab3768e00475", + "src/world/Gen4StrengthPush.lua": "776684a00c61c1004a5f2057026ec1896202451f7937e1e9ab6ba47fea176011", + "src/world/MapOverview.lua": "5e41f8528a2f052734975d12715abc09cb9c77ecb90c65a8ab1cc8c827c9418e", + "src/world/Gen4MiningSpots.lua": "fda0d502dec5790315c0e01e10531daeac46cd08c3c80c5dc37b2afccb099ac8", + "src/world/Gen4FishingTask.lua": "96631a7ac5b4e04dba9e8e03da3756b8992d100d62e4f4ec476cf13b7b140935", + "src/world/Gen2PhoneBook.lua": "12274ffd9d67dd35b7d274628eb18ccb2536ca7491364ae6aa454440f93d0fae", + "src/world/Gen4HMCutIn.lua": "d7f87c75a1d12c2c0d2db09615d6ff3c301af18cfe79d27e13b0529bcbbe31ea", + "src/world/Gen3Elevation.lua": "8d4010ca8e753b8d87415019aeb283f9eb1c99a12985a925d6fe59b74b330e29", + "src/world/VsSeeker.lua": "2d363a3754090577faa920e0ca2aea52f296867c05ab19e25e87ef585f98114c", + "src/world/Gen4Snow.lua": "a9e017a517bedfd809cfa3ebd2fe081d99304642397a5bc5d81e3baa4c08b3ae", + "src/world/Gen4HiddenPaths.lua": "71f24a56763b36f538995dffab5482ab4bd8f3fd38308b7f8412937172f6de3e", + "src/world/Gen4MovementTypes.lua": "c1910b4e8b7a0dba4f9f764d873fb5b3dabe6f7327c8b7bfe1f5ceac0e93d58d", + "src/world/Gen5PlayerForms.lua": "8f5949a58cf6283a45e98192a6ee2f4ebb01c68919f177af3ae56a55a87f30ef", + "src/world/Gen3FanClub.lua": "6344ab097e7043a00237cab0258f8bd3b8fca1de5f92b8722e590b4ea100000f", + "src/world/NpcControls.lua": "f22cb5e1394102156594a0ca40280321bdf019996dbd18963360453862325902", + "src/world/Gen4Doors.lua": "f5a134ffa295fd2869456cfedc17935234e507bacd81c439fd4b893027f072fe", + "src/world/WorldActions.lua": "6dd26baf4a0d4937d6edff957b1087f7edbac9139d5d251b85d871cccb8a9261", + "src/world/Gen4Fly.lua": "d03e34924998dda751a9b626f65fe95b7f891dc49ec262501319c1269f791525", + "src/world/Gen2EncounterRules.lua": "f7ad4662d516e938cf37857305dd15c748af8a92cda8eafdbf61ae486eb98642", + "src/world/BugContest.lua": "fa3c744a443179f3c68296a59fc8f5385e93a2a9b82889fc0b2a8c6956f1213e", + "src/world/Gen4PalPark.lua": "175e180c337eab0ba43140ddac21a8476f0676afe7693c3ac233887a160fbd49", + "src/world/Gen3SweetScent.lua": "ca076f8af9c1fbee4243a105f7154fb0f4157adf0320dd21277c87f97ac3aea9", + "src/world/Gen4PropOneShot.lua": "cb6ad82819577acd3db913dc8371902b7432bae060a784fb7992c6b574af80e0", + "src/world/SsAnneLayout.lua": "bc59903a3009e86d42413a1ac5382c7c78bce05603a2a7abc18b7894256c537b", + "src/world/OptionalFollower.lua": "cd9e8129acfdb6af5cf60eb87f05ef618f781847494d96cabd1f69af9947033d", + "src/world/Gen4WildLead.lua": "b2c3e8337885a69c63429e71a1bbad671151fa7b2eacc0d65b7585802f2170b8", + "src/world/HgssFriendshipStatues.lua": "4021251d94b544c32b6e35ffe696476c8d3de478c86f1c59defd638c28167498", + "src/world/Player.lua": "a1cbb34b80ae5b8680dea7114f0e43663eadce22217de279eb352b93d8526e0c", + "src/world/Gen3FieldMove.lua": "999d9e263e81926a899378ba50d71ed6655ff214f9878fa4a3f1fe5bebab77fa", + "src/script/HgssFieldQueries.lua": "1b3ad25133d6284bcf741cf6cab7a28688c8393fc577ac9145a6bf10953c7ef8", + "src/script/HgssFrontierTower.lua": "2bb05b78a127287bb5b11a7cce01d7c28bede35e456cae755d1f1cec2619ddef", + "src/script/ScriptSave.lua": "6568c8d26a40aadc5af0d70c63828edf25667fcd89c8595a3d35b62b7228eddf", + "src/script/Gen2ScriptVM.lua": "d411895ee777802497fe0be348df4cde39904111c9d1ecdd1929400745e7617a", + "src/script/Gen3BattlePike.lua": "9f7629cb26283d3affa9861eb6e675c177aadf467af1614ada9d4b6429182bf6", + "src/script/Gen3Specials.lua": "6a4a5495463d0029014961a52ae7fe7d08ff6b43f1b7e9b69e943eeb1d45cc2c", + "src/script/Gen4FrontierVM.lua": "0ea1539eb8980134f9b6bd2fd6de4f13412cd40440684b291a36c45283117e14", + "src/script/Gen4BattleFactory.lua": "c2e480a87182b2544bbd16b9ce8eb21cd8fb6247d225085f60f431b17478f62d", + "src/script/Gen4Commands-1.lua": "f35e2d0a7d9de9ed63288f9e64dbe34d705b41538f4b71675bb5011426f220b4", + "src/script/Gen5FieldCommands.lua": "7b179030ecd698b49302020b6b48aff386afc33024d6b83ae9b6709e5ca1c152", + "src/script/EasyChat.lua": "6d79db9c3c39094849092acce54dc7f9e3500f22ea53a3d151fb500cfa50abe0", + "src/script/HgssPokeathlonShops.lua": "fae629f29e253576f7d264e99a836575e2fb69c362a3482eccff5cdc0c88220e", + "src/script/Gen4Commands.lua": "f34ac63ea5a3370771fedf9732c906f1860fa78f42c7752ce5926efe8908c197", + "src/script/Gen4RankingsMachine.lua": "3d9fa05bf5f20414d102ce2ba3992eb188dfa989c4628b846bd085fb22c0d345", + "src/script/HgssMartCommands.lua": "fa033ee8f6974d44416cc1cc975d69bb865181e2cd3edf27ac7006531fd170e7", + "src/script/Tokens.lua": "120011147de9a679f72a171177d00a125944bc166444544594344943156b837b", + "src/script/Gen3BattlePyramid.lua": "0da693c362a8345ad813c6e5e91c8bca21e3de0550a10ecd67de464342dee373", + "src/script/HgssPhoneRematch.lua": "c1ee3ef13624a67840d6eb8ad34437e57aa29a5d3d9163d0ed2bcfe1796e493b", + "src/script/DecodedScript.lua": "3b39ca370eb20e66a49b3471b7a51a6322d3785f12792160f548fb8278deac58", + "src/script/Gen5PokeTransferCommands.lua": "a998620cfa01d03871e0ba6be95aaea00dfca72883161cd1cf7981a6659c43f2", + "src/script/Gen5Musical.lua": "e8ba53bec07881d9fa3a766f80e031bd4d1084cbf519fcb0aded550c7afeb7cd", + "src/script/Gen2Daily.lua": "4db437580049c801b1cdaaa80d2d7e5f2f5e2e1fd04be3732cf272ab7471b1bf", + "src/script/HgssYesNoMessage.lua": "c076a27e0e6ddfa4475967f34aae1bccf4e54a9ed40f03931d56a3c2faa12cfc", + "src/script/Gen3BattleFrontier.lua": "5317434283eb7b4c74812a0f0e8e74392a112e0b0f487c7b4b44b8aae980fd2a", + "src/script/Gen4Save.lua": "c905035a7489d7f130b462e3c6f2a70a2aef1c5f0b1cd98afc4bac3276db1dbc", + "src/script/Gen5MoveExec.lua": "928e767f2b18a3fa21dd8af577b1e597901e246670926b25e0f2401ea0de4faf", + "src/script/Gen4ScriptVM.lua": "2358e07b86a9adc61a8a3eef6d356032252db458bfaa907449cda4e6c4470bf6", + "src/script/Gen4BattleHallLobby.lua": "34155baa3b03a6de4ead369c776fceab90c89b5ef7b8d4dbf34050dd381caefb", + "src/script/Gen4FrontierScrCmds.lua": "ea956914c294b7973857ddc0eb098864be6955dc0cf8df847acf6ac36011d1c9", + "src/script/Gen4BattleHall.lua": "0283f233129ac3993adb8fe9dbfe7146b676a15bb0b77884ab3b7df9e86adce1", + "src/script/Gen3SpecialsFRLG.lua": "3409ee3a2768ec4830a4aa8f917fb41ba04bdbb2b800d07071fc576f09ecd44e", + "src/script/Gen5MartCommands.lua": "1f40995a45276ab1d1d264cc1e27740de57ef71d87db28227a210098b21205db", + "src/script/Gen2Commands.lua": "1c5f85cf0040d091c15db07e28aa2713c67653a7932d3f23888b50b52ddda183", + "src/script/HgssPhoneCallCommands.lua": "5005bb33646fde15dea83c8fc55fcd0da4818ee10edc3357058afbd2f668aa08", + "src/script/Gen4Daily.lua": "20961a4282c147610c7db6de526c8d15b5a08a5ea718a6cc531d9e58f6aaa009", + "src/script/Gen5BattleSubway.lua": "0623ea2336664e537fb6bfcec228b4e996781017952f23bb723b6ec643903d46", + "src/script/ScriptRunner.lua": "1caf422ed85a4cd7f03603200ec0d0a83be1700e8794a3c6b20489d57b47ff33", + "src/script/Gen4PalParkCommands.lua": "b6b7f7098f5a0dcb1051e7e5a1cd718d1d2eaaf14c6c659aa87a88c8b402b9fa", + "src/script/Gen3BattleDome.lua": "2a09b2d02abfe19e51d67813d6096c0bed0a2f9c5e594990bc6eaa2b59373f69", + "src/script/HgssPhoneNumbers.lua": "d0ec6bac90372f71e96f708b7919eada7f78556d603ef0b13c84a0692f0442c3", + "src/script/Gen4FieldEncounters.lua": "85ed6b319cf5d201372298127428ebc00f847d7676c39ff14ba12a0567c1398f", + "src/script/Gen4PcAnimation.lua": "9f4c7e40dfc9b389b8e55728e18507754c9c3ba09a6c15c8f2262659bcc9fb76", + "src/script/HgssPokeathlonCommands.lua": "bd9839b5cc5cc9e70994d35f270868f7d3e9b3481263c05ad2171d5cc4712604", + "src/script/Gen4BattleCastle.lua": "0c1c405cd3643898d1df5ef3c0d3719beb407007feb77625ff2852e537284feb", + "src/script/MatchCall.lua": "cf03d529d670217732217479662111648b3a0b8015fc3fa7d6e076c13e81700a", + "src/script/Commands.lua": "3fa3d569ff6cfa988c80491c6537c7cd4c3538e4aa5262b501f6067bbadd7c4b", + "src/script/Gen4BattleArcade.lua": "09ff49d619682160c40d70b401baf72f710acbf4f85e31af5677005e6a0abf28", + "src/script/Gen2Specials.lua": "53a7f0c2ddf3f1cc2a9f8f87bb647989cd90843c7378bd6461690a4fea6a5345", + "src/script/Gen2Flags.lua": "2fef027b70ef308982f6be96c9a4a1e0066732041aaab590b6e27f025c60bd67", + "src/script/Gen3ScriptVM.lua": "7847cf677f2ffc910f70e659d3a6838bc3b9633b9e15316861dc770209b604df", + "src/script/Gen4BattleTowerCommands.lua": "07e2e6748e9f157c3e9eac5fe315068301d7faebf79da72fdc1705e0e7b48646", + "src/script/RuinsOfAlph.lua": "6a9887522a0cf6f7535859f4e256bea23600e53193ca81948260d37325acf168", + "src/script/Gen4ScratchOffCommands.lua": "4b56c06f59055503aec28b9fdc6a6b75aa5f9ef591adf13097003b2901fa5e57", + "src/script/Flags.lua": "bf4a30f60be18169f3ff273a97b92cd14db0d49cd921d440d98f33c2b09963e9", + "src/script/Gen4PlatinumFieldApps.lua": "ae6b7e9af99cf7d2197c48ad6648f3ec3da3eb8a33078ef01317ed1d15f7792e", + "src/script/Gen5TradeCommands.lua": "e3d309c43e9b0c466b5bd80a61158519e244451adc5406ccb42746c93213e75c", + "src/script/Gen5Stadium.lua": "6b8921304de895432d5ec772d528fa5e1a5f12e02f11c9ab8e13dbee5c448e14", + "src/script/MapScripts.lua": "8703767ec2c1a417a159c084c6a202f5cc2174b9511b0170d31d13a1cc4893ca", + "src/script/Gen5XtransceiverCommands.lua": "9a94e8c52a5c61f83fa2164a596554f11bf9ae11dc75ad24d971c02adbefa6ec", + "src/script/Gen3Commands.lua": "3f30e176ae87a37b720bbd3c680a51bf3d5975ffc0f624c3e30ea6a62fb41d43", + "src/script/Gen5MessageCommands.lua": "57a7a64aff5e9a1d6d9f4ba41fd501589bfc86ee8669591a4d83a7d1c924078b", + "src/script/Gen3BattleFactory.lua": "4a32c9d707082315d51bbbb620c48b3b490af0b482dadbcada5fdffc4b45751e", + "src/script/Gen4MoveExec.lua": "086792ff6faeddd6fcf00074cd20a4552510ef71ef82844383232b797e244445", + "src/audio/NitroAudio.lua": "1d81f278653ce90e5521d9b89d9a75ad24a9d860927e00e9ce87a715137366cf", + "src/audio/NitroSequence.lua": "5a93a96afebe4945e951fe0cd204924f4e350cc1aa8fa8c348774dc69ca1463a", + "src/audio/NitroWorker.lua": "6ae969b5bc6171060aee2226a2ea536d6a5223d87ee86be43ed2d46067e8a4ab", + "src/audio/NitroTransport.lua": "bc2e6e0f3aae8020bcf06a8d5591f59a03955e05d02b7e355fe02e82ee9fbbd2", + "src/audio/ChipAsm.lua": "b018d7a82fc7af8fe302398134eb78f7e21f8f3d487ddd31c4d928534c9e3c38", + "src/link/Protocol.lua": "81cd62d6d2318e9d37355fc1fa53fd80709360486906f7cfdcea640aebdf5a7b", + "src/link/TradeText.lua": "7e0bc4c0b771ff6870084a5522662788cc3dea71fbe088df1a969e67fb96d024", + "src/link/Json.lua": "5b1662cc46508ad28f5fe8768e322b4a898caadb74711f5fcd880377dcd2d79c", + "src/link/LinkBattle.lua": "7b717bbe6d8455061deb29160843ded313ab2e00e3353db521d9a0a171cfbd99", + "src/link/Fingerprint.lua": "9fe9abac01cc395364d4108e201acdbf07fd87dcc7e1a880dc14ec89b3226be7", + "src/link/Net.lua": "93a508597a331c660c887a5d52c0342f01aaff8c6823773d48cae9bc47dad616", + "src/link/Handshake.lua": "f89a6a200021d7bd337f5c98e9011fff700d7f4cf11e151868eece978d6a02c8", + "src/link/CodeEntry.lua": "95c8d6e42d7b5da81b64ac11a2e051817df6828ed1c453df3be5a463009fc8f0", + "src/link/Tournament.lua": "0752e6b1b40bbbfdfce6b640c3f575c9a3ed7b4988ed05333bdc7ee5e41aa9c8", + "src/link/LinkState.lua": "ebbd6f5ce922e44d0f96b0e136077bdf65fe563a2faaf3d56110e1bdfb3bfde0", + "src/inventory/Gen5Items.lua": "33bffe334d844ae461892ba4a12ee3c79c903e296ec5e335a8f50cab9eeb9f74", + "src/inventory/Gen4ItemUse.lua": "566ead80919424c4751774a249e5c69b2d5330fcce6fdf31953194b870e6a194", + "src/inventory/PolishedBerries.lua": "a0d3d2d637c8786b8336649141b04c9f7cfd32ee901fcbef592bbb2f8a957ed8", + "src/inventory/ItemEffects.lua": "ce76864d60301a34b9178f14a98f4faaebd31d1f6b2a8eddf5da57dce97b83b8", + "src/inventory/Pokeblocks.lua": "5e37c404241a974c357131bd01e12e677f78f197c7b4538f21f0942f7628c513", + "src/inventory/Gen5ItemUse.lua": "a2c2e05ad391b03fce2d3519bbc330ce84a58eca7c52171be0ac843e5d3dea57", + "src/inventory/Gen4FieldChecks.lua": "adde117e3ddbfd7d2d639bd98bc7ea15b09736dbfb1eba226e7684a2a112865d", + "src/inventory/Bag.lua": "5486939b73e3a4ea86841ec3485ed63940bfa476e1085b4f426b900d2215e2a3", + "src/inventory/Badges.lua": "2a045f98c0b0ded18e6b5aca470b491bd04b8b9a872ad8ef83a30aa17c861dbd", + "src/cloud/OneDrive.lua": "f2a671d7f7d4d0bb51e7ea3539d53bf253107e9780a0080382afafcc50d29077", + "src/cloud/http_worker.lua": "be45a07722e9087073a0b59e3fdfc4c8c142c91dd2aad0c1e7ed33ab23ca66d0", + "src/cloud/CloudClients.lua": "8a45df459c926106d6fc156c9271e0b49eaabd4391dac4811ec951af9d844093", + "src/cloud/Http.lua": "4ba8a992e3b71f86648f8afd273a3bd02885906cc6020a02b7e9e3bd0cac4c88", + "src/cloud/Task.lua": "c2a037b7abd5c71a60ca401920976ba1bae2488e93acf0ee3f638b38c9edd629", + "src/cloud/MicrosoftAuth.lua": "ee38953c9ebee6e69daed4de66f2176837d71f632a21109d0d279deaf8b361c9", + "src/cloud/GoogleAuth.lua": "e1c9d942d9fa47b967c3db5f9c4e350dbbfbae72109dc5cc4bf0b0f801456557", + "src/cloud/GoogleDrive.lua": "2d6a7a83d080a17ab33e490be481795bc006e664bfc55de8410ec17f082ce55e", + "src/mods/Loader.lua": "3b7fe633df57326084b33f4bb145805f4047e07fff40eaf82c74907b9a84c1de", + "src/mods/StreamMD5.lua": "fa97fc3482f1de1cf28c1da567bac12c5d8ac96d2a74e41f1f096e3dabd21bc5", + "src/mods/UnifiedCallbacks.lua": "d7ef6833d429424e43ca8e371de9d49e7c249d95c912ed90a18a127aaa441494", + "src/mods/Runtime.lua": "b27e75396cded1c8d6076e18152ff622d17474fc2c0b2c6a3edfc9ab813957b6", + "src/mods/Hooks.lua": "1a18a22d92efaea9bc79fce0abc74e8f0c66764c5d955588ff8106bdf6696e55", + "src/mods/ModGens.lua": "71d8da96ca6881c95542da234d6f646002a06f07478386476a23074a83a57924", + "src/mods/PortableScripts.lua": "a4fc8a6f34f6983caa197e7098ef724ba46f44bbd171c51b4bc5da73885f205c", + "src/mods/UnifiedContent.lua": "ea4cbbf9352dc88f969369f5f05e0cf8f02391daf155a2d8457704abb739fdda", + "src/mods/Api3WorldEdits.lua": "2c4de48ca6f10db20e64588be3e18820c05a1f181d2fd7aea74bf4fe46b25ba9", + "src/mods/BattleIntents.lua": "05db3c192d186a1aef560c0a0ef0518e871070e0b3a7de184233f26517292f55", + "src/mods/Api3Catalog.lua": "59cb1c13b6e6db1f1c5ee414d307889f43e7973fddf0bbc36da8f0d86dd974b8", + "src/mods/ModImports.lua": "c8447a5c0831a9392432a77a081f83f41739b2e773f4fde4cce3181c09416078", + "src/mods/AssetPacks.lua": "788424783a3dc86d1a8421422bc7d20ef35a2181779b7d938a6eb8896a6dd88f", + "src/mods/PhoneContent.lua": "e15027baf9f191be82414f0240d28b9d9c0392852923f0aea3b8d1b22a5979da", + "src/mods/ScriptProgram.lua": "d7b82e0d1ada721977a483c2de9dfb923381935972183554e0ca307159eedaae", + "src/mods/Api3Records.lua": "91f4127235302def2e33d808cf5c78a1e03f0b46b2133dfa665012e28e58e5df", + "src/mods/Api3NPC.lua": "45b3c00a8008f8b42aa622372ee15a6703555ee5e5e142ae1550dade3326dc8c", + "src/mods/Api3.lua": "99151fb6dc515ca856a686c75ef478cf710af00e27b703e9fe025185b9dd3886", + "src/mods/Gen1Compat.lua": "ea1202ef4fc1feb46fc85f6c6486b80dba0406034630bfaa514a2d6ff5c707d2", + "src/mods/Registry.lua": "e78d29ea0eeea3165010c23ea9ce7f2cccb65cdd3727de91dcf383ed17f75965", + "src/mods/Events.lua": "8ca0a72df6b8a761d1ca031aab4161640e4bb518b19fb9c932dc9792e31c68d7", + "src/mods/LogReport.lua": "cdd6efff8ce1371db5ce356ed183e06a8ddad043505d20cb122cfe68567da9ec", + "src/mods/Manifest.lua": "e89f0d5a5aee609d756a86991c93dec696cdb7cde1ca52fbc8fcce9fa92dfe58", + "src/mods/ModCopies.lua": "5d66850f3c0c0f754dfc8073f274e8047c1e84b66d556edb1af90a21b33e5202", + "src/mods/Capabilities.lua": "da57d928b759a5971de48b20aac21344a064c1fe51a1cb00ff79dcb925f6359f", + "src/mods/Api3Warp.lua": "a6802656f9f4ec65656e2bb1ebd56256599bff92aaba918292655fe73a78d0cc", + "src/mods/UnifiedIds.lua": "651c283e31083549031226bc975d845695bf4b06e6545b11fa59c516ffbb3823", + "src/mods/ModIndex.lua": "711e100af3ef8836868f913a8805970d0c5fe74e397c00d0e71e5e4b160c0273", + "src/mods/Merge.lua": "ab4406b5235108a336a7c3a0f2688f5b8a39452c4edd5354e1c8d77681f6e8ed", + "src/mods/AssetTransform.lua": "59657044cec253d5eab63896904f63a7f4413fc86a2e2f05bc833cc58b0ee3e2", + "src/mods/LauncherMods.lua": "e4ed29fb38c9026d927e3c3f93cd36a7dce6b504caf519efb5524c751f3fae0a", + "src/mods/DecorationContent.lua": "f86e0dc3e47aa1327732b071a93ce64744e9f769bb3f68c0bfd55f05566b1718", + "src/mods/LandmarkContent.lua": "a6ffae7ef24ee1f0505b1bd6c89ad72a6cfb6060acfea2f8f7c70ec1926f7fa2", + "src/mods/Net.lua": "bbd33eea7c119bc305fb57bc2bb7c3fbf1984f1adf7e98fbea286604c1a5f270", + "src/mods/PlaythroughStorage.lua": "b4d0adcd2ae2917f5e474f364a5cdbdb8d5b8a1ae70280381839307a88c761ae", + "src/mods/Schemas.lua": "1d871f236f348494804e0074d355da13b9e762fba1bae25e96a891fbe0d08e51", + "src/mods/Storage.lua": "d07c8921f9e49947158521c11d31c1619a2ec7574327bdc0e7226250a71de24d", + "src/mods/DecodedCallbacks.lua": "ba0318a4970d29da314e70b95a2713964f5f758686e839f99bc8dd3d01c450c8", + "src/mods/InputControl.lua": "8897d9e051d4adb7489495f7e64364a6eba19a7df73574724cd9772dab4ca615", + "src/mods/ApricornContent.lua": "e6451f59a295e29819cb58bb94d50e9ea5c63a5d37ba9652272b511e0b400350", + "src/mods/CallbackRecords.lua": "480d5fa3526d01f4478326e989c9157286803ed56029736fd78341ec2f508b16", + "src/mods/ModSeams.lua": "660f73af0b6f2f3ac6edb42ec1f742ce6ae28a388868eb8625ab0f7cf8f118d3", + "src/mods/ImportAccess.lua": "cbf7294ea84f0b0647ca078488457cac799cbd59fe0cf409a70e4f8e2f18da29", + "src/mods/UnifiedSchemas.lua": "b7dc4984044bc63fa8533aee9f8a5da429c83f33966e45fb24a9b017a32feac3", + "src/mods/WorldReads.lua": "5529dc5ce24a10df31c690fb1b01ed09deb084cf7bf5dea96cca7a5ffacaff48", + "src/mods/ModRename.lua": "59c23658c1503360c9ac26869e5b9b5f7bf73aa3635460073765c4dfdae6d0dc", + "src/mods/ImportAccess-1.lua": "47102cafb9ac4c59be34f9a83a81920c82e8806ee8fc3977ab20509a65312097", + "src/mods/ManagerState.lua": "e29f62294f6c15fe5b78a52e3059f6d73803b76fc8c2989094de458a20c4dfc4", + "src/mods/ModProfile.lua": "d8debce893a8ec695f5656036947bd24b27bba65afa52f24375dc7fc3879a1b8", + "src/mods/ModImports-1.lua": "20a3c396244a6e480e47c8e01e8501e1c12b5e220e41dffdc2dcb00ae34a4565", + "src/mods/WorldOverview.lua": "b7c0c46cdacd405356fbe46c8d7898aecd237925d74abb3643dbdd155f5c517e", + "src/mods/RadioContent.lua": "06d0ac27359603bffb9945539f05dd5318c50119d81d1b4caf45bcfa0dd9205e", + "src/mods/Semver.lua": "3a39e20cbdbb93c6e67595143fbc7646986334087820554b4121e73fdb3fc724", + "src/mods/UIActions.lua": "dc3feca71cace847c503d6b6189bcf6aced394cbeb3a729e68a8f9b52a9c2392", + "src/mods/ModUpdate.lua": "106f6fb364b1261cbb31cafc87ce0a00a05d541d1c6d9746fddb89b3b64041ac", + "src/mods/Builtins.lua": "378d301dd0637431a5783eb5879ecedbb8ad8973d9922bf68d157f0ba5aaa22e", + "src/mods/BattleSnapshots.lua": "47ed9fece415e624d46b66fd9136e0957ba24bebe5c746c5dd2577d3f5604f90", + "src/dev/Console.lua": "63b268239f3430f24ccbe0cb2ec94720bc676fafd2a3bc19f8161f49a6261c3d", + "src/dev/HotReload.lua": "8e54c2847109ebcdee901d591d499ecae435b718dbce730d02653a55820ebc78", + "src/contest/ContestRun.lua": "42ae50e4f3feb214f79d234fd8c02831eb54ed713352afeb7500668250db3522", + "src/import/Gen4Items.lua": "7f4a00b277b7008f295aca878e2d9a9979a7d0716ee85439117c330b231b023a", + "src/import/RomExtractorGen3.lua": "636613acf761acc52fcd1181929f29bd777318611b20e24a3dc2bddc57f33635", + "src/import/Gen4ContestArt.lua": "4a2e1f0332950b591714221625887b7b5274259f630e8b05d56fa6ac20d1b89a", + "src/import/Gen4ShopArt.lua": "85e30b3cc8c02d11a1d408d43b214ec85aaa034a19a62f553371db8f53913310", + "src/import/Gen5MoveAnimData.lua": "c8a3fcd16c3b823f6a4010b65cdf5a5ddef4cf7e135dd983b15eb1860be28965", + "src/import/Gen4HgssBoxArt.lua": "6171bf0f921b4a6b26dd465828ef36c9f894473a911dfefe806e73ac1db2adab", + "src/import/Gen4TradeArt.lua": "2edae77779518860455b92faf056459b1bb84c2f460601b33b3119787c48d9fb", + "src/import/LuaWriter.lua": "6a94964bdccfdfe49772e0d22de26d400c62899bbdeeaffa623095b4be54d647", + "src/import/Gen5Font.lua": "6831879b6b30fedd1cf7fdc55147c8af59253161881134e68c0e808aa85dd9db", + "src/import/Gen5BootArt.lua": "0db690048010afacc265f7da00f0b49acbc69eb915a92acd0d9e7de66b8d3068", + "src/import/Gen4TypeChart.lua": "d122ce147564554f1b47de29b8458e377abc8347867be3bcf4df60832eeb82fd", + "src/import/Gen4SpecialChars.lua": "96a22d14a5c4ee57bef37a10ae7b6c8bc9e88d4b952a64c0c1d7b55ecd62a963", + "src/import/Gen5EncounterCutIns.lua": "f74695a275b01c008714b2d47fcf93ab55e93d4ed3d3d86ebe327a2c90d60338", + "src/import/Gen4Events.lua": "0aa970d5c6a8e544868f7af97625ebd07e97422270982e4d3be70da7992074d4", + "src/import/Gen4HgssScriptTable.lua": "794b71159f39d480e07bf3f4e3bbaa486bbd43d8a842901596a3a8587bbb53ef", + "src/import/Gen5MusicalData.lua": "888be5d240c397a826102cab3972bafcaba74bbefa37d69111780d1d221a01ef", + "src/import/Gen4MapHeaders.lua": "086f1b4a94c6fe505a0195cce0c00b5403c5f94048c987b0b82d1b43929e3bdd", + "src/import/Gen4HgssFiles.lua": "bdc512072a046423d5d38f2a77dc58f5ca581c15842638262d6e92c8b365fcfc", + "src/import/Gen4Screens.lua": "199dbf3f95d4d885b41099df67cc759b3fd0813165d5c8c6aba3f707f2f5efff", + "src/import/Gen4HgssPokeathlonLampJumpArt.lua": "77cdeed0af92e01a99342cc8d641689684395d7d33ac7f5ad0936d3a02664927", + "src/import/Gen5Overworld.lua": "d7c57e4f1544c2bd0c92209322e8c8cab37af0f88d5e695feec3220e86f7826c", + "src/import/RomExtractorGen2.lua": "b62a458e3bbdfeb5b2d14c287635f0ec06ddbe9a386dc423286b997188c9d98b", + "src/import/Gen4Trainers.lua": "57beadad20d602fa60b64ef446a4f12fbc837fbd8bce9b14de102a9e27579991", + "src/import/Gen5BattleUi.lua": "f348106f44d2a3a89f40eca195d172df6616b45536b80f7255993fe31402bcd4", + "src/import/Gen4Cells.lua": "b2c76fe6ca28bc7ef24061c267811e6db3ac43ca162339f4118db909ac651af6", + "src/import/HgssEncounterEffects.lua": "f229596d1e28222404b4a25e1dcceec7bf51694b2e7cb5b7ae2675deb862c946", + "src/import/Gen4HgssPokeathlonCirclePushArt.lua": "11a00e31c5ba33b9c6b2205387a737dafe14a86ce9757bfce52053a0ca9d02b2", + "src/import/Gen4HgssMainMenu.lua": "abbde6e6fabbaf95a2664a70c8cd5334f2c7e38a297ae64c8280b5746ee8fa5d", + "src/import/Gen5EvolutionArt.lua": "3b9019f0209785c3cf020972dab4b3137ef022713f219948566fb2097291a6e8", + "src/import/Gen4GameCorner.lua": "9558f15bf9391a886b2a1f3b73e9e1aa14ac9774775a9868288cffcf0862538c", + "src/import/Gen4HgssArea.lua": "24b63049948c64bb5aef173a01b955e249e47c9c22485702a94ce22fff55656e", + "src/import/Gen4TrainerCardArt.lua": "db922855f86f8a40bece40b8b9d3cf3d07d29ccafb26d75f5cf120f8f222a52d", + "src/import/Gen4BackThrow.lua": "b13b6e6e518f1fc28f3e347bd20a305162ba5f5287bd7d58650eaec20d536ef6", + "src/import/Gen4Trgra.lua": "bdf0a304a40087ba878f893e475ba51c3c05c470a5c03a7426b2d6ce17905129", + "src/import/Gen4Trades.lua": "c1f3bb47f1fe9162e2b40718b065d67addec453bbb31b1f8d53d880d7f566abf", + "src/import/Gen4HgssPokeathlonPennantArt.lua": "347437eec2bd71f1fe136f6e30737278c83e26b195a07103e8941be47792527c", + "src/import/Gen4TownMap.lua": "92ad40058a3671feae453a782bdaf5fe14d616e402452a621506d7ea3d8812d9", + "src/import/Gen4HgssOptionsArt.lua": "3cbedbb59f09f6aa4e9829f98b835c931814e3bbd8dc8f7adb51d55f3e3cb3bc", + "src/import/Gen4Emotes.lua": "1ba63ae0f8557f4b7a53a9ce4d896dc9a5b4c7f483ae13bf63ba9656d2db7f43", + "src/import/Gen4HgssAlphPuzzle.lua": "fce5a951533b7fa757f64fd6501f2780ad156ea3e7ee31355974133e61ba9d15", + "src/import/RomExtractorGen5.lua": "5d7da4e3754aacbda5e51ea0513d2c5cb5287a5846ccb1b0754821396794a01b", + "src/import/Gen4Moves.lua": "c27f14ce18dc30c851bf5743c358bfb5362db1def20f59ec0545a610c4efaca0", + "src/import/Gen4Terrain.lua": "f601b93d74e0ce5540703ef87ff293145a08871c40d24cafc64e9bbe49af28d4", + "src/import/Gen4HgssDexArt.lua": "bdef563272ffbaa227635892c68c1f4fa85aca8811cae6a9f042c821cf19c5c5", + "src/import/Gen4ContestData.lua": "2612e9bc09ae666fbfba5b4841d08a4b30052e16eff0d9f486fb286290523c6a", + "src/import/RomGba.lua": "41880f95ea74f5ecf2aedd4742526ba2524c5652b96c40204cddd0a90529ed78", + "src/import/Gen5MenuArt.lua": "e86aa6481b962b9decbee776d99b37b842b48f2573558be61643d51b81746268", + "src/import/LauncherCloudCode.lua": "87f6eab370e99be0ac2890e346b717295a81535762fec3dd01adbe5f966f485b", + "src/import/Gen4HgssPokeathlonSnowThrowArt.lua": "9a5ace5148dd971588ff4b57b99abd4910ae48a682436ef6627dd0a83dcf0fe5", + "src/import/Gen4JournalArt.lua": "4f3b859a768870d6421f21c2f7adfdc8980e04815fe27dd365fd425b89eb9959", + "src/import/Gen4Anim.lua": "21e7a1db824260d5c86dc68e2c515fa45ca4b85abbc3a4801a1595aa07c9ad7e", + "src/import/Gen4PoketchMap.lua": "f281f85ed40bddf97785f21b2a965c8f9131ae887714825cf59ad63b9fcd4a8a", + "src/import/Gen5Data.lua": "c882faace21eb8f6cad26dc812c1626b32309af44933d61dffcb527534a0f66e", + "src/import/Gen5Cursor.lua": "520270ef74d47f54735ba55b357c88bd0a1074db778506691b63109f85b85192", + "src/import/Gen4EndingArt.lua": "d668d96e061fe537d7ee7a6a5881e3ef8e78c262270c299192cf29ca1e07e553", + "src/import/Gen4HgssField.lua": "7d48f449e6e49f2ad939dae72254ef9da9c3cc46991b6a9a1d3a2b1d6cfd9880", + "src/import/Gen4Nsbmd.lua": "5b1ed726c0423eb10b0a87e8bba73727eeedbbca50d8600fb3c71bb250ed4b60", + "src/import/RomExtractorGen4.lua": "a391eb27d13ed12fe0475caa74eee1a985132e3da70420605f06babd2b6efd1d", + "src/import/LauncherBackdrop.lua": "aec2493e0dbe7ad3bfc3e3494b62ed85b8f4919dc31aa2bceab1f84922fce20b", + "src/import/Gen5Facilities.lua": "62fe2a650ec5de63978bc6e6c37d50412cef8a850ccf826a834da4d43826211f", + "src/import/Gen4StartMenuArt.lua": "18c9eba8ce12c5ed6d245e04cd01bf9e1b4acc7d96687109a87d8d280e401546", + "src/import/Gen4Font.lua": "43beb32b056b56ef7c399b2a38cb58541401675e19bfb962db5dba7b1de7cd94", + "src/import/Gen4HgssPokegearArt.lua": "08c371e33fa962df2e52d636ecb654b631844ec9795e995d15a89535b190ab5a", + "src/import/Gen4HgssPokeathlonRingDropArt.lua": "780be45a4d67376a3688549a9d00b09ec748d346037c9edeaa89b21d4bd7d2ac", + "src/import/Gen3PalettePrograms.lua": "47f5a5aa2e9da8d8eed86a243d63937f02b4af5bc01e8a189ace0532c77dab5c", + "src/import/Gen4HgssOakArt.lua": "8871801b2f2e5b784966fb13a19e7d3a0a2e5c8a9169895b752ebca0172b8175", + "src/import/Gen1TradeArt.lua": "ffa053dc928ef06932ab248592b9b8daf0929be4023c8e87901dc6679dbfd431", + "src/import/Gen4BattleHallTables.lua": "81755152278d2b813c7b219fbacae40602d62e201f62722f8d8d3b5caca72385", + "src/import/Gen5TrainerCardArt.lua": "91742f255437a0afcf2f233d5f523423019a03afcfe0b99e6eca5a85bf6d4734", + "src/import/Gen5NamingArt.lua": "bfb8fa9ebc8bd7926c08a52dbf1e779e8471281e2488d43a639b2b92d09781e6", + "src/import/Gen4HgssFollowMon.lua": "cfc2ac63ba0fd363dbf9644f60f65731f08abeb9152a6ebcc676e67d752ff5f4", + "src/import/Gen4PropShapes.lua": "1e3d2b6ccf833d9e87db51ca4fdaa66fe8aa3148a429a84eaa45f7d4e8c9ec86", + "src/import/Gen4TownMapArt.lua": "c46cc36bb25588b062966b86e5dd5ac87d3ae8da82dbeb7ddcd93ce01655f080", + "src/import/NdsTrimIdentity.lua": "45b6b35656f7adaecd38f98eac7890ff84e228bc59045676461040f0ca3c72a4", + "src/import/Gen4PlatformLifts.lua": "b41995001f39f04c3e8c4fd37146f43568eb3c082182324abe38a1735237561d", + "src/import/Gen4HgssBattle.lua": "6d6fc045006c211c6b3d92d780a8be9922a85d21600e8e601ee26876fe1048a3", + "src/import/LauncherSidebar.lua": "51dd35db4400fd76f9bb4357abd087db62f895622aa177170b29cd899ecd9a60", + "src/import/Gen4HgssFacings.lua": "0ff107dbb49d4d891aa7487f3cd2dff051219a768716246dfbf23a348d4e72f6", + "src/import/Gen4Weather.lua": "33feeb7b29ba66783e5656d4adbed7c309d62aa69deb9f19321c431b09dc3af8", + "src/import/Gen4PtFieldFx.lua": "0b9a25e6a68c0770652ef53c91bcb107793cdcfccf0545558ac8fe944a8ded22", + "src/import/Gen4Facings.lua": "5c0f623912e7f4e49d731d3af3e50878437b2e7a042f6a4c582a04e00fae9a89", + "src/import/Gen4DressUp.lua": "fc57efd45ebe1baf4401908f944f00cd40d2b85ff6ae52f80fda93fe5f3c8120", + "src/import/Gen4HgssPokeathlonRoomArt.lua": "e802ffc6880f5265d8c7d1a03f26e30c711bff1cd7f2c3ab2f435cae5efb01b9", + "src/import/RomVerification.lua": "a1f531ef9e7ad188cd2b177cebefe561358f626dcdf2dd39a45222c28774cf9f", + "src/import/Gen4HgssPokeathlonCommonArt.lua": "9192464aea8842641c820888f563a4a4c01bf885659e830ca8ca887762ec2f21", + "src/import/Gen4Text.lua": "7b56c12da325bf0c5a4a37dbaa7a03c51fb2bc151a89ef9bda35fd8b20d2f1bb", + "src/import/Gen4Seals.lua": "97b8be7b1d14dc61b09addb56c2d11615a9add43aa1f6ddbb04af4c9593c316c", + "src/import/Gen4HgssConstants.lua": "28760fc80423bb0a7b0106e98c8620570f143aad383d33b2246b8bc374527ddb", + "src/import/Gen4HgssBagArt.lua": "700131faee87737deea5b0ccfbe09d9abc00b33a050c30c1eb17a8be978bfcb8", + "src/import/AndroidStorage.lua": "2670aaa5c121537d11e144412ffcbd87df0239b179b47ea33e28f4d61bf428c6", + "src/import/Gen2TradeArt.lua": "5ae64ebd987bc6573bb431097ff5e87674d8704063427828426c0b3b1aef473a", + "src/import/Gen4HealthboxParts.lua": "2892e314feccfde3a08816f633969eea92dc70ac71bb31e48d2601c9db70d880", + "src/import/Gen4Maps.lua": "6470511e18e585087a12d584d70263bbc173858229fc9bfe65d24f841a2a22c9", + "src/import/Gen4Particle.lua": "5bfb083fd440c738b6df9ae4da25a3ddf703e78d263ef0329d5f799ab5fd2e1e", + "src/import/Gen5Events.lua": "7dcaa8f36c13f824f563a412ca7405ee25a42c5dcae25a98861a89ce3087f1e4", + "src/import/Gen4Dex.lua": "6b3b8c5a6e05183af759d2c414b33aaa6d48200bee1adb9ec3f400b73b0c2db8", + "src/import/Gen5EncounterEffects.lua": "65c54f4b1d426b1bf8702570abfd7e358b8c16d22883c79dd30bb3a2b59df2f9", + "src/import/RomExtractor.lua": "74f6a88cb4a0a231d9bf9fb12f5239e1ddf278693e1e5013fdb5f5f7f96e2044", + "src/import/Gen4BattleTowerData.lua": "61009d86fbf45e1dc1e98915ade447577a90e47e835ae69ec7758bc6f9ea3349", + "src/import/Gen5BattleBagArt.lua": "8ea397b08421b9c8ef58de63f272d108d406769604a91cff374166d65fe7cade", + "src/import/Gen4TrainerPrize.lua": "67eb5d9aae8e74514bce2d819611ba2f5d0ca29609f960da6e01d09e71e8be20", + "src/import/Gen4EncounterEffects.lua": "35868a3926d024b3b575e2737931adbc190fe361d145d7daef4dff038de2fdb1", + "src/import/Gen3ScriptOps.lua": "0094689f1277cfc3ef65f316cf6c59a7b92c871a25c5cd5b772727a9d1a3fab5", + "src/import/Gen4HgssStarterArt.lua": "14b226ce6803a7ecc0aca9a351ebd58866d2cc4b1b0ead026ba875f9be6c9de2", + "src/import/Gen5Trades.lua": "3393239e5bac78f6a0d26a6dc2fc9da68387be3fab6e3354007b10f15fecc287", + "src/import/Gen4ScriptOps.lua": "741078316804b655711bed195cf8f97b80da2f6a28872a68fc242dd669cfa40a", + "src/import/Gen4IntroScene.lua": "5bca967f304a5ef41441890e6b2a309d6be42fec6c63ae4e596fa49be79afd36", + "src/import/Gen4MailGra.lua": "0f07951a4319d4fd20396e5580fc7dcfdbb3cea50ffbaf44028ea7ed65b91e52", + "src/import/Gen5StarterArt.lua": "b6c380ac6f45f344424db0e0a8b2ac1339c4ef6ddfc604ebd0b818708a70ef4e", + "src/import/Gen4EggMoves.lua": "8e28bd37f19f7836f1bd0a8c4a5af3761b883f163f0f045769a2e9ae41f5ebba", + "src/import/Gen4InitScripts.lua": "418ad57caef42ce0f808dccdfc97795f582c81d030d01a0cf350714fed8e78fa", + "src/import/Gen4PartyArt.lua": "2797ac614bb1cf5eeaa33535ae759328e4d0b1bcc3d063b7cb3299a7d84c82b5", + "src/import/PackInbox.lua": "03600a1c190104342c813f514647df6f5bdea5470f157ce34c0ccf6fd943b55a", + "src/import/Gen4HgssScriptOps.lua": "67d0df873cd11da1b4d12bc728869a9392b5f8753630f27c1890a06cd45bd3c8", + "src/import/Gen5Lz.lua": "b2fe132b395049222b4a7cbf909fd1a190980d36aafc6b5d58bb6654d924be51", + "src/import/NarcArchive.lua": "56262649e629f7607a6d3fd15f8fd6a657ff3cb6d43cc6510026744ae127cfc4", + "src/import/LauncherRestore.lua": "56d5cd2a9728917038790f34af91468f2746cee79f13ec1ed9c1d7b21bc263a9", + "src/import/Gen4OpeningAssets.lua": "5423a01c7117e080a7f40b6b4e04bc42c02192285f71d752189168383baa80a2", + "src/import/Gen4HgssPokeathlonRelayRunArt.lua": "cb87a7f1e437748e8e0bfd3d0d4309b21f149f01cbf1f91142c55643f6b520c5", + "src/import/Gen4HgssFieldMoves.lua": "436fcf32b965b74a616f8d2b05d2d5d4afea0560d2378fe7d285888242255b0d", + "src/import/Gen4HgssTexAnim.lua": "03f457472fb8a792fc8ab5e951238ab33325c2332bbe04c54c183357746dceb4", + "src/import/Gen4EvolutionArt.lua": "ce28b878d5f912138696c47c040d74a654c57e7bc79dae60357cc36eb83c0520", + "src/import/Gen4BattleMusic.lua": "965e8bdc2c6aed79d1c948ea57fd18b9cb940c008fc55c3d238da6ccb48da2dc", + "src/import/Gen4Models.lua": "6df58c6271c1b573bc81c27119413ad3203d268ce05d0e6d508d0bc448ed3155", + "src/import/Gen5Text.lua": "4279eb697ad283e99a01cbed9ff6704fecd97c71155058a114c0ce824521c73a", + "src/import/Gen5Movement.lua": "6ed6e1c0f8d75022bf3f0d29f076024c788283ddd3d92a6fac09c1837771e078", + "src/import/Gen5OpeningArt.lua": "0f3648fbaa279a54df3e1879ff781bc984197e38f42a2aeaecab00b92ffe4bba", + "src/import/Gen5ScriptOps.lua": "db5ca2b4e93cfd7efae260fc9a4d2e5c6aff7e2d9e9d80fa1fc763845dbb5165", + "src/import/Gen4PoketchSprites.lua": "51bb47b1d3e49a2bc67caa47a01b7a2cc392b1966497dc925c3e22a75c60045b", + "src/import/Gen2ScriptOps.lua": "1a3e9bf553a8afb7af6e361bc40ccc7323ebb23dddc78d7ecfa3c9914364575b", + "src/import/Gen5Encounters.lua": "afc0bf6e55c6aabc85142bae8b8f01c3e38e82d9ebf5a1df3a49563733174482", + "src/import/Gen4SummaryArt.lua": "8d3c23c8cab2c215af26c42e60fc7beb4546ba8d8cb8298a6a45121bad19abd3", + "src/import/Gen5Maps.lua": "4f04b333415a77f8604cef2bc41524cdf742dea7dfe267751502b6ceb16d3520", + "src/import/Gen4HgssPhoneCall.lua": "59e4b6cbbe1f836f3c938b7066a4a2318b4b6852100b465010b48dc992fb5846", + "src/import/Rom.lua": "7bc60f2a515d3e9d9f475d8591f253283ac849a91903fb5d9ffd88a3fe4eecca", + "src/import/Gen3PyramidTables.lua": "e5fb56f2022f71c32f9a13ad1eda2012c68f2c57023c7f72b18c98366bf50fac", + "src/import/Gen5Field.lua": "f1df978bbd23b5f0ac6553785f9241165ba2ae3432c9fe8305c024a5f4364d42", + "src/import/Gen4Field.lua": "63c4bd391ca98cbbc3ad39c28815f81ebf3b0e99a66b3c024971bde5aca0f8b4", + "src/import/Gen4SpecialEncounters.lua": "d8dbd49bbd6e4274dd70ee5a232f7f80c8cd0efad83eb6bfbe07b3c3c4b9ac92", + "src/import/Gen4MiningArt.lua": "7fa3da6e72c8273f8a121eb15b539760e2f9449db284e82a789fd4c7730b8485", + "src/import/Gen4Bdhc.lua": "9fe693350cee26000e6a2e09b0f215f851127a049571b2bbc4b5ad8a9c726cc7", + "src/import/Gen4Subscreen.lua": "19e664fd64c53a2579da99053931213201f3cb3dd3c1571ab9b17dd4a982f8e5", + "src/import/Gen4MenuArt.lua": "fd0b8a32a728b9105081a217ef3a6fb200e6e4d50231d7c414a1525b0260ec3b", + "src/import/LauncherTouch.lua": "d02c620dcce18dbf73540d9462a2f213e8a4d1d6da8c38b95b127c67c3e9b40b", + "src/import/Gen5Subway.lua": "ff5ca3904c9b877306c5d0f1868c3d9b909fff9bb3b8032e5fc30ebaea8da5ca", + "src/import/Gen4ItemIcons.lua": "69fca190bc5cac17ab7aa8dab4e7e6fa43c4622728b0bc053a85f4b01690dce0", + "src/import/Gen5XtransceiverArt.lua": "769b87be45d47fee631e91d5a5c3d5a36103ad59fd4892e9b724aa1cf616744f", + "src/import/RomRevisions.lua": "0e26cb4718be36ff3d0758ed867623afb0f674363c301d6d21635d6691a796a2", + "src/import/Gen4HoldEffects.lua": "d2b1f6ae8e932899631726accbe7d11ec688a1ef7816419299c2eeb62565f97f", + "src/import/Gen4PropAnim.lua": "4eab553b0a2a07631b7394d0a02d76031630921654622291f6cb639646ec0d8d", + "src/import/Gen4Archives.lua": "7c46847d58256e89ddb216c8532f85a913291a35772aab97b4bfd712d033eb49", + "src/import/Gen5BattleSprites.lua": "7a93ce6e15764d4b4e09c5ae0b7001f08f6de0dabedeb017add1284b1a4c2604", + "src/import/Gen4Icons.lua": "59ac81d842c78f76d81725ecf667e92af5d146eea74f1137726e922c49cbe28b", + "src/import/Gen5Icons.lua": "8407afb0356dfb2d4c4bbd997dcdc078562ca89a41339c0b4eb2afe09d9da6df", + "src/import/Gen4DistSky.lua": "fa9f55ea6096f709a946164faa2ee5d5025e137234ad4320398d217a8d042ade", + "src/import/Gen4Battle.lua": "636227456f8015c63fe539ff2f0d4559f5560654ba7ac65b8702990a06dd4923", + "src/import/Gen4Versions.lua": "6829d16083fefb4fc603db0b2e9ad25963ca78fe10f3858392870063f6925404", + "src/import/Gen4Otherpoke.lua": "0416eee1e9740465f7090ec426523249b63e1784150a2a3e25835d08c9b85fbe", + "src/import/Gen4HgssPokegearExtra.lua": "67c7aa252a03a9e2d28406447e97cf718559d9bfaf66c20e7f1203e73f86b79c", + "src/import/Gen4TrainerMusic.lua": "02454b0be146617142b95d9b5e3eb8c1db9438fd6dca12e379e5e9cfcfff9577", + "src/import/Gen4HgssObjectGfx.lua": "ae276d54100577f2684576dfcff6b0ae3a1508007dd27ff52a2e3a14b5a71093", + "src/import/Gen4FrontierCommands.lua": "af0e3cbc2482bfdcf578dac75d75a980cf4c9c187723a0f9036283228fb0f0a8", + "src/import/Gen4AreaPopup.lua": "57d65731046c200f266d8e645f96df7201205e715605584a360ebe2c4fd6b21c", + "src/import/Gen5SummaryArt.lua": "3955b4aaeb0b8a09d209759677888b82716ca6250c97e216fe728eb689f46c46", + "src/import/Gen4Menus.lua": "5720b28953b0a557affcaaa22d77f245a4176e3a69f95ef49c66ea4a5f95ab45", + "src/import/Gen4MoveTutor.lua": "d64690f4a38c2c4e4e9fd532a63d514866892ccd10d43e0fed69b8b317097193", + "src/import/Gen4Encounters.lua": "94ed5d772a89c00a1ce2ebc5d6d4582c16bda9d9f085439471dab6fd37fab4ac", + "src/import/Gen4Naming.lua": "d259695ba16f0e115825281f13f829739a2a38c22bd658d53286ac66fd35641c", + "src/import/Gen4HgssTitleArt.lua": "8b92067e44a043ffde6a9dfda514e1ad107a66ec1ddefd7ecb8194ec03e18fdb", + "src/import/RomImportTask.lua": "a6eb7257f3f0e0f5a3b24adeafa33fe052ccfd011a6929ac0b4b5729754f0b8c", + "src/import/Gen4FrontierScripts.lua": "38e8ae94537df4a205c2bf64168d7acb5bec36a3f4d924bfa8b2ddda39287c0f", + "src/import/Gen4AreaLight.lua": "0b7118a4c6b36a90edf75f25173e798d44dfb57cb912ea70e884f8fafc42b4a5", + "src/import/Gen4Elevators.lua": "8ec3c8b8d2fd742a676c83a432f8d0002d7ec4d4b07fa5ab9a7df0f9f34a1f92", + "src/import/Gen5TrainerPics.lua": "994efbab8d287c159ddc6550345064c4811f711f568373ed5148d7d00fda406c", + "src/import/AdoptedTileset.lua": "b08f6066528129342e0f26213c6e5237421df5a288819dd9773e6c942b9b6045", + "src/import/Gen4HgssPokeathlonBlockSmashArt.lua": "3ebc080b932ce78720c9b8527004efaff81306d0b101eed44e0cefbb49bfd89e", + "src/import/Gen4Mining.lua": "e0c40f9cf8e212628c710e217df9c702cfc4180e563101fe0d89f75a107ab917", + "src/import/Gen5ShopArt.lua": "704e7171f10a97a9387e54e84dc5e49c71e8f81689c85f9edf8c83283c0c7d3f", + "src/import/Gen4HgssMartArt.lua": "36baa661d7094db07d0a72b0ef598a1e1e0ad10f81a0cf2eb4669b3bf372da23", + "src/import/DataMove.lua": "e7cc0d51b420e89b65ad288dcafc0b39fa03aaa6144cac5671aed44cbf7f0708", + "src/import/Gen4HgssSummaryArt.lua": "4850ad585d95ad81b19556b7fd0212ccb5baeb6dd3367b75f97224832ff952ea", + "src/import/Gen4Mart.lua": "3c5a64c4d0d716089af548b319e22bf011605532ed5b7f18f531b3b8818f69fa", + "src/import/Gen4Abilities.lua": "eb63e1afd9e921f414f3490a2209a7649488e7557d96deaa33ec0c90eaa7a8cf", + "src/import/Gen4Audio.lua": "cdd96a8668c1eb219a5728c3d1a862ccdd7da15a3ef9139f8f90dfce962bbc6e", + "src/import/RomImporter.lua": "715665c05439a64cc692d7f2e03f05d38b2350e8e9f103102ff73d4567e0beb4", + "src/import/Gen5Audio.lua": "cf6ee9908bd6fcaf532391df7ed1e3563bd651acafa60f4001068159e1008820", + "src/import/Gen5ItemIcons.lua": "8b75d750de1561678f6a45d200adebae73de46a8e2126bd6b84dce3fcee13c97", + "src/import/Gen4ObjectGfx.lua": "82f3b93f2d16f03c54e6c40f457906b60b3dd07d78a6b548f2a931fe5797b418", + "src/import/Gen4MoveButtons.lua": "c79dcdca34fa4512599f89ccf30c45a1ffc706a4abe215a83d65680dc88d3fc0", + "src/import/ImportRecovery.lua": "ca54c59e6fa164ba6882f87fee1712e20074a4963a975b8b5f4e176e5b48414a", + "src/import/Gen5BagArt.lua": "ce7e910da9992e6ddf21392015629846b709ef73aa441233e278ff9c588ed28d", + "src/import/Gen4HgssHurdleDashArt.lua": "659bbd47828edaa29044681c8310f78ba525854347c196e0930dc49088b5d310", + "src/import/Gen4BoxArt.lua": "a3c3cf31d8d86f29fa4313a7bff536943538acb8d60af82213d339a4d987ea14", + "src/import/CacheFs.lua": "415e93dcf0f9b85cd390684ac3ece39c9a2baa2410aef176cce2eb25c3c09eaf", + "src/import/Gen4BattleArt.lua": "c2e1fdf333c0e798f9efe440e2713053bee6e6bfe7f8d1ecc9f1359b46cc6159", + "src/import/Gen5MonAnim.lua": "a4f4a0776afe9d19b9eac1e1fea811f2162a6864cef4bf89598600b0c76481d3", + "src/import/NdsRom.lua": "6905535a4fef88336b04e44c01c36ff1e2862fa0fc5af90c1475ea6a9a033184", + "src/import/Gen4PoketchArt.lua": "a3e4d2f799a5563dc7e8b55e4c82ea4130a499848afbc714158f0445f580e847", + "src/import/Gen5Marts.lua": "94033cc946e95d1db275c5fb9b788ac2665ae3d4ded6267de670301cc2f68158", + "src/import/Gen4Shadow.lua": "58e898a80a7e1eab2da3c3250cc1dc0e05731914651d4a5abc25a21dc6ff6b83", + "src/import/Gen5TradeArt.lua": "4861db8240584b576290ea73174c3a5a9fc9956527cad14ee300a1ef53fbf2c7", + "src/import/Gen4HgssScriptAliases.lua": "3b6dcdee5fc5ab61a10156859ac218f64d46fe3bb9f8d3a271f2e395016747a0", + "src/import/Gen4BerryData.lua": "379498577c7ef08953d293aa3c8031e2b9017f11520307087f9f94b6cf9687c0", + "src/import/Gen5JuniperArt.lua": "ce76ce7a98d5830a80f0c828b3592b511384a89e555da5676a2de4116df5cb3c", + "src/import/Gen4CellAnim.lua": "3c1378b3c5390d9b6d794e50e3eb7ed9cc8dbfa1e3d69bef62f175245570cbb1", + "src/import/Gen5Particles.lua": "b5afd8dae63d9b8aa87277b7d243d8745a3333ccce93c4eef1afe4e90996ecc4", + "src/import/Gen4HgssPropAnim.lua": "98aecd92b57cd2f0ec22345e152a98fe82fe40f88a9a7ee461ec5b6023ee3793", + "src/import/Gen4Behaviors.lua": "9b546b07e7681a230243f092c1f3e0489519c37c8a5d3d6031f5e73067d03f90", + "src/import/Gen4HgssCertificates.lua": "0347ab81141d2bcae138c71f576162a5ceaeb62df51d092b28fb2a223555bc4d", + "src/import/Gen4MoveAnim.lua": "f9f0f0c61838bc4eb8e0a68f466ea11b0f7feaac15c070197ce8d4d155bb6c93", + "src/import/Gen4PoffinArt.lua": "ca14bcb28795acf5ca59efb3e0a3ab08eaa59bfbdcef18848ee099e561b8ae08", + "src/import/Gen4ScratchOffArt.lua": "9ff1ff4d8f5653c28208982c6cce75e0f79b50830a11f0768f9d8f5498a5f17d", + "src/import/Gen4PokeAnimScript.lua": "79e089e2487a28ec0683292ddbd69b3c03e91a1dc0b4863a412a64f61d5702cd", + "src/import/BorrowedTiles.lua": "f58d1f6a780e2694419ccd9db6ccacf076e56bcc32ede6ff5f34318748be8bdb", + "src/import/NdsBlz.lua": "396a2cd920f9cecedfd38d586b6a57d9d4e579899e8dcb64f711ae5a05771d5d", + "src/import/Gen4WeatherSys.lua": "de7910afad61f96173bd2f6668651915562c890c34c142e38b192da576335ac3", + "src/import/Gen4TrainerMessages.lua": "620535a46048785500bf5a868f2a2d0213886def7c30be5b3dddc903b3bd8f85", + "src/import/Gen4Graphics.lua": "745d10e7e9a518a59946f49ebf6d57121478a2f1e625949a2a7db2cfc93de2b1", + "src/import/Gen4ModelPack.lua": "88cdba987ffca3b97d6c393f21b8283c2ce7a1105d64dfbc969931da453507a9", + "src/import/Gen4Pokegra.lua": "64c13544c0f832a9fb856d075a64b465017b988631a2875e30cfe942422e6851", + "src/import/Gen4Sdat.lua": "94c4a792c621d525b25204f83968797261b4d89b610ed16c27492286ba1fb981", + "src/import/Gen5FieldEffects.lua": "911accabb16fd06cac2b6e28856ce487abf403a940eaf27494b4491095847ab9", + "src/import/Gen4Movement.lua": "594a1c8c3b52a1500729416f02de271e19bf626244cd855f60608109461e6e68", + "src/import/Gen4OptionsArt.lua": "b52747fdfe7f763cc3725dcda8c007a9703fbdf608a7775370873f51c54db3ea", + "src/import/LauncherArt.lua": "0109c5a944328524a346a48ab21542abc154bb21849b8ee6929ae1b2b8e0be84", + "src/import/Gen5DexArt.lua": "8d8efb73bb69e9b39f1fa0587f06b1a95faca9e7ba8e6257c7597edb65e95735", + "src/import/Gen4PokeMotion.lua": "f84a4ec400897c3cc25494b6b6ce59fca034475ccdfa94d0e70d6f4d32f6ec67", + "src/import/Gen5TownMapArt.lua": "f052c2ac129ead2c9eb15480a43273071770492c270e75b0149900d35fba40d3", + "src/import/Gen5MainMenuArt.lua": "ba496c6aa8d365ed49a0a116be88b830194e864b72400355119c61628edf0573", + "src/import/Gen4Pickups.lua": "bb9cab2925e9fb8a4c1930a94c9df98ec971676e1abe647d9410977a5ad474df", + "src/import/Gen4EggHatchArt.lua": "224759ee8f9d1ce8754649179179af907fbe09564659ec801fb4cee92bdaa085", + "src/import/Gen5PartyArt.lua": "b4b99f980ef2b1ee2efea6ba86217859ec5ca86caee2bc788dff5aa4711150ee", + "src/import/Gen5TypeIcons.lua": "6faa71520aa44a870457d6aadb01dccab3170c92c4e10d7ab726c0a669519ffb", + "src/import/Gen5DynamicCity.lua": "55176584d9f3f66ac99f670a522b14e50ef20940769ee452e5c0e5afe200c06a", + "src/import/Gen4HgssSpecialMarts.lua": "47a81f9c646a516360afefbd956063fb0bf0ca852518c6d2edb50d5a3fa1d2cf", + "src/import/Gen4HgssLegendArt.lua": "26bed2a4e19feedb86f522130e42fffe76442970870e33eb8d11452900e40684", + "src/import/Gen5EggMoves.lua": "9f49ba80fd33f5c3c399baf02eebdd3b48563275946e33b794dfaa31e2431d6a", + "src/import/CloudSync.lua": "cbfe7f609b8f123019ddf2b02a676266b9a239fa59acdc4fcf9d9a8503387734", + "src/import/Gen4HgssBootArt.lua": "82a3978e84db00a51ec4b25d48f2fe52f555a175a24b6bccd3741db90cb1896a", + "src/import/Gen4HgssPokeathlonDiscCatchArt.lua": "11f25541429ed06a380a69b86cf1951434214e3815c74db1953ff97d3655a0b1", + "src/import/SaveFileIO.lua": "17c100d6827d8909038008b9802e6128a8620b496ea430d7ab8fc4e38f9a7160", + "src/import/Gen4HgssPokeathlonCourses.lua": "95c0bcf45dee0c946e9b39f5ca6796fd7be6b23ae974c1d429c8a76f3d7429ba", + "src/import/Gen5Heights.lua": "71eb6fdf57543aa80ce14d5cf6510b1030c95132cc3d64270afcd87827b5ae26", + "src/import/Gen4HgssFloorNo.lua": "7fbab5b6fe7857922f8c903d61563b3c81d1d1307373b67213ae3e206dc7e37c", + "src/import/Gen4Feebas.lua": "6684b52cd6289508a9850a70f07159d9d4648a15134a22119e3fd296c397a300", + "src/import/Gen5BattleScene.lua": "0cbf34e2a11e832486642052dbd6cda904d5fbd0e8c8774d982e491224c78553", + "src/import/rom_import_worker.lua": "494a33f7d1c43b4dc84e2d7409972209726c1fb903447e1aa6c641764b5ecd03", + "src/import/PackPublish.lua": "f60513624737e5d804faed65b65f0bb5d1d435cb17810dcfd3eeb137b9e000b4", + "src/import/LauncherTheme.lua": "470feacf8cb89263811bd697a05ccb55df2b2a54f6a6e4cf4e846b7cae512819", + "src/import/Gen4PalParkData.lua": "96b54b51a52edaf93f6cd1a970a8968009ded3680ca9a08a7a8205a239b2e59a", + "src/import/ImageWriter.lua": "d5622dd626b4242a3d1e8385702966ee59708be9ef8a9b0f7e5a42d98acc9c51", + "src/import/Gen4HgssBattleUi.lua": "a36fa520d14057f4337e304f4a331f342b985bdaf3958891d299da12f4fdffa6", + "src/import/Gen5EggHatchArt.lua": "1ff23bc40ff79ed72340e458ab9f57d352c3add60229d579fe3fb7a60a073704", + "src/import/Gen4HgssPokeathlonGoalRollArt.lua": "1593e7afc4207be6a53de5065e576efa70263ae312b051fd8a54168037896576", + "src/import/Gen4FrontierArt.lua": "69cc03e3d3aa4502bcbe56894c905a68adfebd41aacf6854a30f9f207a3119bf", + "src/import/Gen2PhoneIdentities.lua": "a031b0b21fc3024b5478391681fe4b29208fd21dc51a752635ccd1c9499488fa", + "src/import/Gen4MainMenuArt.lua": "61efde4e82628a04df20f818ad72a5b37c4b12ddd98d2df5c145d06617f43bfd", + "src/import/Gen4VoltorbFlip.lua": "60305416c1039a88f6da06c91bb13ab042fbcd4e2f246e09a5ce9e83d1ffea81", + "src/import/Gen4HgssPokeathlonTeamArt.lua": "cd3db5f4ad483ec50fc55f16f7ee71bfc45be2ed7f6030167446c9277d07ae77", + "src/import/Gen4HgssPokeathlon.lua": "18a5942bec47adf0033f6110e16ce81241c9dfea696f8de0ec09e773f1a690d5", + "src/import/Gen4HgssSafari.lua": "92088791df26dfcda8f8512b69260650f3e912d5596a28e9217d20f5fb40ea68", + "src/import/Gen5ScriptLink.lua": "a5a093728c4224969e6bbf8362ddf73b7e275d5560bba1e772847af26f48cd51", + "src/import/Gen3TradeArt.lua": "94b36762822c710cd07ec6cb5fd38ac3b6339e0d6439a7a6d15e587e68b5bbf3", + "src/import/Gen4Script.lua": "5940cac0a1107f93a9f9543b0f59a412b5feadc1d8fe07e53db0aa489ce1d014", + "src/import/Gen4BagArt.lua": "b470b96b9e80f3dbd8e93db7045d2f0fd4a9d8260dd500f344428eb27d3ef48f", + "src/import/Gen5BoxArt.lua": "3638c06ac1f292bf3195d58af2225c0cfb417060b8188c6e8c6bd665842da532", + "src/import/Gen4UIResources.lua": "4ed1a09603cd84881332fc8ad626faebdceff8ef26f92e340aa966a1ff3e01fe", + "src/import/PackProducer.lua": "132d4f610ff0c15b85fbf9e32233fb209905d9f5ff43cc3aceeebe7183049783", + "src/import/Gen4Species.lua": "b50183857c6954d1c6902c6f4874600135abac5b889ceca848d8e804681ea3ae", + "src/import/Gen4Tileset.lua": "c61ad1b5ae2cc6c05c9f5edd86b36b6dcf527f2f3e8182e857c9a9b2f548a21e", + "src/import/Gen4HgssFieldFx.lua": "30ced2204068305179b8406dd923e34824d265b2b7371275125e3461b0f9d620", + "src/import/Gen4HgssMenuArt.lua": "64cdedd0d181cae75df51bfbf0b460e2560ddd9eebcbfc0ab6c3dca8b56a41e6", + "src/import/PackData.lua": "9a1fc4f747d17237c22775e9378055f0ad38513b35b13f3230e1498fa1050d3e", + "src/import/Importers.lua": "79551c25cb8eeb9dbdd8445ecf0459655c2a50e6d096ded6555ab4d332fc4601", + "src/import/Gen4HgssTrainerCardArt.lua": "33754f392371f1f4742ecddcdbb144a9c856065f088bfe72e7928d02992cf5ec", + "src/import/Gen4DistWorld.lua": "9bf51ffd0d7405339354981775e75b893a5ea1de7f9c62a7f7a8c37c8d225c3d", + "src/import/Gen4ScriptBands.lua": "8e3156af4021cbb851d4b0251d84002c03d788ca0274517065f1724279586fc2", + "src/import/snes/Graphics.lua": "5400a2ed931e2767873389e4868aee8673397387c899a859d40dde7bddff0e68", + "src/import/snes/Lz2.lua": "9bbbf6223bce4ea7f9f8a5498eecf1ae196f1b7583759c55d7cff77fb4950445", + "src/import/lttp/SnesGfx.lua": "6f294fbf29b64dd9ce05ce71c5e0ea488837b2d98a62ef3be11d61b2c56e70b1", + "src/import/lttp/LttpImport.lua": "e303e798d973b4432cbe5a5aed48992f0674a124a7c601eec5f35c196f96e854", + "src/import/lttp/Lz2.lua": "099913192c77063b8decdec7a4c9e3781206d259ac78c55fabf05517dc80ffbb", + "src/import/lttp/Overworld.lua": "660f6a1c587c4657190c53a7a72da8fd3fb762c9014f3cb7a8d9ad3653aa5cab", + "src/import/pmd/Sprite.lua": "1c61fd5446c85d2600a7ad7e87fecea95f31b09102a5cd7329042b44a9cebfb5", + "src/pokewalker/WalkerRadarPools.lua": "8065d79cb74e4bfa88dba072287efe345403d287d8603274fb182932da50333e", + "src/pokewalker/WalkerEconomy.lua": "9f4e1122cec088896c2dfabd383024f678e9ad3d7b1fb9ec3751e6d2ee16fc7d", + "src/pokewalker/WalkerDelivery.lua": "8f084a6556aac8b97cbbc49ca6345931614cafb62ae9fe2272f05d0409afc368", + "src/pokewalker/Walker.lua": "3b88bbf30b01970f12e3ef9629bcbf046c092627003ec8dbd7a59f62929aa6db", + "src/pokewalker/WalkerEvents.lua": "3d7a21895e8b343010d4f1a08f25fe91ea905607cb1319aefe37ceac3b5fb499", + "src/pokewalker/WalkerShop.lua": "32abccbf6543d1a048b2b39754d44bc4c1cabdb7dad00a1cf56bcc3f3295e770", + "src/pokewalker/WalkerCourses.lua": "120dc847424c1be13f65617ee26fd7cbdd55397c0bef6b7cb8d6a21a49151524", + "src/pokewalker/WalkerNotify.lua": "c1e51924fe5ad8be79079f7bfd4c42e1df6f52785426372c7a6c961916e29765", + "src/pokewalker/WalkerArt.lua": "9dd09eaae8636dc1379a094ac6293c2b26471e3f81562147620c4d98dc6b7c18", + "src/pokewalker/WalkerInbox.lua": "28ba8e64fb595cb71796bb4bb5c829f5904c23ba47144e8aa76553d3db336623", + "src/pokewalker/WalkerRoutes.lua": "7f7a07cb6cd4b8baf14408833197fa745b575a5cc63a210e58341b0c32b8f057", + "src/pokewalker/WalkerLink.lua": "4a617d6a63b5369eccc5975397e30a99edcd86f54610a68321c2e6b485d3125b", + "src/pokewalker/WalkerSteps.lua": "d0ef0aa134ee49a7fdcf31b74d68c3c7abd5144c78fa5a5b1cb0db3509f37517", + "src/pokewalker/minigames/TreasureHunt.lua": "1c05eaf5bce009dcc6e7fc540994f1e4b1475d628309040cb6992e62d2a69a36", + "src/pokewalker/minigames/Gfx.lua": "fbd1fd80b1b131408b353247134f135b8d6ca3038c00009f92a01d1d93af34f2", + "src/pokewalker/minigames/CatchTiming.lua": "8b8a5e1cba3da4949363510ad6a67c24a8a37a77ed00426e05e2c041899d3327", + "src/pokewalker/minigames/Rewards.lua": "2f933a366280dd9cc3e04d93e6cf865dee394c097a6625d4af25ac477fe5a41c", + "src/pokewalker/minigames/JumpRunner.lua": "06e0037f657a4e2c384cf9eb37f3f2838ecca69d5ebf7b3d588a9c9c243d2558", + "src/pokewalker/minigames/Context.lua": "9d0b920f2074499a6dd2127d993a1afdeb450a4812bc52b96d5eb46f642b0216", + "src/pokewalker/minigames/RhythmTap.lua": "e81ae69b36e84d21d26604eb89428ee800dacab5ae7251813c8b50d96e693a7d", + "src/pokewalker/minigames/Base.lua": "8d5b8b3da1a22a42af3aac2e8eb1db564a41f05946e9cf73605657e36aa08968", + "src/pokewalker/minigames/MemoryMatch.lua": "25719608a817e8534c14f3a118ae3285ddfa389c634c7261f1e7364dc274b316", + "mods/DRAMATIC_SHAPE/main.lua": "b9285e7d22948ea731713ae501c43f8a673f1c397053ba8cf0d7120693381012", + "mods/DRAMATIC_SHAPE/manifest.json": "b94bdb437120be77c014937f0949a17ee81377db570abb46d997e80dd7e52ca3", + "mods/DRAMATIC_SHAPE/lib/HullCache.lua": "f00c5349d5fd394c11f6de81df78e533c41b92f09a82a12a3e1df3cad160811a", + "mods/DRAMATIC_SHAPE/lib/ShaderState.lua": "5a43d3be681ffaab6744425a1a6a9964c466b9a8a568e2286947dcc04ac38595", + "mods/DRAMATIC_SHAPE/lib/RetireQueue.lua": "ec210b5098aed8cf0f9a49a36ad414b95fd7e83c2fb7cf194c90297772242250", + "mods/DRAMATIC_SHAPE/lib/PolishedAtlas.lua": "3d84ab22295655f41ce5eeb1eb4e549a6926a101b9cfefe2d857f037cafaa3cf", + "mods/DRAMATIC_SHAPE/lib/HordeMobs.lua": "15a2548a9f1f1c62f03f41fa5ba4a644c372c0ae50f61876b68f282dbcdbbee5", + "mods/DRAMATIC_SHAPE/lib/FirstPerson.lua": "7ea2022fd7b44095b75608aa70b87dfd1ba311ad20397e2724f04ce8813fc2a4", + "mods/DRAMATIC_SHAPE/lib/WorldShadows.lua": "121d65657f992b93cf63b2f50455297943efe89d9150822a02b8e89ca160419d", + "mods/DRAMATIC_SHAPE/lib/VRXR.lua": "8d9fdb79d00864e94e5ca0ec39a40c3a3006335e1c3081c878ad51ef52c0c6ee", + "mods/DRAMATIC_SHAPE/lib/Voxel3D.lua": "8c19dd62281c8a2eff129627e51bf2d50c208c672d76808b9ec9ab31190807ed", + "mods/DRAMATIC_SHAPE/lib/MeshBatch.lua": "f9e4e401c619e79153b6a663638ae10a005f09c7b1f18b7944e29b8e259a59e1", + "mods/DRAMATIC_SHAPE/lib/GpuQuadSink.lua": "a8ea5f044b92b4eaf8653dd09d2fa24bacef36cf0022f42573239727bc9927b8", + "mods/DRAMATIC_SHAPE/lib/PolishedProfile.lua": "0887c7310119f7e59d6241386df130e8f5c42aa1e38fb8d532f217d5d3ea8156", + "mods/DRAMATIC_SHAPE/lib/HordeSfx.lua": "aead3d1be4ef435680bd24c64f7168d7bc7b55fa9010a83d325a3e6f54d10d43", + "mods/DRAMATIC_SHAPE/lib/ChunkMesher.lua": "d169f799b7f3919a01d394e16cede790ddc4da630a4fdb73226d2b10e7f825a1", + "mods/DRAMATIC_SHAPE/lib/BattleArena.lua": "d4a796948c7199cb006dac5f5a4236957edcfa775544a732340fd77b3505e66b", + "mods/DRAMATIC_SHAPE/lib/Buildings.lua": "0217c2a3c6e87b064cc9b2a381ec0c302696deff11c3233ceb000b05ba11ac1f", + "mods/DRAMATIC_SHAPE/lib/BattleExit.lua": "9a46fd8609a0269d0a52be8ceaac6a1cc15b418f8a6056e11b440f50800a7d10", + "mods/DRAMATIC_SHAPE/lib/TiltShift.lua": "6f48728062fc7654b9c5825525d2fc60b428314e28500cff495b92b8f45b1840", + "mods/DRAMATIC_SHAPE/lib/RenderScale.lua": "2cff29fdcac5aa5eb69020432cce25dab050a2f3475c20767bbc872573386774", + "mods/DRAMATIC_SHAPE/lib/Mat4.lua": "af1bf410b6d2855cce3d361837f9096613b17f4300a076e8b530fae5228b4827", + "mods/DRAMATIC_SHAPE/lib/BattleBillboard.lua": "0ee39926a60de750f8e3ae8f9ae7d8c87d59d5e66136d13b8fe0a73c6bea0d42", + "mods/DRAMATIC_SHAPE/lib/ShadowMap.lua": "049897e9f7ac8c791cfd54e22d13161e4d0ac3f70b6000689d1a5e8d118d7f26", + "mods/DRAMATIC_SHAPE/lib/HullBatch.lua": "0910e86248f5bae9d0e0c75ec33d26d86c45a1f7c0c568d7700c973dd834d70e", + "mods/DRAMATIC_SHAPE/lib/BorderMaskGraph.lua": "4f47d971f8e50dae8973bfdeaa09f7ad9ca3fa8651e3c868e00c4227ca644ebe", + "mods/DRAMATIC_SHAPE/lib/HordeExitPrompt.lua": "320592c560a68205158105ff6deaef14550f4fbe3b8a7e56809d9e4ca4a67160", + "mods/DRAMATIC_SHAPE/lib/WaterBudget.lua": "0616d66174acb385a52025e695e10488024ea93e9b28b5df9dd1fb844afdeb49", + "mods/DRAMATIC_SHAPE/lib/EnginePixelCanvas.lua": "f41b25296374ce7946cb4b020f686603d8b0616490bc6bac7e077c75fba70d9e", + "mods/DRAMATIC_SHAPE/lib/DayTint.lua": "d1a356f7e7c023a13a0e73789be3888b8fcef7dd6321b8b7e41e1946e6206a58", + "mods/DRAMATIC_SHAPE/lib/BattleScene.lua": "f06b1de4c323a6921136edd7230ca5eb0bb36fc189302cfe5735a0fa8fd8269e", + "mods/DRAMATIC_SHAPE/lib/StampStream.lua": "2089062b32c18a99368bc0c3c840f93e85a10143e2a440c3f89b770a25b81e5d", + "mods/DRAMATIC_SHAPE/lib/BattlePics.lua": "14c8f8f20c4fd7289eef1b1d5ce456b7927e39baf7e367c11b2a6b565078a16e", + "mods/DRAMATIC_SHAPE/lib/EmeraldBuildings.lua": "537c2fba21def0740bd9690fdcf031fa425f309ad0e7f9e17264648d2009fc19", + "mods/DRAMATIC_SHAPE/lib/VoxelDiskCache.lua": "c796f3b07839c8d47a93252704fef0ca41179021f4f5a3d71515df7cfdc13315", + "mods/DRAMATIC_SHAPE/lib/TilePlotCache.lua": "bc3a307b98939278b0c4e85c5e5660c6cdc05f1780e71d2eb4a52f6480a4aa4a", + "mods/DRAMATIC_SHAPE/lib/Rewrap.lua": "59dc977ef93b8628bf35573ef5fabe3e3ab78d8792630927a6c838eba4698dea", + "mods/DRAMATIC_SHAPE/lib/GpuMesher.lua": "c3a4d8484aef31a655fae964fda6902ee8833a107864a84ca7f5e28e6eab7c81", + "mods/DRAMATIC_SHAPE/lib/AnalysisCache.lua": "5c117885d46781a698393ad892404e6bc47b849d2be617aff168834d157466be", + "mods/DRAMATIC_SHAPE/lib/NativeGen4.lua": "a815e76e8a500de55fa6cea437b22a203327ed0d7ffdd5a2896c88901714f3f1", + "mods/DRAMATIC_SHAPE/lib/VRRig.lua": "222909d72fed70594a04665ed47b716a495bafa956e079a83f4acf562faf32ed", + "mods/DRAMATIC_SHAPE/lib/AntiAlias.lua": "7b50c401ea1a34a022391042befe170d6e0479edae2f69a6783597473c976b6c", + "mods/DRAMATIC_SHAPE/lib/AtlasPixels.lua": "cb8bfaa227c2205277b8782c847548e086e34fabd10c848406cf1b3a643fa087", + "mods/DRAMATIC_SHAPE/lib/BuildBudget.lua": "85c8556db6f747e62d39c74c7285704a2bd42f54a0b9e3ee22119dd571cb631e", + "mods/DRAMATIC_SHAPE/lib/VoxelPrebake.lua": "59f17fff46250d8eecae23bb1ffaefeb10d8e4e6b9ce16daf068eca7dbf67f1d", + "mods/DRAMATIC_SHAPE/lib/TerrainAtlas.lua": "19f226eb2a05539b43460b2302f4eae3a373dac3f8fb1b9e77f9fe6df82fefc6", + "mods/DRAMATIC_SHAPE/lib/BattleDOF.lua": "da42434316b734758ea562a528719e2f8879859bd2e7babfad392cc11a754c62", + "mods/DRAMATIC_SHAPE/lib/DayNight.lua": "b0556aab01bcc2679b45dd61ce55d2a4186c3f52d134671a8eb24b2a170d7f14", + "mods/DRAMATIC_SHAPE/lib/GraphicsCompat.lua": "575c8ed741f8f3344dcefa4dfd4f539caac13f1f2b1ea69f5120d3a4dfed179c", + "mods/DRAMATIC_SHAPE/lib/Structures.lua": "c4dc200239c3119bf87a226473ce72fa4b5290ed66fc204801c1b3be06a81781", + "mods/DRAMATIC_SHAPE/lib/EngineSheets.lua": "2fe429ca76673f99208dbd94bea8462a679530e934b96a052d88e14e45e09c82", + "mods/DRAMATIC_SHAPE/lib/NativeStage.lua": "5cc43771afad181afd45b5ceb96039c0e48dde76aa8a4133ad204ebaf4a36f5e", + "mods/DRAMATIC_SHAPE/lib/VRGL.lua": "cd64c759d8e5206151307904bf039c16d8b046b9349ad2f6c4b3eafe5949d73e", + "mods/DRAMATIC_SHAPE/lib/Pokedex.lua": "f0e7c0a9429486094e84b90ab94bab695d78a2c4ceb0e7c88e56c43206b9f9d8", + "mods/DRAMATIC_SHAPE/lib/VoxelScene.lua": "115e2a358d94bdafdbaf6395edd15bc76b678515f36ba2c7c61f90472093e195", + "mods/DRAMATIC_SHAPE/lib/BattleHud.lua": "0bd31b17c37686068e747bff362b647c1d9614316340f3f313cd2f53abc88f31", + "mods/DRAMATIC_SHAPE/lib/Sky.lua": "dedc35325fd7923c0dba940dc90b5a1f50574ac7775b98367632c76a366ed992", + "mods/DRAMATIC_SHAPE/lib/TileShape.lua": "1ded661f0a08c719ce42994d643c43cbffcaa03e27ae260a1ed4d3d444e90931", + "mods/DRAMATIC_SHAPE/lib/Tier.lua": "a02d73cd3eef5461632a2fc6e6934cb1d2298c15fc199d1500a59d3bb76ab386", + "mods/DRAMATIC_SHAPE/lib/Water.lua": "47ea97b33cd6b3fe9b8cfb65e763fe04d5f2857e48dd0759c8074ac7405dfa9a", + "mods/DRAMATIC_SHAPE/lib/ImageCache.lua": "9f7b3b008a475243086ccf542f3f80821fec520395df9b66894da9d29642a776", + "mods/DRAMATIC_SHAPE/lib/HordeHud.lua": "67faa79d32c546a6f92b3f0a5c839896bd6718dba5a099307d9f43b82d6f8cd4", + "mods/DRAMATIC_SHAPE/lib/ModSetting.lua": "1ca8f395454f50b5c54708242ca8fe986d0f138482d7540887a0309b71deddd2", + "mods/DRAMATIC_SHAPE/lib/OverworldBattle.lua": "5ee8feb4d65c1200b6c729a92ca3da9c2f85625a1498e62806456a31cffd5c90", + "mods/DRAMATIC_SHAPE/lib/Horde.lua": "28d40cc8019bcde39cbc9d19eb390156e4576decdd52b92e49da8306b9e5bef9", + "mods/DRAMATIC_SHAPE/lib/AtlasUpdates.lua": "eac5fbf72f24c13a3ea3e9d0f5cb8c8fa49a58c7fca46f0d06b646263a1bc00b", + "mods/DRAMATIC_SHAPE/lib/RenderStamp.lua": "1f418a058610659dd86778d756e608ea23971fe77ad6f545be27fc5ea1321e4e", + "mods/DRAMATIC_SHAPE/lib/GrassSprites.lua": "20c15d5decc2616341caa809a01fb5d7ff648b6ae11256e5df94b63cd6cb9519", + "mods/DRAMATIC_SHAPE/lib/CamControl.lua": "2c3f2ede7229a1b209e44acbda65a90317687f80b765f00573228a52ca42e68f", + "mods/DRAMATIC_SHAPE/lib/VoxelGrid.lua": "60856c15b4fe39f9371032b9a0847a642e6ef6719664e7698a51d3776c4bdabb", + "mods/DRAMATIC_SHAPE/lib/HordeGun.lua": "1cf8f87be6a6c4bc61ac01bfdd64ef7eb26fead8c682adfde708af39baa35a51", + "mods/DRAMATIC_SHAPE/lib/VR.lua": "18a8431d5fb5eebf84fb7216b0855df556d57be72ea706bbf64e2f7309120ce8", + "mods/DRAMATIC_SHAPE/lib/ThirdPerson.lua": "89d33454a0b8262ea5fac1d477870badcf99ce7c1ac2f99482981a368413935b", + "mods/DRAMATIC_SHAPE/lib/HordeGameOver.lua": "f56e5d3ece1e6cc7ac2d6a442df250b044e718525b9e9cd0f363c6179d2cf97b", + "mods/DRAMATIC_SHAPE/lib/WorldCurve.lua": "91ee9b80d6b9dec75f7e643159fc9ff52a7dcd7bea06c036295dda9828831a94", + "mods/DRAMATIC_SHAPE/lib/GlassMask.lua": "242327056d1b09d28278588f6b8c712f665ac57c38a5b4fb4320903310d98778", + "mods/DRAMATIC_SHAPE/lib/MeshBounds.lua": "aa6498641058e536f57764235ece43778c4ceeb831ea2629e2891892bc2c18a8", + "mods/DRAMATIC_SHAPE/lib/Gen3.lua": "268de6258e4910ee7f4e37eaf665a3a9fc01a4af98d7a09791171996122a473d", + "mods/DRAMATIC_SHAPE/lib/BattleCam.lua": "ea605b9ecb9c272676772344334eef6d6d23b1fa92bac7402c7f16aebe9d780b", + "mods/DRAMATIC_SHAPE/lib/HullPoints.lua": "bf0849eb323cc5af8f08818dec6b6ce4902d6537f7936ff6ceeab88d2dc22329", + "mods/DRAMATIC_SHAPE/lib/QuadIndices.lua": "95d334fb8277c10de48a12f5c8d10f7b96df903911159bb65cf67852146ebbde", + "mods/DRAMATIC_SHAPE/lib/PolishedFruitSprite.lua": "43582c5a5ecf40eb61eb04ab1d1149875defbeb35b482f390d8a54026dd8fbbe", + "mods/DRAMATIC_SHAPE/lib/PixelCanvas.lua": "a86164fa7147644e2c497fcf3ce4d6c00e1bbfc0d81601b35ac6f89f7bb6883c", + "mods/DRAMATIC_SHAPE/lib/Perf.lua": "f88c185ec321b98b1ca5e797f4601b165fa042487d947024fc30c64d271a9802", + "mods/DRAMATIC_SHAPE/lib/VoxelState.lua": "0d6aeee58aebeea880d8dbf482d7243b55b60c413146db5a5684a91528fedced", + "mods/DRAMATIC_SHAPE/lib/SpriteBillboards.lua": "827e540dbb8a897e576adc5a4119f93af275b77aad9876d854a0373c52ccc138", + "mods/DRAMATIC_SHAPE/lib/FreeMove.lua": "35c7e609652472255ab7c8ed2db50cdcfe493de154c8a4c04a7a2cfd48181305", + "mods/DRAMATIC_SHAPE/data/gen3_terraces.lua": "2d340b90d55a351f03ca3d2bc53b154f369c88e58954cb093aa2677c25d10f26", + "mods/DRAMATIC_SHAPE/data/gen3_shapes.lua": "a6e5c69d91c53d05282afed8499cfa41226702baa0f83da7f3cbf49bbbcc2fce", + "mods/DRAMATIC_SHAPE/data/gen3_palings.lua": "dd9cb9cf168545c61c9f328bdacc983d8975f26e9879c3f375ffd1916c6e9832", + "mods/DRAMATIC_SHAPE/data/battle_arenas.lua": "b94c2f2df11baff890869cf9a5528632e28dd291c994933eac3824785d6c9ecc", + "mods/DRAMATIC_SHAPE/data/gen3_metatiles.lua": "5dc2a6be45e57057c0ed8e94388741d76eaa266a853eaa40133edbcca99d5387", + "mods/DRAMATIC_SHAPE/data/gen3_maps.lua": "2fa3f7d3bc69e24269331e5992b8be8791ab455d3c43772001a55a17e2547a55", + "mods/DRAMATIC_SHAPE/data/voxel_heights.lua": "71610ea7b18e4053728b8506b41fa2fb7ed8e09ce5e81e70ccfe95af408e3aaf", + "mods/DRAMATIC_SHAPE/data/polishedcrystal/voxel_indices.lua": "6a2381c84da1c5b6877fca232741629523f78ba2ebe5321035be6ea52142a554", + "mods/DRAMATIC_SHAPE/data/polishedcrystal/furniture.lua": "6c79002470fbb753a421e25fe6440f69b31a1850b1b9261cf360fba6872c0bef", + "mods/DRAMATIC_SHAPE/data/firered/gen3_palings.lua": "e036aafe4e07e3a749e54537f38b55ff21dc07f5322df3fe1dae662326f4734f", + "mods/DRAMATIC_SHAPE/data/firered/gen3_metatiles.lua": "36dbc083583aaaa8357f0177d282d15f84895dabd678ddf134513f323d96d0a2", + "mods/DRAMATIC_SHAPE/data/firered/gen3_maps.lua": "af479e8433b1d728bc9d8d529eeba7405fe6c724fedf36fd19dd8a13f5e9bfab" + }, + "driverSha256": "ef1131b63ddf8b3a9b9556bbd78f04f36da8e5107fa87ab7d3288519c431f881", + "minimumFreeGiB": 20, + "games": { + "native": { + "edition": "emerald", + "architecture": "arm64", + "simulationHz": 60, + "phases": [ + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.49790746527778784 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.17459218750003708 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0023784722223529897 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.01573489583333032 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0012852430556635022 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.0032104166665880562 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.3460383680555049 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.2580565972222634 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.2715119791666498 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.36096354166677 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 9.030534027777744 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.25547986111109644 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.004042708333277615 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.002932291666690734 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.001277083333469283 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.08104236111119063 + }, + " update: music": { + "windows": 2, + "meanMs": 0.06019947916664122 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.01713194444438552 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.06910017361100307 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.23380798611100095 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 10.707645138888878 + } + }, + "fpsCap": 60, + "present": { + "meanMs": 0.3601967013888929, + "p50Ms": 0.35245833333430454, + "p95Ms": 0.397124999999221, + "samples": 240, + "p99Ms": 0.40908333333256053 + }, + "vsync": 1, + "elapsedSeconds": 4.974142666666666, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 20.805987970711275, + "p50Ms": 16.678291666666567, + "p95Ms": 18.240500000000992, + "samples": 239, + "p99Ms": 21.201708333332014 + }, + "card": "P1" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.30275954861109045 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.42259236111102566 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0041888888889266935 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.19896128472161045 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0022769097223651613 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.005755729166767587 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 1.112777430555393 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.9962954861111022 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.07947569444460474 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.4066420138889285 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 14.600797743055619 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.005610937500044599 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.006154861111292339 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 0.3171631944444586 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0038475694444040087 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.35535000000022077 + }, + " update: music": { + "windows": 2, + "meanMs": 0.05533524305552836 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.024421006944500924 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.029184027777902816 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.9673236111110558 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.9907213541667865 + } + }, + "fpsCap": 60, + "present": { + "meanMs": 0.4207958333333058, + "p50Ms": 0.4203333333343551, + "p95Ms": 0.4658749999997269, + "samples": 240, + "p99Ms": 0.48741666666884953 + }, + "vsync": 1, + "elapsedSeconds": 3.9701822083333322, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 16.602120118549507, + "p50Ms": 16.672041666664583, + "p95Ms": 17.85925000000077, + "samples": 239, + "p99Ms": 18.597583333335166 + }, + "card": "P1" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.08542413194578083 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.2972538194441506 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0026890625000941526 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.002287500000175413 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 1.9751751736112468 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.7183272569445769 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.6414664930556757 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.0765656249999136 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.20061336805593893 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 4.625552430555668 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.63671145833335 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0035819444446823683 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.002818923610907002 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.008072916666771826 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.19604496527763082 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.022921180555712795 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.018548263889147286 + }, + " update: music": { + "windows": 2, + "meanMs": 0.028725868055682703 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.6131835069443407 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.005145312500006345 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.2954885416668625, + "p50Ms": 0.38583333333264136, + "p95Ms": 0.45766666666224864, + "samples": 240, + "p99Ms": 0.48020833333950463 + }, + "vsync": 0, + "elapsedSeconds": 1.852431833333334, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 7.720079497907943, + "p50Ms": 6.225374999992539, + "p95Ms": 15.866750000000707, + "samples": 239, + "p99Ms": 16.76220833333275 + }, + "card": "P2" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.34213923611116454 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.2467182291668202 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.004387152777581467 + }, + " update: music": { + "windows": 2, + "meanMs": 0.040143229166966776 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0021788194444501605 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.005009201389016837 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.966205034722023 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.8592093749997698 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.07689618055544874 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.30280312499984713 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 3.798583159722316 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.005752604166486819 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.007768229166593699 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.4019213541669546 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.003772916666762901 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.11319704861110225 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.02850937499991879 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.022674479166454965 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.027639409722442682 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.8268696180558255 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.8525468750001746 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.023374479166567614, + "p50Ms": 0.022625000006826212, + "p95Ms": 0.03350000000068576, + "samples": 240, + "p99Ms": 0.044291666668527796 + }, + "vsync": 0, + "elapsedSeconds": 1.7467716666666675, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 7.302367677824275, + "p50Ms": 6.209083333331478, + "p95Ms": 15.41841666666599, + "samples": 239, + "p99Ms": 15.980833333333777 + }, + "card": "P2" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.3992840277776614 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.2451385416668117 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0036222222224768785 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.026395833333081598 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0017378472220978363 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.005035069444359053 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 1.1527711805558707 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.9039949652773327 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.10894045138862829 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.32641597222224183 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0038461805553483925 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.8966864583336805 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.011103819444328405 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.826288715277814 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.002128298611140759 + }, + " update: music": { + "windows": 2, + "meanMs": 0.17838715277775918 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.030748263888839368 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.023782291666870257 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.11019184027804367 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.8706876736110332 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 2.4563439236110858 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.026294965277617877, + "p50Ms": 0.02529166666320748, + "p95Ms": 0.04308333332758707, + "samples": 240, + "p99Ms": 0.053624999996060296 + }, + "vsync": 0, + "elapsedSeconds": 1.5830512083333304, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 6.6018125871687525, + "p50Ms": 6.2344999999943695, + "p95Ms": 14.46174999999883, + "samples": 239, + "p99Ms": 15.32566666666213 + }, + "card": "P3" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.3836902777782628 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.2741229166667155 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0055611111110298594 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.004095833333828125 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0019831597222778425 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.006661979166470525 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 1.1829500000005635 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 1.0398100694446466 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.08772187499856976 + }, + " update: music": { + "windows": 2, + "meanMs": 0.05594635416668048 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 2.833618750000122 + }, + " step: whole": { + "windows": 2, + "meanMs": 1.0330409722221803 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.011414062499988848 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.664316493055526 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.011806944444581191 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.0261173611108223 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.12621180555630515 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.024380902777595047 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.029651562500765394 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 1.0056237847225387 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.3440744791662306 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.026160416666535607, + "p50Ms": 0.024041666662810712, + "p95Ms": 0.04412500000228192, + "samples": 240, + "p99Ms": 0.05933333332919233 + }, + "vsync": 0, + "elapsedSeconds": 1.6331057916666651, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 6.8239102161785015, + "p50Ms": 6.317166666661933, + "p95Ms": 14.506041666663805, + "samples": 239, + "p99Ms": 15.551791666663206 + }, + "card": "P3" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.45207743055533106 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.2992987847228494 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.003957291666948729 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.010248958333125559 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.002578993055143049 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.006068923611124433 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.9459303819446891 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.8268656249999099 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.2193322916667443 + }, + " update: music": { + "windows": 2, + "meanMs": 0.05470677083344218 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 4.223928124999876 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.8173473958333528 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.00837517361131172 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.6863362847223606 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.010667187500068565 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.0316164930554604 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.11602847222175565 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.030780381944624697 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.042563888888539715 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.773203819444627 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.3070350694442207 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.03909531250002966, + "p50Ms": 0.031500000005735274, + "p95Ms": 0.07075000000611453, + "samples": 240, + "p99Ms": 0.21924999999356487 + }, + "vsync": 0, + "elapsedSeconds": 1.954807124999995, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 8.14158786610878, + "p50Ms": 6.166374999999391, + "p95Ms": 16.586625000002186, + "samples": 239, + "p99Ms": 33.72341666666756 + }, + "card": "P4" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.3514807291666422 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.11899236111077016 + }, + " update: music": { + "windows": 2, + "meanMs": 0.020493749999742995 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.002153298611240473 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0013501736110181164 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0018335069441673588 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.34138142361079815 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.3003663194442119 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.13139201388939387 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.17933993055481895 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 4.2370694444445665 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.2969024305554532 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0038312499998305327 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.003788194444916589 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0007869791669593933 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.05952812499897912 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.0231916666666739 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.0253218750002245 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.04235572916639896 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.27154670138885467 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.790489236111071 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.03310833333355726, + "p50Ms": 0.026083333331428094, + "p95Ms": 0.05070833333320479, + "samples": 240, + "p99Ms": 0.06962500000184946 + }, + "vsync": 0, + "elapsedSeconds": 1.8118586249999993, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 7.570836297071125, + "p50Ms": 6.403708333330371, + "p95Ms": 16.44954166666679, + "samples": 239, + "p99Ms": 17.797874999999408 + }, + "card": "P4" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.23901371527808024 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.10369409722255085 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.015609722221595254 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0021201388892203945 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0015749999997242508 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.002473611111260728 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.45567413194452416 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.4030769097221073 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.06233663194414874 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.16559999999996577 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 6.115118923610799 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.39623923611162104 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.005280381944257803 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0039053819446503253 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0015053819441964342 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.056597395832014286 + }, + " update: music": { + "windows": 2, + "meanMs": 0.02541145833359811 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.02149461805635392 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.030275520833278332 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.37639513888866694 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 1.6684697916672597 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.027260590278199714, + "p50Ms": 0.026541666670709674, + "p95Ms": 0.04583333333130213, + "samples": 240, + "p99Ms": 0.05329166666712126 + }, + "vsync": 0, + "elapsedSeconds": 2.0056575416666647, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 8.365920327754521, + "p50Ms": 3.589041666671733, + "p95Ms": 17.178000000008353, + "samples": 239, + "p99Ms": 18.174125000001595 + }, + "card": "P5" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.27386527777772207 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.12078177083300426 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.017743229166544694 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0016664930558481692 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0014640625000339469 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.0020661458343340655 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.49098211805578273 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.41023524305566406 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.07939565972131153 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.17709930555485087 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 5.679511111111128 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.40672864583323565 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0061267361111265 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0037543402777136707 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0009451388895224492 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.06295538194457015 + }, + " update: music": { + "windows": 2, + "meanMs": 0.044566145833109985 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.022706770833380574 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.03409201388917538 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.38345920138880274 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 1.8691546875003517 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.030081597221946293, + "p50Ms": 0.027916666667238133, + "p95Ms": 0.04762499999344527, + "samples": 240, + "p99Ms": 0.06445833334112194 + }, + "vsync": 0, + "elapsedSeconds": 1.9589907499999981, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 8.188237796373754, + "p50Ms": 6.337999999999511, + "p95Ms": 17.552333333341608, + "samples": 239, + "p99Ms": 18.203291666665677 + }, + "card": "P5" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.22869045138905383 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.12707673611120165 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.01856614583285084 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.002765277777102189 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0017682291668800572 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.002762326389849079 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.481777777778151 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.4088437500008979 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.08113368055602994 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.20663402777723047 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 5.974373611111202 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.405649652777947 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.006460937499882155 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.005221527778420665 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0012772569445947586 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.08329583333305615 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.03617638888859057 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.025251215278565553 + }, + " update: music": { + "windows": 2, + "meanMs": 0.03811163194399707 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.36238159722209085 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 1.7012376736104784 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.03283871527729095, + "p50Ms": 0.03012499999499596, + "p95Ms": 0.046749999995654434, + "samples": 240, + "p99Ms": 0.07566666666036781 + }, + "vsync": 0, + "elapsedSeconds": 1.9895978333333346, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 8.319948221757315, + "p50Ms": 6.04783333332648, + "p95Ms": 17.804874999995945, + "samples": 239, + "p99Ms": 18.368291666668313 + }, + "card": "P6" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.24944739583293202 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.11139340277711203 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.019063194444276142 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0016203125003499963 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0019458333329798923 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.0020694444448743354 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.4403657986107312 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.3651741319445456 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.092690277779095 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.19125677083415837 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 5.90617795138891 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.36204861111057696 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0039800347225652875 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0040399305558066105 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0015227430553525307 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.07008142361071279 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.03569999999939455 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.024861458333151155 + }, + " update: music": { + "windows": 2, + "meanMs": 0.044542708333346795 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.33820243055524557 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 1.787778124999632 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.03096510416661739, + "p50Ms": 0.02983333333617111, + "p95Ms": 0.047041666654479286, + "samples": 240, + "p99Ms": 0.08195833335378211 + }, + "vsync": 0, + "elapsedSeconds": 1.9850945833333355, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 8.300677301255236, + "p50Ms": 7.329208333324289, + "p95Ms": 17.653416666661315, + "samples": 239, + "p99Ms": 17.94758333333135 + }, + "card": "P6" + }, + { + "enabled": false, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.399957118055975 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.15994288194507433 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.0214656250000426 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.002014236110407334 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0012348958329096622 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.0025848958335572547 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.4614185763892683 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.3897052083325766 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.08310138888868816 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.21752777777832577 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 3.4080913194446323 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.3863876736108848 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.004375347222218551 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0035690972227146274 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0009090277774070898 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.079100868052695 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.04339409722229348 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.037366493055894054 + }, + " update: music": { + "windows": 2, + "meanMs": 0.037163715277621634 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.36187795138893364 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 3.0733723958329113 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.04016111111087758, + "p50Ms": 0.03262500001000035, + "p95Ms": 0.09083333331716403, + "samples": 240, + "p99Ms": 0.23241666666962146 + }, + "vsync": 0, + "elapsedSeconds": 1.7039781666666727, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 7.105942468619225, + "p50Ms": 6.207166666662545, + "p95Ms": 16.04199999999878, + "samples": 239, + "p99Ms": 16.920791666663604 + }, + "card": "P9" + }, + { + "enabled": true, + "world": "MAP_G00_N15", + "sections": { + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.3707079861105633 + }, + " world: entities": { + "windows": 2, + "meanMs": 0.1682434027775237 + }, + " tiles: ensureWindow": { + "windows": 2, + "meanMs": 0.06706076388907078 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.002457118055421148 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.017083680555470455 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 2.799895833333347 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.4804137152770357 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.39820295138917794 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.10849843750110702 + }, + " world: tiles": { + "windows": 2, + "meanMs": 0.19974374999955913 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 3.999949305555095 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.005511631945095038 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.003470486111438002 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.0038769097223815647 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0012675347224562947 + }, + " update: music": { + "windows": 2, + "meanMs": 0.034332465277694034 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.046464062500106706 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.03322604166588287 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.02029479166682317 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.36730052083363185 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.3950276041662093 + } + }, + "fpsCap": 160, + "present": { + "meanMs": 0.04054635416608221, + "p50Ms": 0.03187499999768306, + "p95Ms": 0.07137500000453656, + "samples": 240, + "p99Ms": 0.16833333333465816 + }, + "vsync": 0, + "elapsedSeconds": 1.7893360833333318, + "windows": 2, + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "cameraMotion": "native camera follow", + "shadowCasts": [], + "intervals": { + "meanMs": 7.472740062761489, + "p50Ms": 6.275416666667866, + "p95Ms": 17.709166666662668, + "samples": 239, + "p99Ms": 18.821750000000748 + }, + "card": "P9" + } + ], + "limitations": [ + "sequential off/on samples; no causal whole-game speedup claim", + "Emerald does not exercise DS Nitro synthesis or GB palette uniforms", + "LOW native UI quad menus are covered by their separate allocation fixture" + ], + "measurement": { + "perSwitchWarmup": 180, + "profileWindow": 120, + "samples": 240, + "warmupNative": 240, + "warmupVoxel": 1500 + }, + "timing": "FrameProfile wall time; GPU submission, no completion fence; no screenshots inside intervals", + "runtime": [ + 12, + 0, + 0, + "Bestest Friend" + ], + "passed": true, + "mode": "native", + "gpu": { + "renderer": "Metal", + "device": "Apple M2", + "vendor": "", + "api": "2.1" + } + }, + "voxel": { + "phases": [ + { + "enabled": false, + "windows": 2, + "vsync": 0, + "fpsCap": 160, + "present": { + "meanMs": 0.022437326390445378, + "p50Ms": 0.022708333347054577, + "samples": 240, + "p99Ms": 0.033583333333808696, + "p95Ms": 0.02987499999562715 + }, + "intervals": { + "meanMs": 8.429272838214771, + "p50Ms": 8.931083333322931, + "samples": 239, + "p99Ms": 15.901875000025711, + "p95Ms": 14.817708333339397 + }, + "voxelLevel": 3, + "cameraMotion": "controlled 0.25 world-pixel pan per update", + "shadowCasts": { + "shadow.cast": 240 + }, + "world": "MAP_G00_N15", + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "elapsedSeconds": 2.017875874999987, + "card": "P7", + "sections": { + " hook world.tod <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/main.lua:1737": { + "windows": 2, + "meanMs": 0.1106043402774759 + }, + " hook input.step <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/lib/Horde.lua:623": { + "windows": 2, + "meanMs": 0.005437673611533948 + }, + " pipeline: voxel": { + "windows": 2, + "meanMs": 0.03548437499745205 + }, + " pipeline: tiltshift": { + "windows": 2, + "meanMs": 0.001681597222713549 + }, + " world pass: voxel": { + "windows": 2, + "meanMs": 2.715046180556395 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.03296944444469337 + }, + " voxel: sun pass": { + "windows": 2, + "meanMs": 0.9418126736137584 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.05559548610764391 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.2535001736090928 + }, + " voxel: terrain": { + "windows": 2, + "meanMs": 0.12729270833157597 + }, + " update: music": { + "windows": 2, + "meanMs": 0.014209027777075297 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.001570833334578007 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.04270399305331314 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.33433871527866665 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.001142708333408867 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0022684027774981055 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.2495055555549186 + }, + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.15033749999998253 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.01920902777818867 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 7.0878850694469975 + }, + " voxel: water": { + "windows": 2, + "meanMs": 0.034555208333012885 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0026380208352586956 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.0247074652775107 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.002109548610581176 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.2197496527787261 + }, + " voxel: cards": { + "windows": 2, + "meanMs": 0.6550522569449176 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 0.8995496527781199 + } + } + }, + { + "enabled": true, + "windows": 2, + "vsync": 0, + "fpsCap": 160, + "present": { + "meanMs": 0.0238498263875897, + "p50Ms": 0.022458333319264057, + "samples": 240, + "p99Ms": 0.05699999999819738, + "p95Ms": 0.037333333324340856 + }, + "intervals": { + "meanMs": 8.493223152022395, + "p50Ms": 9.03358333334836, + "samples": 239, + "p99Ms": 15.26287499996215, + "p95Ms": 14.79208333333304 + }, + "voxelLevel": 3, + "cameraMotion": "controlled 0.25 world-pixel pan per update", + "shadowCasts": { + "shadow.static.cast": 9, + "shadow.dynamic.cast": 240 + }, + "world": "MAP_G00_N15", + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "elapsedSeconds": 2.032040875000007, + "card": "P7", + "sections": { + " hook world.tod <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/main.lua:1737": { + "windows": 2, + "meanMs": 0.08174618053497322 + }, + " hook input.step <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/lib/Horde.lua:623": { + "windows": 2, + "meanMs": 0.005720312502551413 + }, + " pipeline: voxel": { + "windows": 2, + "meanMs": 0.04609114583544738 + }, + " pipeline: tiltshift": { + "windows": 2, + "meanMs": 0.002034548607454667 + }, + " world pass: voxel": { + "windows": 2, + "meanMs": 2.7421758680560515 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.04112274305742384 + }, + " voxel: sun pass": { + "windows": 2, + "meanMs": 0.7756128472208227 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.05531111111451992 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.3278739583305897 + }, + " voxel: terrain": { + "windows": 2, + "meanMs": 0.15537361111069478 + }, + " update: music": { + "windows": 2, + "meanMs": 0.016452604168293306 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0019630208340023123 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.05418923611119434 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.4240703125014752 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.001363194443963304 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0018829861113308273 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.3233065972212993 + }, + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.12348958333336196 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.021127604167029784 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 7.098802951387503 + }, + " voxel: water": { + "windows": 2, + "meanMs": 0.0450168402782225 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0034388888866014136 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.025685069443544727 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.0018961805600052153 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.2911411458314935 + }, + " voxel: cards": { + "windows": 2, + "meanMs": 0.7202407986106607 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 0.8555972222196099 + } + } + }, + { + "enabled": false, + "windows": 2, + "vsync": 0, + "fpsCap": 160, + "present": { + "meanMs": 0.023449131944275337, + "p50Ms": 0.022083333362843405, + "samples": 240, + "p99Ms": 0.058499999994410246, + "p95Ms": 0.03158333328201479 + }, + "intervals": { + "meanMs": 8.547566422594073, + "p50Ms": 8.64766666666128, + "samples": 239, + "p99Ms": 17.10745833332794, + "p95Ms": 14.692291666648316 + }, + "voxelLevel": 3, + "cameraMotion": "native camera follow", + "shadowCasts": { + "shadow.static.cast": 8, + "shadow.dynamic.cast": 240 + }, + "world": "MAP_G00_N15", + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "elapsedSeconds": 2.0467259166666736, + "card": "P8", + "sections": { + " hook world.tod <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/main.lua:1737": { + "windows": 2, + "meanMs": 0.0924618055156164 + }, + " hook input.step <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/lib/Horde.lua:623": { + "windows": 2, + "meanMs": 0.005585243054421577 + }, + " pipeline: voxel": { + "windows": 2, + "meanMs": 0.03462465277953204 + }, + " pipeline: tiltshift": { + "windows": 2, + "meanMs": 0.0015611111117882124 + }, + " world pass: voxel": { + "windows": 2, + "meanMs": 2.7714302083348725 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.02822482638672833 + }, + " voxel: sun pass": { + "windows": 2, + "meanMs": 0.6287888888902415 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.052896180548363717 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.21917690972242573 + }, + " voxel: terrain": { + "windows": 2, + "meanMs": 0.3438159722211509 + }, + " update: music": { + "windows": 2, + "meanMs": 0.013511805554955458 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0013590277793203618 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.04046874999919699 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.29497378472515356 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.2153925347261776 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0020411458341603366 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 7.281948263889385 + }, + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.1396723958335381 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.01575416666597107 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0016246527771102137 + }, + " voxel: water": { + "windows": 2, + "meanMs": 0.09131128471947156 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.002786979170347574 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.025137152779208805 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.0017555555556233078 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.19054322916633265 + }, + " voxel: cards": { + "windows": 2, + "meanMs": 0.7007552083329927 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 0.8670545138912189 + } + } + }, + { + "enabled": true, + "windows": 2, + "vsync": 0, + "fpsCap": 160, + "present": { + "meanMs": 0.02431753472092642, + "p50Ms": 0.023874999982353984, + "samples": 240, + "p99Ms": 0.054624999989982825, + "p95Ms": 0.04345833335150928 + }, + "intervals": { + "meanMs": 8.546358263598453, + "p50Ms": 8.908583333322895, + "samples": 239, + "p99Ms": 17.526000000003705, + "p95Ms": 15.047166666647627 + }, + "voxelLevel": 3, + "cameraMotion": "native camera follow", + "shadowCasts": { + "shadow.static.cast": 8, + "shadow.dynamic.cast": 240 + }, + "world": "MAP_G00_N15", + "stack": "...eady-metal/source/tools/full_game_performance_driver.lua:73", + "elapsedSeconds": 2.046862625000017, + "card": "P8", + "sections": { + " hook world.tod <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/main.lua:1737": { + "windows": 2, + "meanMs": 0.08897291666301044 + }, + " hook input.step <- Gen2Recomped-DramaticShapes @ mods/DRAMATIC_SHAPE/lib/Horde.lua:623": { + "windows": 2, + "meanMs": 0.006951041667354237 + }, + " pipeline: voxel": { + "windows": 2, + "meanMs": 0.05130121527834319 + }, + " pipeline: tiltshift": { + "windows": 2, + "meanMs": 0.003108680557180795 + }, + " world pass: voxel": { + "windows": 2, + "meanMs": 2.892305208333923 + }, + " world: FX": { + "windows": 2, + "meanMs": 0.03943680555025253 + }, + " voxel: sun pass": { + "windows": 2, + "meanMs": 0.8711494791670068 + }, + "(unmeasured / present / GPU)": { + "windows": 2, + "meanMs": 0.05786961805966939 + }, + " update: fixed steps": { + "windows": 2, + "meanMs": 0.3501102430562971 + }, + " voxel: terrain": { + "windows": 2, + "meanMs": 0.18821770833289025 + }, + " update: music": { + "windows": 2, + "meanMs": 0.02190434027831145 + }, + " update: tilt": { + "windows": 2, + "meanMs": 0.0017180555521652727 + }, + " pipelines: update": { + "windows": 2, + "meanMs": 0.06118420138783165 + }, + "update: whole frame": { + "windows": 2, + "meanMs": 0.45662743055541927 + }, + " update: graphics stack drain": { + "windows": 2, + "meanMs": 0.0011550347210231848 + }, + " update: gc step": { + "windows": 2, + "meanMs": 0.0018480902780263855 + }, + " step: whole": { + "windows": 2, + "meanMs": 0.3460309027788119 + }, + " frame pacing: gc budget": { + "windows": 2, + "meanMs": 0.17482638888897858 + }, + " step: mod input.step": { + "windows": 2, + "meanMs": 0.02158923611096005 + }, + "draw: whole frame": { + "windows": 2, + "meanMs": 7.091951388887688 + }, + " voxel: water": { + "windows": 2, + "meanMs": 0.047376562499816074 + }, + " update: discord": { + "windows": 2, + "meanMs": 0.0031012152780599913 + }, + "present (GPU wait / vsync)": { + "windows": 2, + "meanMs": 0.025607986110761278 + }, + " renderer: zone blit": { + "windows": 2, + "meanMs": 0.0021526041647253655 + }, + " logic: stack:update": { + "windows": 2, + "meanMs": 0.3134807291682288 + }, + " voxel: cards": { + "windows": 2, + "meanMs": 0.6757993055547473 + }, + "frame pacing: sleep": { + "windows": 2, + "meanMs": 0.8896291666642261 + } + } + } + ], + "mode": "voxel", + "measurement": { + "warmupVoxel": 1500, + "perSwitchWarmup": 180, + "profileWindow": 120, + "readyWaitFrames": 18240, + "samples": 240, + "warmupNative": 240 + }, + "passed": true, + "limitations": [ + "sequential off/on samples; no causal whole-game speedup claim", + "Emerald does not exercise DS Nitro synthesis or GB palette uniforms", + "LOW native UI quad menus are covered by their separate allocation fixture" + ], + "timing": "FrameProfile wall time; GPU submission, no completion fence; no screenshots inside intervals", + "gpu": { + "device": "Apple M2", + "vendor": "", + "renderer": "Metal", + "api": "2.1" + }, + "runtime": [ + 12, + 0, + 0, + "Bestest Friend" + ], + "edition": "emerald", + "architecture": "arm64", + "simulationHz": 60 + } + }, + "freshIdentity": true, + "legacyMigrationBypassed": true, + "testAdaptations": [ + "Enable existing native pacing for the driver in the frozen copy", + "Quarter-pixel camera pan during P7; native update and draw remain active" + ], + "passed": true + }, + "forkOpenGL": { + "sourceCommit": "dae881de97e03fafbfa6e50f6f39215bda87843e", + "native": { + "architecture": "arm64", + "simulationHz": 60, + "phases": [ + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 16.667387203626213, + "p50Ms": 16.66808333333325, + "p95Ms": 17.266375000001055, + "p99Ms": 17.619625000000028, + "samples": 239 + }, + "present": { + "meanMs": 14.672560243055655, + "p50Ms": 14.739583333332362, + "p95Ms": 15.451000000002324, + "p99Ms": 15.778916666668863, + "samples": 240 + }, + "elapsedSeconds": 3.999789708333336, + "card": "P1", + "sections": { + " update: gc step": { + "meanMs": 0.0016048611110930366, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.002357291666588157, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 0.39517500000003647, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.020693402777756848, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.2481880208333176, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.41677986111109766, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.034011111111006034, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.006581944444479977, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.14170520833319084, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.38464618055561084, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.2165138888889621, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.06932222222219378, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.8190932291667119, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.620315104166665, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 14.621406423611175, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.4214760416666093, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.20373315972222286, + "windows": 2 + }, + " update: music": { + "meanMs": 0.022903819444441975, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.002659895833242073, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0038954861111088297, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.006481423611063667, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 60, + "vsync": 1 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 16.673870641562075, + "p50Ms": 16.670291666667225, + "p95Ms": 17.413624999996102, + "p99Ms": 18.324291666662162, + "samples": 239 + }, + "present": { + "meanMs": 14.979631423611115, + "p50Ms": 15.034083333333115, + "p95Ms": 15.825416666665149, + "p99Ms": 16.317708333332348, + "samples": 240 + }, + "elapsedSeconds": 4.001582000000001, + "card": "P1", + "sections": { + " update: gc step": { + "meanMs": 0.0004954861110443669, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.003987673611168438, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 0.2674690972221727, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.017954687499995476, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.24440260416667137, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.3385887152777508, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.035574826389036154, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0066156249999356955, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.12828524305561473, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.3094074652776705, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.20117499999997196, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.05807482638911694, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.752117361111182, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.5277243055554548, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 14.92901701388892, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.3432960069444343, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.19687760416675637, + "windows": 2 + }, + " update: music": { + "meanMs": 0.023178298610937453, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0019244791665151695, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0028041666666848526, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.005840104166709992, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 60, + "vsync": 1 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.235281555090652, + "p50Ms": 6.279208333332065, + "p95Ms": 6.734624999999994, + "p99Ms": 7.179083333337388, + "samples": 239 + }, + "present": { + "meanMs": 1.1148600694444197, + "p50Ms": 0.6057083333317337, + "p95Ms": 3.741958333336015, + "p99Ms": 4.9237499999996714, + "samples": 240 + }, + "elapsedSeconds": 1.4958087499999984, + "card": "P2", + "sections": { + " update: gc step": { + "meanMs": 0.0037821180555989526, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0013111111109539073, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.218259895833458, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.01284809027775798, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.15237760416654625, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.030271527778028922, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.00427725694459961, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.07504253472194004, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.17913472222195934, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.02605364583267248, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.11598749999990332, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.1140116319444893, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2868946180555406, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.19106545138880549, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.19322482638881588, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5152126736112391, + "windows": 2 + }, + " update: music": { + "meanMs": 0.011342708333161866, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.000982986111308648, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.001664756944341761, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.0032869791666871135, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.3571999651324935, + "p50Ms": 6.278958333336249, + "p95Ms": 6.820374999996659, + "p99Ms": 12.679916666666458, + "samples": 239 + }, + "present": { + "meanMs": 1.150429861110854, + "p50Ms": 0.4453333333316323, + "p95Ms": 4.427291666662114, + "p99Ms": 5.447958333327563, + "samples": 240 + }, + "elapsedSeconds": 1.5252069166666615, + "card": "P2", + "sections": { + " update: gc step": { + "meanMs": 0.00044270833345905203, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0012154513887807639, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.101502604166951, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.01295503472215446, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.4976496527780612, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.2022562500001583, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.03378506944479535, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0032618055554767267, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.17682951388883575, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.18297812500009947, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03228177083408982, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.1337104166663957, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.1529953125000012, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.3050876736111115, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5935704861109059, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.20462274305536413, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.17468350694427315, + "windows": 2 + }, + " update: music": { + "meanMs": 0.017961458333089673, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0012517361111926326, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0019406250002370247, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.0028975694443526363, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.2498349023709965, + "p50Ms": 6.267833333332362, + "p95Ms": 7.119416666665046, + "p99Ms": 8.127916666669677, + "samples": 239 + }, + "present": { + "meanMs": 1.1161321180553392, + "p50Ms": 0.33645833333295627, + "p95Ms": 4.195541666661029, + "p99Ms": 4.905958333335292, + "samples": 240 + }, + "elapsedSeconds": 1.499905833333333, + "card": "P3", + "sections": { + " update: gc step": { + "meanMs": 0.00048402777782878087, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0011078125000008043, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.114880555555726, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.015697222221966456, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5128435763889587, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.1911970486108583, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.04074270833357059, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0027131944443444427, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.08369809027852781, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.171840972222137, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.1338819444447618, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03896597222121656, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.1200206597217655, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2927954861109712, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.6131821180552333, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.19364809027796842, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.16749774305579263, + "windows": 2 + }, + " update: music": { + "meanMs": 0.0192269097222475, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.002479166666550607, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0018090277776957229, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.002604687500159149, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.231283124128319, + "p50Ms": 6.261208333331325, + "p95Ms": 7.001499999997662, + "p99Ms": 7.463500000000067, + "samples": 239 + }, + "present": { + "meanMs": 1.2672111111111515, + "p50Ms": 0.3423333333323342, + "p95Ms": 4.796791666667133, + "p99Ms": 5.50641666666607, + "samples": 240 + }, + "elapsedSeconds": 1.4951105000000027, + "card": "P3", + "sections": { + " update: gc step": { + "meanMs": 0.00033263888883361403, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0010701388890736279, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.06861510416654, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.012214756944336122, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5115572916669642, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.1811409722223563, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.028483854166664692, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0028697916666597933, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.0755835069446172, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.16242621527797024, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.12084479166645974, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.032524652777550976, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.261551041666742, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2777340277775912, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5206501736111887, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.18335000000035961, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.1499586805554583, + "windows": 2 + }, + " update: music": { + "meanMs": 0.01663871527763187, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.000941666666465224, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0017366319448116248, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.002830034722167696, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.229724721059962, + "p50Ms": 6.2338749999995, + "p95Ms": 6.930208333333354, + "p99Ms": 7.492708333330711, + "samples": 239 + }, + "present": { + "meanMs": 1.2278831597220061, + "p50Ms": 0.32112499999925603, + "p95Ms": 4.744166666664995, + "p99Ms": 5.4069583333351545, + "samples": 240 + }, + "elapsedSeconds": 1.4950517500000018, + "card": "P4", + "sections": { + " update: gc step": { + "meanMs": 0.0003310763887783471, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.000969618055535193, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.178820138888728, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.00876458333336494, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5011140624998707, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.1555885416667735, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.024649305555553745, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0019319444444517349, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.0617000000001718, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.13974027777763354, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03135173611103308, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.11353420138894657, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.2234223958332326, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2385585069445491, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5006347222222078, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.15734357638857627, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.13805763888825595, + "windows": 2 + }, + " update: music": { + "meanMs": 0.015418750000077234, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0015307291670059917, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0015885416666379846, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.003576909722221208, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.231817468619242, + "p50Ms": 6.257208333330766, + "p95Ms": 7.405749999996658, + "p99Ms": 7.792083333328037, + "samples": 239 + }, + "present": { + "meanMs": 0.9956045138891589, + "p50Ms": 0.33050000000400814, + "p95Ms": 4.119125000002555, + "p99Ms": 4.78858333333676, + "samples": 240 + }, + "elapsedSeconds": 1.4952891250000064, + "card": "P4", + "sections": { + " update: gc step": { + "meanMs": 0.00035069444459523424, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0011078125000748191, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.2933576388889465, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.01157673611148861, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5029746527777472, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.2071057291665272, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.029269618055642137, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.002420833333053688, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.0750385416660393, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.1895786458331254, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.12370677083340573, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03399965277880075, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 0.9976267361111013, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.3031071180558639, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5355546875002541, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.20928802083330922, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.15870277777771946, + "windows": 2 + }, + " update: music": { + "meanMs": 0.01726649305557141, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0010347222221559114, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.001778472222587387, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.0027508680554492555, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.277770746164587, + "p50Ms": 6.263708333335671, + "p95Ms": 6.964250000002892, + "p99Ms": 7.821249999999225, + "samples": 239 + }, + "present": { + "meanMs": 0.8983097222220534, + "p50Ms": 0.2876249999985703, + "p95Ms": 3.931000000001461, + "p99Ms": 4.967875000005506, + "samples": 240 + }, + "elapsedSeconds": 1.5064056666666659, + "card": "P5", + "sections": { + " update: gc step": { + "meanMs": 0.0003213541668619276, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.001024131944582507, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.53699114583325, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.01031006944443765, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5055859375001267, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.14694895833358146, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.025333680555335768, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0026815972223500255, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.07530277777823002, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.15551788194413757, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.10914913194414547, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.02923090277662984, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 0.9001652777776019, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2673343750001204, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.1734963541663544, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.17553767361097408, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.4722008680552688, + "windows": 2 + }, + " update: music": { + "meanMs": 0.01566944444396275, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0009607638892757583, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0016673611113127151, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.0022078125000213333, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.288016562064135, + "p50Ms": 6.2477083333263295, + "p95Ms": 6.868000000004315, + "p99Ms": 7.630166666672267, + "samples": 239 + }, + "present": { + "meanMs": 1.1863953124998439, + "p50Ms": 0.3171249999951442, + "p95Ms": 4.988291666663258, + "p99Ms": 5.577625000000808, + "samples": 240 + }, + "elapsedSeconds": 1.5095238333333398, + "card": "P5", + "sections": { + " update: gc step": { + "meanMs": 0.0003803819446683102, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0009340277780841196, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.252718749999959, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.010822222222461875, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5122611111113488, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.16764635416614365, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.026926909722568837, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.002986111110943786, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.07106579861124729, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.15060434027767872, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.1183315972222469, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03510555555526669, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.188424131944258, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2549993055555847, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.4970548611111347, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.1696093749995538, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.14856579861142194, + "windows": 2 + }, + " update: music": { + "meanMs": 0.016086631944863445, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0011151041664862285, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0014835069445181168, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.002245659722680908, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.278589609483924, + "p50Ms": 6.26395833333504, + "p95Ms": 7.1881249999989905, + "p99Ms": 7.485791666667296, + "samples": 239 + }, + "present": { + "meanMs": 1.1705394097221007, + "p50Ms": 0.32845833333539076, + "p95Ms": 4.630125000005592, + "p99Ms": 5.722624999989989, + "samples": 240 + }, + "elapsedSeconds": 1.506839124999999, + "card": "P6", + "sections": { + " update: gc step": { + "meanMs": 0.00034010416645931707, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.001067881944501655, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.20900538194422, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.011357465277879916, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5015076388888318, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.15868246527726745, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.03448628472186854, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.002503472222533295, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.06927222222182414, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.16029131944469793, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.13261822916685873, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.0359767361114649, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.1596720486112488, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2714201388891446, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.1775411458331118, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.1797045138885641, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5435371527780101, + "windows": 2 + }, + " update: music": { + "meanMs": 0.015531770833258443, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0009668402776163987, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0017440972220228446, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.0024164930555237163, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.23306328451883, + "p50Ms": 6.255083333336131, + "p95Ms": 7.198875000014482, + "p99Ms": 7.57250000000198, + "samples": 239 + }, + "present": { + "meanMs": 0.9339451388887218, + "p50Ms": 0.29008333333280234, + "p95Ms": 4.043541666661099, + "p99Ms": 4.722125000007793, + "samples": 240 + }, + "elapsedSeconds": 1.495526958333329, + "card": "P6", + "sections": { + " update: gc step": { + "meanMs": 0.00039097222244895613, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.000847569444169712, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.505499652777445, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.009628298610072267, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5035550347219402, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.1346505208331905, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.026045486111285026, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0020236111114198723, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.06297934027739889, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.13628732638893598, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.030362152777326422, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.1092288194445068, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 0.9217019097225905, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.2629105902777873, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.15155989583295337, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.15499218749989535, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.45328940972249043, + "windows": 2 + }, + " update: music": { + "meanMs": 0.035219618056044524, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0009321180554818417, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.001254513888918988, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.001953819444692565, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": false, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.229652719665299, + "p50Ms": 6.2589166666811025, + "p95Ms": 7.280583333326263, + "p99Ms": 7.749541666669302, + "samples": 239 + }, + "present": { + "meanMs": 0.91216111111135, + "p50Ms": 0.3208750000140981, + "p95Ms": 3.651374999989798, + "p99Ms": 4.524875000001316, + "samples": 240 + }, + "elapsedSeconds": 1.495454500000001, + "card": "P9", + "sections": { + " update: gc step": { + "meanMs": 0.0009611111111122266, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.001731944444098114, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.314396354166246, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.014539583332862094, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5120800347215553, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.22618732638874187, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.029385937500029755, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.1308651041671105, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.08679409722350448, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.20707690972248116, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.03550347222057344, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.0023960069438129965, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 0.9088880208338423, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.3375545138884384, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.002890972222916825, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.22935729166627303, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.1510756944449317, + "windows": 2 + }, + " update: music": { + "meanMs": 0.01913645833354257, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.001617708333541875, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.002508333333928192, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5573109374990299, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + }, + { + "windows": 2, + "enabled": true, + "world": "MAP_G00_N15", + "shadowCasts": [], + "cameraMotion": "native camera follow", + "intervals": { + "meanMs": 6.231109309623432, + "p50Ms": 6.238791666667964, + "p95Ms": 6.786041666664744, + "p99Ms": 7.130583333335494, + "samples": 239 + }, + "present": { + "meanMs": 1.1638343750006424, + "p50Ms": 0.36958333333814153, + "p95Ms": 4.549416666662864, + "p99Ms": 5.23783333332517, + "samples": 240 + }, + "elapsedSeconds": 1.4956047916666577, + "card": "P9", + "sections": { + " update: gc step": { + "meanMs": 0.00046892361051931175, + "windows": 2 + }, + " update: graphics stack drain": { + "meanMs": 0.0012071180552908345, + "windows": 2 + }, + "frame pacing: sleep": { + "meanMs": 4.127126388889243, + "windows": 2 + }, + " world: FX": { + "meanMs": 0.012624479167122615, + "windows": 2 + }, + " frame pacing: gc budget": { + "meanMs": 0.5193821180554595, + "windows": 2 + }, + " step: whole": { + "meanMs": 0.2057081597218513, + "windows": 2 + }, + " renderer: zone blit": { + "meanMs": 0.033097222222681445, + "windows": 2 + }, + " step: mod input.step": { + "meanMs": 0.002634375000424664, + "windows": 2 + }, + "(unmeasured / present / GPU)": { + "meanMs": 0.08020607638874466, + "windows": 2 + }, + " logic: stack:update": { + "meanMs": 0.18604531249971265, + "windows": 2 + }, + " world: tiles": { + "meanMs": 0.11129149305582806, + "windows": 2 + }, + " tiles: ensureWindow": { + "meanMs": 0.029175173610962446, + "windows": 2 + }, + "present (GPU wait / vsync)": { + "meanMs": 1.1652595486112047, + "windows": 2 + }, + "update: whole frame": { + "meanMs": 0.314084027777195, + "windows": 2 + }, + "draw: whole frame": { + "meanMs": 0.5189491319447015, + "windows": 2 + }, + " update: fixed steps": { + "meanMs": 0.20847239583341448, + "windows": 2 + }, + " world: entities": { + "meanMs": 0.149564062500455, + "windows": 2 + }, + " update: music": { + "meanMs": 0.019894270833316813, + "windows": 2 + }, + " update: tilt": { + "meanMs": 0.0015460069450116503, + "windows": 2 + }, + " pipelines: update": { + "meanMs": 0.0019810763885352856, + "windows": 2 + }, + " update: discord": { + "meanMs": 0.001560590277686439, + "windows": 2 + } + }, + "stack": "...ady-opengl/source/tools/full_game_performance_driver.lua:73", + "fpsCap": 160, + "vsync": 0 + } + ], + "passed": true, + "limitations": [ + "sequential off/on samples; no causal whole-game speedup claim", + "Emerald does not exercise DS Nitro synthesis or GB palette uniforms", + "LOW native UI quad menus are covered by their separate allocation fixture" + ], + "mode": "native", + "measurement": { + "warmupNative": 240, + "warmupVoxel": 1500, + "perSwitchWarmup": 180, + "profileWindow": 120, + "samples": 240 + }, + "edition": "emerald", + "runtime": [ + 12, + 0, + 0, + "Bestest Friend" + ], + "timing": "FrameProfile wall time; GPU submission, no completion fence; no screenshots inside intervals", + "gpu": { + "device": "Apple M2", + "api": "4.1 Metal - 89.4", + "vendor": "Apple", + "renderer": "OpenGL" + } + }, + "voxel": { + "qualified": false, + "status": "stopped by the shared 20 GiB free-space reserve after the P7 baseline; no complete voxel result is claimed" + } + }, + "selectedFrameProfileRows": { + "P1": { + "section": "frame pacing: sleep", + "OpenGL native": { + "off": 0.39517500000003647, + "on": 0.2674690972221727 + }, + "Metal": { + "off": 10.707645138888878, + "on": 0.3171631944444586 + } + }, + "P2": { + "section": " update: gc step", + "OpenGL native": { + "off": 0.0037821180555989526, + "on": 0.00044270833345905203 + }, + "Metal": { + "off": 0.008072916666771826, + "on": 0.003772916666762901 + } + }, + "P3": { + "section": "draw: whole frame", + "OpenGL native": { + "off": 0.6131821180552333, + "on": 0.5206501736111887 + }, + "Metal": { + "off": 2.4563439236110858, + "on": 2.833618750000122 + } + }, + "P4": { + "section": " world: entities", + "OpenGL native": { + "off": 0.13805763888825595, + "on": 0.15870277777771946 + }, + "Metal": { + "off": 0.2992987847228494, + "on": 0.11899236111077016 + } + }, + "P5": { + "section": " renderer: zone blit", + "OpenGL native": { + "off": 0.025333680555335768, + "on": 0.026926909722568837 + }, + "Metal": { + "off": 0.02149461805635392, + "on": 0.022706770833380574 + } + }, + "P6": { + "section": " update: music", + "OpenGL native": { + "off": 0.015531770833258443, + "on": 0.035219618056044524 + }, + "Metal": { + "off": 0.03811163194399707, + "on": 0.044542708333346795 + } + }, + "P9": { + "section": "draw: whole frame", + "OpenGL native": { + "off": 0.5573109374990299, + "on": 0.5189491319447015 + }, + "Metal": { + "off": 3.4080913194446323, + "on": 3.999949305555095 + } + }, + "P7": { + "section": " voxel: sun pass", + "Metal": { + "off": 0.9418126736137584, + "on": 0.7756128472208227 + }, + "castsOff": { + "shadow.cast": 240 + }, + "castsOn": { + "shadow.static.cast": 9, + "shadow.dynamic.cast": 240 + } + }, + "P8": { + "section": " voxel: water", + "Metal": { + "off": 0.09131128471947156, + "on": 0.047376562499816074 + } + } + }, + "limitations": [ + "Sequential off/on samples, no causal universal speedup claim; slower sections are retained", + "Metal qualifies all nine cards; the additional OpenGL replay qualifies native cards only", + "GPU completion timing and full-story checkpoint playthroughs are not measured", + "Emerald does not exercise DS Nitro audio, GB palette uniforms or UI quad-heavy menus; separate component acceptance applies", + "Physical Android, Switch and handheld performance is not measured; the measured Apple M2 is ARM64" + ] +} diff --git a/docs/qa/program-current-upstream.json b/docs/qa/program-current-upstream.json new file mode 100644 index 000000000..4fb01f5ce --- /dev/null +++ b/docs/qa/program-current-upstream.json @@ -0,0 +1,520 @@ +{ + "schema": 2, + "date": "2026-10-26", + "upstream": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d", + "fork": { + "head": "8e336d459619d26f7099825248a655f2d5385940", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 561, + "total": 561, + "sourceCommit": "a6f947a5f9f9e836fafd4f57dae961a631e4193c" + }, + "contractComparisons": 200, + "completeFixtures": 17, + "sdkSuites": 3, + "galleryChecks": 253, + "focusedChecks": { + "Native checkpoint actor continuation": 432, + "Native checkpoint battle continuation": 1118, + "Native trainer checkpoint producer": 236, + "Native checkpoint story continuation": 756 + }, + "editionSelections": [ + "red", + "blue", + "yellow", + "gold", + "silver", + "crystal", + "firered", + "leafgreen", + "ruby", + "sapphire", + "emerald", + "platinum", + "heartgold", + "soulsilver", + "black", + "white", + "prism", + "polishedcrystal" + ], + "engineScope": "Full suite at a6f947a5; 22 affected engine suites and every required compatibility gate at the final concurrent integration.", + "fullEngineBaseline": { + "head": "a6f947a5f9f9e836fafd4f57dae961a631e4193c", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 1 + } + }, + "targetedEngineSuites": { + "sourceCommit": "8e336d459619d26f7099825248a655f2d5385940", + "passed": 22, + "total": 22, + "exitCodes": { + "link_battle_ended": 0, + "api3_compat": 0, + "gate_meta_coverage": 0, + "mod_supports": 0, + "unified_mod_callbacks": 0, + "gate_fingerprint": 0, + "gate_hooks": 0, + "gate_events": 0, + "checkpoint_battle_continuation": 0, + "native_follower_callback_owner": 0, + "native_session_callbacks": 0, + "native_companion_runtime": 0, + "suspend_continue": 0, + "suspend_journal": 0, + "suspend_store": 0, + "screen_space_ui": 0, + "friend_wager": 0, + "friend_activity": 0, + "friend_chat": 0, + "friend_maps": 0, + "account_workspace": 0, + "friends_menu": 0 + } + } + }, + "publicM4": { + "head": "d25511709163eaf8fd80f55a1692ef806d63a372", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 168, + "total": 168 + }, + "contractComparisons": 48, + "completeFixtures": 17, + "sdkSuites": 3, + "galleryChecks": 253, + "focusedChecks": { + "Native checkpoint actor continuation": 360, + "Native checkpoint battle continuation": 858, + "Native trainer checkpoint producer": 197, + "Native checkpoint story continuation": 630 + }, + "editionSelections": [ + "red", + "blue", + "yellow", + "gold", + "silver", + "crystal", + "firered", + "emerald", + "platinum", + "heartgold", + "soulsilver", + "black", + "white", + "prism", + "polishedcrystal" + ] + }, + "branches": { + "upstream-perf-p3": { + "head": "7e3d306b6f740197f6c4308badcf5d69651d8b34", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 1, + "api3": 2, + "fixtures": 2 + }, + "engineSuites": { + "passed": 80, + "total": 80 + }, + "scope": "Standalone engine/SDK pass; this early host retains inherited missing gallery files and lacks later contract/external-fixture tooling. Complete compatibility is qualified on the composed fork/P9 stack." + }, + "upstream-perf-p4": { + "head": "6d2e0c1749c1435c9e84aef02b8a6f079762e295", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 1, + "api3": 2, + "fixtures": 2 + }, + "engineSuites": { + "passed": 81, + "total": 81 + }, + "scope": "Standalone engine/SDK pass; this early host retains inherited missing gallery files and lacks later contract/external-fixture tooling. Complete compatibility is qualified on the composed fork/P9 stack." + }, + "upstream-perf-p5": { + "head": "c89369556cf30328264a41b2d0f523785cb0c0ee", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 1, + "api3": 2, + "fixtures": 2 + }, + "engineSuites": { + "passed": 82, + "total": 82 + }, + "scope": "Standalone engine/SDK pass; this early host retains inherited missing gallery files and lacks later contract/external-fixture tooling. Complete compatibility is qualified on the composed fork/P9 stack." + }, + "upstream-perf-p6": { + "head": "538a636833d175b02ed2e796fddacabce1a0da70", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 169, + "total": 169 + }, + "scope": "All five gates pass, including 17 complete unchanged external entrypoints." + }, + "upstream-perf-p7": { + "head": "cc32b57423f4cecfd7c93e1a08cae25098e8aca1", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 169, + "total": 169 + }, + "scope": "All five gates pass, including 17 complete unchanged external entrypoints." + }, + "upstream-perf-p8": { + "head": "5a1b786ebe2b66734b06491b9f09f313088576ee", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 169, + "total": 169 + }, + "scope": "All five gates pass, including 17 complete unchanged external entrypoints." + }, + "upstream-perf-p9": { + "head": "094d8627e173715907d5d7c913b3b2b23415c911", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 180, + "total": 180 + }, + "scope": "All five gates pass, including 17 complete unchanged external entrypoints." + }, + "upstream-compat-m5": { + "head": "9fbf292c785bf2ec42dbec4b599f84a54d4a5bde", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 1 + }, + "engineSuites": { + "passed": 90, + "total": 90 + }, + "scope": "Intermediate dependency host: engine/SDK/gallery/contract pass; 12/14 external entrypoints load. Existing asset-pack/follower offers complete the stack on M8/P9." + }, + "upstream-compat-m6": { + "head": "3e9b01b19b6d99d66e6d684087925b35c5c61e82", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 1 + }, + "engineSuites": { + "passed": 94, + "total": 94 + }, + "scope": "Intermediate dependency host: engine/SDK/gallery/contract pass; 12/14 external entrypoints load. Existing asset-pack/follower offers complete the stack on M8/P9." + }, + "upstream-compat-m7": { + "head": "36d78277688d3a34ddbfee9c6ad466ccedbaa871", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 1 + }, + "engineSuites": { + "passed": 114, + "total": 114 + }, + "scope": "Intermediate dependency host: engine/SDK/gallery/contract pass; 12/14 external entrypoints load. Existing asset-pack/follower offers complete the stack on M8/P9." + }, + "upstream-compat-m8": { + "head": "886a0090f7229d138d95a46a248a77d652953364", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + }, + "engineSuites": { + "passed": 162, + "total": 162 + }, + "scope": "All five gates pass, including 17 complete unchanged external entrypoints.", + "fixtureFollowUp": { + "head": "0fafaa48", + "completeEntrypoints": 17, + "nativeObjectConstructors": true + } + } + }, + "pullRequests": [ + { + "number": 102, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "aa406f27fa4467ac9b50acd533ad5a8a32e494ce", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 122, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "ce62c61b31d0efb6e092dcabd4930499343a0426", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 155, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "91f14c97ebdf892a0373e747d3b6aa96cd9620a6", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 272, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "01b6b2480e75e2604c6a07625ed922ed847aa8db", + "baseRefOid": "13af468b97dc37015d14ad5c71d7f067c3ee810d" + }, + { + "number": 274, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "fd332722acba0edad6a2d9deb13450d9a50e3ee9", + "baseRefOid": "13af468b97dc37015d14ad5c71d7f067c3ee810d" + }, + { + "number": 279, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "7d8d81f69fd21356c2b30fd8541259b22520c51e", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 283, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "5507ef2f5f5a4b869470d7861ad6fec39daa1422", + "baseRefOid": "ed5189c247ee7a2ccca666784e21fd4fba8df781" + }, + { + "number": 288, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "cb62f9404efd9bfbf34c92c5187d2357c0084cd2", + "baseRefOid": "ed5189c247ee7a2ccca666784e21fd4fba8df781" + }, + { + "number": 290, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "7e3d306b6f740197f6c4308badcf5d69651d8b34", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 291, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "6d2e0c1749c1435c9e84aef02b8a6f079762e295", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 293, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "c89369556cf30328264a41b2d0f523785cb0c0ee", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 303, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "9fbf292c785bf2ec42dbec4b599f84a54d4a5bde", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 305, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "3e9b01b19b6d99d66e6d684087925b35c5c61e82", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 306, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "36d78277688d3a34ddbfee9c6ad466ccedbaa871", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 312, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "0fafaa48846c3583c45141b044e33902b47ae63a", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 316, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "538a636833d175b02ed2e796fddacabce1a0da70", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 319, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "cc32b57423f4cecfd7c93e1a08cae25098e8aca1", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 321, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "5a1b786ebe2b66734b06491b9f09f313088576ee", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + }, + { + "number": 323, + "isDraft": false, + "mergeable": "MERGEABLE", + "headRefOid": "094d8627e173715907d5d7c913b3b2b23415c911", + "baseRefOid": "8d5b4387420cc1fdcadf58ad4ca78a7d8596e41d" + } + ], + "performanceEvidence": "Full-game Metal and partial OpenGL samples retain their original recorded source revisions. They are not new measurements of this subsequent integration.", + "earlierHosts": { + "api3-acceptance-pr": { + "head": "aa406f27fa4467ac9b50acd533ad5a8a32e494ce", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 1 + }, + "engineSuites": { + "passed": 88, + "total": 88 + }, + "scope": "Preparation/early dependency host. Engine/SDK pass; raw exit codes retain inherited gallery/tooling gaps. Complete unchanged-fixture acceptance is qualified on the composed M8/P9 stack." + }, + "legacy-datasets-pr": { + "head": "ce62c61b31d0efb6e092dcabd4930499343a0426", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 1, + "api3": 2, + "fixtures": 2 + }, + "engineSuites": { + "passed": 110, + "total": 110 + }, + "scope": "Preparation/early dependency host. Engine/SDK pass; raw exit codes retain inherited gallery/tooling gaps. Complete unchanged-fixture acceptance is qualified on the composed M8/P9 stack." + }, + "legacy-storage-pr": { + "head": "91f14c97ebdf892a0373e747d3b6aa96cd9620a6", + "exitCodes": { + "engine": 0, + "modkit": 0, + "gallery": 1, + "api3": 2, + "fixtures": 2 + }, + "engineSuites": { + "passed": 120, + "total": 120 + }, + "scope": "Preparation/early dependency host. Engine/SDK pass; raw exit codes retain inherited gallery/tooling gaps. Complete unchanged-fixture acceptance is qualified on the composed M8/P9 stack." + } + }, + "finalConcurrentIntegration": { + "incoming": "fc46bd85c919b6faef1c470597397d7ef02b96bb", + "head": "8e336d459619d26f7099825248a655f2d5385940", + "fullEngineBaseline": "a6f947a5f9f9e836fafd4f57dae961a631e4193c", + "scope": "Twenty-two targeted engine suites after integrating the separately qualified native link-result, suspend/friends and Gen2 menu publication, plus every required SDK/gallery/contract/unchanged-fixture gate. Full engine qualification is recorded at its exact baseline revision.", + "exitCodes": { + "link_battle_ended": 0, + "api3_compat": 0, + "gate_meta_coverage": 0, + "mod_supports": 0, + "unified_mod_callbacks": 0, + "gate_fingerprint": 0, + "gate_hooks": 0, + "gate_events": 0, + "checkpoint_battle_continuation": 0, + "native_follower_callback_owner": 0, + "native_session_callbacks": 0, + "native_companion_runtime": 0, + "suspend_continue": 0, + "suspend_journal": 0, + "suspend_store": 0, + "screen_space_ui": 0, + "friend_wager": 0, + "friend_activity": 0, + "friend_chat": 0, + "friend_maps": 0, + "account_workspace": 0, + "friends_menu": 0, + "modkit": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + } + } +} diff --git a/docs/qa/render-budget-p8.json b/docs/qa/render-budget-p8.json new file mode 100644 index 000000000..0ba46a937 --- /dev/null +++ b/docs/qa/render-budget-p8.json @@ -0,0 +1,98 @@ +{ + "runtimeCommit": "c1906c64a62e13cd3d5585e9c77ef387d483c44d", + "native": { + "metal": { + "checks": 4092, + "scope": "Owned water/foreground geometry, native shader/targets/scene producer; CPU submission and pass completion with equal one-pixel GPU fences, no ROM or game FPS", + "phases": [ + { + "meanFencedMs": 1.5684343750000107, + "mode": "legacy", + "frames": 120, + "warmupFrames": 120, + "meanSubmitMs": 0.12469236111114317 + }, + { + "meanFencedMs": 1.0172923611111089, + "mode": "budget", + "frames": 120, + "warmupFrames": 120, + "meanSubmitMs": 0.10366909722220505 + } + ], + "renderer": "Metal", + "sha256": { + "mods/DRAMATIC_SHAPE/lib/RenderScale.lua": "2cff29fdcac5aa5eb69020432cce25dab050a2f3475c20767bbc872573386774", + "mods/DRAMATIC_SHAPE/lib/WaterBudget.lua": "0616d66174acb385a52025e695e10488024ea93e9b28b5df9dd1fb844afdeb49", + "mods/DRAMATIC_SHAPE/lib/ShaderState.lua": "5a43d3be681ffaab6744425a1a6a9964c466b9a8a568e2286947dcc04ac38595", + "mods/DRAMATIC_SHAPE/lib/Water.lua": "47ea97b33cd6b3fe9b8cfb65e763fe04d5f2857e48dd0759c8074ac7405dfa9a", + "mods/DRAMATIC_SHAPE/lib/VoxelScene.lua": "115e2a358d94bdafdbaf6395edd15bc76b678515f36ba2c7c61f90472093e195", + "mods/DRAMATIC_SHAPE/lib/Voxel3D.lua": "8c19dd62281c8a2eff129627e51bf2d50c208c672d76808b9ec9ab31190807ed", + "mods/DRAMATIC_SHAPE/lib/Tier.lua": "a02d73cd3eef5461632a2fc6e6934cb1d2298c15fc199d1500a59d3bb76ab386", + "mods/DRAMATIC_SHAPE/lib/TileShape.lua": "1ded661f0a08c719ce42994d643c43cbffcaa03e27ae260a1ed4d3d444e90931", + "mods/DRAMATIC_SHAPE/manifest.json": "b94bdb437120be77c014937f0949a17ee81377db570abb46d997e80dd7e52ca3", + "mods/DRAMATIC_SHAPE/main.lua": "b9285e7d22948ea731713ae501c43f8a673f1c397053ba8cf0d7120693381012", + "tools/render-budget-check/main.lua": "fdf6ec2c11427b8b465d337c37ca57f676d9ab044ccf8659124698dac7c7ce6d", + "tools/render-budget-check/conf.lua": "05f59ce05bdda5cfa9dbe8b13cadf0179c6ff82e237503e810e8da7ed5ce8e8d" + }, + "playerProfilesUsed": false + }, + "openGL": { + "checks": 4094, + "scope": "Owned water/foreground geometry, native shader/targets/scene producer; CPU submission and pass completion with equal one-pixel GPU fences, no ROM or game FPS", + "phases": [ + { + "mode": "legacy", + "frames": 120, + "warmupFrames": 120, + "meanSubmitMs": 0.14358993055555064, + "meanFencedMs": 1.3747506944444303 + }, + { + "mode": "budget", + "frames": 120, + "warmupFrames": 120, + "meanSubmitMs": 0.1722927083333492, + "meanFencedMs": 0.8823149305555666 + } + ], + "renderer": "OpenGL", + "sha256": { + "mods/DRAMATIC_SHAPE/lib/RenderScale.lua": "2cff29fdcac5aa5eb69020432cce25dab050a2f3475c20767bbc872573386774", + "mods/DRAMATIC_SHAPE/lib/WaterBudget.lua": "0616d66174acb385a52025e695e10488024ea93e9b28b5df9dd1fb844afdeb49", + "mods/DRAMATIC_SHAPE/lib/ShaderState.lua": "5a43d3be681ffaab6744425a1a6a9964c466b9a8a568e2286947dcc04ac38595", + "mods/DRAMATIC_SHAPE/lib/Water.lua": "47ea97b33cd6b3fe9b8cfb65e763fe04d5f2857e48dd0759c8074ac7405dfa9a", + "mods/DRAMATIC_SHAPE/lib/VoxelScene.lua": "115e2a358d94bdafdbaf6395edd15bc76b678515f36ba2c7c61f90472093e195", + "mods/DRAMATIC_SHAPE/lib/Voxel3D.lua": "8c19dd62281c8a2eff129627e51bf2d50c208c672d76808b9ec9ab31190807ed", + "mods/DRAMATIC_SHAPE/lib/Tier.lua": "a02d73cd3eef5461632a2fc6e6934cb1d2298c15fc199d1500a59d3bb76ab386", + "mods/DRAMATIC_SHAPE/lib/TileShape.lua": "1ded661f0a08c719ce42994d643c43cbffcaa03e27ae260a1ed4d3d444e90931", + "mods/DRAMATIC_SHAPE/manifest.json": "b94bdb437120be77c014937f0949a17ee81377db570abb46d997e80dd7e52ca3", + "mods/DRAMATIC_SHAPE/main.lua": "b9285e7d22948ea731713ae501c43f8a673f1c397053ba8cf0d7120693381012", + "tools/render-budget-check/main.lua": "fdf6ec2c11427b8b465d337c37ca57f676d9ab044ccf8659124698dac7c7ce6d", + "tools/render-budget-check/conf.lua": "05f59ce05bdda5cfa9dbe8b13cadf0179c6ff82e237503e810e8da7ed5ce8e8d" + }, + "playerProfilesUsed": false + } + }, + "acceptance": { + "enginePassed": 156, + "engineSuites": 167, + "inheritedFailingSuites": 11, + "unchangedFailureLines": 29, + "baselineCommit": "de1a7e8d827400e48c62975457fa7e9a53be9fe1", + "sdkSuites": 3, + "galleryChecks": 253, + "api3Comparisons": 48, + "isolatedApi3Workers": 96, + "externalFixtureEntrypoints": 17, + "externalFixturePhase": "complete", + "head": "c1906c64a62e13cd3d5585e9c77ef387d483c44d", + "exitCodes": { + "engine": 1, + "sdk": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + } + } +} diff --git a/docs/qa/world-shadows-p7.json b/docs/qa/world-shadows-p7.json new file mode 100644 index 000000000..b0653467c --- /dev/null +++ b/docs/qa/world-shadows-p7.json @@ -0,0 +1,108 @@ +{ + "runtimeCommit": "73ff4d48756f10ae848ee3b745cb67ab2b2fc7bd", + "native": { + "metal": { + "checks": 22, + "scope": "Offscreen owned geometry, actual scene casting/shaders/targets, one-pixel GPU completion fence; no presentation, ROM or full-game FPS", + "renderer": "Metal", + "phases": [ + { + "warmupFrames": 120, + "staticPasses": 0, + "dynamicPasses": 0, + "legacyPasses": 120, + "mode": "legacy", + "frames": 120, + "meanMs": 0.7672239583333287, + "meanSubmitMs": 0.09647534722222184, + "meanFrameMs": 0.8747288194444452 + }, + { + "warmupFrames": 120, + "staticPasses": 4, + "dynamicPasses": 120, + "legacyPasses": 0, + "mode": "world anchored", + "frames": 120, + "meanMs": 0.5632812500000001, + "meanSubmitMs": 0.07356666666667824, + "meanFrameMs": 0.5803350694444542 + } + ], + "sha256": { + "mods/DRAMATIC_SHAPE/lib/ShadowMap.lua": "049897e9f7ac8c791cfd54e22d13161e4d0ac3f70b6000689d1a5e8d118d7f26", + "mods/DRAMATIC_SHAPE/lib/WorldShadows.lua": "121d65657f992b93cf63b2f50455297943efe89d9150822a02b8e89ca160419d", + "mods/DRAMATIC_SHAPE/lib/VoxelScene.lua": "ddea948a3027ad72f2d9d3544e27b30bab3801b3021a795b09bd9975924afc68", + "mods/DRAMATIC_SHAPE/lib/Voxel3D.lua": "ada0f764add2ef7eb0e52660db30d84415b9b80f9b4cf9a3b9b158ea326dda62", + "mods/DRAMATIC_SHAPE/lib/Tier.lua": "fe9ed868da8aa8feb3824d8dd9edd2307df89631500aa4ed69a9e436919de329", + "mods/DRAMATIC_SHAPE/manifest.json": "b94bdb437120be77c014937f0949a17ee81377db570abb46d997e80dd7e52ca3", + "mods/DRAMATIC_SHAPE/main.lua": "9cac22e8385199ce15d43afe458d9a12e4271f2f54a008e1a24242c4da0ed8b3", + "tools/world-shadow-check/main.lua": "d4a495cece9e2ab0d091f1f2d2992f654ebe34cdb1da16a38af3cd5d0cc0579c", + "tools/world-shadow-check/conf.lua": "26f7c0d1ad1cf92a36858450d5778c69bb465fc85b6227d54cd9bc8061e0af99" + }, + "playerProfilesUsed": false + }, + "openGL": { + "phases": [ + { + "meanMs": 0.7941934027777819, + "meanSubmitMs": 0.08953645833333682, + "meanFrameMs": 0.8148767361111123, + "warmupFrames": 120, + "staticPasses": 0, + "dynamicPasses": 0, + "legacyPasses": 120, + "frames": 120, + "mode": "legacy" + }, + { + "meanMs": 0.29765173611111617, + "meanSubmitMs": 0.04534131944444464, + "meanFrameMs": 0.3067003472222209, + "warmupFrames": 120, + "staticPasses": 4, + "dynamicPasses": 120, + "legacyPasses": 0, + "frames": 120, + "mode": "world anchored" + } + ], + "renderer": "OpenGL", + "scope": "Offscreen owned geometry, actual scene casting/shaders/targets, one-pixel GPU completion fence; no presentation, ROM or full-game FPS", + "checks": 22, + "sha256": { + "mods/DRAMATIC_SHAPE/lib/ShadowMap.lua": "049897e9f7ac8c791cfd54e22d13161e4d0ac3f70b6000689d1a5e8d118d7f26", + "mods/DRAMATIC_SHAPE/lib/WorldShadows.lua": "121d65657f992b93cf63b2f50455297943efe89d9150822a02b8e89ca160419d", + "mods/DRAMATIC_SHAPE/lib/VoxelScene.lua": "ddea948a3027ad72f2d9d3544e27b30bab3801b3021a795b09bd9975924afc68", + "mods/DRAMATIC_SHAPE/lib/Voxel3D.lua": "ada0f764add2ef7eb0e52660db30d84415b9b80f9b4cf9a3b9b158ea326dda62", + "mods/DRAMATIC_SHAPE/lib/Tier.lua": "fe9ed868da8aa8feb3824d8dd9edd2307df89631500aa4ed69a9e436919de329", + "mods/DRAMATIC_SHAPE/manifest.json": "b94bdb437120be77c014937f0949a17ee81377db570abb46d997e80dd7e52ca3", + "mods/DRAMATIC_SHAPE/main.lua": "9cac22e8385199ce15d43afe458d9a12e4271f2f54a008e1a24242c4da0ed8b3", + "tools/world-shadow-check/main.lua": "d4a495cece9e2ab0d091f1f2d2992f654ebe34cdb1da16a38af3cd5d0cc0579c", + "tools/world-shadow-check/conf.lua": "26f7c0d1ad1cf92a36858450d5778c69bb465fc85b6227d54cd9bc8061e0af99" + }, + "playerProfilesUsed": false + } + }, + "acceptance": { + "enginePassed": 156, + "engineSuites": 167, + "inheritedFailingSuites": 11, + "unchangedFailureLines": 29, + "baselineCommit": "5af8542a384f01beeffc1c1f88f82d9b9a0926c4", + "sdkSuites": 3, + "galleryChecks": 253, + "api3Comparisons": 48, + "isolatedApi3Workers": 96, + "externalFixtureEntrypoints": 17, + "externalFixturePhase": "complete", + "head": "73ff4d48756f10ae848ee3b745cb67ab2b2fc7bd", + "exitCodes": { + "engine": 1, + "sdk": 0, + "gallery": 0, + "api3": 0, + "fixtures": 0 + } + } +} diff --git a/docs/render-budget-p8.md b/docs/render-budget-p8.md new file mode 100644 index 000000000..4336f9763 --- /dev/null +++ b/docs/render-budget-p8.md @@ -0,0 +1,101 @@ +# World render and water budget (P8) + +`V.renderBudget` defaults to `true` in `mods/DRAMATIC_SHAPE/main.lua`. Set it to +`false` to disable P8 independently: native window resolution, the previous water +algorithm with 24 ray steps / 5 refinements and analytic waves, original sends, uncached neighbor/atlas paths and string shape keys. The LÖVE 12 effect-signature correction is a +shader compatibility repair shared by both modes. + +## RenderScale card + +The RENDER SCALE row stores AUTO or an integer 1–4. AUTO uses two framebuffer +pixels per rendered pixel on Android/iOS, Switch/Horizon and Linux ARM; macOS and +other desktop hosts use one. Explicit choices remain available. The world renders +at ceil(width/factor) × ceil(height/factor), AA resolves at that size, then an owned +full-size canvas receives a nearest-filtered upscale. Projected field FX and the +world HUD use the corresponding scale. Engine UI and headset eye/mirror policy +retain their own existing resolution. Readable depth allocation tries depth16 +first when the renderer reports OpenGL ES; desktop format preference stays intact. + +## WaterBudget card + +The WATER row adds AUTO. Existing FULL/SKY/OFF values retain their meaning and are +not rewritten. AUTO resolves to FULL on desktop HIGH and SKY on mobile/Linux ARM +or capped tiers. Existing tier ceilings still apply. Ray steps/refinements are +additive tier values, 12/3, with the old 24/5 fields retained for rollback. This is +a smaller reflection search budget, with corresponding visual differences. + +A 256-square, repeat-wrapped R8 field stores a periodic approximation of the +existing wave trains at the existing quantized wave time. Spatial frequencies +round to complete cycles across the tile; temporal rates, weights, bend/swell and +height/normal controls stay derived from the authored tables. Wave lookup replaces +repeated sine evaluation in relief and normal sampling. Unsupported R8 rendering +retains analytic waves. Invalidation releases the field and its shader. + +Reflective water shades into a separate half-width/half-height canvas. A flat +prepass preserves thin sheets and establishes surface depth. The expensive shader +reads full-resolution scene depth. A cheap second geometry draw composites the +nearest sampled result against the reattached full-resolution depth buffer; it is +not a rectangle laid over foreground objects. Failed allocation/binding restores +the complete full-resolution reflective path, with sticky target refusal until +invalidation. Planar reflection disables the simultaneous screen-space march and +its otherwise-unused cast copy. Off-plane planar fragments retain sky. + +The GLES fragment default becomes mediump for color/lighting. World positions, +matrices, depth reconstruction and ray/relief calculations retain explicit high +precision. The installed LÖVE 12 parser requires effect parameter precision to +match its declaration; both switch modes use that signature. Plain/grid variants +compile natively; LÖVE 11 also validates both modes as GLES shader source. This is +not a measurement on an actual GLES device. + +## Existing caches + +ShaderState keeps persistent per-shader/per-name records and snapshots small +numeric vectors/matrices. It probes the first send, remembers absent uniforms, +then calls known sends directly when values change. Flag changes clear snapshots; +unknown names are not retried, while a first rejected value for a known name can +recover. Existing instanced-state copies remain supported. Water uses this path +only with P8 enabled. + +Neighbor translations are private weak-key records with live offset validation. +The render-local atlas memo resolves each map/palette once for all passes in that +frame, avoiding repeated TerrainAtlas preparation and palette-key work without +changing its cross-frame invalidation. Gen-3 shape memoization uses class buckets, +separate pinned/unpinned buckets and numeric height keys. Persisted editor-key +formats are unchanged. No engine record or public mod interface receives these +caches. The manifest, permissions, engine-internals use and source notices remain. + +## Reproduction and evidence + +Run `python3 tools/check_render_budget.py --love /absolute/path/to/love --report +.local/qa/render-budget/profile.json` on one shell line. The guarded runner pins +inputs and uses an isolated identity. Its owned water and raised foreground quads +exercise actual scene/water shaders, canvas/depth handling, wave field and producer. +No ROM, imported artwork or player save is used. Readback checks preserve foreground +pixels and distinguish reflection from the flat fallback, including failed half +and R8 targets. Policy, nearest upscale, mutation-aware uniforms, unknown names, +live rollback, neighbor offsets, half-target size and planar/march exclusion are +also checked. Counts include individual foreground pixels and scene opens. + +Each mode warms up 120 frames and measures 120. FrameProfile reports +`voxel.water submit (owned geometry)`; the separate completion value includes an +equal one-pixel GPU readback. Presentation and full-game FPS are excluded. + +| Desktop macOS ARM64 host | Previous path | Budget | +| --- | ---: | ---: | +| LÖVE 11.5 OpenGL, CPU submission | 0.144 ms | 0.172 ms | +| LÖVE 11.5 OpenGL, GPU completion fence included | 1.375 ms | 0.882 ms | +| LÖVE 12 Metal, CPU submission | 0.125 ms | 0.104 ms | +| LÖVE 12 Metal, GPU completion fence included | 1.568 ms | 1.017 ms | + +The added prepass/composite can cost submission time; OpenGL's final run does. +Other Metal runs also showed a small submission increase. The scoped reduction in +completion time is not a universal CPU/FPS claim. Mobile/handheld, populated maps, +VR and long-session behavior remain unmeasured. OpenGL passes 4094 scoped checks +(including two GLES validations), Metal 4092. Source hashes and acceptance are in +[render-budget-p8.json](qa/render-budget-p8.json). + +Integrated fork: 478/478 engine suites, 3/3 SDK suites, 253 gallery checks, +200 API-3 comparisons / 400 workers and all 17 unchanged external fixture +entrypoints in complete mode pass. The public stack retains exactly its previous +29 FAIL lines across 11 inherited suites (156/167), with no new failing suite; +SDK 3/3, gallery 253, API-3 48/96 and all 17 external entrypoints pass. diff --git a/docs/shader-presets.md b/docs/shader-presets.md new file mode 100644 index 000000000..3948af316 --- /dev/null +++ b/docs/shader-presets.md @@ -0,0 +1,247 @@ +# Optional shader presets + +OPTIONS -> SHADER PRESET opens a shared picker on GB, GBA and DS games. +The default is OFF. WARM DISPLAY and SOFT LCD are bundled, adjustable examples; +local presets appear after them in filename order. Select with A, then SELECT +on the active preset to edit its parameters. Left/Right or A changes a value; +SELECT resets that value. B/START returns. Changes use the ordinary options +save path, including existing generation-specific option overrides. + +The options and shader-control screens bypass this effect so they remain +readable. F8 turns the preset OFF and saves that choice even when another +screen captures keyboard input. A failed selection shows a separate error +page and keeps the last working preset. If a saved preset is unavailable, +startup falls back to OFF without deleting the saved choice. Missing shader +support, allocation errors and render/uniform errors also fall back to the +unfiltered input; a failing active effect is retired until explicitly selected +again. This handles reported API errors, not GPU-driver hangs or the visual +quality of arbitrary user shaders. + +## Local format + +Place a JSON descriptor and a LÖVE-compatible fragment shader in +`shader-presets/` in the active game-data folder (the normal LÖVE save folder, +portable folder, or the folder selected in the launcher). Discovery uses the +same `CacheFs.dataFs()` read union as other installation data. Reopen the +picker and select the preset again after editing its files. + +Example `shader-presets/dim.json`: + +```json +{ + "format": 1, + "name": "DIM DISPLAY", + "shader": "dim.glsl", + "parameters": [ + { "id": "brightness", "name": "BRIGHTNESS", + "min": 0.2, "max": 1.0, "step": 0.05, "default": 0.8 } + ] +} +``` + +Matching `shader-presets/dim.glsl`: + +```glsl +extern number brightness; + +vec4 effect(vec4 color, Image image, vec2 uv, vec2 pixel) { + vec4 c = Texel(image, uv) * color; + return vec4(c.rgb * brightness, c.a); +} +``` + +Descriptors are data only; no Lua is loaded. Filenames contain only letters, +digits, `_` or `-` followed by `.json`/`.glsl`; paths and subdirectories are +not accepted. Names are 1–48 printable ASCII characters and are shortened in +the fixed-width menu. Descriptors are limited to 16 KiB and shader source to +64 KiB. Invalid descriptors remain visible as INVALID; one bad file does not +hide the other presets. + +Up to eight scalar parameters are supported. Each requires a unique GLSL +identifier `id`, display `name`, finite `min < max`, positive `step` no larger +than the range, and an in-range `default`. A range may contain at most one +million steps. The compiled shader must expose each declared parameter as a +uniform. Saved values are clamped and snapped to steps; non-finite values use +the default. Defaults themselves are snapped to the same step grid, so reset +and first use agree. Parameter changes send uniforms without recompiling the shader. +`sourceSize`, `pixelScale` and names beginning with `love_` are reserved. + +Two optional uniforms are supplied when the shader uses them: + +| Uniform | Meaning | +| --- | --- | +| `extern vec2 sourceSize` | Input texture size in physical framebuffer pixels | +| `extern number pixelScale` | Renderer's physical pixels per native game pixel, at least 1 | + +Presets must use LÖVE's `effect()` convention and support the target's GLSL +or GLSL ES dialect. The offline `.slangp` importer described below can generate +compatible local presets. There is one selected preset; the game loads no +native compiler or shader-pack downloader. + +## Rendering and persistence + +The pass processes the completed **primary game view**: world, ordinary game +menus/dialogues, transitions and screen veils. It runs after mod +`render_pipelines.present` and before the existing GBC FX LCD effect. When +GBC colour correction from PR #89 is installed, that remains before the mod +pipelines and this preset pass. Touch controls, `render.hud` overlays and +separately composed DS lower displays retain their existing later draw paths. +A mod that fully handles `render.compose` also retains control of its output. + +OFF preserves the existing render path without enumeration, shader compilation +or another render target. Active output preserves the input's unit dimensions +and DPI scale. Targets are reused, recreated/released on resize or DPI changes, +and released with the shader on OFF or replacement. GPU state is enclosed by +`push("all")`/`pop()`; the input is never the active output target. + +The additive keys are `options.shaderPreset` (`"off"`, `"builtin:warm"`, +`"builtin:soft-lcd"`, or `"local:"`) and +`options.shaderPresetParams[presetId][parameterId]`. Values belonging to other +presets are preserved. Old option files need no migration and remain OFF. +Custom shaders can be expensive; this first version does not assign device +performance tiers or claim validation on mobile hardware. + +## Verification + +```sh +luajit tests/engine/shader_presets.lua +love tools/shader-preset-check +``` + +The ROM-free unit suite covers discovery, validation, persistence, parameter +editing, recovery, menu navigation and resource/state lifetime. The GPU tool +validates bundled sources for desktop GL and GLES, compares real pixels at DPI +1/2/2.75, checks alpha and physical-size uniforms, renders the real +`Renderer:endFrame` path, verifies OFF/identity equivalence and stage ordering, +and injects a draw error to check fallback/state restoration. Set +`SHADER_PRESET_CHECK_SHOT=/absolute/path.png` to export its comparison sheet. +It uses synthetic pixels and the engine's fallback font, with no ROM or saves. + +## Importing RetroArch `.slangp` presets + +The optional offline importer builds on this preset picker. It uses +librashader 0.12 (preprocessing, SPIR-V compilation/reflection and GLSL +emission), adapts both vertex and fragment stages to LÖVE, and writes a +local preset. The game does not load Rust, invoke a compiler or download +shader packs. Conversion is a command-line step; there is no `.slangp` +file picker in the game in this PR. + +Install Rust and LÖVE 11.5 on a desktop with a working graphics display, +then run from the checkout: + +```sh +cargo build --manifest-path tools/slang-import/Cargo.toml --release --locked +# Supply your own shader pack and the active game-data folder. +tools/slang-import/target/release/gen2-slang-import \ + /path/to/slang-shaders/handheld/lcd3x.slangp \ + /path/to/game-data/shader-presets --name "LCD3X" +``` + +Windows uses `gen2-slang-import.exe`. If `love` is not on PATH, add +`--love /absolute/path/to/love` (on macOS, the executable inside +`love.app/Contents/MacOS/love`). Building the importer needs a C++ compiler +for its compiler dependencies; it is independent of normal game builds. +Cargo.lock pins the dependency graph. CI builds/tests the tool on Linux, +macOS and Windows and exercises LÖVE under a virtual display on Linux. + +The tool follows local preset references and shader includes through +librashader. Before writing any preset files it validates both GL 1.20 and +GLES 1.00 shader pairs with LÖVE, compiles the current GPU's pair, checks +parameter/size/sampler uniforms and NPOT wrapping, and renders/reads back a +small test target. A failed conversion or preflight leaves the destination +untouched. Success writes four GLSL files per pass, optional PNG LUTs and one JSON descriptor, with +content-derived names and exclusive file creation. Existing files are never +overwritten; a duplicate import reports an error. Sources are synced before +the descriptor is written; a handled write failure removes only that import's +new files. A process interruption can leave orphaned files, which can be +removed manually. Reopen OPTIONS -> SHADER PRESET to discover the result. +Keep the original shader pack and its licence notices; converted shaders +remain subject to their original licences. No third-party shader pack is +bundled here. + +The importer supports a bounded subset: + +- One to eight ordered passes and up to eight PNG LUTs. Intermediate passes default to source 1x; the final pass defaults to viewport 1x. Explicit source, original, viewport (1/64x to 8x) and absolute (1 to 8192) scaling are supported per axis. +- Separate `Source`/`Original`, strictly earlier `PassOutputN` and named LUT samplers and their size uniforms, `OutputSize`, + `FinalViewportSize`, the vertex MVP and up to eight scalar parameters, + including preset overrides. Optimized-out parameters remain editable but + have no GPU uniform to send. +- Nearest/linear sampling and clamp-to-border, clamp-to-edge, repeat or + mirrored-repeat addressing when supported by the target GPU. +- Normal RGBA8 output. History/feedback textures, + mipmaps, float/sRGB targets, temporal/sensor/HDR uniforms, + unsigned arithmetic and other unsupported GLSL interfaces are rejected. + +LCD3x and zfast LCD are examples of this subset. Import success is not a +claim that every RetroArch shader works, or that a shader tested on desktop +will compile or perform well on mobile/console GPUs. GLES is validated as +shader source on desktop; mobile hardware execution needs separate testing. + +Generated descriptors use `format: 2`, add `vertex`, `esShader`, `esVertex`, +`filter` and `wrap`, and retain the same parameter and persistence schema. +The renderer selects the matching GL/GLES pair. Generated uniform names are +reserved: `g2_p_`, `g2_Source`, and `g2_`. +Native format-1 presets keep their existing behavior. + +For format 2, a private canvas reconstructs the completed primary view on its +native pixel grid: physical input dimensions divided by the renderer's pixel +scale, rounded to the nearest positive integer. `SourceSize` and +`OriginalSize` describe that actual texture as `(w,h,1/w,1/h)`; +`OutputSize` and `FinalViewportSize` describe the physical output. The primary +view includes its current widescreen area and any letterboxing, rather than +an independent original cartridge framebuffer. A mod's preceding presentation +pass is sampled as supplied. Filter/wrap changes apply only to the private +texture. Output dimensions/DPI and the stage ordering above are preserved; +resize, scale changes, replacement, OFF and failures release owned targets. +An imported selection is compiled and checked before replacing a working +preset; later render failures use the existing OFF fallback. + +```sh +cargo test --manifest-path tools/slang-import/Cargo.toml --release --locked +luajit tests/engine/slang_presets.lua +# Import the original PROBE and WRAP fixtures using the command above, then: +SLANG_IMPORT_CHECK_DIR=/absolute/path/to/test-presets love tools/slang-import-check +``` + +The GPU tool checks both shader dialects, custom vertex varyings, parameters, +source/output sizes, wrapping/filtering, alpha, resize/DPI 1/2/2.75 and failed +selection rollback. If the directory also contains imports named `LCD3X` and +`ZFAST LCD`, it compares their real output with independent CPU equations. +`SLANG_IMPORT_CHECK_SHOT=/absolute/path.png` exports a comparison sheet. + +### Format-3 pass graphs + +Multipass, LUT or nonlegacy scaling imports use `format: 3` with `passes` +and `textures` arrays. Each pass holds the four stage filenames, filter, +wrap and `scaleX`/`scaleY` objects (`type`, `value`). LUT records hold `id`, +`image`, `filter` and `wrap`. Filenames are closed basenames; no executable +metadata is loaded. Shared parameters keep their existing IDs and save keys; +conflicting definitions across passes are rejected. + +The runtime compiles both GL/GLES dialects and draws/reads back the complete +graph before replacing a working effect. All canvases and LUT images are +private. Pass sampling settings affect only private rendered inputs; LUTs +retain their own filter/wrap. Original always means the reconstructed native +primary view; Source is the previous pass. The final result is copied to the +primary view's logical size/DPI. Borrowed inputs and the caller's graphics +state are preserved. Resize, OFF, replacement and render failure release +owned resources; render failure returns the unfiltered input. + +Before allocation, each frame checks GPU dimension limits and a 32-million +aggregate pixel budget (RGBA8 targets plus LUT pixels). LUTs must be 8-bit +PNG, at most 8 MiB compressed each, 2048 per axis and eight million pixels +combined; header limits precede native decoding. These bounds refuse expensive +presets rather than silently downscaling them. This is a spatial RGBA8 subset, +not full RetroArch compatibility. + +```sh +# Import graph.slangp as GRAPH, then: +luajit tests/engine/shader_graph.lua +SHADER_GRAPH_CHECK_DIR=/absolute/path/to/test-presets love tools/shader-graph-check +``` + +The graph GPU probe checks separate Original/Source/earlier-pass/LUT values, +per-pass sizes, live parameters, alpha, resize/DPI 1/2/2.75, state restoration +and rejected-candidate rollback against an independent pixel equation. +Pixels exactly on a nearest-sampling boundary are excluded from that equation +because GPU floating-point rounding can legitimately choose either neighbor. 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..7fce046bc --- /dev/null +++ b/docs/world-read-api.md @@ -0,0 +1,154 @@ +# 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 (API 1/2) + +API 3 uses the portable pixel-placement contract in `api3-live-maps.md`. +The enriched records below remain the API-1/2 contract. + +`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/docs/world-shadows-p7.md b/docs/world-shadows-p7.md new file mode 100644 index 000000000..3ece5fef7 --- /dev/null +++ b/docs/world-shadows-p7.md @@ -0,0 +1,68 @@ +# World anchored sun shadows (P7) + +`V.shadowWorldAnchor` defaults to `true` in `mods/DRAMATIC_SHAPE/main.lua`. +Set it to `false` to restore the combined, camera-fitted shadow pass independently +of the other performance flags. A failed secondary target also selects that +complete legacy pass, with a sticky failure until shadow resources are invalidated. + +`WorldShadows` is a separate internal card. It rounds the existing visible-ground +bounds outward to a 256-world-pixel grid. The static light projection stays fixed +while that set of cells remains visible. Actual terrain, water and flower meshes, +map/neighbor offsets, atlas identities and alpha-animation steps, sun direction +and the existing resolution cap participate in invalidation. There is one cached +static projection, not an unbounded collection of tile textures. + +Moving sprites, authored figures and battle cards use a second 256-square target +with the same projection. Their poses, resolved images, chosen frames, first-person +view and battle token determine invalidation. The existing BALANCED cadence still +limits moving updates. Static changes update both maps immediately. The lit shader +samples both maps and combines coverage without doubling shadow darkness. Water +continues to sample static casters only, as its previous actor exclusion requires. + +The anchored path corrects render-target lookup Y under the tested LÖVE 12 Metal +backend. Its pixel test compares the same lit scene with only dynamic sampling +enabled/disabled. The original four-argument shadow pass retains its conversion. +`Tier.lua`, the manifest and engine-internals permissions are unchanged. No loader, +public mod interface, hook or engine record changes are needed. + +## Reproduction and measurement + +Run `python3 tools/check_world_shadow_profile.py --love /absolute/path/to/love +--report .local/qa/world-shadows/profile.json` on one shell line. The guarded runner +uses a separate QA identity, owned geometry and texture, actual native shadow/scene +shaders, matrices and targets. It stores source hashes and the lit-image negative +control. It does not use a cartridge, player profile or game save. + +The 13,824-vertex flat grid and one moving alpha-tested card are an isolated pass +fixture. Each mode has 120 warmup and 120 measured frames. FrameProfile separates +`voxel.sun submit (owned geometry)` from `voxel.sun GPU fence (owned geometry)`. +A one-pixel readback completes GPU work in both modes. Presentation is excluded; +these are offscreen pass times, not full-game FPS or an end-to-end speedup claim. + +| Desktop macOS ARM64 host | Combined pass | Anchored layers | +| --- | ---: | ---: | +| LÖVE 11.5 OpenGL, CPU submission | 0.090 ms | 0.045 ms | +| LÖVE 11.5 OpenGL, including GPU fence | 0.794 ms | 0.298 ms | +| LÖVE 12 Metal, CPU submission | 0.096 ms | 0.074 ms | +| LÖVE 12 Metal, including GPU fence | 0.767 ms | 0.563 ms | +| Static passes per 120 measured frames | 120 | 4 | + +The trajectory crosses a grid boundary and wraps, so four static updates are +expected; the moving layer updates all 120 frames at HIGH. GPU fence costs vary +between runs: an earlier Metal fixture run measured 0.547 → 0.732 ms, while the +final source-bound run above improved. The reduction in static work is repeatable; +this small fixture does not establish a universal GPU speedup. A real populated +map and mobile/handheld performance remain unmeasured. + +Both native hosts pass 22 scoped checks: plain/instanced shader compilation, +negative-coordinate snapping, unchanged static pixels/matrix during walking, +moving pixels and their actual lit contribution, cell/geometry/sun/neighbor +invalidation, idle reuse, balanced cap/cadence, live rollback, and sticky allocation +failure with complete legacy shadows. The source-bound record is +[world-shadows-p7.json](qa/world-shadows-p7.json). + +Integrated fork acceptance: 477/477 engine suites, 3/3 SDK suites, 253 gallery +checks, 200 API-3 comparisons / 400 workers, and all 17 unchanged external fixture +entrypoints in complete mode pass. The public stack retains exactly its previous +29 FAIL lines across 11 inherited suites: 156/167, with no new failure. Its SDK, +gallery, 48 API-3 comparisons / 96 workers and 17 fixture entrypoints all pass. 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_battle_bottom_ui/main.lua b/examples/portable_battle_bottom_ui/main.lua new file mode 100644 index 000000000..d8a64c6a4 --- /dev/null +++ b/examples/portable_battle_bottom_ui/main.lua @@ -0,0 +1,16 @@ +-- A custom battle UI can hide the native bottom layer without pausing input. +-- Decide from state properties; composition may query the predicate repeatedly. +local mod = ... +local hidden, seen = false, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(value) + hidden = value == true +end +mod.exports.stop = mod.hooks:wrap("battle.bottom_ui_visible", function(next, state, ...) + seen.calls = seen.calls + 1 + seen.state, seen.extra = state, select("#", ...) + if hidden then + return false + end + return next(state) +end) diff --git a/examples/portable_battle_bottom_ui/manifest.json b/examples/portable_battle_bottom_ui/manifest.json new file mode 100644 index 000000000..f674b2d36 --- /dev/null +++ b/examples/portable_battle_bottom_ui/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_battle_bottom_ui", + "name": "Portable Battle Bottom UI", + "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_battle_grid/main.lua b/examples/portable_battle_grid/main.lua new file mode 100644 index 000000000..2a3fc7f21 --- /dev/null +++ b/examples/portable_battle_grid/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local mode, seen = "native", { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.mode(value) + mode = value +end +mod.exports.stop = mod.hooks:wrap("battle.move_grid_navigation", function(next, battle) + seen.calls, seen.battle = seen.calls + 1, battle + if mode == "grid" then + return true + end + if mode == "list" then + return false + end + return next(battle) +end) diff --git a/examples/portable_battle_grid/manifest.json b/examples/portable_battle_grid/manifest.json new file mode 100644 index 000000000..c1ce41463 --- /dev/null +++ b/examples/portable_battle_grid/manifest.json @@ -0,0 +1 @@ +{"id":"portable_battle_grid","name":"Portable Battle Navigation","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_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_follower/main.lua b/examples/portable_follower/main.lua new file mode 100644 index 000000000..b3c8f6ef9 --- /dev/null +++ b/examples/portable_follower/main.lua @@ -0,0 +1,18 @@ +local mod = ... +local state = { mode = "native", calls = 0 } +mod.exports.api, mod.exports.state = mod, state +local stop +if not mod.supports or mod:supports("hooks", "world.follower.spawn") then + stop = mod.hooks:wrap("world.follower.spawn", function(next, game, world) + state.calls, state.game, state.world = state.calls + 1, game, world + if state.mode == "native" then + return next(game, world) + end + return state.mode == "allow" + end) +end +mod.exports.stop = function() + if stop then + stop() + end +end diff --git a/examples/portable_follower/manifest.json b/examples/portable_follower/manifest.json new file mode 100644 index 000000000..8d7e6f494 --- /dev/null +++ b/examples/portable_follower/manifest.json @@ -0,0 +1 @@ +{"id":"portable_follower","name":"Portable follower predicate","version":"1.0.0","api":3,"games":["all"],"entry":"main.lua","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_level_visible/main.lua b/examples/portable_level_visible/main.lua new file mode 100644 index 000000000..f026590af --- /dev/null +++ b/examples/portable_level_visible/main.lua @@ -0,0 +1,16 @@ +-- Hide levels per surface while leaving the native screen and input active. +-- Rendering can query a predicate repeatedly; keep the decision deterministic. +local mod = ... +local hidden, seen = {}, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(where, value) + hidden[where] = value == true +end +mod.exports.stop = mod.hooks:wrap("pokemon.level_visible", function(next, mon, context) + seen.calls = seen.calls + 1 + seen.mon, seen.context = mon, context + if hidden[context.where] then + return false + end + return next(mon, context) +end) diff --git a/examples/portable_level_visible/manifest.json b/examples/portable_level_visible/manifest.json new file mode 100644 index 000000000..60aef31ee --- /dev/null +++ b/examples/portable_level_visible/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_level_visible", + "name": "Portable Level Visibility", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3], + "affects_link": false +} diff --git a/examples/portable_live_maps/main.lua b/examples/portable_live_maps/main.lua new file mode 100644 index 000000000..da0cfb322 --- /dev/null +++ b/examples/portable_live_maps/main.lua @@ -0,0 +1,21 @@ +local mod = ... +local events, stops = {}, {} +for _, phase in ipairs({ "preparing", "updated" }) do + local label = phase + local name = "world.live_maps_" .. phase + if not mod.supports or mod:supports("events", name) then + stops[#stops + 1] = mod.events:on(name, function(payload) + events[#events + 1] = { phase = label, mapId = payload.mapId, maps = payload.maps } + end) + end +end +mod.exports.api = mod +mod.exports.events = events +mod.exports.maps = function() + return mod.world:liveMaps() +end +mod.exports.stop = function() + for _, stop in ipairs(stops) do + stop() + end +end diff --git a/examples/portable_live_maps/manifest.json b/examples/portable_live_maps/manifest.json new file mode 100644 index 000000000..9e02dea12 --- /dev/null +++ b/examples/portable_live_maps/manifest.json @@ -0,0 +1 @@ +{"id": "portable_live_maps", "name": "Portable live-map lifecycle", "version": "1.0.0", "api": 3, "games": ["all"], "entry": "main.lua", "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_party_grid/main.lua b/examples/portable_party_grid/main.lua new file mode 100644 index 000000000..9fc580cb6 --- /dev/null +++ b/examples/portable_party_grid/main.lua @@ -0,0 +1,16 @@ +local mod = ... +local enabled = false +local seen = { calls = 0 } +local stop = mod.hooks:wrap("ui.party.grid_navigation", function(next, menu) + seen.calls, seen.menu = seen.calls + 1, menu + if enabled then + return true + end + return next(menu) +end) +mod.exports.api = mod +mod.exports.seen = seen +mod.exports.grid = function(value) + enabled = value +end +mod.exports.stop = stop diff --git a/examples/portable_party_grid/manifest.json b/examples/portable_party_grid/manifest.json new file mode 100644 index 000000000..ae978eb57 --- /dev/null +++ b/examples/portable_party_grid/manifest.json @@ -0,0 +1 @@ +{"id": "portable_party_grid", "name": "Portable party navigation", "version": "1.0.0", "api": 3, "generations": [1, 2, 3], "entry": "main.lua", "games": ["all"], "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/examples/portable_status_hud/main.lua b/examples/portable_status_hud/main.lua new file mode 100644 index 000000000..d7dc8ea97 --- /dev/null +++ b/examples/portable_status_hud/main.lua @@ -0,0 +1,16 @@ +-- A custom battle UI can hide the native status panels without pausing input. +-- Decide from state properties; composition may query the predicate repeatedly. +local mod = ... +local hidden, seen = false, { calls = 0 } +mod.exports.api, mod.exports.seen = mod, seen +function mod.exports.hide(value) + hidden = value == true +end +mod.exports.stop = mod.hooks:wrap("battle.status_hud_visible", function(next, state, ...) + seen.calls = seen.calls + 1 + seen.state, seen.extra = state, select("#", ...) + if hidden then + return false + end + return next(state) +end) diff --git a/examples/portable_status_hud/manifest.json b/examples/portable_status_hud/manifest.json new file mode 100644 index 000000000..be7bdee7b --- /dev/null +++ b/examples/portable_status_hud/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_status_hud", + "name": "Portable Status HUD", + "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_storage/main.lua b/examples/portable_storage/main.lua new file mode 100644 index 000000000..35d8e0373 --- /dev/null +++ b/examples/portable_storage/main.lua @@ -0,0 +1,17 @@ +local mod = ... +local storage = mod.storage +for _, method in ipairs({ + "context", + "selected", + "read", + "write", + "readBytes", + "writeBytes", + "list", + "delete", +}) do + local name = method + mod.exports[name] = function(...) + return storage[name](storage, ...) + end +end diff --git a/examples/portable_storage/manifest.json b/examples/portable_storage/manifest.json new file mode 100644 index 000000000..4c01872f9 --- /dev/null +++ b/examples/portable_storage/manifest.json @@ -0,0 +1 @@ +{"id": "portable_storage", "name": "Portable playthrough storage", "version": "1.0.0", "api": 3, "games": ["all"], "entry": "main.lua"} diff --git a/examples/portable_trainer_preparation/main.lua b/examples/portable_trainer_preparation/main.lua new file mode 100644 index 000000000..baaf869a3 --- /dev/null +++ b/examples/portable_trainer_preparation/main.lua @@ -0,0 +1,21 @@ +-- A preparation UI may retain continueBattle; next itself stays synchronous. +local mod = ... +local out = mod.exports +out.api, out.mode, out.calls = mod, "defer", 0 +out.stop = mod.hooks:wrap("trainer.before_battle", function(next, game, context, continueBattle, ...) + out.calls = out.calls + 1 + out.game, out.context, out.continue, out.extra = game, context, continueBattle, select("#", ...) + if out.mode == "error" or out.mode == "after error" then + if out.mode == "after error" then + next(game, context, continueBattle) + end + error("intentional trainer preparation failure") + end + if out.mode == "observe" then + return next(game, context, continueBattle) + end + if out.mode == "immediate" then + continueBattle(out.options) + end + return out.mode == "defer" or out.mode == "immediate" +end) diff --git a/examples/portable_trainer_preparation/manifest.json b/examples/portable_trainer_preparation/manifest.json new file mode 100644 index 000000000..fe5447bf1 --- /dev/null +++ b/examples/portable_trainer_preparation/manifest.json @@ -0,0 +1 @@ +{"id":"portable_trainer_preparation","name":"Portable Trainer Preparation","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_world_talk/main.lua b/examples/portable_world_talk/main.lua new file mode 100644 index 000000000..1e129b1a1 --- /dev/null +++ b/examples/portable_world_talk/main.lua @@ -0,0 +1,18 @@ +-- A portable mod can answer its own NPC without replacing engine methods. +local mod = ... +local out = mod.exports +local claimedId +out.api, out.seen = mod, { calls = 0, claimed = 0 } +function out.claim(id) + claimedId = id +end +out.stop = mod.hooks:wrap("world.talk", function(next, owner, target, ...) + local seen = out.seen + seen.calls = seen.calls + 1 + seen.owner, seen.target, seen.extra = owner, target, select("#", ...) + if claimedId and target and target.id == claimedId then + seen.claimed = seen.claimed + 1 + return "claimed" + end + return next(owner, target) +end) diff --git a/examples/portable_world_talk/manifest.json b/examples/portable_world_talk/manifest.json new file mode 100644 index 000000000..c3212a247 --- /dev/null +++ b/examples/portable_world_talk/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "portable_world_talk", + "name": "Portable NPC Talk", + "version": "1.0.0", + "api": 3, + "entry": "main.lua", + "games": ["all"], + "generations": [1, 2, 3, 4, 5], + "affects_link": false +} diff --git a/licenses/SameBoy.txt b/licenses/SameBoy.txt new file mode 100644 index 000000000..41c3bc963 --- /dev/null +++ b/licenses/SameBoy.txt @@ -0,0 +1,21 @@ +Expat License + +Copyright (c) 2015-2026 Lior Halphon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/main.lua b/main.lua index cf453d89d..d3e476ef0 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. @@ -1203,6 +1206,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 +1227,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 +1267,7 @@ function love.run() -- update dt if love.timer then dt = love.timer.step() end + GCFrameBudget.beginFrame(Performance) -- call update and draw -- @@ -1274,12 +1284,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 +1324,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/RenderScale.lua b/mods/DRAMATIC_SHAPE/lib/RenderScale.lua new file mode 100644 index 000000000..bc1cee666 --- /dev/null +++ b/mods/DRAMATIC_SHAPE/lib/RenderScale.lua @@ -0,0 +1,63 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local V = ... +local Card = {} +local Setting = V.require("ModSetting") +Card.setting = Setting.new("renderScale", "RENDER SCALE", { "auto", 1, 2, 3, 4 }, { "AUTO", "1X", "2X", "3X", "4X" }) +function Card.mobile() + local os = love.system and love.system.getOS and love.system.getOS() or "" + local arch = jit and jit.arch or "" + return os == "Android" + or os == "iOS" + or os == "Horizon" + or os == "Switch" + or (os == "Linux" and (arch == "arm" or arch == "arm64")) +end +function Card.factor() + if V.renderBudget == false then + return 1 + end + local value = Card.setting:get() + if value == "auto" then + return Card.mobile() and 2 or 1 + end + return math.max(1, math.min(4, math.floor(tonumber(value) or 1))) +end +function Card.size(w, h) + local f = Card.factor() + return math.max(1, math.ceil(w / f)), math.max(1, math.ceil(h / f)) +end +local canvas, width, height +function Card.present(image, w, h) + if image:getWidth() == w and image:getHeight() == h then + return image + end + if not canvas or width ~= w or height ~= h then + local ok, made = V.require("PixelCanvas").new(w, h) + if not ok then + return nil + end + Card.invalidate() + canvas, width, height = made, w, h + canvas:setFilter("nearest", "nearest") + end + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.setShader() + love.graphics.setDepthMode() + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.clear(0, 0, 0, 0) + love.graphics.setBlendMode("replace", "premultiplied") + image:setFilter("nearest", "nearest") + love.graphics.draw(image, 0, 0, 0, w / image:getWidth(), h / image:getHeight()) + love.graphics.pop() + return canvas +end +function Card.invalidate() + if canvas then + canvas:release() + end + canvas, width, height = nil, nil, nil +end +return Card diff --git a/mods/DRAMATIC_SHAPE/lib/ShaderState.lua b/mods/DRAMATIC_SHAPE/lib/ShaderState.lua index 493ccd828..5fb890e0c 100644 --- a/mods/DRAMATIC_SHAPE/lib/ShaderState.lua +++ b/mods/DRAMATIC_SHAPE/lib/ShaderState.lua @@ -3,6 +3,7 @@ local V=... local S={} local values=setmetatable({}, {__mode="k"}) local unpack=table.unpack or unpack +local lastBudget -- Snapshot small plain numeric vectors/matrices. Comparing table identity is -- unsafe: callers mutate camera and model arrays in place between draws. local function numericSize(value) @@ -13,11 +14,14 @@ local function numericSize(value) return n end function S.send(shader,name,...) - if not (V and V.sharedHulls) then return pcall(shader.send,shader,name,...) end + local budget=V and V.renderBudget~=false + if budget~=lastBudget then values=setmetatable({}, {__mode="k"});lastBudget=budget end + if not budget and not (V and V.sharedHulls) then return pcall(shader.send,shader,name,...) end local record=values[shader] if not record then record={};values[shader]=record end local args=record[name] if not args then args={};record[name]=args end + if budget and args.failed then return false,args.failed end local arrays=args.arrays if not arrays then arrays={};args.arrays=arrays end local n=select("#",...) @@ -46,7 +50,10 @@ function S.send(shader,name,...) end for i=n+1,args.n or #args do args[i]=nil;arrays[i]=nil end args.n=n - local ok,err=pcall(shader.send,shader,name,...) + local ok,err + if budget and args.sent then shader:send(name,...);ok=true + else ok,err=pcall(shader.send,shader,name,...) end + if budget and not ok and (not shader.hasUniform or not shader:hasUniform(name)) then args.failed=err end args.sent=ok return ok,err end diff --git a/mods/DRAMATIC_SHAPE/lib/ShadowMap.lua b/mods/DRAMATIC_SHAPE/lib/ShadowMap.lua index 320dfc268..17cc51b88 100644 --- a/mods/DRAMATIC_SHAPE/lib/ShadowMap.lua +++ b/mods/DRAMATIC_SHAPE/lib/ShadowMap.lua @@ -168,6 +168,8 @@ ShadowMap._source = function() return SHADER end -- named for the suite local shader = nil -- nil = untried, false = unavailable local canvas = nil -- nil = untried, false = unavailable +local dynamicCanvas, dynamicReady = nil, false +local anchored = false local canvasRes = 0 -- the edge `canvas` was made at local blank = nil -- 1x1 stand-in so the sampler is never unbound local drawing = false @@ -353,20 +355,20 @@ end -- continuously with the camera reprojects every shadow edge a fraction of a -- texel every frame and the whole world's shadows crawl and shimmer while -- you walk. -local function fit(cx, cy, vw, vh) +function ShadowMap.groundBounds(cx,cy,vw,vh) + local reach=ShadowMap.HEIGHT*math.max(math.abs(ShadowMap.KX),math.abs(ShadowMap.KZ))+24 + local north=groundReach(vh) + local spread=north*.5 + return cx-vw/2-spread,cx+vw/2+spread+reach,cy-north,cy+vh/2+reach +end +local function fit(cx, cy, vw, vh, bounds) local f = sunDir() local view = Mat4.lookAt({ 0, 0, 0 }, f, { 0, 0, -1 }) - - local reach = ShadowMap.HEIGHT - * math.max(math.abs(ShadowMap.KX), math.abs(ShadowMap.KZ)) + 24 - local north = groundReach(vh) - -- the view widens with distance, so the far ground spans more than the - -- near ground does; half the depth is a serviceable stand-in for the - -- frustum's true spread and costs a good deal less resolution - local spread = north * 0.5 - local xs = { cx - vw / 2 - spread, cx + vw / 2 + spread + reach } - local ys = { -32, ShadowMap.HEIGHT } -- -32 covers recessed water - local zs = { cy - north, cy + vh / 2 + reach } + local x0,x1,z0,z1=ShadowMap.groundBounds(cx,cy,vw,vh) + if bounds then x0,x1,z0,z1=bounds[1],bounds[2],bounds[3],bounds[4]end + local xs={x0,x1} + local ys={-32,ShadowMap.HEIGHT} + local zs={z0,z1} local l, r, b, t, zn, zf for _, x in ipairs(xs) do @@ -428,6 +430,14 @@ local function fit(cx, cy, vw, vh) ShadowMap.clipVP = Mat4.mul(proj, view) ShadowMap.uvVP = Mat4.mul(TO_UNIT, ShadowMap.clipVP) + -- The LÖVE 12 Metal target rasterizes the canvas Y axis opposite to GL. + -- Correct the new anchored lookup, including water's static lookup; the + -- original four-argument pass keeps its existing conversion for rollback. + local major=love.getVersion and love.getVersion()or 11 + local renderer=love.graphics.getRendererInfo and love.graphics.getRendererInfo() + if bounds and major>=12 and renderer=="Metal"then + ShadowMap.uvVP=Mat4.mul({1,0,0,0, 0,-1,0,1, 0,0,1,0, 0,0,0,1},ShadowMap.uvVP) + end -- what the frustum ended up covering, for probes: the lateral extent in -- world pixels divided by RES is how fine a shadow edge can land ShadowMap.extent = { r - l, t - b, far - near } @@ -499,11 +509,11 @@ end -- Begin the sun pass. Returns false when it could not start, in which case -- the caller must not draw into it or call finish. -function ShadowMap.begin(cx, cy, vw, vh) +function ShadowMap.begin(cx, cy, vw, vh, bounds) local sh = getShader() if not sh then return false end -- fit first: it is what decides which resolution rung this view wants - fit(cx, cy, vw, vh) + fit(cx, cy, vw, vh, bounds) local c = getCanvas(ShadowMap.res) if not c then return false end local ok = pcall(love.graphics.setCanvas, { c, depth = true }) @@ -525,11 +535,60 @@ function ShadowMap.begin(cx, cy, vw, vh) -- the world until a cast pass says otherwise, reset per pass so one that -- forgot to put it back cannot leak into the next map's terrain ShaderState.send(sh, "sprite", 0) + anchored=bounds~=nil + dynamicReady=false drawing = true ready = false return true end +-- Optional P7 layer. Both textures use the same world-to-light matrix; +-- water keeps sampling only the static texture, as it already ignores actors. +function ShadowMap.isAnchored()return anchored end +function ShadowMap.useLegacy() + if anchored then ready=false end + anchored=false;dynamicReady=false +end +function ShadowMap.dynamicAvailable() + if dynamicCanvas==false then return false end + if dynamicCanvas then return true end + local ok,c=V.require("PixelCanvas").new(256,256) + if not(ok and c)then dynamicCanvas=false;return false end + c:setFilter("nearest","nearest");pcall(c.setWrap,c,"clamp","clamp") + dynamicCanvas=c + return true +end +function ShadowMap.dynamicActive() + return V.shadowWorldAnchor~=false and anchored and ready and dynamicReady and caps().shadows +end +function ShadowMap.dynamicTexture() + return ShadowMap.dynamicActive()and dynamicCanvas or getBlank() +end +function ShadowMap.beginDynamic() + if not(ready and anchored and ShadowMap.dynamicAvailable())then return false end + local ok=pcall(love.graphics.setCanvas,{dynamicCanvas,depth=true}) + if not ok then + pcall(love.graphics.setCanvas) + if dynamicCanvas.release then pcall(dynamicCanvas.release,dynamicCanvas)end + dynamicCanvas=false;dynamicReady=false + return false + end + prevBlend,prevAlphaMode=love.graphics.getBlendMode() + love.graphics.clear(1,1,0,1,true,true) + love.graphics.setDepthMode("lequal",true);love.graphics.setMeshCullMode("none") + love.graphics.setBlendMode("replace","premultiplied") + love.graphics.setShader(getShader());love.graphics.setColor(1,1,1,1) + ShaderState.send(getShader(),"lightVP","row",ShadowMap.clipVP) + ShaderState.send(getShader(),"sprite",1) + drawing=true;dynamicReady=false + return true +end +function ShadowMap.finishDynamic() + if not drawing then return end + ShadowMap.finish(lastSig) + dynamicReady=true +end + -- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward -- pull, which is a trick for the VIEW's depth buffer and would drag a -- shadow off whatever throws it. @@ -593,6 +652,7 @@ end -- Drop the GPU objects (window resize, hot reload). function ShadowMap.invalidate() canvas, canvasRes, blank = nil, 0, nil + dynamicCanvas,dynamicReady,anchored=nil,false,false drawing, ready, lastSig = false, false, nil end diff --git a/mods/DRAMATIC_SHAPE/lib/Tier.lua b/mods/DRAMATIC_SHAPE/lib/Tier.lua index 26790bf32..2b8673be8 100644 --- a/mods/DRAMATIC_SHAPE/lib/Tier.lua +++ b/mods/DRAMATIC_SHAPE/lib/Tier.lua @@ -144,10 +144,13 @@ end -- player's OWN choice on, and a ceiling cannot. local CAPS = { high = { shadows = true, shadowRes = nil, shadowHz = nil, + waterRaySteps = 12, waterRayRefine = 3, water = nil, waterCast = nil, waterPlanar = nil, aa = nil }, balanced = { shadows = true, shadowRes = 1024, shadowHz = 3, + waterRaySteps = 12, waterRayRefine = 3, water = 1, waterCast = 0, waterPlanar = 0, aa = 2 }, low = { shadows = false, shadowRes = 1024, shadowHz = 0, + waterRaySteps = 12, waterRayRefine = 3, water = 0, waterCast = 0, waterPlanar = 0, aa = 0 }, } diff --git a/mods/DRAMATIC_SHAPE/lib/TileShape.lua b/mods/DRAMATIC_SHAPE/lib/TileShape.lua index 7ead121da..1edd1925c 100644 --- a/mods/DRAMATIC_SHAPE/lib/TileShape.lua +++ b/mods/DRAMATIC_SHAPE/lib/TileShape.lua @@ -570,8 +570,15 @@ end -- apron, which on Route 119 is about eighty thousand calls per rebuild. local gen3Shapes = {} local function gen3Shape(class, h, pinned) - local key = class .. "\0" .. h .. (pinned and "\0p" or "") - local hit = gen3Shapes[key] + local bucket=gen3Shapes + local key + if V.renderBudget~=false then + local group=bucket[class] + if not group then group={{},{}};bucket[class]=group end + bucket=group[pinned and 2 or 1] + key=h + else key=class .. "\0" .. h .. (pinned and "\0p" or "") end + local hit = bucket[key] if hit then return hit end local art = ART[class] or "upright" local rec = { @@ -593,7 +600,7 @@ local function gen3Shape(class, h, pinned) authored = pinned or (art ~= "upright"), gen3 = true, } - gen3Shapes[key] = rec + bucket[key] = rec return rec end diff --git a/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua b/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua index cc9c52b77..de7275cf6 100644 --- a/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua +++ b/mods/DRAMATIC_SHAPE/lib/Voxel3D.lua @@ -202,6 +202,9 @@ local SHADER = [[ #endif #ifdef PIXEL uniform Image sunMap; + uniform Image sunDynamicMap; + uniform float sunDynamicActive; + uniform vec2 sunDynamicTexel; uniform float sunDark; // how far into black a shadow goes; 0 = off uniform float sunBias; uniform vec2 sunTexel; @@ -212,6 +215,11 @@ local SHADER = [[ return c.r + c.g * (1.0 / 255.0); } + float sunDynamicDepth(vec2 uv) { + vec4 c = Texel(sunDynamicMap, uv); + return c.r + c.g * (1.0 / 255.0); + } + // 1.0 in full sun, 1.0 - sunDark in full shadow. Four taps half a texel // out on the diagonals: a 2x2 box filter, which is what turns the // shadow map's texel staircase into a one-pixel soft edge. @@ -235,6 +243,13 @@ local SHADER = [[ + step(z, sunDepth(p.xy + sunTexel * vec2( 0.5, -0.5))) + step(z, sunDepth(p.xy + sunTexel * vec2(-0.5, 0.5))) + step(z, sunDepth(p.xy + sunTexel * vec2( 0.5, 0.5))); + if (sunDynamicActive > 0.5) { + float moving = step(z, sunDynamicDepth(p.xy + sunDynamicTexel * vec2(-0.5, -0.5))) + + step(z, sunDynamicDepth(p.xy + sunDynamicTexel * vec2( 0.5, -0.5))) + + step(z, sunDynamicDepth(p.xy + sunDynamicTexel * vec2(-0.5, 0.5))) + + step(z, sunDynamicDepth(p.xy + sunDynamicTexel * vec2( 0.5, 0.5))); + lit = min(lit, moving); + } return 1.0 - sunDark * edge * (1.0 - lit * 0.25); } @@ -398,7 +413,13 @@ local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" } local function newDepth(w, h) if not (love.graphics and love.graphics.newCanvas) then return nil end local c = nil - for _, format in ipairs(DEPTH_FORMATS) do + local formats=DEPTH_FORMATS + local renderer,version + if love.graphics.getRendererInfo then renderer,version=love.graphics.getRendererInfo()end + if V.renderBudget~=false and ((renderer or ""):find("OpenGL ES",1,true) or (version or ""):find("OpenGL ES",1,true)) then + formats={"depth16","depth24","depth24stencil8","depth32f"} + end + for _, format in ipairs(formats) do local ok, made = pcall(love.graphics.newCanvas, w, h, { format = format, readable = true }) if ok and made then c = made break end @@ -1018,6 +1039,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) ShaderState.send(sh, "sunVP", "row", map and ShadowMap.uvVP or IDENTITY) local tex = ShadowMap.texture() if tex then ShaderState.send(sh, "sunMap", tex) end + local movingTex=ShadowMap.dynamicTexture() + if movingTex then ShaderState.send(sh,"sunDynamicMap",movingTex)end + ShaderState.send(sh,"sunDynamicActive",ShadowMap.dynamicActive()and 1 or 0) + ShaderState.send(sh,"sunDynamicTexel",{1/256,1/256}) ShaderState.send(sh, "sunDark", map and Voxel3D.SHADOW_ALPHA or 0) ShaderState.send(sh, "sunBias", ShadowMap.bias) local texel = 1 / ShadowMap.res diff --git a/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua b/mods/DRAMATIC_SHAPE/lib/VoxelScene.lua index 2084e56b4..18fab4490 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 @@ -22,6 +23,18 @@ local Tier = V.require("Tier") local Voxel3D = V.require("Voxel3D") local ShadowMap = V.require("ShadowMap") local ChunkMesher = V.require("ChunkMesher") +local WorldShadows = V.require("WorldShadows") +local WaterBudget = V.require("WaterBudget") +local neighborMatrices=setmetatable({}, {__mode="k"}) +local function neighborMatrix(nb) + if V.renderBudget==false then return Mat4.translate(nb.ox,0,nb.oy)end + local entry=neighborMatrices[nb] + if not entry or entry.x~=nb.ox or entry.z~=nb.oy then + entry={x=nb.ox,z=nb.oy,matrix=Mat4.translate(nb.ox,0,nb.oy)} + neighborMatrices[nb]=entry + end + return entry.matrix +end local SpriteBillboards = V.require("SpriteBillboards") local TileShape = V.require("TileShape") local Structures = V.require("Structures") @@ -2184,7 +2197,7 @@ local function drawPlanar(state, terrain, nbMesh, atlasFor, cast, 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)) + neighborMatrix(nb)) end -- the bank the water laps at. No camera-ward pull: the pull is a depth -- bias aimed at the FRAME's own buffer, to settle grass against the feet @@ -2194,7 +2207,7 @@ local function drawPlanar(state, terrain, nbMesh, atlasFor, cast, Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil) Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil) for _, nb in ipairs(state.neighbors or {}) do - local m = Mat4.translate(nb.ox, 0, nb.oy) + local m = neighborMatrix(nb) Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), m) Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), m) end @@ -2271,12 +2284,14 @@ function VoxelScene.drawWater(draws, cast, planar, planarY) local tW = Perf.now() -- prepass only under the bend; see the header local curved = (Voxel3D.curveK or 0) > 0 - if curved then + local prepass = curved or WaterBudget.enabled() + if prepass then for _, d in ipairs(draws) do Voxel3D.draw(d[1], d[2], d[3]) end end - local plain = not curved + local plain = not prepass + local half, painted if Water.enabled() and Voxel3D.depthReadable() then -- `cast` only where something will actually LOOK at the mirror. Below -- FULL the shader's `rays` is 0 and it never samples reflectTex at all, @@ -2286,16 +2301,19 @@ function VoxelScene.drawWater(draws, cast, planar, planarY) -- draws nobody looked at (see Water.castLevel). With the WATER SPRITES -- row ON at FULL this is exactly the `cast` it always was, and the -- frame is the frame it always drew. - local mirror, depth = Voxel3D.beginWater(Water.reflectCast() and cast + local mirror, depth = Voxel3D.beginWater(Water.reflectCast() and not (WaterBudget.enabled() and planar) and cast or nil) local w, h = Voxel3D.size() + local pw,ph=w,h + if mirror and depth then half,pw,ph=WaterBudget.begin(w,h) end + if not half then pw,ph=w,h end local ok = mirror and depth and Water.begin({ reflect = mirror, depth = depth, vp = Voxel3D.vp, eye = Voxel3D.eye, curve = { Voxel3D.curveX or 0, Voxel3D.curveZ or 0, Voxel3D.curveK or 0 }, - screen = { w, h }, cell = Voxel3D.cell, fov = Voxel3D.fovY, - skyEdge = Voxel3D.skyEdge, grid = VoxelGrid.enabled(), + screen = { pw, ph }, cell = Voxel3D.cell*pw/w, fov = Voxel3D.fovY, + skyEdge = Voxel3D.skyEdge and Voxel3D.skyEdge*ph/h, grid = VoxelGrid.enabled(), lookFlat = Voxel3D.lookFlat, descent = Voxel3D.descent, -- the mirrored scene, already rendered and already released from its -- canvas (see drawPlanar). nil is the answer on every frame the row is @@ -2309,6 +2327,7 @@ function VoxelScene.drawWater(draws, cast, planar, planarY) Water.draw(d[1], d[2], d[3]) end Water.finish() + painted = true plain = false end -- Unconditionally, and OUTSIDE the success branch: beginWater unbinds @@ -2316,7 +2335,11 @@ function VoxelScene.drawWater(draws, cast, planar, planarY) -- so a frame that bails halfway through has to be put back together -- exactly like one that succeeded -- otherwise every pass after it runs -- with no shader and no depth test. + WaterBudget.finish() Voxel3D.endWater() + if half and painted then + WaterBudget.composite(draws,Voxel3D.vp,{Voxel3D.curveX or 0,Voxel3D.curveZ or 0,Voxel3D.curveK or 0}) + end end -- the fallback flat draw -- unless the curve's prepass already put the -- same meshes down, in which case a bailed frame is already whole @@ -2434,60 +2457,11 @@ end -- Runs BEFORE Voxel3D.beginScene, because canvases do not nest. Grass is -- left out on purpose: thousands of tufts would cast a speckle no bigger -- than the pixels it lands on, at the cost of the mesh being drawn twice. -local shadowAnimations={} -local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, - atlasFor, water, nbWater, battleCards, battleToken) - if not ShadowMap.available() then return end - shadowAnimations[1]=TerrainAtlas.shadowStep(state.map) - local count=1 - for i,nb in ipairs(state.neighbors or {})do - count=count+1;shadowAnimations[count]=nbMesh[i] and TerrainAtlas.shadowStep(nb.map) or 0 - end - for i=count+1,#shadowAnimations do shadowAnimations[i]=nil end - local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh, battleToken,shadowAnimations) - -- a staged fight's pics move every frame the animation does, and the sun - -- has to follow them (VR frames only; see render) - if not ShadowMap.stale(sig) then return end - - -- THE SUN DOES NOT HAVE TO FOLLOW EVERY FOOTSTEP. - -- - -- The stamp above is honest and it is also why this pass runs on every - -- single frame: it stales on any caster moving, and in a town or a wood - -- somebody is always mid-step. Measured standing still in Petalburg - -- Woods with nothing left to build: 202 sun passes in 202 rendered - -- frames, 53% of VoxelScene.render, to re-record a terrain mesh that had - -- not changed by one vertex. - -- - -- So BALANCED rate-limits the CAST half and only the cast half. The - -- static half (camera, view, sun angle, first-person head, the meshes - -- themselves) still forces a redraw on the frame it changes, because a - -- map fitted to last frame's frustum is a wrong shadow rather than a late - -- one -- see shadowStaticTerms. What is delayed is strictly "somebody - -- took a step", and at the shipped rate of 3 the worst case is a walker's - -- own shadow two frames behind their feet, which at walking pace is under - -- two world pixels. - -- - -- nil at HIGH -- the branch is skipped entirely and this function behaves - -- exactly as it did, redraw for redraw. - local hz = Tier.caps().shadowHz - if hz and hz > 1 then - local st = shadowStaticSignature(terrain, nbMesh, cx, cy, vw, vh,shadowAnimations) - shadowStale = shadowStale + 1 - if ShadowMap.hasFrame() and st == shadowLastStatic and (shadowStale - shadowLastCast) < hz then - Perf.count("shadow.throttled") - return - end - shadowLastStatic, shadowLastCast = st, shadowStale - end - - Perf.count("shadow.cast") - local tSun = Perf.now() - if not ShadowMap.begin(cx, cy, vw, vh) then return end - +local function drawStaticCasters(state,terrain,nbMesh,atlasFor,water,nbWater) ShadowMap.draw(terrain, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do ShadowMap.draw(nbMesh[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + neighborMatrix(nb)) end -- The water surface, which the terrain mesh no longer carries (it is its -- own reflective pass now -- see Water). The sun still has to see it, or @@ -2496,7 +2470,7 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, ShadowMap.draw(water, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + neighborMatrix(nb)) end -- flower billboards live outside the terrain mesh (they draw after the -- characters, pulled -- see render), but the sun still sees them: a @@ -2508,8 +2482,10 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, ShadowMap.snug(nil)) for _, nb in ipairs(state.neighbors or {}) do ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + ShadowMap.snug(neighborMatrix(nb))) end +end +local function drawMovingCasters(state,posed,atlasFor,battleCards) -- From here down it is the CAST, marked as such in the map (see -- ShadowMap.sprites) so water can decline them: everything the world casts -- still shades a lake, a silhouette of somebody standing beside it does @@ -2557,6 +2533,85 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, end ShadowMap.sprites(false) +end +local function shadowPoseFrame(p) + return frameFor(p.sprite.def,viewFacing(p),p.phase,p.flip,p.frame) +end +local function castAnchoredShadows(state,terrain,nbMesh,posed,cx,cy,vw,vh,atlasFor,water,nbWater,battleCards,battleToken,animation) + if not ShadowMap.dynamicAvailable()then return false end + local plan=WorldShadows.prepare(state,terrain,nbMesh,water,nbWater,posed,cx,cy,vw,vh,battleToken,animation,FirstPerson.signature(),atlasFor,shadowPoseFrame) + local tSun=Perf.now() + if plan.static then + if not ShadowMap.begin(cx,cy,vw,vh,plan.bounds)then return false end + drawStaticCasters(state,terrain,nbMesh,atlasFor,water,nbWater) + ShadowMap.finish(plan.staticToken);WorldShadows.commitStatic() + Perf.count("shadow.static.cast") + end + if plan.dynamic then + if not ShadowMap.beginDynamic()then return false end + drawMovingCasters(state,posed,atlasFor,battleCards) + ShadowMap.finishDynamic();WorldShadows.commitDynamic() + Perf.count("shadow.dynamic.cast") + end + Perf.add("VoxelScene.castShadows",tSun) + return true +end +local shadowAnimations={} +local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, + atlasFor, water, nbWater, battleCards, battleToken) + if not ShadowMap.available() then return end + shadowAnimations[1]=TerrainAtlas.shadowStep(state.map) + local count=1 + for i,nb in ipairs(state.neighbors or {})do + count=count+1;shadowAnimations[count]=nbMesh[i] and TerrainAtlas.shadowStep(nb.map) or 0 + end + for i=count+1,#shadowAnimations do shadowAnimations[i]=nil end + if V.shadowWorldAnchor~=false and castAnchoredShadows(state,terrain,nbMesh,posed,cx,cy,vw,vh, + atlasFor,water,nbWater,battleCards,battleToken,shadowAnimations)then return end + ShadowMap.useLegacy() + local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh, battleToken,shadowAnimations) + -- a staged fight's pics move every frame the animation does, and the sun + -- has to follow them (VR frames only; see render) + if not ShadowMap.stale(sig) then return end + + -- THE SUN DOES NOT HAVE TO FOLLOW EVERY FOOTSTEP. + -- + -- The stamp above is honest and it is also why this pass runs on every + -- single frame: it stales on any caster moving, and in a town or a wood + -- somebody is always mid-step. Measured standing still in Petalburg + -- Woods with nothing left to build: 202 sun passes in 202 rendered + -- frames, 53% of VoxelScene.render, to re-record a terrain mesh that had + -- not changed by one vertex. + -- + -- So BALANCED rate-limits the CAST half and only the cast half. The + -- static half (camera, view, sun angle, first-person head, the meshes + -- themselves) still forces a redraw on the frame it changes, because a + -- map fitted to last frame's frustum is a wrong shadow rather than a late + -- one -- see shadowStaticTerms. What is delayed is strictly "somebody + -- took a step", and at the shipped rate of 3 the worst case is a walker's + -- own shadow two frames behind their feet, which at walking pace is under + -- two world pixels. + -- + -- nil at HIGH -- the branch is skipped entirely and this function behaves + -- exactly as it did, redraw for redraw. + local hz = Tier.caps().shadowHz + if hz and hz > 1 then + local st = shadowStaticSignature(terrain, nbMesh, cx, cy, vw, vh,shadowAnimations) + shadowStale = shadowStale + 1 + if ShadowMap.hasFrame() and st == shadowLastStatic and (shadowStale - shadowLastCast) < hz then + Perf.count("shadow.throttled") + return + end + shadowLastStatic, shadowLastCast = st, shadowStale + end + + Perf.count("shadow.cast") + local tSun = Perf.now() + if not ShadowMap.begin(cx, cy, vw, vh) then return end + + drawStaticCasters(state,terrain,nbMesh,atlasFor,water,nbWater) + drawMovingCasters(state,posed,atlasFor,battleCards) + ShadowMap.finish(sig) if V.numericShadowStamps~=false then shadowFrame.commit() end Perf.add("VoxelScene.castShadows", tSun) @@ -2629,7 +2684,9 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) local g = VoxelScene.glintStep(glint, cx, cy) Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp + local atlasMemo = V.renderBudget~=false and {} local function atlasFor(map) + if atlasMemo and atlasMemo[map]~=nil then return atlasMemo[map] or nil end -- Lua evaluates texture arguments even when draw(nil, texture) returns -- immediately. Newly exposed neighbours have no published terrain yet; -- preparing their atlas here bypasses the queued build's time slicing. @@ -2638,7 +2695,9 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) if Gen3.mapIsGen3(map) and not (ChunkMesher.peek(map,false) or ChunkMesher.peek(map,true)) then return nil end - return TerrainAtlas.forMap(map, modeColors(paletteFor, map)) + local image=TerrainAtlas.forMap(map, modeColors(paletteFor, map)) + if atlasMemo then atlasMemo[map]=image or false end + return image end -- sprite palettes only exist in the SGB modes; under RED++ the OBP bake @@ -2725,8 +2784,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 +2795,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)) + neighborMatrix(nb)) 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 +2888,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 } @@ -2831,7 +2896,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) for i, nb in ipairs(state.neighbors or {}) do if nbWater and nbWater[i] then waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy) } + neighborMatrix(nb) } end end if #waterDraws > 0 then @@ -2859,6 +2924,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 +2987,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 @@ -2937,7 +3006,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull) for _, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy), pull) + neighborMatrix(nb), pull) end -- flower billboards: pulled like the characters and the grass, MINUS -- the depth of 8 world pixels along the view (8 sin a -- the camera @@ -2956,8 +3025,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) fpull, ShadowMap.snug(nil)) for _, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy), fpull, - ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + neighborMatrix(nb), fpull, + ShadowMap.snug(neighborMatrix(nb))) end -- The VR pokedex in the player's left hand, last of all: a prop over diff --git a/mods/DRAMATIC_SHAPE/lib/Water.lua b/mods/DRAMATIC_SHAPE/lib/Water.lua index 5a20297f1..0112b37bb 100644 --- a/mods/DRAMATIC_SHAPE/lib/Water.lua +++ b/mods/DRAMATIC_SHAPE/lib/Water.lua @@ -132,6 +132,12 @@ local ShadowMap = V.require("ShadowMap") local Mat4 = V.require("Mat4") local Water = {} +local Budget = V.require("WaterBudget") +local ShaderState = V.require("ShaderState") +local function sendUniform(shader,name,...) + if Budget.enabled()then return ShaderState.send(shader,name,...)end + return pcall(shader.send,shader,name,...) +end -- ------- the row -- @@ -144,8 +150,8 @@ Water.KEY = "water" Water.LABEL = "WATER" Water.setting = ModSetting.new(Water.KEY, Water.LABEL, - { "full", "sky", "off" }, - { "FULL", "SKY", "OFF" }) + { "auto", "full", "sky", "off" }, + { "AUTO", "FULL", "SKY", "OFF" }) -- The PERFORMANCE tier's ceilings (see lib/Tier.lua), resolved lazily for -- the same reason ShadowMap's are. @@ -164,7 +170,7 @@ end function Water.level() local v = Water.setting:get() - local lv = 2 + local lv = v == "auto" and Budget.defaultLevel() or 2 if v == "off" then lv = 0 elseif v == "sky" then lv = 1 end -- A CEILING, NOT A SETTING. The row's own three rungs are the player's @@ -773,6 +779,8 @@ uniform float waveHeight; // the tallest column, in whole world pixels uniform float waveSlope; // how far a column's neighbours tilt its normal uniform float waveSlopeLean; // and how far the horizon lean may open that up uniform float waveT; +uniform Image waveField; +uniform float waveFieldOn; uniform vec4 faceShade; // the mesh's own direction shading: E, W, S, N uniform vec2 atlasSize; // the tileset atlas, in texels uniform float fresnelFloor; @@ -1063,7 +1071,8 @@ vec4 march(vec3 origin, vec3 dir) { // and the reasoning behind their weights live; pasted in rather than sent, // so the speed derived from those same numbers cannot drift from the field // they describe. -float waveRaw(vec2 q) { +float waveRaw(LOVE_HIGHP_OR_MEDIUMP vec2 q) { + if(waveFieldOn>0.5)return Texel(waveField,(q+vec2(0.5))/256.0).r; float h = 0.0; //@TRAINS return h * 0.5 + 0.5; @@ -1475,16 +1484,61 @@ end Water._trainSource = trainSource -- named for the suite +local bakeSource +function Water._waveBakeSource() + if bakeSource then return bakeSource end + local function periodic(value)return math.floor(value*256/(2*math.pi)+0.5)*(2*math.pi)/256 end + local src=trainSource() + src=src:gsub("vec2%(([%d%.%-]+), ([%d%.%-]+)%)",function(x,z) + return ("vec2(%.9f, %.9f)"):format(periodic(tonumber(x)),periodic(tonumber(z))) + end) + bakeSource="float waveRaw(vec2 q){float h=0.0;\n"..src.."\nreturn h*0.5+0.5;}" + return bakeSource +end + local function source(grid) local src = SHADER_SRC:gsub("//@CRATERS", (craterSource():gsub("%%", "%%%%"))) src = src:gsub("//@TRAINS", (trainSource():gsub("%%", "%%%%"))) + if Budget.enabled() then + src=src:gsub("precision highp float;","precision mediump float;") + src=src:gsub("uniform mat4", "uniform LOVE_HIGHP_OR_MEDIUMP mat4") + src=src:gsub("uniform vec3 curve", "uniform LOVE_HIGHP_OR_MEDIUMP vec3 curve") + src=src:gsub("uniform vec3 eye", "uniform LOVE_HIGHP_OR_MEDIUMP vec3 eye") + src=src:gsub("varying vec3 vSun", "varying LOVE_HIGHP_OR_MEDIUMP vec3 vSun") + -- These routines carry world coordinates, projected depth and ray crossings. + -- Color/sky lighting and normalized texture values keep the stage default. + for _,name in ipairs({"bendDrop","project","march","relief","waveAt","waveNormal"})do + local from=src:find("[%w_]+%s+"..name.."%(") + if from then + local open=src:find("{",from,true) + local at,level=open,1 + repeat + at=at+1 + local c=src:sub(at,at) + if c=="{"then level=level+1 elseif c=="}"then level=level-1 end + until level==0 or at>#src + local fn=src:sub(from,at):gsub("(vec[234])(%s+[%w_]+)","LOVE_HIGHP_OR_MEDIUMP %1%2") + :gsub("float(%s+[%w_]+)","LOVE_HIGHP_OR_MEDIUMP float%1") + src=src:sub(1,from-1)..fn..src:sub(at+1) + end + end + for _,name in ipairs({"selfC","selfZ","sheet","view","hit","col","surf"})do + src=src:gsub(" (vec[234]) "..name.."([ ;=])"," LOVE_HIGHP_OR_MEDIUMP %1 "..name.."%2") + src=src:gsub(" float "..name.."([ ;=])"," LOVE_HIGHP_OR_MEDIUMP float "..name.."%1") + end + end + if love.getVersion and love.getVersion()>=12 then + -- LÖVE 12 declares effect's numeric parameters at high precision. + src=src:gsub("effect%(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc%)", + "effect(LOVE_HIGHP_OR_MEDIUMP vec4 color, Image tex, LOVE_HIGHP_OR_MEDIUMP vec2 tc, LOVE_HIGHP_OR_MEDIUMP vec2 sc)") + end local head = ("#define RAY_STEPS %d\n#define RAY_REFINE %d\n" .. "#define WAVE_STEPS %d\n#define WAVE_STRIDE %.1f\n" -- compiled in rather than sent, like the step bounds beside -- it: a uniform that failed to send would silently take the -- slack back to zero, which is the bug this is here to fix .. "#define RAY_BIAS %.6f\n") - :format(Water.RAY_STEPS, Water.RAY_REFINE, Water.WAVE_STEPS, + :format(Budget.steps(Water.RAY_STEPS,Water.RAY_REFINE), select(2,Budget.steps(Water.RAY_STEPS,Water.RAY_REFINE)), Water.WAVE_STEPS, Water.WAVE_STRIDE, Water.RAY_BIAS) if grid then head = head .. "#define VOXEL_GRID 1\n" end return head .. src @@ -1499,10 +1553,12 @@ Water._source = source -- named for the suite local shaders = { [false] = nil, [true] = nil } function Water.shader(grid) + local steps,refine=Budget.steps(Water.RAY_STEPS,Water.RAY_REFINE) + local key=tostring(grid and true or false)..":"..steps..":"..refine..":"..tostring(Budget.enabled()) grid = grid and true or false - if shaders[grid] == nil then + if shaders[key] == nil then if not (love.graphics and love.graphics.newShader) then - shaders[grid] = false + shaders[key] = false else local ok, sh = pcall(love.graphics.newShader, V.require("GraphicsCompat").shader(source(grid))) if not ok and V and V.mod and V.mod.log then @@ -1511,10 +1567,10 @@ function Water.shader(grid) V.mod.log:warn("water shader did not compile: %s -- lakes draw flat", tostring(sh)) end - shaders[grid] = (ok and sh) or false + shaders[key] = (ok and sh) or false end end - return shaders[grid] or nil + return shaders[key] or nil end -- ------- the pass @@ -1566,7 +1622,7 @@ function Water.begin(ctx) love.graphics.setShader(sh) love.graphics.setColor(1, 1, 1, 1) local function send(name, ...) - pcall(sh.send, sh, name, ...) + sendUniform(sh, name, ...) end send("vp", "row", ctx.vp) @@ -1594,7 +1650,7 @@ function Water.begin(ctx) send("sunTexel", { texel, texel }) send("dayTint", Voxel3D.tint or { 1, 1, 1 }) - send("rays", level >= 2 and 1 or 0) + send("rays", level >= 2 and not (Budget.enabled() and ctx.planar) and 1 or 0) -- THE PLANAR MIRROR, or the switch that says there is not one. -- -- planarTex is bound EITHER WAY. A declared sampler left unbound is a @@ -1615,7 +1671,11 @@ function Water.begin(ctx) send("waveHeight", Water.WAVE_HEIGHT) send("waveSlope", Water.WAVE_SLOPE) send("waveSlopeLean", Water.WAVE_SLOPE_LEAN) - send("waveT", waveTime()) + local wt=waveTime() + local field=Budget.waveTexture(wt, Water._waveBakeSource()) + send("waveField",field or ctx.reflect) + send("waveFieldOn",field and 1 or 0) + send("waveT", wt) -- the columns' side faces wear the MESH's own direction shading, sent in -- rather than restated, so a wave crest is lit like every other voxel local fs = Voxel3D.FACE_SHADE @@ -1646,7 +1706,7 @@ end -- back at itself, which is what a pond in a cave does. function Water.sendSky(sh, ctx) local function send(name, ...) - pcall(sh.send, sh, name, ...) + sendUniform(sh, name, ...) end local ramp, count = Sky.ramp() local edge = ctx.skyEdge @@ -1711,7 +1771,7 @@ function Water.draw(mesh, texture, model) if not (active and mesh) then return end local parts = type(mesh) == "table" and mesh.parts if texture and not parts then mesh:setTexture(texture) end - pcall(active.send, active, "model", "row", model or Mat4.identity()) + sendUniform(active, "model", "row", model or Mat4.identity()) -- Per draw rather than per pass, and read off the TEXTURE rather than -- assumed: it is what converts a world pixel of the columns' parallax into -- the texel of art standing on it, and two maps in one frame can be drawn @@ -1719,7 +1779,7 @@ function Water.draw(mesh, texture, model) if texture and texture.getDimensions then local ok, w, h = pcall(texture.getDimensions, texture) if ok and w and h then - pcall(active.send, active, "atlasSize", { w, h }) + sendUniform(active, "atlasSize", { w, h }) end end if parts then V.require("MeshBatch").draw(mesh, texture, nil, model) @@ -1733,6 +1793,8 @@ end -- Drop the compiled shaders (window resize, hot reload): they are GPU -- objects on a context that may not exist any more. function Water.invalidate() + bakeSource=nil + Budget.invalidate() for k, sh in pairs(shaders) do if sh and sh.release then pcall(sh.release, sh) end shaders[k] = nil diff --git a/mods/DRAMATIC_SHAPE/lib/WaterBudget.lua b/mods/DRAMATIC_SHAPE/lib/WaterBudget.lua new file mode 100644 index 000000000..7a6f771be --- /dev/null +++ b/mods/DRAMATIC_SHAPE/lib/WaterBudget.lua @@ -0,0 +1,165 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local V = ... +local Card = {} +local Scale = V.require("RenderScale") +local Mat = V.require("Mat4") +local State = V.require("ShaderState") +local waves, waveShader, lastTime, lastSource +local layer, layerW, layerH, composite, drawing +local COMPOSITE = [[ +#ifdef VERTEX +uniform mat4 vp; +uniform mat4 model; +uniform vec3 curve; +vec4 position(mat4 tp,vec4 p){ + vec4 w=model*p;vec2 d=w.xz-curve.xy; + if(curve.z>0.0)w.y-=dot(d,d)*curve.z; + return vp*w; +} +#endif +#ifdef PIXEL +uniform Image layer; +vec4 effect(vec4 color,Image atlas,vec2 uv,vec2 sc){ + vec4 c=Texel(layer,sc/love_ScreenSize.xy); + if(c.a<0.5)discard; + return c; +} +#endif +]] +function Card.enabled() + return V.renderBudget ~= false +end +function Card.defaultLevel() + V.require("Tier").caps() + return (not Card.enabled() or (not Scale.mobile() and V.require("Tier").name == "high")) and 2 or 1 +end +function Card.steps(steps, refine) + local tier = V.require("Tier").caps() + return Card.enabled() and (tier.waterRaySteps or 12) or steps or 24, + Card.enabled() and (tier.waterRayRefine or 3) or refine or 5 +end +function Card.waveTexture(time, source) + if not Card.enabled() or waves == false then + return nil + end + if not waves then + local ok, made = pcall(love.graphics.newCanvas, 256, 256, { format = "r8", dpiscale = 1, readable = true }) + if not ok then + waves = false + return nil + end + waves = made + waves:setFilter("linear", "linear") + waves:setWrap("repeat", "repeat") + end + if source ~= lastSource then + local ok, made = pcall(love.graphics.newShader, "uniform float waveT;\n" .. source .. [[ +vec4 effect(vec4 c,Image t,vec2 uv,vec2 sc){return vec4(waveRaw(uv*256.0),0.0,0.0,1.0);} +]]) + if not ok then + waves:release() + waves = false + return nil + end + if waveShader then + waveShader:release() + end + waveShader, lastSource, lastTime = made, source, nil + end + if lastTime ~= time then + love.graphics.push("all") + love.graphics.setCanvas(waves) + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setDepthMode() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setShader(waveShader) + waveShader:send("waveT", time) + love.graphics.rectangle("fill", 0, 0, 256, 256) + love.graphics.pop() + lastTime = time + end + return waves +end +function Card.begin(w, h) + if not Card.enabled() or layer == false then + return false + end + local ww, hh = math.max(1, math.ceil(w / 2)), math.max(1, math.ceil(h / 2)) + if not composite then + local ok, made = pcall(love.graphics.newShader, V.require("GraphicsCompat").shader(COMPOSITE)) + if not ok then + layer = false + return false + end + composite = made + end + if not layer or layerW ~= ww or layerH ~= hh then + local ok, made = V.require("PixelCanvas").new(ww, hh) + if not ok then + if layer then + layer:release() + end + layer = false + return false + end + if layer then + layer:release() + end + layer, layerW, layerH = made, ww, hh + layer:setFilter("nearest", "nearest") + end + love.graphics.push("all") + local ok = pcall(love.graphics.setCanvas, { layer, depth = true }) + if not ok then + love.graphics.pop() + layer:release() + layer = false + return false + end + love.graphics.setScissor() + love.graphics.setDepthMode("lequal", true) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.clear(0, 0, 0, 0, true, true) + drawing = true + return true, ww, hh +end +function Card.finish() + if drawing then + love.graphics.pop() + drawing = false + end +end +function Card.composite(draws, vp, curve) + if not (layer and composite) then + return + end + love.graphics.push("all") + love.graphics.setShader(composite) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setDepthMode("lequal", true) + State.send(composite, "vp", "row", vp) + State.send(composite, "curve", curve) + State.send(composite, "layer", layer) + for _, d in ipairs(draws) do + State.send(composite, "model", "row", d[3] or Mat.identity()) + local mesh = d[1] + if type(mesh) == "table" and mesh.parts then + V.require("MeshBatch").draw(mesh, d[2]) + else + love.graphics.draw(mesh) + end + end + love.graphics.pop() +end +function Card.invalidate() + Card.finish() + for _, object in ipairs({ waves or false, waveShader or false, layer or false, composite or false }) do + if object and object.release then + object:release() + end + end + waves, waveShader, lastTime, lastSource, layer, composite = nil, nil, nil, nil, nil, nil +end +return Card diff --git a/mods/DRAMATIC_SHAPE/lib/WorldShadows.lua b/mods/DRAMATIC_SHAPE/lib/WorldShadows.lua new file mode 100644 index 000000000..a2307c3f5 --- /dev/null +++ b/mods/DRAMATIC_SHAPE/lib/WorldShadows.lua @@ -0,0 +1,117 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Existing mod copyright and MIT notices remain in ../LICENSE. Separate static/dynamic shadow planning; no engine records. +local V = ... +local Stamp = V.require("RenderStamp") +local Shadow = V.require("ShadowMap") +local Mesher = V.require("ChunkMesher") +local Tier = V.require("Tier") +local Card = { GRID = 256 } +local static, dynamic = Stamp.new(), Stamp.new() +local plan = { bounds = {} } +local staleFrames, lastDynamic = 0, -1000 +function Card.bounds(x0, x1, z0, z1, out) + out = out or {} + out[1] = math.floor(x0 / Card.GRID) * Card.GRID + out[2] = math.ceil(x1 / Card.GRID) * Card.GRID + out[3] = math.floor(z0 / Card.GRID) * Card.GRID + out[4] = math.ceil(z1 / Card.GRID) * Card.GRID + return out +end +function Card.prepare( + state, + terrain, + nbMesh, + water, + nbWater, + posed, + cx, + cy, + vw, + vh, + battleToken, + animation, + firstPerson, + atlasFor, + poseFrame +) + local x0, x1, z0, z1 = Shadow.groundBounds(cx, cy, vw, vh) + Card.bounds(x0, x1, z0, z1, plan.bounds) + static.begin() + local put = static.put + for i = 1, 4 do + put(plan.bounds[i]) + end + put(Shadow.KX) + put(Shadow.KZ) + put(state.map) + put(terrain) + put(water) + if atlasFor then + put(atlasFor(state.map)) + end + put(Mesher.flowers(state.map)) + for i, nb in ipairs(state.neighbors or {}) do + put(nb.map) + put(nb.ox) + put(nb.oy) + put(nbMesh[i]) + put(nbWater and nbWater[i]) + put(Mesher.flowers(nb.map)) + if atlasFor then + put(atlasFor(nb.map)) + end + end + for i = 1, #animation do + put(animation[i]) + end + put(Tier.caps().shadowRes) + plan.staticToken = static.token() + plan.static = not Shadow.hasFrame() or not Shadow.isAnchored() or Shadow.stale(plan.staticToken) + dynamic.begin() + put = dynamic.put + put(plan.staticToken) + put(firstPerson) + put(battleToken) + -- Authored figures can arrive independently of the terrain mesh. + put(Mesher.figures(state.map)) + for _, nb in ipairs(state.neighbors or {}) do + put(Mesher.figures(nb.map)) + end + for _, p in ipairs(posed) do + put(p.sprite.def) + put(p.sprite.def.image) + put(p.px) + put(p.py) + put(p.gh) + put(p.lift or 0) + put(p.facing) + put(p.phase) + put(p.flip) + put(p.frame) + put(p.sprite:resolveImage()) + if poseFrame then + local frame, mirror = poseFrame(p) + put(frame) + put(mirror) + end + end + plan.dynamicToken = dynamic.token() + plan.dynamic = plan.static or not Shadow.dynamicActive() or plan.dynamicToken ~= plan.lastDynamicToken + if plan.dynamic then + staleFrames = staleFrames + 1 + local hz = Tier.caps().shadowHz + if not plan.static and hz and hz > 1 and staleFrames - lastDynamic < hz then + plan.dynamic = false + end + end + return plan +end +function Card.commitStatic() + static.commit() +end +function Card.commitDynamic() + dynamic.commit() + plan.lastDynamicToken = plan.dynamicToken + lastDynamic = staleFrames +end +return Card diff --git a/mods/DRAMATIC_SHAPE/main.lua b/mods/DRAMATIC_SHAPE/main.lua index c9c7b9530..b8af9ca80 100644 --- a/mods/DRAMATIC_SHAPE/main.lua +++ b/mods/DRAMATIC_SHAPE/main.lua @@ -45,6 +45,9 @@ local mod = ... -- passed in as its vararg (`local V = ...`). local V = { mod = mod, path = mod.path } +-- P7: independently reversible world-anchored static and 256px actor shadows. +V.shadowWorldAnchor = true +V.renderBudget = true -- Repeated Gen 3 hulls share immutable GPU meshes where the driver can draw -- their offsets per instance. Other drivers keep the expanded vertex path. do @@ -263,6 +266,7 @@ local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") local AntiAlias = V.require("AntiAlias") +local RenderScale = V.require("RenderScale") local FirstPerson = V.require("FirstPerson") local FreeMove = V.require("FreeMove") local CamControl = V.require("CamControl") @@ -468,7 +472,8 @@ mod.content.render_pipelines:register("voxel", { -- these two lines knows: every pass in the frame measures itself in the -- canvas it was handed, so the sky's dither, the water's march and the -- camera itself all come out the same picture at a higher sample rate. - local rw, rh = AntiAlias.expand(sw, sh) + local bw, bh = RenderScale.size(sw, sh) + local rw, rh = AntiAlias.expand(bw, bh) local drawStarted=love.timer.getTime() local canvas = VoxelScene.render(ctx.state, rw, rh, ctx.vw, ctx.vh, ctx.paletteFor) @@ -489,18 +494,19 @@ mod.content.render_pipelines:register("voxel", { ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, Voxel3D.groundY or 0, wy) end, - ctx.scale * AntiAlias.factor()) + ctx.scale * AntiAlias.factor() * bw / sw) -- the horde's readout rides the same overlay, over the FX: health, -- ammunition, the crosshair and the banners, sized in the same -- supersampled canvas pixels everything else here is drawn in. A -- headset never reaches this line (drawWorld returns the mirror -- above) -- lib/VR draws the same HUD onto each eye instead. - HordeHud.drawFlat(rw, rh, ctx.scale * AntiAlias.factor()) + HordeHud.drawFlat(rw, rh, ctx.scale * AntiAlias.factor() * bw / sw) Voxel3D.endOverlay() end -- and back to the window's own size, which is what the engine composites -- one canvas pixel to one display pixel. A pass-through when AA is off. - local out = AntiAlias.resolve(canvas, sw, sh, "world") + local out = AntiAlias.resolve(canvas, bw, bh, "world") + out = RenderScale.present(out, sw, sh) -- THE FRAME SEAM. Perf.frame stamps one whole-frame time per RENDERED -- frame and this is the only place in the mod that is reached exactly -- once per rendered world frame -- Pipelines calls drawWorld from @@ -518,6 +524,7 @@ mod.content.render_pipelines:register("voxel", { Voxel3D.invalidate() OverworldBattle.invalidate() AntiAlias.invalidate() + RenderScale.invalidate() ChunkMesher.invalidate() -- no map id = every cached mesh VR.invalidate() -- the mirror, and FBO ids of dead canvases end, @@ -754,6 +761,10 @@ local SETTINGS = { -- machine it happens to be running on, so it neither sets this nor takes -- the row away -- the player decides what their hardware can carry, from -- inside FULL like anywhere else. + { RenderScale.setting, + "Render the world at fewer pixels, then enlarge with nearest filtering. " + .. "AUTO uses 2X on mobile and Linux ARM; desktop uses 1X. UI stays at its own resolution.", + full = true }, { AntiAlias.setting, "Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge " .. "lips, a tree against the sky -- by rendering the diorama larger than " 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..9e3e32369 100644 --- a/src/audio/NitroAudio.lua +++ b/src/audio/NitroAudio.lua @@ -1,7 +1,10 @@ -- 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 Transport=require('src.audio.NitroTransport') +local Audio={};local Player={} +Player.__index=Player local archives,players={},setmetatable({}, {__mode="k"}) local RATE,BLOCK=32768,512 local function u8(s,o) return s:byte(o+1) end @@ -27,15 +30,22 @@ function Audio.archive(data) end return arc end -function Audio.new(data,def) - local arc=Audio.archive(data) +local function engineFor(arc,def) local seq=assert(Sdat.sequence(arc,assert(def.nds)),'missing SDAT sequence') local at,size=Sdat.fileAt(arc,seq.file);local bytes=arc.data:sub(at+1,at+size) assert(bytes:sub(1,4)=='SSEQ','not a native SSEQ') local start=assert(u32(bytes,24),'truncated sequence') - local self=setmetatable({arc=arc,seq=seq,bytes=bytes,start=start,tracks={[0]=track(start)}, + return setmetatable({arc=arc,seq=seq,bytes=bytes,start=start,tracks={[0]=track(start)}, voices={},samples={},tempo=120,tickPhase=0,master=(seq.volume or 127)/127, - queue=love.audio.newQueueableSource(RATE,16,2,4),looping=true,running=false},Player) + looping=true,running=false},Player) +end +local hydrate +function Audio.new(data,def) + local self=engineFor(Audio.archive(data),def) + local threaded=Transport.attach(self,{nds=def.nds},function(...)return hydrate(...)end,function(state) + state.tracks={[0]=track(state.start)};state.voices={};state.tickPhase=1;state.variables={};state.ended=false + end) + self.queue=love.audio.newQueueableSource(RATE,16,2,threaded and 16 or 4) players[self]=true return self end @@ -45,12 +55,14 @@ end -- NNS_SndPlayerReadVariable), so nothing outside the sequences writes them. local globalVariables={} function Player:variable(index) + local value,handled=Transport.variable(self,index);if handled then return value end if index>=32 then return 0 end local v if index<16 then v=self.variables and self.variables[index] else v=globalVariables[index-16] end if v==nil then return -1 end;return v end function Player:setVariable(index,v) + if Transport.setVariable(self,index,v)then return end v=math.floor(v)%65536;if v>=32768 then v=v-65536 end if index<16 then self.variables=self.variables or {};self.variables[index]=v elseif index<32 then globalVariables[index-16]=v end @@ -62,15 +74,31 @@ function Player:getPitch() return self.pitch or 1 end function Player:getVolume() return self.queue:getVolume() end function Player:setFilter(...) return self.queue:setFilter(...) end function Player:setLooping(v) self.looping=v end -function Player:isPlaying() return self.running and (not self.ended or #self.voices>0 or self.queue:isPlaying()) end -function Player:stop() self.running=false;self.queue:stop();self.voices={} end +function Player:isPlaying() local value=Transport.isPlaying(self);if value~=nil then return value end;return self.running and (not self.ended or #self.voices>0 or self.queue:isPlaying()) end +function Player:stop() if Transport.stop(self)then return end;self.running=false;self.queue:stop();self.voices={} end function Player:pause() self.paused=true;self.queue:pause() end function Player:play() + if Transport.play(self)then return end if self.paused then self.paused=false;self.queue:play();return end self.queue:stop();self.tracks={[0]=track(self.start)};self.voices={};self.tickPhase=1;self.variables={} self.ended=false;self.running=true;self:update();self.queue:play() end local function signed(v) return v>=128 and v-256 or v end +-- Worker-only PCM storage. The public legacy player still exposes Lua arrays. +local packed=setmetatable({},{__mode='k'}) +local workerEngines=setmetatable({},{__mode='k'}) +local ffiOK,ffi=pcall(require,'ffi') +local function compactSample(sample,key) + if not ffiOK or packed[sample]then return end + local kind=tonumber(key:match('^(%d+):')) + local scale=kind==1 and 1/32768 or kind==2 and .5 or .3 + local pcm=sample.pcm + local bytes=love.data.newByteData(#pcm*2) + local pointer=ffi.cast('int16_t *',bytes:getFFIPointer()) + for n=1,#pcm do pointer[n-1]=pcm[n]/scale end + packed[sample]={bytes=bytes,pointer=pointer,count=#pcm,scale=scale} + sample.pcm=pointer +end function Player:note(t,note,velocity,duration) note=note+t.transpose local ins=Sdat.instrument(self.arc,self.seq.bank,t.program,note) @@ -93,6 +121,7 @@ function Player:note(t,note,velocity,duration) end end if not sample then return end + if workerEngines[self]then compactSample(sample,key)end if #self.voices>=32 then table.remove(self.voices,1) end local root=ins.kind==1 and ins.root or 69 local pan=math.max(0,math.min(127,t.pan+ins.pan-64))/127 @@ -194,6 +223,8 @@ 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() + Transport.detach(self,'explicit render call') + 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,9 +246,11 @@ 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() + if Transport.update(self)then return end if not self.running or self.paused then return end if self.ended and #self.voices==0 then if not self.queue:isPlaying() then self.running=false end @@ -226,6 +259,97 @@ function Player:update() while self.queue:getFreeBufferCount()>0 do self.queue:queue(self:render());if self.ended and #self.voices==0 then break end end if not self.queue:isPlaying() then self.queue:play() end end +function Audio._workerNew(arc,def) + local self=engineFor(arc,def) + workerEngines[self]=true + self.tickPhase=1;self.variables={};self.running=true + return self +end +local function workerSnapshot(self,sent) + local state={tracks=self.tracks,voices={},variables=self.variables,tempo=self.tempo, + tickPhase=self.tickPhase,master=self.master,ended=self.ended==true} + local samples,keys={},{} + for key,s in pairs(self.samples)do + keys[s]=key + if not sent[key]then + local p=assert(packed[s],'unpacked worker sample') + samples[key]={bytes=p.bytes,count=p.count,scale=p.scale,loop=s.loop,loopAt=s.loopAt,rate=s.rate} + sent[key]=true + end + end + for n,v in ipairs(self.voices)do + local row={sampleKey=assert(keys[v.sample])} + for key,value in pairs(v)do if key~='sample'then row[key]=value end end + state.voices[n]=row + end + return state,samples +end +hydrate=function(self,state,samples) + local decoded={} + for key,s in pairs(samples)do + local pointer=ffi.cast('int16_t *',s.bytes:getFFIPointer()) + local pcm={};for n=1,s.count do pcm[n]=pointer[n-1]*s.scale end + decoded[key]={pcm=pcm,loop=s.loop,loopAt=s.loopAt,rate=s.rate} + end + local voices={} + for n,v in ipairs(state.voices or {})do + if v.sampleKey then + local row={sample=assert(decoded[v.sampleKey])} + for key,value in pairs(v)do if key~='sampleKey'then row[key]=value end end + voices[n]=row + else voices[n]=v end + end + for key,value in pairs(state)do rawset(self,key,value)end + rawset(self,'voices',voices) + if next(samples)then rawset(self,'samples',decoded)end +end +-- Mix a constant-event span by voice. Phase increments retain the original +-- addition order; tick() runs before the exact same first sample of a tick. +function Audio._workerRender(self,count,sent) + assert(ffiOK,'FFI unavailable') + local buffers=workerEngines[self] + if buffers==true then + buffers={left=ffi.new('double[?]',count+1),right=ffi.new('double[?]',count+1)} + workerEngines[self]=buffers + end + local left,right=buffers.left,buffers.right + ffi.fill(left,(count+1)*8);ffi.fill(right,(count+1)*8) + local at,actual=0,count + while at=1 do self.tickPhase=self.tickPhase-1;self:tick()end + local last=at+1 + local step=self.tempo*48/60/RATE + while last=p.count then + if s.loop and p.count>s.loopAt then v.pos=s.loopAt+(v.pos-s.loopAt)%(p.count-s.loopAt) + else table.remove(self.voices,n);break end + end + local gain=v.gain;local remove=false + if v.release>0 then gain=gain*v.release/(RATE*.04);v.release=v.release-1;remove=v.release==0 end + local index=math.floor(v.pos) + local value=(index>=0 and index= 176 and op <= 189 and op ~= 183 then + assert(op ~= 182, "sequence uses the main RNG") + assert(read() < 16, "sequence uses shared variables") + parameter(2) + elseif op >= 192 and op <= 223 then + parameter(1) + elseif op == 224 or op == 225 or op == 227 then + parameter(2) + elseif op == 254 then + read() + read() + elseif op == 253 or op == 255 then + if not conditional then + return + end + elseif op ~= 252 then + error(("unsupported SSEQ command %02X"):format(op)) + end + end + error("sequence walk budget exceeded") + end + local ok, why = pcall(function() + while #pending > 0 do + walk(table.remove(pending)) + end + end) + return ok, not ok and tostring(why) or nil +end +return M diff --git a/src/audio/NitroTransport.lua b/src/audio/NitroTransport.lua new file mode 100644 index 000000000..669729743 --- /dev/null +++ b/src/audio/NitroTransport.lua @@ -0,0 +1,287 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Main-state queue transport. Ordinary sequencer-field access retires this +-- transport and materializes the last queued snapshot for the legacy player. +local Performance = require("src.core.Performance") +local Sequence = require("src.audio.NitroSequence") +local Logger = require("src.core.Logger") +local M = {} +local entries = setmetatable({}, { __mode = "k" }) +local archiveIds = setmetatable({}, { __mode = "k" }) +local serial, archiveSerial = 0, 0 +local proxies = setmetatable({}, { __mode = "k" }) +local protected = { + arc = true, + seq = true, + bytes = true, + start = true, + tracks = true, + voices = true, + samples = true, + tempo = true, + tickPhase = true, + master = true, + variables = true, + ended = true, +} +local logged = {} +local ffiOK = pcall(require, "ffi") +local function log(reason) + if not logged[reason] then + logged[reason] = true + Logger.info("DS audio: synchronous fallback (%s)", reason) + end +end +local function chip() + return require("src.core.ChipAudio") +end +local function send(e, action, values) + values = values or {} + values.action, values.id, values.gen = action, e.id, e.gen + return chip()._nitroCommand(values) +end +function M.attach(player, def, hydrate, reset) + if Performance.audioWorkerOptimization == false then + return + end + if not ffiOK then + log("FFI unavailable") + return + end + if not (love.thread and love.thread.newThread) then + log("no love.thread") + return + end + local safe, why = Sequence.workerSafe(player.bytes, player.start) + if not safe then + log(why) + return + end + serial = serial + 1 + local meta = getmetatable(player) + local proxy = proxies[meta] + if not proxy then + proxy = { + __index = function(self, key) + return meta[key] or M.read(self, key) + end, + __newindex = M.write, + } + proxies[meta] = proxy + end + local state = {} + for key in pairs(protected) do + state[key] = rawget(player, key) + rawset(player, key, nil) + end + entries[player] = + { id = serial, gen = 0, state = state, def = def, hydrate = hydrate, reset = reset, samples = {}, meta = meta } + setmetatable(player, proxy) + return true +end +function M.detach(player, reason) + local e = entries[player] + if not e then + return + end + if e.active then + send(e, "stop") + end + if e.channel then + e.channel:clear() + end + entries[player] = nil + setmetatable(player, e.meta) + e.hydrate(player, e.state, e.samples) + if reason then + log(reason) + end +end +function M.read(player, key) + if protected[key] and entries[player] then + M.detach(player, "direct sequencer state access: " .. key) + return rawget(player, key) + end +end +function M.write(player, key, value) + local e = entries[player] + if e and (protected[key] or type(e.meta[key]) == "function") then + M.detach(player, "direct sequencer state/method write: " .. key) + end + rawset(player, key, value) +end +function M.play(player) + local e = entries[player] + if not e then + return false + end + if Performance.audioWorkerOptimization == false then + M.detach(player, "optimization disabled") + return false + end + if player.paused and e.active then + player.paused = false + player.queue:play() + return true + end + player.queue:stop() + e.gen = e.gen + 1 + e.done = false + e.reset(e.state) + e.pendingVariables = {} + e.rev = 0 + local arc = e.state.arc + local thread = chip()._nitroReady() + if not thread then + M.detach(player, "worker unavailable") + return false + end + local a = archiveIds[arc] + if not a or a.bytes ~= arc.data or a.thread ~= thread then + archiveSerial = archiveSerial + 1 + a = { id = archiveSerial, bytes = arc.data, thread = thread } + if not chip()._nitroCommand({ action = "archive", archive = a.id, bytes = a.bytes }) then + M.detach(player, "worker unavailable") + return false + end + archiveIds[arc] = a + end + e.channel = love.thread.getChannel("chipaudio_nds_" .. e.id) + e.channel:clear() + if + not send( + e, + "start", + { + archive = a.id, + def = e.def, + pitch = player.pitch, + looping = player.looping, + tempo = e.state.tempo, + master = e.state.master, + audioWorkerOptimization = true, + } + ) + then + M.detach(player, "worker unavailable") + return false + end + e.pitch, e.looping = player.pitch, player.looping + e.touch = 0 + e.active = true + player.running, player.paused = true, false + return true +end +function M.stop(player) + local e = entries[player] + if not e then + return false + end + if e.active then + send(e, "stop") + end + e.active = false + e.done = false + e.state.voices = {} + if e.channel then + e.channel:clear() + end + player.running = false + player.queue:stop() + return true +end +function M.isPlaying(player) + local e = entries[player] + if not e then + return nil + end + return player.running and (not e.done or player.queue:isPlaying()) or false +end +function M.variable(player, index) + local e = entries[player] + if not e or index >= 16 then + return nil, false + end + local value = e.state.variables and e.state.variables[index] + return value == nil and -1 or value, true +end +function M.setVariable(player, index, value) + local e = entries[player] + if not e then + return false + end + if index >= 16 then + M.detach(player, "shared variable write") + return false + end + value = math.floor(value) % 65536 + if value >= 32768 then + value = value - 65536 + end + e.state.variables = e.state.variables or {} + e.state.variables[index] = value + if e.active then + e.rev = e.rev + 1 + e.pendingVariables[index] = { value = value, rev = e.rev } + send(e, "variable", { index = index, value = value, rev = e.rev }) + end + return true +end +function M.update(player) + local e = entries[player] + if not e then + return false + end + if Performance.audioWorkerOptimization == false then + M.detach(player, "optimization disabled") + return false + end + if not player.running or player.paused or not e.active then + return true + end + local now = love.timer.getTime() + if e.pitch ~= player.pitch or e.looping ~= player.looping or now - e.touch >= 1 then + if not send(e, "control", { pitch = player.pitch, looping = player.looping }) then + M.detach(player, "worker unavailable") + return false + end + e.pitch, e.looping, e.touch = player.pitch, player.looping, now + end + while player.queue:getFreeBufferCount() > 0 do + local row = e.channel:pop() + if not row then + break + end + if row.gen == e.gen then + if row.error then + M.detach(player, "worker: " .. row.error) + return false + end + if row.sd then + player.queue:queue(row.sd) + for key, value in pairs(row.state) do + e.state[key] = value + end + -- nil variables/ended values are represented explicitly by the worker. + e.state.variables = row.state.variables + for index, write in pairs(e.pendingVariables) do + if (row.rev or 0) < write.rev then + e.state.variables[index] = write.value + else + e.pendingVariables[index] = nil + end + end + for key, value in pairs(row.samples) do + e.samples[key] = value + end + e.done = row.done + end + end + end + if e.done and not player.queue:isPlaying() and player.queue:getFreeBufferCount() == 16 then + player.running = false + elseif not player.queue:isPlaying() and player.queue:getFreeBufferCount() < 16 then + player.queue:play() + end + return true +end +return M diff --git a/src/audio/NitroWorker.lua b/src/audio/NitroWorker.lua new file mode 100644 index 000000000..6bee65b48 --- /dev/null +++ b/src/audio/NitroWorker.lua @@ -0,0 +1,87 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Worker-side bounded DS players on the existing ChipAudio command loop. +local Sdat = require("src.import.Gen4Sdat") +local Audio = require("src.audio.NitroAudio") +local Performance = require("src.core.Performance") +local M = {} +local archives, archiveOrder, jobs, order = {}, {}, {}, {} +local cursor = 0 +local function channel(id) + return love.thread.getChannel("chipaudio_nds_" .. id) +end +function M.command(cmd) + if cmd.action == "archive" then + if not archives[cmd.archive] then + archiveOrder[#archiveOrder + 1] = cmd.archive + end + archives[cmd.archive] = assert(Sdat.open(cmd.bytes)) + while #archiveOrder > 4 do + archives[table.remove(archiveOrder, 1)] = nil + end + return + end + local job = jobs[cmd.id] + if cmd.action == "start" then + if not job and #order >= 16 then + channel(cmd.id):push({ gen = cmd.gen, error = "DS worker player limit reached" }) + return + end + local ok, engine = pcall(Audio._workerNew, assert(archives[cmd.archive], "DS archive retired"), cmd.def) + if not ok then + channel(cmd.id):push({ gen = cmd.gen, error = tostring(engine) }) + return + end + if not job then + order[#order + 1] = cmd.id + end + engine.pitch, engine.looping = cmd.pitch, cmd.looping + engine.tempo, engine.master = cmd.tempo or engine.tempo, cmd.master or engine.master + jobs[cmd.id] = + { engine = engine, gen = cmd.gen, channel = channel(cmd.id), sent = {}, last = love.timer.getTime(), rev = 0 } + elseif job and job.gen == cmd.gen then + job.last = love.timer.getTime() + if cmd.action == "stop" then + jobs[cmd.id] = nil + for i=#order,1,-1 do if order[i]==cmd.id then table.remove(order,i)end end + job.channel:clear() + elseif cmd.action == "control" then + job.engine.pitch, job.engine.looping = cmd.pitch, cmd.looping + elseif cmd.action == "variable" then + job.engine:setVariable(cmd.index, cmd.value) + job.rev = cmd.rev or job.rev + end + elseif cmd.action == "control" then + channel(cmd.id):push({ gen = cmd.gen, error = "DS worker lease expired" }) + end +end +function M.step() + local now = love.timer.getTime() + for i = #order, 1, -1 do + local id = order[i] + local job = jobs[id] + if not job or now - job.last > 5 then + jobs[id] = nil + table.remove(order, i) + end + end + for _ = 1, #order do + cursor = cursor % #order + 1 + local job = jobs[order[cursor]] + if not job.done and job.channel:getCount() < 2 then + local priorOptimization=Performance.audioWorkerOptimization + Performance.audioWorkerOptimization = true + local ok, sd, state, samples, done = pcall(Audio._workerRender, job.engine, 8192, job.sent) + Performance.audioWorkerOptimization=priorOptimization + if ok then + job.channel:push({ gen = job.gen, sd = sd, state = state, samples = samples, done = done, rev = job.rev }) + job.done = done + else + job.channel:push({ gen = job.gen, error = tostring(sd) }) + job.done = true + end + return true + end + end + return false +end +return M 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..a44fa0309 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -19,11 +19,16 @@ 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 LevelDisplay = require("src.ui.LevelDisplay") +local MenuAuxiliary = require("src.battle.MenuAuxiliary") +local MoveNavigation = require("src.battle.MoveNavigation") local Logger = require("src.core.Logger") local MoveEffects = require("src.battle.MoveEffects") local Party = require("src.pokemon.Party") local Pokemon = require("src.pokemon.Pokemon") local Runtime = require("src.mods.Runtime") +local UIVisibility = require("src.battle.UIVisibility") +local StatusHUDVisibility = require("src.battle.StatusHUDVisibility") local Screens = require("src.ui.Screens") local Status = require("src.battle.Status") local StatusRegistry = require("src.battle.StatusRegistry") @@ -38,6 +43,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 +59,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,12 +366,12 @@ 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:moveGridNavigation() + return MoveNavigation.grid(self) +end + function BattleState:moveGridNavigate(index, count, input) - if not (self:isWideBattleLayout() or self:gen3Layout() - or self:gen4Layout()) then - return nil - end - return WideBattle.navigate(index, count, input) + return MoveNavigation.navigate(self, index, count, input) end -- The four actions, in the order this layout's grid reads them. Gen 1 goes @@ -581,6 +591,16 @@ function BattleState:uiSize() return 160, 144 end +BattleState.statusHUDVisible = StatusHUDVisibility.visible + +function BattleState:playerPartyView() + return self.playerParty or self.game.save.party or {} +end + +function BattleState:bottomUIVisible() + return UIVisibility.bottomVisible(self, true) +end + -- Battle colors itself per-pixel (species pics + HP bar tints), so the -- SGB whole-screen remap must not run over it. The wide layout still -- needs a zone list of its own: the invented 160x144 one would leave its @@ -1706,10 +1726,12 @@ function BattleState:trainerLabel_() return self.trainerLabel or (self.trainer and self.trainer.name) or "?" end -function BattleState.newTrainer(game, oppClass, partyIndex) +function BattleState.newTrainer(game, oppClass, partyIndex, opts) local self = newBattle(game) self.kind = "trainer" self.oppClass = oppClass + self.playerParty, self.playerPartyIndices = require("src.battle.PlayerPartyScope").select(game, + type(opts) == "table" and opts.playerPartyIndices or nil) -- wOtherTrainerID: which trainer inside the class. PlayBattleMusic reads -- it to tell the two RIVAL2 fights apart, so keep it on the battle. self.partyIndex = tonumber(partyIndex) or 1 @@ -1737,7 +1759,7 @@ function BattleState.newTrainer(game, oppClass, partyIndex) end self.enemyParty = buildTrainerParty(game, oppClass, partyIndex or 1, partyDef) self.enemyIndex = 1 - local playerMon = Party.firstHealthy(game.save.party) + local playerMon = Party.firstHealthy(self:playerPartyView()) if not playerMon then Logger.warn("trainer battle with no healthy party; skipping") self.dead = true @@ -1844,7 +1866,7 @@ function BattleState:sendOutSecondPair() if self:battlerAt(BattleState.POS.PLAYER_RIGHT) == nil and not self.ally then -- the next healthy one that is not already standing in the left slot local lead = self.player and self.player.mon - local second = Party.firstHealthy(game.save.party, lead) + local second = Party.firstHealthy(self:playerPartyView(), lead) if second then self:placeBattler(BattleState.POS.PLAYER_RIGHT, makeBattler(game.data, second, true, game.save)) @@ -1991,7 +2013,7 @@ BattleState.EDITOR_TRAINER_CLASS = "OPP_EDITOR_TRAINER" -- `party` is an array of slots in newTrainer's own shape; `look` is the class -- to borrow a pic, palette and AI from (the NPC's own trainerClass, when it -- named one). -function BattleState.newEditorTrainer(game, party, look, name) +function BattleState.newEditorTrainer(game, party, look, name, opts) local trainers = game.data.trainers if type(party) ~= "table" or #party == 0 then return nil, "that team is empty" end local base = look and trainers[look] or nil @@ -2009,7 +2031,7 @@ function BattleState.newEditorTrainer(game, party, look, name) def.paletteSource, def.aiMods = base.paletteSource, base.aiMods end trainers[BattleState.EDITOR_TRAINER_CLASS] = def - return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1) + return BattleState.newTrainer(game, BattleState.EDITOR_TRAINER_CLASS, 1, opts) end -- PLATINUM'S ESCORT WILD BATTLE (BATTLE_TYPE_AI_PARTNER, wild_encounters.c): @@ -4288,6 +4310,7 @@ function BattleState:touchpressed(_, px, py) return true end function BattleState:update(dt) + 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 @@ -4440,7 +4463,7 @@ function BattleState:update(dt) if self.palPark then require("src.battle.Gen4PalParkBattle").skipRow(self, dir) end end if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4466,7 +4489,7 @@ function BattleState:update(dt) end self.menuIndex = row * 2 + col + 1 if input:wasPressed("a") then - self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex]) + self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex]) end return end @@ -4494,7 +4517,7 @@ function BattleState:update(dt) -- loops the party menu until a healthy mon is picked, so B and -- fainted picks land back here and reopen it if acting.mon.hp <= 0 then - if Party.firstHealthy(self.game.save.party) then + if Party.firstHealthy(self:playerPartyView()) then self:openReplacementMenu() end return @@ -4507,6 +4530,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 +4572,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 +4601,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 +4626,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 +4649,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 @@ -5104,7 +5019,7 @@ function BattleState:openPokedudeTarget(step, switchMode) if switchMode and p.scriptTick == 48 then p.index = step.switchTo or 2 end if p.scriptTick > 96 and not p._pokedudePicked then p._pokedudePicked = true - local mon = self.game.save.party[p.index] + local mon = self:playerPartyView()[p.index] p:close() if switchMode then self:resolveSwitch(mon) @@ -5567,6 +5482,32 @@ function BattleState:enemyAction(battler) return self:vanillaEnemyAction(battler) end +local function gen2EnemyClassAction(battle) + if not GameVersion.isGen2() or not Runtime.wantsHook("battle.enemy_switch_or_item")then + return TrainerAI.classAction(battle) + end + local native,view + local choice=Runtime.call("battle.enemy_switch_or_item",function(current) + native=TrainerAI.classAction(current) + if not native then return false end + view={kind=native.special=='aiSwitch' and 'switch' or 'item', + index=native.index,item=native.item} + return view + end,battle) + if choice==view then return native end + if choice==true then return {special='modEnemyTurn'}end + if type(choice)~='table'then return nil end + if choice.kind=='switch'then + local index=tonumber(choice.index) + local mon=index and battle.enemyParty and battle.enemyParty[index] + if index and index%1==0 and index~=battle.enemyIndex and mon and (mon.hp or 0)>0 then + return {special='aiSwitch',index=index} + end + elseif choice.kind=='item' and type(choice.item)=='string' and battle.data.items[choice.item]then + return {special='aiItem',item=choice.item} + end +end + function BattleState:vanillaEnemyAction(battler) battler = battler or self.enemy if not battler then return nil end @@ -5586,7 +5527,7 @@ function BattleState:vanillaEnemyAction(battler) -- right-hand slot ask too would have a trainer drink two SUPER POTIONs on -- the same Pokemon in the same turn. if battler == self.enemy then - local classAct = TrainerAI.classAction(self) + local classAct = gen2EnemyClassAction(self) if classAct then return classAct end end return TrainerAI.chooseMove(battler, self.rng, self) @@ -7710,6 +7651,7 @@ function BattleState:executeAction(user, target, action) if not (user and user.mon) then return end if user.mon.hp <= 0 then return end if not action then return end + if action.special=='modEnemyTurn'then return end -- PLATINUM / HGSS: dragged out or switched out earlier this turn (Roar, -- U-turn, Baton Pass -- src/battle/Gen4SwitchOut.lua): its chosen action -- left the field with it @@ -8701,7 +8643,7 @@ function BattleState:benchFor(isPlayer, position) if isPlayer and self.ally and position == BattleState.POS.PLAYER_RIGHT then return self.ally.party or {} end - if isPlayer then return self.game.save.party or {} end + if isPlayer then return self:playerPartyView() or {} end if position == BattleState.POS.OPPONENT_RIGHT and self.enemyPartyB then return self.enemyPartyB end @@ -8889,7 +8831,7 @@ function BattleState:awardExp(fallen) -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only -- the surviving participants are counted and paid local participants, alive = 0, {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if self.participants and self.participants[mon] then participants = participants + 1 if mon.hp > 0 then table.insert(alive, mon) end @@ -9010,7 +8952,7 @@ function BattleState:awardExp(fallen) local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0 if not expAll then local holders = {} - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if (mon.hp or 0) > 0 and holdsExpShare(mon) then holders[#holders + 1] = mon end @@ -9040,9 +8982,9 @@ function BattleState:awardExp(fallen) -- experience.asm:9-13); each mon gets its own GainedText with the -- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no -- summary line - for _, mon in ipairs(self.game.save.party) do + for _, mon in ipairs(self:playerPartyView()) do if mon.hp > 0 then - ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll") + ctx.applyShare(mon, math.max(1, ctx.participants) * #self:playerPartyView() * 2, "expAll") end end end @@ -9087,7 +9029,13 @@ function BattleState:enemyMonFainted() local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local style = tostring((self.game.save.options or {}).battleStyle or "shift") :lower() - local partyCount = #self.game.save.party + if GameVersion.isGen2() and Runtime.wantsHook("battle.style")then + local chosen=Runtime.call("battle.style",function(battle) + return tostring((battle.game.save.options or{}).battleStyle or 'shift'):lower() + end,self) + if chosen=='set' or chosen=='shift'then style=chosen end + end + local partyCount = #self:playerPartyView() -- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the -- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays -- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into @@ -9201,20 +9149,8 @@ function BattleState:enemyMonFainted() end) return end - -- ComputeTrainerReward (0E:58D4) sets wBattleReward = the class's base - -- reward byte (TrainerClassAttributes+2) times wCurPartyLevel, the level - -- of the LAST mon the party reader loaded. That is only a QUARTER of the - -- payout though: WinTrainerBattle (0F:4F46) then runs `ld c, 4` and adds - -- wBattleReward once per iteration -- b of the four quarters through - -- .AddMoneyToMom, the remaining c through .AddMoneyToWallet -- so the - -- trainer is worth base * level * 4 in total. b comes from - -- wMomSavingMoney & 7 (0 = you keep it all, 3 = she banks all four), and - -- is forced to 0 when Mom's account is already maxed. Nothing here - -- models Mom's savings account, which is the `keep it all` branch, so all - -- four quarters land in the wallet -- Falkner pays 25 * 9 * 4 = 900 and a - -- Sprout Tower sage 8 * 3 * 4 = 96, instead of the 225 and 24 a single - -- quarter gives. Gen1's TrainerBattleVictory has no such split, so the - -- multiplier stays on the Gen2 side of the fence. + -- Native GSC rewards are four quarters. Their destination is selected + -- by Mom's saving bits; Gen2MomShopping applies the per-account cap. -- THE LEVEL THE PRIZE IS COUNTED FROM. -- -- Gen 1 and Gen 2 count from the mon that was out when the battle ended. @@ -9262,7 +9198,9 @@ function BattleState:enemyMonFainted() prize = P.prize(self.data, self.trainer, opts) if self.trainerB then prize = prize + P.prize(self.data, self.trainerB, opts) end end - self.game.save.money = self.game.save.money + prize + local momAward=require("src.world.Gen2MomShopping").reward(self.game.save,prize) + if not momAward then self.game.save.money=self.game.save.money+prize end + self.g2MomAward=momAward -- Prism's Spurge Bank ATM (event/bank.asm) offers DIRECT DEPOSIT: with it -- enabled, a quarter of what you win in battle is routed to the account -- instead of the wallet. The switch lives in the ATM special @@ -9323,9 +9261,17 @@ function BattleState:enemyMonFainted() -- Platinum's font, so a Sinnoh win read "got 円 480". This default is -- only reached when the dataset has no `_MoneyForWinningText` of its own, -- which is every Gen 3 and Gen 4 cartridge. - self:sayNext(self:romText("_MoneyForWinningText", - "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", - self.game.save.player.name, prize)) + local momPool=require("src.world.Gen2MomShopping").pool(self.data) + local moneyText=momAward and momPool and momPool.rewardTexts and momPool.rewardTexts[momAward.mode] + if moneyText and (self.data.text and self.data.text[moneyText])then + local text=self.data.text[moneyText] + text=text:gsub("{PLAYER}",function()return self.game.save.player.name end):gsub("{NUM}",tostring(prize)) + self:sayNext(text) + else + self:sayNext(self:romText("_MoneyForWinningText", + "%s got " .. require("src.core.GameVersion").moneySign() .. "%d\nfor winning!", + self.game.save.player.name, prize)) + end end self.result = "win" self.afterQueue = "finish" @@ -9417,7 +9363,7 @@ function BattleState.isOaksLabStarterRival(self) end function BattleState:playerMonFainted() - local nextMon = Party.firstHealthy(self.game.save.party) + local nextMon = Party.firstHealthy(self:playerPartyView()) -- Being out of useable POKéMON blacks you out even when the battle was -- already decided in our favour. A double faint -- our last mon dying -- to residual damage on the turn it lands the KO -- used to hit the @@ -9505,7 +9451,7 @@ function BattleState:playerMonFainted() local ChoiceBox = require("src.ui.ChoiceBox") return ChoiceBox.new(game, function(yes) if yes then return end -- the menu-phase guard opens the party menu - local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0 + local pSpd = (self:playerPartyView()[1].stats or { speed = 0 }).speed or 0 if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then require("src.core.Sound").play(self.data, "Run") self:say(self:romText("_GotAwayText", "Got away safely!")) @@ -9563,7 +9509,7 @@ function BattleState:openReplacementMenu() -- unasked (PlayerHandleChoosePokemon's arena arm returns the next slot) if self.palace or self.arena then self:act(function() - for _, mon in ipairs(game.save.party or {}) do + for _, mon in ipairs(self:playerPartyView() or {}) do if mon.hp > 0 and not Party.isEgg(mon) and mon ~= (self.player and self.player.mon) then onSwitch(mon) return @@ -10048,7 +9994,8 @@ function BattleState:storeCaughtMon() -- Ruins of Alph researcher and the dex's UNOWN mode gate on local form = require("src.pokemon.Sprites").formIndex( game.data.pokemon[species], self.enemy.mon) - if dex and type(form)=='number' then + local nativeUnown = require("src.pokemon.Gen2Unown") + if not nativeUnown.supported() and dex and type(form)=='number' then dex.unownForms = dex.unownForms or {} if not dex.unownForms[form] then dex.unownForms[form] = true @@ -10076,6 +10023,15 @@ function BattleState:storeCaughtMon() local function askCaughtNickname() local caught = self.enemy.mon local enemyName = self.enemy.name + if GameVersion.isGen2() and Runtime.wantsHook("catch.nickname")then + local decision=Runtime.call("catch.nickname",function()return true end,caught, + {battle=self,name=enemyName,game=game}) + if decision==false then return end + if type(decision)=='string'then + if #decision>0 then caught.nickname=decision:sub(1,10)end + return + end + end self:uiNext(function() return self:askNicknameUI(caught, enemyName) end) @@ -10092,12 +10048,21 @@ function BattleState:storeCaughtMon() self.enemy.mon.ball = self.lastBall end if Party.add(game.save.party, self.enemy.mon) then + if nativeUnown.supported() then nativeUnown.registerCatch(game.save, self.enemy.mon, game.data) end require('src.pokemon.Gen4PoketchState').remember(game, self.enemy.mon) askCaughtNickname() else + local claimed=GameVersion.isGen2() and Runtime.wantsHook("catch.party_full") + and Runtime.call("catch.party_full",function()return false end, + {battle=self,mon=self.enemy.mon,name=self.enemy.name,game=game}) + if claimed then + if nativeUnown.supported() then nativeUnown.registerCatch(game.save,self.enemy.mon,game.data) end + destination='mod' + else destination = "box" local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) if boxNum then + if nativeUnown.supported() then nativeUnown.registerCatch(game.save, self.enemy.mon, game.data) end require('src.pokemon.Gen4PoketchState').remember(game, self.enemy.mon) askCaughtNickname() -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL @@ -10107,6 +10072,7 @@ function BattleState:storeCaughtMon() else self:sayNext(Strings("But every BOX\nis full!")) end + end end -- CatchMon copies the species name into wStringBuffer1, which is what the -- Bug Contest's "Caught !" and friends splice back out with text_ram @@ -10842,7 +10808,7 @@ function BattleState:finish() -- go on, the partner heals in the field), so it stands. if self.result ~= "lose" and not self.demo and not self.pokedudeDemo and not (self.ally and self.result == "win") - and not Party.firstHealthy(self.game.save.party) then + and not Party.firstHealthy(self:playerPartyView()) then Logger.warn("battle finished %s with no healthy party; forcing blackout", tostring(self.result)) self.result = "lose" @@ -10861,6 +10827,11 @@ function BattleState:finish() -- (home/overworld.asm:2343-2348) require("src.core.Music").restoreMap(self.data) self.game.stack:pop() + if not self._pokerusDone and not self.demo and not self.pokedudeDemo and self.result=='win' + and require('src.pokemon.Gen2Breeding').isVanilla()then + self._pokerusDone=true + require('src.pokemon.Gen2Pokerus').afterBattle(self.game.save) + end Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) -- Coming back from the battle screen is a fade, not a cut: EnterMap sees -- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle @@ -12188,7 +12159,7 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) -- .mimicmenu) wipes rows 7+. The port draws pics above the menu -- layer in the colorized pipeline, so clip them to the visible rows. local g = love.graphics - local clipY = not skipMenuClip + local clipY = not skipMenuClip and self:bottomUIVisible() and (self.phase == "mimicSelect" and 56 or self.phase == "moveSelect" and 64) or nil @@ -12496,6 +12467,7 @@ function BattleState:drawHUDs(slide) -- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill -- double-applies and the zone shade shader maps the whole bar to black (#229). local grayFill = self:colorMode() + local showStatus = self:statusHUDVisible() local barData = self.data local fx = self.fx local hudShake = (fx and fx.hudShakeX) or 0 @@ -12505,7 +12477,7 @@ function BattleState:drawHUDs(slide) -- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763) -- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the -- player's ball row with no enemy HUD beside it (#317) - if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut + if showStatus and self.enemy and not self.showEnemyTrainer and not self.enemySendingOut and not self:growInScale(self.enemy) and slide == 0 and not self.introBalls and not self.enemy.fainted then -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, +level (4,1), @@ -12519,7 +12491,7 @@ function BattleState:drawHUDs(slide) Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) if self.enemy.shownStatus then Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) - else + elseif LevelDisplay.visible(self.enemy.mon, "battle.enemy", self.game) then hudTile(0x6E, 32, 8) -- Font.draw(tostring(self.enemy.mon.level), 40, 8) end @@ -12589,10 +12561,10 @@ function BattleState:drawHUDs(slide) for i = 10, 17 do hudTile(0x76, i * 8, 88) end hudTile(0x6F, 72, 88) love.graphics.setColor(1, 1, 1, 1) - self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) + self:drawBallRow(self.playerParty or self:playerPartyView(), 88, 80, 8) end local hidePlayer = self.safari or self:demoHidesPlayer() - if self.player and not hidePlayer and not self.showPlayerBack + if showStatus and self.player and not hidePlayer and not self.showPlayerBack and slide == 0 then -- player HUD (DrawPlayerHUDAndHPBar): name (10,7), +level -- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with @@ -12601,7 +12573,7 @@ function BattleState:drawHUDs(slide) Font.draw(self.player.name, nameX(10, self.player.name), 56) if self.player.shownStatus then Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64) - else + elseif LevelDisplay.visible(self.player.mon, "battle.player", self.game) then hudTile(0x6E, 112, 64) -- Font.draw(tostring(self.player.mon.level), 120, 64) end @@ -12637,6 +12609,7 @@ function BattleState:drawHUDs(slide) end function BattleState:drawTextArea() + if not self:bottomUIVisible() then return end -- EMERALD'S OWN, and reached through this method rather than around it. -- Gen3Battle.draw used to call its layer functions directly, which meant a -- mod wrapping this name never saw a Gen 3 battle at all. diff --git a/src/battle/Gen3AI.lua b/src/battle/Gen3AI.lua index 42fe0fd4c..520c44ecc 100644 --- a/src/battle/Gen3AI.lua +++ b/src/battle/Gen3AI.lua @@ -433,7 +433,7 @@ OPS["count_usable_party_mons"] = function(vm, args) local party if b.isPlayer then party = battle.playerParty or (battle.game and battle.game.save - and battle.game.save.party) + and (battle.playerParty or battle.game.save.party)) else party = battle.enemyParty end diff --git a/src/battle/Gen3Battle.lua b/src/battle/Gen3Battle.lua index dcaa62f71..155df7754 100644 --- a/src/battle/Gen3Battle.lua +++ b/src/battle/Gen3Battle.lua @@ -39,9 +39,11 @@ local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Theme = require("src.ui.Theme") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") +local StatusHUDVisibility = require("src.battle.StatusHUDVisibility") local Strings = require("src.core.Strings") local TypeChart = require("src.battle.TypeChart") local GameVersion = require("src.core.GameVersion") @@ -890,7 +892,11 @@ end local HUD_TEXT_STYLE = { text = { 0.16, 0.16, 0.16, 1 } } Gen3Battle.HUD_TEXT_STYLE = HUD_TEXT_STYLE -local function levelAt(battle, battler, x, y, text) +local function levelAt(battle, battler, x, y, text, isStatus) + if not (isStatus or (not text and battler.shownStatus)) + and not LevelDisplay.visible(battler.mon, + (battler.isPlayer == true or battler == battle.player) and "battle.player" or "battle.enemy", + battle.game) then return end if text then Font.draw(text, x, y) elseif battler.shownStatus then @@ -1126,7 +1132,8 @@ local function drawStatusPanel(battle, battler, x, y, player) Font.draw(symbol, ix + 1 + Font.width(shown), nameY) Font.popStyle() end - levelAt(battle, battler, ix + inner.w - levelW - 1, nameY, level) + levelAt(battle, battler, ix + inner.w - levelW - 1, nameY, level, + not badge and battler.shownStatus) -- the foe's exact HP is never shown, as on every cartridge if player then @@ -1440,17 +1447,18 @@ local function healthboxAt(battle, key, panel, fx, fy) end local function drawHUDs(battle, slide) + local showStatus = StatusHUDVisibility.forBattle(battle) local record = hudRecord(battle) local images = record and record.images or {} local double = battle.isDouble and battle:isDouble() - if not double and battle.enemy and not battle.showEnemyTrainer + if showStatus and not double and battle.enemy and not battle.showEnemyTrainer and not battle.enemySendingOut and not battle:growInScale(battle.enemy) and slide == 0 and not battle.introBalls and not battle.enemy.fainted then local x, y = healthboxAt(battle, "singlesOpponent", hudImage(images.opponent), 8, 8) drawStatusPanel(battle, battle.enemy, x, y, false) end - if not double and not battle.safari and battle.player and not battle.demo + if showStatus and not double and not battle.safari and battle.player and not battle.demo and not battle.showPlayerBack and slide == 0 then local x, y = healthboxAt(battle, "singlesPlayer", hudImage(images.player), 112, 64) @@ -1467,7 +1475,7 @@ local function drawHUDs(battle, slide) -- The player's doubles panel is its own art too -- shorter, and with no -- EXP bar, which is why the cartridge ships five healthbox blobs and not -- two. - if double and slide == 0 then + if showStatus and double and slide == 0 then local pairs_ = { { pos = 0, key = "playerLeft", img = images.player, player = true }, { pos = 2, key = "playerRight", img = images.playerDoubles diff --git a/src/battle/Gen3MoveEffects.lua b/src/battle/Gen3MoveEffects.lua index 1704c46d1..4a6053087 100644 --- a/src/battle/Gen3MoveEffects.lua +++ b/src/battle/Gen3MoveEffects.lua @@ -256,7 +256,7 @@ P.HEAL_BELL_EFFECT = function(battle, user) user.mon.status = nil user.toxicCounter = nil local party = user.isPlayer and battle.game and battle.game.save - and battle.game.save.party + and (battle.playerParty or battle.game.save.party) for _, mon in ipairs(party or {}) do mon.status = nil end return { Strings("A bell chimed!") } end @@ -1024,7 +1024,7 @@ F.FUTURE_SIGHT_EFFECT = { F.BEAT_UP_EFFECT = { hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game - and ctx.battle.game.save and ctx.battle.game.save.party + and ctx.battle.game.save and (ctx.battle.playerParty or ctx.battle.game.save.party) if not party then return 1 end local n = 0 for _, mon in ipairs(party) do diff --git a/src/battle/Gen4Battle.lua b/src/battle/Gen4Battle.lua index 78701f609..b5bc0e6b6 100644 --- a/src/battle/Gen4Battle.lua +++ b/src/battle/Gen4Battle.lua @@ -1896,25 +1896,8 @@ function Gen4Battle.drawHealthboxIcons(battle, key, battler, boxX, boxY, side) return true end --- A mod that owns the battle HUD (Colosseum UI, realtime host) already --- answers `battle.status_hud_visible`. Gen 1/2 HP rows go through --- BattleState:drawHUDs, which those mods wrap. Sinnoh paints Platinum's --- healthboxes HERE instead, so that wrap never ran and the native bars --- stayed on screen over the Colosseum HUD. Returning true (boxes "drawn") --- is what keeps Gen4Battle.draw from falling through to the Game Boy panels. -local function statusHudVisible(battle) - if type(battle) == "table" and type(battle.statusHUDVisible) == "function" then - local ok, vis = pcall(battle.statusHUDVisible, battle) - if ok and vis == false then return false end - end - local okR, Runtime = pcall(require, "src.mods.Runtime") - if okR and Runtime and Runtime.wantsHook and Runtime.wantsHook("battle.status_hud_visible") then - local vis = Runtime.call("battle.status_hud_visible", - function() return true end, battle) - if vis == false then return false end - end - return true -end +-- A hidden DS healthbox counts as handled: do not fall back to GB panels. +local statusHudVisible = require("src.battle.StatusHUDVisibility").forBattle -- drawHealthboxes(battle) -> true when BOTH were drawn -- 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/Gen5MoveEffects.lua b/src/battle/Gen5MoveEffects.lua index 64682dade..46abc5276 100644 --- a/src/battle/Gen5MoveEffects.lua +++ b/src/battle/Gen5MoveEffects.lua @@ -1545,7 +1545,7 @@ end }) DEF.BW_BEAT_UP = F({ hitCount = function(ctx) local party = ctx.user.isPlayer and ctx.battle.game and ctx.battle.game.save - and ctx.battle.game.save.party or ctx.battle.enemyParty + and (ctx.battle.playerParty or ctx.battle.game.save.party) or ctx.battle.enemyParty local n = 0 for _, mon in ipairs(party or {}) do if (mon.hp or 0) > 0 and not mon.status then n = n + 1 end @@ -1708,7 +1708,7 @@ end }) local function benchOf(battle, b) local out = {} if b.isPlayer then - local party = battle.game and battle.game.save and battle.game.save.party or {} + local party = battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or {} for _, mon in ipairs(party) do if mon ~= b.mon and (mon.hp or 0) > 0 and not mon.isEgg and not mon.egg then local onField = false @@ -2105,7 +2105,7 @@ DEF.BW_REFRESH = P({ run = enginePrimary("REFRESH_EFFECT") }) DEF.BW_HEAL_BELL = P({ run = function(ctx) local battle, user = ctx.battle, ctx.user user.mon.status, user.toxicCounter = nil, nil - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty for _, mon in ipairs(party or {}) do mon.status = nil end return { T.std(battle, ctx.move.id == 312 and "aroma" or "bell") } @@ -2490,7 +2490,7 @@ DEF.BW_METRONOME = F({ callsMove = function(ctx) end }) DEF.BW_ASSIST = F({ callsMove = function(ctx) local battle, u = ctx.battle, ctx.user - local party = u.isPlayer and battle.game and battle.game.save and battle.game.save.party or battle.enemyParty + local party = u.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty local pool = {} for _, mon in ipairs(party or {}) do if mon ~= u.mon then diff --git a/src/battle/Gen5MoveRules.lua b/src/battle/Gen5MoveRules.lua index b6faa003d..c8afd08cf 100644 --- a/src/battle/Gen5MoveRules.lua +++ b/src/battle/Gen5MoveRules.lua @@ -609,7 +609,7 @@ end local function beatUp(G5) return F({ perform = function(ctx, rec) local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move - local party = user.isPlayer and battle.game and battle.game.save and battle.game.save.party + local party = user.isPlayer and battle.game and battle.game.save and (battle.playerParty or battle.game.save.party) or battle.enemyParty or {} local members = {} for _, mon in ipairs(party) do 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/MoveNavigation.lua b/src/battle/MoveNavigation.lua new file mode 100644 index 000000000..6b757a473 --- /dev/null +++ b/src/battle/MoveNavigation.lua @@ -0,0 +1,52 @@ +-- Public choice of navigation mode; battle handles stay opaque to callbacks. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Wide = require("src.battle.WideBattle") +local M = {} +local function nativeGrid(battle) + return battle:isWideBattleLayout() or battle:gen3Layout() or battle:gen4Layout() +end +function M.grid(battle) + -- Gen1's wide GB layout has a fixed grid. Gold's distinct native screen + -- allows wrappers to choose a list even when composed on a wider surface. + if battle:isWideBattleLayout() and not Version.isGen2() then + return true + end + if not Runtime.wantsHook("battle.move_grid_navigation") then + return nativeGrid(battle) + end + return Runtime.call("battle.move_grid_navigation", nativeGrid, battle) == true +end +function M.navigate(battle, index, count, input) + if not M.grid(battle) or count < 1 then + return nil + end + if not (battle:gen3Layout() and battle:isDouble()) then + return Wide.navigate(index, count, input) + end + -- GBA's doubles controller clamps to its current row/column. Singles and + -- the GB mod grid wrap; missing cells retain the current occupied slot. + local target = index + local column, row = (index - 1) % 2, math.floor((index - 1) / 2) + if input:wasPressed("left") then + if column == 1 then + target = index - 1 + end + elseif input:wasPressed("right") then + if column == 0 then + target = index + 1 + end + elseif input:wasPressed("up") then + if row == 1 then + target = index - 2 + end + elseif input:wasPressed("down") then + if row == 0 then + target = index + 2 + end + else + return nil + end + return target >= 1 and target <= count and target or index +end +return M diff --git a/src/battle/PlayerPartyScope.lua b/src/battle/PlayerPartyScope.lua new file mode 100644 index 000000000..6706a2b4b --- /dev/null +++ b/src/battle/PlayerPartyScope.lua @@ -0,0 +1,36 @@ +-- Battle-local ordered references; never reorder or replace save.party. +local Logger = require("src.core.Logger") +local M = {} +function M.select(game, indices) + if indices == nil then + return nil, nil + end + local function fallback() + Logger.warn("trainer battle party scope is invalid; using full party") + return nil, nil + end + if type(indices) ~= "table" or #indices == 0 then + return fallback() + end + local count = #indices + local keys = 0 + for key in pairs(indices) do + keys = keys + 1 + if type(key) ~= "number" or key % 1 ~= 0 or key < 1 or key > count then + return fallback() + end + end + if keys ~= count then + return fallback() + end + local party, normalized, seen = {}, {}, {} + for i = 1, count do + local index = indices[i] + if type(index) ~= "number" or index % 1 ~= 0 or not game.save.party[index] or seen[index] then + return fallback() + end + seen[index], normalized[i], party[i] = true, index, game.save.party[index] + end + return party, normalized +end +return M diff --git a/src/battle/StatusHUDVisibility.lua b/src/battle/StatusHUDVisibility.lua new file mode 100644 index 000000000..37c64aec0 --- /dev/null +++ b/src/battle/StatusHUDVisibility.lua @@ -0,0 +1,25 @@ +-- One native predicate for GB/GBA and DS healthboxes. Only literal false hides. +local Runtime = require("src.mods.Runtime") +local M = {} +local function defaultVisible() + return true +end +function M.visible(battle) + if not Runtime.wantsHook("battle.status_hud_visible") then + return true + end + return Runtime.call("battle.status_hud_visible", defaultVisible, battle) ~= false +end +-- Standalone renderers also accept partial battle fixtures and legacy overrides. +-- The native method is M.visible itself, so it must not dispatch the hook twice. +function M.forBattle(battle) + local method = type(battle) == "table" and battle.statusHUDVisible + if type(method) == "function" and method ~= M.visible then + local ok, visible = pcall(method, battle) + if ok and visible == false then + return false + end + end + return M.visible(battle) +end +return M diff --git a/src/battle/TrainerPreparation.lua b/src/battle/TrainerPreparation.lua new file mode 100644 index 000000000..bd387361b --- /dev/null +++ b/src/battle/TrainerPreparation.lua @@ -0,0 +1,124 @@ +-- A synchronous hook can retain this one-shot native admission continuation. +local Runtime = require("src.mods.Runtime") +local M = { CANCEL_SCRIPT = {} } +local pending = setmetatable({}, { __mode = "k" }) +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function defaultGate() + return false +end +function M.active(game) + return pending[game] ~= nil +end +function M.prepare(game, context, startBattle, cancelBattle, valid, retireBattle) + if not Runtime.wantsHook("trainer.before_battle") then + startBattle() + return false + end + if pending[game] then + return true + end + local hooks, events = Runtime.hooks, Runtime.events + local save, data, stack, session = game.save, game.data, game.stack, game.sessionStartedAt + local world, overworld = game.world, game.overworld + local party = save and save.party + local members = {} + for i, mon in ipairs(party or {}) do + members[i] = mon + end + local entries = {} + for _, entry in ipairs(hooks.chains["trainer.before_battle"] or {}) do + entries[#entries + 1] = entry + end + local token, finished, unsubscribe = {}, false + pending[game] = token + local function current() + if + game.save ~= save + or game.data ~= data + or game.stack ~= stack + or game.sessionStartedAt ~= session + or game.world ~= world + or game.overworld ~= overworld + or (save and save.party ~= party) + then + return false + end + if #(party or {}) ~= #members then + return false + end + for i, mon in ipairs(members) do + if party[i] ~= mon then + return false + end + end + return not valid or valid() + end + local function live() + if Runtime.hooks ~= hooks or Runtime.events ~= events then + return false + end + for _, original in ipairs(entries) do + local found = false + for _, entry in ipairs(hooks.chains["trainer.before_battle"] or {}) do + if entry == original then + found = true + break + end + end + if not found then + return false + end + end + return true + end + local function discard() + finished = true + if pending[game] == token then + pending[game] = nil + end + if unsubscribe then + unsubscribe() + unsubscribe = nil + end + end + local function continueBattle(options) + if finished or pending[game] ~= token then + return false + end + local eligible = current() + if not eligible or not live() then + discard() + if eligible and cancelBattle then + cancelBattle() + elseif retireBattle then + retireBattle() + end + return false + end + discard() + if type(options) == "table" and options.cancel == true then + if cancelBattle then + cancelBattle() + end + else + startBattle(options) + end + return true + end + unsubscribe = hooks:wrap("core.update", function(next, ...) + local results = pack(next(...)) + if not finished and (not current() or not live()) then + continueBattle({ cancel = true }) + end + return unpack(results, 1, results.n) + end, -1000, "base") + local deferred = Runtime.call("trainer.before_battle", defaultGate, game, context, continueBattle) + if deferred ~= true and not finished then + continueBattle() + end + return deferred == true +end +return M diff --git a/src/battle/UIVisibility.lua b/src/battle/UIVisibility.lua index 3165af0ca..a79808544 100644 --- a/src/battle/UIVisibility.lua +++ b/src/battle/UIVisibility.lua @@ -23,7 +23,8 @@ -- -- `box` is the TextBox / ChoiceBox instance about to draw and may be nil; -- `isTextBox` is true for the text box and false for the choice box. Both are --- passed to every predicate; neither is interpreted here. +-- passed unchanged to every legacy claim. The public hook below uses +-- isTextBox to decide whether to query that individual state as well. -- -- A predicate that throws is treated as "no opinion" and its claim is dropped, -- because this is called from inside a draw and a broken claim must not be @@ -31,6 +32,7 @@ -- session, which would leave the game unplayable with no error on screen. local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") local UIVisibility = {} @@ -42,39 +44,92 @@ local bottomClaims = {} -- twice with the same owner replaces the predicate rather than stacking, so -- an install that runs again after a reload cannot leak a claim. function UIVisibility.claimBottom(owner, predicate) - if owner == nil or type(predicate) ~= "function" then return false end + if owner == nil or type(predicate) ~= "function" then + return false + end bottomClaims[owner] = predicate return true end function UIVisibility.releaseBottom(owner) - if owner == nil then return false end + if owner == nil then + return false + end local had = bottomClaims[owner] ~= nil bottomClaims[owner] = nil return had end +local function visibleByDefault() + return true +end + +local function enclosingBattle(state) + local stack = state and state.game and state.game.stack + local states = stack and stack.states + if not states then + return nil + end + local found = false + for i = #states, 1, -1 do + local candidate = states[i] + if candidate == state then + found = true + end + if found and candidate and (candidate.isBattle or candidate.isBattleState) then + return candidate + end + end +end + +-- TextBox is queried individually too. ChoiceBox only inherits an enclosing +-- battle, so a field YES/NO never becomes a battle-hook state. +local function hookBottomVisible(state, queryState) + if not Runtime.wantsHook("battle.bottom_ui_visible") then + return true + end + local battle = enclosingBattle(state) + if + battle + and battle ~= state + and Runtime.call("battle.bottom_ui_visible", visibleByDefault, battle) == false + then + return false + end + if queryState or battle == state then + return Runtime.call("battle.bottom_ui_visible", visibleByDefault, state) ~= false + end + return true +end + -- Whoever is asking is allowed to draw the bottom box, unless a claim says no. function UIVisibility.bottomVisible(box, isTextBox) for owner, predicate in pairs(bottomClaims) do local ok, visible = pcall(predicate, box, isTextBox) if not ok then bottomClaims[owner] = nil - Logger.warn("UIVisibility: bottom claim %s failed and was dropped: %s", - tostring(owner), tostring(visible)) + Logger.warn( + "UIVisibility: bottom claim %s failed and was dropped: %s", + tostring(owner), + tostring(visible) + ) elseif visible == false then return false end end - return true + return hookBottomVisible(box, isTextBox) end -- Introspection for the mod manager / a diagnostic overlay: who is currently -- holding the strip. Returns a fresh array of owner ids. function UIVisibility.bottomClaimants() local out = {} - for owner in pairs(bottomClaims) do out[#out + 1] = owner end - table.sort(out, function(a, b) return tostring(a) < tostring(b) end) + for owner in pairs(bottomClaims) do + out[#out + 1] = owner + end + table.sort(out, function(a, b) + return tostring(a) < tostring(b) + end) return out end diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index b8a4a4ca5..59bd18163 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -12,9 +12,12 @@ -- prompt/command window, and a 2x2 move menu with an attached PP/type panel. local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") local Runtime = require("src.mods.Runtime") +local StatusHUDVisibility = require("src.battle.StatusHUDVisibility") +local UIVisibility = require("src.battle.UIVisibility") local Strings = require("src.core.Strings") local TypeChart = require("src.battle.TypeChart") @@ -86,7 +89,9 @@ end local function levelAt(battle, battler, x, y) if battler.shownStatus then Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y) - else + elseif LevelDisplay.visible(battler.mon, + (battler.isPlayer == true or battler == battle.player) and "battle.player" or "battle.enemy", + battle.game) then HudTiles.tile(0x6E, x, y) -- '' Font.draw(tostring(battler.mon.level), x + 8, y) end @@ -224,7 +229,8 @@ local function drawIntroBalls(battle) end local function drawHUDs(battle, slide) - if battle.enemy and not battle.showEnemyTrainer + local showStatus = StatusHUDVisibility.forBattle(battle) + if showStatus and battle.enemy and not battle.showEnemyTrainer and not battle.enemySendingOut and not battle:growInScale(battle.enemy) and slide == 0 and not battle.introBalls and not battle.enemy.fainted then drawStatusPanel(battle, battle.enemy, 0, 0, false) @@ -235,7 +241,7 @@ local function drawHUDs(battle, slide) -- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside -- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in -- drawCommandMenu below like the classic layout's (#540). - if not battle.safari and battle.player and not battle.demo + if showStatus and not battle.safari and battle.player and not battle.demo and not battle.showPlayerBack and slide == 0 then -- WHERE THE PLAYER'S BLOCK SITS. The framed panel was fifteen tiles -- wide and filled the corner; the Game Boy block is only four tiles plus @@ -359,6 +365,7 @@ local function drawMoveMenu(battle) end local function drawTextArea(battle) + if not UIVisibility.bottomVisible(battle, true) then return end if battle.phase == "messages" and (battle.current or battle.animPlaying) then drawMessageBox(battle) elseif battle.phase == "menu" then diff --git a/src/compat/game3/Follower.lua b/src/compat/game3/Follower.lua new file mode 100644 index 000000000..86c09be9c --- /dev/null +++ b/src/compat/game3/Follower.lua @@ -0,0 +1,9 @@ +-- 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 + return world and Native.current(world)or nil +end +return F diff --git a/src/compat/game3/Player.lua b/src/compat/game3/Player.lua new file mode 100644 index 000000000..f708e02e4 --- /dev/null +++ b/src/compat/game3/Player.lua @@ -0,0 +1,12 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Field-only fallback for hosts without a concrete live player provider. +local function player() + local game=package.loaded['src.core.Game'] + return game and game.overworld and game.overworld.player +end +return setmetatable({},{__index=function(_,key) + local current=player();return current and current[key] +end,__newindex=function(_,key,value) + local current=assert(player(),'native player is unavailable') + current[key]=value +end}) diff --git a/src/compat/game3/Pokemon.lua b/src/compat/game3/Pokemon.lua new file mode 100644 index 000000000..859ac5121 --- /dev/null +++ b/src/compat/game3/Pokemon.lua @@ -0,0 +1,17 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local P={} +function P.national(species) + local game=package.loaded['src.core.Game'] + local records=game and game.data and game.data.pokemon or require('src.core.Data').pokemon or{} + local key=type(species)=='number'and string.format('SPECIES_%03d',species)or species + local def=records[species]or records[key] + return def and(def.dex or def.nationalDex)or nil +end +function P.unownLetter(personality) + local bits=require('bit') + local n=tonumber(personality)or 0 + local value=0 + for shift=24,0,-8 do value=value*4+bits.band(bits.rshift(n,shift),3)end + return value%28 +end +return P diff --git a/src/compat/gen2/Decorations.lua b/src/compat/gen2/Decorations.lua new file mode 100644 index 000000000..952a801a6 --- /dev/null +++ b/src/compat/gen2/Decorations.lua @@ -0,0 +1,138 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Legacy argument/result contracts over the native cartridge and placed bytes. +-- No fallback catalogue, ownership bitfield or second decoration save exists. +local Native=require('src.world.Gen2Decorations') +local Version=require('src.core.GameVersion') +local Flags=require('src.script.Flags') +local Gen2Flags=require('src.script.Gen2Flags') +local Copy=require('src.mods.Merge').deepCopy +local M={SLOTS=Copy(Native.slots),OBJECT_SLOTS=Copy(Native.objectSlots),ACTIONS={}, + EVENT_PLAYERS_ROOM_POSTER=716,DECOFLAG_GOLD_TROPHY_DOLL=43,DECOFLAG_SILVER_TROPHY_DOLL=44, + DESC_SLOTS={DECODESC_POSTER={slot='poster'},DECODESC_LEFT_DOLL={slot='leftOrnament',named=true}, + DECODESC_RIGHT_DOLL={slot='rightOrnament',named=true},DECODESC_BIG_DOLL={slot='bigDoll'}, + DECODESC_CONSOLE={slot='console',named=true}}} +for n=1,14 do + local name=Native.actionName(n);local action=Native.action(name) + if not action.put then action.put=nil end + M.ACTIONS[name]=action +end +local bound,edition,epoch=nil,nil,0 +local function data() + return edition==Version.get()and Native.ready(bound)and bound or nil +end +function M.useRegistry(value) + epoch=epoch+1;bound=nil;edition=nil + if Native.ready(value)then bound=value;edition=Version.get()end + return bound~=nil +end +function M.idFor(id)return 'deco:'..tostring(id)end +function M.idForFlag(n) + n=n==nil and 0 or tonumber(n) + local d=data();local info=d and Native.pool(d).decorationInfo + if not(n and n%1==0 and n>=0 and n<=255 and info and info.giftsComplete)then return nil end + return info.gifts and info.gifts[n+1] +end +function M.attributes(id)return Native.attributes(data(),id)end +function M.state(save)return Native.state(data(),save)end +function M.name(id,monName)return Native.name(data(),id,monName)end +function M.registerInto(registry,source,owner) + local d=Native.ready(source)and source or data();local count=0 + if not d then return count end + local seen={} + for raw in pairs(Native.pool(d).decorations)do + local id=Native.byte(raw) + if id and not seen[id]then + seen[id]=true;registry:register(M.idFor(id),Copy(Native.attributes(d,id)),owner);count=count+1 + end + end + return count +end +-- Optional native adapter for callers of the numeric Events protocol. Its +-- get/set operations reach the same canonical flags as scripts and SRAM. +-- A retained adapter expires on rebind/unload; it never captures a new game. +function M.events(save) + if not data()or type(save)~='table'then return nil end + local generation=epoch + local function current(id) + return generation==epoch and data()and type(id)=='number'and id%1==0 and id>=0 and id<65535 + end + return{get=function(_,id) + return current(id)and type(save.flags)=='table'and Flags.get(save,Gen2Flags.eventFlag(id))or false + end,set=function(_,id,value) + if not current(id)then return false end + save.flags=save.flags or{} + if value then Flags.set(save,Gen2Flags.eventFlag(id))else Flags.clear(save,Gen2Flags.eventFlag(id))end + return true + end} +end +local function usable(id) + id=Native.byte(id) + local row=M.attributes(id);local action=row and Native.action(row.action) + return id and id~=0 and action and not action.put and row or nil +end +function M.owns(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.get)=='function'then return events:get(row.flag)and true or false end + return Native.owns(data(),events,id) +end +function M.give(events,id) + local row=usable(id);if not row or type(events)~='table'then return false end + if type(events.set)=='function'then return events:set(row.flag,true)~=false end + return Native.give(data(),events,id) +end +function M.giveFlag(events,n)return M.give(events,M.idForFlag(n))end +function M.ownedCategories(events) + local out={} + for _,category in ipairs(Native.categories(data()))do + for _,id in ipairs(category.members)do + if M.owns(events,id)then out[#out+1]=category;break end + end + end + return out +end +function M.rows(events,category) + local out={} + -- Rebuild membership rather than trusting a category retained across reload. + for _,current in ipairs(Native.categories(data()))do + if category and current.id==category.id and(current.slot==category.slot or + category.slot==nil and category.id~=nil)then + for _,id in ipairs(current.members)do if M.owns(events,id)then out[#out+1]=id end end + if #out>0 then + for _,id in ipairs(current.headers)do out[#out+1]=id end + out[#out+1]=0 + end + break + end + end + return out +end +function M.apply(state,id,side,monName) + local placement=Native.placement(data(),state,id,side,monName) + if not placement then return false,{}end + state[placement.slot]=placement.value + return placement.changed,Native.feedbackPages(data(),placement) +end +function M.clearOtherSide(state,id,side) + id=Native.byte(id) + if not data()or type(state)~='table'or not id or id==0 or(side~='left'and side~='right')then return end + local other=side=='right'and 'leftOrnament'or 'rightOrnament' + if state[other]==id then state[other]=0 end +end +function M.visibility(state)return data()and Native.layoutVisibility(data(),state)or{}end +function M.tiles(state)return data()and Native.layoutTiles(data(),state)or{}end +function M.posterVisible(state)return data()~=nil and Native.posterVisible(state)end +return setmetatable(M,{__index=function(_,key) + if key=='CATEGORIES'then return Native.categories(data())end + if key=='ATTRIBUTES'then + local out={};local d=data() + for raw in pairs(d and Native.pool(d).decorations or{})do + local id=Native.byte(raw);if id then out[id]=Native.attributes(d,id)end + end + return out + end + if key=='NOTHING_TO_CHOOSE'then + local pages=Native.feedbackPages(data(),{role='noChoice',buffers={}}) + return pages[1] + end +end}) diff --git a/src/compat/gen2/Unown.lua b/src/compat/gen2/Unown.lua new file mode 100644 index 000000000..c69e3e0af --- /dev/null +++ b/src/compat/gen2/Unown.lua @@ -0,0 +1,19 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Reference spellings over the native imported records and native save. +local Native = require("src.pokemon.Gen2Unown") +local M = {NUM_UNOWN=Native.NUM_UNOWN, SPECIES="UNOWN", ALPHABET=Native.ALPHABET, + name=Native.name, index=Native.index, letterFromDVs=Native.letterFromDVs, + dvsForLetter=Native.dvsForLetter, monLetter=Native.monLetter, + word=Native.word, formSprite=Native.formSprite, + dex=Native.letters, caught=Native.caught, count=Native.count, + updateDex=Native.updateDex, registerCatch=Native.registerCatch} +-- Reference consumers index forms by A..Z; native storage indexes by number. +-- The public name map is derived on demand; picture records remain native. +function M.forms(pokemon) + local source = Native.forms(pokemon) + if not source then return nil end + local out = {} + for i = 1, 26 do if source[i] then out[Native.name(i)] = source[i] end end + return out +end +return M diff --git a/src/core/AudioPcm.lua b/src/core/AudioPcm.lua new file mode 100644 index 000000000..29b371308 --- /dev/null +++ b/src/core/AudioPcm.lua @@ -0,0 +1,80 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Private PCM writes; the returned object is still an ordinary SoundData. +local Performance = require("src.core.Performance") +local ok, ffi = pcall(require, "ffi") +local M = {} +local float = ok and ffi.new("float[1]") or nil +local nativeFloat = false +function M.pointer(sound) + if + Performance.audioWorkerOptimization == false + or not ok + or not jit + or not jit.status() + or not sound.getFFIPointer + then + return nil + end + -- LÖVE's LuaJIT SoundData wrapper multiplies doubles; its C method takes + -- and multiplies floats. Follow the installed method, including on 11.x. + nativeFloat = debug.getinfo(sound.setSample, "S").what == "C" + local found, pointer = pcall(sound.getFFIPointer, sound) + if not found or pointer == nil then + return nil + end + local cast, value = pcall(ffi.cast, "int16_t *", pointer) + return cast and value ~= nil and value or nil +end +function M.sample(value) + -- SoundData takes a float argument and multiplies in float precision. + -- Keep both rounding points before the native signed-integer truncation. + if not nativeFloat then + return value * 32767 + end + float[0] = value + float[0] = float[0] * 32767 + return float[0] +end +function M.render(engine, count, channels, rate) + local sound = love.sound.newSoundData(count, rate, 16, channels) + local pointer = M.pointer(sound) + if pointer then + if channels == 2 then + for n = 0, count - 1 do + local left, right = engine:sampleStereo() + pointer[n * 2], pointer[n * 2 + 1] = M.sample(left), M.sample(right) + end + else + for n = 0, count - 1 do + pointer[n] = M.sample(engine:sample()) + end + end + else + for n = 0, count - 1 do + if channels == 2 then + local left, right = engine:sampleStereo() + sound:setSample(n, 1, left) + sound:setSample(n, 2, right) + else + sound:setSample(n, engine:sample()) + end + end + end + return sound +end +function M.arrays(left, right, count, rate) + local sound = love.sound.newSoundData(count, rate, 16, 2) + local pointer = M.pointer(sound) + if pointer then + for n = 1, count do + pointer[(n - 1) * 2], pointer[(n - 1) * 2 + 1] = M.sample(left[n]), M.sample((right or left)[n]) + end + else + for n = 1, count do + sound:setSample(n - 1, 1, left[n]) + sound:setSample(n - 1, 2, (right or left)[n]) + end + end + return sound +end +return M diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 288901d82..8352458be 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -13,9 +13,10 @@ -- to start, music falls back to the original synchronous, amortized queue fill -- so behavior is unchanged -- see the `threaded` branch in each entry point. -- --- SFX and cries stay synchronous: they are short one-shots rendered once into --- a static Source, not a per-frame streaming cost. +-- SFX and cries are prewarmed by the same worker into Sound.lua's static +-- source cache; a cold/unavailable path retains a logged synchronous fallback. +local Performance = require("src.core.Performance") local Assets = require("src.render.Assets") local ChipSynth = require("src.core.ChipSynth") @@ -157,6 +158,9 @@ end -- --------------------------------------------------------------------------- local worker, cmdCh, outCh +, fxCh +local fxGeneration, fxSerial, fxPending = 1, 0, 0 +local workerPerformance local workerReady -- nil = untried, true = running, false = unavailable local function ensureWorker() @@ -179,12 +183,14 @@ local function ensureWorker() end cmdCh = love.thread.getChannel("chipaudio_cmd") outCh = love.thread.getChannel("chipaudio_out") + fxCh = love.thread.getChannel("chipaudio_fx") local started = pcall(function() thread:start() end) if not started then workerReady = false return false end worker = thread + workerPerformance = nil workerReady = true return true end @@ -229,7 +235,7 @@ local function slimAudio(data) local audio = data.audio or {} return { programFile = audio.programFile, - programPrefix = require('src.core.WorkerFs').prefix(), + programPrefix = require("src.core.WorkerFs").prefix(), bankOrder = audio.bankOrder, waveBanks = audio.waveBanks, gen2Dialect = audio.gen2Dialect, @@ -238,9 +244,100 @@ local function slimAudio(data) -- the Gen 3 synth reads its bytecode, voicegroups and sampled -- instruments out of one image beside the cache; this is where it is gen3 = audio.gen3, + gen3Cries = audio.gen3Cries, } end +-- Private transport shared with NitroAudio; no second synthesis thread. +function ChipAudio._nitroReady() + if Performance.audioWorkerOptimization==false or not ensureWorker() then return nil end + if not worker or worker:getError() then return nil end + return worker +end +function ChipAudio._nitroCommand(command) + if not ChipAudio._nitroReady() then return false end + command.cmd="nitro" + return pcall(cmdCh.push,cmdCh,command) +end + +-- Private prewarm protocol: no Source, playback or public sound event in the +-- worker. Sound.lua installs completed PCM into its ordinary source cache. +function ChipAudio.effectSpec(data, kind, def, pitch, tempo) + local request = { + audio = slimAudio(data), + stereo = ChipSynth.getStereo(), + volumes = ChipSynth.getChannelVolumes(), + pitches = ChipSynth.getChannelPitches(), + } + if kind == "cry" then + if type(def) ~= "table" then + return nil + end + if def.m4a then + request.kind, request.index = "cry", def.m4a + elseif def.chip or def.header then + request.kind, request.header = "chip", def.chip and def or def.header + request.options = { frequencyOffset = def.pitch, cryLength = def.length } + else + return nil + end + elseif type(def) == "table" and def.m4a then + request.kind = "m4a" + request.header = data.audio.songs and data.audio.songs[("SONG_%03X"):format(def.m4a)] + if not request.header then + return nil + end + else + request.kind, request.header = "chip", def + request.options = { frequencyOffset = pitch or 0, frameTicks = 0x80 + (tempo or 0x80) } + end + return request +end +function ChipAudio.requestEffect(spec) + if Performance.audioWorkerOptimization == false or sessionSuspended or fxPending >= 2 or not ensureWorker() then + return nil + end + -- A failed worker is never asked to accept another speculative job. + if worker:getError() then + return nil + end + fxSerial = fxSerial + 1 + local id = fxSerial + local ok = pcall( + cmdCh.push, + cmdCh, + { cmd = "fx", generation = fxGeneration, id = id, spec = spec, audioWorkerOptimization = true } + ) + if not ok then + return nil + end + fxPending = fxPending + 1 + return id +end +function ChipAudio.pollEffect() + if not fxCh then + return nil + end + local row = fxCh:pop() + while row and row.generation ~= fxGeneration do + row = fxCh:pop() + end + if row then + fxPending = math.max(0, fxPending - 1) + end + return row +end +function ChipAudio.cancelEffects() + fxGeneration = fxGeneration + 1 + fxPending = 0 + if fxCh then + fxCh:clear() + end + if workerReady and cmdCh then + cmdCh:push({ cmd = "fxCancel", generation = fxGeneration }) + end +end + -- If the worker died (a malformed def that errors mid-synth), fall back to the -- synchronous path for the rest of the session instead of going silent. local function workerAlive() @@ -306,7 +403,8 @@ local function playMusicSync(data, header, allowLoops, synthName) ChipAudio.stopMusic() currentMusic = { source = source, engine = engine, threaded = false, synth = synth, synthName = synthName, - started = true, finished = false } + started = true, finished = false , + } fillSync(MUSIC_FILL_INITIAL) if not musicHeld then source:play() end return source @@ -340,17 +438,20 @@ function ChipAudio.playMusic(data, header, allowLoops, synthName) musicGen = musicGen + 1 local gen = musicGen cmdCh:push({ cmd = "play", gen = gen, header = header, synth = synthName, - allowLoops = allowLoops, audio = slimAudio(data), + audioWorkerOptimization = require("src.core.Performance").audioWorkerOptimization ~= false, + allowLoops = allowLoops, audio = slimAudio(data), channelVolumes = ChipSynth.getChannelVolumes(), stereo = ChipSynth.getStereo(), - channelPitches = ChipSynth.getChannelPitches() }) + channelPitches = ChipSynth.getChannelPitches() , + }) currentMusic = { source = source, gen = gen, threaded = true, synth = synth, synthName = synthName, started = false, finished = false, -- kept so a worker that dies before delivering its first -- buffer can be recovered onto the sync path below def = { data = data, header = header, - allowLoops = allowLoops, synth = synthName } } + allowLoops = allowLoops, synth = synthName } , + } -- playback starts in update() once the first buffer arrives (~1 frame) return source end @@ -360,7 +461,8 @@ local function pushChannelMix() cmdCh:push({ cmd = "channelMix", volumes = ChipSynth.getChannelVolumes(), stereo = ChipSynth.getStereo(), - pitches = ChipSynth.getChannelPitches() }) + pitches = ChipSynth.getChannelPitches() , + }) end end @@ -435,6 +537,10 @@ local function updateThreaded() end function ChipAudio.update() + local enabled=Performance.audioWorkerOptimization~=false + if workerReady and cmdCh and workerPerformance~=enabled then + cmdCh:push({cmd="performance",enabled=enabled});workerPerformance=enabled + end local m = currentMusic if not m then return end -- INTERRUPTION: nothing is fed to the device while the OS holds the session @@ -573,6 +679,7 @@ end -- hot reload: the next play re-reads programs.bin (a mod may have swapped the -- file out from under the single-slot bank cache), on both threads function ChipAudio.invalidate() + ChipAudio.cancelEffects() ChipAudio.stopMusic() ChipSynth.invalidateBanks() if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end @@ -582,10 +689,11 @@ end -- process exits and the worker's command loop only returns on "quit", so -- skipping this leaves the process running after the window is gone (#339). function ChipAudio.shutdown() + ChipAudio.cancelEffects() ChipAudio.stopMusic() if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end if worker then pcall(function() worker:wait() end) end - worker, cmdCh, outCh = nil, nil, nil + worker, cmdCh, outCh , fxCh = nil, nil, nil, nil workerReady = false end @@ -645,10 +753,22 @@ end Assets.register(ChipAudio.invalidate) -- --------------------------------------------------------------------------- --- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources +-- One-shot source constructors retain their synchronous fallback contract. -- --------------------------------------------------------------------------- +local fallbackLogged = {} +local function effectFallback(kind) + if fallbackLogged[kind] then + return + end + fallbackLogged[kind] = true + require("src.core.Logger").info( + "chip audio: %s synchronous fallback (cold cache, worker unavailable, or prewarm disabled)", + kind + ) +end local function renderEffect(data, header, options) + effectFallback("chip effect") local sd = ChipSynth.renderEffectData(data, header, options) if not sd then return nil end return love.audio.newSource(sd, "static") @@ -668,6 +788,7 @@ end -- A Gen 3 sound effect: the same sequencer as the music, run once to its end -- and handed over as a static Source. function ChipAudio.newM4AEffect(data, def) + effectFallback("sampled effect") local songs = data.audio and data.audio.songs local song = songs and def and def.m4a ~= nil and songs[("SONG_%03X"):format(def.m4a)] @@ -684,6 +805,7 @@ function ChipAudio.newCry(data, species, resolved) -- names an index into the cartridge's cry table and is rendered by the -- sampler next door. Same seam, same Source, different generation. if cry.m4a then + effectFallback("sampled cry") local sd = synthFor("m4a").renderCry(data, cry.m4a) if not sd then return nil end return love.audio.newSource(sd, "static") diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index 3880081c6..8f82292ab 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -1072,17 +1072,7 @@ end -- is available on worker threads, so this is the hand-off unit the worker -- produces and the main thread queues. local function soundData(engine, samples, channels) - local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels) - for index = 0, samples - 1 do - if channels == 2 then - local left, right = engine:sampleStereo() - result:setSample(index, 1, left) - result:setSample(index, 2, right) - else - result:setSample(index, engine:sample()) - end - end - return result + return require('src.core.AudioPcm').render(engine,samples,channels,SAMPLE_RATE) end -- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when @@ -1115,13 +1105,7 @@ local function renderEffectData(data, header, options) values[count] = engine:sample() end if count < math.floor(SAMPLE_RATE / 100) then return nil end - local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2) - for index = 1, count do - local value = values[index] - result:setSample(index - 1, 1, value) - result:setSample(index - 1, 2, value) - end - return result + return require('src.core.AudioPcm').arrays(values,nil,count,SAMPLE_RATE) end ChipSynth.newEngine = Engine.new diff --git a/src/core/Data.lua b/src/core/Data.lua index 02984c906..359881c68 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -1893,6 +1893,7 @@ function Data:seedDefaults() end seedMissingGen2Tilesets(self) self:publishGen2Palettes() + require("src.compat.gen2.Decorations").useRegistry(self) ensureGen2WarpFallbacks(self) ensureGen2HomeTextFallbacks(self) end diff --git a/src/core/DiscordPresence.lua b/src/core/DiscordPresence.lua index cc21c85e5..f052a11f0 100644 --- a/src/core/DiscordPresence.lua +++ b/src/core/DiscordPresence.lua @@ -8,6 +8,7 @@ local Json = require("src.link.Json") local Logger = require("src.core.Logger") +local Performance = require("src.core.Performance") local Runtime = require("src.mods.Runtime") local Strings = require("src.core.Strings") @@ -478,6 +479,7 @@ local function activityKey(activity) end local function flush(force) + if Performance.extraCap("discordPresence")==false then return end if not state.enabled or not state.connected then return end local ok = pcall(function() local now = love.timer.getTime() @@ -717,6 +719,7 @@ function DiscordPresence.init(game) end subscribe(game) + if Performance.extraCap("discordPresence")==false then return end if connect() then flush(true) else @@ -734,6 +737,10 @@ function DiscordPresence.init(game) end function DiscordPresence.update(_dt) + if Performance.extraCap("discordPresence")==false then + if state.connected then closeSocket()end + return + end if not state.enabled then return end pcall(function() local now = love.timer.getTime() 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..dacc614ed 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 @@ -107,7 +120,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") @@ -377,24 +393,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 +624,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 +635,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 +651,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 +659,36 @@ 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.extraCap("discordPresence")~=false then + 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 + 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 +866,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 +957,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 +975,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 +1032,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 +1045,29 @@ 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 ScreenSpaceUI = require('src.render.ScreenSpaceUI') + local windowUI, windowUIIndex = ScreenSpaceUI.select(self.stack,self.stack,drawFrom) + local windowUIOverlay = false 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 +1149,10 @@ function Game:_draw() local ownsSurface = wideState or (native ~= nil and state == native) or Game.wantsThisSurface(state) - if state and state.draw then + if state == windowUI then + if renderVisible(self.stack,state)then love.graphics.clear(0,0,0,0)else windowUI=nil end + elseif renderVisible(self.stack,state) and state.draw then + if windowUIIndex and i > windowUIIndex then windowUIOverlay = true end -- ...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 +1173,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 +1186,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 +1240,22 @@ function Game:_draw() if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end - local viewport = Renderer:endFrame(zones, worldZones) + -- Keep shader controls readable even when a local shader paints black. + local top = self.stack:top() + Renderer.shaderPresetBypass = top and top.shaderPresetBypass or false + local viewport, privateViewport + if Performance.hotPathHoist and not windowUI and Renderer.endFrame == Renderer._nativeEndFrame then + viewport = Renderer:_endFrameForEngine(zones, worldZones) + privateViewport = true + else + viewport = Renderer:endFrame(zones, worldZones) + end + if windowUI then ScreenSpaceUI.draw(windowUI,Renderer,viewport,zones,windowUIOverlay) 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 +1275,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 +1296,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,8 +1438,16 @@ 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 == "f8" then + require("src.render.ShaderPresets"):off() + if self.save and self.save.options then + self.save.options.shaderPreset = "off" + self:writeOptions() + end + return + end if key == "f9" then return self:unstick() end if key == "f11" then return self:toggleFrameProfile() end if key == "f10" then return self:toggleModManager() end @@ -1492,6 +1579,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 @@ -1514,24 +1612,42 @@ end -- menus that used to call love.event.quit for the second case were killing -- the process instead, which is only the right answer on a build with no -- launcher behind it -- and HostShell decides that, not the menu. -function Game:returnToLauncher() +local function quitToLauncherBody() require("src.core.Music").stop() require("src.core.HostShell").toLauncher() end +function Game:returnToLauncher() + if ModRuntime.wantsHook("core.quit_to_launcher") then + return ModRuntime.call("core.quit_to_launcher", quitToLauncherBody) + end + return quitToLauncherBody() +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 +1660,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 +1712,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 @@ -1870,10 +2019,12 @@ function Game:applyOptions(opts) self.input:setButtonMode(opts.gen3ButtonMode or 1) end require("src.render.PaletteFX").applyOptions(opts) + require("src.render.GbcColorCorrection").applyOptions(opts) require("src.render.Tilt").applyOptions(opts) -- after Tilt, so a persisted world pipeline can switch the tilt level it -- just restored back off (the two are mutually exclusive) require("src.render.Pipelines").applyOptions(opts) + require("src.render.ShaderPresets"):applyOptions(opts) require("src.render.Zoom").applyOptions(opts) require("src.render.TileRenderer").applyOptions(opts) -- returns true when a persisted GBC FX level was cleared on mobile @@ -1893,6 +2044,10 @@ function Game:applyOptions(opts) -- default on a normal desktop, and every options.lua predating this -- option) clamps nothing, so it is a no-op for the common case. local caps = require("src.core.Performance").applyOptions(opts) + if Performance.extraCap("discordPresence")==false then + local presence=package.loaded["src.core.DiscordPresence"] + if presence and presence.update then pcall(presence.update,0)end + end if not caps.tilt then require("src.render.Tilt").setLevel(0) end if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end local Zoom = require("src.render.Zoom") diff --git a/src/core/GameVersion.lua b/src/core/GameVersion.lua index 330f4f3e9..9165d3fc2 100644 --- a/src/core/GameVersion.lua +++ b/src/core/GameVersion.lua @@ -1082,7 +1082,14 @@ GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver", "crystal", GameVersion.current = "red" function GameVersion.set(id) + local previous = GameVersion.current GameVersion.current = GameVersion.VERSIONS[id] and id or "red" + if previous ~= GameVersion.current then + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end + local decorations = package.loaded["src.compat.gen2.Decorations"] + if decorations then decorations.useRegistry(nil) end + end return GameVersion.current end diff --git a/src/core/Gen2Clock.lua b/src/core/Gen2Clock.lua new file mode 100644 index 000000000..f0f564663 --- /dev/null +++ b/src/core/Gen2Clock.lua @@ -0,0 +1,128 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared native GSC RTC reads. The public Clock spelling follows the +-- reference contract; no private mod implementation or clock is embedded. +local Clock = { MINUTES_PER_DAY = 1440, DAYS = 7, DEFAULT_HOUR = 10, DEFAULT_MINUTE = 0 } +local Strings = require("src.core.Strings") +Clock.DAY_NAMES = { "SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY" } +local observed = setmetatable({}, { __mode = "k" }) +local function integer(value) + local n = tonumber(value) + if n and n == n and n ~= math.huge and n ~= -math.huge then return math.floor(n) end +end +local function rtc(save) + return type(save) == "table" and type(save.rtc) == "table" and save.rtc or nil +end +function Clock.hostMinutes(now) + return ((integer(os.date("%H", now)) or 0) * 60 + (integer(os.date("%M", now)) or 0)) % 1440 +end +function Clock.hostWeekday(now) + return (integer(os.date("%w", now)) or 0) % 7 +end +-- Newly set clocks advance by elapsed RTC seconds, including while closed. +-- The old host-local offsets remain readable without migrating a save. Host +-- DST/time-zone changes must not silently change an explicitly set game clock. +local function anchored(state, now) + if not state then return nil end + local at, minute, day = integer(state.anchorTime), integer(state.anchorMinute), integer(state.anchorDay) + if not at or not minute or minute < 0 or minute >= 1440 or not day or day < 0 or day >= 7 then return nil end + now = integer(now or os.time()) + if not now then return nil end + return minute + math.floor((now - at) / 60), day +end +function Clock.minutes(save, now) + local state = rtc(save) + local total = anchored(state, now) + if total then return total % 1440 end + return (Clock.hostMinutes(now) + (state and integer(state.startMinute) or 0)) % 1440 +end +function Clock.hour(save, now) return math.floor(Clock.minutes(save, now) / 60) end +function Clock.minute(save, now) return Clock.minutes(save, now) % 60 end +function Clock.isSet(save) + local state = rtc(save) + return state ~= nil and integer(state.startMinute) ~= nil +end +function Clock.setTime(save, hour, minute) + if type(save) ~= "table" then return false end + hour, minute = integer(hour or 0), integer(minute or 0) + if not hour or not minute then return false end + local now = os.time() + local day, date = Clock.weekday(save, now), Clock.dateKey(save, now) + local wanted = (hour % 24) * 60 + minute % 60 + save.rtc = rtc(save) or {} + save.rtc.startMinute = (wanted - Clock.hostMinutes(now)) % 1440 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 + save.g2DayOffset = save.rtc.startDay + save.rtc.anchorTime, save.rtc.anchorMinute = now, wanted + save.rtc.anchorDay, save.rtc.anchorDate = day, date + return true +end +local function note(save, day, reason, now) + if type(save) ~= "table" then return day end + local Runtime = require("src.mods.Runtime") + if not require("src.core.GameVersion").isGen2() or not Runtime.wants("clock.day_changed") then + observed[save] = nil + return day + end + local previous, date = observed[save], Clock.dateKey(save, now) + observed[save] = { day=day, date=date } -- Commit before dispatch; reentrant reads are silent. + if previous ~= nil and (previous.day ~= day or previous.date ~= date) then + Runtime.emit("clock.day_changed", { day = day, previous = previous.day, reason = reason }) + end + return day +end +function Clock.weekday(save, now) + local state = rtc(save) + local total, day = anchored(state, now) + if total then return note(save, (day + math.floor(total / 1440)) % 7, "rollover", now) end + local offset = state and integer(state.startDay) + if offset == nil then offset = type(save) == "table" and integer(save.g2DayOffset) or 0 end + return note(save, (Clock.hostWeekday(now) + (offset or 0)) % 7, "rollover", now) +end +function Clock.setWeekday(save, day) + day = integer(day) + if type(save) ~= "table" or not day then return false end + local now = os.time() + save.rtc = rtc(save) or {} + day = day % 7 + save.rtc.startDay = (day - Clock.hostWeekday(now)) % 7 + save.rtc.dayOfWeek = day + local total = anchored(save.rtc, now) + if total then save.rtc.anchorDay = (day - math.floor(total / 1440)) % 7 end + -- Keep the existing native field in sync for older native consumers/saves. + save.g2DayOffset = save.rtc.startDay + note(save, day, "set", now) + return true +end +-- Calendar identity for daily/weekly timers. Only date arithmetic uses the +-- host calendar library; elapsed game days come from the same RTC as minutes +-- and weekday. Noon normalization avoids missing/duplicated local DST hours. +function Clock.calendarDate(save, now) + local state = rtc(save) + local total = anchored(state, now) + local year, month, day + if total and type(state.anchorDate) == "string" then + year, month, day = state.anchorDate:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") + year, month, day = tonumber(year), tonumber(month), tonumber(day) + end + if year and month >= 1 and month <= 12 and day >= 1 and day <= 31 then + local ok, stamp = pcall(os.time, { year=year, month=month, + day=day + math.floor(total / 1440), hour=12, min=0, sec=0, isdst=nil }) + if ok and stamp then return os.date("*t", stamp) end + end + return os.date("*t", now) +end +function Clock.dateKey(save, now) + local date = Clock.calendarDate(save, now) + return ("%04d-%02d-%02d"):format(date.year, date.month, date.day) +end +function Clock.weekdayName(day) + local name = Clock.DAY_NAMES[day] + return name and Strings(name) +end +function Clock.daytimeLabel(hour) + hour = integer(hour) or Clock.hour() + hour = hour % 24 + return Strings(hour < 4 and "NITE" or hour < 10 and "MORN" or hour < 18 and "DAY" or "NITE") +end +function Clock.resetObservations() observed = setmetatable({}, { __mode = "k" }) end +return Clock 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/M4ASynth.lua b/src/core/M4ASynth.lua index 7dfa83025..854167bbc 100644 --- a/src/core/M4ASynth.lua +++ b/src/core/M4ASynth.lua @@ -962,6 +962,8 @@ function M4A.renderCry(data, index, options) -- OpenAL spatializes a mono Source at the listener and spreads it over -- every output the device has local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2) + local Pcm=require("src.core.AudioPcm") + local pointer=Pcm.pointer(result) local gain = (options.volume or 1) * CRY_GAIN local frame, left = 0, 0 for i = 0, samples - 1 do @@ -972,8 +974,12 @@ function M4A.renderCry(data, index, options) frame = frame - 1 local value = voice:sample() * (voice.env / 255) * gain if value > 1 then value = 1 elseif value < -1 then value = -1 end - result:setSample(i, 1, value) - result:setSample(i, 2, value) + if pointer then + local sample=Pcm.sample(value);pointer[i*2],pointer[i*2+1]=sample,sample + else + result:setSample(i, 1, value) + result:setSample(i, 2, value) + end left = value end local _ = left @@ -1016,28 +1022,13 @@ function M4A.renderEffect(data, def, options) -- and a Source that is mostly silence blocks a caller waiting on it local length = math.min(n, audible + math.floor(SAMPLE_RATE * 0.05)) if length < 64 then return nil end - local result = love.sound.newSoundData(length, SAMPLE_RATE, 16, 2) - for i = 1, length do - result:setSample(i - 1, 1, left[i]) - result:setSample(i - 1, 2, right[i]) - end - return result + return require('src.core.AudioPcm').arrays(left,right,length,SAMPLE_RATE) end -- The same shape ChipSynth hands ChipAudio, so the queueing path does not -- have to know which generation it is filling buffers for. function M4A.soundData(engine, samples, channels) - local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels) - for index = 0, samples - 1 do - if channels == 2 then - local left, right = engine:sampleStereo() - result:setSample(index, 1, left) - result:setSample(index, 2, right) - else - result:setSample(index, engine:sample()) - end - end - return result + return require('src.core.AudioPcm').render(engine,samples,channels,SAMPLE_RATE) end return M4A diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..8196513e5 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -30,8 +30,6 @@ -- src.world.gen2.FieldMoves field-move entry points live in -- src/script/* and the fieldmove.eligibility -- hook, not one module --- src.world.gen2.Follower PikachuFollower is Yellow's, not a general --- party follower -- src.ui.gen2.Chrome no shared window-chrome module -- src.ui.gen2.SaveMenu the save flow is part of StartMenu here -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view @@ -63,10 +61,17 @@ ModCompat.ALIASES = { ["src.world.gen2.World"] = "src.world.OverworldController", ["src.world.gen2.Map"] = "src.world.Map", ["src.world.gen2.Permissions"] = "src.world.Collision", + -- Native optional companion, with Gen2 predicate and lifecycle lookup. + ["src.world.gen2.Follower"] = "src.world.OptionalFollower", ["src.world.gen2.Palettes"] = "src.render.PaletteFX", -- save-side data ["src.core.gen2.Boxes"] = "src.pokemon.Boxes", + ["src.core.gen2.Clock"] = "src.core.Gen2Clock", + ["src.ui.gen2.MapRadio"] = "src.ui.Gen2MapRadio", + -- Explicit signature adapter: native operations take (data, save). + ["src.core.gen2.Decorations"] = "src.compat.gen2.Decorations", + ["src.core.gen2.Unown"] = "src.compat.gen2.Unown", ["src.core.gen2.BugContest"] = "src.world.BugContest", ["src.core.gen2.ItemEffects"] = "src.inventory.ItemEffects", ["src.core.gen2.Save"] = "src.core.SaveData", @@ -100,6 +105,13 @@ ModCompat.ALIASES = { ["src.render.GbcPalette"] = "src.render.GBCFX", } +-- Extend the data vocabulary without changing loader permissions or public +-- module objects. Existing aliases retain their native identities. +local Compatibility = require("src.mods.Gen1Compat") +for name, target in pairs(Compatibility.ALIASES) do + if ModCompat.ALIASES[name] == nil then ModCompat.ALIASES[name] = target end +end + local seen = {} local installed = false @@ -107,6 +119,8 @@ local installed = false -- matters because "the target is missing too" is a build problem, not a mod -- problem, and should not be reported as an unknown module. function ModCompat.resolve(name) + local missing = Compatibility.unavailable(name) + if missing then return nil, missing end local target = ModCompat.ALIASES[name] if not target then return nil, nil end local ok, value = pcall(require, target) @@ -128,7 +142,23 @@ function ModCompat.install() if installed then return false end local loaders = package.loaders or package.searchers if type(loaders) ~= "table" then return false end - loaders[#loaders + 1] = function(name) + local searcher + searcher = function(name) + local missing = Compatibility.unavailable(name) + if missing then + -- A later host/plugin provider still outranks an unsupported descriptor. + local following = false + for _, candidate in ipairs(loaders) do + if candidate == searcher then following = true + elseif following then + local provider, origin = candidate(name) + if type(provider) == "function" then return provider, origin end + end + end + -- Raise before require installs its in-progress sentinel. Retrying the + -- import must retain the diagnostic instead of reporting a fake loop. + error(missing, 0) + end if not ModCompat.ALIASES[name] then -- the standard "not found" contract: a string the require error joins return "\n\tno mod-compat alias for '" .. tostring(name) .. "'" @@ -142,6 +172,7 @@ function ModCompat.install() return value end end + loaders[#loaders + 1] = searcher installed = true return true end diff --git a/src/core/Music.lua b/src/core/Music.lua index e5b95baf6..f188fffd0 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -380,6 +380,16 @@ function Music.current() return state.current end +-- Original contribution by Roxas2712; see the offered PR's contribution +-- permission. Radio ownership changes the restorable cue, not playback. +function Music.mapSong() + return state.mapSong +end + +function Music.setMapSong(song) + state.mapSong = song +end + function Music.stop() local previous = state.current stopSource(state.source) @@ -676,6 +686,7 @@ end -- call once per frame: chains a finished intro into its loop body and -- restores the map theme after a one-shot jingle +local prewarmSound function Music.update(data) if data and data.audio and data.audio.ndsArchive then require("src.audio.NitroAudio").update() end -- INTERRUPTION: the OS holds the audio session (an incoming call), every @@ -683,6 +694,8 @@ function Music.update(data) -- in here is worth doing against a device that is not there -- and the -- ChipAudio.update on the next line is the call that crashed. if sessionSuspended then return end + if not prewarmSound then prewarmSound=require("src.core.Sound") end + if prewarmSound.update then prewarmSound.update(data) end -- The other half of the same case: the re-cue in Music.resume can land -- before the OS has handed the audio device back, and nothing else re-cues -- a map theme that never stopped being the map theme. So the retry is diff --git a/src/core/Performance.lua b/src/core/Performance.lua index 861121ddd..d150a76b7 100644 --- a/src/core/Performance.lua +++ b/src/core/Performance.lua @@ -42,6 +42,23 @@ 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 +-- P5: palette upload batching and cropped zone quads. +Performance.paletteZoneCache = true +-- P6: PCM bulk writes, speculative effect prewarming and DS worker transport. +Performance.audioWorkerOptimization = true +-- P9: additional LOW presentation caps, pixel-area AUTO and native UI quads. +Performance.lowTierBudget = 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 +77,16 @@ Performance.CAPS = { balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false }, low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 }, } +for name, caps in pairs(Performance.CAPS) do + caps.shaderPresets = name~="low" + caps.colorCorrection = name~="low" + caps.gen3PaletteCycle = name=="low" and "quarter" or "full" + caps.reflections = name~="low" + caps.discordPresence = name~="low" + 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 @@ -81,7 +108,8 @@ local function processorCount() end -- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on --- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a +-- phones, Apple Silicon Macs and PortMaster handhelds; "x86"/"x64" on +-- Intel/AMD desktops. nil under a -- plain-Lua tool or test with no jit table, which resolves to HIGH (no -- clamping) so tooling is never surprised. local function cpuArch() @@ -94,26 +122,40 @@ end -- multi-core desktop -- the common case, and every existing install whose -- options.lua predates this option -- resolves to HIGH and behaves exactly -- as before. +Performance.AUTO_DESKTOP_PIXELS = 3840 * 2160 +Performance.AUTO_MOBILE_PIXELS = 1920 * 1080 +local function pixelArea() + if not Performance.lowTierBudget then return nil end + local graphics=love and love.graphics + if not (graphics and graphics.getPixelDimensions)then return nil end + local ok,w,h=pcall(graphics.getPixelDimensions) + if ok and type(w)=="number" and type(h)=="number" and w>0 and h>0 + and wPerformance.AUTO_MOBILE_PIXELS then return "low"end return "balanced" end -- Desktop: a dual-core (or single-core) box is the low-end line. if cores and cores <= 2 then + if pixels and pixels>1280*720 then return "low"end return "balanced" end + if pixels and pixels>=Performance.AUTO_DESKTOP_PIXELS then return "balanced"end return "high" end @@ -168,4 +210,13 @@ function Performance.cycle(value, dir) return Performance.TIERS[nextIdx] end +-- Added presentation caps use the live tier without changing the stored choices. +function Performance.extraCap(name) + if not Performance.lowTierBudget then return nil end + return (Performance.CAPS[Performance.tier] or Performance.CAPS.high)[name] +end +function Performance.paletteTick(tick) + if Performance.extraCap("gen3PaletteCycle")=="quarter" then return math.floor(tick/4)*4 end + return tick +end return Performance 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/PlaythroughIdentity.lua b/src/core/PlaythroughIdentity.lua new file mode 100644 index 000000000..8aac1f53e --- /dev/null +++ b/src/core/PlaythroughIdentity.lua @@ -0,0 +1,194 @@ +-- Opaque storage identity. Never consumes gameplay RNG or rewrites progress +-- merely because a mod requests a namespace. Slot routing is engine-owned. +local Version = require("src.core.GameVersion") +local Serializer = require("src.core.SaveSerializer") +local copy = require("src.mods.Merge").deepCopy +local M = {} +local fresh = setmetatable({}, { __mode = "k" }) +local sequence = 0 +local function saves() + return require("src.core.SaveData") +end +local function valid(id) + return type(id) == "string" and id:match("^[%w_-]+$") ~= nil +end +function M.markFresh(save) + if type(save) == "table" then + fresh[save] = true + end +end +function M.newId() + sequence = sequence + 1 + local address = tostring({}):match("0x(%x+)") or "0" + local function word(n) + return math.floor(tonumber(n) or 0) % 4294967296 + end + return ("%08x%08x%08x%08x"):format( + word(os.time()), + word(os.clock() * 1000000), + word(tonumber(address:sub(-8), 16)), + word(sequence) + ) +end +local function routing(version, fs) + local opts = saves().loadOptions(fs) + local reg = opts.saveSlots and opts.saveSlots[version] + local slot = saves()._storageSlot(version, fs) + or (reg and (reg.active or (reg.list and reg.list[1]))) + or "legacy" + if not valid(slot) then + slot = "legacy" + end + local ids = opts.playthroughIds and opts.playthroughIds[version] + return opts, slot, ids and ids[slot] +end +local function normalSave(version, slot, fs) + local main = slot == "legacy" and ("save" .. Version.saveSuffix(version) .. ".lua") + or ("saves/" .. version .. "/" .. slot .. ".lua") + for _, path in ipairs({ main, main .. ".tmp", main .. ".bak" }) do + local body = fs.read(path) + local data = type(body) == "string" and Serializer.decode(body) + if type(data) == "table" and data.version == version then + return data + end + end +end +local function bind(opts, version, slot, id, fs) + opts.playthroughIds = opts.playthroughIds or {} + opts.playthroughIds[version] = opts.playthroughIds[version] or {} + if opts.playthroughIds[version][slot] == id then + return true + end + opts.playthroughIds[version][slot] = id + return saves().saveOptions(opts, fs) ~= nil +end +function M.ensure(save, fs) + if type(save) ~= "table" or not Version.info(save.version) then + return nil + end + save.meta = type(save.meta) == "table" and save.meta or {} + if valid(save.meta.playthroughId) then + return save.meta.playthroughId + end + local opts, slot, mapped = routing(save.version, fs) + local id = not fresh[save] and valid(mapped) and mapped or M.newId() + -- Boot/New Game skeletons must not strand an already-bound real save. + if not (fresh[save] and valid(mapped)) and not bind(opts, save.version, slot, id, fs) then + return nil + end + save.meta.playthroughId = id + return id +end +function M.inherit(save, fs) + if type(save) ~= "table" or fresh[save] or not Version.info(save.version) then + return + end + if save.meta and valid(save.meta.playthroughId) then + return + end + local _, _, id = routing(save.version, fs) + if valid(id) then + save.meta = type(save.meta) == "table" and save.meta or {} + save.meta.playthroughId = id + end +end +function M.selected(save, fs) + if type(save) ~= "table" then + return nil, "not_in_playthrough", "No selected playthrough is available." + end + if not Version.info(save.version) then + return nil, "unknown_game", "The selected game version is unavailable." + end + local id = save.meta and save.meta.playthroughId + if not fresh[save] and valid(id) then + return id + end + local _, slot, mapped = routing(save.version, fs) + -- A committed normal save is authoritative even if its options update + -- failed, or the registry was recovered from an older backup. + local normal = normalSave(save.version, slot, fs) + local stamped = normal and normal.meta and normal.meta.playthroughId + if valid(stamped) then + return stamped + end + if valid(mapped) then + return mapped + end + return nil, "no_selected_playthrough", "The selected playthrough has no durable tool state." +end +function M.remember(save, fs, slot, version) + local id = save.meta and save.meta.playthroughId + if not valid(id) then + fresh[save] = nil + return true + end + version = version or save.version + local opts, active = routing(version, fs) + local ok = bind(opts, version, slot or active, id, fs) + if ok then + fresh[save] = nil + if save.options then + save.options.playthroughIds = copy(opts.playthroughIds) + end + end + return ok +end +function M.preserveOptions(opts, fs) + local stored = saves().loadOptions(fs) + -- A live options snapshot can predate lazy legacy-to-slot promotion. + opts.playthroughIds = copy(stored.playthroughIds) + opts.saveSlots = copy(stored.saveSlots) +end +function M.prepareSlot(save, version, slot, fs) + local id = save.meta and save.meta.playthroughId + if not valid(id) then + return save + end + local opts = saves().loadOptions(fs) + local ids = opts.playthroughIds and opts.playthroughIds[version] + local collision = false + for other, used in pairs(ids or {}) do + if other ~= slot and used == id then + collision = true + end + end + local reg = opts.saveSlots and opts.saveSlots[version] + for _, other in ipairs(reg and reg.list or {}) do + if other ~= slot then + local base = "saves/" .. version .. "/" .. other .. ".lua" + for _, path in ipairs({ base, base .. ".tmp", base .. ".bak" }) do + local bytes = fs.read(path) + local data = type(bytes) == "string" and Serializer.decode(bytes) + if type(data) == "table" and data.meta and data.meta.playthroughId == id then + collision = true + end + end + end + end + if collision then + save = copy(save) + save.meta.playthroughId = M.newId() + end + return save +end +function M.normalInfo(save, fs) + local id, code, message = M.selected(save, fs) + if not id then + return nil, code, message + end + local _, slot = routing(save.version, fs) + local normal = normalSave(save.version, slot, fs) + if + not normal + or normal.version ~= save.version + or (normal.meta and normal.meta.playthroughId and normal.meta.playthroughId ~= id) + then + return { exists = false } + end + local at = normal.meta and normal.meta.savedAt + if type(at) ~= "number" or at < 0 or at ~= at or at == math.huge then + at = nil + end + return { exists = true, savedAt = at } +end +return M diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2bfe53530..bfd64ae84 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -404,8 +404,8 @@ end -- Public seam for mods (Stadium import, HD zip install, custom pickers). -- Same routing as options/saves: injected fs, then portable.io, then LOVE. -function SaveData.persistenceFs() - return persistFs(love and love.filesystem) +function SaveData.persistenceFs(injectedFs) + return persistFs(injectedFs or (love and love.filesystem)) end -- Port + original Options menu defaults. Missing keys on load are filled @@ -459,6 +459,8 @@ function SaveData.defaultOptions() colors = "gbc", tilt = 0, gbcfx = 0, + -- Optional GBC display response, separate from the LCD grid/glare effects. + gbcColorCorrection = "off", -- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua zoom = 0, -- OVERWORLD beyond-edge fill: trees | water | black @@ -597,6 +599,12 @@ function SaveData.saveOptions(opts, fs, gen) -- write cannot clobber another mod's persisted keys. Every other -- option stays on the shallow path. local onDisk = readTable(fs, OPTIONS_FILENAME) + -- A pre-storage UI/settings snapshot has no identity map. Saving it must + -- not erase tool state allocated since that snapshot was taken. Engine + -- routing changes supply a table explicitly (including an empty table). + if onDisk and opts.playthroughIds == nil and type(onDisk.playthroughIds) == "table" then + opts.playthroughIds = onDisk.playthroughIds + end if onDisk and type(onDisk.modOptions) == "table" then local merged = {} for modId, bucket in pairs(onDisk.modOptions) do @@ -853,6 +861,8 @@ local function tryMigrateLegacy(version, fs) local opts = SaveData.loadOptions(fs) opts.saveSlots = opts.saveSlots or {} opts.saveSlots[version] = { list = { id }, active = id } + local ids = opts.playthroughIds and opts.playthroughIds[version] + if ids and ids.legacy then ids[id], ids.legacy = ids.legacy, nil end SaveData.saveOptions(opts, fs) return id end @@ -1131,11 +1141,14 @@ function SaveData.writeSlot(version, slotId, saveTable) if type(slotId) ~= "string" then return false, "missing slot id" end if type(saveTable) ~= "table" then return false, "missing save table" end local main, bak = slotNames(version, slotId) - local encoded = SaveSerializer.encode(saveTable) local fs = persistFs(nil) + saveTable = require("src.core.PlaythroughIdentity").prepareSlot(saveTable, version, slotId, fs) + local encoded = SaveSerializer.encode(saveTable) ensureParentDir(fs, main) -- atomic, previous good copy kept (SaveData.commitSave) - return SaveData.commitSave(fs, main, bak, encoded, saveTable) + local ok, err = SaveData.commitSave(fs, main, bak, encoded, saveTable) + if ok then require("src.core.PlaythroughIdentity").remember(saveTable, fs, slotId, version) end + return ok, err end -- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the @@ -1174,6 +1187,8 @@ function SaveData.deleteSlot(version, slotId) end end + local ids = opts.playthroughIds and opts.playthroughIds[version] + if ids then ids[slotId] = nil end table.remove(reg.list, idx) if reg.names then reg.names[slotId] = nil end if reg.active == slotId then @@ -1194,6 +1209,14 @@ function SaveData.resetSlotState() for k in pairs(slotsChecked) do slotsChecked[k] = nil end end +-- Internal identity routing follows this process's resolved save destination. +-- A different launcher window can change options without moving this game. +function SaveData._storageSlot(version, fs) + if fs == persistFs(nil) and slotsChecked[version] then + return activeSlotCache[version] or "legacy" + end +end + -- ------- meta -- the version/engine/mod-set stamp every v2 save carries; mods is the @@ -1216,6 +1239,7 @@ function SaveData.buildMeta(mods, previous) engine = Version.engine, savedAt = os.time(), mods = list, + playthroughId = type(previous) == "table" and previous.playthroughId or nil, } end @@ -1719,7 +1743,11 @@ function SaveData.save(data, mods) -- write to the file matching this save's own version, not just the active -- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua local FILENAME, BACKUP_FILENAME = saveNames(data.version) + local identity = require("src.core.PlaythroughIdentity") + local fs = persistFs(nil) + identity.inherit(data, fs) if data.options then + identity.preserveOptions(data.options, fs) SaveData.saveOptions(data.options) end if mods ~= nil or data.meta == nil then @@ -1732,7 +1760,6 @@ function SaveData.save(data, mods) end return SaveSerializer.encode(gameOnly) end - local fs = persistFs(nil) -- the active slot may live in saves//, which must exist before -- the .tmp/.bak/main writes land (a no-op for the flat legacy path) ensureParentDir(fs, FILENAME) @@ -1741,6 +1768,7 @@ function SaveData.save(data, mods) Logger.error("save failed: %s", tostring(err)) return false end + identity.remember(data, fs) markOpen(fs, FILENAME) Logger.info("saved game") return true @@ -1802,6 +1830,7 @@ function SaveData.load(version) loadedWalkerKeys[FILENAME] = SaveSafety.walkerKeys(data) markOpen(fs, FILENAME) SaveData.runMigrations(data) + require("src.core.PlaythroughIdentity").inherit(data, fs) data.options = SaveData.optionsFor(version) Logger.info("loaded save") return data, recovered @@ -2570,7 +2599,20 @@ function SaveData.newGame(boot) -- -- Callers that ARE starting a playthrough pass nothing and are unaffected. if boot.noHooks then return save end - return Runtime.call("save.new_game", function(s) return s end, save) + save = Runtime.call("save.new_game", function(s) return s end, save) + require("src.core.PlaythroughIdentity").markFresh(save) + return save +end + +-- Engine-owned persistence scope used by the portable storage facade. +function SaveData.ensurePlaythroughId(save, fs) + return require("src.core.PlaythroughIdentity").ensure(save, persistFs(fs)) +end +function SaveData.selectedPlaythroughId(save, fs) + return require("src.core.PlaythroughIdentity").selected(save, persistFs(fs)) +end +function SaveData.selectedNormalSaveInfo(save, fs) + return require("src.core.PlaythroughIdentity").normalInfo(save, persistFs(fs)) end return SaveData diff --git a/src/core/Sound.lua b/src/core/Sound.lua index 7093a32f5..c3fce19dc 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -9,9 +9,99 @@ local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") +local Performance = require("src.core.Performance") + local Sound = {} local cache = {} +-- Speculative sources share this cache, but remain revocable until first use. +-- Public definitions and Source objects never acquire private cache fields. +local warmed, pending = {}, {} +local warmBytes, warmAudio, warmCursor, warmKind = 0, nil, nil, "sfx" +local WARM_LIMIT = 16 * 1024 * 1024 +local ChipAudio +local function chipAudio() + if not ChipAudio then + ChipAudio = require("src.core.ChipAudio") + end + return ChipAudio +end +local function copy(value, seen) + if type(value) ~= "table" then + assert( + type(value) == "nil" or type(value) == "string" or type(value) == "number" or type(value) == "boolean", + "custom audio definition requires synchronous fallback" + ) + return value + end + assert(getmetatable(value) == nil, "custom audio definition requires synchronous fallback") + seen = seen or {} + assert(not seen[value], "cyclic audio definition requires synchronous fallback") + local out = {} + seen[value] = out + for k, v in pairs(value) do + out[k] = copy(v, seen) + end + seen[value] = nil + return out +end +local function same(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 same(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 forgetWarm(key, drop) + local row = warmed[key] + if not row then + return + end + warmed[key] = nil + warmBytes = warmBytes - row.bytes + if drop and cache[key] == row.source then + cache[key] = nil + if row.source.release then + pcall(row.source.release, row.source) + end + end +end +local function useWarm(data, key, kind, def, pitch, tempo) + local row = warmed[key] + if not row then + return + end + local spec = chipAudio().effectSpec(data, kind, def, pitch, tempo) + local valid = Performance.audioWorkerOptimization ~= false and same(row.spec, spec) + forgetWarm(key, not valid) +end +local function resetWarm() + for key in pairs(warmed) do + forgetWarm(key, true) + end + pending = {} + warmAudio, warmCursor, warmKind = nil, nil, "sfx" + if ChipAudio then + ChipAudio.cancelEffects() + end +end -- port addition: 0-7 SFX volume from save.options.sfxVol (OptionsMenu), -- scaling the 0.8 base every source gets local BASE_VOLUME = 0.8 @@ -231,6 +321,7 @@ local function playPath(data, key, def, pitch, tempo) -- INTERRUPTION: building a Source now would fail and cache `false` below, -- permanently disabling this effect (see sessionSuspended, case 2) if sessionSuspended then return nil end + useWarm(data, key, "sfx", def, pitch, tempo) local src = cache[key] if src == false then return nil end -- known bad, already logged if not src then @@ -394,7 +485,7 @@ function Sound.seDef(data, key) local direct = nativeDef(sfx[key]) if direct or type(key) ~= "string" then return direct end local base, n = key:match("^(.-)_sseq_(%d+)$") - if not base then base = key:match("^(.-)_sseq$"); n = base and 0 end + if not base then base = key:match("^(.-)_sseq$")n = base and 0 end if not base then return nil end n = tonumber(n) local anchor = nativeDef(sfx[base]) @@ -539,6 +630,95 @@ local function resolveCry(data, def, depth) } end +-- Poll and schedule one bounded job per frame. No prewarm operation plays a +-- Source, ducks music, or emits sound.played. A changed def/mix is rechecked +-- on first use, including in-place edits and derived cry chains. +function Sound.update(data) + if sessionSuspended or not love.audio then + return + end + if Performance.audioWorkerOptimization == false then + if warmAudio then + resetWarm() + end + return + end + local audio = data and data.audio + if not audio then + return + end + if warmAudio ~= audio then + resetWarm() + warmAudio = audio + end + local chip = chipAudio() + local row = chip.pollEffect() + while row do + local job = pending[row.id] + pending[row.id] = nil + if job and row.sd and cache[job.key] == nil and warmBytes + row.sd:getSize() <= WARM_LIMIT then + local ok, source = pcall(love.audio.newSource, row.sd, "static") + if ok and source then + safeAudio("Source:setVolume", source.setVolume, source, volumeFor(job.key)) + cache[job.key] = source + warmed[job.key] = { source = source, spec = job.spec, bytes = row.sd:getSize() } + warmBytes = warmBytes + row.sd:getSize() + end + end + row = chip.pollEffect() + end + local count = 0 + for _ in pairs(pending) do + count = count + 1 + end + if count >= 2 or warmBytes >= WARM_LIMIT or warmKind == "done" then + return + end + -- Skip a handful of unsupported/file/already-cached definitions without + -- walking an unbounded registry during a frame. + for _ = 1, 8 do + local defs = audio[warmKind] or {} + local ok, name, def = pcall(next, defs, warmCursor) + if not ok then + warmCursor = nil + return + end + warmCursor = name + if not name then + if warmKind == "sfx" then + warmKind = "cries" + else + warmKind = "done" + return + end + else + local kind = warmKind == "cries" and "cry" or "sfx" + local key = kind == "cry" and "cry:" .. tostring(name) or tostring(name) + local resolved = kind == "cry" and resolveCry(data, def, 0) or def + if + cache[key] == nil + and type(resolved) == "table" + and (kind == "cry" or isChipDef(resolved) or resolved.m4a) + then + local spec = chip.effectSpec(data, kind, resolved) + if spec then + local plain, frozen = pcall(copy, spec) + if plain then + local id = chip.requestEffect(frozen) + if id then + pending[id] = { key = key, spec = frozen } + else + -- No worker/capability: playback retains its logged cold path. + warmKind = "done" + end + return + end + end + end + end + end +end + local function newCrySource(data, species, def) -- A GEN 3 CRY names a recording rather than a chip program, so there is no -- base chain to resolve and no header to look up: the index IS the def. @@ -614,6 +794,7 @@ function Sound.playCry(data, species, opts) end if not def then return nil end local key = "cry:" .. tostring(species) + useWarm(data, key, "cry", resolveCry(data, def, 0)) local src = cache[key] if src == false then return nil end if not src then @@ -686,9 +867,9 @@ end function Sound.anyPlaying(kind) for key,src in pairs(cache) do - local cry=tostring(key):sub(1,4)=='cry:' - if src and ((kind=='cry' and cry) or (kind=='sfx' and not cry)) then - local ok,playing=pcall(src.isPlaying,src);if ok and playing then return true end + local cry=tostring(key):sub(1,4)=="cry:" + if src and ((kind=="cry" and cry) or (kind=="sfx" and not cry)) then + local ok,playing=pcall(src.isPlaying,src)if ok and playing then return true end end end return false @@ -781,6 +962,7 @@ end -- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo -- variants included) or all of them, so the next play re-resolves the def function Sound.invalidate(name) + resetWarm() local function evict(store, key) local src = store[key] if src then pcall(src.stop, src) end @@ -813,6 +995,7 @@ Assets.register(Sound.invalidate) function Sound.suspend() if sessionSuspended then return end sessionSuspended = true + resetWarm() -- A LOOPING source (the low-health siren, the link tournament theme) is the -- only kind that is still sounding when the call arrives. Ask it to stop -- while the device may still take the call, and forget it, so its owner's diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index baf622c4c..00e8f8390 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -172,26 +172,48 @@ 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) + for i = #states, 1, -1 do + if 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 + for i = opaqueBase(self.states), #self.states - 1 do local state = self.states[i] if state.animate then state:animate(dt) end end end --- index of the lowest state drawn this frame (highest opaque, else 1) +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. +function StateStack:renderVisible(state) + if not state then return false 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 visible opaque, else 1) function StateStack:visibleBase() + if not Runtime.wantsHook("screen.render_visible") then return opaqueBase(self.states) 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 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 state = self.states[i] + if self:renderVisible(state) 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/chip_worker.lua b/src/core/chip_worker.lua index 73f6b188d..c970a460f 100644 --- a/src/core/chip_worker.lua +++ b/src/core/chip_worker.lua @@ -20,10 +20,26 @@ require("love.thread") require("love.timer") require("love.sound") +require("love.data") require("love.filesystem") -package.preload['src.core.WorkerFs']=function() - return assert(love.filesystem.load('src/core/WorkerFs.lua'))() +package.preload["src.core.WorkerFs"]=function() + return assert(love.filesystem.load("src/core/WorkerFs.lua"))() end +for _, name in ipairs({ "Performance", "AudioPcm" }) do + local module = name + package.preload["src.core." .. module] = function() + return assert(love.filesystem.load("src/core/" .. module .. ".lua"))() + end +end + +for _,path in ipairs({"src.core.FrameProfile","src.core.Logger","src.import.Gen4Sdat", + "src.audio.NitroSequence","src.audio.NitroTransport","src.audio.NitroAudio","src.audio.NitroWorker"})do + local name=path + package.preload[name]=function() + return assert(love.filesystem.load(name:gsub("%.","/")..".lua"))() + end +end +local Nitro -- Load the synth explicitly via love.filesystem (a fresh thread Lua state does -- not necessarily carry the package searcher that resolves "src.core..."): @@ -51,6 +67,35 @@ end local cmdCh = love.thread.getChannel("chipaudio_cmd") local outCh = love.thread.getChannel("chipaudio_out") +local fxCh = love.thread.getChannel("chipaudio_fx") +local fxJobs, fxGeneration = {}, 1 +local function renderFx(job) + local spec = job.spec + local perf=require("src.core.Performance") + local priorOptimization=perf.audioWorkerOptimization + local volumes, pitches, stereo = ChipSynth.getChannelVolumes(), ChipSynth.getChannelPitches(), ChipSynth.getStereo() + ChipSynth.setChannelVolumes(spec.volumes) + ChipSynth.setChannelPitches(spec.pitches) + ChipSynth.setStereo(spec.stereo) + require("src.core.Performance").audioWorkerOptimization = job.audioWorkerOptimization ~= false + local ok, sd = pcall(function() + local input = { audio = spec.audio } + if spec.kind == "chip" then + return ChipSynth.renderEffectData(input, spec.header, spec.options) + elseif spec.kind == "m4a" then + return synthFor("m4a").renderEffect(input, spec.header) + elseif spec.kind == "cry" then + return synthFor("m4a").renderCry(input, spec.index) + end + error("unknown effect job") + end) + perf.audioWorkerOptimization=priorOptimization + ChipSynth.setChannelVolumes(volumes) + ChipSynth.setChannelPitches(pitches) + ChipSynth.setStereo(stereo) + fxCh:push({ generation = job.generation, id = job.id, sd = ok and sd or nil, error = not ok and tostring(sd) or nil }) +end + local BUF = ChipSynth.MUSIC_BUFFER_SAMPLES -- how many finished buffers may sit in the hand-off channel before the worker -- pauses. The deep (~6s) playback depth lives in the main-thread Source; this @@ -64,7 +109,24 @@ local finished = false -- the current song ran out (non-looping) local data = nil -- { audio = } for ROM bank/wave reads local function handle(cmd) - if cmd.cmd == "play" then + if cmd.cmd == "performance" then + require("src.core.Performance").audioWorkerOptimization=cmd.enabled + elseif cmd.cmd == "nitro" then + if not Nitro then Nitro=require("src.audio.NitroWorker") end + local ok,why=pcall(Nitro.command,cmd) + if not ok and cmd.id then + love.thread.getChannel("chipaudio_nds_"..cmd.id):push({gen=cmd.gen,error=tostring(why)}) + end + elseif cmd.cmd == "fx" then + if cmd.generation == fxGeneration and #fxJobs < 2 then + fxJobs[#fxJobs + 1] = cmd + end + elseif cmd.cmd == "fxCancel" then + fxGeneration = cmd.generation + fxJobs = {} + fxCh:clear() + elseif cmd.cmd == "play" then + require("src.core.Performance").audioWorkerOptimization = cmd.audioWorkerOptimization ~= false ChipSynth.setStereo(cmd.stereo) gen = cmd.gen finished = false @@ -129,8 +191,14 @@ while true do finished = true end end - else + elseif #fxJobs == 0 then -- nothing to do (idle, or the look-ahead is full): yield the core love.timer.sleep(0.001) end + -- At most one effect between music buffers, so prewarming cannot monopolize + -- the worker while a streaming source needs its next buffer. + if #fxJobs > 0 and fxCh:getCount() < 2 then + renderFx(table.remove(fxJobs, 1)) + end + if Nitro then Nitro.step() 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/Gen2PhoneIdentities.lua b/src/import/Gen2PhoneIdentities.lua new file mode 100644 index 000000000..2ac555308 --- /dev/null +++ b/src/import/Gen2PhoneIdentities.lua @@ -0,0 +1,183 @@ +-- Original selection/copy logic by Roxas2712. See offered PR contribution permission. +-- Compatibility constant identifiers and numeric orders only; no ROM bytes, +-- artwork, dialogue or scripts. Reference: Gen1Recomp535a5ec0 manifest constants. +-- Existing engine/third-party credits and rights remain unchanged. +local M = {} +local gold = { + ["phoneContactOrder"] = {"PHONE_00", "PHONE_MOM", "PHONE_OAK", "PHONE_BILL", "PHONE_ELM", "PHONE_SCHOOLBOY_JACK", "PHONE_POKEFAN_BEVERLY", "PHONE_SAILOR_HUEY", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_COOLTRAINERM_GAVEN", "PHONE_COOLTRAINERF_BETH", "PHONE_BIRDKEEPER_JOSE", "PHONE_COOLTRAINERF_REENA", "PHONE_YOUNGSTER_JOEY", "PHONE_BUG_CATCHER_WADE", "PHONE_FISHER_RALPH", "PHONE_PICNICKER_LIZ", "PHONE_HIKER_ANTHONY", "PHONE_CAMPER_TODD", "PHONE_PICNICKER_GINA", "PHONE_JUGGLER_IRWIN", "PHONE_BUG_CATCHER_ARNIE", "PHONE_SCHOOLBOY_ALAN", "PHONE_UNUSED", "PHONE_LASS_DANA", "PHONE_SCHOOLBOY_CHAD", "PHONE_POKEFANM_DEREK", "PHONE_FISHER_CHRIS", "PHONE_POKEMANIAC_BRENT", "PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON", "PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN"}, + ["trainerClassMembers"] = { + ["BEAUTY"] = {"VICTORIA", "SAMANTHA", "JULIE", "JACLYN", "BRENDA", "CASSIE", "CAROLINE", "CARLENE", "JESSICA", "RACHAEL", "ANGELICA", "KENDRA", "VERONICA", "JULIA", "THERESA", "VALERIE"}, + ["BIKER"] = {"KAZU1", "KAZU2", "DWAYNE", "HARRIS", "ZEKE", "CHARLES", "RILEY", "JOEL", "GLENN"}, + ["BIRD_KEEPER"] = {"ROD", "ABE", "BRYAN", "THEO", "TOBY", "DENIS", "VANCE1", "HANK", "ROY", "BORIS", "BOB", "JOSE1", "PETER", "JOSE2", "PERRY", "BRET", "JOSE3", "VANCE2", "VANCE3"}, + ["BLACKBELT_T"] = {"KENJI1", "YOSHI", "KENJI2", "LAO", "NOB", "KIYO", "LUNG", "KENJI3", "WAI"}, + ["BLAINE"] = {"BLAINE1"}, + ["BLUE"] = {"BLUE1"}, + ["BOARDER"] = {"RONALD", "BRAD", "DOUGLAS"}, + ["BROCK"] = {"BROCK1"}, + ["BRUNO"] = {"BRUNO1"}, + ["BUGSY"] = {"BUGSY1"}, + ["BUG_CATCHER"] = {"DON", "ROB", "ED", "WADE1", "BENNY", "AL", "JOSH", "ARNIE1", "KEN", "WADE2", "WADE3", "DOUG", "ARNIE2", "ARNIE3"}, + ["BURGLAR"] = {"DUNCAN", "EDDIE", "COREY"}, + ["CAL"] = {"CAL1", "CAL2", "CAL3"}, + ["CAMPER"] = {"ROLAND", "TODD1", "IVAN", "ELLIOT", "BARRY", "LLOYD", "DEAN", "SID", "HARVEY", "DALE", "TED", "TODD2", "TODD3", "THOMAS", "LEROY", "DAVID", "JOHN", "JERRY", "SPENCER"}, + ["CHAMPION"] = {"LANCE"}, + ["CHUCK"] = {"CHUCK1"}, + ["CLAIR"] = {"CLAIR1"}, + ["COOLTRAINERF"] = {"GWEN", "LOIS", "FRAN", "LOLA", "KATE", "IRENE", "KELLY", "JOYCE", "BETH1", "REENA1", "MEGAN", "BETH2", "CAROL", "QUINN", "EMMA", "CYBIL", "JENN", "BETH3", "REENA2", "REENA3"}, + ["COOLTRAINERM"] = {"NICK", "AARON", "PAUL", "CODY", "MIKE", "GAVEN1", "GAVEN2", "RYAN", "JAKE", "GAVEN3", "BLAKE", "BRIAN", "ERICK", "ANDY", "TYLER", "SEAN", "KEVIN", "STEVE", "ALLEN"}, + ["ERIKA"] = {"ERIKA1"}, + ["EXECUTIVEF"] = {"EXECUTIVEF_1", "EXECUTIVEF_2"}, + ["EXECUTIVEM"] = {"EXECUTIVEM_1", "EXECUTIVEM_2", "EXECUTIVEM_3", "EXECUTIVEM_4"}, + ["FALKNER"] = {"FALKNER1"}, + ["FIREBREATHER"] = {"OTIS", "DICK", "NED", "BURT", "BILL", "WALT", "RAY", "LYLE"}, + ["FISHER"] = {"JUSTIN", "RALPH1", "ARNOLD", "KYLE", "HENRY", "MARVIN", "CHRIS1", "ANDRE", "RAYMOND", "WILTON1", "EDGAR", "JONAH", "MARTIN", "STEPHEN", "BARNEY", "RALPH2", "RALPH3", "CHRIS2", "CHRIS3", "WILTON2", "SCOTT", "WILTON3"}, + ["GENTLEMAN"] = {"PRESTON", "EDWARD", "GREGORY", "VIRGIL", "ALFRED"}, + ["GRUNTF"] = {"GRUNTF_1", "GRUNTF_2", "GRUNTF_3", "GRUNTF_4", "GRUNTF_5"}, + ["GRUNTM"] = {"GRUNTM_1", "GRUNTM_2", "GRUNTM_3", "GRUNTM_4", "GRUNTM_5", "GRUNTM_6", "GRUNTM_7", "GRUNTM_8", "GRUNTM_9", "GRUNTM_10", "GRUNTM_11", "GRUNTM_12", "GRUNTM_13", "GRUNTM_14", "GRUNTM_15", "GRUNTM_16", "GRUNTM_17", "GRUNTM_18", "GRUNTM_19", "GRUNTM_20", "GRUNTM_21", "GRUNTM_22", "GRUNTM_23", "GRUNTM_24", "GRUNTM_25", "GRUNTM_26", "GRUNTM_27", "GRUNTM_28", "GRUNTM_29", "GRUNTM_30", "GRUNTM_31"}, + ["GUITARIST"] = {"CLYDE", "VINCENT"}, + ["HIKER"] = {"ANTHONY1", "RUSSELL", "PHILLIP", "LEONARD", "ANTHONY2", "BENJAMIN", "ERIK", "MICHAEL", "PARRY1", "TIMOTHY", "BAILEY", "ANTHONY3", "TIM", "NOLAND", "SIDNEY", "KENNY", "JIM", "DANIEL", "PARRY2", "PARRY3"}, + ["JANINE"] = {"JANINE1"}, + ["JASMINE"] = {"JASMINE1"}, + ["JUGGLER"] = {"IRWIN1", "FRITZ", "HORTON", "IRWIN2", "IRWIN3"}, + ["KAREN"] = {"KAREN1"}, + ["KIMONO_GIRL"] = {"KYOKO", "NAOKO", "SAYO", "ZUKI", "KUNI", "MIKI"}, + ["KOGA"] = {"KOGA1"}, + ["LASS"] = {"CARRIE", "BRIDGET", "ALICE", "KRISE", "CONNIE1", "LINDA", "LAURA", "SHANNON", "MICHELLE", "DANA1", "ELLEN", "CONNIE2", "CONNIE3", "DANA2", "DANA3"}, + ["LT_SURGE"] = {"LT_SURGE1"}, + ["MEDIUM"] = {"MARTHA", "GRACE", "BETHANY", "MARGRET", "ETHEL", "REBECCA", "DORIS"}, + ["MISTY"] = {"MISTY1"}, + ["MORTY"] = {"MORTY1"}, + ["OFFICER"] = {"KEITH", "DIRK"}, + ["PICNICKER"] = {"LIZ1", "GINA1", "BROOKE", "KIM", "CINDY", "HOPE", "SHARON", "DEBRA", "GINA2", "ERIN1", "LIZ2", "LIZ3", "HEIDI", "EDNA", "GINA3", "TIFFANY1", "TIFFANY2", "ERIN2", "TANYA", "TIFFANY3", "ERIN3"}, + ["POKEFANF"] = {"BEVERLY1", "RUTH", "BEVERLY2", "BEVERLY3", "GEORGIA"}, + ["POKEFANM"] = {"WILLIAM", "DEREK1", "ROBERT", "JOSHUA", "CARTER", "TREVOR", "BRANDON", "JEREMY", "COLIN", "DEREK2", "DEREK3", "ALEX"}, + ["POKEMANIAC"] = {"LARRY", "ANDREW", "CALVIN", "SHANE", "BEN", "BRENT1", "RON", "ETHAN", "BRENT2", "BRENT3", "ISSAC", "DONALD", "ZACH"}, + ["POKEMON_PROF"] = {}, + ["PRYCE"] = {"PRYCE1"}, + ["PSYCHIC_T"] = {"NATHAN", "FRANKLIN", "HERMAN", "FIDEL", "GREG", "NORMAN", "MARK", "PHIL", "RICHARD", "GILBERT", "JARED", "RODNEY"}, + ["RED"] = {"RED1"}, + ["RIVAL1"] = {"RIVAL1_1_CHIKORITA", "RIVAL1_1_CYNDAQUIL", "RIVAL1_1_TOTODILE", "RIVAL1_2_CHIKORITA", "RIVAL1_2_CYNDAQUIL", "RIVAL1_2_TOTODILE", "RIVAL1_3_CHIKORITA", "RIVAL1_3_CYNDAQUIL", "RIVAL1_3_TOTODILE", "RIVAL1_4_CHIKORITA", "RIVAL1_4_CYNDAQUIL", "RIVAL1_4_TOTODILE", "RIVAL1_5_CHIKORITA", "RIVAL1_5_CYNDAQUIL", "RIVAL1_5_TOTODILE"}, + ["RIVAL2"] = {"RIVAL2_1_CHIKORITA", "RIVAL2_1_CYNDAQUIL", "RIVAL2_1_TOTODILE", "RIVAL2_2_CHIKORITA", "RIVAL2_2_CYNDAQUIL", "RIVAL2_2_TOTODILE"}, + ["SABRINA"] = {"SABRINA1"}, + ["SAGE"] = {"CHOW", "NICO", "JIN", "TROY", "JEFFREY", "PING", "EDMOND", "NEAL", "LI"}, + ["SAILOR"] = {"EUGENE", "HUEY1", "TERRELL", "KENT", "ERNEST", "JEFF", "GARRETT", "KENNETH", "STANLY", "HARRY", "HUEY2", "HUEY3"}, + ["SCHOOLBOY"] = {"JACK1", "KIPP", "ALAN1", "JOHNNY", "DANNY", "TOMMY", "DUDLEY", "JOE", "BILLY", "CHAD1", "NATE", "RICKY", "JACK2", "JACK3", "ALAN2", "ALAN3", "CHAD2", "CHAD3"}, + ["SCIENTIST"] = {"ROSS", "MITCH", "JED", "MARC", "RICH"}, + ["SKIER"] = {"ROXANNE", "CLARISSA"}, + ["SUPER_NERD"] = {"ERIC_UNUSED", "ERIC", "GREGG", "JAY", "DAVE", "SAM", "TOM", "PAT", "SHAWN", "TERU", "RUSS", "NORTON"}, + ["SWIMMERF"] = {"ELAINE", "PAULA", "KAYLEE", "SUSIE", "DENISE", "KARA", "WENDY", "LISA", "JILL", "MARY", "KATIE", "DAWN", "TARA", "NICOLE", "LORI", "JODY", "NIKKI", "DIANA", "BRIANA"}, + ["SWIMMERM"] = {"HAROLD", "SIMON", "RANDALL", "CHARLIE", "GEORGE", "BERKE", "KIRK", "MATHEW", "HAL", "PATON", "DARYL", "WALTER", "TONY", "JEROME", "TUCKER", "RICK", "CAMERON", "SETH", "JAMES", "LEWIS", "PARKER"}, + ["TEACHER"] = {"COLETTE", "HILLARY", "SHIRLEY"}, + ["TRAINER_NONE"] = {}, + ["TWINS"] = {"AMYANDMAY1", "ANNANDANNE1", "ANNANDANNE2", "AMYANDMAY2", "JOANDZOE1", "JOANDZOE2", "MEGANDPEG1", "MEGANDPEG2"}, + ["WHITNEY"] = {"WHITNEY1"}, + ["WILL"] = {"WILL1"}, + ["YOUNGSTER"] = {"JOEY1", "MIKEY", "ALBERT", "GORDON", "SAMUEL", "IAN", "JOEY2", "JOEY3", "WARREN", "JIMMY", "OWEN", "JASON"}, + }, + ["trainerClassOrder"] = {"TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY", "MORTY", "PRYCE", "JASMINE", "CHUCK", "CLAIR", "RIVAL1", "POKEMON_PROF", "WILL", "CAL", "BRUNO", "KAREN", "KOGA", "CHAMPION", "BROCK", "MISTY", "LT_SURGE", "SCIENTIST", "ERIKA", "YOUNGSTER", "SCHOOLBOY", "BIRD_KEEPER", "LASS", "JANINE", "COOLTRAINERM", "COOLTRAINERF", "BEAUTY", "POKEMANIAC", "GRUNTM", "GENTLEMAN", "SKIER", "TEACHER", "SABRINA", "BUG_CATCHER", "FISHER", "SWIMMERM", "SWIMMERF", "SAILOR", "SUPER_NERD", "RIVAL2", "GUITARIST", "HIKER", "BIKER", "BLAINE", "BURGLAR", "FIREBREATHER", "JUGGLER", "BLACKBELT_T", "EXECUTIVEM", "PSYCHIC_T", "PICNICKER", "CAMPER", "EXECUTIVEF", "SAGE", "MEDIUM", "BOARDER", "POKEFANM", "KIMONO_GIRL", "TWINS", "POKEFANF", "RED", "BLUE", "OFFICER", "GRUNTF"}, +} +local crystal = { + ["phoneContactOrder"] = {"PHONE_00", "PHONE_MOM", "PHONE_OAK", "PHONE_BILL", "PHONE_ELM", "PHONE_SCHOOLBOY_JACK", "PHONE_POKEFAN_BEVERLY", "PHONE_SAILOR_HUEY", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_UNUSED", "PHONE_COOLTRAINERM_GAVEN", "PHONE_COOLTRAINERF_BETH", "PHONE_BIRDKEEPER_JOSE", "PHONE_COOLTRAINERF_REENA", "PHONE_YOUNGSTER_JOEY", "PHONE_BUG_CATCHER_WADE", "PHONE_FISHER_RALPH", "PHONE_PICNICKER_LIZ", "PHONE_HIKER_ANTHONY", "PHONE_CAMPER_TODD", "PHONE_PICNICKER_GINA", "PHONE_JUGGLER_IRWIN", "PHONE_BUG_CATCHER_ARNIE", "PHONE_SCHOOLBOY_ALAN", "PHONE_UNUSED", "PHONE_LASS_DANA", "PHONE_SCHOOLBOY_CHAD", "PHONE_POKEFANM_DEREK", "PHONE_FISHER_TULLY", "PHONE_POKEMANIAC_BRENT", "PHONE_PICNICKER_TIFFANY", "PHONE_BIRDKEEPER_VANCE", "PHONE_FISHER_WILTON", "PHONE_BLACKBELT_KENJI", "PHONE_HIKER_PARRY", "PHONE_PICNICKER_ERIN", "PHONE_BUENA"}, + ["trainerClassMembers"] = { + ["BEAUTY"] = {"VICTORIA", "SAMANTHA", "JULIE", "JACLYN", "BRENDA", "CASSIE", "CAROLINE", "CARLENE", "JESSICA", "RACHAEL", "ANGELICA", "KENDRA", "VERONICA", "JULIA", "THERESA", "VALERIE", "OLIVIA"}, + ["BIKER"] = {"BIKER_BENNY", "KAZU", "DWAYNE", "HARRIS", "ZEKE", "CHARLES", "RILEY", "JOEL", "GLENN"}, + ["BIRD_KEEPER"] = {"ROD", "ABE", "BRYAN", "THEO", "TOBY", "DENIS", "VANCE1", "HANK", "ROY", "BORIS", "BOB", "JOSE1", "PETER", "JOSE2", "PERRY", "BRET", "JOSE3", "VANCE2", "VANCE3"}, + ["BLACKBELT_T"] = {"KENJI1", "YOSHI", "KENJI2", "LAO", "NOB", "KIYO", "LUNG", "KENJI3", "WAI"}, + ["BLAINE"] = {"BLAINE1"}, + ["BLUE"] = {"BLUE1"}, + ["BOARDER"] = {"RONALD", "BRAD", "DOUGLAS"}, + ["BROCK"] = {"BROCK1"}, + ["BRUNO"] = {"BRUNO1"}, + ["BUGSY"] = {"BUGSY1"}, + ["BUG_CATCHER"] = {"DON", "ROB", "ED", "WADE1", "BUG_CATCHER_BENNY", "AL", "JOSH", "ARNIE1", "KEN", "WADE2", "WADE3", "DOUG", "ARNIE2", "ARNIE3", "WADE4", "WADE5", "ARNIE4", "ARNIE5", "WAYNE"}, + ["BURGLAR"] = {"DUNCAN", "EDDIE", "COREY"}, + ["CAL"] = {"CAL1", "CAL2", "CAL3"}, + ["CAMPER"] = {"ROLAND", "TODD1", "IVAN", "ELLIOT", "BARRY", "LLOYD", "DEAN", "SID", "HARVEY", "DALE", "TED", "TODD2", "TODD3", "THOMAS", "LEROY", "DAVID", "JOHN", "JERRY", "SPENCER", "TODD4", "TODD5", "QUENTIN"}, + ["CHAMPION"] = {"LANCE"}, + ["CHUCK"] = {"CHUCK1"}, + ["CLAIR"] = {"CLAIR1"}, + ["COOLTRAINERF"] = {"GWEN", "LOIS", "FRAN", "LOLA", "KATE", "IRENE", "KELLY", "JOYCE", "BETH1", "REENA1", "MEGAN", "BETH2", "CAROL", "QUINN", "EMMA", "CYBIL", "JENN", "BETH3", "REENA2", "REENA3", "CARA"}, + ["COOLTRAINERM"] = {"NICK", "AARON", "PAUL", "CODY", "MIKE", "GAVEN1", "GAVEN2", "RYAN", "JAKE", "GAVEN3", "BLAKE", "BRIAN", "ERICK", "ANDY", "TYLER", "SEAN", "KEVIN", "STEVE", "ALLEN", "DARIN"}, + ["ERIKA"] = {"ERIKA1"}, + ["EXECUTIVEF"] = {"EXECUTIVEF_1", "EXECUTIVEF_2"}, + ["EXECUTIVEM"] = {"EXECUTIVEM_1", "EXECUTIVEM_2", "EXECUTIVEM_3", "EXECUTIVEM_4"}, + ["FALKNER"] = {"FALKNER1"}, + ["FIREBREATHER"] = {"OTIS", "DICK", "NED", "BURT", "BILL", "WALT", "RAY", "LYLE"}, + ["FISHER"] = {"JUSTIN", "RALPH1", "ARNOLD", "KYLE", "HENRY", "MARVIN", "TULLY1", "ANDRE", "RAYMOND", "WILTON1", "EDGAR", "JONAH", "MARTIN", "STEPHEN", "BARNEY", "RALPH2", "RALPH3", "TULLY2", "TULLY3", "WILTON2", "SCOTT", "WILTON3", "RALPH4", "RALPH5", "TULLY4"}, + ["GENTLEMAN"] = {"PRESTON", "EDWARD", "GREGORY", "VIRGIL", "ALFRED"}, + ["GRUNTF"] = {"GRUNTF_1", "GRUNTF_2", "GRUNTF_3", "GRUNTF_4", "GRUNTF_5"}, + ["GRUNTM"] = {"GRUNTM_1", "GRUNTM_2", "GRUNTM_3", "GRUNTM_4", "GRUNTM_5", "GRUNTM_6", "GRUNTM_7", "GRUNTM_8", "GRUNTM_9", "GRUNTM_10", "GRUNTM_11", "GRUNTM_12", "GRUNTM_13", "GRUNTM_14", "GRUNTM_15", "GRUNTM_16", "GRUNTM_17", "GRUNTM_18", "GRUNTM_19", "GRUNTM_20", "GRUNTM_21", "GRUNTM_22", "GRUNTM_23", "GRUNTM_24", "GRUNTM_25", "GRUNTM_26", "GRUNTM_27", "GRUNTM_28", "GRUNTM_29", "GRUNTM_30", "GRUNTM_31"}, + ["GUITARIST"] = {"CLYDE", "VINCENT"}, + ["HIKER"] = {"ANTHONY1", "RUSSELL", "PHILLIP", "LEONARD", "ANTHONY2", "BENJAMIN", "ERIK", "MICHAEL", "PARRY1", "TIMOTHY", "BAILEY", "ANTHONY3", "TIM", "NOLAND", "SIDNEY", "KENNY", "JIM", "DANIEL", "PARRY2", "PARRY3", "ANTHONY4", "ANTHONY5"}, + ["JANINE"] = {"JANINE1"}, + ["JASMINE"] = {"JASMINE1"}, + ["JUGGLER"] = {"IRWIN1", "FRITZ", "HORTON", "IRWIN2", "IRWIN3", "IRWIN4"}, + ["KAREN"] = {"KAREN1"}, + ["KIMONO_GIRL"] = {"NAOKO_UNUSED", "NAOKO", "SAYO", "ZUKI", "KUNI", "MIKI"}, + ["KOGA"] = {"KOGA1"}, + ["LASS"] = {"CARRIE", "BRIDGET", "ALICE", "KRISE", "CONNIE1", "LINDA", "LAURA", "SHANNON", "MICHELLE", "DANA1", "ELLEN", "CONNIE2", "CONNIE3", "DANA2", "DANA3", "DANA4", "DANA5"}, + ["LT_SURGE"] = {"LT_SURGE1"}, + ["MEDIUM"] = {"MARTHA", "GRACE", "BETHANY", "MARGRET", "ETHEL", "REBECCA", "DORIS"}, + ["MISTY"] = {"MISTY1"}, + ["MORTY"] = {"MORTY1"}, + ["MYSTICALMAN"] = {"EUSINE"}, + ["OFFICER"] = {"KEITH", "DIRK"}, + ["PICNICKER"] = {"LIZ1", "GINA1", "BROOKE", "KIM", "CINDY", "HOPE", "SHARON", "DEBRA", "GINA2", "ERIN1", "LIZ2", "LIZ3", "HEIDI", "EDNA", "GINA3", "TIFFANY1", "TIFFANY2", "ERIN2", "TANYA", "TIFFANY3", "ERIN3", "LIZ4", "LIZ5", "GINA4", "GINA5", "TIFFANY4"}, + ["POKEFANF"] = {"BEVERLY1", "RUTH", "BEVERLY2", "BEVERLY3", "GEORGIA", "JAIME"}, + ["POKEFANM"] = {"WILLIAM", "DEREK1", "ROBERT", "JOSHUA", "CARTER", "TREVOR", "BRANDON", "JEREMY", "COLIN", "DEREK2", "DEREK3", "ALEX", "REX", "ALLAN"}, + ["POKEMANIAC"] = {"LARRY", "ANDREW", "CALVIN", "SHANE", "BEN", "BRENT1", "RON", "ETHAN", "BRENT2", "BRENT3", "ISSAC", "DONALD", "ZACH", "BRENT4", "MILLER"}, + ["POKEMON_PROF"] = {}, + ["PRYCE"] = {"PRYCE1"}, + ["PSYCHIC_T"] = {"NATHAN", "FRANKLIN", "HERMAN", "FIDEL", "GREG", "NORMAN", "MARK", "PHIL", "RICHARD", "GILBERT", "JARED", "RODNEY"}, + ["RED"] = {"RED1"}, + ["RIVAL1"] = {"RIVAL1_1_CHIKORITA", "RIVAL1_1_CYNDAQUIL", "RIVAL1_1_TOTODILE", "RIVAL1_2_CHIKORITA", "RIVAL1_2_CYNDAQUIL", "RIVAL1_2_TOTODILE", "RIVAL1_3_CHIKORITA", "RIVAL1_3_CYNDAQUIL", "RIVAL1_3_TOTODILE", "RIVAL1_4_CHIKORITA", "RIVAL1_4_CYNDAQUIL", "RIVAL1_4_TOTODILE", "RIVAL1_5_CHIKORITA", "RIVAL1_5_CYNDAQUIL", "RIVAL1_5_TOTODILE"}, + ["RIVAL2"] = {"RIVAL2_1_CHIKORITA", "RIVAL2_1_CYNDAQUIL", "RIVAL2_1_TOTODILE", "RIVAL2_2_CHIKORITA", "RIVAL2_2_CYNDAQUIL", "RIVAL2_2_TOTODILE"}, + ["SABRINA"] = {"SABRINA1"}, + ["SAGE"] = {"CHOW", "NICO", "JIN", "TROY", "JEFFREY", "PING", "EDMOND", "NEAL", "LI", "GAKU", "MASA", "KOJI"}, + ["SAILOR"] = {"EUGENE", "HUEY1", "TERRELL", "KENT", "ERNEST", "JEFF", "GARRETT", "KENNETH", "STANLY", "HARRY", "HUEY2", "HUEY3", "HUEY4"}, + ["SCHOOLBOY"] = {"JACK1", "KIPP", "ALAN1", "JOHNNY", "DANNY", "TOMMY", "DUDLEY", "JOE", "BILLY", "CHAD1", "NATE", "RICKY", "JACK2", "JACK3", "ALAN2", "ALAN3", "CHAD2", "CHAD3", "JACK4", "JACK5", "ALAN4", "ALAN5", "CHAD4", "CHAD5"}, + ["SCIENTIST"] = {"ROSS", "MITCH", "JED", "MARC", "RICH"}, + ["SKIER"] = {"ROXANNE", "CLARISSA"}, + ["SUPER_NERD"] = {"STAN", "ERIC", "GREGG", "JAY", "DAVE", "SAM", "TOM", "PAT", "SHAWN", "TERU", "RUSS", "NORTON", "HUGH", "MARKUS"}, + ["SWIMMERF"] = {"ELAINE", "PAULA", "KAYLEE", "SUSIE", "DENISE", "KARA", "WENDY", "LISA", "JILL", "MARY", "KATIE", "DAWN", "TARA", "NICOLE", "LORI", "JODY", "NIKKI", "DIANA", "BRIANA"}, + ["SWIMMERM"] = {"HAROLD", "SIMON", "RANDALL", "CHARLIE", "GEORGE", "BERKE", "KIRK", "MATHEW", "HAL", "PATON", "DARYL", "WALTER", "TONY", "JEROME", "TUCKER", "RICK", "CAMERON", "SETH", "JAMES", "LEWIS", "PARKER"}, + ["TEACHER"] = {"COLETTE", "HILLARY", "SHIRLEY"}, + ["TRAINER_NONE"] = {}, + ["TWINS"] = {"AMYANDMAY1", "ANNANDANNE1", "ANNANDANNE2", "AMYANDMAY2", "JOANDZOE1", "JOANDZOE2", "MEGANDPEG1", "MEGANDPEG2", "LEAANDPIA1", "LEAANDPIA2"}, + ["WHITNEY"] = {"WHITNEY1"}, + ["WILL"] = {"WILL1"}, + ["YOUNGSTER"] = {"JOEY1", "MIKEY", "ALBERT", "GORDON", "SAMUEL", "IAN", "JOEY2", "JOEY3", "WARREN", "JIMMY", "OWEN", "JASON", "JOEY4", "JOEY5"}, + }, + ["trainerClassOrder"] = {"TRAINER_NONE", "FALKNER", "WHITNEY", "BUGSY", "MORTY", "PRYCE", "JASMINE", "CHUCK", "CLAIR", "RIVAL1", "POKEMON_PROF", "WILL", "CAL", "BRUNO", "KAREN", "KOGA", "CHAMPION", "BROCK", "MISTY", "LT_SURGE", "SCIENTIST", "ERIKA", "YOUNGSTER", "SCHOOLBOY", "BIRD_KEEPER", "LASS", "JANINE", "COOLTRAINERM", "COOLTRAINERF", "BEAUTY", "POKEMANIAC", "GRUNTM", "GENTLEMAN", "SKIER", "TEACHER", "SABRINA", "BUG_CATCHER", "FISHER", "SWIMMERM", "SWIMMERF", "SAILOR", "SUPER_NERD", "RIVAL2", "GUITARIST", "HIKER", "BIKER", "BLAINE", "BURGLAR", "FIREBREATHER", "JUGGLER", "BLACKBELT_T", "EXECUTIVEM", "PSYCHIC_T", "PICNICKER", "CAMPER", "EXECUTIVEF", "SAGE", "MEDIUM", "BOARDER", "POKEFANM", "KIMONO_GIRL", "TWINS", "POKEFANF", "RED", "BLUE", "OFFICER", "GRUNTF", "MYSTICALMAN"}, +} +local function copy(value) + if type(value) ~= "table" then return value end + local out = {} + for key, item in pairs(value) do out[key] = copy(item) end + return out +end +function M.forVersion(version) + local data = (version == "gold" or version == "silver") and gold + or version == "crystal" and crystal or nil + return data and copy(data) +end +function M.install(constants, manifest, version) + local supplied = manifest and manifest.constants or {} + local keys = { "phoneContactOrder", "trainerClassOrder", "trainerClassMembers" } + local authored = false + for _, key in ipairs(keys) do + if constants[key] ~= nil or supplied[key] ~= nil then authored = true end + end + -- A partial custom identity set must not silently mix with a retail roster. + local fallback = not authored and M.forVersion(version) or {} + for _, key in ipairs(keys) do + -- Authored native constants win. Manifests can explicitly describe an + -- alternative cartridge; retail identities never guess a hack's roster. + if constants[key] == nil then + local value = type(supplied[key]) == "table" and supplied[key] or fallback[key] + if value then constants[key] = copy(value) end + end + end + return constants +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/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/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 90b74029e..f7869558a 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -961,6 +961,9 @@ function RomExtractorGen2:constants() -- Generation is runtime metadata, not cartridge data. Battle formulas use -- it to select the Gen II critical-hit ladder without guessing from a ROM id. self._constants.generation = 2 + -- Numeric phone/trainer identities are independent of localized names. + require("src.import.Gen2PhoneIdentities").install( + self._constants, self.manifest, self.version) local badges = {} -- A CARTRIDGE MAY HAVE ITS OWN SET. Prism awards TWENTY badges across -- three engine-flag bytes, and its flag keys keep the ENGINE_ prefix that @@ -3317,6 +3320,221 @@ function RomExtractorGen2:gen2MovementTable() return out end +-- Kurt's actual selection-order and recipe pairs, not the bag's item order. +function RomExtractorGen2:gen2ApricornBalls() + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local sym=self:symbol("ApricornBalls") + if not (sym and self.rom) then return nil end + local rows,seen={},{} + local info={source="ApricornBalls",complete=false,rowsRead=0} + -- Native db apricorn,ball pairs, terminated by a single $ff apricorn. + -- Never walk through a bank boundary or accept an unterminated prefix. + for slot=0,128 do + local at=sym.address+slot*2 + local ok,apricorn=pcall(function()return self.rom:byte(sym.bank,at)end) + if not ok then info.reason="truncated table";return {},info end + if apricorn==255 then info.complete=true;return rows,info end + if slot==128 then info.reason="unterminated table";return {},info end + local read,ball=pcall(function()return self.rom:byte(sym.bank,at+1)end) + if not read then info.reason="truncated pair";return {},info end + if apricorn==0 or ball==0 or ball==255 or seen[apricorn] then + info.reason="invalid or duplicate native item";return {},info + end + seen[apricorn]=true + rows[#rows+1]={index=slot+1,apricorn=string.format("ITEM_%03d",apricorn), + ball=string.format("ITEM_%03d",ball)} + info.rowsRead=#rows + end +end + +function RomExtractorGen2:gen2QueueApricornPrograms(pool) + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local symbols={ask="Kurt1.AskApricorn",pending="Kurt1.GotLureBall", + gave="Kurt1.GaveKurtApricorns",cancel="Kurt1.Cancel"} + local found={} + for role,name in pairs(symbols)do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address) then return nil end + found[role]=at + end + local out={} + for role,at in pairs(found)do out[role]=self:gen2QueueScript(at.bank,at.address,pool)end + return out +end + +-- Six native bytes per attribute row: kind/name/action/event-word/sprite. +-- Names immediately follow attributes in stock GSC; that boundary supplies +-- the row count instead of a copied retail catalogue. +-- Native Mom tables have eight bytes: two big-endian money triples and +-- kind/item bytes. End symbols supply counts, never a copied retail list. +function RomExtractorGen2:gen2MomShopping(pool) + if self.version~="gold"and self.version~="silver"and self.version~="crystal"then return nil end + local result={complete=false} + local ok,why=pcall(function() + for role,name in pairs({random="MomItems_1",ladder="MomItems_2"})do + local first,last=self:symbol(name),self:symbol(name..".End") + assert(first and last and first.bank==last.bank,"missing Mom table bounds") + local count=(last.address-first.address)/8 + assert(count%1==0 and count>0 and count<=255 and first.address>=0x4000 and last.address<=0x8000,"invalid Mom table bounds") + local rows={} + for i=0,count-1 do + local b=self.rom:bytes(first.bank,first.address+i*8,8) + local trigger=b[1]*65536+b[2]*256+b[3];local cost=b[4]*65536+b[5]*256+b[6] + assert(trigger<=999999 and cost<=999999 and(b[7]==1 or b[7]==2)and b[8]>0 and b[8]<255,"invalid Mom row") + rows[#rows+1]={trigger=trigger,cost=cost,kind=b[7],item=b[8]} + end + result[role]=rows + end + local at,ram=self:symbol("CheckBalance_MomItem2.check_have_2300"),self:symbol("hMoneyTemp") + assert(at and ram,"missing Mom trigger instructions") + local b=self.rom:bytes(at.bank,at.address,11) + assert(b[1]==0x21 and b[2]+b[3]*256==ram.address and b[4]==0x36 and b[6]==0x23 and + b[7]==0x36 and b[9]==0x23 and b[10]==0x36,"unrecognized Mom trigger instructions") + result.step=b[5]*65536+b[8]*256+b[11] + assert(result.step>0 and result.step<=999999,"invalid Mom trigger amount") + end) + if not ok then return{complete=false,reason=tostring(why)}end + local found={} + for role,name in pairs({item="Mom_GetScriptPointer.ItemScript",doll="Mom_GetScriptPointer.DollScript"})do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return{complete=false,reason="missing Mom phone program"}end + found[role]=at + end + result.programs={} + for role,at in pairs(found)do result.programs[role]=self:gen2QueueScript(at.bank,at.address,pool)end + result.rewardTexts={} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for mode,name in pairs({[0]="GotMoneyForWinningText",[1]="SentSomeToMomText",[2]="SentHalfToMomText",[3]="SentAllToMomText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then result.rewardTexts[mode]=self:gen2RegisterScriptText(at.bank,at.address)end + end + result.complete=true;return result +end + +function RomExtractorGen2:gen2Decorations() + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local info={complete=false,rowsRead=0,defaultsComplete=false,giftsComplete=false} + local attr,names=self:symbol("DecorationAttributes"),self:symbol("DecorationNames") + local function fail(why)info.reason=why;return {},info end + if not attr or not names then return fail("missing decoration table symbols")end + local count=(names.address-attr.address)/6 + if attr.bank~=names.bank or count%1~=0 or count<1 or count>256 or + attr.address<0x4000 or names.address>0x8000 then return fail("invalid decoration table boundary")end + local Native=require("src.world.Gen2Decorations") + local ok,rows=pcall(function() + local raw,maxName={},0 + for i=0,count-1 do + local r=self.rom:bytes(attr.bank,attr.address+i*6,6) + assert(r[1]>=1 and r[1]<=6,"invalid decoration kind") + assert(r[3]==0 or Native.actionName(r[3]),"invalid decoration action") + assert(r[4]+r[5]*256<65535,"indirect decoration event") + raw[i]=r;if r[1]<=3 then maxName=math.max(maxName,r[2])end + end + local charmap=self:readSourceTable("charmap") + local words,pc={},names.address + for i=0,maxName do + local bytes,ended={},false + for _=1,128 do + assert(pc<0x8000,"decoration name crosses bank") + local b=self.rom:byte(names.bank,pc);pc=pc+1 + if b==0x50 then ended=true;break end + bytes[#bytes+1]=b + end + assert(ended,"unterminated decoration name") + words[i]=self:gen2GlyphBytes(bytes,charmap) + end + local pokemon=self:symbol("PokemonNames") + local out={} + for i=0,count-1 do + local r=raw[i];local name=words[r[2]] + if r[1]>=4 then + assert(pokemon and r[2]>=1,"missing decoration species name") + local address=pokemon.address+(r[2]-1)*10 + assert(address>=0x4000 and address+10<=0x8000,"decoration species name crosses bank") + local bytes={} + for n=0,9 do local b=self.rom:byte(pokemon.bank,address+n);if b==0x50 then break end;bytes[#bytes+1]=b end + name=self:gen2GlyphBytes(bytes,charmap) + info.species=info.species or{};info.sourceNames=info.sourceNames or{} + info.species[i]=string.format("SPECIES_%03d",r[2]);info.sourceNames[i]=name + end + assert(type(name)=="string" and name~="","missing decoration name") + out[i]={type=r[1],name=name,action=Native.actionName(r[3]),flag=r[4]+r[5]*256,sprite=r[6]} + end + return out + end) + if not ok then return fail(tostring(rows))end + local init=self:symbol("InitDecorations") + if init then + local valid,bytes=pcall(self.rom.bytes,self.rom,init.bank,init.address,11) + if valid and bytes[1]==0x3e and bytes[3]==0xea and bytes[6]==0x3e and bytes[8]==0xea and bytes[11]==0xc9 and + rows[bytes[2]] and rows[bytes[2]].action=="SET_UP_BED" and rows[bytes[7]] and rows[bytes[7]].action=="SET_UP_POSTER" then + local bed,poster=self:symbol("wDecoBed"),self:symbol("wDecoPoster") + if bed and poster and bytes[4]+bytes[5]*256==bed.address and bytes[9]+bytes[10]*256==poster.address then + info.defaults={bed=bytes[2],poster=bytes[7]};info.defaultsComplete=true + end + end + end + local gifts=self:symbol("DecorationIDs") + if gifts then + local valid,list=pcall(function() + local out={} + for n=0,255 do + assert(gifts.address+n<0x8000,"decoration gift table crosses bank") + local id=self.rom:byte(gifts.bank,gifts.address+n) + if id==255 then return out end + assert(rows[id] and Native.action(rows[id].action) and not Native.action(rows[id].action).put,"invalid gift decoration") + out[#out+1]=id + end + error("unterminated decoration gift table") + end) + if valid then info.gifts=list;info.giftsComplete=true end + end + info.complete=true;info.rowsRead=count + return rows,info +end + +function RomExtractorGen2:gen2QueueDecorationPrograms(pool) + if self.version~="gold" and self.version~="silver" and self.version~="crystal" then return nil end + local roles={ornament="DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript", + big="DecorationDesc_GiantOrnament.BigDollScript",null="DecorationDesc_NullPoster"} + local found={} + for role,name in pairs(roles)do + local at=self:symbol(name) + if not at or at.address<0x4000 or not self:gen2InRom(at.bank,at.address)then return nil end + found[role]=at + end + local pointers=self:symbol("DecorationDesc_PosterPointers") + if not pointers then return nil end + local ok,posters=pcall(function() + local out={} + for n=0,255 do + local pc=pointers.address+n*3;assert(pc<0x8000,"poster pointers cross bank") + local id=self.rom:byte(pointers.bank,pc) + if id==255 then return out end + assert(pc+2<0x8000,"truncated poster pointer") + local address=self.rom:word(pointers.bank,pc+1) + assert(address>=0x4000 and self:gen2InRom(pointers.bank,address)and not out[id],"invalid poster pointer") + out[id]={bank=pointers.bank,address=address} + end + error("unterminated poster pointers") + end) + if not ok then return nil end + local out={posters={},texts={}} + self._gen2ScriptTexts=self._gen2ScriptTexts or{} + for role,name in pairs({put="PutAwayTheDecoText",empty="NothingToPutAwayText", + setup="SetUpTheDecoText",replace="PutAwayAndSetUpText",same="AlreadySetUpText", + noChoice="PopulateDecoCategoryMenu.NothingToChooseText", + sideSetup="WhichSidePutOnText",sidePut="WhichSidePutAwayText"})do + local at=self:symbol(name) + if at and self:gen2InRom(at.bank,at.address)then + out.texts[role]=self:gen2RegisterScriptText(at.bank,at.address) + end + end + for role,at in pairs(found)do out[role]=self:gen2QueueScript(at.bank,at.address,pool)end + for id,at in pairs(posters)do out.posters[id]=self:gen2QueueScript(at.bank,at.address,pool)end + return out +end + -- Marts is a dw pointer table; each mart is `db count`, the item ids, `db -1`. -- The table ends where its own first entry starts. function RomExtractorGen2:gen2Marts() @@ -5522,6 +5740,8 @@ function RomExtractorGen2:gen2MapIndex() -- PALETTE_DARK, the only value ReplaceTimeOfDayPals answers with -- .NeedsFlash (23:$4400) palette = field(paletteAt, 0) % 16, + paletteBits = field(paletteAt,0), + phoneService = math.floor(field(paletteAt,0)/16)%16==0, } byGroupNumber[group * 256 + number] = entry byLabel[label] = byLabel[label] or entry @@ -6521,6 +6741,7 @@ function RomExtractorGen2:extractMapsFromRom() -- is why a Pokemon Center does not go dark at night on hardware -- and -- the Ruins of Alph chambers carry it too. def.mapPalette = header and header.palette or def.mapPalette + if header then def.mapPaletteBits=header.paletteBits;def.phoneService=header.phoneService end -- Byte 8 is the FISHGROUP_* constant, and 0 is FISHGROUP_NONE -- NOT -- FISHGROUP_SHORE, which is 1 (constants/map_data_constants.asm). -- gen2Fishing keys its table by that same constant, so 0 finds nothing @@ -8126,7 +8347,10 @@ function RomExtractorGen2:gen2ResolveScriptMapIds(maps, keys, pool) if slot and type(row[slot]) == "number" and type(row[slot + 1]) == "number" then local entry = mapIndex[row[slot] * 256 + row[slot + 1]] local key = entry and keyByLabel[entry.label] - if key then + if (row[1]=="warp"or row[1]=="warpfacing") and row[slot]==0 and row[slot+1]==0 and + (self.version=="gold"or self.version=="silver"or self.version=="crystal")then + row[slot]="NONE";row[slot+1]=0 + elseif key then row[slot] = key row[slot + 1] = 0 end @@ -8431,7 +8655,6 @@ end -- POKeGEAR had no dialogue at all to show. local GEN2_PHONE_ROW_BYTES = 12 local GEN2_PHONE_MAX = 64 -local GEN2_NONTRAINER_NAMES = 5 -- db class, id | db group, number | db ptime, bank, dw callee script (the one -- that runs when YOU ring them) | db ctime, bank, dw caller script (the one -- that runs when THEY ring you -- ELM's is the same $41E1 @@ -8447,14 +8670,30 @@ function RomExtractorGen2:gen2PhoneContacts(pool) local sym = self:symbol("PhoneContacts") if not (sym and self.rom) then return nil end + -- SpecialPhoneCallList closes the table, including the reserved holes. + local limit = GEN2_PHONE_MAX + local stop = self:symbol("SpecialPhoneCallList") + if stop and stop.bank == sym.bank and stop.address > sym.address then + limit = math.floor((stop.address - sym.address) / GEN2_PHONE_ROW_BYTES) + end + local rows, rowsRead, lastName = {}, 0, 0 + for id = 0, limit - 1 do + local ok, row = pcall(self.rom.bytes, self.rom, sym.bank, + sym.address + id * GEN2_PHONE_ROW_BYTES, GEN2_PHONE_ROW_BYTES) + if not ok then break end + rows[id], rowsRead = row, rowsRead + 1 + if row[1] == 0 then lastName = math.max(lastName, row[2]) end + end + -- Polished names can contain a Huffman-compressed suffix ($5D). - -- Decode the string stream before applying its charmap. + -- Decode the string stream before applying its charmap. Read every name + -- referenced by the actual table: the Gold-sized four-name bound discarded + -- Crystal's Buena row (non-trainer identity5), including both call scripts. local names = {} local nsym = self:symbol("NonTrainerCallerNames") if nsym then local polished = self.version == "polishedcrystal" - local count = polished and 7 or GEN2_NONTRAINER_NAMES - for id = 1, count - 1 do + for id = 1, lastName do local ok, address = pcall(self.rom.word, self.rom, nsym.bank, nsym.address + id * 2) if ok and self:gen2InRom(nsym.bank, address) then @@ -8470,18 +8709,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) end end - -- SpecialPhoneCallList closes the table; sizing off it beats guessing - local limit = GEN2_PHONE_MAX - local stop = self:symbol("SpecialPhoneCallList") - if stop and stop.bank == sym.bank and stop.address > sym.address then - limit = math.floor((stop.address - sym.address) / GEN2_PHONE_ROW_BYTES) - end - local out = {} - for id = 0, limit - 1 do - local ok, row = pcall(self.rom.bytes, self.rom, sym.bank, - sym.address + id * GEN2_PHONE_ROW_BYTES, GEN2_PHONE_ROW_BYTES) - if not ok then break end + for id = 0, rowsRead - 1 do + local row = rows[id] local class, trainer = row[1], row[2] -- the unused rows are class 0 / id 0, which GetCallerName renders as the -- "----------" placeholder; skip them so the POKeGEAR can never list one @@ -8490,7 +8720,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) entry = { class = class, trainer = trainer } elseif trainer ~= 0 and names[trainer] then local name, role = names[trainer]:match("^([^\n\f\v]+)[\n\f\v]%s*(.*)$") - entry = { name = (name or names[trainer]):gsub(":$", ""), role = role } + -- Keep the non-trainer's native identity; names are localizable. + entry = { name = (name or names[trainer]):gsub(":$", ""), role = role, + class = 0, contactNumber = trainer } end -- The contact's OWN map, bytes 3 and 4. GetAvailableCallers (36:$40DE) -- compares it against wMapGroup/wMapNumber and drops the contact from the @@ -8510,8 +8742,9 @@ function RomExtractorGen2:gen2PhoneContacts(pool) end out[id] = entry end - if not next(out) then return nil end - return out + local info = { size = limit, rowsRead = rowsRead, nonTrainerNames = names } + if not next(out) then return nil, info end + return out, info end -- `SpecialPhoneCallList` ([36,$45F6] .. PhoneOutOfAreaScript, 48 bytes) is @@ -8891,7 +9124,12 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts end out.stds = self:gen2QueueStdScripts(pool) + out.momShopping=self:gen2MomShopping(pool) + out.decorations,out.decorationInfo=self:gen2Decorations() + out.decorationPrograms=self:gen2QueueDecorationPrograms(pool) out.marts = self:gen2Marts() + out.apricornBalls, out.apricornInfo = self:gen2ApricornBalls() + out.apricornPrograms = self:gen2QueueApricornPrograms(pool) out.spawns = self:gen2SpawnPoints(maps, keys) -- extractField runs after this stage and needs both of these: GSC has no -- Fly-warp list of its own, because Fly lands on the SPAWN POINT of the @@ -8900,7 +9138,7 @@ function RomExtractorGen2:extractMapScripts() self:beginStage("Gen2 map scripts -- drawn in. self._gen2Spawns = out.spawns self._gen2MapDefs = maps - out.phone = self:gen2PhoneContacts(pool) + out.phone, out.phoneInfo = self:gen2PhoneContacts(pool) out.specialCalls = self:gen2SpecialPhoneCalls(pool) -- wVariableSprites as InitializeEventsScript leaves it on a new game, so an -- object_event whose sprite byte is $F0 or more resolves before the map's @@ -11418,7 +11656,10 @@ function RomExtractorGen2:gen2Landmarks() if not (sym and self.rom) then return nil end local charmap = self:readSourceTable("charmap") local out = {} - for id = 0, GEN2_LANDMARK_COUNT - 1 do + local order,why=require("src.world.Gen2Landmarks").order(self.version,self.manifest) + if why then return nil end + local count=#order>0 and #order or self:layout("townMapLandmarkCount",GEN2_LANDMARK_COUNT) + for id = 0, math.min(256,count) - 1 do pcall(function() local row = sym.address + id * GEN2_LANDMARK_BYTES local name = self:decodeGen2TextAt(sym.bank, @@ -11426,6 +11667,7 @@ function RomExtractorGen2:gen2Landmarks() -- $1F is the landmark line break (TownMap_ConvertLineBreakCharacters) name = name:gsub("{BYTE:1F}", "\n") out[id] = { + id = order[id+1], index = id, x = self.rom:byte(sym.bank, row), y = self.rom:byte(sym.bank, row + 1), name = name ~= "" and name or nil, @@ -11436,12 +11678,15 @@ function RomExtractorGen2:gen2Landmarks() end function RomExtractorGen2:gen2TownMap() + local landmarks = self:gen2Landmarks() local johto = self:gen2TownMapImage("JohtoMap", "ui/town_map_johto.png") - if not johto then return nil end + -- Native name scripts do not depend on a successfully decoded map picture. + if not johto and not landmarks then return nil end local out = { johto = johto, kanto = self:gen2TownMapImage("KantoMap", "ui/town_map_kanto.png"), - landmarks = self:gen2Landmarks(), + landmarks = landmarks, + landmarkOrder = require("src.world.Gen2Landmarks").order(self.version,self.manifest), } -- Player "you are here" icon: ChrisSpriteGFX facing-down (4 tiles = 16x16) -- tinted with PAL_OW_RED so it is not a greyscale silhouette on the map. diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index c2ce0b55e..8ca751e33 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 @@ -27068,9 +27070,11 @@ 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 @@ -27122,6 +27126,7 @@ 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), @@ -40984,7 +40989,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 +42524,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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index bbd959cfd..432076001 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1316,7 +1316,10 @@ local function markerFor(version) -- Only these three caches need rebuilding; other markers remain stable. -- v8: field.gen2TradeAnim, GSC's own trade movie art (Gen2TradeArt). if version == "gold" or version == "silver" or version == "crystal" then - revision = "gen2-field-encounters-v8:" + revision = "gen2-field-encounters-v8:gen2-phone-identities-v1:" + revision = revision .. "gen2-apricorn-table-v1:gen2-apricorn-programs-v1:" + revision = revision .. "gen2-landmark-identities-v1:" + revision = revision .. "gen2-mom-shopping-native-v1:gen2-decorations-native-v1:" end -- field.tradeArt: the cable-trade movie's tiles out of the ROM -- (Gen1TradeArt); without it the trade ran on drawn stand-ins. @@ -4906,6 +4909,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 +5456,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 +7160,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 @@ -8579,6 +8587,9 @@ RomImporter.SETTINGS_ROWS = { labels = { gbc = "GBC", sgb = "SGB", dmg = "DMG", classic = "CLASSIC" } }, { id = "gbcfx", label = "GBC FX", values = { false, true }, labels = { [false] = "OFF", [true] = "ON" } }, + { id = "gbcColorCorrection", label = "GBC COLOR (GEN 2)", + values = { "off", "accurate", "harsh" }, + labels = { off = "OFF", accurate = "MODERN ACCURATE", harsh = "HARSH REALITY" } }, { id = "videoMode", label = "VIDEO MODE", values = { "windowed", "borderless", "fullscreen" }, labels = { windowed = "WINDOWED", borderless = "BORDERLESS", @@ -9880,6 +9891,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 +10443,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/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/mods/Api3.lua b/src/mods/Api3.lua new file mode 100644 index 000000000..7ac25c885 --- /dev/null +++ b/src/mods/Api3.lua @@ -0,0 +1,234 @@ +-- API-3 entry over the existing native adapters. Unsupported operations have +-- callable handles but never delegate to a native method with a different contract. +local Records = require("src.mods.Api3Records") +local Runtime = require("src.mods.Runtime") +local Warp = require("src.mods.Api3Warp") +local Edits = require("src.mods.Api3WorldEdits") +local NPC = require("src.mods.Api3NPC") +local copy = require("src.mods.Merge").deepCopy +local A = {} +local WORLD = [[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview warpTo toggleObject + setFlag getFlag effectiveEncounters replaceBlock spawnNpc liveMaps removeNpc npc + queueScript startWildBattle invalidateMap]] +local SUPPORTED = {} +for name in + ([[overworld current activeBlockAt party canReorderParty reorderParty + availableFieldActions useFieldAction canFly flyTo mapOverview effectiveEncounters + setFlag getFlag liveMaps queueScript warpTo toggleObject replaceBlock spawnNpc removeNpc npc]]):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 storage = require("src.mods.PlaythroughStorage").new(api.id, loader.fs) + api.storage = {} + for _, method in ipairs({ "context", "selected", "read", "write", "readBytes", "writeBytes", "list", "delete" }) do + local name = method + api.storage[name] = function(_, ...) return storage[name](storage, ...) end + end + local gen = api.generation + local supportedGeneration = gen >= 1 and gen <= 3 + -- Passthrough registries keep native shapes but expose the same handle metadata. + for name, entry in pairs(api.content) do + local handle = entry + if type(handle) == "table" and handle.supports == nil then + local available, reason = api:supports("content", name) + handle.available = available + local get, each = handle.get, handle.each + if get then + handle.get = function(self, id) + local value, why = get(self, id) + return copy(value), why + end + end + if each then + handle.each = function(self) + local iter, state, key = each(self) + return function() + local id, value = iter(state, key) + key = id + return id, copy(value) + end + end + end + handle.supports = function() + return available, reason + end + end + if type(handle) == "table" and handle.has == nil and handle.get then + handle.has = function(_, id) + return handle:get(id) ~= nil + end + end + end + local scripts = api.world -- PortableScripts owns the per-loader/session queue. + local world = {} + local nativeGame, nativeWorld + local function current() + return loader:_game(true) -- Never bootstrap Game for an observational early call. + end + local function native(game) + if game ~= nativeGame then + nativeWorld = engineRequire("src.world.WorldAPI").new(game, api.id, readContext) + nativeWorld.overviewContext = overviewContext + nativeGame = game + end + return nativeWorld + end + local warp, licensed, npc + if supportedGeneration then + warp, licensed = Warp.new(loader, api.id, gen, current, native) + npc = NPC.new(loader, api.id, gen, current, native, licensed, engineRequire) + end + local function dispatch(name, ...) + if (not supportedGeneration and not (name == "liveMaps" and gen >= 1 and gen <= 5)) or not SUPPORTED[name] then + return nil, "unsupported: API 3 world method " .. name + end + if name == "spawnNpc" or name == "removeNpc" or name == "npc" then + return npc(name, ...) + end + if name == "warpTo" then + return warp(...) + end + local edit = name == "toggleObject" or name == "replaceBlock" + if edit and not licensed() then + return nil, "world facade is retired" + end + local game = current() + if not game then + return nil, "no game" + end + if edit then + return Edits.call(game, native(game), gen, engineRequire, name, ...) + end + if name == "setFlag" or name == "getFlag" then + 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 + if name == "liveMaps" then + local ow = native(game):overworld() + if not (ow and ow.map) then return nil, "no overworld" end + return require("src.world.LiveMaps").snapshot(ow) + 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 name == "liveMaps" and gen >= 1 and gen <= 5 then return true end + 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/Api3NPC.lua b/src/mods/Api3NPC.lua new file mode 100644 index 000000000..b1a29b51b --- /dev/null +++ b/src/mods/Api3NPC.lua @@ -0,0 +1,546 @@ +-- Public API-3 NPC handles. Engine objects never cross the handle boundary. +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Edits = require("src.mods.Api3WorldEdits") +local Controls = require("src.world.NpcControls") +local copy = require("src.mods.Merge").deepCopy +local N = {} +local services = setmetatable({}, { __mode = "k" }) +local observers = setmetatable({}, { __mode = "k" }) +local moves = {} +local dirs = { up = true, down = true, left = true, right = true } +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local function integer(n, minimum, maximum) + return type(n) == "number" + and n == n + and n >= (minimum or 0) + and n < math.huge + and n % 1 == 0 + and (not maximum or n <= maximum) +end +local function message(value) + local ok, text = pcall(tostring, value) + return ok and text or "unprintable error" +end +local function identifier(s) + return type(s) == "string" and #s > 0 and #s <= 256 and not s:find("%c") +end +local function reference(v) + return identifier(v) or integer(v) +end +-- Bounded data-only copy, including extra generation-native definition fields. +local function definition(value) + local visiting, nodes, bytes = {}, 0, 0 + local function clone(v, depth) + nodes = nodes + 1 + if nodes > 4096 or depth > 16 then + error("NPC definition too large", 0) + end + local t = type(v) + if t == "string" then + bytes = bytes + #v + if #v > 4096 or bytes > 65536 then + error("NPC definition too large", 0) + end + return v + elseif t == "number" then + if v ~= v or v == math.huge or v == -math.huge then + error("invalid NPC definition", 0) + end + return v + elseif t == "nil" or t == "boolean" then + return v + elseif t ~= "table" or getmetatable(v) ~= nil or visiting[v] then + error("invalid NPC definition", 0) + end + visiting[v] = true + local out, count = {}, 0 + for k, item in pairs(v) do + count = count + 1 + if count > 256 or type(k) ~= "string" and not integer(k) then + error("invalid NPC definition", 0) + end + out[clone(k, depth + 1)] = clone(item, depth + 1) + end + visiting[v] = nil + return out + end + if type(value) ~= "table" then + return nil, "invalid NPC definition" + end + local ok, result = pcall(clone, value, 0) + if not ok then + return nil, result + end + if not integer(result.x, 0, 1000000) or not integer(result.y, 0, 1000000) then + return nil, "invalid NPC coordinates" + end + for _, key in ipairs({ "name", "sprite" }) do + if result[key] ~= nil and not identifier(result[key]) then + return nil, "invalid NPC " .. key + end + end + if result.facing ~= nil and not dirs[result.facing] then + return nil, "invalid NPC facing" + end + return result +end +local function removeFrom(list, predicate) + for i = #(list or {}), 1, -1 do + if predicate(list[i]) then + table.remove(list, i) + end + end +end +local function retire(move) + move.done = true + moves[move] = nil + move.callback = nil + local removed = false + removeFrom(move.world.scriptMoves, function(entry) + if move.entries[entry] then + removed = true + return true + end + end) + if removed then + local foreign = false + for _, entry in ipairs(move.world.scriptMoves or {}) do + if entry.entity == move.actor then + foreign = true + end + end + if not foreign then + move.actor.moving, move.actor.marching = false, false + move.actor.targetX, move.actor.targetY = nil, nil + move.actor.px, move.actor.py = move.actor.cellX * 16, move.actor.cellY * 16 + end + end +end +local function removeSpawn(token) + -- Scoped by exact native record identity, even after Game/provider changes. + local rows = token.data.maps and token.data.maps[token.mapId] + local removed = false + removeFrom(rows and rows.objects, function(row) + if row == token.record then + removed = true + return true + end + end) + local function owned(actor) + return actor + and ( + actor == token.actor + or actor.def == token.record + or removed and actor.id == token.mapId .. "_obj_" .. tostring(token.record.index) + ) + end + removeFrom(token.world.npcs, owned) + removeFrom(token.world.entities, owned) + removeFrom(token.world.ghosts, function(g) + return owned(g.npc) + end) + for id, actor in pairs(token.world.npcPool or {}) do + if owned(actor) then + token.world.npcPool[id] = nil + end + end +end +function N.cancelAll(loader, owner) + for service in pairs(services) do + if (not loader or loader == service.loader) and (not owner or owner == service.owner) then + service.closed = true + for move in pairs(moves) do + if move.service == service then + retire(move) + end + end + for _, token in ipairs(service.spawned) do + removeSpawn(token) + end + service.spawned = {} + services[service] = nil + end + end +end +local function inspect(loader) + for move in pairs(moves) do + if move.service.loader == loader then + local ok, valid = pcall(move.valid) + if not ok or not valid then + retire(move) + end + end + end +end +local function observe(loader) + if observers[loader] and observers[loader][1] == loader.hooks then + return + end + observers[loader] = setmetatable({ loader.hooks }, { __mode = "v" }) + loader.hooks:wrap("input.step", function(nextFn, ...) + if next(moves) == nil then + return nextFn(...) + end + inspect(loader) + local result = pack(pcall(nextFn, ...)) + inspect(loader) + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, math.huge, "engine:api3_npc") + for _, event in ipairs({ "map.exited", "map.entered" }) do + loader.events:on(event, function() + for move in pairs(moves) do + if move.service.loader == loader then + retire(move) + end + end + end, math.huge, "engine:api3_npc") + end +end +function N.new(loader, owner, gen, current, native, licensed, req) + local service = { loader = loader, owner = owner, spawned = {} } + services[service] = true + observe(loader) + local function alive() + return not service.closed and licensed() + end + local function resolveGame() + if not alive() then + return nil, nil, "world facade is retired" + end + local game = current() + if not game then + return nil, nil, "no game" + end + local ow = native(game):overworld() + if not ow then + return nil, nil, "no overworld" + end + return game, ow + end + local function mapId(game, id) + if gen == 3 and not (game.data.maps or {})[id] then + local prefix = ({ ruby = "RU_", sapphire = "SA_", emerald = "EM_" })[Version.get()] or "FR_" + if (game.data.maps or {})[prefix .. id] then + return prefix .. id + end + end + return id + end + local function actorAt(ow, id, ref) + local function matches(actor) + local def = actor.def or {} + return actor.id == ref or def.index == ref or def.name == ref + end + if ow.map and ow.map.id == id then + for _, actor in ipairs(ow.npcs or {}) do + if matches(actor) then + return actor, ow.map, ow.cast or ow.entities + end + end + return nil, "no such object: " .. tostring(ref) + end + for _, g in ipairs(ow.ghosts or {}) do + local actor = g.npc + local map = g.map + if map and map.id == id and actor and matches(actor) then + local peers = g.nb and g.nb.cast or g.peers + if not peers then + peers = {} + for _, sibling in ipairs(ow.ghosts) do + if sibling.map == map then + peers[#peers + 1] = sibling.npc + end + end + end + return actor, map, peers, true + end + end + return nil, "map is not live" + end + local function sprite(game, id) + return game.data and game.data.sprites and game.data.sprites[id] + end + local function queue(game, ow, actor, valid, name, a, b, callback) + local hooks, events = Runtime.hooks, Runtime.events + local move = { service = service, world = ow, actor = actor, entries = {}, callback = callback } + local save, data, stack, map, version, fastWorld = + game.save, game.data, game.stack, ow.map, Version.get(), game.overworld + function move.valid() + return alive() + and current() == game + and game.save == save + and game.data == data + and game.stack == stack + and game.overworld == fastWorld + and ow.map == map + and Version.get() == version + and Runtime.hooks == hooks + and Runtime.events == events + and valid() + end + local accepted, deferred + local function finish(...) + if move.done then + return + end + if accepted == nil then + if not deferred then + deferred = pack(...) + end + return + end + local checked, valid = pcall(move.valid) + local fn = accepted and checked and valid and move.callback + retire(move) + if not fn then + return + end + local previous = Runtime.currentMod + Runtime.currentMod = owner + local ok, why = pcall(fn, ...) + Runtime.currentMod = previous + if not ok then + Runtime.reportError(owner, "NPC movement callback failed: " .. message(why)) + end + end + local before = {} + for _, entry in ipairs(ow.scriptMoves or {}) do + before[entry] = true + end + moves[move] = true + local called, result, detail + if name == "scriptMove" then + called, result, detail = pcall(ow.scriptMove, ow, actor, a, b or 1, finish) + else + called, result, detail = pcall(ow.marchInPlace, ow, actor, finish) + end + for _, entry in ipairs(ow.scriptMoves or {}) do + if not before[entry] and entry.entity == actor then + move.entries[entry] = true + end + end + accepted = called and result ~= false and not (result == nil and detail ~= nil) + if accepted then + local checked, valid = pcall(move.valid) + accepted = checked and valid and not move.done + end + if not accepted then + retire(move) + return nil, + called and (detail or "NPC movement failed") or "world operation failed: " .. message(result) + end + local drain = deferred and function() + finish(unpack(deferred, 1, deferred.n)) + end + return true, nil, drain + end + local function handle(game, ow, id, ref) + local actor, map = actorAt(ow, id, ref) + if not actor then + return nil, map + end + local publicId = actor.id + if not identifier(publicId) then + return nil, "NPC identity unavailable" + end + local save, data, version, fastWorld = game.save, game.data, Version.get(), game.overworld + local function live() + if + not alive() + or current() ~= game + or game.save ~= save + or game.data ~= data + or game.overworld ~= fastWorld + or Version.get() ~= version + or native(game):overworld() ~= ow + then + return + end + local found, activeMap, peers, ghost = actorAt(ow, id, publicId) + if found ~= actor or activeMap ~= map then + return + end + return actor, activeMap, peers, ghost + end + local out = { id = publicId } + local function invoke(name, a, b, c) + if not alive() then + return nil, "world facade is retired" + end + local npc, onMap, peers, ghost = live() + if not npc then + return nil, "NPC handle is stale" + end + if name == "position" then + return npc.cellX, npc.cellY + end + if name == "isMoving" then + return Controls.isMoving(npc) + end + if name == "canStep" or name == "stepNow" or name == "face" or name == "scriptMove" then + if not dirs[a] then + return nil, "invalid direction" + end + end + if name == "canStep" then + return Controls.canStep(npc, onMap, peers, a) + end + if + name == "scriptMove" and (b ~= nil and not integer(b, 1, 1024) or c ~= nil and type(c) ~= "function") + then + return nil, "invalid movement" + end + if name == "marchInPlace" and a ~= nil and type(a) ~= "function" then + return nil, "invalid callback" + end + if + name == "placeAt" + and (not integer(a, 0, 1000000) or not integer(b, 0, 1000000) or c ~= nil and not dirs[c]) + then + return nil, "invalid coordinates" + end + if name == "setPassable" and type(a) ~= "boolean" then + return nil, "invalid passability" + end + if name == "setAppearance" and not identifier(a) then + return nil, "invalid sprite id" + end + local ok, why = Edits.ready(game, ow) + if not ok then + return nil, why + end + if ghost and (name == "scriptMove" or name == "marchInPlace") then + return nil, "movement tracks require the active map" + end + local drain + local value, detail = Edits.run(game, function() + if name == "scriptMove" or name == "marchInPlace" then + local value, why + local callback = a + if name == "scriptMove" then + callback = c + end + value, why, drain = queue(game, ow, npc, live, name, a, b, callback) + return value, why + elseif name == "face" then + npc.facing = a + elseif name == "stepNow" then + return Controls.stepNow(npc, a) + elseif name == "placeAt" then + return Controls.placeAt(npc, a, b, c) + elseif name == "setPassable" then + return Controls.setPassable(npc, a) + elseif name == "setAppearance" then + return Controls.setAppearance(npc, game, a, req) + end + return true + end) + if drain then + drain() + end + return value, detail + end + for _, name in ipairs({ + "position", + "isMoving", + "canStep", + "scriptMove", + "marchInPlace", + "face", + "stepNow", + "placeAt", + "setPassable", + "setAppearance", + }) do + local method = name + out[method] = function(_, ...) + local previous = Runtime.currentMod + Runtime.currentMod = nil + local result = pack(pcall(invoke, method, ...)) + Runtime.currentMod = previous + if not result[1] then + return nil, "world operation failed: " .. message(result[2]) + end + return unpack(result, 2, result.n) + end + end + return out + end + return function(name, a, b) + local game, ow, why = resolveGame() + if not game then + return nil, why + end + if name == "npc" then + if not identifier(a) or not reference(b) then + return nil, "invalid NPC reference" + end + return handle(game, ow, mapId(game, a), b) + end + local ok, reason = Edits.ready(game, ow) + if not ok then + return nil, reason + end + return Edits.run(game, function() + if name == "spawnNpc" then + if not identifier(a) or type(b) ~= "table" then + return nil, "invalid NPC definition" + end + local id = mapId(game, a) + local mapDef = game.data.maps and game.data.maps[id] + if not mapDef then + return nil, "unknown map: " .. a + end + local row, err = definition(b) + if not row then + return nil, err + end + if row.sprite and not sprite(game, row.sprite) then + return nil, "unknown sprite: " .. row.sprite + end + local before = {} + for _, item in ipairs(mapDef.objects or {}) do + before[item] = true + end + local called, value, detail = pcall(native(game).spawnNpc, native(game), id, row) + for _, item in ipairs(mapDef.objects or {}) do + if not before[item] and item.runtime and item.owner == owner then + local publicId = id .. "_obj_" .. tostring(item.index) + local actor = actorAt(ow, id, publicId) + local token = { data = game.data, world = ow, mapId = id, record = item, actor = actor } + if not called or not value then + removeSpawn(token) + else + service.spawned[#service.spawned + 1] = token + end + end + end + if not called then + return nil, "world operation failed: " .. message(value) + end + return value, detail + end + if not reference(a) or gen == 3 and not identifier(a) then + return nil, "invalid NPC id" + end + local value, err = native(game):removeNpc(a) + if value then + for i = #service.spawned, 1, -1 do + local token = service.spawned[i] + if token.mapId .. "_obj_" .. tostring(token.record.index) == a then + table.remove(service.spawned, i) + end + end + end + return value, err + end) + end +end +return N diff --git a/src/mods/Api3Records.lua b/src/mods/Api3Records.lua new file mode 100644 index 000000000..22d540b20 --- /dev/null +++ b/src/mods/Api3Records.lua @@ -0,0 +1,59 @@ +-- API-3 public Pokemon values. API-2 unified records keep their existing shape. +local R = {} +local Stats = require("src.pokemon.Stats") +local bit = require("bit") +local LONG = { SLP = "sleep", PSN = "poison", BRN = "burn", FRZ = "freeze", PAR = "paralyze", TOX = "toxic" } +local SHORT = {} +for short, long in pairs(LONG) do + SHORT[long], SHORT[short] = short, short +end +function R.status(value) + if value == false or value == "" or value == 0 then + return nil + end + return SHORT[value] or value +end +function R.nativeStatus(value, previous) + if value == nil or value == false then + return nil + end + local short = R.status(value) + assert(LONG[short], "unknown status") + if previous and LONG[SHORT[previous]] == previous then + return LONG[short] + end + return short +end +function R.stats(out, gen) + out.special = gen ~= 1 and out.specialAttack or out.special + return out +end +function R.shiny(raw, gen) + if raw.shiny ~= nil then + return raw.shiny == true + end + if gen ~= 3 then + return Stats.isShiny(raw.dvs) + end + if raw.isShiny ~= nil then + return raw.isShiny == true + end + local p = (tonumber(raw.personality) or 0) % 4294967296 + local id = (tonumber(raw.otId or raw.trainerId) or 0) % 4294967296 + local secret = tonumber(raw.otSecretId) or math.floor(id / 65536) + return bit.bxor(id % 65536, secret, math.floor(p / 65536), p % 65536) < 8 +end +function R.mon(out, data, gen) + if type(out) ~= "table" then + return out + end + out.status = R.status(out.status) + out.stats = R.stats(out.stats or {}, gen) + local raw = out.native or {} + local def = ((data or {}).pokemon or {})[raw.species or out.species] or {} + out.name = out.nickname and out.nickname ~= "" and out.nickname or out.name or def.name or out.species + out.dvs = gen ~= 3 and type(raw.dvs) == "table" and raw.dvs or nil -- raw is already a detached snapshot + out.shiny = R.shiny(raw, gen) + return out +end +return R diff --git a/src/mods/Api3Warp.lua b/src/mods/Api3Warp.lua new file mode 100644 index 000000000..400465d87 --- /dev/null +++ b/src/mods/Api3Warp.lua @@ -0,0 +1,250 @@ +-- 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 +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 + 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 + 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 = facing or (generation ~= 1 and ow.player.facing) or "down" + 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/Api3WorldEdits.lua b/src/mods/Api3WorldEdits.lua new file mode 100644 index 000000000..c21586e25 --- /dev/null +++ b/src/mods/Api3WorldEdits.lua @@ -0,0 +1,165 @@ +-- 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 api:toggleObject(id, ref, visible) + 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 } + if gen == 2 then + if not store(save.g2ObjectToggles) then + return nil, "invalid object toggle store" + end + req("src.script.Gen2Commands").setObjectVisibility(ctx, object, id, visible) + else + local index = object.index + 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 + 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 +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 api:replaceBlock(bx, by, block) + end + -- 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 +-- 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 +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 operation = name == "toggleObject" and toggle or replace + return E.run(game, operation, game, ow, api, generation, req, ...) +end +return E diff --git a/src/mods/ApricornContent.lua b/src/mods/ApricornContent.lua new file mode 100644 index 000000000..2146365c6 --- /dev/null +++ b/src/mods/ApricornContent.lua @@ -0,0 +1,236 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +local Merge=require('src.mods.Merge') +local Ids=require('src.mods.UnifiedIds') +local Programs=require('src.world.Gen2ApricornPrograms') +local M={} +local mounted=setmetatable({},{__mode='k'}) +local previous=setmetatable({},{__mode='k'}) +local copy=Merge.deepCopy +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(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 +local function finite(v,seen) + if type(v)=='number'then assert(v==v and math.abs(v)(ranks[owner] or 0)then owner=other end + problems[#problems+1]={owner=owner,message='apricorns: duplicate '..label..' in '..old..' and '..id} + end + for id,value in registry:each()do + local ok,r=pcall(row,loader,id,value,false) + if not ok then problems[#problems+1]={owner=registry.owners[id],message=tostring(r)} + else + if items[r.apricorn]then conflict(id,items[r.apricorn],'native apricorn item')else items[r.apricorn]=id end + if events[r.event]then conflict(id,events[r.event],'recipe event')else events[r.event]=id end + end + end + return problems +end +function M.prepare(loader,data) + local history=data and previous[data] + if history and data.map_scripts==history.pool then + local pool=history.pool + if pool.apricornBalls==history.rows then + -- Undo per record, not per whole array: one external edit must not + -- keep every other unchanged recipe owned by the disabled mod alive. + local present,replacements,removed,extras={},{},{},{} + for _,value in ipairs(history.rows)do present[value]=true end + for owned,entry in pairs(history.rowOwners)do + if present[owned]then + if not equal(owned,entry.snapshot)then + if entry.before then replacements[entry.before]=owned + else extras[owned]=true end + end + elseif entry.before then removed[entry.before]=true end + end + for _,value in ipairs(history.rows)do + if not history.rowOwners[value]then + local before=type(value)=='table' and history.beforeByItem[value.apricorn] + if before and not replacements[before]then + replacements[before]=value;removed[before]=nil + else extras[value]=true end + end + end + local restored={} + for _,value in ipairs(history.beforeRows)do + if not removed[value]then restored[#restored+1]=replacements[value] or value end + end + for _,value in ipairs(history.rows)do + if extras[value]then restored[#restored+1]=value end + end + local pristine=#restored==#history.beforeRows + for i,value in ipairs(restored)do if value~=history.beforeRows[i]then pristine=false;break end end + pool.apricornBalls=pristine and history.beforeRows or restored + end + if pool.scripts==history.scripts then + for key,value in pairs(history.changed)do + if pool.scripts[key]==value and equal(value,history.snapshots[key])then + pool.scripts[key]=history.beforeScripts[key] + end + end + local same=true + for key,value in pairs(pool.scripts)do if history.beforeScripts[key]~=value then same=false;break end end + if same then + for key,value in pairs(history.beforeScripts)do if pool.scripts[key]~=value then same=false;break end end + end + if same then pool.scripts=history.beforeScripts end + end + end + if data then previous[data]=nil end + local desc,why + if loader:_generation()==2 then desc,why=Programs.describe(data)end + local state={data=data,description=desc,why=why,base={}} + if desc then + local ids=Ids.new(loader) + for _,r in ipairs(desc.base)do + local id=ids:read('items',r.apricorn) or r.apricorn + if state.base[id]then id=r.apricorn end + state.base[id]=copy(r) + end + end + mounted[loader]=state + loader.content.apricorns.base=function()return state.base end +end +function M.apply(loader,data) + if loader:_generation()~=2 or not data then return end + local registry=loader.content.apricorns + if not next(registry.ops) and not require('src.mods.Runtime').wants('apricorn.converted')then return end + local state=assert(mounted[loader]);local desc=state.description + if not desc and not next(registry.ops)then return end + assert(desc,state.why) + local pool=data.map_scripts + assert(pool and pool.scripts==desc.scripts,'apricorns: native Kurt programs changed during mod load') + local recipes,normalized,ids=ordered(loader) + -- A removed/rekeyed native recipe stops accepting new work, but an old + -- event can still deliver its original job unless an active rule claims it. + local scripts,touched=Programs.project(desc,recipes,desc.base) + local history={pool=pool,beforeRows=pool.apricornBalls,rows=copy(recipes), + beforeByItem={},rowOwners={},beforeScripts=pool.scripts,scripts=scripts,changed={},snapshots={}} + for _,value in ipairs(history.beforeRows)do history.beforeByItem[value.apricorn]=value end + for i,value in ipairs(history.rows)do + local base=state.base[ids[i]] + history.rowOwners[value]={snapshot=copy(value),before=base and history.beforeByItem[base.apricorn]} + end + for key in pairs(touched)do history.changed[key]=scripts[key];history.snapshots[key]=copy(scripts[key])end + -- Keep the folded view in actual native keys after deferred references + -- resolve. The generic post-merge reference audit reads the same view as + -- the native projection; the owner journals remain intact for rollback. + for id,value in pairs(normalized)do registry.cache[id]={value=value}end + -- All validation and IR construction precedes the first native mutation. + pool.apricornBalls=history.rows;pool.scripts=scripts + previous[data]=history +end +function M.api(loader,mod,native,generation) + local available=generation==2 + local reason=not available and 'unsupported: apricorns has no native target in this dataset' or nil + local registry=loader.content.apricorns + local function key(id) + if registry:get(id)~=nil or registry.ops[id]then return id end + local ok,item=pcall(nativeItem,loader,id) + if ok then + for other,value in registry:each()do if value.apricorn==item then return other end end + end + return id + end + local function view(value) + if not value then return nil end + local ids=Ids.new(loader);value=copy(value) + value.apricorn=ids:read('items',value.apricorn) + value.ball=ids:read('items',value.ball) + return value + end + local out={available=available,supports=function()return available,reason end} + function out:get(id)if not available then return nil,reason end;return view(native:get(key(id)))end + function out:has(id)return self:get(id)~=nil end + function out:each() + if not available then return function()end end + local nextRow=native:each() + return function()local id,value=nextRow();return id,view(value)end + end + for _,verb in ipairs({'register','override','patch','remove'})do + out[verb]=function(_,id,value) + if not available then return nil,reason end + assert(type(id)=='string' and id~='','apricorns: id is required');id=key(id) + local state=assert(mounted[loader]);assert(state.description,state.why) + if verb=='remove'then native:remove(id);return end + finite(value);assert(type(value)=='table' and value~=Merge.DELETE,'apricorns: expected recipe record') + for k in pairs(value)do assert(registry.spec.fields[k],'apricorns: unknown field '..tostring(k))end + local payload=copy(value) + local effective=verb=='patch' and Merge.deepMerge(copy(registry:get(id) or{}),payload,'record') or payload + effective=row(loader,id,effective,true) + ordered(loader,id,effective,true) + native[verb](native,id,effective) + return out:get(id) + end + end + return out +end +return M 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..66936f8f4 --- /dev/null +++ b/src/mods/Capabilities.lua @@ -0,0 +1,119 @@ +-- Availability of the existing API-2 surfaces, before a Game/overworld exists. +-- Keep callback names tied to native call sites, not to the permissive buses: +-- successfully subscribing to a name does not mean the engine ever fires it. +local GameVersion = require("src.core.GameVersion") +local Schemas = require("src.mods.Schemas") + +local Capabilities = {} +local callbacks = { hooks = {}, events = {} } +local function add(surface, generations, names) + for name in names:gmatch("%S+") do + callbacks[surface][name] = generations + end +end + +-- Shared simulation, world, input, rendering and lifecycle code. A callback +-- need not fire on every action (e.g. an always-hit move skips accuracy). +add("hooks", "12345", [[ + battle.accuracy battle.bottom_ui_visible battle.catch_exp battle.crit battle.damage battle.enemy_action + battle.exp_award battle.low_health_alarm battle.menu_auxiliary battle.move_grid_navigation 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.before_battle trainer.party transition.style ui.list_menu ui.options.rows + ui.title_menu.items warp.destination world.talk 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", "12345", "battle.status_hud_visible") +add("hooks", "123", "pokemon.level_visible ui.party.grid_navigation") +add("hooks", "123", "world.follower.spawn") +add("hooks", "2", "phone.contact_list breeding.compatibility catch.nickname catch.party_full battle.style battle.enemy_switch_or_item") +add("hooks", "12345", "core.quit_to_launcher") +add("hooks", "12", "intro.oak_speech.build") + +add("events", "12345", [[ + world.live_maps_preparing world.live_maps_updated + 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", "2", "clock.day_changed") +add("events", "2", "radio.channel phone.call_received apricorn.converted breeding.egg_created egg.hatched happiness.changed pokerus.infected roamer.encountered roamer.moved unown.unlocked bug_contest.scored intro.boot.copyright intro.boot.gamefreak intro.boot.movie intro.boot.movie_ended intro.boot.title") +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 (name=="unown.unlocked" or name=="happiness.changed" or name=="pokerus.infected") + and version~="gold" and version~="silver" and version~="crystal"then + return false,name.." has no native producer in "..version + end + 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/DecodedCallbacks.lua b/src/mods/DecodedCallbacks.lua new file mode 100644 index 000000000..c2b56f8bd --- /dev/null +++ b/src/mods/DecodedCallbacks.lua @@ -0,0 +1,312 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Selected by the host per mod; imported mod bytes and native buses stay intact. +local M = {} +local unpack = table.unpack or unpack +local function pack(...) + return { n = select("#", ...), ... } +end +local fields = { + setvar = { "var", "value" }, + addvar = { "var", "value" }, + subvar = { "var", "value" }, + copyvar = { "var", "src" }, + setorcopyvar = { "var", "value" }, + compare_var_to_value = { "var", "value" }, + loadword = { "dest", "value" }, + setflag = { "flag" }, + clearflag = { "flag" }, + checkflag = { "flag" }, + callstd = { "id" }, + jumpstd = { "id" }, + gotostd = { "std" }, + ["goto"] = { "target" }, + call = { "target" }, + goto_if = { "cond", "target" }, + call_if = { "cond", "target" }, + setval = { "value" }, + addval = { "value" }, + writebyte = { "value" }, + setevent = { "event" }, + clearevent = { "event" }, + checkevent = { "event" }, + scall = { "script" }, + farscall = { "script" }, + sjump = { "script" }, + farsjump = { "script" }, + iftrue = { "script" }, + iffalse = { "script" }, + ifequal = { "value", "script" }, + ifnotequal = { "value", "script" }, + ifgreater = { "value", "script" }, + ifless = { "value", "script" }, + writetext = { "text" }, + jumptext = { "text" }, + farwritetext = { "text" }, + givepoke = { "species", "level", "item", "trainer", "name", "otName" }, + giveegg = { "species", "level" }, + givemon = { "species", "level", "item" }, + sdefer = { "script" }, + stopandsjump = { "script" }, + jumptextfaceplayer = { "text" }, + farjumptext = { "text" }, + pause = { "frames" }, + setscene = { "scene" }, + setmapscene = { "group", "map", "scene" }, + turnobject = { "object", "facing" }, + applymovement = { "object", "movement" }, + applymovementlasttalked = { "movement" }, + special = { "id" }, + playmusic = { "id" }, + playsound = { "id" }, + cry = { "id" }, + getmonname = { "species", "buffer" }, + getitemname = { "item", "buffer" }, + gettrainername = { "group", "trainer", "buffer" }, + loadtrainer = { "class", "member" }, + loadwildmon = { "species", "level" }, + winlosstext = { "winText", "lossText" }, + trainertext = { "index" }, + trainerflagaction = { "action" }, + setlasttalked = { "object" }, + showemote = { "emote", "object", "frames" }, + giveitem = { "item", "quantity" }, + verbosegiveitem = { "item", "quantity" }, + addcellnum = { "phone" }, + delcellnum = { "phone" }, + checkcellnum = { "phone" }, + readvar = { "var" }, + writevar = { "var" }, + follow = { "a", "b" }, + faceobject = { "a", "b" }, + follownotexact = { "a", "b" }, + trade = { "trade" }, + describedecoration = { "decoration" }, +} +local function copy(t) + local out = {} + for k, v in pairs(t) do + out[k] = v + end + return out +end +-- Only lossless numeric byte layouts can be reconstructed from native IR. +-- Decoded pointers/text use named fields; normalized hack layouts are not bytes. +local function rawOperands(op, ir) + local version = require("src.core.GameVersion").get() + if version ~= "gold" and version ~= "silver" and version ~= "crystal" then + return {}, nil + end + local spec + for _, command in ipairs(require("src.import.Gen2ScriptOps").commandsFor(version)) do + if command[1] == op then + spec = command[2] + break + end + end + if not spec or spec:find("[^bwm]") or #spec ~= #ir - 1 then + return {}, nil + end + local out = {} + for i = 1, #spec do + local value, width = ir[i + 1], ({ b = 1, w = 2, m = 3 })[spec:sub(i, i)] + if type(value) ~= "number" or value % 1 ~= 0 or value < 0 or value >= 256 ^ width then + return {}, nil + end + for _ = 1, width do + out[#out + 1] = value % 256 + value = math.floor(value / 256) + end + end + return out, spec +end +local function context(ctx) + local world, map = ctx.overworld, ctx.overworld and ctx.overworld.map + if not map then + return ctx + end + local data = ctx.game and ctx.game.data + local maps = data and data.maps + local version = require("src.core.GameVersion").get() + local id = map.id + if maps and (version == "firered" or version == "leafgreen") then + local def = maps[map.id] + local verified = def and map.def == def and def.group == 4 and def.number == 3 + if verified then + for _, other in pairs(maps) do + if type(other) == "table" and other ~= def and other.group == 4 and other.number == 3 then + verified = false + break + end + end + end + -- One explicit callback alias, qualified against this world's native + -- definition. Similar spellings or an unbound slot are not evidence. + if verified then + id = "OAKS_LAB" + end + end + local publicMap = setmetatable({ id = id }, { __index = map }) + local publicWorld = setmetatable({ map = publicMap }, { + __index = world, + __newindex = function(_, k, v) + world[k] = v + end, + }) + return setmetatable({ overworld = publicWorld }, { + __index = ctx, + __newindex = function(_, k, v) + ctx[k] = v + end, + }) +end +function M.install(loader, api, owner, generation) + if generation ~= 2 and generation ~= 3 then + return + end + local aliases = copy(fields) + if generation == 3 then + aliases.callstd = { "std" } + aliases.special = { "id" } + aliases.applymovement = { "localId", "movement" } + end + local wrap, on, once = api.hooks.wrap, api.events.on, api.events.once + local ids = require("src.mods.UnifiedIds").new(loader) + ids:entries("pokemon") + api.hooks.wrap = function(self, name, callback, priority) + if name ~= "script.command" then + return wrap(self, name, callback, priority) + end + return loader._decodedHooks:wrap(name, function(nextFn, ctx, op, ir) + local row = { op = op } + for i = 2, #ir do + row[i - 1] = ir[i] + end + for i, field in ipairs(aliases[op] or {}) do + row[field] = row[i] + end + local original = copy(row) + local public = context(ctx) + local args, byteSpec = {}, nil + if generation == 2 then + args, byteSpec = rawOperands(op, ir) + row.args = args + end + local originalArgs = copy(args) + local active = true + local function nextPublic(hctx, hname, hrow, hcmd) + assert(active, "decoded next is only valid during its callback") + assert(hctx == nil or hctx == public, "decoded next must retain its script context") + local edited = generation == 2 and (hcmd or row) or (hrow or row) + local raw = copy(ir) + for i = 1, math.max(#row, #edited) do + raw[i + 1] = edited[i] + end + for i, field in ipairs(aliases[op] or {}) do + if edited[field] ~= original[field] then + raw[i + 1] = edited[field] + end + end + if generation == 2 then + local bytes = hrow or edited.args or args + local changed = #bytes ~= #originalArgs + for k, v in pairs(bytes) do + if originalArgs[k] ~= v then + changed = true + end + end + if changed then + assert( + byteSpec, + "raw Gen-2 byte rewrite is unavailable for this normalized command; use decoded fields" + ) + assert(#bytes == #originalArgs, "Gen-2 operand byte width changed") + local at = 1 + for i = 1, #byteSpec do + local value, width = 0, ({ b = 1, w = 2, m = 3 })[byteSpec:sub(i, i)] + for n = 0, width - 1 do + local byte = bytes[at] + at = at + 1 + assert( + type(byte) == "number" and byte % 1 == 0 and byte >= 0 and byte < 256, + "invalid Gen-2 operand byte" + ) + value = value + byte * 256 ^ n + end + raw[i + 1] = value + end + end + end + raw[1] = hname or edited.op or op + return nextFn(ctx, raw[1], raw) + end + local result + if generation == 2 then + result = pack(pcall(callback, nextPublic, public, op, args, row)) + else + result = pack(pcall(callback, nextPublic, public, op, row)) + end + active = false + if not result[1] then + error(result[2], 0) + end + return unpack(result, 2, result.n) + end, priority, owner) + end + local function subscribe(register, self, name, callback, priority) + if name ~= "pokemon.before_give" then + return register(self, name, callback, priority) + end + return register(self, name, function(gift) + local view = copy(gift) + view.ctx = context(gift.ctx) + view.species = ids:read("pokemon", gift.species) + local order = gift.ctx.game and gift.ctx.game.data.constants and gift.ctx.game.data.constants.speciesOrder + or {} + local function numberFor(species) + for number, id in pairs(order) do + if id == species then + return number + end + end + end + view.speciesId = numberFor(gift.species) + local before = view.species + callback(view) + local species = gift.species + if view.species ~= before then + if type(view.species) == "number" then + species = assert(order[view.species], "unknown decoded gift species number") + else + species = assert(ids:resolve("pokemon", view.species)).key + end + end + local level = tonumber(view.level) + level = level and math.max(1, math.min(100, math.floor(level))) or gift.level + local nickname = type(view.nickname) == "string" and view.nickname ~= "" and view.nickname or gift.nickname + -- A variable-backed Gen-3 gift keeps the requested species in that same + -- native variable for the rest of the starter/story script. + local ctx, number = gift.ctx, numberFor(species) + local command = ctx.script and ctx.script[ctx.pc] + if + generation == 3 + and species ~= gift.species + and number + and command + and command[1] == "g3_give_pokemon" + and type(command[2]) == "number" + and command[2] >= 0x4000 + then + ctx.save.gen3Vars = ctx.save.gen3Vars or {} + ctx.save.gen3Vars[command[2]] = number + end + gift.species, gift.level, gift.nickname = species, level, nickname + end, priority) + end + api.events.on = function(self, ...) + return subscribe(on, self, ...) + end + api.events.once = function(self, ...) + return subscribe(once, self, ...) + end +end +return M diff --git a/src/mods/DecorationContent.lua b/src/mods/DecorationContent.lua new file mode 100644 index 000000000..059e0f790 --- /dev/null +++ b/src/mods/DecorationContent.lua @@ -0,0 +1,101 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- A DECO byte is identity, not the sorted position of a mod record. +local Merge=require('src.mods.Merge') +local Native=require('src.world.Gen2Decorations') +local Schemas=require('src.mods.Schemas') +local M={} +local copy=Merge.deepCopy +local mounted=setmetatable({},{__mode='k'}) +local previous=setmetatable({},{__mode='k'}) +local function equal(a,b) + if a==b then return true end + if type(a)~='table'or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(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 +local function byte(id) + local digits=type(id)=='string'and id:match('^deco:(%d+)$') + local n=digits and tonumber(digits) + assert(n and n>=0 and n<=255 and 'deco:'..n==id,'decorations: expected canonical deco: ID') + return n +end +local function finite(value,seen) + if type(value)=='number'then assert(value==value and math.abs(value)=0 and n<=255 and type(row)=='table'then + state.base['deco:'..n]=copy(data.map_scripts.decorations[n]or row) + end + end + end + mounted[loader]=state + loader.content.decorations.base=function()return state.base end +end +function M.apply(loader,data) + local registry=loader.content.decorations;local state=mounted[loader] + if not(state and state.available)or not next(registry.ops)then return end + local pool=data.map_scripts;local before=pool.decorations;local out={} + for key,row in pairs(before)do if tonumber(key)==nil then out[key]=row end end + for id,value in registry:each()do out[byte(id)]=registry.ops[id]and record(registry,id,value)or copy(value)end + local changed={};local keys={} + for key in pairs(before)do keys[key]=true end;for key in pairs(out)do keys[key]=true end + for key in pairs(keys)do + if before[key]~=out[key]then changed[key]={before=before[key],value=out[key],snapshot=copy(out[key])}end + end + pool.decorations=out;previous[data]={pool=pool,before=before,after=out,changed=changed} +end +function M.api(loader,mod,native,generation) + local state=mounted[loader] + local available=generation==2 and state and state.available or false + local reason=not available and 'unsupported: decorations requires an imported native GSC attribute table'or nil + local registry=loader.content.decorations + local out={available=available,supports=function()return available,reason end} + function out:get(id)if not available then return nil,reason end;byte(id);return copy(native:get(id))end + function out:has(id)return self:get(id)~=nil end + function out:each() + if not available then return function()end end + local nextRow=native:each();return function()local id,value=nextRow();return id,copy(value)end + end + for _,verb in ipairs({'register','override','patch','remove'})do + out[verb]=function(_,id,value) + if not available then return nil,reason end + byte(id) + if verb=='remove'then return native:remove(id)end + finite(value);assert(type(value)=='table'and value~=Merge.DELETE,'decorations: expected record') + for key in pairs(value)do assert(registry.spec.fields[key],'decorations: unknown field '..tostring(key))end + local effective=verb=='patch'and Merge.deepMerge(copy(registry:get(id)or{}),copy(value),'record')or value + effective=record(registry,id,effective);native[verb](native,id,effective) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/Gen1Compat.lua b/src/mods/Gen1Compat.lua new file mode 100644 index 000000000..00cae7bc5 --- /dev/null +++ b/src/mods/Gen1Compat.lua @@ -0,0 +1,33 @@ +-- Compatibility spellings, kept separate from the loader's execution policy. +-- Only interchangeable native objects belong in ALIASES. Unsupported modules +-- fail at import with an actionable diagnostic instead of a misleading facade. +local C = {} +C.ALIASES = { + ['src.world.gen2.WorldAPI'] = 'src.world.WorldAPI', + ['src.world.gen2.Follower'] = 'src.world.OptionalFollower', + ['src.world.game3.Follower'] = 'src.compat.game3.Follower', + ['src.core.game3.pokemon'] = 'src.compat.game3.Pokemon', + ['src.core.game3.player'] = 'src.compat.game3.Player', + ['src.core.gen2.Unown'] = 'src.compat.gen2.Unown', +} +C.UNAVAILABLE = { + ['src.import.pmd.Sprite'] = { 'mystery_dungeon_follower' }, +} +-- Concrete host modules and registered providers always take precedence. +function C.hasProvider(name) + if type(name) ~= 'string' then return false end + if type(package.loaded[name]) == 'table' or type(package.preload[name]) == 'function' then return true end + if package.searchpath then + if package.searchpath(name, package.path or '') or package.searchpath(name, package.cpath or '') then return true end + end + local path = name:gsub('%.', '/') .. '.lua' + return love and love.filesystem and love.filesystem.getInfo + and love.filesystem.getInfo(path, 'file') ~= nil or false +end +function C.unavailable(name) + local consumers = C.UNAVAILABLE[name] + if not consumers or C.hasProvider(name) then return nil end + return ('Compatibility module %s is unavailable in this engine; affected mods: %s') + :format(name, table.concat(consumers, ', ')) +end +return C 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/LandmarkContent.lua b/src/mods/LandmarkContent.lua new file mode 100644 index 000000000..eb18aa634 --- /dev/null +++ b/src/mods/LandmarkContent.lua @@ -0,0 +1,135 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +-- Semantic landmark records project into the native town-map number space. +local Merge=require('src.mods.Merge') +local Native=require('src.world.Gen2Landmarks') +local Schemas=require('src.mods.Schemas') +local M={} +local copy=Merge.deepCopy +local previous=setmetatable({},{__mode='k'}) +local mounted=setmetatable({},{__mode='k'}) +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table'then return false end + for k,v in pairs(a)do if not equal(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 +local function finite(v,seen) + if type(v)=='number'then assert(v==v and math.abs(v)=0 and n<=255 and n or nil +end +local function record(registry,id,value) + assert(type(value)=='table' and value~=Merge.DELETE,'landmarks: expected record') + finite(value) + for key in pairs(value)do assert(registry.spec.fields[key],'landmarks: unknown field '..tostring(key))end + local ok,why=Schemas.check(registry.spec,'landmarks',id,value,'register');assert(ok,why) + assert(index(value.index),'landmarks: native index must be a byte') + return copy(value) +end +function M.prepare(loader,data) + local history=data and previous[data] + if history and data.field==history.field and history.field.townMap==history.town and + history.town.landmarks==history.after then + local rows=history.after + for key,entry in pairs(history.changed)do + if rows[key]==entry.value and equal(rows[key],entry.snapshot)then rows[key]=entry.before end + end + local pristine=true + for key,value in pairs(rows)do if history.before[key]~=value then pristine=false;break end end + if pristine then + for key,value in pairs(history.before)do if rows[key]~=value then pristine=false;break end end + end + if pristine then + if history.createdRows then history.town.landmarks=nil else history.town.landmarks=history.before end + end + if history.createdTown and not next(history.town)then history.field.townMap=nil end + if history.createdField and not next(history.field)then data.field=nil end + end + if data then previous[data]=nil end + local state={base={},order=Native.dataOrder(data)} + if loader:_generation()==2 then + local town=Native.town(data);local rows=town and town.landmarks or{} + local indices={} + for key in pairs(rows)do local n=index(key);if n then indices[n]=true end end + local ordered={};for n in pairs(indices)do ordered[#ordered+1]=n end;table.sort(ordered) + for _,n in ipairs(ordered)do + local native=rows[n] or rows[tostring(n)] + if type(native)=='table'then + local id=native.landmarkId or native.id or state.order[n+1] or 'landmark:'..n + if type(id)~='string' or id=='' or state.base[id]then id='landmark:'..n end + state.base[id]={id=native.id,name=native.name,x=native.x,y=native.y,index=n} + end + end + end + mounted[loader]=state + loader.content.landmarks.base=function()return state.base end +end +function M.apply(loader,data) + local registry=loader.content.landmarks + if loader:_generation()~=2 or not data or not next(registry.ops)then return end + local state=assert(mounted[loader]);local values,chosen={},{} + for id,value in registry:each()do + value=registry.ops[id] and record(registry,id,value) or copy(value) + values[id]=value + local n=index(value.index) + assert(n,'landmarks: native index must be a byte') + local old=chosen[n] + -- Match the reference's index arbitration: a native identity keeps its + -- original slot; newcomers use lexical ID order, independent of pairs(). + if not old or state.order[n+1]==id or state.order[n+1]~=old and id= 2 then error(err, 0) end Logger.warn("[%s] %s", modId, err) end - return { + local api = { register = function(_, id, value) note() validate("register", id, value) @@ -750,6 +773,23 @@ function Loader:_contentApi(mod, registry, deprecation) return registry:each() end, } + if registry.name == "radio_channels" then + return require("src.mods.RadioContent").api(loader,mod,api,loader:_generation()) + end + if registry.name == "phone_contacts" then + return require("src.mods.PhoneContent").api(loader,mod,api,loader:_generation()) + end + if registry.name == "apricorns" then + return require("src.mods.ApricornContent").api(loader,mod,api,loader:_generation()) + end + if registry.name == "landmarks" then + return require("src.mods.LandmarkContent").api(loader,mod,api,loader:_generation()) + end + if registry.name=="decorations"then + return require("src.mods.DecorationContent").api(loader,mod,api,loader:_generation()) + end + return api + end -- mod.commands is sugar over the commands registry; the engine's own verbs @@ -803,6 +843,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 +881,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 +891,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 +920,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 +937,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 +965,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 +1055,16 @@ function Loader:_api(mod) }, } self.exports[modId] = api.exports + local callbackProfile = self._callbackProfiles and self._callbackProfiles[modId] + assert(callbackProfile == nil or callbackProfile == "native" or callbackProfile == "decoded", + "unknown callback profile for " .. modId) + if callbackProfile == "decoded" then + assert(api.callbackFormat == "native", "decoded profile cannot be combined with unified callbacks") + engineRequire("src.mods.DecodedCallbacks").install(loader, api, modId, hostGen) + end + 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 +1086,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 +1189,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 +1239,41 @@ 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._npcAlive = function() + return loader.mods[modId] == mod and loader.apis[modId] == api + and loader.exports[modId] ~= nil and isActive(mod) + end + 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,11 +1320,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) + Api3NPC.cancelAll(self, modId) + Api3Warp.cancelAll(self, modId) + 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 self.events:removeOwner(modId) self.hooks:removeOwner(modId) + self._decodedHooks:removeOwner(modId) self.exports[modId] = nil self.optionSchemas[modId] = nil self.optionStatus[modId] = nil @@ -1310,12 +1440,20 @@ local function stampAudioOwners(data, name, registry) end function Loader:load(data) + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end + Api3NPC.cancelAll() + Api3Warp.cancelAll() + 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. ModSeams.unwindAll() self.baseData = data + require("src.compat.gen2.Decorations").useRegistry(data) -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later for _, registry in pairs(self.content) do @@ -1326,8 +1464,13 @@ function Loader:load(data) end end end + require("src.mods.RadioContent").prepare(self, data) + require("src.mods.ApricornContent").prepare(self, data) + require("src.mods.LandmarkContent").prepare(self, data) + require("src.mods.DecorationContent").prepare(self,data) -- vanilla content is registrations too, and they land before discovery so -- a mod's register collides with the engine's and has to say override + require("src.mods.PhoneContent").prepare(self, data) require("src.mods.Builtins").install(self.content, data) self:_loadState() self:_discover() @@ -1356,7 +1499,10 @@ function Loader:load(data) tostring(id or "?")) end for id, mod in pairs(self.mods) do - if not self.disabled[id] and mod.manifest.experimental + local env=mod.manifest.force_enable_env + local forceEnabled=not self.noMods and env and os.getenv(env)=='1' + if forceEnabled then self.disabled[id]=nil end + if not forceEnabled and not self.disabled[id] and mod.manifest.experimental and ModGens.active(modsOpt[id], gen) ~= true then self.disabled[id] = true end @@ -1450,6 +1596,7 @@ function Loader:load(data) end) -- engine call sites reach these buses -- and this error feed, for failures -- that only surface at play time -- through Runtime from here on + self.hooks._decodedHooks = self._decodedHooks Runtime.install(self.events, self.hooks, self.errors) self:_validate() local ordered = self:_resolve() @@ -1490,6 +1637,27 @@ function Loader:load(data) -- the commands registry is final once every entry chunk has run, so -- each map_scripts contribution's rows can be judged before they merge self:_validateScripts() + -- Resolve recipe/item forward references against all surviving entries, + -- before any native script projection or data merge. Repeat after owner + -- rollback so another recipe cannot retain an item supplied by a failed mod. + for _=1,#self.order+1 do + local problems=require("src.mods.ApricornContent").validate(self) + local failed=false + for _,problem in ipairs(problems)do + local mod=problem.owner and self.mods[problem.owner] + if mod and not mod.failed then self:_fail(mod,"failed",problem.message);failed=true end + end + if not failed then break end + self:_enforceDependencies() + for i=#self.loaded,1,-1 do + local mod=self.loaded[i] + if mod.failed then self:_rollback(mod.manifest.id);table.remove(self.loaded,i)end + end + for i=#self.order,1,-1 do + local mod=self.mods[self.order[i]] + if mod and mod.failed then table.remove(self.order,i)end + end + end -- merge: fold every touched id from its pristine base value and write it -- home, creating the Data namespace when the base modules never shipped -- one. A registry nobody wrote to -- engine included -- is skipped, so @@ -1551,6 +1719,10 @@ function Loader:load(data) stampAudioOwners(data, name, registry) end end + require("src.mods.PhoneContent").apply(self, data) + require("src.mods.ApricornContent").apply(self, data) + require("src.mods.LandmarkContent").apply(self, data) + require("src.mods.DecorationContent").apply(self,data) -- dangling f.id references are attributed to the id's last writer; -- api 1 mods keep the warning-only compat path if data then @@ -1632,6 +1804,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,6 +1827,14 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + local clock = package.loaded["src.core.Gen2Clock"] + if clock then clock.resetObservations() end + Api3NPC.cancelAll() + Api3Warp.cancelAll() + InputControl.closeAll() + PortableScripts.cancelAll(nil, nil, "mods unloaded") + require("src.compat.gen2.Decorations").useRegistry(nil) + Net.unloadAll() local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), 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..a153a896e 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -223,6 +223,16 @@ function Manifest.validate(raw, path) assert(api <= Version.modApi, ("requires mod API %d; this engine provides %d") :format(api, Version.modApi)) local strict = api >= 2 + -- API 3 is one portable contract, not three independent API-2 opt-ins. + for _, field in ipairs({ "content_format", "callback_format", "script_format" }) do + assert(api < 3 or raw[field] == nil or raw[field] == "unified", + "API 3 requires unified " .. field) + end + local defaultFormat = api >= 3 and "unified" or "native" + assert(raw.content_format == nil or raw.content_format == "native" + or raw.content_format == "unified", "content_format must be native or unified") + assert(raw.content_format ~= "unified" or api >= 2, + "unified content requires mod API 2") local profile = raw.profile or "content" if not Manifest.PROFILES[profile] then @@ -240,6 +250,23 @@ 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 forceEnv=raw.force_enable_env + assert(forceEnv==nil or type(forceEnv)=='string' and forceEnv~='', + 'force_enable_env must be a nonempty string') + + 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,16 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + force_enable_env = forceEnv, + 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/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/PhoneContent.lua b/src/mods/PhoneContent.lua new file mode 100644 index 000000000..b1a6372d5 --- /dev/null +++ b/src/mods/PhoneContent.lua @@ -0,0 +1,210 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-content.md. +-- Semantic contacts project onto the native numeric book, never onto saves. +local Merge = require('src.mods.Merge') +local M = {} +local previous = setmetatable({}, {__mode='k'}) +local mounted = setmetatable({}, {__mode='k'}) +local copy = Merge.deepCopy +local function equal(a,b) + if a==b then return true end + if type(a)~='table' or type(b)~='table' then return false end + for k,v in pairs(a) do if not equal(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 +local function finite(v,seen) + if type(v)=='number' then assert(v==v and math.abs(v)=1 and index<=255,'phone_contacts: invalid or exhausted index') + assert(not used[index] or used[index]==id,'phone_contacts: index collision') + assert(old or index>state.floor,'phone_contacts: native rows and holes are reserved') + return index +end +local function nativeRow(loader,id,row) + local state=assert(mounted[loader]);local data=state.data + local old=state.native[id] or {} + local out=copy(old) + out.name=row.name;out.contactNumber=row.number;out.rematchEvent=row.rematchEvent + if row.class or row.member then + assert(row.class and row.member,'phone_contacts: class and member are required together') + assert(row.number==nil,'phone_contacts: trainer and non-trainer identities are exclusive') + local c=data.constants or {} + out.class=assert(position(c.trainerClassOrder,row.class),'phone_contacts: unknown trainer class') + out.trainer=assert(position((c.trainerClassMembers or {})[row.class],row.member),'phone_contacts: unknown trainer member') + out.contactNumber=nil + elseif not (old.class and state.base[id] and not state.base[id].class) then + out.class=nil;out.trainer=nil + end + if row.map then + local def=loader.content.maps:get(row.map) + assert(type(def)=='table' and type(def.group)=='number' and type(def.number)=='number', + 'phone_contacts: map requires native group/number metadata') + out.group,out.number=def.group,def.number + elseif state.base[id] and state.base[id].map then + out.group,out.number=nil,nil + end + out.call=row.calleeKey or row.callee + out.receive=row.callerKey or row.caller + out.callTime,out.receiveTime=row.calleeTime or 0,row.callerTime or 0 + for _,field in ipairs({'call','receive'}) do + local key=out[field];local mask=out[field..'Time'] + assert(mask==0 or type(key)=='string' and key~='','phone_contacts: enabled direction needs a script key') + -- This version delegates existing imported scripts to the real native VM. + -- It does not interpret Gen1 bytecode or fabricate missing script labels. + if key then assert(data.map_scripts and data.map_scripts.scripts and data.map_scripts.scripts[key], + 'phone_contacts: script key is not in the native script pool: '..tostring(key)) end + end + assert(type(row.name)=='string' and row.name~='' or out.class, + 'phone_contacts: contact needs a name or trainer identity') + return out +end +function M.apply(loader,data) + local registry=loader.content.phone_contacts + if loader:_generation()~=2 or not data or not next(registry.ops) then return end + local state=assert(mounted[loader]);local plan={} + for _,id in ipairs(registry.order) do + if registry.ops[id] then + local row=registry:get(id);local old=state.base[id] + local index=row and row.index or old and old.index + if index then plan[#plan+1]={index=index,value=row and nativeRow(loader,id,row)} end + end + end + -- Validate every conversion before the first native write. + local createdPool=data.map_scripts==nil + data.map_scripts=data.map_scripts or {} + local createdPhone=data.map_scripts.phone==nil + data.map_scripts.phone=data.map_scripts.phone or {} + local target=data.map_scripts.phone;local undo={createdPool=createdPool,createdPhone=createdPhone,pool=data.map_scripts,target=target} + for _,entry in ipairs(plan) do + undo[#undo+1]={target=target,index=entry.index,before=target[entry.index], + value=entry.value,snapshot=copy(entry.value)} + target[entry.index]=entry.value + end + previous[data]=undo +end +function M.api(loader,mod,native,generation) + local registry=loader.content.phone_contacts;local available=generation==2 + local reason=not available and 'unsupported: phone_contacts has no native target in this dataset' or nil + local out={available=available,supports=function() return available,reason end} + local function view(row) + row=copy(row) + if row then row.callee=row.calleeKey or row.callee;row.caller=row.callerKey or row.caller end + return row + end + function out:get(id) if not available then return nil,reason end;return view(native:get(id)) end + function out:has(id) return self:get(id)~=nil end + function out:each() + if not available then return function() end end + local nextRow=native:each() + return function() local id,row=nextRow();return id,view(row) end + end + for _,verb in ipairs({'register','override','patch','remove'}) do + out[verb]=function(_,id,value) + if not available then return nil,reason end + assert(type(id)=='string' and id~='','phone_contacts: id is required') + if verb=='remove' then native:remove(id);return nil end + finite(value);assert(type(value)=='table' and value~=Merge.DELETE,'phone_contacts: expected record') + for key in pairs(value) do assert(registry.spec.fields[key],'phone_contacts: unknown field '..tostring(key)) end + local ok,err=require('src.mods.Schemas').check(registry.spec,'phone_contacts',id,value,verb) + assert(ok,err) + local payload=copy(value) + for _,direction in ipairs({'callee','caller'}) do + if payload[direction]~=nil then + local key=direction..'Key' + if payload[key]==nil then payload[key]=payload[direction] end + payload[direction]=nil + end + end + payload.index=indexFor(registry,assert(mounted[loader]),id,value) + local effective=Merge.deepMerge(verb=='patch' and copy(registry:get(id) or {}) or {},payload,'record') + nativeRow(loader,id,effective) + if verb~='patch' then payload=effective end + native[verb](native,id,payload) + return out:get(id) + end + end + return out +end +return M diff --git a/src/mods/PlaythroughStorage.lua b/src/mods/PlaythroughStorage.lua new file mode 100644 index 000000000..6410bb00b --- /dev/null +++ b/src/mods/PlaythroughStorage.lua @@ -0,0 +1,472 @@ +-- Data-only and opaque-byte per-mod persistence, scoped by game version and +-- opaque playthrough. +-- API-3 contract adapted from Gen1Recomp Storage at 79ea62f45fb2ea8f65d081a109431e4eb1bbdb36. +-- Engine-private; the public handle contains only bound facade methods. + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local Version = require("src.core.Version") + +local Storage = {} +Storage.__index = Storage +Storage.MAX_BYTES = 512 * 1024 * 1024 + +local ROOT = "mod_storage" + +local function failure(code, message) + return nil, code, message +end + +local function validSegment(value) + return type(value) == "string" and value ~= "" and value:match("^[%w_-]+$") ~= nil +end + +local function validKey(key, allowEmpty) + if type(key) ~= "string" or (key == "" and not allowEmpty) then + return false + end + if key == "" then + return true + end + if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then + return false + end + for segment in key:gmatch("[^/]+") do + if not validSegment(segment) then + return false + end + end + return true +end + +local function ensureParent(fs, path) + local dir = path:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then + fs.createDirectory(dir) + end +end + +local function remove(fs, path) + if fs.remove then + fs.remove(path) + end +end + +local function decodeAt(fs, path) + if not (fs.getInfo and fs.getInfo(path)) then + return nil + end + local body = fs.read and fs.read(path) + if type(body) ~= "string" then + return nil + end + local data = SaveSerializer.decode(body) + if not data then + return nil + end + return data, body +end + +local function readOpaqueAt(fs, path) + if not (fs.getInfo and fs.getInfo(path)) then + return nil + end + local body = fs.read and fs.read(path) + if type(body) ~= "string" then + return nil + end + return body +end + +local function hasAny(fs, paths) + for _, path in ipairs(paths) do + if fs.getInfo(path) then + return true + end + end + return false +end + +function Storage.new(modId, fs) + assert(validSegment(modId), "Storage.new needs a safe mod id") + return setmetatable({ modId = modId, injectedFs = fs }, Storage) +end + +function Storage:_scope(game) + local save = game and game.save + local meta = save and save.meta + local version = save and save.version + if not (save and validSegment(version)) then + return failure("not_in_playthrough", "Storage is available only inside an identified playthrough.") + end + local fs = SaveData.persistenceFs(self.injectedFs) + if not (fs and fs.read and fs.write and fs.getInfo) then + return failure("storage_unavailable", "The persistence backend is unavailable.") + end + local playthroughId = meta and meta.playthroughId + if not validSegment(playthroughId) then + playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs) + end + if not validSegment(playthroughId) then + return failure("not_in_playthrough", "Storage could not identify the active playthrough.") + end + local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/") + return { gameVersion = version, playthroughId = playthroughId, base = base, fs = fs } +end + +local function isTitleSession(game) + local states = game and game.stack and game.stack.states + if type(states) ~= "table" then + return false + end + for _, state in ipairs(states) do + if type(state) == "table" and state.screenId == "TitleState" then + return true + end + end + return false +end + +-- Bind this mod only to the engine-selected existing playthrough while the +-- title session is active. Unlike _scope this must never allocate an identity: +-- browsing history before the first normal SAVE is a read of durable state, +-- not the start of a New Game. The returned facade closes over its private +-- proxy game, so callers cannot substitute another playthrough id or path. +function Storage:selected(game) + if not isTitleSession(game) then + return failure("not_at_title", "Selected playthrough storage is available only from the title session.") + end + local save = game and game.save + local version = save and save.version + if not validSegment(version) then + return failure("not_in_playthrough", "The title session has no selected game version.") + end + local playthroughId, code, message = SaveData.selectedPlaythroughId(save, self.injectedFs) + if not validSegment(playthroughId) then + return failure(code, message) + end + + local selectedGame = { + save = { version = version, meta = { playthroughId = playthroughId } }, + } + local context = { + engineVersion = Version.engine, + gameVersion = version, + playthroughId = playthroughId, + } + local normal = SaveData.selectedNormalSaveInfo(save, self.injectedFs) + if type(normal) == "table" and normal.savedAt ~= nil then + context.normalSavedAt = normal.savedAt + end + return { + context = function() + local copy = { + engineVersion = context.engineVersion, + gameVersion = context.gameVersion, + playthroughId = context.playthroughId, + } + if context.normalSavedAt ~= nil then + copy.normalSavedAt = context.normalSavedAt + end + return copy + end, + read = function(_, key) + return self:read(selectedGame, key) + end, + write = function(_, key, value) + return self:write(selectedGame, key, value) + end, + readBytes = function(_, key) + return self:readBytes(selectedGame, key) + end, + writeBytes = function(_, key, bytes) + return self:writeBytes(selectedGame, key, bytes) + end, + list = function(_, prefix) + return self:list(selectedGame, prefix) + end, + delete = function(_, key) + return self:delete(selectedGame, key) + end, + } +end + +function Storage:context(game) + local scope, code, message = self:_scope(game) + if not scope then + return nil, code, message + end + return { + engineVersion = Version.engine, + gameVersion = scope.gameVersion, + playthroughId = scope.playthroughId, + } +end + +function Storage:_names(game, key, allowEmpty, extension) + if not validKey(key, allowEmpty) then + return failure( + "invalid_key", + "Storage keys use nonempty letters, numbers, underscore, dash and slash segments." + ) + end + local scope, code, message = self:_scope(game) + if not scope then + return nil, code, message + end + local path = scope.base .. (key ~= "" and ("/" .. key) or "") + extension = extension or ".lua" + return scope, path .. extension, path .. extension .. ".bak", path .. extension .. ".tmp", path +end + +function Storage:write(game, key, value) + local scope, main, bak, tmp, path = self:_names(game, key, false) + if not scope then + return false, main, bak + end + local fs = scope.fs + if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then + return false, "type_conflict", "A byte value already exists for this storage key; delete it first." + end + if type(value) ~= "table" then + return false, "encode_failed", "Storage values must be data-only tables." + end + local encodedOk, encoded = pcall(SaveSerializer.encode, value) + if not encodedOk then + return false, "encode_failed", "Storage value is not serializable data: " .. tostring(encoded) + end + + ensureParent(fs, main) + local _, previous = decodeAt(fs, main) + if not previous then + _, previous = decodeAt(fs, bak) + end + + local ok, err = fs.write(tmp, encoded) + if not ok then + return false, "write_failed", "Could not stage storage data: " .. tostring(err) + end + local staged = decodeAt(fs, tmp) + if not staged then + remove(fs, tmp) + return false, "verify_failed", "Staged storage data could not be verified." + end + + if previous then + fs.write(bak, previous) + end + ok, err = fs.write(main, encoded) + if not ok then + remove(fs, tmp) + return false, "write_failed", "Could not replace storage data: " .. tostring(err) + end + local verified = decodeAt(fs, main) + if not verified then + remove(fs, main) + remove(fs, tmp) + return false, "verify_failed", "Replacement storage data could not be verified." + end + + -- At rest both main and backup hold the newest verified record. If a later + -- write dies after rolling this copy aside, one verified generation remains. + fs.write(bak, encoded) + remove(fs, tmp) + return true +end + +function Storage:read(game, key) + local scope, main, bak, tmp, path = self:_names(game, key, false) + if not scope then + return nil, main, bak + end + local fs = scope.fs + if hasAny(fs, { path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" }) then + return failure("type_mismatch", "This storage key contains opaque bytes; use readBytes instead.") + end + local data, body = decodeAt(fs, main) + if data then + return data + end + + data, body = decodeAt(fs, tmp) + if not data then + data, body = decodeAt(fs, bak) + end + if not data then + return nil, "not_found", "No valid stored value exists for this key." + end + + -- Best-effort healing. The recovered copy remains in tmp/bak if promotion + -- cannot land, so returning it is still safe and the next read can retry. + ensureParent(fs, main) + if fs.write(main, body) then + fs.write(bak, body) + end + remove(fs, tmp) + return data +end + +function Storage:writeBytes(game, key, bytes) + local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin") + if not scope then + return false, main, bak + end + if type(bytes) ~= "string" then + return false, "invalid_bytes", "Opaque storage values must be strings." + end + if #bytes > Storage.MAX_BYTES then + return false, "size_limit", ("Opaque storage values cannot exceed %d bytes."):format(Storage.MAX_BYTES) + end + + local fs = scope.fs + if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then + return false, "type_conflict", "A table value already exists for this storage key; delete it first." + end + + ensureParent(fs, main) + local previous = readOpaqueAt(fs, main) + if previous == nil then + previous = readOpaqueAt(fs, bak) + end + + local ok, err = fs.write(tmp, bytes) + if not ok then + return false, "write_failed", "Could not stage opaque storage data: " .. tostring(err) + end + local staged = readOpaqueAt(fs, tmp) + if staged == nil or staged ~= bytes then + remove(fs, tmp) + return false, "verify_failed", "Staged opaque storage data could not be verified." + end + + if previous ~= nil then + fs.write(bak, previous) + end + ok, err = fs.write(main, bytes) + if not ok then + remove(fs, tmp) + return false, "write_failed", "Could not replace opaque storage data: " .. tostring(err) + end + local verified = readOpaqueAt(fs, main) + if verified == nil or verified ~= bytes then + remove(fs, main) + remove(fs, tmp) + return false, "verify_failed", "Replacement opaque storage data could not be verified." + end + + fs.write(bak, bytes) + remove(fs, tmp) + return true +end + +function Storage:readBytes(game, key) + local scope, main, bak, tmp, path = self:_names(game, key, false, ".bin") + if not scope then + return nil, main, bak + end + local fs = scope.fs + if hasAny(fs, { path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" }) then + return failure("type_mismatch", "This storage key contains table data; use read instead.") + end + + local bytes = readOpaqueAt(fs, main) + if bytes ~= nil then + return bytes + end + bytes = readOpaqueAt(fs, tmp) + if bytes == nil then + bytes = readOpaqueAt(fs, bak) + end + if bytes == nil then + return nil, "not_found", "No valid opaque value exists for this key." + end + + ensureParent(fs, main) + if fs.write(main, bytes) then + fs.write(bak, bytes) + end + remove(fs, tmp) + return bytes +end + +function Storage:list(game, prefix) + prefix = prefix or "" + local scope, main, codeOrBak = self:_names(game, prefix, true) + if not scope then + return nil, main, codeOrBak + end + local fs = scope.fs + if not fs.getDirectoryItems then + return nil, "storage_unavailable", "The persistence backend cannot enumerate keys." + end + + local base = scope.base + local start = prefix == "" and base or (base .. "/" .. prefix) + local out, seen = {}, {} + + local function add(logical) + if not seen[logical] then + seen[logical] = true + out[#out + 1] = logical + end + end + + local function walk(path, logical) + local info = fs.getInfo(path) + if not info then + return + end + if info.type == "file" then + local suffix = path:sub(-4) + if suffix == ".lua" or suffix == ".bin" then + add(logical:sub(1, -5)) + end + return + end + for _, child in ipairs(fs.getDirectoryItems(path) or {}) do + local childLogical = logical == "" and child or (logical .. "/" .. child) + walk(path .. "/" .. child, childLogical) + end + end + + -- A prefix may identify one exact key or a directory of keys. + if fs.getInfo(start .. ".lua") then + add(prefix) + elseif fs.getInfo(start .. ".bin") then + add(prefix) + else + walk(start, prefix) + end + table.sort(out) + return out +end + +function Storage:delete(game, key) + local scope, main, bak, tmp, path = self:_names(game, key, false) + if not scope then + return false, main, bak + end + local fs = scope.fs + local byteMain, byteBak, byteTmp = path .. ".bin", path .. ".bin.bak", path .. ".bin.tmp" + if + not ( + fs.getInfo(main) + or fs.getInfo(bak) + or fs.getInfo(tmp) + or fs.getInfo(byteMain) + or fs.getInfo(byteBak) + or fs.getInfo(byteTmp) + ) + then + return false, "not_found", "No stored value exists for this key." + end + remove(fs, main) + remove(fs, bak) + remove(fs, tmp) + remove(fs, byteMain) + remove(fs, byteBak) + remove(fs, byteTmp) + return true +end + +return Storage 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/RadioContent.lua b/src/mods/RadioContent.lua new file mode 100644 index 000000000..083a13941 --- /dev/null +++ b/src/mods/RadioContent.lua @@ -0,0 +1,115 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Virtual native station records; failed entries never mutate a live dial. +local Merge=require("src.mods.Merge") +local Radio=require("src.world.Gen2Radio") +local M={} +local previous=setmetatable({},{__mode="k"}) +local pending=setmetatable({},{__mode="k"}) +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(value,seen) + if type(value)=="number" then assert(value==value and math.abs(value)= 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..f5387d823 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 @@ -1257,10 +1258,52 @@ R.text_pointers = { example = 'mod.content.text_pointers:patch("PalletTown", { TEXT_PALLETTOWN_SIGN = { text = "_MySign" } })', } +R.decorations = { + semantics="record",generations="2", + fields={type=f.int(1,6),name=f.str,action=f.opt(f.str),flag=f.int(0),sprite=f.int(0,255)}, + example='mod.content.decorations:patch("deco:2", {name="CUSTOM"})', + notes="Native imported GSC DECO bytes (deco:0..255), ownership event bits, room callbacks and house-PC selection. Action must be a known SET_UP_/PUT_AWAY_ native decoration action. No retail attribute catalogue is bundled. Requires a complete imported native attribute table.", +} + -- ------- persistence +R.phone_contacts = { + semantics = "record", generations = "2", + fields = { + index=f.opt(f.int(0,255)), number=f.opt(f.int(0,255)), + name=f.opt(f.str), class=f.opt(f.str), member=f.opt(f.str), + map=f.opt(f.id("maps")), rematchEvent=f.opt(f.int(0,2047)), + calleeTime=f.opt(f.int(0,7)), callerTime=f.opt(f.int(0,7)), + callee=f.opt(f.str), caller=f.opt(f.str), calleeKey=f.opt(f.str), callerKey=f.opt(f.str), + }, + example='mod.content.phone_contacts:patch("PHONE_YOUNGSTER_JOEY", { map="ROUTE_31" })', + notes="Gen2 native numeric phone book. Existing indices and reserved holes remain fixed; new contacts append at 1..255. Caller/callee keys must name existing native imported scripts, not Gen1 bytecode. New authored scripts, forward script/map references and the legacy Phone facade are separate work.", +} + +R.apricorns = { + semantics="record", generations="2", + fields={apricorn=f.id("items"),ball=f.id("items"),event=f.int(0),index=f.int(1)}, + example='mod.content.apricorns:patch("RED_APRICORN", {ball="ULTRA_BALL"})', + notes="Native Gen2 recipe records bind real Kurt item selection, event flags and ball delivery. A current native Kurt program import and indexed GSC item IDs are required. New recipes preserve the selected native item byte; generated native branches retain original dialogue/day flags/refusal behavior.", +} + +R.landmarks = { + semantics="record",generations="2", + fields={id=f.opt(f.str),name=f.str,x=f.int(0),y=f.int(0),index=f.int(0)}, + example='mod.content.landmarks:patch("LANDMARK_ROUTE_29", {x=80})', + notes="Native Gen2 town-map records and script landmark names. Coordinates retain native OAM byte units, not tile cells. The index is the map-header byte. Native identities win index collisions; otherwise lexical ID order is deterministic. Imported identity metadata is independent of display names.", +} + -- compose, keyed by the owning mod id: the runner walks each owner's chain -- in semver order against the versions recorded in the save +R.radio_channels = { + semantics = "record", target = "gen2RadioChannels", generations = "2", + fields = { channel=f.opt(f.int(0,255)), name=f.opt(f.str) }, + write=function(target,registry)require("src.mods.RadioContent").write(target,registry)end, + example='mod.content.radio_channels:register("PIRATE_RADIO", { channel=9, name="PIRATE RADIO" })', + notes="Gen2 only. Station identities are shared by native wall radios and Pokegear. Channel 0 remains regional/time-dependent; positive channel numbers must be unique. Names are authored display text. Full ROM show-state/audio reproduction is separate.", +} + R.migrations = { semantics = "compose", value = f.rec{ since = f.str, run = f.fn }, 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..c9d2f0c9a --- /dev/null +++ b/src/mods/UnifiedCallbacks.lua @@ -0,0 +1,234 @@ +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.menu_auxiliary battle.move_grid_navigation ui.party.grid_navigation + battle.accuracy battle.bottom_ui_visible 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.level_visible pokemon.sprite render.compose render.hud + render.letterbox render.zones save.new_game save.write screen.render_visible script.command trainer.before_battle trainer.party transition.style + phone.contact_list breeding.compatibility catch.nickname catch.party_full battle.style battle.enemy_switch_or_item core.quit_to_launcher + 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.follower.spawn world.talk world.tod zoom.range +]]) +C.events = names([[ + clock.day_changed + world.live_maps_preparing world.live_maps_updated + radio.channel phone.call_received apricorn.converted breeding.egg_created egg.hatched happiness.changed pokerus.infected roamer.encountered roamer.moved unown.unlocked bug_contest.scored + intro.boot.copyright intro.boot.gamefreak intro.boot.movie intro.boot.movie_ended intro.boot.title + 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 }, + ["ui.party.grid_navigation"] = { false }, + ["battle.move_grid_navigation"] = { false }, + ["battle.menu_auxiliary"] = { false, "context" }, + ["pokemon.level_visible"] = { "mon", "context" }, + ["trainer.before_battle"] = { false, "context", false }, + ["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..0bb09b62b --- /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 = require("src.core.Gen2Clock").hour(game.save) + 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..d8c708bea --- /dev/null +++ b/src/net/DownloadPublish.lua @@ -0,0 +1,25 @@ +-- 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/DayCare.lua b/src/pokemon/DayCare.lua index e5a1c46c6..2cb866d15 100644 --- a/src/pokemon/DayCare.lua +++ b/src/pokemon/DayCare.lua @@ -296,7 +296,7 @@ end -- 5 = brimming with energy (the matching-DV rejection). The -- ordering is the ROM's too -- a pair that shares an OT ID is the *worst* -- match, and two of the same species from different trainers the best. -function DayCare.compatibility(data, save) +local function nativeCompatibility(data, save) local a, b = DayCare.pair(data, save) if not a then return 0 end local version = require("src.core.GameVersion").get() @@ -317,6 +317,35 @@ end DayCare.EGG_ODDS = { [0] = 0, [1] = 10 / 256, [2] = 40 / 256, [3] = 30 / 256, [4] = 80 / 256, [5] = 0 } +local compatibilityBytes={[0]=0,[1]=10,[2]=40,[3]=30,[4]=80,[5]=255} +local function evaluateCompatibility(data,save) + local tier=nativeCompatibility(data,save) + local byte=compatibilityBytes[tier] + local Runtime=require('src.mods.Runtime') + if not require('src.core.GameVersion').isGen2() or not Runtime.wantsHook("breeding.compatibility")then + return tier,byte + end + local context={data=data,mon1=DayCare.mon(save,1),mon2=DayCare.mon(save,2),dayCare=true} + local result=Runtime.call("breeding.compatibility",function(c) + local view={daycare={breed={{mon=c.mon1},{mon=c.mon2}}}} + return compatibilityBytes[nativeCompatibility(c.data,view)] + end,context) + local n=tonumber(result) + if not n or n~=n or math.abs(n)==math.huge then return tier,byte end + n=math.max(0,math.min(255,math.floor(n))) + -- Preserve the native public text tier. The step loop also receives the + -- exact byte so an authored probability need not round to a stock tier. + if n==0 then tier=0 elseif n==255 then tier=5 + elseif n<=10 then tier=1 elseif n<=30 then tier=3 elseif n<=40 then tier=2 else tier=4 end + return tier,n,true +end +local compatibilityReads=setmetatable({},{__mode='k'}) +function DayCare.compatibility(data,save) + local tier,byte,hooked=evaluateCompatibility(data,save) + if hooked and save then compatibilityReads[save]={tier=tier,byte=byte}end + return tier +end + -- --------------------------------------------------------------------------- -- HOENN'S OWN ANSWER TO THE SAME QUESTION (GetDaycareCompatibilityScore). -- @@ -553,7 +582,11 @@ function DayCare.step(data, save, expPerStep) end local version = require("src.core.GameVersion").get() local vanillaGen2 = version == "gold" or version == "silver" or version == "crystal" + compatibilityReads[save]=nil local tier = DayCare.compatibility(data, save) + local reading=compatibilityReads[save];compatibilityReads[save]=nil + local hooked=reading and reading.tier==tier + local compatibility=hooked and reading.byte or compatibilityBytes[tier] if vanillaGen2 then if tier == 0 or tier == 5 then return false end -- First attempt: 150..255 steps. Later attempts: a random byte; @@ -577,6 +610,7 @@ function DayCare.step(data, save, expPerStep) -- indifferent pair simply keeps walking. local odds = gen3 and (DayCare.gen3Score(data, save) / 100) or DayCare.EGG_ODDS[tier] + if vanillaGen2 and hooked then odds=compatibility/256 end if not gen3 and not vanillaGen2 then -- Preserve the existing shared-model behavior for other cartridges. odds = ({[0]=0,[1]=64/256,[2]=128/256,[3]=191/256,[4]=255/256})[tier] @@ -612,6 +646,12 @@ function DayCare.step(data, save, expPerStep) -- callback moves the MAN out to the fence -- that walk is the ROM's whole -- "an EGG is waiting" announcement, there is no phone call for it. DayCare.syncFlags(save) + local Runtime=require('src.mods.Runtime') + if require('src.core.GameVersion').isGen2() and Runtime.wants("breeding.egg_created")then + local mother,father=DayCare.parents(data,save) + Runtime.emit("breeding.egg_created",{egg=egg,mother=mother,father=father, + compatibility=compatibility,stepsToEgg=breed.stepsToEgg}) + end return true end diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 321387210..e9506843f 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -89,7 +89,7 @@ Evolution.METHODS = { return false end if not evo.timeOfDay then return true end - local night = Evolution.isNight() + local night = Evolution.isNight(game.save) if evo.timeOfDay == "NITE" then return night end return not night -- TR_MORNDAY: morn or day end, @@ -223,10 +223,11 @@ Evolution.HAPPINESS_TO_EVOLVE = 220 Evolution.BASE_HAPPINESS = 70 -- GSC time-of-day bands (constants/time_constants.asm): MORN 4-9, DAY --- 10-17, NITE 18-3. The port has no in-game clock, so the wall clock is --- what GSC's RTC would have been. -function Evolution.isNight() - local hour = tonumber(os.date("%H")) or 12 +-- 10-17, NITE 18-3. GSC reads the shared saved RTC offset; other editions +-- retain their existing host-clock behavior. +function Evolution.isNight(save) + local hour = require("src.core.GameVersion").isGen2() + and require("src.core.Gen2Clock").hour(save) or tonumber(os.date("%H")) or 12 return hour >= 18 or hour < 4 end diff --git a/src/pokemon/Gen2Friendship.lua b/src/pokemon/Gen2Friendship.lua index 7ef888c15..9cec9fc5c 100644 --- a/src/pokemon/Gen2Friendship.lua +++ b/src/pokemon/Gen2Friendship.lua @@ -11,6 +11,16 @@ local changes = { LEVELUPATHOME = {10,6,4}, } +local events={LEVELUP=1,VITAMIN=2,XITEM=3,GYMBATTLE=4,LEARNMOVE=5, + FAINT=6,POISONFAINT=7,STRONGFOE=8,BITTERPOWDER=15,ENERGYROOT=16,REVIVALHERB=17,LEVELUPATHOME=19} +local function changed(mon,from,event,reason) + local Runtime=require('src.mods.Runtime') + if from~=mon.happiness and Runtime.wants("happiness.changed")then + Runtime.emit("happiness.changed",{mon=mon,event=event,reason=reason, + from=from,to=mon.happiness,delta=mon.happiness-from}) + end +end + function Friendship.isVanilla() return require("src.pokemon.Gen2Breeding").isVanilla() end @@ -23,6 +33,7 @@ function Friendship.change(mon, reason) local h = mon.happiness local band = h < 100 and 1 or h < 200 and 2 or 3 mon.happiness = math.max(0, math.min(255, h + row[band])) + changed(mon,h,events[reason],"event") return true end @@ -34,7 +45,9 @@ function Friendship.stepCycle(save) if save.g2HappinessStepCount ~= 0 then return end for _, mon in ipairs(save.party or {}) do if not require("src.pokemon.Party").isEgg(mon) and mon.happiness ~= nil then + local from=mon.happiness mon.happiness = math.min(255, mon.happiness + 1) + changed(mon,from,nil,"step") end end end diff --git a/src/pokemon/Gen2Pokerus.lua b/src/pokemon/Gen2Pokerus.lua new file mode 100644 index 000000000..5d12b7290 --- /dev/null +++ b/src/pokemon/Gen2Pokerus.lua @@ -0,0 +1,75 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native GSC party-byte transitions. Imported cured bytes retain immunity. +local P={} +local function value(mon) + local n=tonumber(mon and mon.pokerus)or 0 + if n~=n or math.abs(n)==math.huge then return 0 end + return math.max(0,math.floor(n))%256 +end +local function write(party,index,byte,source) + local mon=party[index];mon.pokerus=byte + local Runtime=require('src.mods.Runtime') + if Runtime.wants("pokerus.infected")then + Runtime.emit("pokerus.infected",{party=party,slot=index,mon=mon, + strain=math.floor(byte/16),days=byte%16,source=source}) + end + return index +end +function P.afterBattle(save,random) + if not require('src.pokemon.Gen2Breeding').isVanilla()then return end + local party=save and save.party or{} + if #party==0 then return end + random=random or function()return love.math.random(0,255)end + local carrier + for n,mon in ipairs(party)do if value(mon)%16>0 then carrier=n;break end end + if carrier then + if random()>=85 or #party==1 then return end + local direction=carrier==#party and -1 or (random()<129 and -1 or 1) + local byte=value(party[carrier]) + for target=carrier+direction,direction==1 and #party or 1,direction do + local nextByte=value(party[target]) + if nextByte==0 then + local strain=math.floor(byte/16) + return write(party,target,strain*16+strain%4+1,'spread') + end + if nextByte%4==0 then return end + byte=nextByte + end + return + end + local Flags=require('src.script.Flags') + if not Flags.get(save,require('src.script.Gen2Flags').engineFlag(21))then return end + if random()~=0 or random()>=3 then return end + local index + repeat index=random()%8+1 until index<=#party + if value(party[index])>=16 then return end + local roll + repeat roll=random()until roll~=0 + local strain=roll<16 and 0 or roll%8+1 + return write(party,index,strain*16+strain%4+1,'contracted') +end +function P.advance(party,days) + for _,mon in ipairs(party or{})do + local byte=value(mon) + if byte%16>0 then mon.pokerus=byte-byte%16+math.max(0,byte%16-days)end + end +end +local monthDays={0,31,59,90,120,151,181,212,243,273,304,334} +local function day(save,now) + local Clock=require('src.core.Gen2Clock') + local date=Clock.calendarDate and Clock.calendarDate(save,now)or os.date('*t',now) + local y=date.year-1 + local ordinal=365*y+math.floor(y/4)-math.floor(y/100)+math.floor(y/400) + +monthDays[date.month]+date.day + if date.month>2 and date.year%4==0 and(date.year%100~=0 or date.year%400==0)then ordinal=ordinal+1 end + return ordinal +end +function P.poll(save,now) + if not save or not require('src.pokemon.Gen2Breeding').isVanilla()then return end + local today=day(save,now) + local previous=save.g2PokerusDay + save.g2PokerusDay=today + if type(previous)~='number' or previous==today then return end + P.advance(save.party,today= 1 and letter <= 26 then + return letter + end +end +function M.name(letter) + local index = M.index(letter) + return index and M.ALPHABET:sub(index, index) or nil +end +local function middle(dv) + dv = dv or 0 + if type(dv) ~= "number" or dv ~= dv or dv < 0 or dv > 15 then return nil end + return math.floor(dv / 2) % 4 +end +function M.letterFromDVs(dvs) + if type(dvs) ~= "table" then return nil end + local a,b,c,d = middle(dvs.attack), middle(dvs.defense), middle(dvs.speed), middle(dvs.special) + if not (a and b and c and d) then return nil end + return math.floor((a*64+b*16+c*4+d) / 10) + 1 +end +function M.dvsForLetter(letter) + local index = M.index(letter) + if not index then return nil end + local packed = (index - 1) * 10 + return {attack = math.floor(packed / 64) % 4 * 2, + defense = math.floor(packed / 16) % 4 * 2, + speed = math.floor(packed / 4) % 4 * 2, special = packed % 4 * 2} +end +function M.liveData(data) + if type(data) == "table" then return data end + local game = package.loaded["src.core.Game"] + return type(game) == "table" and type(game.data) == "table" and game.data or nil +end +function M.definition(data) + data = M.liveData(data) + if not (data and type(data.pokemon) == "table") then return nil end + local known = data.pokemon.UNOWN + if type(known) == "table" then return known end + for _, def in pairs(data.pokemon) do + if type(def) == "table" and (def.translationSourceName or def.name) == "UNOWN" then + return def + end + end +end +function M.word(letter, data) + local index = M.index(letter) + data = M.liveData(data) + local record = data and data.unown_dex + local words = type(record) == "table" and record.words + local word = index and type(words) == "table" and words[index] + return type(word) == "string" and word or nil +end +function M.forms(pokemon) + local def = M.definition({pokemon = pokemon}) + return def and def.forms or nil +end +function M.formSprite(pokemon, letter, back) + local index, def = M.index(letter), M.definition({pokemon = pokemon}) + if not (index and def) then return nil end + local form = def.forms and def.forms[index] + if back then return form and form.spriteBack or def.spriteBack end + return form and form.spriteFront or def.spriteFront +end +function M.monLetter(mon, data) + if type(mon) ~= "table" or require("src.pokemon.Party").isEgg(mon) then return nil end + data = M.liveData(data) + local def = data and data.pokemon and data.pokemon[mon.species] + if def then + if def ~= M.definition(data) then return nil end + elseif mon.species ~= "UNOWN" then return nil end + return M.index(mon.unownLetter) or M.letterFromDVs(mon.dvs) +end +function M.letters(save) + local dex = type(save) == "table" and save.pokedex + if type(dex) ~= "table" then return {} end + local out, seen = {}, {} + local function add(letter) + local index = M.index(letter) + if index and not seen[index] then seen[index] = true; out[#out + 1] = index end + end + for _, letter in ipairs(type(dex.unownDex) == "table" and dex.unownDex or {}) do add(letter) end + local forms = type(dex.unownForms) == "table" and dex.unownForms or {} + for i = 1, 26 do if forms[i] then add(i) end end + return out +end +function M.count(save) return #M.letters(save) end +function M.caught(save, letter) + local index = M.index(letter) + if not index then return false end + for _, present in ipairs(M.letters(save)) do if present == index then return true end end + return false +end +function M.updateDex(save, letter, data) + local index = M.index(letter) + if not (M.supported() and type(save) == "table" and index) or M.caught(save, index) then return false end + if save.pokedex ~= nil and type(save.pokedex) ~= "table" then return false end + if save.pokedex and save.pokedex.unownForms ~= nil and type(save.pokedex.unownForms) ~= "table" then return false end + local list = M.letters(save) + list[#list + 1] = index + save.pokedex = save.pokedex or {} + local dex = save.pokedex + dex.unownDex, dex.unownForms = list, dex.unownForms or {} + for _, value in ipairs(list) do dex.unownForms[value] = true end + local Runtime = require("src.mods.Runtime") + if Runtime.wants("unown.unlocked") then + Runtime.emit("unown.unlocked", {letter=index, name=M.name(index), word=M.word(index, data), count=#list}) + end + return true +end +function M.registerCatch(save, mon, data) + local letter = M.monLetter(mon, data) + return letter and M.updateDex(save, letter, data) or false +end +return M 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/Sprites.lua b/src/pokemon/Sprites.lua index 245e67151..9c52bea60 100644 --- a/src/pokemon/Sprites.lua +++ b/src/pokemon/Sprites.lua @@ -14,9 +14,6 @@ local Sprites = {} local function samePath(path) return path end --- (dv & 6) >> 1 -- the middle two bits GetUnownLetter (20:$5749) keeps -local function mid2(dv) return math.floor((dv or 0) / 2) % 4 end - -- GetUnownLetter packs the middle two bits of the atk/def/spd/spc DVs into -- one byte and divides by 10, giving letter 1..26. A species record only -- carries `forms` when the ROM has per-form pics (UNOWN). @@ -28,10 +25,9 @@ function Sprites.formIndex(def, mon) return mon.form end local dvs = mon.dvs - if type(dvs) ~= "table" then return nil end - local packed = mid2(dvs.attack) * 64 + mid2(dvs.defense) * 16 - + mid2(dvs.speed) * 4 + mid2(dvs.special) - local index = math.floor(packed / 10) + 1 + local Unown = require("src.pokemon.Gen2Unown") + local index = (Unown.supported() and Unown.index(mon.unownLetter)) or Unown.letterFromDVs(dvs) + if not index then return nil end if not forms[index] then return nil end return index end diff --git a/src/render/GbcColorCorrection.lua b/src/render/GbcColorCorrection.lua new file mode 100644 index 000000000..fe48bbdbe --- /dev/null +++ b/src/render/GbcColorCorrection.lua @@ -0,0 +1,164 @@ +-- GBC display response adapted from SameBoy's GB_convert_rgb15 (CGB model, +-- neutral light temperature), revision 26a56bd6a122a6318a456abd80558408742d5adf. +-- Copyright (c) 2015-2026 Lior Halphon. Expat license: licenses/SameBoy.txt. +-- This is separate from GBCFX's grid/glare and never alters imported palettes. +local GameVersion = require("src.core.GameVersion") +local PaletteFX = require("src.render.PaletteFX") +local Logger = require("src.core.Logger") +local Performance = require("src.core.Performance") + +local Correction = { mode = "off" } +Correction.MODES = { "off", "accurate", "harsh" } +Correction.LABELS = { off = "OFF", accurate = "ACCURATE", harsh = "HARSH" } + +local CURVE = { 0,6,12,20,28,36,45,56,66,76,88,100,113,125,137,149, + 161,172,182,192,202,210,218,225,232,238,243,247,250,252,254,255 } +local floor = math.floor +local shader, output, failedTarget +local textures = {} + +function Correction.normalize(mode) + return (mode == "accurate" or mode == "harsh") and mode or "off" +end + +function Correction.setMode(mode) + Correction.mode = Correction.normalize(mode) + failedTarget = nil + if Correction.mode == "off" and output then + output:release() + output = nil + end +end + +function Correction.applyOptions(opts) + Correction.setMode(opts and opts.gbcColorCorrection) +end + +function Correction.cycle(mode, dir) + mode = Correction.normalize(mode) + for i, value in ipairs(Correction.MODES) do + if value == mode then return Correction.MODES[(i - 1 + dir) % #Correction.MODES + 1] end + end +end + +-- Pure RGB555 -> RGB888 reference. Integer truncations in HARSH are deliberate +-- (SameBoy's uint8_t arithmetic); changing their order changes the palette. +function Correction.convert5(r, g, b, mode) + mode = Correction.normalize(mode) + if mode == "off" then + return r * 8 + floor(r / 4), g * 8 + floor(g / 4), b * 8 + floor(b / 4) + end + r, g, b = CURVE[r + 1], CURVE[g + 1], CURVE[b + 1] + if g ~= b then + g = floor((((g / 255)^2.2 * 3 + (b / 255)^2.2) / 4)^(1 / 2.2) * 255 + 0.5) + end + if mode == "harsh" then + local nr = floor(r * 15 / 16) + floor((g + b) / 32) + local ng = floor(g * 15 / 16) + floor((r + b) / 32) + local nb = floor(b * 15 / 16) + floor((r + g) / 32) + return floor(nr * 117 / 255) + 45, floor(ng * 126 / 255) + 41, + floor(nb * 119 / 255) + 38 + end + return r, g, b +end + +-- 32 blue slices, eight columns of 32x32 red/green tiles. Bilinear red/green +-- sampling plus blue interpolation preserves smooth gradients in mod art; +-- there is no per-frame quantization of the scene back down to five bits. +Correction.SHADER_SRC = [[ +extern Image correctionLut; +vec2 lutCoord(vec2 rg, float blue) { + vec2 tile = vec2(mod(blue, 8.0), floor(blue / 8.0)); + return (tile * 32.0 + rg + vec2(0.5)) / vec2(256.0, 128.0); +} +vec4 effect(vec4 color, Image texture, vec2 uv, vec2 screen) { + vec4 pixel = Texel(texture, uv); + vec3 rgb = clamp(pixel.rgb, 0.0, 1.0) * 31.0; + float low = floor(rgb.b); + vec3 a = Texel(correctionLut, lutCoord(rgb.rg, low)).rgb; + vec3 b = Texel(correctionLut, lutCoord(rgb.rg, min(low + 1.0, 31.0))).rgb; + return vec4(mix(a, b, fract(rgb.b)), pixel.a) * color; +} +]] + +local function resources() + if shader == false or textures[Correction.mode] == false then return false end + local pixels + local ok, err = pcall(function() + if not shader then shader = love.graphics.newShader(Correction.SHADER_SRC) end + if not textures[Correction.mode] then + pixels = love.image.newImageData(256, 128) + for b = 0, 31 do + for g = 0, 31 do + for r = 0, 31 do + local nr, ng, nb = Correction.convert5(r, g, b, Correction.mode) + pixels:setPixel((b % 8) * 32 + r, floor(b / 8) * 32 + g, + nr / 255, ng / 255, nb / 255, 1) + end + end + end + local texture = love.graphics.newImage(pixels) + texture:setFilter("linear", "linear") + texture:setWrap("clamp", "clamp") + textures[Correction.mode] = texture + end + end) + if pixels then pixels:release() end + if not ok then + if not shader then shader = false end + textures[Correction.mode] = false + Logger.warn("GBC color correction unavailable: %s", tostring(err)) + end + return ok +end + +function Correction.active() + if Performance.extraCap("colorCorrection")==false then return false end + if failedTarget or Correction.mode == "off" or not GameVersion.isGen2() + or PaletteFX.monoMode() then return false end + return resources() +end + +-- Colour the native finished frame before mod post-processing and LCD FX. +-- OFF/unsupported returns the original surface without allocating a canvas. +function Correction.apply(canvas) + if not Correction.active() then return canvas end + local width, height = canvas:getDimensions() + local dpi = canvas.getDPIScale and canvas:getDPIScale() or 1 + local ok, err = pcall(function() + if not output or output:getWidth() ~= width or output:getHeight() ~= height + or output:getDPIScale() ~= dpi then + if output then output:release(); output = nil end + output = love.graphics.newCanvas(width, height, { dpiscale = dpi }) + output:setFilter("nearest", "nearest") + end + end) + if not ok then + failedTarget = true + Logger.warn("GBC color correction target unavailable: %s", tostring(err)) + return canvas + end + love.graphics.push("all") + local rendered, drawError = pcall(function() + love.graphics.setCanvas(output) + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setStencilTest() + love.graphics.setDepthMode() + love.graphics.setColorMask(true, true, true, true) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + shader:send("correctionLut", textures[Correction.mode]) + love.graphics.setShader(shader) + love.graphics.draw(canvas, 0, 0) + end) + love.graphics.pop() + if not rendered then + failedTarget = true + Logger.warn("GBC color correction draw failed: %s", tostring(drawError)) + return canvas + end + return output +end + +return Correction 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..9fc8bcb94 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) diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..60667a734 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -20,6 +20,10 @@ -- (data/palettes_yellow.lua / ROM cgbBase). local GameVersion = require("src.core.GameVersion") +local HotModules = require("src.core.HotModules") +local Performance = require("src.core.Performance") +local nativeDraw = love and love.graphics and love.graphics.draw +local nativeSetShader = love and love.graphics and love.graphics.setShader local PaletteFX = {} @@ -309,7 +313,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; @@ -535,6 +539,22 @@ function PaletteFX.gen2Tod() return gen2Tod end -- bgSet fetches it (LoadMapPals). Neither reads or writes the live state, so -- calling them cannot disturb what the 2D atlas was baked with. +-- Public RTC-only query used by Gen2 scene renderers (including train rides). +-- It must not inherit the live map override, darkness, or atlas bake state. +function PaletteFX.clockDaytime(hour) + hour = tonumber(hour) + if not hour and GameVersion.isGen2() then + local game = package.loaded["src.core.Game"] + hour = require("src.core.Gen2Clock").hour(game and game.save) + end + if not hour then hour = tonumber(os.date("%H")) end + hour = math.floor(hour or 12) % 24 + if hour < 4 then return "NITE" end + if hour < 10 then return "MORN" end + if hour < 18 then return "DAY" end + return "NITE" +end + -- Which of MORN / DAY / NITE / DARK a map takes. -- -- mapDef a record out of data.maps; its `mapPalette` is map header @@ -1181,7 +1201,60 @@ end -- send a 4-color (0-255 RGB) palette to the shade-remap shader, after -- applying the active COLORS display mode +local nativeEffectiveColors, nativePermute = PaletteFX.effectiveColors, PaletteFX.permute +local _uniforms = setmetatable({}, {__mode="k"}) +local UNIFORM_NAMES = {"c0", "c1", "c2", "c3"} + +-- Native upload code resolves rows by index, never by a borrowed public palette. +local function effectiveSource(c) + if PaletteFX.effectiveColors ~= nativeEffectiveColors or PaletteFX.permute ~= nativePermute then + return PaletteFX.effectiveColors(c), 1, 2, 3, 4 + end + if not c then return nil end + local mode = gen2Native() and "gbc" or PaletteFX.mode or "gbc" + local reverse = mode == "og_inv" or mode == "gbc_inv" + if mode == "og" or mode == "og_inv" then c = PaletteFX.GRAYS + elseif mode == "classic" then c = PaletteFX.CLASSIC end + local a, b, d, e = 1, 2, 3, 4 + if shadeMap then a,b,d,e = shadeMap[0]+1,shadeMap[1]+1,shadeMap[2]+1,shadeMap[3]+1 end + if reverse then a,b,d,e = 5-a,5-b,5-d,5-e end + return c, a, b, d, e +end +local function uniformState(shader) + local state = _uniforms[shader] + if not state then + state = {rows={{},{},{},{}}} + _uniforms[shader] = state + end + return state +end +local function uploadRow(shader, state, index, color, force) + local row = state.rows[index] + local r,g,b = color[1]/255,color[2]/255,color[3]/255 + if force or not state.valid or row[1] ~= r or row[2] ~= g or row[3] ~= b then + row[1],row[2],row[3] = r,g,b + shader:send(UNIFORM_NAMES[index], row) + end +end +local function uploadColors(shader, c, state, force) + local source,a,b,d,e = effectiveSource(c) + if not source then return end + uploadRow(shader,state,1,source[a],force) + uploadRow(shader,state,2,source[b],force) + uploadRow(shader,state,3,source[d],force) + uploadRow(shader,state,4,source[e],force) + state.valid = true +end +local function nativeShader(shader) + return type(shader) == "userdata" and shader.typeOf and shader:typeOf("Shader") +end + +-- A caller can write raw shader uniforms between public calls. Each public +-- call therefore uploads unconditionally. Reused vectors stay inside LÖVE. function PaletteFX.sendColors(shader, c) + if Performance.paletteZoneCache and nativeShader(shader) then + return uploadColors(shader, c, uniformState(shader), true) + end c = PaletteFX.effectiveColors(c) if not c then return end shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 }) @@ -1189,5 +1262,24 @@ function PaletteFX.sendColors(shader, c) shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 }) shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 }) end +PaletteFX._nativeSendColors = PaletteFX.sendColors + +-- Cache validity is limited to one contiguous native blit. There are no mod +-- callbacks between its zones; every later blit starts with a full upload. +function PaletteFX._beginColorBatch(shader) + if not Performance.paletteZoneCache or not nativeShader(shader) + or PaletteFX.effectiveColors ~= nativeEffectiveColors or PaletteFX.permute ~= nativePermute + or love.graphics.draw ~= nativeDraw or love.graphics.setShader ~= nativeSetShader then return nil end + local state = uniformState(shader) + if state.busy then state = {rows={{},{},{},{}}} end + state.valid, state.busy = false, true + return state +end +function PaletteFX._sendColorsInBatch(shader, c, state) + return uploadColors(shader, c, state, false) +end +function PaletteFX._endColorBatch(state) + if state then state.busy = nil end +end return PaletteFX diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 4d8143548..d904224c4 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -6,12 +6,16 @@ -- 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 GbcColorCorrection = require("src.render.GbcColorCorrection") +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 @@ -31,6 +35,8 @@ local function gbcFX() return GBCFXMod end +local ShaderPresets = require("src.render.ShaderPresets") + local Renderer = {} -- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every @@ -291,7 +297,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 +318,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 @@ -416,7 +422,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 @@ -680,23 +686,100 @@ local function withTrueColor(zoneList, pass) return merged end +-- Cropped zone geometry is equivalent only on a native nearest-filtered, +-- pixel-density-matched, untransformed target. Public unusual layouts keep +-- their established scissor path (including flips and anisotropic DPI). +local rasterMajor = love and love.getVersion and love.getVersion() or 11 +local function wholePixel(value) + return math.abs(value - math.floor(value + 0.5)) <= 1e-7 +end +local function truncate(value) + return value < 0 and math.ceil(value) or math.floor(value) +end +local function zonePixelSpan(first, last, density) + local lo,hi = math.floor(first*density),math.ceil(last*density) + if rasterMajor < 12 then + -- The 11.x Lua API truncates logical scissor arguments before DPI conversion. + local left = truncate(truncate((lo+0.5)/density)*density) + local width = truncate(truncate((hi-lo+0.5)/density)*density) + return left/density,(left+width)/density + end + -- 12.x rounds float scissor values. This path is used only for power-of-two + -- DPI so the existing half-pixel padding is exactly representable. + return (lo+1)/density,(hi+2)/density +end +local function zoneQuad(self, canvas, sx, sy, bx, by, dpiX, dpiY) + if not Performance.paletteZoneCache or type(canvas) ~= "userdata" or not canvas.typeOf + or not (canvas:typeOf("Canvas") or canvas:typeOf("Texture")) + or sx <= 0 or sy <= 0 then return nil end + if not canvas.getDPIScale or canvas:getDPIScale() ~= 1 then return nil end + if not canvas.getFilter or canvas:getFilter() ~= "nearest" then return nil end + local g = love.graphics + if not (g.newQuad and g.transformPoint) then return nil end + local target = g.getCanvas() + if target and type(target) ~= "userdata" then return nil end + local density = target and target.getDPIScale and target:getDPIScale() or g.getDPIScale() + if math.abs(density - (dpiX or 1)) > 1e-7 or math.abs(density - (dpiY or 1)) > 1e-7 then return nil end + -- Fractional magnification/origins can put sample centres exactly on a + -- texel boundary. Cropping then changes floating-point tie rounding. + if not (wholePixel(sx*density) and wholePixel(sy*density) + and wholePixel(bx*density) and wholePixel(by*density)) then return nil end + if rasterMajor >= 12 and density ~= 1 and density ~= 2 and density ~= 4 then return nil end + local x0,y0 = g.transformPoint(0,0) + local xx,xy = g.transformPoint(1,0) + local yx,yy = g.transformPoint(0,1) + if x0 ~= 0 or y0 ~= 0 or xx ~= 1 or xy ~= 0 or yx ~= 0 or yy ~= 1 then return nil end + local quad = self._zoneQuad + if not quad then + local w,h = canvas:getDimensions() + quad = g.newQuad(0,0,w,h,w,h) + self._zoneQuad = quad + end + return quad +end +function Renderer:_drawZoneQuad(quad, canvas, sx, sy, zoneSx, zoneSy, bx, by, + boxX, boxY, boxW, boxH, dpiX, dpiY, z) + local x,y = bx + z.x*zoneSx, by + z.y*zoneSy + local x2,y2 = math.min(x+z.w*zoneSx,boxX+boxW),math.min(y+z.h*zoneSy,boxY+boxH) + x,y = math.max(x,boxX),math.max(y,boxY) + if x2 <= x or y2 <= y then return false end + dpiX,dpiY = dpiX or 1,dpiY or 1 + -- The old scissor rounds starts down and ends up in physical pixels. + x,x2 = zonePixelSpan(x,x2,dpiX) + y,y2 = zonePixelSpan(y,y2,dpiY) + local cw,ch = canvas:getDimensions() + local qx,qy = math.max(0,(x-bx)/sx),math.max(0,(y-by)/sy) + local qx2,qy2 = math.min(cw,(x2-bx)/sx),math.min(ch,(y2-by)/sy) + if qx2 <= qx or qy2 <= qy then return false end + quad:setViewport(qx,qy,qx2-qx,qy2-qy,cw,ch) + love.graphics.draw(canvas,quad,bx+qx*sx,by+qy*sy,0,sx,sy) + return true +end + -- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin -- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/ -- zoneSy convert zone coords (canvas-space) into screen units. Public so a -- 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) + local colorBatch = PaletteFX.sendColors == PaletteFX._nativeSendColors + and PaletteFX._beginColorBatch(shader) or nil -- a colors == false zone is the trueColor opt-out: its rect draws with -- no shader at all. Nothing sets one without a mod, so a vanilla zone -- list never toggles and issues exactly the calls it always did. + local quad = zoneSx > 0 and zoneSy > 0 and boxX >= 0 and boxY >= 0 + and zoneQuad(self, canvas, sx, sy, bx, by, dpiX, dpiY) or nil + if quad then love.graphics.setScissor() end local bare = false for _, z in ipairs(zoneList) do local plain = z.colors == false @@ -704,8 +787,14 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, bare = plain love.graphics.setShader(not plain and shader or nil) end - if not plain then PaletteFX.sendColors(shader, z.colors) end - if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy, + if not plain then + if colorBatch then PaletteFX._sendColorsInBatch(shader, z.colors, colorBatch) + else PaletteFX.sendColors(shader, z.colors) end + end + if quad then + self:_drawZoneQuad(quad, canvas, sx, sy, zoneSx, zoneSy, bx, by, + boxX, boxY, boxW, boxH, dpiX, dpiY, z) + elseif scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy, z.w * zoneSx, z.h * zoneSy, boxX, boxY, boxW, boxH, dpiX, dpiY) then love.graphics.draw(canvas, bx, by, 0, sx, sy) @@ -713,6 +802,8 @@ function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy, end love.graphics.setScissor() love.graphics.setShader() + PaletteFX._endColorBatch(colorBatch) + closeBlit() end -- THE BARS, FILLED WITH THE EDGE THEY FRAME. @@ -844,7 +935,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 +991,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 +1031,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 +1046,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 @@ -947,7 +1062,8 @@ function Renderer:endFrame(zones, worldZones) and (self.battleWipe.style == "frlg_swirl" or self.battleWipe.needsSource) and self.battleWipe.screenDraw - local needPresent = GBCFX.active() or Pipelines.wantsPresent() + local needPresent = GBCFX.active() or GbcColorCorrection.active() or Pipelines.wantsPresent() + or (ShaderPresets:active() and not self.shaderPresetBypass) or battleNeedsSource local present = nil if needPresent then @@ -990,7 +1106,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 +1149,13 @@ 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 -- A PUBLISHER MAY HAVE FREED ITS CANVAS SINCE IT HANDED IT OVER. @@ -1100,7 +1219,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 +1267,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 +1390,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 +1431,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 +1458,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 +1486,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 +1502,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,14 +1550,17 @@ 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 -- LCD grid is then drawn over rather than something that smears the -- grid itself. Each pass hands back a canvas; with none registered -- this returns `present` unchanged and the frame is byte-identical. - local composed = Pipelines.present(present, - { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present + local corrected = GbcColorCorrection.apply(present) + local composed = Pipelines.present(corrected, + { width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or corrected + if not self.shaderPresetBypass then composed = ShaderPresets:apply(composed, Sp) end if GBCFX.active() then -- shader grid/shadow math is in framebuffer pixels GBCFX.present(composed, Sp) @@ -1448,19 +1570,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/ScreenSpaceUI.lua b/src/render/ScreenSpaceUI.lua new file mode 100644 index 000000000..4ca2a5bea --- /dev/null +++ b/src/render/ScreenSpaceUI.lua @@ -0,0 +1,54 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Optional window-resolution UI. Simulation/input remain native; only an +-- explicitly advertised drawer leaves the cartridge-sized UI canvas. +local M = {} +local function visible(stack, state) + return not stack.renderVisible or stack:renderVisible(state) +end +function M.select(stack, scene, first) + for i = #scene.states, first, -1 do + local state = scene.states[i] + if visible(stack, state) and type(state.drawsWidescreen) == 'function' + and type(state.drawWidescreen) == 'function' and state:drawsWidescreen() then + return state, i + end + end +end +function M.classic(draw, renderer, width, height) + local G = love.graphics + local uw, uh = renderer:uiSize() + local p = renderer.uiPresentation + if not p then + local scale = math.max(1, math.floor(math.min(width / uw, height / uh))) + p = { x=(width-uw*scale)/2, y=(height-uh*scale)/2, scaleX=scale, scaleY=scale } + end + G.push('all') + G.origin() + G.translate(p.x+(uw-renderer.WIDTH)*p.scaleX/2, p.y+(uh-renderer.HEIGHT)*p.scaleY/2) + G.scale(p.scaleX,p.scaleY) + local ok, err = pcall(draw) + G.pop() + if not ok then error(err,0) end +end +function M.draw(owner, renderer, viewport, zones, overlay) + local G = love.graphics + G.push('all') + G.origin();G.setShader();G.setScissor();G.setColor(1,1,1,1);G.setBlendMode('alpha') + local ok, err = pcall(owner.drawWidescreen, owner, viewport.width, viewport.height) + G.pop() + if not ok then error(err,0) end + -- The classic canvas contains only states above this owner. Reapply it + -- after the high-resolution drawer so a native textbox keeps its parent. + if overlay then + local p = renderer.uiPresentation + if not p then + p = {x=viewport.gameX,y=viewport.gameY,scaleX=viewport.scale/(viewport.dpiX or 1),scaleY=viewport.scale/(viewport.dpiY or 1)} + end + G.push('all');G.origin();G.setShader();G.setColor(1,1,1,1);G.setBlendMode('alpha','premultiplied') + local overlayOK, overlayError = pcall(renderer.blitCanvas,renderer,renderer.canvas,p.scaleX,p.scaleY,zones,p.scaleX,p.scaleY, + p.x,p.y,0,0,viewport.width,viewport.height,viewport.dpiX or 1,viewport.dpiY or 1) + G.pop() + if not overlayOK then error(overlayError,0) end + end +end +return M diff --git a/src/render/ShaderGraph.lua b/src/render/ShaderGraph.lua new file mode 100644 index 000000000..45b181af1 --- /dev/null +++ b/src/render/ShaderGraph.lua @@ -0,0 +1,372 @@ +-- Bounded, data-only RGBA8 pass graphs for converted RetroArch presets. +local Graph = + { MAX_PASSES = 8, MAX_TEXTURES = 8, MAX_PIXELS = 32 * 1024 * 1024, MAX_LUT_PIXELS = 8 * 1024 * 1024 } +Graph.__index = Graph +local function finite(v) + return type(v) == "number" and v == v and math.abs(v) < math.huge +end +local function filename(v, ext) + return type(v) == "string" and #v <= 64 and v:match("^[%w_-]+%." .. ext .. "$") ~= nil +end +local filters = { nearest = true, linear = true } +local wraps = { clampzero = true, clamp = true, ["repeat"] = true, mirroredrepeat = true } +local function array(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 type(k) ~= "number" or k < 1 or k > #t or k % 1 ~= 0 then + return false + end + n = n + 1 + end + return n == #t +end +local function scale(s) + if type(s) ~= "table" or getmetatable(s) ~= nil then + return nil + end + for k in pairs(s) do + if k ~= "type" and k ~= "value" then + return nil + end + end + if not finite(s.value) then + return nil + end + if s.type == "absolute" then + if s.value % 1 ~= 0 or s.value < 1 or s.value > 8192 then + return nil + end + elseif + not ({ source = true, original = true, viewport = true })[s.type] + or s.value < 1 / 64 + or s.value > 8 + then + return nil + end + return { type = s.type, value = s.value } +end +function Graph.validate(raw) + if + type(raw) ~= "table" + or raw.format ~= 3 + or not array(raw.passes, Graph.MAX_PASSES) + or #raw.passes < 1 + or not array(raw.textures or {}, Graph.MAX_TEXTURES) + then + return nil, "invalid shader graph" + end + local out = { passes = {}, textures = {} } + for _, p in ipairs(raw.passes) do + if type(p) ~= "table" or getmetatable(p) ~= nil then + return nil, "invalid shader pass" + end + for key in pairs(p) do + if + not ({ + shader = true, + vertex = true, + esShader = true, + esVertex = true, + filter = true, + wrap = true, + scaleX = true, + scaleY = true, + })[key] + then + return nil, "unknown shader pass field" + end + end + local x, y = scale(p.scaleX), scale(p.scaleY) + if not x or not y or not filters[p.filter] or not wraps[p.wrap] then + return nil, "invalid shader pass options" + end + local copy = { filter = p.filter, wrap = p.wrap, scaleX = x, scaleY = y } + for _, k in ipairs({ "shader", "vertex", "esShader", "esVertex" }) do + if not filename(p[k], "glsl") then + return nil, "invalid shader graph filename" + end + copy[k] = p[k] + end + out.passes[#out.passes + 1] = copy + end + for _, t in ipairs(raw.textures or {}) do + if type(t) ~= "table" or getmetatable(t) ~= nil then + return nil, "invalid LUT" + end + for key in pairs(t) do + if not ({ id = true, image = true, filter = true, wrap = true })[key] then + return nil, "unknown LUT field" + end + end + if + not filename(t.image, "png") + or not filters[t.filter] + or not wraps[t.wrap] + or type(t.id) ~= "string" + or #t.id > 32 + or not t.id:match("^[%a_][%w_]*$") + then + return nil, "invalid LUT options" + end + out.textures[#out.textures + 1] = { id = t.id, image = t.image, filter = t.filter, wrap = t.wrap } + end + return out +end +local function png(bytes) + if + type(bytes) ~= "string" + or #bytes < 33 + or #bytes > 8 * 1024 * 1024 + or bytes:sub(1, 8) ~= "\137PNG\13\10\26\10" + or bytes:sub(9, 16) ~= "\0\0\0\13IHDR" + then + error("LUT must be a bounded PNG", 0) + end + local function word(p) + local a, b, c, d = bytes:byte(p, p + 3) + return ((a * 256 + b) * 256 + c) * 256 + d + end + local w, h = word(17), word(21) + if + w < 1 + or h < 1 + or w > 2048 + or h > 2048 + or bytes:byte(25) ~= 8 + or not ({ [0] = true, [2] = true, [3] = true, [4] = true, [6] = true })[bytes:byte(26)] + then + error("unsupported LUT dimensions/bit depth", 0) + end + return w, h +end +local function release(v) + if v and v.release then + pcall(v.release, v) + end +end +function Graph:own(v) + self.resources[v] = true + return v +end +function Graph:drop(v) + if v then + self.resources[v] = nil + release(v) + end +end +function Graph:release() + for v in pairs(self.resources) do + release(v) + end + self.resources = {} + self.targets = {} + self.original = nil + self.output = nil +end +local function send(shader, name, value) + if shader:hasUniform(name) then + shader:send(name, value) + end +end +-- LÖVE 11 returns a boolean; LÖVE 12 returns no value. Observe the actual mode. +local function setWrap(image, mode) + local result = image:setWrap(mode, mode) + if result == false then + error("unsupported graph wrapping", 0) + end + if image.getWrap then + local x, y = image:getWrap() + assert(x == mode and y == mode, "unsupported graph wrapping") + else + assert(result == true, "cannot verify graph wrapping") + end +end +Graph.setWrap = setWrap +local function sizes(w, h) + return { w, h, 1 / w, 1 / h } +end +local function dimensions(s, input, original, viewport) + local n = s.type == "absolute" and s.value + or s.value * ({ source = input, original = original, viewport = viewport })[s.type] + return math.max(1, math.floor(n + 0.5)) +end +function Graph:canvas(old, w, h, dpi) + local g = self.g + if + old + and old:getWidth() == w + and old:getHeight() == h + and (old.getDPIScale and old:getDPIScale() or 1) == dpi + then + return old + end + self:drop(old) + local c = self:own(g.newCanvas(w, h, { dpiscale = dpi, format = "rgba8" })) + c:setFilter("nearest", "nearest") + return c +end +function Graph:owns(image) + return self.resources[image] == true +end +function Graph:run(input, pixelScale) + local g = self.g + local pw, ph = input:getPixelDimensions() + local w, h = input:getDimensions() + local dpi = input.getDPIScale and input:getDPIScale() or 1 + local ratio = finite(pixelScale) and math.max(1, pixelScale) or 1 + local nw, nh = math.max(1, math.floor(pw / ratio + 0.5)), math.max(1, math.floor(ph / ratio + 0.5)) + local limit = g.getSystemLimits and g.getSystemLimits().texturesize or 4096 + limit = math.min(8192, limit) + local total = nw * nh + pw * ph + self.lutPixels + local resolved = {} + local iw, ih = nw, nh + for i, p in ipairs(self.spec.passes) do + local ow = dimensions(p.scaleX, iw, nw, pw) + local oh = dimensions(p.scaleY, ih, nh, ph) + total = total + ow * oh + if ow > limit or oh > limit or total > Graph.MAX_PIXELS then + error("shader graph exceeds GPU size/pixel budget", 0) + end + resolved[i] = { ow, oh } + iw, ih = ow, oh + end + if nw > limit or nh > limit or pw > limit or ph > limit or total > Graph.MAX_PIXELS then + error("shader graph exceeds GPU size/pixel budget", 0) + end + self.original = self:canvas(self.original, nw, nh, 1) + self.output = self:canvas(self.output, w, h, dpi) + for i, size in ipairs(resolved) do + self.targets[i] = self:canvas(self.targets[i], size[1], size[2], 1) + end + local saved = g.getCanvas() + local pushed, why = pcall(g.push, "all") + if not pushed then + error(why, 0) + end + local ok, err = pcall(function() + g.origin() + g.setScissor() + g.setStencilTest() + g.setDepthMode() + g.setColorMask(true, true, true, true) + g.setColor(1, 1, 1, 1) + g.setBlendMode("replace", "premultiplied") + g.setCanvas(self.original) + g.setShader() + g.clear(0, 0, 0, 0) + g.draw(input, 0, 0, 0, nw / w, nh / h) + for i, p in ipairs(self.spec.passes) do + local shader = self.shaders[i] + local source = i == 1 and self.original or self.targets[i - 1] + local sw, sh = source:getDimensions() + local ow, oh = resolved[i][1], resolved[i][2] + -- All rendered inputs are private. LUT samplers retain their own settings. + for j = 0, i - 1 do + local img = j == 0 and self.original or self.targets[j] + img:setFilter(p.filter, p.filter) + setWrap(img, p.wrap) + end + send(shader, "g2_Source", source) + send(shader, "g2_Original", self.original) + send(shader, "g2_SourceSize", sizes(sw, sh)) + send(shader, "g2_OriginalSize", sizes(nw, nh)) + send(shader, "g2_OutputSize", sizes(ow, oh)) + send(shader, "g2_FinalViewportSize", sizes(pw, ph)) + for j = 1, i - 1 do + local image = self.targets[j] + send(shader, "g2_Pass" .. (j - 1), image) + send(shader, "g2_Pass" .. (j - 1) .. "Size", sizes(image:getDimensions())) + end + for j, img in ipairs(self.images) do + send(shader, "g2_Lut" .. (j - 1), img) + send(shader, "g2_Lut" .. (j - 1) .. "Size", sizes(img:getDimensions())) + end + for id, n in pairs(self.values) do + send(shader, "g2_p_" .. id, n) + end + g.setCanvas(self.targets[i]) + g.clear(0, 0, 0, 0) + g.setShader(shader) + g.draw(source, 0, 0, 0, ow / sw, oh / sh) + end + local last = self.targets[#self.targets] + last:setFilter("nearest", "nearest") + g.setCanvas(self.output) + g.setShader() + g.clear(0, 0, 0, 0) + g.draw(last, 0, 0, 0, w / last:getWidth(), h / last:getHeight()) + end) + g.pop() + g.setCanvas(saved) + if not ok then + error(err, 0) + end + return self.output +end +function Graph.new(spec, read, g, values, fileData) + local self = setmetatable( + { + spec = spec, + g = g, + values = values, + resources = {}, + targets = {}, + shaders = {}, + images = {}, + lutPixels = 0, + }, + Graph + ) + local probe + local ok, why = pcall(function() + local renderer, version = g.getRendererInfo() + local es = (tostring(renderer) .. tostring(version)):find("OpenGL ES", 1, true) ~= nil + for i, p in ipairs(spec.passes) do + local source = {} + for _, key in ipairs({ "shader", "vertex", "esShader", "esVertex" }) do + source[key] = assert(read(p[key], 65536)) + end + for _, mobile in ipairs({ false, true }) do + local valid, message = g.validateShader( + mobile, + source[mobile and "esShader" or "shader"], + source[mobile and "esVertex" or "vertex"] + ) + assert(valid, tostring(message)) + end + self.shaders[i] = + self:own(g.newShader(source[es and "esShader" or "shader"], source[es and "esVertex" or "vertex"])) + end + for i, t in ipairs(spec.textures) do + local bytes = assert(read(t.image, 8 * 1024 * 1024)) + local width, height = png(bytes) + self.lutPixels = self.lutPixels + width * height + assert(self.lutPixels <= Graph.MAX_LUT_PIXELS, "LUT pixel budget") + local data = fileData(bytes, t.image) + local loaded, img = pcall(g.newImage, data, { mipmaps = false, linear = true }) + release(data) + if not loaded then + error(img, 0) + end + self.images[i] = self:own(img) + assert(img:getWidth() == width and img:getHeight() == height, "LUT decoder dimensions differ") + img:setFilter(t.filter, t.filter) + setWrap(img, t.wrap) + end + probe = g.newCanvas(3, 5, { dpiscale = 1, format = "rgba8" }) + local output = self:run(probe, 1) + local pixels = output:newImageData() + assert(pixels, "graph readback unavailable") + release(pixels) + end) + release(probe) + if not ok then + self:release() + return nil, why + end + return self +end +return Graph diff --git a/src/render/ShaderPresets.lua b/src/render/ShaderPresets.lua new file mode 100644 index 000000000..1f37c0d5c --- /dev/null +++ b/src/render/ShaderPresets.lua @@ -0,0 +1,476 @@ +-- Optional bounded LÖVE shaders over the completed primary game view. +-- Metadata is JSON, never executable Lua. OFF does no discovery or GPU work. +local Json = require("src.link.Json") +local Graph = require("src.render.ShaderGraph") +local Logger = require("src.core.Logger") +local Performance = require("src.core.Performance") +local Presets = {} +Presets.__index = Presets +local ROOT, MAX_JSON, MAX_SOURCE = "shader-presets", 16384, 65536 +local function finite(n) + return type(n) == "number" and n == n and math.abs(n) < math.huge +end +local function label(s) + return type(s) == "string" and #s > 0 and #s <= 48 and not s:find("[^ -~]") +end +local function filename(s, ext) + return type(s) == "string" and #s <= 64 and s:match("^[%w_-]+%." .. ext .. "$") ~= nil +end +local function release(resource) + if resource and resource.release then + pcall(resource.release, resource) + end +end +local function value(p, n) + if not finite(n) then + return p.default + end + local steps = math.floor((math.max(p.min, math.min(p.max, n)) - p.min) / p.step + 0.5) + return math.max(p.min, math.min(p.max, p.min + steps * p.step)) +end +local function validate(raw, id) + if + type(raw) ~= "table" + or (raw.format ~= 1 and raw.format ~= 2 and raw.format ~= 3) + or not label(raw.name) + then + return nil, "invalid preset metadata" + end + if raw.format ~= 3 and not filename(raw.shader, "glsl") and not raw.builtinSource then + return nil, "invalid shader filename" + end + local out = { id = id, name = raw.name, shader = raw.shader, source = raw.builtinSource, parameters = {} } + if raw.format == 3 then + local graph, why = Graph.validate(raw) + if not graph or raw.builtinSource then + return nil, why or "invalid graph" + end + out.graph, out.imported = graph, true + elseif raw.format == 2 then + if + raw.builtinSource + or not filename(raw.vertex, "glsl") + or not filename(raw.esShader, "glsl") + or not filename(raw.esVertex, "glsl") + or (raw.filter ~= "nearest" and raw.filter ~= "linear") + or not ({ clampzero = true, clamp = true, ["repeat"] = true, mirroredrepeat = true })[raw.wrap] + then + return nil, "invalid imported preset metadata" + end + out.imported, out.vertex, out.esShader, out.esVertex = true, raw.vertex, raw.esShader, raw.esVertex + out.filter, out.wrap = raw.filter, raw.wrap + end + local params = raw.parameters or {} + if type(params) ~= "table" or #params > 8 then + return nil, "invalid parameter list" + end + local seen = { sourceSize = true, pixelScale = true } + for k in pairs(params) do + if type(k) ~= "number" or k < 1 or k > #params or k ~= math.floor(k) then + return nil, "invalid parameter list" + end + end + for _, p in ipairs(params) do + if + type(p) ~= "table" + or type(p.id) ~= "string" + or #p.id > 32 + or not p.id:match("^[%a_][%w_]*$") + or seen[p.id] + or p.id:match("^love_") + or (out.imported and p.id:match("^g2_")) + or not label(p.name) + or not finite(p.min) + or not finite(p.max) + or p.min >= p.max + or not finite(p.max - p.min) + or not finite(p.step) + or p.step <= 0 + or p.step > p.max - p.min + or (p.max - p.min) / p.step > 1000000 + or not finite(p.default) + or p.default < p.min + or p.default > p.max + then + return nil, "invalid parameter definition" + end + seen[p.id] = true + out.parameters[#out.parameters + 1] = + { id = p.id, name = p.name, min = p.min, max = p.max, step = p.step, default = value(p, p.default) } + end + return out +end +local BUILTINS = { + { + id = "builtin:warm", + format = 1, + name = "WARM DISPLAY", + parameters = { + { id = "strength", name = "STRENGTH", min = 0, max = 1, step = 0.05, default = 0.4 }, + }, + builtinSource = [[ +extern number strength; +vec4 effect(vec4 color, Image tex, vec2 uv, vec2 pixel) { + vec4 c = Texel(tex, uv) * color; + return vec4(c.rgb * mix(vec3(1.0), vec3(1.0, 0.94, 0.80), strength), c.a); +}]], + }, + { + id = "builtin:soft-lcd", + format = 1, + name = "SOFT LCD", + parameters = { + { id = "strength", name = "STRENGTH", min = 0, max = 0.5, step = 0.05, default = 0.15 }, + }, + builtinSource = [[ +extern number strength; +extern number pixelScale; +vec4 effect(vec4 color, Image tex, vec2 uv, vec2 pixel) { + vec4 c = Texel(tex, uv) * color; + float row = mod(floor(pixel.y / max(1.0, pixelScale)), 2.0); + return vec4(c.rgb * (1.0 - strength * row), c.a); +}]], + }, +} +function Presets.new(opts) + opts = opts or {} + return setmetatable( + { fs = opts.fs, graphics = opts.graphics, entries = {}, byId = {}, fileData = opts.fileData }, + Presets + ) +end +function Presets:_fs() + return self.fs or (love and love.filesystem and require("src.import.CacheFs").dataFs()) +end +function Presets:_graphics() + return self.graphics or (love and love.graphics) +end +local function read(fs, path, limit) + if not fs or not fs.read or not fs.getInfo then + return nil, "filesystem unavailable" + end + local ok, info = pcall(fs.getInfo, path) + if not ok or not info or info.type ~= "file" or (info.size and info.size > limit) then + return nil, "missing or oversized file: " .. path + end + local success, text = pcall(fs.read, path, limit + 1) + if not success or type(text) ~= "string" or #text > limit then + return nil, "cannot read file: " .. path + end + return text +end +function Presets:refresh() + self.entries, self.byId = {}, {} + local function add(entry) + self.entries[#self.entries + 1] = entry + self.byId[entry.id] = entry + end + for _, raw in ipairs(BUILTINS) do + add(assert(validate(raw, raw.id))) + end + local fs = self:_fs() + local ok, names = false, nil + if fs and fs.getDirectoryItems then + ok, names = pcall(fs.getDirectoryItems, ROOT) + end + if ok and type(names) == "table" then + table.sort(names) + for _, name in ipairs(names) do + if filename(name, "json") then + local id = "local:" .. name:sub(1, -6) + local text, why = read(fs, ROOT .. "/" .. name, MAX_JSON) + local parsed, raw = false, nil + if text then + parsed, raw = pcall(Json.decode, text) + end + local entry + if parsed then + -- Local metadata cannot inject a built-in source or use Lua loaders. + if type(raw) == "table" then + raw.builtinSource = nil + end + entry, why = validate(raw, id) + end + add(entry or { id = id, name = name:sub(1, -6), error = why or "invalid JSON" }) + end + end + end + self.scanned = true + return self.entries +end +function Presets:list() + if not self.scanned then + self:refresh() + end + return self.entries +end +function Presets:_fail(reason) + self.error = tostring(reason):gsub("[\r\n]+", " "):sub(1, 240) + return nil, self.error +end +function Presets:off() + if self.graph then + self.graph:release() + self.graph = nil + end + release(self.shader) + release(self.output) + release(self.importInput) + self.importInput = nil + self.shader, self.output, self.entry, self.values = nil, nil, nil, nil + self.error = nil +end +function Presets:active() + if Performance.extraCap("shaderPresets")==false then return false end + return self.shader ~= nil or self.graph ~= nil +end +function Presets:activate(id, params) + if id == nil or id == "off" then + self:off() + return true + end + self:list() + local entry = self.byId[id] + if not entry then + return self:_fail("preset not found") + end + if entry.error then + return self:_fail(entry.error) + end + local g = self:_graphics() + if entry.graph then + if not (g and g.newShader and g.newCanvas and g.validateShader) then + return self:_fail("shader graph graphics unavailable") + end + local values = {} + for _, p in ipairs(entry.parameters) do + values[p.id] = value(p, type(params) == "table" and params[p.id] or nil) + end + local graph, why = Graph.new(entry.graph, function(name, limit) + return read(self:_fs(), ROOT .. "/" .. name, limit) + end, g, values, self.fileData or (love and love.filesystem and love.filesystem.newFileData)) + if not graph then + return self:_fail(why) + end + self:off() + self.graph, self.entry, self.values = graph, entry, values + return true + end + local es = false + if entry.imported and g and g.getRendererInfo then + local name, version = g.getRendererInfo() + es = (tostring(name) .. tostring(version)):find("OpenGL ES", 1, true) ~= nil + end + local source, why = entry.source + if not source then + source, why = read(self:_fs(), ROOT .. "/" .. (es and entry.esShader or entry.shader), MAX_SOURCE) + end + local vertex + if entry.imported then + vertex, why = read(self:_fs(), ROOT .. "/" .. (es and entry.esVertex or entry.vertex), MAX_SOURCE) + if not vertex then + return self:_fail(why) + end + end + if not source then + return self:_fail(why) + end + if not (g and g.newShader and g.newCanvas) then + return self:_fail("shader graphics unavailable") + end + local shader, importInput + local values = {} + local ok, err = pcall(function() + shader = vertex and g.newShader(source, vertex) or g.newShader(source) + if entry.imported then + -- Check NPOT wrapping and standardized uniform types before retiring the old effect. + importInput = g.newCanvas(3, 5, { dpiscale = 1 }) + importInput:setFilter(entry.filter, entry.filter) + Graph.setWrap(importInput, entry.wrap) + for _, name in ipairs({ "SourceSize", "OriginalSize", "OutputSize", "FinalViewportSize" }) do + if shader:hasUniform("g2_" .. name) then + shader:send("g2_" .. name, { 3, 5, 1 / 3, 1 / 5 }) + end + end + if shader:hasUniform("g2_Source") then + shader:send("g2_Source", importInput) + end + end + for _, p in ipairs(entry.parameters) do + local uniform = entry.imported and ("g2_p_" .. p.id) or p.id + -- Slang declares parameters in both blocks even when optimized out. + assert(entry.imported or shader:hasUniform(uniform), "missing parameter uniform: " .. uniform) + values[p.id] = value(p, type(params) == "table" and params[p.id] or nil) + if shader:hasUniform(uniform) then + shader:send(uniform, values[p.id]) + end + end + end) + if not ok then + release(shader) + release(importInput) + return self:_fail(err) + end + self:off() + self.shader, self.entry, self.values = shader, entry, values + self.importInput = importInput + return true +end +function Presets:applyOptions(opts) + -- Reset the previous game's selection even when this save's preset is bad. + self:off() + local id = type(opts) == "table" and opts.shaderPreset + if id == nil or id == "off" then + return true + end + local all = type(opts.shaderPresetParams) == "table" and opts.shaderPresetParams + return self:activate(id, all and all[id]) +end +function Presets:select(opts, id) + local all = type(opts.shaderPresetParams) == "table" and opts.shaderPresetParams + local ok, why = self:activate(id, all and all[id]) + if ok then + opts.shaderPreset = id or "off" + end + return ok, why +end +function Presets:setParameter(opts, id, n) + if not self.entry then + return nil, "no active preset" + end + for _, p in ipairs(self.entry.parameters) do + if p.id == id then + self.values[id] = value(p, n) + if type(opts.shaderPresetParams) ~= "table" then + opts.shaderPresetParams = {} + end + local key = self.entry.id + if type(opts.shaderPresetParams[key]) ~= "table" then + opts.shaderPresetParams[key] = {} + end + opts.shaderPresetParams[key][id] = self.values[id] + return true + end + end + return nil, "unknown parameter" +end +function Presets:apply(input, pixelScale) + if Performance.extraCap("shaderPresets")==false then return input end + if self.graph then + if self.graph:owns(input) then + return input + end + local ok, output = pcall(self.graph.run, self.graph, input, pixelScale) + if ok then + return output + end + self:off() + self:_fail(output) + Logger.warn("shader graph disabled: %s", self.error) + return input + end + if not self:active() or (input == self.output or input == self.importInput) then + return input + end + local g = self:_graphics() + local ok, err = pcall(function() + local w, h = input:getDimensions() + local dpi = input.getDPIScale and input:getDPIScale() or 1 + if + not self.output + or self.output:getWidth() ~= w + or self.output:getHeight() ~= h + or self.output:getDPIScale() ~= dpi + then + release(self.output) + self.output = nil + self.output = g.newCanvas(w, h, { dpiscale = dpi }) + self.output:setFilter("nearest", "nearest") + end + end) + if not ok then + self:off() + self:_fail(err) + return input + end + local pushed, pushError = pcall(g.push, "all") + if not pushed then + self:off() + self:_fail(pushError) + return input + end + ok, err = pcall(function() + g.setCanvas(self.output) + g.origin() + g.setScissor() + g.setStencilTest() + g.setDepthMode() + g.setColorMask(true, true, true, true) + g.setBlendMode("replace", "premultiplied") + g.setColor(1, 1, 1, 1) + local sampled = input + if self.entry.imported then + local pw, ph = input:getPixelDimensions() + local scale = finite(pixelScale) and math.max(1, pixelScale) or 1 + local nw, nh = math.max(1, math.floor(pw / scale + 0.5)), math.max(1, math.floor(ph / scale + 0.5)) + if not self.importInput or self.importInput:getWidth() ~= nw or self.importInput:getHeight() ~= nh then + release(self.importInput) + self.importInput = nil + self.importInput = g.newCanvas(nw, nh, { dpiscale = 1 }) + self.importInput:setFilter(self.entry.filter, self.entry.filter) + Graph.setWrap(self.importInput, self.entry.wrap) + end + -- Reconstruct the primary view's native-pixel grid in a private texture. + -- Its filter/wrap never change a mod's or the renderer's input texture. + g.setCanvas(self.importInput) + g.setShader() + local iw, ih = input:getDimensions() + g.draw(input, 0, 0, 0, nw / iw, nh / ih) + sampled = self.importInput + g.setCanvas(self.output) + local function send(id, v) + if self.shader:hasUniform(id) then + self.shader:send(id, v) + end + end + local ow, oh = input:getPixelDimensions() + local size = { nw, nh, 1 / nw, 1 / nh } + send("g2_Source", sampled) + send("g2_SourceSize", size) + send("g2_OriginalSize", size) + send("g2_OutputSize", { ow, oh, 1 / ow, 1 / oh }) + send("g2_FinalViewportSize", { ow, oh, 1 / ow, 1 / oh }) + end + for id, n in pairs(self.values) do + local uniform = self.entry.imported and ("g2_p_" .. id) or id + if self.shader:hasUniform(uniform) then + self.shader:send(uniform, n) + end + end + if self.shader:hasUniform("sourceSize") then + local w, h = input:getPixelDimensions() + self.shader:send("sourceSize", { w, h }) + end + if self.shader:hasUniform("pixelScale") then + self.shader:send("pixelScale", finite(pixelScale) and math.max(1, pixelScale) or 1) + end + g.setShader(self.shader) + if self.entry.imported then + local w, h = input:getDimensions() + g.draw(sampled, 0, 0, 0, w / sampled:getWidth(), h / sampled:getHeight()) + else + g.draw(input, 0, 0) + end + end) + g.pop() + if not ok then + self:off() + self:_fail(err) + Logger.warn("shader preset disabled: %s", self.error) + return input + end + return self.output +end +local default = Presets.new() +default.new = Presets.new +return default diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 07f605295..704e3ff9b 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -8,7 +8,9 @@ local Logger = require("src.core.Logger") local Assets = require("src.render.Assets") +local UiQuad = require("src.render.UiQuad") local Font = require("src.render.Font") +local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") @@ -420,6 +422,44 @@ function TextBox:beginLine() table.insert(self.shown, {}) end +-- Only the manual input branches; typing, automatic text and holds still +-- belong to update(), including the delay after a page/line advance. +function TextBox:advance() + if (self.holdFrames or 0) > 0 or (self.preWait or 0) > 0 then + return + end + if self.done then + if self.stay or self.auto or self.choice then + return + end + require("src.core.Sound").play(self.game.data, "Press_AB") + self.game.stack:pop() + if self.onDone then + self.onDone() + end + elseif self.waiting then + require("src.core.Sound").play(self.game.data, "Press_AB") + self.waiting = false + if self.contAdvance then + -- ContText / ManualTextScroll: keep the box, scroll one line + self.contAdvance = false + self.lineIndex = self.lineIndex + 1 + self:beginLine() + -- ScrollTextUpOneLine is 5 blocking frames and, as its own comment + -- says, is "always called twice in a row" (home/text.asm:280-305) + self.holdFrames = self.scrollHoldFrames or Timing.TEXT_SCROLL_PAIR + else + self.shown = {} + self.pageIndex = self.pageIndex + 1 + self.lineIndex = 1 + self:beginLine() + -- ClearScreenArea then DelayFrames 20: the box sits empty before the + -- next page starts typing (home/text.asm:236-240) + self.holdFrames = Timing.TEXT_PAGE_CLEAR + end + end +end + function TextBox:update(dt) local input = self.game.input self.blink = (self.blink + 1) % 60 @@ -519,9 +559,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 +572,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 +638,7 @@ function TextBox:uiSize() end function TextBox:draw() + if not UIVisibility.bottomVisible(self, true) then return end -- 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 @@ -699,7 +720,7 @@ function TextBox:draw() local seq = { 0, 1, 2, 1 } local frame = math.min(frames - 1, seq[math.floor(self.blink / 9) % 4 + 1]) local iw, ih = self.promptArrowImage:getDimensions() - local quad = love.graphics.newQuad(frame * fw, 0, fw, fh, iw, ih) + local quad = UiQuad.get(self,"prompt",self.promptArrowImage,frame * fw,0,fw,fh,iw,ih) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.promptArrowImage, quad, lastPen, lastY) end diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 5f7a007a5..6f1fb4b89 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -8,10 +8,13 @@ -- 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") local PaletteFX = require("src.render.PaletteFX") +local Performance = require("src.core.Performance") +local performancePaletteTicks=setmetatable({}, {__mode="k"}) local TileRenderer = {} TileRenderer.__index = TileRenderer @@ -999,6 +1002,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 +1032,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 +1121,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 +1138,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 +1146,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 +1181,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(Performance.paletteTick(animFrame)) + end -- ---- WHAT MOVES, AND WHERE ITS OTHER PICTURES GO ------------------------ -- @@ -1198,7 +1219,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 +1233,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 +1242,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 +1331,27 @@ 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 tick=Performance.paletteTick(animFrame) + if performancePaletteTicks[record]==tick then return end + performancePaletteTicks[record]=tick + local changed = tiles:setPaletteTicks(tick) + 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 +1641,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 +1661,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 +1698,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 +1711,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 +1734,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 +1908,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 +1961,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 +2038,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 +2047,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 +2090,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 +2190,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 +2397,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/UiQuad.lua b/src/render/UiQuad.lua new file mode 100644 index 000000000..114faaaae --- /dev/null +++ b/src/render/UiQuad.lua @@ -0,0 +1,24 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native immediate draws only; records and hook arguments never contain this cache. +local Performance = require("src.core.Performance") +local M = {} +local owners = setmetatable({}, { __mode = "k" }) +function M.get(owner, slot, image, x, y, w, h, iw, ih) + if not Performance.lowTierBudget then + return love.graphics.newQuad(x, y, w, h, iw, ih) + end + local record = owners[owner] + if not record then + record = {} + owners[owner] = record + end + local q = record[slot] + if not q then + q = love.graphics.newQuad(x, y, w, h, iw, ih) + record[slot] = q + else + q:setViewport(x, y, w, h, iw, ih) + end + return q +end +return M diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 79a875bd7..d313bddef 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -141,6 +141,8 @@ local LAYOUT_GEN2 = { numBalls = 0xD5FC, balls = 0xD5FD, numPCItems = 0xD616, pcItems = 0xD617, pokegearFlags = 0xD67C, + momMoney=0xD576,momSaving=0xD579,momWhich=0xD961,momSet=0xD962,momTrigger=0xD963, + decorations = 0xD959, -- wDecoBed; eight bytes, big doll is last eventFlags = 0xD7B7, curBox = 0xD8BC, visitedSpawns = 0xD9EE, mapGroup = 0xDA00, mapNumber = 0xDA01, yCoord = 0xDA02, xCoord = 0xDA03, partyCount = 0xDA22, partySpecies = 0xDA23, partyMons = 0xDA2A, @@ -268,6 +270,8 @@ local LAYOUT_CRYSTAL = { numBalls = 0xD8D7, balls = 0xD8D8, numPCItems = 0xD8F1, pcItems = 0xD8F2, pokegearFlags = 0xD957, + momMoney=0xD851,momSaving=0xD854,momWhich=0xDC17,momSet=0xDC18,momTrigger=0xDC19, + decorations = 0xDC0F, -- wDecoBed, distinct from Gold/Silver eventFlags = 0xDA72, curBox = 0xDB72, visitedSpawns = 0xDCA5, mapGroup = 0xDCB5, mapNumber = 0xDCB6, yCoord = 0xDCB7, xCoord = 0xDCB8, partyCount = 0xDCD7, partySpecies = 0xDCD8, partyMons = 0xDCDF, @@ -365,6 +369,12 @@ end function Gen2Save.setLayout(version) applyLayout((type(version) == "table" and version) or LAYOUTS[tostring(version)] or LAYOUT_GEN2) + -- An unreviewed hack's legacy fallback layout does not prove where its + -- decoration bytes live. Explicit custom layouts may name their own run. + if type(version)~="table" and version~="gold" and version~="silver" and version~="crystal" then + O.decorations=nil + O.momMoney=nil;O.momSaving=nil;O.momWhich=nil;O.momSet=nil;O.momTrigger=nil + end end applyLayout(LAYOUT_GEN2) @@ -1024,6 +1034,9 @@ end -- decode: raw 32768-byte SRAM string -> save.lua-shaped table -- ------------------------------------------------------------------ +-- Physical WRAM order differs from the public semantic slot order. +local DECO_SLOTS={"bed","carpet","plant","poster","console","leftOrnament","rightOrnament","bigDoll"} + function Gen2Save.decode(bytes, data) assert(#bytes == Gen2Save.SAVE_SIZE, "expected a 32768-byte save") assert(charmap, "Gen2Save.setCharmap must be called before decode") @@ -1060,6 +1073,11 @@ function Gen2Save.decode(bytes, data) currentBox = 1, } + if O.decorations then + save.decorations={} + for i,key in ipairs(DECO_SLOTS)do save.decorations[key]=u8(bytes,O.decorations+i-1)end + end + -- bag: four pockets folded into one flat inventory local bagOrder = {} decodePairList(bytes, O.items, MAX_ITEMS, cw, save.inventory, bagOrder) @@ -1115,6 +1133,18 @@ function Gen2Save.decode(bytes, data) end end + if O.momMoney then + save.g2MomMoney=u24be(bytes,O.momMoney) + local mode=u8(bytes,O.momSaving) + save.g2MomSavingMode=mode%8 + save.g2MomSaving=save.g2MomSavingMode~=0 + save.g2MomBankOpen=hasBit(mode,7)or nil + save.flags.ENGINE_MOM_SAVING_MONEY=hasBit(mode,0) + save.flags.ENGINE_MOM_ACTIVE=hasBit(mode,7) + save.g2MomShopping={whichItem=u8(bytes,O.momWhich),whichSet=u8(bytes,O.momSet), + triggerBalance=u24be(bytes,O.momTrigger)} + end + -- wVisitedSpawns -> the FLY list. HasVisitedSpawn tests bit [spawn id]; -- this port spells the same set save.visited[mapId] (src/ui/TownMap.lua), -- so without this an imported save could not fly anywhere. @@ -1240,6 +1270,15 @@ function Gen2Save.encode(save, data, template) for i = 1, Gen2Save.SAVE_SIZE do buf[i] = zero end end + if O.decorations and type(save.decorations)=="table"then + for i,key in ipairs(DECO_SLOTS)do + local value=save.decorations[key]or 0 + assert(type(value)=="number"and value>=0 and value<=255 and value%1==0, + "invalid native decoration byte: "..key) + setByte(buf,O.decorations+i-1,value) + end + end + local player = save.player or {} local playerName = player.name or "GOLD" encodeName(buf, O.playerName, NAME_LENGTH, playerName) @@ -1296,6 +1335,30 @@ function Gen2Save.encode(save, data, template) end end + if O.momMoney then + local function nativeNumber(value,limit,label) + assert(type(value)=="number"and value%1==0 and value>=0 and value<=limit,"invalid native Mom "..label) + return value + end + if save.g2MomMoney~=nil then setU24be(buf,O.momMoney,nativeNumber(save.g2MomMoney,999999,"balance"))end + local value=buf[O.momSaving+1]:byte() + if save.g2MomSavingMode~=nil or save.g2MomSaving~=nil then + local mode=save.g2MomSavingMode~=nil and nativeNumber(save.g2MomSavingMode,7,"saving mode")or(save.g2MomSaving and 1 or 0) + if save.flags and save.flags.ENGINE_MOM_SAVING_MONEY~=nil then + mode=mode-mode%2+(save.flags.ENGINE_MOM_SAVING_MONEY and 1 or 0) + end + value=value-value%8+mode + end + if save.g2MomBankOpen~=nil then value=withBit(value,7,save.g2MomBankOpen==true)end + setByte(buf,O.momSaving,value) + if type(save.g2MomShopping)=="table"then + local state=save.g2MomShopping + setByte(buf,O.momWhich,nativeNumber(state.whichItem,255,"ladder index")) + setByte(buf,O.momSet,nativeNumber(state.whichSet or 0,255,"random row")) + setU24be(buf,O.momTrigger,nativeNumber(state.triggerBalance,999999,"random threshold")) + end + end + -- wVisitedSpawns: the FLY destinations this save has reached local spawnFlags = data and data.field and data.field.spawnFlags if type(spawnFlags) == "table" then diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 53cef91fa..55f74952f 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -636,6 +636,36 @@ end -- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex function Commands.start_battle(ctx, kind, a, b, opts) + local skipPreparation = opts and (opts.link or opts.firstBattle or opts.firstBattleKind + or opts.oldManTutorial or opts.tutorialKind) + if kind ~= "wild" and not skipPreparation + and require("src.mods.Runtime").wantsHook("trainer.before_battle") then + local runner, co, map = ctx.runner, ctx.runner.co, ctx.overworld and ctx.overworld.map + local ready, waiting, cancelled, selected = false, false, false, nil + local function finish(options) + selected, ready = options, true + if waiting then runner:resume() end + end + require("src.battle.TrainerPreparation").prepare(ctx.game, { + trainerClass = a, partyIndex = b or 1, + mapId = map and map.id, npcId = ctx.npc and ctx.npc.id, + }, finish, function() + cancelled, ready = true, true + ctx.lastCheck, ctx.lastBattleResult = false, "cancel" + ctx.trainerPreparationCancelled = true + if waiting then runner:cancelTrainerPreparation(ctx, co) end + end, function() + return runner.co == co and runner.ctx == ctx and runner:isRunning() + and (not ctx.overworld or ctx.overworld.map == map) + end, function() runner:cancelTrainerPreparation(ctx, co, true) end) + if not ready then waiting = true; runner:yield(); waiting = false end + if cancelled then error(require("src.battle.TrainerPreparation").CANCEL_SCRIPT, 0) end + local original = opts + opts = {} + for key, value in pairs(original or {}) do opts[key] = value end + if type(selected) == "table" then opts.playerPartyIndices = selected.playerPartyIndices end + end + local BattleState = require("src.battle.BattleState") local runner = ctx.runner local battle @@ -665,7 +695,7 @@ function Commands.start_battle(ctx, kind, a, b, opts) battle.enemy.mon.fatefulEncounter = true end else - battle = BattleState.newTrainer(ctx.game, a, b) + battle = BattleState.newTrainer(ctx.game, a, b, opts) -- the beaten trainer's own line, printed on the battle screen before -- MoneyForWinningText rather than by the script afterwards battle.endBattleText = opts and opts.endBattleText or nil @@ -1285,7 +1315,8 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) -- This is intentionally an event rather than a special-case starter hook: -- mods can use the same seam for story gifts, fossils, or custom scripts. local gift = { ctx = ctx, species = species, level = level } - if ctx.game.mods then + local giftEvents = ctx.game.mods and ctx.game.mods.events + if giftEvents and (not giftEvents.listeners or giftEvents.listeners["pokemon.before_give"]) then ctx.game.mods.events:emit("pokemon.before_give", gift) species, level = gift.species, gift.level end @@ -1333,6 +1364,8 @@ function Commands.give_pokemon(ctx, species, level, skipNickname, opts) dex.seen[species] = true dex.owned[species] = true end + local Unown = require("src.pokemon.Gen2Unown") + if Unown.supported() then Unown.registerCatch(ctx.save, mon, ctx.game.data) end require('src.pokemon.Gen4Forms').record(ctx.game,species,mon) ctx.lastCheck = true ctx.addedToParty = addedToParty diff --git a/src/script/DecodedScript.lua b/src/script/DecodedScript.lua new file mode 100644 index 000000000..12f3f190d --- /dev/null +++ b/src/script/DecodedScript.lua @@ -0,0 +1,155 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Compiler provenance lives outside public command rows and engine contexts. +local M = {} +local origins = setmetatable({}, { __mode = "k" }) + +function M.origin(rows, data, entry, compile, generation) + origins[rows] = { + data = setmetatable({ data, data.map_scripts.scripts }, { __mode = "v" }), + entry = entry, + compile = compile, + generation = generation, + } +end +function M.trace() + return { commands = {}, byKey = {}, owners = {}, labels = {}, returns = {}, returnKeys = {} } +end +function M.begin(t, label, index, ir) + local c = { key = label .. ":" .. index, label = label, index = index, ir = ir, returns = 0 } + t.commands[#t.commands + 1], t.byKey[c.key], t.current = c, c, c +end +function M.emit(t, row) + t.owners[row] = t.current +end +function M.returnLabel(t, label) + local c = t.current + if not c then + return + end + c.returns = c.returns + 1 + local key = c.key .. ":return:" .. c.returns + t.returns[label], t.returnKeys[key] = key, label +end +function M.finish(rows, t) + local positions = {} + for pc, row in ipairs(rows) do + positions[row] = pc + local c = t.owners[row] + if c and not c.pc then + c.pc = pc + end + end + t.starts = {} + local nextPC + for i = #t.commands, 1, -1 do + local c = t.commands[i] + local finish = assert(positions[t.labels[c.label]], "missing decoded script boundary") + if not nextPC or c.label ~= t.commands[i + 1].label then + nextPC = finish + end + c.after = nextPC + c.pc = c.pc or nextPC + nextPC = c.pc + end + for _, c in ipairs(t.commands) do + t.starts[c.pc] = t.starts[c.pc] or {} + t.starts[c.pc][#t.starts[c.pc] + 1] = c + end +end +function M.prepare(rows) + local stored = origins[rows] + if not stored then + return + end + local data = assert(stored.data[1], "decoded script source dataset has been retired") + assert( + data.map_scripts and data.map_scripts.scripts == stored.data[2], + "decoded script source cohort has been retired" + ) + -- Only an executing script borrows a strong source reference. The weak-key + -- compiler cache and this provenance table must not keep each other alive. + local origin = { data = data, entry = stored.entry, compile = stored.compile, generation = stored.generation } + local trace = M.trace() + local result = origin.compile(origin.data, origin.entry, { trace = trace }) + assert(M.same(result, rows), "decoded profile requires an unmodified native compiled script") + return rows, trace, origin +end +-- Recompile a private overlay; never change imported IR or the ordinary cache. +function M.rewrite(origin, oldTrace, overrides, command, ir, ctx) + overrides[command.label] = overrides[command.label] or {} + overrides[command.label][command.index] = ir + local scripts = {} + for label, rows in pairs(origin.data.map_scripts.scripts) do + if overrides[label] then + local copy = {} + for i, row in ipairs(rows) do + copy[i] = overrides[label][i] or row + end + scripts[label] = copy + else + scripts[label] = rows + end + end + local data, pool = {}, {} + for k, v in pairs(origin.data) do + data[k] = v + end + for k, v in pairs(origin.data.map_scripts) do + pool[k] = v + end + pool.scripts, data.map_scripts = scripts, pool + local trace = M.trace() + local rows = origin.compile(data, origin.entry, { trace = trace, keepLabels = oldTrace.labels }) + for _, field in ipairs({ "g2Stack", "g3Stack" }) do + for i, label in ipairs(ctx[field] or {}) do + local key = oldTrace.returns[label] + if key then + ctx[field][i] = assert(trace.returnKeys[key], "rewritten command removed an active return") + end + end + end + return rows, trace, assert(trace.byKey[command.key]) +end +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[k] = M.copy(v, seen) + end + return out +end +function M.same(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] then + return seen[a] == b + end + seen[a] = b + for k, v in pairs(a) do + if not M.same(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 +function M.source(origin, command) + return origin.data.map_scripts.scripts[command.label][command.index] +end +return M diff --git a/src/script/Gen2Commands.lua b/src/script/Gen2Commands.lua index a62fa0e17..c56eb9107 100644 --- a/src/script/Gen2Commands.lua +++ b/src/script/Gen2Commands.lua @@ -896,6 +896,27 @@ function Gen2Commands.scriptVarValue(ctx, var) return 1 end +function Commands.g2_apricorn_converted(ctx,apricorn,ball,event) + local Runtime=require("src.mods.Runtime") + if Runtime.wants("apricorn.converted")then + Runtime.emit("apricorn.converted",{apricorn=apricorn,ball=ball,event=event}) + end +end + +function Commands.g2_deco_visibility(ctx) + require('src.world.Gen2Decorations').visibility(ctx) +end +function Commands.g2_deco_tiles(ctx) + require('src.world.Gen2Decorations').tiles(ctx) +end +function Commands.g2_deco_description(ctx,kind,allowed) + local target,name=require('src.world.Gen2Decorations').description(ctx.game.data,ctx.save,kind) + if target and allowed and allowed[target]then + if name then setBuffer(ctx.game,3,name)end + return target + end +end + -- Script_loadmem (25:$7495): write an immediate byte to a WRAM address. The -- same address-keyed mirror readmem/writemem use -- keyed by the raw -- address number exactly as they key it, so a script that pokes a byte here @@ -1098,6 +1119,12 @@ function Commands.g2_object(ctx, index, visible) obj, mapId = objectByIndex(ctx, index) end if not obj or not mapId then return end + return Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) +end + +-- Shared native effect after resolving a map object. Public API adapters can +-- supply the resolved template without depending on cached script-ID numbering. +function Gen2Commands.setObjectVisibility(ctx, obj, mapId, visible) local OverworldState = require("src.world.OverworldController") local name = OverworldState.objectToggleKey(obj) -- The PERSISTENT half, which this used to leave to a `setevent` the ROM @@ -1779,6 +1806,19 @@ end -- --------------------------------------------------------------------------- function Commands.g2_warp(ctx, mapId, x, y, facing) + if mapId=="NONE" then + local ow=ctx.overworld + if ow and ow.map and type(ow.reloadMap)=="function"then + local map=ow.map.id + ctx.afterScript=ctx.afterScript or{} + ctx.afterScript[#ctx.afterScript+1]=function() + if ow.player and DIRS[facing]then ow.player.facing=DIRS[facing]end + ow:reloadMap(map,"native-script") + end + return math.huge + end + return + end if type(mapId) ~= "string" or mapId == "" then return end Commands.warp(ctx, mapId, x, y, facing) end @@ -2054,13 +2094,33 @@ end function Gen2Commands.phoneName(data, id) local entry = phoneContact(data, id) if not entry then return nil end - if entry.name then return entry.name, entry.role end local best = phoneTrainerDef(data, entry) + if entry.name then return entry.name, entry.role or (best and best.name) end if not best then return nil end local names = best.partyNames return (names and names[entry.trainer]) or best.name, best.name end +-- Observation of an admitted native incoming call, never of a model-side +-- candidate. Keep the payload detached from the native row and queued IR. +function Gen2Commands.announceIncomingCall(data, id, special) + local Runtime = require("src.mods.Runtime") + if not Runtime.wants("phone.call_received") then return end + local row = phoneContact(data, id) or {} + local pool = data and data.map_scripts or {} + local specialRow = special and pool.specialCalls and pool.specialCalls[special] + local key = specialRow and specialRow.script or row.receive + local name, className = Gen2Commands.phoneName(data, id) + local constants = data and data.constants or {} + local class = (constants.trainerClassOrder or {})[row.class] + local member = class and (constants.trainerClassMembers or {})[class] + local call = {kind="call", direction="incoming", contact=id, + class=class, member=member and member[row.trainer], number=row.contactNumber, + script=key, scriptKey=key, special=special} + Runtime.emit("phone.call_received", {call=call, contact=id, + name=name, className=className, special=special, scriptKey=key}) +end + -- The row's second `dba` is the script PhoneCall (36:$4298) runs when the -- player rings the contact; the first is the one they run when they ring in. -- Both are walked into map_scripts.scripts by the extractor, so hand back the @@ -2122,14 +2182,14 @@ function Gen2Commands.phoneCallDelay(save) return GEN2_CALL_DELAYS[math.min(cycles, #GEN2_CALL_DELAYS - 1) + 1] end --- The sample GetAvailableCallers builds, in PHONE_* order. +-- The sample GetAvailableCallers builds, in saved phone-slot order. function Gen2Commands.availableCallers(data, save, mapDef, tod) local bit = GEN2_TOD_BIT[tod] or 2 local out = {} local group = mapDef and tonumber(mapDef.group) local number = mapDef and tonumber(mapDef.number) - for id, registered in pairs(phoneList(save)) do - if registered and type(id) == "number" then + for _, id in ipairs(require("src.world.Gen2PhoneBook").contacts(save)) do + if id ~= 0 then local entry = phoneContact(data, id) local mask = entry and tonumber(entry.receiveTime) or 0 -- `and [hl]` on the mask, then the map compare @@ -2139,10 +2199,19 @@ function Gen2Commands.availableCallers(data, save, mapDef, tod) end end end - table.sort(out) return out end +-- ChooseRandomCaller swaps one Random byte's nibbles, masks to five bits, +-- then takes the remainder against the eligible count. Preserve that exact +-- mapping and byte consumption rather than drawing a uniform list index. +function Gen2Commands.chooseRandomCaller(callers, rng) + if not callers or #callers == 0 then return nil end + local byte = (rng and rng() or math.random(0, 255)) % 256 + local swapped = (byte % 16) * 16 + math.floor(byte / 16) + return callers[(swapped % 32) % #callers + 1] +end + -- Returns the caller id and its compiled receive script, or nil. `minutes` is -- the play clock in minutes, which stands in for the ROM's day/hour/minute -- countdown; `entrance` is CheckStandingOnEntrance. @@ -2190,7 +2259,7 @@ function Gen2Commands.rollIncomingCall(data, save, mapDef, tod, minutes, entranc if math.floor(palette / 16) % 16 ~= 0 then return nil end local callers = Gen2Commands.availableCallers(data, save, mapDef, tod) if #callers == 0 then return nil end - local id = callers[math.random(#callers)] + local id = Gen2Commands.chooseRandomCaller(callers) local script = Gen2Commands.phoneReceiveScript(data, id) if not script then return nil end -- InitCallReceiveDelay at the tail of Script_ReceivePhoneCall (36:$42B7): @@ -2290,16 +2359,18 @@ end function Commands.g2_bank_of_mom(ctx) local save = ctx.save + local Mom=require("src.world.Gen2MomShopping") + Mom.prepareBank(save) -- CheckIfBankInitialized: the first visit opens the account and asks if save.g2MomBankOpen == nil then - save.g2MomBankOpen = true + Mom.openBank(save) Commands.ask(ctx, momLine(ctx, "saveMoney", "Should I save money for you?")) if ctx.lastCheck then - save.g2MomSaving = true + Mom.setSaving(save,true) return momSay(ctx, "startSavingMoney", "OK, I'll save your money.") end - save.g2MomSaving = false + Mom.setSaving(save,false) return momSay(ctx, "justDoWhatYouCan", "Just do what you can.") end @@ -2364,7 +2435,7 @@ function Commands.g2_bank_of_mom(ctx) momSay(ctx, "takenMoney", "Took ¥" .. amount .. ".") end) elseif choice == 3 then - save.g2MomSaving = not save.g2MomSaving + Mom.setSaving(save,not save.g2MomSaving) if save.g2MomSaving then momSay(ctx, "startSavingMoney", "OK, I'll save your money.") else @@ -2628,12 +2699,9 @@ function Commands.g2_readvar(ctx, var) elseif var == 6 then ctx.g2Var = countKeys(ctx.save.pokedex and ctx.save.pokedex.seen) elseif var == 10 then - ctx.g2Var = tonumber(os.date("%H")) or 0 + ctx.g2Var = require("src.core.Gen2Clock").hour(ctx.save) elseif var == 11 then - -- g2DayOffset is what mom's SetDayOfWeek picker answered, as a shift off - -- the host day, so Arthur still shows up on a real Thursday. - ctx.g2Var = ((tonumber(os.date("%w")) or 0) - + (ctx.save.g2DayOffset or 0)) % 7 + ctx.g2Var = require("src.core.Gen2Clock").weekday(ctx.save) elseif var == 16 then -- BoxFreeSpace counts only the current box, not all available PC slots. local Boxes = require("src.pokemon.Boxes") @@ -2666,6 +2734,12 @@ function Commands.g2_readvar(ctx, var) -- sampled by `special SampleKenjiBreakCountdown` ctx.g2Var = ctx.save.g2KenjiBreak or 0 elseif var == 14 then + local Unown = require("src.pokemon.Gen2Unown") + if Unown.supported() then + ctx.g2Var = Unown.count(ctx.save) + ctx.lastCheck = ctx.g2Var ~= 0 + return + end -- UnownCaught (engine/events/unown_walls.asm): how many distinct UNOWN -- letters are in the dex. The port has no per-letter model, so a caught -- UNOWN counts once -- enough for the Ruins researcher's `ifgreater 2` @@ -2720,39 +2794,25 @@ end -- Phone_FindOpenSlot (36:$402D) fails when none of them is free. That cap is -- the whole point of the ".PhoneFull" branch every phone trainer carries -- an -- uncapped list can never reach it. -local GEN2_PHONE_SLOTS = 10 - -local function phoneListCount(save) - local n = 0 - for _, registered in pairs(phoneList(save)) do - if registered then n = n + 1 end - end - return n -end - -- AddPhoneNumber (36:$4000): carry means "not added" -- either the id is --- already in the list or there is no free slot. -local function addPhoneNumber(save, id) - local list = phoneList(save) - if list[id] then return false end - if phoneListCount(save) >= GEN2_PHONE_SLOTS then return false end - list[id] = true - return true +-- already in the list or there is no unreserved slot. Keep the same ordered +-- native book for the script verbs and Pokegear; the set remains compatible. +local function addPhoneNumber(ctx, id) + return require("src.world.Gen2PhoneBook").add( + ctx.data or (ctx.game and ctx.game.data), ctx.save, id) end function Commands.g2_cellnum(ctx, id, add) if type(id) ~= "number" then return end if add then - ctx.lastCheck = addPhoneNumber(ctx.save, id) + ctx.lastCheck = addPhoneNumber(ctx, id) else - local list = phoneList(ctx.save) - ctx.lastCheck = list[id] == true - list[id] = nil + ctx.lastCheck = require("src.world.Gen2PhoneBook").remove(ctx.save, id) end end function Commands.g2_check_cellnum(ctx, id) - ctx.lastCheck = phoneList(ctx.save)[id] == true + ctx.lastCheck = require("src.world.Gen2PhoneBook").has(ctx.save, id) ctx.g2Var = ctx.lastCheck and 1 or 0 end @@ -2783,7 +2843,7 @@ function Commands.g2_ask_cellnum(ctx, id, textId) ctx.g2Var = 2 return end - if addPhoneNumber(ctx.save, id) then + if addPhoneNumber(ctx, id) then ctx.g2Var = 0 else ctx.g2Var = 1 @@ -2898,7 +2958,8 @@ local WEEKDAYS = { -- either way the day still rolls over at real midnight instead of freezing. function Commands.g2_set_day_of_week(ctx) local game, runner = ctx.game, ctx.runner - local host = tonumber(os.date("%w")) or 0 + local Clock = require("src.core.Gen2Clock") + local current = Clock.weekday(ctx.save) local picked repeat Commands.show_text(ctx, "SetDayOfWeek.OakTimeWhatDayIsItText") @@ -2912,14 +2973,14 @@ function Commands.g2_set_day_of_week(ctx) -- no cancel: the ROM's loop watches A only, and onCancel is the only -- other path back to runner:resume() local menu = require("src.ui.Menu").new(game, items, { cancelable = false }) - menu.index = host + 1 + menu.index = current + 1 menu:clampScroll() game.stack:push(menu) runner:yield() Commands.ask(ctx, Strings(WEEKDAYS[picked + 1]) .. resolvedText(ctx, "SetDayOfWeek.OakTimeIsItText")) until ctx.lastCheck - ctx.save.g2DayOffset = (picked - host) % 7 + Clock.setWeekday(ctx.save, picked) end -- UnownPuzzle (SpecialsPointers row 41). The chamber scripts run @@ -3002,72 +3063,60 @@ end -- run of `ifequal` rows that decide which ball Kurt starts on. -- --------------------------------------------------------------------------- --- RED, BLU, BLK, WHT, PNK, GRN, YLW -- the same ids Kurt1 `checkitem`s before --- it bothers to ask -local APRICORNS = { 85, 89, 92, 93, 97, 99, 101 } - function Commands.g2_select_apricorn(ctx) - local game, runner, save = ctx.game, ctx.runner, ctx.save - local Bag = require("src.inventory.Bag") - local Menu = require("src.ui.Menu") - local picked, held, items = 0, 0, {} - -- SelectApricornForKurt zeroes wKurtApricornQuantity on entry, so a - -- cancelled pick cannot leave the previous count standing. - save.g2KurtApricorns = 0 - for _, id in ipairs(APRICORNS) do - local key = string.format("ITEM_%03d", id) - local qty = save.inventory and save.inventory[key] or 0 - if qty > 0 then - local def = game.data.items and game.data.items[key] - items[#items + 1] = { - label = string.format("%s x%d", (def and def.name) or key, qty), - onSelect = function() picked, held = id, qty runner:resume() end, - } + local game,runner,save=ctx.game,ctx.runner,ctx.save + local Bag=require("src.inventory.Bag") + local Menu=require("src.ui.Menu") + local Apricorns=require("src.world.Gen2Apricorns") + local version=require("src.core.GameVersion").get() + local stock=version=="gold" or version=="silver" or version=="crystal" + save.g2KurtApricorns=0 + local selected=1 + while true do + local picked,held,key=0,0,nil + local items={} + for _,row in ipairs(Apricorns.inBag(game.data,save))do + local id,quantity,item=row.item,row.quantity,row.apricorn + local def=game.data.items and game.data.items[item] + items[#items+1]={label=string.format("%s x%d",(def and def.name) or item,quantity), + onSelect=function()picked,held,key=id,quantity,item;runner:resume()end} end - end - if #items == 0 then - ctx.g2Var, ctx.lastCheck = 0, false - return - end - game.stack:push(Menu.new(game, items, { - onCancel = function() runner:resume() end, - })) - runner:yield() - if picked == 0 then - ctx.g2Var, ctx.lastCheck = 0, false - return - end - - -- "How many should I make?" -- Kurt_SelectQuantity (engine/events/kurt.asm) - -- loads wItemQuantity with how many of that apricorn you HAVE and lets you - -- scroll 1..that, then Kurt_GiveUpSelectedQuantityOfSelectedApricorn takes - -- exactly that many and the count goes into wKurtApricornQuantity -- - -- VAR_KURT_APRICORNS, which the `verbosegiveitemvar` on the collection - -- branch reads back to decide how many balls to hand over. - -- - -- Taking one apricorn and never writing that var is what left the var at 0 - -- and, with verbosegiveitemvar unlowered as well, Kurt holding a ball he - -- could never give. - local count = 1 - if held > 1 then - local rows = {} - for n = 1, held do - rows[#rows + 1] = { - label = string.format("x%d", n), - onSelect = function() count = n runner:resume() end, - } + if #items==0 then ctx.g2Var,ctx.lastCheck=0,false;return end + if stock then + items[#items+1]={label="CANCEL",onSelect=function()runner:resume()end} + end + local menu=Menu.new(game,items,{maxVisible=version=="crystal" and 4 or 8, + onCancel=function()runner:resume()end}) + menu.index=math.min(selected,#items);menu:clampScroll() + game.stack:push(menu);runner:yield() + if picked==0 then ctx.g2Var,ctx.lastCheck=0,false;return end + selected=menu.index + local count,cancelled=1,false + -- Gold/Silver's special always tosses exactly one. Crystal asks even + -- for a one-item stack and goes back to the list on quantity cancel. + if version=="crystal" or version~="gold" and version~="silver" and held>1 then + local choices={} + for n=1,held do + local quantity=n + choices[#choices+1]={label=string.format("x%d",n), + onSelect=function()count=quantity;runner:resume()end} + end + game.stack:push(Menu.new(game,choices,{maxVisible=5, + onCancel=function()cancelled=true;runner:resume()end})) + runner:yield() + end + if not cancelled or version~="crystal" then + count=math.max(1,math.min(count,held)) + local available=tonumber(save.inventory and save.inventory[key]) or 0 + if available~=available or available= save.g2LuckyNextFriday + return Clock.dateKey(save, now) >= save.g2LuckyNextFriday end function Gen2Daily.resetLottery(save, now) @@ -121,7 +119,10 @@ function Gen2Daily.poll(save, now) Gen2Daily.ensureRoam(save, nil) if not save then return end Gen2Daily.seedInitialObjectFlags(save) - local today = todayKey(now) + local today = Clock.dateKey(save, now) + if save.g2PokerusDay==nil or save.g2DailyDay~=today then + require('src.pokemon.Gen2Pokerus').poll(save,now) + end if save.g2DailyDay == today then return end save.g2DailyDay = today Gen2Daily.onNewDay(save) diff --git a/src/script/Gen2ScriptVM.lua b/src/script/Gen2ScriptVM.lua index 0d5c51cfe..39bb41db7 100644 --- a/src/script/Gen2ScriptVM.lua +++ b/src/script/Gen2ScriptVM.lua @@ -23,6 +23,7 @@ -- behind the hand-ported data/scripts/ modules, which still win outright. local Logger = require("src.core.Logger") +local Decoded = require("src.script.DecodedScript") local MapScripts = require("src.script.MapScripts") local Gen2Flags = require("src.script.Gen2Flags") require("src.script.Gen2Commands") @@ -75,7 +76,10 @@ local function itemId(n) return string.format("ITEM_%03d", n) end local L = {} -local function emit(s, row) s.out[#s.out + 1] = row end +local function emit(s, row) + s.out[#s.out + 1] = row + if s._decoded then Decoded.emit(s._decoded, row) end +end -- A pointer that fell outside the ROM, or a far script that failed to -- disassemble, is stored as "" by the extractor. Branches to one lower to @@ -278,19 +282,30 @@ L.givepokemail = function() end -- NPC instead. -- loadtemptrainer/randomwildmon/loadpikachudata build a battle out of WRAM -- scratch the port fills from its own encounter tables. --- _2dmenu/describedecoration are presentation the port has no +-- _2dmenu is presentation the port has no -- equivalent for; the surrounding dialogue still runs. -- autoinput replays a canned joypad script over a cutscene. -- (catchtutorial used to live here too; it is implemented now, below.) local function vmNoop() end for _, name in ipairs({ "xycompare", "writeobjectxy", "loadtemptrainer", "randomwildmon", - "loadpikachudata", "_2dmenu", "describedecoration", + "loadpikachudata", "_2dmenu", "autoinput", }) do L[name] = vmNoop end +L.describedecoration=function(ir,s) + local p=s.decorationPrograms + if type(p)~='table'then return end + local allowed={} + for _,label in pairs(p.posters or{})do local target=branch(s,label);if target then allowed[target]=true end end + for _,role in ipairs({'ornament','big','null'})do + local target=branch(s,p[role]);if target then allowed[target]=true end + end + emit(s,{'g2_deco_description',ir[2],allowed}) +end + L["end"] = function(_, s) emit(s, { "g2_return" }) end @@ -477,6 +492,7 @@ L.setevent = function(ir, s) else emit(s,{"set_flag",eventFlag(ir[2])}) end end L.clearevent = function(ir, s) emit(s, { "clear_flag", eventFlag(ir[2]) }) end +L.apricornconverted = function(ir,s)emit(s,{"g2_apricorn_converted",ir[2],ir[3],ir[4]})end L.checkflag = function(ir, s) emit(s, { "check_flag", engineFlag(ir[2]) }) end L.setflag = function(ir, s) emit(s, { "set_flag", engineFlag(ir[2]) }) end L.clearflag = function(ir, s) emit(s, { "clear_flag", engineFlag(ir[2]) }) end @@ -1080,6 +1096,12 @@ local SPECIALS = { PokemonCenterPC = "g2_pokemon_center_pc", Special_PokemonCenterPC = "g2_pokemon_center_pc", PlayersHousePC = "g2_players_house_pc", + ToggleDecorationsVisibility = "g2_deco_visibility", + ToggleDecorationsVisibilitySpecial = "g2_deco_visibility", + Special_ToggleDecorationsVisibility = "g2_deco_visibility", + ToggleMaptileDecorations = "g2_deco_tiles", + ToggleMaptileDecorationsSpecial = "g2_deco_tiles", + Special_ToggleMaptileDecorations = "g2_deco_tiles", Special_PlayersHousePC = "g2_players_house_pc", Special_KrissHousePC = "g2_players_house_pc", MoveTutor = "g2_move_tutor", @@ -1215,9 +1237,6 @@ local SPECIALS_NOOP = { PlayCurMonCry = true, PlaySlowCry = true, SurfStartStep = true, - -- decorations are not modelled, so toggling their visibility does nothing - ToggleDecorationsVisibility = true, - ToggleMaptileDecorations = true, UnusedDummySpecial = true, UnusedBattleTowerDummySpecial1 = true, UnusedBattleTowerDummySpecial2 = true, @@ -2365,7 +2384,7 @@ function Gen2ScriptVM.lowered(op) return L[op] ~= nil end -function Gen2ScriptVM.compile(data, entry) +local function compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end @@ -2373,13 +2392,15 @@ function Gen2ScriptVM.compile(data, entry) local key = entry compiled[scripts] = compiled[scripts] or {} local hit = compiled[scripts][key] - if hit ~= nil then return hit or nil end + if not options and hit ~= nil then return hit or nil end local out, queued, order = {}, { [entry] = true }, { entry } local counter = 0 local state = { out = out, + _decoded = options and options.trace, stds = pool.stds, + decorationPrograms = pool.decorationPrograms, has = function(label) return type(label) == "string" and scripts[label] ~= nil end, @@ -2391,14 +2412,20 @@ function Gen2ScriptVM.compile(data, entry) end, newLabel = function() counter = counter + 1 - return string.format("%s_r%d", entry, counter) + local label = string.format("%s_r%d", entry, counter) + if options then Decoded.returnLabel(options.trace, label) end + return label end, } + if options and options.keepLabels then + for label in pairs(options.keepLabels) do state.want(label) end + end local index = 1 while index <= #order do local label = order[index] index = index + 1 + if options then options.trace.current = nil end emit(state, { "label", label }) state.lastText = nil state.lastTextRow = nil @@ -2423,6 +2450,7 @@ function Gen2ScriptVM.compile(data, entry) local sifStack, guardLabel = {}, nil local rows = scripts[label] or {} for irIndex, ir in ipairs(rows) do + if options then Decoded.begin(options.trace, label, irIndex, ir) end local op = ir[1] if SIF_OPS[op] then local after = state.newLabel() @@ -2488,7 +2516,9 @@ function Gen2ScriptVM.compile(data, entry) for i = #sifStack, 1, -1 do emit(state, { "label", sifStack[i] }) end if guardLabel then emit(state, { "label", guardLabel }) end -- a script that ran off the end of its own bytecode still has to unwind - emit(state, { "g2_return" }) + local finish = { "g2_return" } + if options then options.trace.current = nil; options.trace.labels[label] = finish end + emit(state, finish) end -- A script that lowers to nothing is why an NPC can be walked up to, @@ -2498,10 +2528,21 @@ function Gen2ScriptVM.compile(data, entry) if #out == 0 then Logger.warn("gen2 script vm: '%s' compiled to zero rows (silent NPC)", key) end - compiled[scripts][key] = #out > 0 and out or false + if options then + Decoded.finish(out, options.trace) + else + Decoded.origin(out, data, entry, compile, 2) + compiled[scripts][key] = #out > 0 and out or false + end return #out > 0 and out or nil end +-- Keep the public compiler's original two-argument contract. Profile tracing +-- uses its private compiler reference, including when a mod wraps compile(). +function Gen2ScriptVM.compile(data, entry) + return compile(data, entry) +end + -- Prism renamed a good deal of Crystal's script vocabulary without changing -- what the commands DO -- `jump` for `sjump`, `if_equal` for `ifequal`, -- `end_all` for `endall`. An unlowered command is not an error, it just diff --git a/src/script/Gen2Specials.lua b/src/script/Gen2Specials.lua index b554b340e..4c3cd65df 100644 --- a/src/script/Gen2Specials.lua +++ b/src/script/Gen2Specials.lua @@ -716,12 +716,16 @@ function Commands.g2_pokemon_center_pc(ctx) answer(ctx, 0) end --- PlayerPC is a Menu of its own, so it closes on its LOG OFF row and on B. --- Neither reaches this script, so there is nothing to wait on: push it and --- answer, exactly as g2_card_flip does. +-- The house script branches on the accumulated decoration-change bit only +-- after LOG OFF/B. Returning before the menu closes silently skips its reload. function Commands.g2_players_house_pc(ctx) - require("src.ui.Screens").push(ctx.game, "PlayerPC") - answer(ctx, 0) + local runner=ctx.runner + local done,changed=false,false + require("src.ui.Screens").push(ctx.game,"PlayerPC",{house=true,onDone=function(value) + if done then return end;done=true;changed=value==true;runner:resume() + end}) + runner:yield() + answer(ctx,changed and 1 or 0) end -- --------------------------------------------------------------------------- diff --git a/src/script/Gen3ScriptVM.lua b/src/script/Gen3ScriptVM.lua index d5c709496..7de34bef5 100644 --- a/src/script/Gen3ScriptVM.lua +++ b/src/script/Gen3ScriptVM.lua @@ -36,6 +36,7 @@ -- mis-frame most of the dialogue in Hoenn. local Logger = require("src.core.Logger") +local Decoded = require("src.script.DecodedScript") local MapScripts = require("src.script.MapScripts") require("src.script.Gen3Commands") @@ -80,7 +81,10 @@ Gen3ScriptVM.flagName = flagName local L = {} -local function emit(s, row) s.out[#s.out + 1] = row end +local function emit(s, row) + s.out[#s.out + 1] = row + if s._decoded then Decoded.emit(s._decoded, row) end +end local droppedBranch = {} @@ -803,19 +807,20 @@ end Gen3ScriptVM.store = store -function Gen3ScriptVM.compile(data, entry) +local function compile(data, entry, options) local pool = store(data) local scripts = pool and pool.scripts if not (scripts and type(entry) == "string" and scripts[entry]) then return nil end compiled[scripts] = compiled[scripts] or {} local hit = compiled[scripts][entry] - if hit ~= nil then return hit or nil end + if not options and hit ~= nil then return hit or nil end local out, queued, order = {}, { [entry] = true }, { entry } local counter = 0 local state = { out = out, + _decoded = options and options.trace, has = function(label) return type(label) == "string" and scripts[label] ~= nil end, @@ -827,33 +832,53 @@ function Gen3ScriptVM.compile(data, entry) end, newLabel = function() counter = counter + 1 - return ("%s_r%d"):format(entry, counter) + local label = ("%s_r%d"):format(entry, counter) + if options then Decoded.returnLabel(options.trace, label) end + return label end, } + if options and options.keepLabels then + for label in pairs(options.keepLabels) do state.want(label) end + end local index = 1 while index <= #order do local label = order[index] index = index + 1 + if options then options.trace.current = nil end emit(state, { "label", label }) -- lastText does NOT survive across script boundaries: a callstd at the -- top of one script must not print the line the previous one loaded state.lastText = nil - for _, ir in ipairs(scripts[label] or {}) do + for irIndex, ir in ipairs(scripts[label] or {}) do + if options then Decoded.begin(options.trace, label, irIndex, ir) end local lower = L[ir[1]] if lower then lower(ir, state) end end -- a script that ran off the end of its own bytecode still has to unwind - emit(state, { "g3_return" }) + local finish = { "g3_return" } + if options then options.trace.current = nil; options.trace.labels[label] = finish end + emit(state, finish) end if #out == 0 then Logger.warn("gen3 script vm: '%s' compiled to zero rows (silent NPC)", entry) end - compiled[scripts][entry] = #out > 0 and out or false + if options then + Decoded.finish(out, options.trace) + else + Decoded.origin(out, data, entry, compile, 3) + compiled[scripts][entry] = #out > 0 and out or false + end return #out > 0 and out or nil end +-- Keep the public compiler's original two-argument contract. Profile tracing +-- uses its private compiler reference, including when a mod wraps compile(). +function Gen3ScriptVM.compile(data, entry) + return compile(data, entry) +end + -- --------------------------------------------------------------------------- -- registration -- --------------------------------------------------------------------------- diff --git a/src/script/ScriptRunner.lua b/src/script/ScriptRunner.lua index 595ca1fa9..f89ba64f1 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.trainerPreparationCancelled 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. @@ -175,10 +176,20 @@ end -- an array of rows; control commands return a new program counter, as a -- row number or a label name. function ScriptRunner:exec(script, ctx) + local Decoded, trace, origin + local decodedHooks = Runtime.hooks._decodedHooks + if decodedHooks and decodedHooks.chains["script.command"] then + Decoded = require("src.script.DecodedScript") + local prepared + prepared, trace, origin = Decoded.prepare(script) + script = prepared or script + end local labels = ScriptRunner.scanLabels(script) local data = self.game and self.game.data local pc = 1 - while pc <= #script do + local function step() + if pc > #script then return end + if trace and ctx.scriptCancelled then pc = math.huge; return end local row = script[pc] local name = row[1] -- THE ROW THE RUNNER IS STANDING ON, kept so a script that parks and @@ -251,6 +262,10 @@ function ScriptRunner:exec(script, ctx) if ok then jump = result else + if result == require("src.battle.TrainerPreparation").CANCEL_SCRIPT then + self:cancelTrainerPreparation(ctx, self.co) + return + end Logger.error("script: command '%s' failed at row %d (skipped): %s", tostring(name), pc, tostring(result)) jump = nil @@ -273,6 +288,84 @@ function ScriptRunner:exec(script, ctx) end end end + if not trace then + while pc <= #script do step() end + return + end + local overrides, visited = {}, {} + local dispatch + dispatch = function() + local candidates = trace and trace.starts[pc] + local command + for _, candidate in ipairs(candidates or {}) do + if not visited[candidate.key] then command = candidate; break end + end + if not command then + step() + visited = {} + else + visited[command.key] = true + local positive = command.after > command.pc + local ran, active, completed = false, true, false + local function vanilla(hctx, op, ir) + assert(active, "decoded next is only valid during its callback") + assert(not ran, "decoded command already executed") + assert(hctx == ctx, "decoded next must retain its native script context") + ran = true + if op ~= command.ir[1] or not Decoded.same(ir, command.ir) then + local copy = {}; for k, v in pairs(ir) do copy[k] = v end; copy[1] = op + script, trace, command = Decoded.rewrite(origin, trace, overrides, command, copy, ctx) + labels = ScriptRunner.scanLabels(script) + pc = command.pc + end + local first, after = command.pc, command.after + local nestedCall = origin.generation == 2 and (op == "scall" or op == "farscall") + local depth = nestedCall and #(ctx.g2Stack or {}) or 0 + positive = after > first + while pc >= first and pc < after and pc <= #script do step() end + -- Gen-2 decoded calls are synchronous: their hook's post-next code + -- observes the completed subroutine, including any native suspension. + if nestedCall then + while pc <= #script and not ctx.scriptCancelled and #(ctx.g2Stack or {}) > depth do dispatch() end + end + completed = true + end + local copy = Decoded.copy(Decoded.source(origin, command)) + local ok, err = pcall(decodedHooks.call, decodedHooks, "script.command", vanilla, ctx, copy[1], copy) + active = false + if not ok then error(err, 0) end + assert(not ran or completed, "decoded native command did not complete; downstream failure cannot be swallowed") + if not ran then + -- Suppress compile-time state as well (loadword/text fusion), once for + -- this visit. The next visit still offers the original source opcode. + script, trace, command = Decoded.rewrite(origin, trace, overrides, command, { "_decoded_skip" }, ctx) + labels = ScriptRunner.scanLabels(script) + pc = command.after + positive = command.after > command.pc + end + if positive then visited = {} end + end + end + while pc <= #script do + dispatch() + if ctx.scriptCancelled then return end + end +end + +-- Retire only the coroutine that owns this admission; cancellation is not a +-- native battle outcome and must never reach post-win script rows/callbacks. +function ScriptRunner:cancelTrainerPreparation(ctx, co, silent) + if self.ctx ~= ctx or self.co ~= co or not co then return false end + ctx.trainerPreparationCancelled = true + ctx.lastCheck, ctx.lastBattleResult = false, "cancel" + 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. @@ -294,6 +387,10 @@ function ScriptRunner:resume(...) end local ok, err = coroutine.resume(co, ...) if not ok then + if err == require("src.battle.TrainerPreparation").CANCEL_SCRIPT then + self:cancelTrainerPreparation(self.ctx, co) + return + end local source = self.ctx and self.ctx.source local where = source and Strings(" [%s %s.%s]", tostring(source.modId or "engine"), diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 13566a36a..8fee26e5a 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -1244,11 +1244,14 @@ function BagMenu.new(game, opts) options[#options + 1] = { label = Strings("QUIT"), onSelect = function() end } end local th = #options * 2 + 1 - game.stack:push(Menu.new(game, options, gen2 + local actions = Menu.new(game, options, gen2 and { tx = 0, ty = 12 - th, tw = 7, th = th } - or { tx = 13, ty = 10, tw = 7, th = th })) + or { tx = 13, ty = 10, tw = 7, th = th }) + actions.publicUIKind = "bag_actions" + game.stack:push(actions) end, }) + list.publicUIKind, list.publicUIBattle = "bag", battle if gen2 then list.sgbPalettes = packPalettes end return list end diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 1f9f41f81..6261b64ec 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -1,6 +1,7 @@ -- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right). local Font = require("src.render.Font") +local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Strings = require("src.core.Strings") local Timing = require("src.core.Timing") @@ -30,6 +31,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,25 +61,14 @@ 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 function ChoiceBox:draw() + if not UIVisibility.bottomVisible(self, false) then return end local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th -- rides the same bottom anchor as the dialogue box it sits above, so the -- pair travels together (the anchor keeps each element's gap from the edge) diff --git a/src/ui/Gen2DecorationMenu.lua b/src/ui/Gen2DecorationMenu.lua new file mode 100644 index 000000000..381b63b04 --- /dev/null +++ b/src/ui/Gen2DecorationMenu.lua @@ -0,0 +1,91 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- The PC changes placed bytes. Room callbacks render them after native reload. +local Native=require('src.world.Gen2Decorations') +local Menu=require('src.ui.Menu') +local Runner=require('src.script.ScriptRunner') +local Strings=require('src.core.Strings') +local M={} +function M.new(game,opts) + opts=opts or{} + local changed,finished=false,false + local programs=Native.pool(game.data)and Native.pool(game.data).decorationPrograms + local texts=programs and programs.texts or{} + local function finish() + if finished then return end;finished=true + if opts.onDone then opts.onDone(changed)end + end + local function say(role,onDone,extra) + local id=texts[role] + if not id then if onDone then onDone()end;return end + local r=Runner.new(game,game.overworld) + r:run({{'show_text',id,nil,extra}}, {onDone=onDone}) + return r + end + local function perform(id,side) + local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) + local state=Native.state(game.data,game.save) + if not(state and action)or not action.put and not Native.owns(game.data,game.save,id)then return end + local placement=Native.placement(game.data,state,id,side) + if not placement then return end + game.stringBuffers=game.stringBuffers or{} + for n,name in pairs(placement.buffers)do game.stringBuffers[n]=name end + say(placement.role,function()changed=Native.choose(game.data,game.save,id,side)or changed end) + end + local function select(id) + local row=Native.attributes(game.data,id);local action=row and Native.action(row.action) + if not action then return end + if not action.ornament then perform(id);return end + local selected,question + local function close(side) + selected=side + if question then game.stack:pop();question:resume() + elseif side then perform(id,side)end + end + local function sides() + game.stack:push(Menu.new(game,{ + {label=Strings('RIGHT SIDE'),onSelect=function()close('right')end}, + {label=Strings('LEFT SIDE'),onSelect=function()close('left')end}, + {label=Strings('CANCEL'),onSelect=function()close()end}, + },{tx=6,ty=6,tw=14,th=8,noSound=true,onCancel=function()close()end})) + end + local role=action.put and 'sidePut'or 'sideSetup' + if texts[role]then + question=say(role,function()if selected then perform(id,selected)end end,{stay={onShown=sides}}) + else sides()end + end + local function category(key) + local items={} + for _,c in ipairs(Native.categories(game.data))do + if c.slot==key then + for _,id in ipairs(c.members)do if Native.owns(game.data,game.save,id)then items[#items+1]=id end end + return items,c.headers + end + end + return items,{} + end + local rows={} + if Native.ready(game.data)and Native.state(game.data,game.save)then + for _,c in ipairs(Native.categories(game.data))do + local key=c.slot;local ids=category(key) + if #ids>0 then + rows[#rows+1]={label=Strings(c.label),keepOpen=true,onSelect=function() + local owned,headers=category(key) + if #owned==0 then say('noChoice');return end + local items={} + for _,id in ipairs(owned)do + local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} + end + for _,id in ipairs(headers)do + local value=id;items[#items+1]={label=Native.name(game.data,value),onSelect=function()select(value)end} + end + items[#items+1]={label=Native.name(game.data,0)~=''and Native.name(game.data,0)or Strings('CANCEL')} + game.stack:push(Menu.new(game,items,{tx=0,ty=0,tw=20,maxVisible=8,noSound=true})) + end} + end + end + end + rows[#rows+1]={label=Strings('EXIT'),onSelect=finish} + return Menu.new(game,rows,{tx=5,ty=0,tw=15,maxVisible=8,noSound=true,onCancel=finish}) +end +return M diff --git a/src/ui/Gen2Intro.lua b/src/ui/Gen2Intro.lua index 92ceb91fa..8a323db27 100644 --- a/src/ui/Gen2Intro.lua +++ b/src/ui/Gen2Intro.lua @@ -208,11 +208,30 @@ function Gen2Intro.new(game, onDone) return self end +local function bootStage(self) + if not self._bootEntered then return end + local phase=self.scene==1 and 'copyright' or self.scene==2 and 'gamefreak' or 'movie' + if phase==self._bootStage then return end + self._bootStage=phase + if phase=='movie'then self._bootMovie=true;self._bootFrames=0 end + local Runtime=require('src.mods.Runtime') + local event='intro.boot.'..phase + if Runtime.wants(event)then + local payload={screen=self,game=self.game} + if phase=='copyright'then Runtime.emit("intro.boot.copyright",payload) + elseif phase=='gamefreak'then Runtime.emit("intro.boot.gamefreak",payload) + else Runtime.emit("intro.boot.movie",payload)end + end +end + function Gen2Intro:enter() if self.skipAll then self:finish() return end + self._bootEntered=true + self._bootFrames=0 + bootStage(self) local data = self.game.data local song = self.cfg.music or "Music_TitleScreen" if data.audio and data.audio.songs and data.audio.songs[song] then @@ -223,6 +242,13 @@ end function Gen2Intro:finish() if self.finished then return end self.finished = true + if self._bootMovie then + local Runtime=require('src.mods.Runtime') + if Runtime.wants("intro.boot.movie_ended")then + Runtime.emit("intro.boot.movie_ended",{screen=self,game=self.game, + skipped=self._bootSkipped==true,frames=self._bootFrames or 0}) + end + end pcall(Music.stop) self.game.stack:pop() if self.onDone then self.onDone() end @@ -230,9 +256,11 @@ end function Gen2Intro:update(dt) if self.finished then return end + self._bootFrames=(self._bootFrames or 0)+1 local input = self.game.input if input and (input:wasPressed("start") or input:wasPressed("a") or input:wasPressed("b")) then + self._bootSkipped=true self:finish() return end @@ -250,6 +278,7 @@ end -- hSCX / hSCY as each scene's setup leaves them (IntroScene1 and -- IntroScene6 both DisableLCD and reseed the camera). function Gen2Intro:enterScene() + if self.scenes[self.scene]then bootStage(self)end local scene = self.scenes[self.scene] local name = scene and scene[1] if name == "underwater" then diff --git a/src/ui/Gen2MapRadio.lua b/src/ui/Gen2MapRadio.lua new file mode 100644 index 000000000..f93655920 --- /dev/null +++ b/src/ui/Gen2MapRadio.lua @@ -0,0 +1,65 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- A wall radio owns its native script until the player closes the broadcast. +-- Native Pokegear program text is reused; this is not a ROM show-state port. +local Radio=require("src.world.Gen2Radio") +local Gear=require("src.ui.PokegearMenu") +local M={};M.__index=M;M.isOpaque=false +function M.new(game,opts) + opts=opts or {} + assert(require("src.core.GameVersion").isGen2(),"MapRadio requires a Gen2 host") + if opts.save and opts.save~=game.save then game=setmetatable({save=opts.save,writeSave=false},{__index=game})end + local world=game.overworld + local map=world and world.map + map=map and (map.def or map) + local tod=opts.timeOfDay or (world and world.timeOfDay and world:timeOfDay()) or "DAY" + local channel=tonumber(opts.channel) or 0 + local self=setmetatable({game=game,onDone=opts.onDone,channel=channel,hold=100,frames=0,line=1},M) + self.map,self.tod=map,tod + self.station=self:resolveStation(channel) + -- Reuse only the drawing/program helpers. Opening a wall radio must not + -- initialize Pokegear cards, roaming encounters or its portrait sheets. + self.gear=setmetatable({game=game},{__index=Gear}) + for _,row in ipairs(self.gear:stationList())do if row.id==self.station then self.program=row;break end end + if self.station then self.music=require("src.world.Gen2RadioAudio").tune(game,self.station)end + Radio.notify(game.data,self.station,channel,"map_radio") + return self +end +function M:exit() + local playing=self.music;self.music=nil + require("src.world.Gen2RadioAudio").finish(self.game,playing) +end +function M:name()return self.station and Radio.stationName(self.game.data,self.station)end +function M:resolveStation(channel) + local id=Radio.resolve(self.game.data,channel,self.map,self.tod) + return id,id and Radio.stationName(self.game.data,id) +end +function M:close() + if self.closed or self.game.stack:top()~=self then return false end + self.closed=true + self.game.stack:pop() + if self.onDone then self.onDone()end + return true +end +function M:update(_dt) + if self.closed then return end + if self.hold>0 then self.hold=self.hold-1;return end + local input=self.game.input + if input and (input:wasPressed("a") or input:wasPressed("b"))then self:close();return end + self.frames=self.frames+1 + local lines=self.program and self.program.lines or {} + if self.frames%100==0 and #lines>0 then self.line=self.line+2;if self.line>#lines then self.line=1 end end +end +function M:draw() + self.gear:textbox(0,12,18,4) + local lines=self.program and self.program.lines or {} + if self.hold>0 or #lines==0 then self.gear:text('"'..(self:name() or '')..'"',1,14,Radio.isLiteral(self.game.data,self.station)) + else self.gear:printBoxText(lines[self.line] or "",lines[self.line+1] or "")end +end +M.channelRecord=Radio.channelRecord +M.STATIONS=Radio.STATIONS +function M.registerInto(registry,data,owner) + local n=0 + for id,row in pairs(Radio.defaults())do registry:register(id,row,owner);n=n+1 end + return n +end +return M diff --git a/src/ui/Gen3BagMenu.lua b/src/ui/Gen3BagMenu.lua index 76565106d..9a7cfe68e 100644 --- a/src/ui/Gen3BagMenu.lua +++ b/src/ui/Gen3BagMenu.lua @@ -54,6 +54,7 @@ -- the item's own cartridge index. local Assets = require("src.render.Assets") +local UiQuad = require("src.render.UiQuad") local Bag = require("src.inventory.Bag") local Font = require("src.render.Font") local Logger = require("src.core.Logger") @@ -1000,7 +1001,7 @@ function Gen3BagMenu:bagFrame() local fh = math.floor(tonumber(record.frameHeight) or 64) local index = math.min(frames, self.pocket) - 1 local iw, ih = image:getDimensions() - return image, love.graphics.newQuad(index * fw, 0, fw, fh, iw, ih), fw, fh + return image, UiQuad.get(self,"pocket",image,index * fw,0,fw,fh,iw,ih), fw, fh end -- The screen's own background, in the palette this save's player carries. @@ -1038,7 +1039,7 @@ function Gen3BagMenu:itemIcon(id, forcedIndex) local size = math.max(1, math.floor(tonumber(rec.size) or 24)) local cols = math.max(1, math.floor(tonumber(rec.cols) or 1)) local iw, ih = img:getDimensions() - return img, love.graphics.newQuad((index % cols) * size, + return img, UiQuad.get(self,"item",img,(index % cols) * size, math.floor(index / cols) * size, size, size, iw, ih), size end diff --git a/src/ui/Gen3BoxMenu.lua b/src/ui/Gen3BoxMenu.lua index d17cf2180..d0c7d83a3 100644 --- a/src/ui/Gen3BoxMenu.lua +++ b/src/ui/Gen3BoxMenu.lua @@ -47,6 +47,8 @@ local Strings = require("src.core.Strings") local TextBox = require("src.render.TextBox") local Theme = require("src.ui.Theme") +local UiQuad = require("src.render.UiQuad") +local Performance = require("src.core.Performance") local Gen3BoxMenu = {} Gen3BoxMenu.__index = Gen3BoxMenu Gen3BoxMenu.isOpaque = true @@ -685,12 +687,16 @@ function Gen3BoxMenu:drawIcon(mon, cx, cy) and (math.floor(((self.t or 0) % (ICON_PERIOD * frames)) / ICON_PERIOD) % frames) or 0 self.quads = self.quads or {} + local quad + if Performance.lowTierBudget then quad=UiQuad.get(self,"box-icon",img,0,frame*frameH,iw,frameH,iw,ih) + else local key = ("%d:%d:%d"):format(iw, frameH, frame) - local quad = self.quads[key] + quad = self.quads[key] if not quad then quad = love.graphics.newQuad(0, frame * frameH, iw, frameH, iw, ih) self.quads[key] = quad end + end love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(img, quad, math.floor(cx - iw / 2), math.floor(cy - frameH / 2)) @@ -805,6 +811,9 @@ function Gen3BoxMenu:frlgImage(key) end function Gen3BoxMenu:frlgQuad(img, x, y, w, h) + if Performance.lowTierBudget then + return UiQuad.get(self,"frlg",img,x,y,w,h,img:getDimensions()) + end self._quads = self._quads or {} local key = ("%s:%d:%d:%d:%d"):format(tostring(img), x, y, w, h) local q = self._quads[key] @@ -1223,7 +1232,7 @@ function Gen3BoxMenu:drawParty() local panel = self:partyPanel() if panel then local pw, ph = panel:getDimensions() - local quad = love.graphics.newQuad(0, 0, math.min(pw, PARTY.w), + local quad = UiQuad.get(self,"party-panel",panel,0,0,math.min(pw, PARTY.w), math.min(ph, PARTY.h), pw, ph) love.graphics.draw(panel, quad, PARTY.x, PARTY.y) else diff --git a/src/ui/Gen3Options.lua b/src/ui/Gen3Options.lua index 6546c8f01..bbbdfb9a0 100644 --- a/src/ui/Gen3Options.lua +++ b/src/ui/Gen3Options.lua @@ -37,6 +37,7 @@ local Theme = require("src.ui.Theme") local Gen3Options = {} Gen3Options.__index = Gen3Options Gen3Options.isOpaque = true +Gen3Options.shaderPresetBypass = true local GBA_W, GBA_H = 240, 160 diff --git a/src/ui/Gen3PartyMenu.lua b/src/ui/Gen3PartyMenu.lua index 4f3f78b39..710527d08 100644 --- a/src/ui/Gen3PartyMenu.lua +++ b/src/ui/Gen3PartyMenu.lua @@ -48,8 +48,10 @@ -- bounce below is this port's, since the cartridge's is a sprite-callback -- rather than data. +local PartyNavigation = require("src.ui.PartyNavigation") local Assets = require("src.render.Assets") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Screens = require("src.ui.Screens") local Strings = require("src.core.Strings") @@ -168,7 +170,9 @@ function Gen3PartyMenu.new(game, opts) return self end -function Gen3PartyMenu:party() return self.game.save.party or {} end +function Gen3PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- slots 1..6, then the CANCEL button as slot #party+1 function Gen3PartyMenu:slots() return #self:party() + 1 end @@ -1015,6 +1019,42 @@ 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:gridNavigation() + return PartyNavigation.enabled(self) +end + function Gen3PartyMenu:update(dt) -- RECONSTRUCTED: every icon bounces, all the time, on the same beat. On -- the cartridge each one is an OBJ with its own callback; here it is one @@ -1059,6 +1099,15 @@ function Gen3PartyMenu:update(dt) return end + local grid = not (self.switchFrom or self.hpTransferFrom) + and self:gridNavigation() and PartyNavigation.spatial(self, input) + if grid then + self.index = grid + -- GBA handles confirm/cancel after navigation, including combined input. + if input:wasPressed("a") then self:confirm() + elseif input:wasPressed("b") then self:cancel() end + return + end local n = self:slots() if input:wasPressed("down") then self.index = self.index % n + 1 elseif input:wasPressed("up") then self.index = (self.index - 2) % n + 1 @@ -1073,20 +1122,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 @@ -1441,7 +1479,9 @@ function Gen3PartyMenu:drawMember(mon, panel, selected, inset) Font.draw(Font.fit(name, rects.name.width + 16), nx, ny) local lx, ly = at("level") - Font.draw(Strings("Lv%d", mon.level or 1), lx, ly) + if LevelDisplay.visible(mon, "party", self.game) then + Font.draw(Strings("Lv%d", mon.level or 1), lx, ly) + end -- Gen 3 derives gender from species ratio + personality when the save does -- not carry an explicit field. Use the engine's common derivation instead of diff --git a/src/ui/Gen3SummaryMenu.lua b/src/ui/Gen3SummaryMenu.lua index 9d54ee41b..8e4678f3f 100644 --- a/src/ui/Gen3SummaryMenu.lua +++ b/src/ui/Gen3SummaryMenu.lua @@ -33,6 +33,7 @@ -- screenshot. local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Stats = require("src.pokemon.Stats") local Strings = require("src.core.Strings") @@ -962,8 +963,10 @@ function Gen3SummaryMenu:drawHeader() if bandWin then local species = (def and def.name) or tostring(mon.species) putIn(bandWin, species, 0, 1, PLAIN) - putIn(bandWin, ("Lv%d"):format(mon.level or 1), PANEL_LEVEL_X, 17, - PLAIN) + if LevelDisplay.visible(mon, "summary", self.game) then + putIn(bandWin, ("Lv%d"):format(mon.level or 1), PANEL_LEVEL_X, 17, + PLAIN) + end local mark = self:genderMark() if mark then putIn(bandWin, mark, PANEL_GENDER_X, 17, @@ -971,7 +974,9 @@ function Gen3SummaryMenu:drawHeader() end else local _, levelY = self:panelRows() - put(("Lv%d"):format(mon.level or 1), PANEL_TEXT_X + 24, levelY, PLAIN) + if LevelDisplay.visible(mon, "summary", self.game) then + put(("Lv%d"):format(mon.level or 1), PANEL_TEXT_X + 24, levelY, PLAIN) + end end end put(title, COL_RIGHT - Font.width(title), 4, PLAIN) @@ -989,7 +994,9 @@ function Gen3SummaryMenu:drawHeader() return end put(name, 40, 10, PLAIN) - put(("Lv%d"):format(mon.level or 1), 40, 24, PLAIN) + if LevelDisplay.visible(mon, "summary", self.game) then + put(("Lv%d"):format(mon.level or 1), 40, 24, PLAIN) + end -- the page name sits on the right of the header, where the cartridge's -- page indicator is put(title, GBA_W - 8 - Font.width(title), 10, PLAIN) @@ -1780,7 +1787,7 @@ function Gen3SummaryMenu:drawFireRed(r) frlgText(self.pages[self.page], 4, 1, header) if not mon then return end local t = r.text or {} - if not detail then + if not detail and LevelDisplay.visible(mon, "summary", self.game) then frlgText("Lv" .. tostring(mon.level or 1), 4, 18, header, true) end frlgText(mon.nickname or def.name or tostring(mon.species), 40, 18, header) diff --git a/src/ui/Gen4Options.lua b/src/ui/Gen4Options.lua index 5f7fe8180..53f1895be 100644 --- a/src/ui/Gen4Options.lua +++ b/src/ui/Gen4Options.lua @@ -50,6 +50,7 @@ local Theme = require("src.ui.Theme") local Gen4Options = {} Gen4Options.__index = Gen4Options Gen4Options.isOpaque = true +Gen4Options.shaderPresetBypass = true local W, H = 256, 192 diff --git a/src/ui/Gen4PartyMenu.lua b/src/ui/Gen4PartyMenu.lua index 670908c8c..36e38bcd6 100644 --- a/src/ui/Gen4PartyMenu.lua +++ b/src/ui/Gen4PartyMenu.lua @@ -261,7 +261,9 @@ function Gen4PartyMenu:drawAt(key, x, y) return true end -function Gen4PartyMenu:party() return self.game.save.party or {} end +function Gen4PartyMenu:party() + return (self.battle and self.battle.playerParty) or self.game.save.party or {} +end -- Slots 1..#party, then CANCEL. function Gen4PartyMenu:count() return #self:party() + (self.select and 2 or 1) end @@ -886,6 +888,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 +942,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/LevelDisplay.lua b/src/ui/LevelDisplay.lua new file mode 100644 index 000000000..0092e81c8 --- /dev/null +++ b/src/ui/LevelDisplay.lua @@ -0,0 +1,16 @@ +-- Shared level-display predicate for native GB/GBA screens. Only literal +-- false hides the complete level label; callers retain status/egg eligibility. +local Runtime = require("src.mods.Runtime") +local LevelDisplay = {} +local function visibleByDefault() + return true +end + +-- where: "battle.enemy" | "battle.player" | "party" | "summary" +function LevelDisplay.visible(mon, where, game) + if not Runtime.wantsHook("pokemon.level_visible") then + return true + end + return Runtime.call("pokemon.level_visible", visibleByDefault, mon, { where = where, game = game }) ~= false +end +return LevelDisplay diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index e93098790..cf42a71f2 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -172,6 +172,25 @@ local function navPressed(self, dir) return true end +function ListMenu:choose() + if #self.items == 0 then + return self:cancel() + end + beep(self) + local item = self.items[self.index] + if self.onChoose then + self.onChoose(item, self) + end +end + +function ListMenu:cancel() + beep(self) + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function ListMenu:update(dt) if self.script then self.script(self) @@ -211,16 +230,10 @@ function ListMenu:update(dt) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then - beep(self) - self.game.stack:pop() - if self.onCancel then self.onCancel() end + self:cancel() return elseif input:wasPressed("a") then - beep(self) - local item = self.items[self.index] - if self.onChoose then - self.onChoose(item, self) - end + self:choose() return end diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 2415d798b..24b36069d 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -120,6 +120,35 @@ function Menu:clampScroll() end end +function Menu:choose() + -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + local item = self.items[self.index] + -- keepOpen entries run without closing the menu (e.g. the + -- Pokédex CRY option keeps the side menu up) + if not item.keepOpen then + self.game.stack:pop() + end + if item.onSelect then + item.onSelect() + end +end + +function Menu:cancel() + if not self.cancelable then + return + end + if not self.noSound then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + self.game.stack:pop() + if self.onCancel then + self.onCancel() + end +end + function Menu:update(dt) local input = self.game.input local wasIndex = self.index @@ -128,15 +157,7 @@ function Menu:update(dt) elseif input:wasPressed("down") then self.index = self.index < #self.items and self.index + 1 or 1 elseif input:wasPressed("a") then - -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press - if not self.noSound then - require("src.core.Sound").play(self.game.data, "Press_AB") - end - local item = self.items[self.index] - -- keepOpen entries run without closing the menu (e.g. the - -- Pokédex CRY option keeps the side menu up) - if not item.keepOpen then self.game.stack:pop() end - if item.onSelect then item.onSelect() end + self:choose() elseif self.cancelable and (input:wasPressed("b") or (self.startCloses and input:wasPressed("start"))) then -- HandleMenuInput_ returns for any watched key, but only replays diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index d6d1f500d..d0c600420 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -14,6 +14,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local Tilt = require("src.render.Tilt") local GBCFX = require("src.render.GBCFX") +local GbcColorCorrection = require("src.render.GbcColorCorrection") local Zoom = require("src.render.Zoom") local TileRenderer = require("src.render.TileRenderer") local GameSpeed = require("src.core.GameSpeed") @@ -31,6 +32,7 @@ local Strings = require("src.core.Strings") local OptionsMenu = {} OptionsMenu.__index = OptionsMenu OptionsMenu.isOpaque = true +OptionsMenu.shaderPresetBypass = true -- Opaque full-screen menu: own MEWMON so opening OPTION from the title -- (or over the overworld) does not inherit TitleState's LOGO1 band -- that @@ -380,6 +382,17 @@ local function buildRows(game) PaletteFX.setMode(o.colors) return true end }, + { id = "gbcColorCorrection", label = Strings("GBC COLOR"), + value = function(g) + return Strings(GbcColorCorrection.LABELS[ + GbcColorCorrection.normalize(g.save.options.gbcColorCorrection)]) + end, + step = function(g, dir) + local o = g.save.options + o.gbcColorCorrection = GbcColorCorrection.cycle(o.gbcColorCorrection, dir) + GbcColorCorrection.applyOptions(o) + return true + end }, { id = "tilt", label = Strings("TILT"), value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end, step = function(g, dir) @@ -407,6 +420,14 @@ local function buildRows(game) GBCFX.setLevel(o.gbcfx) return true end }, + { id = "shaderPreset", label = Strings("SHADER PRESET"), + value = function(g) + local presets = require("src.render.ShaderPresets") + return presets.entry and presets.entry.name:sub(1, 12) or "OFF" + end, + activate = function(g) + g.stack:push(require("src.ui.ShaderPresetMenu").new(g)) + end }, { id = "zoom", label = Strings("ZOOM"), value = function(g) return Zoom.offsetLabel(g.save.options.zoom or 0) @@ -522,6 +543,14 @@ local function buildRows(game) return true end }, } + -- GBC response curves do not describe the other generations' displays. + if not GameVersion.isGen2() then + local filtered = {} + for _, row in ipairs(rows) do + if row.id ~= "gbcColorCorrection" then filtered[#filtered + 1] = row end + end + rows = filtered + end -- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks if not GBCFX.isSupported() then local filtered = {} diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index e59d7173b..268b2156a 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -9,9 +9,11 @@ -- opts.onCancel: fired when the menu closes without a pick (B) -- Pops itself on B. +local PartyNavigation = require("src.ui.PartyNavigation") local Assets = require("src.render.Assets") local Badges = require("src.inventory.Badges") local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") @@ -438,7 +440,7 @@ function PartyMenu.new(game, opts) self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch self.battle = opts.battle - self.party = opts.party -- link battles pass their clamped copies + self.party = opts.party or (opts.battle and opts.battle.playerParty) -- link copies or trainer selection self.swapFrom = nil self.submenu = nil self.subIndex = 1 @@ -472,6 +474,403 @@ 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:gridNavigation() + return PartyNavigation.enabled(self) +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,398 +904,25 @@ 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 - if input:wasPressed("up") then + local grid = not (self.swapFrom or self.softboiledFrom) + and self:gridNavigation() and PartyNavigation.rows(self.index, #party, input) + if grid then + self.index = grid + elseif input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or math.max(1, #party) 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 @@ -965,7 +991,7 @@ function PartyMenu:draw() -- level at column 13 ( tile + digits, PrintLevel) AND the -- status/FNT text at column 17 (PrintStatusCondition), like the -- original rows -- statused mons keep their level display - if isEgg then -- no level row + if isEgg or not LevelDisplay.visible(mon, "party", self.game) then -- no level row elseif mon.level < 100 then HudTiles.tile(0x6E, 104, y) -- Font.draw(tostring(mon.level), 112, y) diff --git a/src/ui/PartyNavigation.lua b/src/ui/PartyNavigation.lua new file mode 100644 index 000000000..50faa39d1 --- /dev/null +++ b/src/ui/PartyNavigation.lua @@ -0,0 +1,86 @@ +-- The party handle remains native; only the navigation decision is shared. +local Runtime = require("src.mods.Runtime") +local M = {} +function M.enabled(menu) + if not menu.battle or not Runtime.wantsHook("ui.party.grid_navigation") then + return false + end + return Runtime.call("ui.party.grid_navigation", function() + return false + end, menu) == true +end +function M.rows(index, count, input) + if count < 1 then + return nil + end + local row, col = math.floor((index - 1) / 2), (index - 1) % 2 + if input:wasPressed("left") or input:wasPressed("right") then + local other = row * 2 + (1 - col) + 1 + return other <= count and other or index + end + local step = input:wasPressed("up") and -1 or input:wasPressed("down") and 1 + if not step then + return nil + end + local rows = math.ceil(count / 2) + for offset = 1, rows do + local other = ((row + step * offset) % rows) * 2 + col + 1 + if other <= count then + return other + end + end + return index +end +local function position(menu, i) + local panel = menu:panelFor(i) + if panel.icon then + return panel.icon.x, panel.icon.y + end + -- The fallback art draws a 32-pixel icon at the left of its panel. + return panel.x + 20, panel.y + panel.height / 2 +end +function M.spatial(menu, input) + local dir = input:wasPressed("up") and "up" + or input:wasPressed("down") and "down" + or input:wasPressed("left") and "left" + or input:wasPressed("right") and "right" + if not dir then + return nil + end + local count, cur = #menu:party(), menu.index + local cancel, target = count + 1, cur + if cur == cancel then + if count > 0 then + if dir == "up" then + target = count + elseif dir == "down" then + target = 1 + end + end + elseif cur >= 1 and cur <= count then + local x, y = position(menu, cur) + local best + for i = 1, count do + if i ~= cur then + local tx, ty = position(menu, i) + local dx, dy = tx - x, ty - y + local distance + if + (dir == "left" and dx < 0) + or (dir == "right" and dx > 0) + or (dx == 0 and ((dir == "up" and dy < 0) or (dir == "down" and dy > 0))) + then + distance = math.abs(dy) + end + if distance and (not best or distance < best) then + target, best = i, distance + end + end + end + if not best and (dir == "up" or dir == "down") then + target = cancel + end + end + return target +end +return M diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 8203e0800..704b885a1 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -169,19 +169,31 @@ PlayerPC.withdraw = withdraw PlayerPC.deposit = deposit PlayerPC.toss = toss -function PlayerPC.new(game) +function PlayerPC.new(game,opts) + opts=opts or{} + local changed,done=false,false + local function finish() + if done then return end;done=true + if opts.onDone then opts.onDone(changed)end + end game.save.pcItems = game.save.pcItems or {} - return Menu.new(game, { + local items={ -- keepOpen so B in the item lists returns here instead of dropping the -- whole PC session (players_pc.asm re-shows the PC menu); same pattern -- as BoxMenu's rows { label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end }, { label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end }, { label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end }, - { label = Strings("LOG OFF") }, + { label = Strings("LOG OFF"),onSelect=finish }, -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 - }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) + } + if opts.house and require("src.world.Gen2Decorations").ready(game.data)then + table.insert(items,#items,{label=Strings("DECORATION"),keepOpen=true,onSelect=function() + require("src.ui.Screens").push(game,"Gen2DecorationMenu",{onDone=function(value)changed=value or changed end}) + end}) + end + return Menu.new(game,items,{tx=0,ty=0,tw=16,maxVisible=8,noSound=true,onCancel=finish}) end return PlayerPC diff --git a/src/ui/PokedexUnownMode.lua b/src/ui/PokedexUnownMode.lua index d5907e743..0906e2f59 100644 --- a/src/ui/PokedexUnownMode.lua +++ b/src/ui/PokedexUnownMode.lua @@ -54,6 +54,8 @@ end -- only have the unownForms set, so fall back to alphabetical for those -- rather than showing an empty page. local function caughtLetters(save) + local Unown = require("src.pokemon.Gen2Unown") + if Unown.supported() then return Unown.letters(save) end local dex = save and save.pokedex if not dex then return {} end local ordered = dex.unownDex diff --git a/src/ui/PokegearMenu.lua b/src/ui/PokegearMenu.lua index f0da924a2..34b9076ac 100644 --- a/src/ui/PokegearMenu.lua +++ b/src/ui/PokegearMenu.lua @@ -5,6 +5,7 @@ local Theme = require("src.ui.Theme") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local Runtime = require("src.mods.Runtime") local PokegearMenu = {} PokegearMenu.__index = PokegearMenu @@ -125,7 +126,6 @@ local function visibleCards(save) return list end -local KANTO_LANDMARK_FIRST = 0x2F local images = {} local function loadImage(path) @@ -318,9 +318,10 @@ function PokegearMenu:drawModeArrow() love.graphics.setColor(1, 1, 1, 1) end -function PokegearMenu:text(str, tx, ty) +function PokegearMenu:text(str, tx, ty, literal) love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings(tostring(str or "")), tx * 8, ty * 8) + local text=tostring(str or "") + Font.draw(literal and text or Strings(text), tx * 8, ty * 8) love.graphics.setColor(1, 1, 1, 1) end @@ -370,6 +371,18 @@ function PokegearMenu:current() return self.cards[self.index] end +-- Source renderers observe the selected native card. Keep one cursor: +-- falling back to a separate cardIndex leaves them drawing the clock while +-- native input and callbacks are already operating the radio. +function PokegearMenu:card() + return self:current() +end + +function PokegearMenu:cardLabel(card) + card = card or self:current() + return card and Strings(card.label or card.id) or "" +end + -- The registered phone numbers, newest id last. -- -- Gen2 keeps two halves of this and the card used to read neither: which ids @@ -389,15 +402,51 @@ end -- `db PHONE_MOM, PHONE_ELM, -1`): those two can never be deleted. local GEN2_PERMANENT_NUMBERS = { [1] = true, [4] = true } -function PokegearMenu.canDeleteContact(entry) +function PokegearMenu.canDeleteContact(entry, data) local id = tonumber(entry and entry.id) - return id ~= nil and not GEN2_PERMANENT_NUMBERS[id] + if not id or id < 1 then return false end + if entry.permanent ~= nil then return not entry.permanent end + if data then return require("src.world.Gen2PhoneBook").canDelete(data, id) end + return not GEN2_PERMANENT_NUMBERS[id] end function PokegearMenu:contacts() local data = self.game.data local save = self.game.save + if require("src.core.GameVersion").isGen2() then + local Book = require("src.world.Gen2PhoneBook") + local Calls = require("src.script.Gen2Commands") + local slots = Book.contacts(save) + if Runtime.wantsHook("phone.contact_list") then + -- Save is an opaque native handle; the display list is detached. The + -- incoming selector continues to use the unhooked physical book. + local shown = Runtime.call("phone.contact_list", function(_, list) return list end, + save, Book.contacts(save)) + if type(shown) == "table" and #shown == Book.SIZE then + slots = {} + for i = 1, Book.SIZE do + local value = shown[i] + local id = (type(value) == "number" or type(value) == "string") and tonumber(value) + slots[i] = id and id % 1 == 0 and id >= 1 and id <= 255 + and Calls.phoneContact(data, id) and id or 0 + end + end + end + local physical = Book.contacts(save) + local permanent, out = Book.permanent(data), {} + for displaySlot, id in ipairs(slots) do + local name = Calls.phoneName(data,id) + local slot = id == 0 and displaySlot or nil + if id ~= 0 then + for i, nativeId in ipairs(physical) do if nativeId == id then slot = i; break end end + end + out[displaySlot] = { id=name and id or 0, slot=slot, name=name or "----------", + permanent=permanent[id] == true } + end + return out + end + local list = {} local registered = save and save.g2Phone if type(registered) == "table" then @@ -427,6 +476,9 @@ function PokegearMenu:contacts() end function PokegearMenu:stationList() + if require("src.core.GameVersion").isGen2() then + return require("src.world.Gen2Radio").gearList(self.game.data,self.game.save,STATIONS,hasExpn(self.game.save)) + end local list = {} for _, st in ipairs(STATIONS) do list[#list + 1] = st end if hasExpn(self.game.save) then @@ -440,6 +492,43 @@ function PokegearMenu:stationList() return list end +-- Read-only presentation of the existing native tuner, not a second tuner +-- or a reproduction of the cartridge's complete radio show state machine. +function PokegearMenu:stations() + if not require("src.core.GameVersion").isGen2() then return {} end + local rows = self:stationList() + for _, row in ipairs(rows) do + row.station = row.id + row.frequency = ("%04.1f"):format(((row.knob or 0) + 2) / 4) + end + return rows +end + +function PokegearMenu:currentStation() + return self:stations()[self.station] +end + +function PokegearMenu:syncRadioView(station) + if not require("src.core.GameVersion").isGen2() or not station then + self.radio = nil + return + end + local view = self.radio + if not view or view.station ~= station.id then view = {}; self.radio = view end + local lines, line = station.lines or {}, self.radioLine or 1 + view.station, view.top, view.bottom = station.id, lines[line] or "", lines[line + 1] or "" +end + +function PokegearMenu:exitRadioMusic() + local playing = self._radioMusic + self._radioMusic = nil + require("src.world.Gen2RadioAudio").finish(self.game, playing) +end + +function PokegearMenu:exit() + self:exitRadioMusic() +end + function PokegearMenu:update(dt) self.blink = (self.blink + dt) % 1 -- A call in progress owns the card: PokegearPhone_MakePhoneCall does not @@ -457,6 +546,7 @@ function PokegearMenu:update(dt) end local input = self.game.input if input:wasPressed("b") then + self.radio = nil Sound.play(self.game.data, "Press_AB") self.game.stack:pop() if self.onCancel then self.onCancel() end @@ -488,9 +578,14 @@ function PokegearMenu:update(dt) return end end + if card.id ~= "RADIO" then + self:exitRadioMusic() + self._radioTuned=nil;self.radio=nil + end if card.id == "RADIO" then local stations = self:stationList() local n = #stations + self.station = math.max(1, math.min(self.station, n)) if input:wasPressed("up") then -- pret: up winds knob toward higher frequencies (clamp, no wrap) if self.station < n then @@ -515,10 +610,17 @@ function PokegearMenu:update(dt) end end local st = stations[self.station] - if st and st.flute then - self.game.save.g2RadioChannel = "POKE_FLUTE" - self.game.save.g2MapMusic = "MUSIC_POKE_FLUTE_CHANNEL" + if require("src.core.GameVersion").isGen2() and st and self._radioTuned~=st.id then + self._radioTuned=st.id + self.radioLine=1 + self._radioMusic=require("src.world.Gen2RadioAudio").tune(self.game,st.id) + require("src.world.Gen2Radio").notify(self.game.data,st.id,st.knob,"pokegear") + end + if not st and self._radioTuned then + self._radioTuned=nil + self._radioMusic=require("src.world.Gen2RadioAudio").tune(self.game,nil) end + self:syncRadioView(st) elseif card.id == "PHONE" then local list = self:contacts() if self.phoneSubmenu then @@ -528,7 +630,7 @@ function PokegearMenu:update(dt) -- stay filled -- and the ROM's DELETE refuses only for the two -- PermanentNumbers, MOM and PROF.ELM. local entry = list[self.contact] - local canDelete = entry and PokegearMenu.canDeleteContact(entry) + local canDelete = entry and PokegearMenu.canDeleteContact(entry, self.game.data) if canDelete and (input:wasPressed("down") or input:wasPressed("up")) then self.phoneAction = self.phoneAction == "delete" and "call" or "delete" Sound.play(self.game.data, "Tink") @@ -539,9 +641,15 @@ function PokegearMenu:update(dt) self.phoneAction = nil self.callText = nil if entry and deleting then - require("src.script.Gen2Commands").phoneList(self.game.save)[entry.id] = nil + if require("src.core.GameVersion").isGen2() then + local Book = require("src.world.Gen2PhoneBook") + if entry.slot then Book.deleteAt(self.game.save, entry.slot) + else Book.remove(self.game.save, entry.id) end + else + require("src.script.Gen2Commands").phoneList(self.game.save)[entry.id] = nil + end if self.contact > 1 then self.contact = self.contact - 1 end - elseif entry then + elseif entry and entry.id ~= 0 then self:startPhoneCall(entry) end elseif input:wasPressed("b") then @@ -558,7 +666,8 @@ function PokegearMenu:update(dt) self.contact = self.contact > 1 and self.contact - 1 or #list Sound.play(self.game.data, "Tink") elseif input:wasPressed("a") then - if #list > 0 then + local entry = list[self.contact] + if entry and entry.id ~= 0 then self.phoneSubmenu = true self.phoneAction = "call" Sound.play(self.game.data, "Press_AB") @@ -690,10 +799,10 @@ function PokegearMenu:drawClock() self:text("SWITCH", 13, 1) local save = self.game.save - local t = math.floor(require("src.core.SaveData").playSeconds(save)) - local hour = math.floor(t / 3600) % 24 - local minute = math.floor(t / 60) % 60 - local day = DAYS[(math.floor(t / 86400) % 7) + 1] + local Clock = require("src.core.Gen2Clock") + local minutes = Clock.minutes(save) + local hour, minute = math.floor(minutes / 60), minutes % 60 + local day = Clock.weekdayName(Clock.weekday(save) + 1) local display = hour % 12 if display == 0 then display = 12 end self:text(day, 6, 6) @@ -724,7 +833,7 @@ function PokegearMenu:drawRadio() -- Station name at (2,9) — UpdateRadioStation if st and st.name then - self:text(st.name, 2, 9) + self:text(st.name, 2, 9, st.nameLiteral) end -- Tuning knob marker (SPRITE_ANIM_OBJ_RADIO_TUNING_KNOB) @@ -767,22 +876,22 @@ function PokegearMenu:drawPhone() self:drawStrip() local list = self:contacts() - if type(list) ~= "table" or #list == 0 then + if (type(list) ~= "table" or #list == 0) and not require("src.core.GameVersion").isGen2() then list = { { name = "MOM" }, { name = "PROF.ELM" } } end if self.contact < 1 then self.contact = 1 end if self.contact > #list then self.contact = #list end local selectedName = nil - -- One row per contact, starting at tile row 4 (inside the cream plate) + -- Keep the selected native slot visible, including slots nine and ten. + local top = math.max(0, self.contact - 8) for i, entry in ipairs(list) do local name = tostring( (type(entry) == "table" and (entry.name or entry.label)) or entry or "?" ):upper() - local ty = 3 + i -- MOM at row 4, next at 5, ... + local ty = 3 + i - top if ty > 11 then break end - - if i == self.contact then + if ty >= 4 and i == self.contact then selectedName = name -- THE CURSOR IS A GLYPH, NOT THE LETTER ">". -- @@ -806,7 +915,7 @@ function PokegearMenu:drawPhone() Font.draw(Strings(name), 18, ty * 8) if Font.endTint then Font.endTint() end love.graphics.setColor(1, 1, 1, 1) - else + elseif ty >= 4 then self:text(name, 3, ty) end end @@ -837,7 +946,7 @@ function PokegearMenu:drawMap() local landmark = def and def.landmark local entry = landmark and town.landmarks and (town.landmarks[landmark] or town.landmarks[tostring(landmark)]) - local path = (landmark and landmark >= KANTO_LANDMARK_FIRST and town.kanto) + local path = (landmark and landmark >= require("src.world.Gen2Landmarks").kantoStart(game.data) and town.kanto) or town.johto local image = path and loadImage(path) if image then @@ -924,4 +1033,16 @@ function PokegearMenu:draw() love.graphics.setColor(1, 1, 1, 1) end +-- Native presentation keeps its original canvas. A replacement of this +-- optional drawer opts a skin into window-resolution composition. +local classicDraw = PokegearMenu.draw +local function drawWidescreen(self,width,height) + return require('src.render.ScreenSpaceUI').classic(function()classicDraw(self)end, + require('src.render.Renderer'),width,height) +end +PokegearMenu.drawWidescreen = drawWidescreen +function PokegearMenu:drawsWidescreen() + return self.drawWidescreen ~= drawWidescreen +end + return PokegearMenu diff --git a/src/ui/ShaderPresetMenu.lua b/src/ui/ShaderPresetMenu.lua new file mode 100644 index 000000000..e85f7cfc4 --- /dev/null +++ b/src/ui/ShaderPresetMenu.lua @@ -0,0 +1,139 @@ +-- Shared small options UI, also reachable through the GBA/DS extra rows. +local Presets = require("src.render.ShaderPresets") +local OptionRows = require("src.ui.OptionRows") +local Menu = {} +Menu.__index = Menu +Menu.isOpaque = true +Menu.shaderPresetBypass = true +local function short(s, n) + s = tostring(s or "") + return #s > n and s:sub(1, n - 1) .. "~" or s +end +local function save(game) + if game.writeOptions then + game:writeOptions() + end +end +function Menu:sgbPalettes(game) + return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") +end +function Menu.parameters(game, presets) + presets = presets or Presets + local self = setmetatable( + { game = game, presets = presets, index = 1, scroll = 0, rows = {}, isParameters = true }, + Menu + ) + local entryId = presets.entry and presets.entry.id + local function current() + return presets.entry and presets.entry.id == entryId and presets.values + end + for _, parameter in ipairs(presets.entry and presets.entry.parameters or {}) do + local p = parameter + self.rows[#self.rows + 1] = { + label = short(p.name, 17), + value = function() + local values = current() + return values and ("%.3g"):format(values[p.id]) or "OFF" + end, + reset = function() + if not current() then + return + end + presets:setParameter(game.save.options, p.id, p.default) + save(game) + end, + step = function(_, dir) + local values = current() + if not values then + return + end + presets:setParameter(game.save.options, p.id, values[p.id] + dir * p.step) + save(game) + end, + } + end + return self +end +function Menu.new(game, presets) + presets = presets or Presets + local self = setmetatable({ game = game, presets = presets, index = 1, scroll = 0, rows = {} }, Menu) + self.rows[1] = { + label = "OFF", + activate = function() + presets:select(game.save.options, "off") + save(game) + end, + } + for _, record in ipairs(presets:refresh()) do + local entry = record + self.rows[#self.rows + 1] = { + presetId = entry.id, + label = short(entry.name, 17), + value = function() + return entry.error and "INVALID" + or (presets.entry and presets.entry.id == entry.id and "ACTIVE" or "SELECT") + end, + activate = function() + local ok = presets:select(game.save.options, entry.id) + if ok then + save(game) + else + game.stack:push(Menu.message(game, presets.error)) + end + end, + } + end + return self +end +function Menu.message(game, reason) + local self = setmetatable({ game = game, index = 1, scroll = 0, rows = {}, isMessage = true }, Menu) + reason = tostring(reason):gsub("[^ -~]", " ") + for i = 1, math.min(136, #reason), 34 do + local text = reason:sub(i, i + 33) + self.rows[#self.rows + 1] = { + label = text:sub(1, 17), + value = function() + return text:sub(18, 34) + end, + } + end + return self +end +function Menu:update() + local input = self.game.input + local bottom = #self.rows + 1 + if input:wasPressed("b") or input:wasPressed("start") then + self.game.stack:pop() + return + end + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or bottom + elseif input:wasPressed("down") then + self.index = self.index < bottom and self.index + 1 or 1 + elseif input:wasPressed("select") then + local row = self.rows[self.index] + if self.isParameters and row and row.reset then + row.reset() + elseif not self.isMessage and self.presets.entry and row and row.presetId == self.presets.entry.id then + self.game.stack:push(Menu.parameters(self.game, self.presets)) + end + elseif input:wasPressed("a") or input:wasPressed("left") or input:wasPressed("right") then + local row = self.rows[self.index] + if not row then + if input:wasPressed("a") then + self.game.stack:pop() + end + elseif row.step then + row.step(self.game, input:wasPressed("left") and -1 or 1) + elseif row.activate and input:wasPressed("a") then + row.activate(self.game) + end + end + self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows, bottom) +end +function Menu:draw() + local footer = self.isMessage and "BACK" + or (self.isParameters and "BACK / SEL RESET" or "BACK / SEL PARAMS") + OptionRows.draw(self.game, self.rows, self.index, self.scroll, footer, #self.rows + 1) +end +return Menu diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 090401b37..69941f5f6 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -13,6 +13,7 @@ -- go, and one of four lines about how close it is to hatching. local Font = require("src.render.Font") +local LevelDisplay = require("src.ui.LevelDisplay") -- status_screen.asm PrintMonType prints the type's DISPLAY name from the -- TypeNames table, not the constant: species types are stored as pokered -- constants (RomExtractor:typesById) and PSYCHIC's is "PSYCHIC_TYPE" (so it @@ -359,7 +360,9 @@ function SummaryMenu:drawGen2Header() HudTiles.statusTile(0x74, 64, 0) -- № Font.drawCode(0xF2, 72, 0) -- Font.draw(("%03d"):format(def.dex or 0), 80, 0) - printLevel(14, 0, mon.level) + if LevelDisplay.visible(mon, "summary", self.game) then + printLevel(14, 0, mon.level) + end local gender = require("src.pokemon.DayCare").gender(data, mon) if gender == "male" or gender == "female" then Font.draw(gender == "male" and "♂" or "♀", 144, 0) @@ -445,8 +448,10 @@ function SummaryMenu:drawGen2PinkPage() local nextExp = (mon.level or 1) < 100 and (Growth.expForLevel(def.growthRate, mon.level + 1) - (mon.exp or 0)) or 0 rightAlign(("%d"):format(math.max(0, nextExp)), 160, 104) - Font.draw(Strings("TO"), 112, 112) - printLevel(17, 14, math.min(100, (mon.level or 1) + 1)) + if LevelDisplay.visible(mon, "summary", self.game) then + Font.draw(Strings("TO"), 112, 112) + printLevel(17, 14, math.min(100, (mon.level or 1) + 1)) + end -- FillInExpBar between the $40/$41 end caps, its RIGHT edge pinned at -- column 18 and the bar growing leftward from there: eight tiles from @@ -583,7 +588,9 @@ function SummaryMenu:draw() -- bracket around the name/HP block, and PrintLevel at (14,2). The -- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea -- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280 - printLevel(14, 2, mon.level) + if LevelDisplay.visible(mon, "summary", self.game) then + printLevel(14, 2, mon.level) + end drawLineBox(19, 1, 6, 10) -- same as the pink page above: the HP palette covers the whole screen, so -- the fill stays grey and the zone pass colours it @@ -641,8 +648,10 @@ function SummaryMenu:draw() local nextExp = mon.level < 100 and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48) - HudTiles.statusTile(0x70, 112, 48) -- '' at (14,6), was missing (#280) - printLevel(16, 6, math.min(100, mon.level + 1)) + if LevelDisplay.visible(mon, "summary", self.game) then + HudTiles.statusTile(0x70, 112, 48) -- '' at (14,6), was missing (#280) + printLevel(16, 6, math.min(100, mon.level + 1)) + end -- Gen2's stats screen carries the same exp bar the battle HUD does -- (stats_screen.asm calls FillInExpBar too). It lands on DrawLineBox's -- bottom run, which is exactly the eight tiles PlaceExpBar writes. diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 9d0289f9b..13fe93b2e 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -264,6 +264,10 @@ function TitleState.new(game, opts) end function TitleState:enter() + if require('src.core.GameVersion').isGen2()then + local Runtime=require('src.mods.Runtime') + if Runtime.wants("intro.boot.title")then Runtime.emit("intro.boot.title",{screen=self,game=self.game})end + end -- Yellow defers the title theme until after the logo drop and -- Pikachu's cry (title.asm plays MUSIC_TITLE_SCREEN only after -- WaitForSoundToFinish on PikachuCry1) diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index 63a5a8155..4727a2725 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -37,18 +37,19 @@ end -- Gen2 town map. field.townMap is a completely different shape from Gen1's: -- two full-screen pictures (johto/kanto) plus `landmarks`, a flat id -> {name, -- x, y} table whose coordinates are absolute screen pixels, and every map --- record carries the landmark id it belongs to. Landmarks from $2E up are --- Kanto: `DEF KANTO_LANDMARK EQU const_value` sits immediately BEFORE --- LANDMARK_PALLET_TOWN in constants/landmark_constants.asm, so Pallet is --- the first of them, not Route 1. A landmark short of it and Pallet read --- as Johto -- flown to off the Johto picture with no cursor drawn on it. -local GEN2_KANTO_FIRST = 0x2E +-- record carries the landmark id it belongs to. Gold/Silver's fallback +-- boundary is $2e; Crystal inserts the Battle Tower and starts Kanto at $2f. +-- Prefer the active cartridge's imported Pallet header below. local function gen2Locations(game) local tm = (game.data.field or {}).townMap if type(tm) ~= "table" or type(tm.landmarks) ~= "table" or not tm.johto then return nil end + -- Crystal inserts the Battle Tower landmark before Silver Cave. The + -- native Pallet header is the first Kanto landmark on the active cartridge; + -- the old Gold-only $2e boundary misclassified Crystal's Silver Cave. + local kantoFirst = require("src.world.Gen2Landmarks").kantoStart(game.data) local byLandmark, locs = {}, {} for id, e in pairs(tm.landmarks) do local x, y = tonumber(e and e.x), tonumber(e and e.y) @@ -62,7 +63,7 @@ local function gen2Locations(game) -- draws around -- taken raw, every nest sat 4px right and 12px -- below the town it belongs to. px = x - 4, py = y - 12, landmark = tonumber(id), - region = tonumber(id) >= GEN2_KANTO_FIRST and "kanto" or "johto", + region = tonumber(id) >= kantoFirst and "kanto" or "johto", } byLandmark[tonumber(id)] = loc locs[#locs + 1] = loc diff --git a/src/ui/UICommands.lua b/src/ui/UICommands.lua new file mode 100644 index 000000000..437932a88 --- /dev/null +++ b/src/ui/UICommands.lua @@ -0,0 +1,346 @@ +-- Admission and dispatch for native party, bag and dialogue screens. +-- No screen is loaded here: an already loaded native class must own the top. +local Commands = {} +local CLASSES = { + PartyMenu = "party_gb", + Gen3PartyMenu = "party_gba", + Gen4PartyMenu = "party_ds", + Gen3BagMenu = "bag_gba", + Gen4BagMenu = "bag_ds", + HgssBagMenu = "bag_hgss", + Gen5BagMenu = "bag_bw", + Gen4BattleBag = "battle_bag", + Gen5BattleBag = "battle_bag", + Gen3ItemMenu = "item_actions", + ChoiceBox = "choice", +} +local function class(screen) + local mt = getmetatable(screen) + if mt == nil then + return + end + if mt == package.loaded["src.render.TextBox"] then + return "text" + end + for name, kind in pairs(CLASSES) do + if mt == package.loaded["src.ui." .. name] then + return kind + end + end + if mt == package.loaded["src.ui.ListMenu"] and screen.publicUIKind == "bag" then + return "bag_gb" + end + if mt == package.loaded["src.ui.Menu"] and screen.publicUIKind == "bag_actions" then + return "bag_actions" + end +end +local function row(out, value, ids, item) + local i = #out.rows + 1 + local r = { index = i, enabled = true } + if type(value) == "table" then + r.label = type(value.label) == "string" and value.label or nil + r.action = type(value.action) == "string" and value.action or nil + if value.header then + r.enabled = false + end + if value.close then + r.action = "back" + end + if item then + r.item = ids:read("items", value.id or value.value) + r.count = type(value.qty) == "number" and value.qty or nil + if not r.label and type(value.def) == "table" and type(value.def.name) == "string" then + r.label = value.def.name + end + end + elseif type(value) == "string" then + r.action, r.label = value, value + end + out.rows[i] = r + return r +end +function Commands.describe(game, screen, ids) + local kind = class(screen) + if not kind then + return nil, "unsupported UI screen" + end + if screen.game ~= game then + return nil, "UI does not belong to current Game" + end + local out = { + kind = "party", + rows = {}, + index = screen.index, + canBack = true, + canAdvance = false, + canPocket = false, + locked = false, + } + local refs = { screen = screen, stack = game.stack, states = game.stack.states, save = game.save } + -- Keep callback and nested option identity private; no engine references escape. + for key, value in pairs(screen) do + if type(value) == "function" or type(value) == "table" then + refs[key] = value + end + end + local function rows(values, items) + for i, value in ipairs(values or {}) do + refs["row:" .. i] = value + if type(value) == "table" then + refs["callback:" .. i] = value.onSelect + end + row(out, value, ids, items) + end + end + if kind:match("^party_") then + local party = kind == "party_gb" and (screen.party or game.save.party) or screen:party() + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + end + if screen.selectNotice then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.submenu then + out.kind = "party_actions" + rows( + kind == "party_gb" and screen.subItems or kind == "party_gba" and screen.submenu or screen:actions() + ) + out.index = kind == "party_ds" and screen.submenu or screen.subIndex + else + for i, mon in ipairs(party or {}) do + refs["mon:" .. i] = mon + local r = row(out, {}, ids) + r.partyIndex, r.species, r.label = i, ids:read("pokemon", mon.species), mon.nickname + r.hp, r.maxHp = mon.hp, mon.stats and mon.stats.hp or mon.maxHp + end + if kind ~= "party_gb" then + if kind == "party_ds" and screen.select then + row(out, "confirm", ids) + end + row(out, "back", ids) + end + end + elseif kind == "text" then + out.kind, out.index, out.canBack = "text", nil, false + out.canAdvance = ( + screen.waiting or (screen.done and not screen.auto and not screen.stay and not screen.choice) + ) + and (screen.preWait or 0) <= 0 + and (screen.holdFrames or 0) <= 0 + or false + out.locked = not out.canAdvance + elseif kind == "choice" then + out.kind = "choice" + rows({ "yes", "no" }) + out.locked = screen.pending ~= nil + elseif kind == "battle_bag" then + out.kind, out.page = "bag", screen.screen + local mem = screen.mem or screen.cur + out.pocket = screen.pocket + out.index = screen.cursor and screen.cursor.index + if screen.message then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.screen == "menu" then + rows({ "pocket:1", "pocket:2", "pocket:3", "pocket:4", "last_item", "back" }) + out.rows[5].enabled = mem.lastItem ~= nil + elseif screen.screen == "pocket" then + for i = 1, 6 do + local item = screen:itemAt(i) + refs["row:" .. i] = item + local r = row(out, item or {}, ids, true) + r.enabled = item ~= nil + end + row(out, "previous_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "next_page", ids).enabled = (screen.pageCount[screen.pocket] or 0) > 0 + row(out, "back", ids) + else + rows({ "use", "back" }) + out.rows[1].enabled = screen:itemAt((mem.positions[screen.pocket] or 0) + 1) ~= nil + end + out.locked = screen.press ~= nil + elseif kind == "bag_actions" or kind == "item_actions" then + out.kind = "bag_actions" + rows(kind == "bag_actions" and screen.items or screen.entries) + if kind == "item_actions" then + for i, r in ipairs(out.rows) do + r.enabled = screen:pickable(i) + r.action = screen.entries[i].kind + end + else + out.canBack = screen.cancelable + end + else + out.kind = "bag" + out.pocket = screen.pocket or screen.pocketIndex + if screen.message or screen.resultMessage then + out.kind, out.canAdvance, out.canBack = "text", true, false + elseif screen.menu then + out.kind, out.index = screen.menu.kind == "yesno" and "choice" or "bag_actions", screen.menu.index + rows(screen.menu.rows) + else + rows(kind == "bag_gb" and screen.items or kind == "bag_hgss" and screen:items() or screen.rows, true) + out.index = kind == "bag_hgss" and screen.page * 6 + screen.slot + or kind == "bag_bw" and screen.cursor + or screen.index + out.canPocket = not screen.lockPocket + and (kind == "bag_gb" and screen.onPocketSwitch ~= nil or kind ~= "bag_gb" and #(screen.pockets or {}) > 1) + or false + end + out.locked = screen.trash ~= nil and not screen.menu + or screen.moving ~= nil and screen.moving ~= false + or false + end + local battle = screen.battle or screen.publicUIBattle + if + battle and (battle.demo or battle.dudeDemo or battle.pokedudeDemo or battle.spectating or battle.dead) + then + out.locked = true + end + if screen.heal or screen.noInput or screen.script or screen.closed then + out.locked = true + end + if out.locked then + out.canAdvance, out.canPocket, out.canBack = false, false, false + end + return out, refs, kind +end +local function press(screen, ds) + local Sound = require("src.core.Sound") + if ds then + if Sound.playSe and screen.game.data then + Sound.playSe(screen.game.data, "SEQ_SE_DP_SELECT") + end + elseif screen.game.data then + Sound.play(screen.game.data, "Press_AB") + end +end +function Commands.dispatch(screen, kind, view, intent) + if intent.kind == "advance" then + if kind == "text" then + screen:advance() + elseif screen.selectNotice then + screen.selectNotice = nil + else + if screen.resultMessage then + press(screen) + end + screen.message, screen.resultMessage = nil, nil + end + elseif kind == "text" then + error("text only supports advance") + elseif kind == "choice" then + screen:choose(intent.kind == "back" and 2 or intent.index) + elseif kind == "battle_bag" then + if intent.kind == "select" then + screen.cursor.index = intent.index + end + screen:pick(intent.kind == "back" and ({ menu = 6, pocket = 9, use = 2 })[screen.screen] or intent.index) + elseif kind == "party_gb" then + press(screen) + if intent.kind == "back" then + screen:cancel() + else + if screen.submenu then + screen.subIndex = intent.index + else + screen.index = intent.index + end + screen:confirm() + end + elseif kind == "party_gba" or kind == "party_ds" then + if intent.kind == "back" then + if screen.submenu then + if screen.itemMenu then + screen.itemMenu, screen.submenu = nil, 1 + else + screen.submenu = nil + end + else + screen:cancel() + end + elseif screen.submenu then + if kind == "party_ds" then + screen.submenu = intent.index + screen:runAction(screen:actions()[intent.index]) + else + screen.subIndex = intent.index + local r = screen.submenu[intent.index] + screen:runAction(r.action, screen:party()[screen.index], r) + end + else + screen.index = intent.index + if kind == "party_gba" then + screen:confirm() + else + screen:choose() + end + end + elseif kind == "bag_actions" then + if intent.kind == "back" then + screen:cancel() + else + screen.index = intent.index + screen:choose() + end + elseif kind == "item_actions" then + press(screen) + if intent.kind == "select" then + screen.index = intent.index + end + screen:close(intent.kind == "back" and "cancel" or screen.entries[intent.index].kind) + elseif screen.menu then + if intent.kind == "back" then + if kind == "bag_ds" then + press(screen, true) + end + if screen.menu.kind == "yesno" then + screen:resolveTrash(false) + else + screen.menu = nil + end + else + screen.menu.index = intent.index + if kind ~= "bag_ds" then + screen:runMenuRow() + else + press(screen, true) + if screen.menu.kind == "yesno" then + screen:resolveTrash(intent.index == 1) + else + screen:runAction(screen.menu.rows[intent.index]) + end + end + end + elseif intent.kind == "pocket" then + if kind == "bag_gb" then + screen.onPocketSwitch(screen, intent.direction) + elseif kind == "bag_hgss" or kind == "bag_bw" then + screen:setPocket((screen.pocket - 1 + intent.direction) % #screen.pockets + 1) + else + screen.pocketLit = nil + screen:movePocket(intent.direction) + end + elseif intent.kind == "back" then + if kind == "bag_gb" then + screen:cancel() + else + if kind ~= "bag_gba" then + press(screen, true) + end + screen:close() + end + else + if kind == "bag_hgss" then + screen.page, screen.slot = math.floor((intent.index - 1) / 6), (intent.index - 1) % 6 + 1 + elseif kind == "bag_bw" then + screen.cursor = intent.index + screen.top = math.max(0, intent.index - 6) + else + screen.index = intent.index + if kind == "bag_ds" then + screen:clampScroll() + end + end + screen:choose() + end +end +return Commands diff --git a/src/world/BugContest.lua b/src/world/BugContest.lua index 34a97b347..9a81db868 100644 --- a/src/world/BugContest.lua +++ b/src/world/BugContest.lua @@ -282,6 +282,11 @@ function BugContest.judge(game) if placing and placing > 3 then placing = nil end s.placing = placing s.board = board + local Runtime=require('src.mods.Runtime') + if Runtime.wants("bug_contest.scored")then + Runtime.emit("bug_contest.scored",{mon=s.caught,score=s.caught and BugContest.score(s.caught)or 0, + place=placing or 0,results=board}) + end return placing, board end diff --git a/src/world/Gen2ApricornPrograms.lua b/src/world/Gen2ApricornPrograms.lua new file mode 100644 index 000000000..b80a71476 --- /dev/null +++ b/src/world/Gen2ApricornPrograms.lua @@ -0,0 +1,188 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +-- Project recipe records into decoded native Kurt programs. Dialogue, +-- working/day flags, quantity and bag-refusal branches remain native IR. +local Merge=require('src.mods.Merge') +local M={} +local copy=Merge.deepCopy +local function number(value) + return type(value)=='number' and value%1==0 and value>=0 and value<=65534 +end +local function item(key) + local n=type(key)=='string' and tonumber(key:match('^ITEM_(%d%d%d)$')) + return n and n>0 and n<255 and n or nil +end +M.item=item +local function selected(row) + return type(row)=='table' and row[1]=='special' and + (row[3]=='SelectApricornForKurt' or row[3]=='Special_SelectApricornForKurt' or row[3]=='select_apricorn') +end +local function eventBranch(scripts,label,gave) + local rows=scripts[label] + if type(rows)~='table' or #rows~=2 then return nil end + if type(rows[1])~='table' or type(rows[2])~='table' then return nil end + if rows[1][1]~='setevent' or not number(rows[1][2]) or + rows[2][1]~='sjump' or rows[2][2]~=gave then return nil end + return rows[1][2] +end +local function chain(rows,start,kind,known,count,ask) + local seen,targets={},{} + for n=0,count-1 do + local a,b=rows[start+n*2],rows[start+n*2+1] + if type(a)~='table' or type(b)~='table' or a[1]~=kind or + not known[a[2]] or seen[a[2]] or b[1]~='iftrue' or + type(b[2])~='string' or ask and b[2]~=ask then return nil end + seen[a[2]]=true;targets[a[2]]=b[2] + end + return targets +end +function M.describe(data) + local pool=data and data.map_scripts + local roles=pool and pool.apricornPrograms + local scripts=pool and pool.scripts + local recipes=pool and pool.apricornBalls + if type(roles)~='table' or type(scripts)~='table' or type(recipes)~='table' or #recipes==0 then + return nil,'apricorns: native Kurt programs require a current ROM import' + end + for _,role in ipairs({'ask','pending','gave','cancel'})do + if type(roles[role])~='string' or type(scripts[roles[role]])~='table' then + return nil,'apricorns: missing native Kurt program '..role + end + end + local ask=scripts[roles.ask];local zero + for i,row in ipairs(ask)do + if selected(row)then + if zero then return nil,'apricorns: ambiguous native selection' end + local nextRow=ask[i+1] + if type(nextRow)=='table' and nextRow[1]=='ifequal' and nextRow[2]==0 and nextRow[3]==roles.cancel then zero=i+1 end + end + end + if not zero then return nil,'apricorns: native selection/cancel signature differs' end + local cases,at={},zero+1 + while type(ask[at])=='table' and ask[at][1]=='ifequal' do + local row=ask[at];local id=row[2] + local event=eventBranch(scripts,row[3],roles.gave) + if type(id)~='number' or id%1~=0 or id<1 or id>254 or cases[id] or not event then + return nil,'apricorns: invalid native selection branch' + end + cases[id]=event;at=at+1 + end + if #ask~=at+1 or type(ask[at])~='table' or type(ask[at+1])~='table' or + ask[at][1]~='setevent' or not number(ask[at][2]) or + ask[at+1][1]~='sjump' or ask[at+1][2]~=roles.gave then + return nil,'apricorns: native default selection signature differs' + end + local base,items,events,default={},{},{},nil + for i,row in ipairs(recipes)do + if type(row)~='table'then return nil,'apricorns: invalid native recipe record' end + local id=item(row.apricorn) + if not id or not item(row.ball) or items[id] then return nil,'apricorns: invalid native recipe table' end + local event=cases[id] + if not event then + if default then return nil,'apricorns: ambiguous native default recipe' end + default=id;event=ask[at][2] + end + if events[event] then return nil,'apricorns: duplicate native recipe event' end + items[id]=true;events[event]=true + base[i]={apricorn=row.apricorn,ball=row.ball,event=event,index=i} + end + if not default then return nil,'apricorns: no native default recipe' end + for id in pairs(cases)do if not items[id] then return nil,'apricorns: native selection is outside recipe table' end end + local pending=scripts[roles.pending];local targets + for i=1,#pending do + local found=chain(pending,i,'checkevent',events,#base) + if found then + if targets then return nil,'apricorns: ambiguous native collection chain' end + targets=found + end + end + if not targets then return nil,'apricorns: native collection chain differs' end + local template,giftAt,clearAt + for _,row in ipairs(base)do + local rows=scripts[targets[row.event]] + if type(rows)~='table' then return nil,'apricorns: native delivery program is absent' end + local gift,clear + for i,op in ipairs(rows)do + if type(op)~='table'then return nil,'apricorns: invalid native delivery row' end + if op[1]=='verbosegiveitem' or op[1]=='verbosegiveitemvar' then + if gift or op[2]~=item(row.ball) then return nil,'apricorns: native ball output differs from recipe' end + gift=i + elseif op[1]=='clearevent' and op[2]==row.event then + if clear then return nil,'apricorns: ambiguous native event clear' end + clear=i + end + end + if not gift or not clear or clear<=gift then return nil,'apricorns: native delivery/event signature differs' end + if not template then template,giftAt,clearAt=rows,gift,clear end + end + return {roles=copy(roles),base=base,items=items,events=events,template=template, + giftAt=giftAt,clearAt=clearAt,zero=zero,scripts=scripts,ask=ask} +end +function M.project(description,recipes,retired) + local d=description;local scripts=d.scripts;local out={} + for key,value in pairs(scripts)do out[key]=value end + local choices,deliveries,generated={},{},{} + local used,generation={},1 + for label in pairs(scripts)do + if type(label)=='string'then + local n=tonumber(label:match('^engine:apricorn:(%d+):')) + if n then used[n]=true end + end + end + while used[generation]do generation=generation+1 end + local function key(kind,index) + local id='engine:apricorn:'..generation..':'..kind..':'..index + assert(scripts[id]==nil,'apricorns: generated script label collision') + return id + end + for i,row in ipairs(recipes)do + assert(item(row.apricorn) and item(row.ball) and number(row.event),'apricorns: invalid projected recipe') + local choose=key('choose',i) + choices[#choices+1]={'ifequal',item(row.apricorn),choose} + generated[choose]={{'setevent',row.event},{'sjump',d.roles.gave}} + end + local deliveryRules,events={},{} + for _,list in ipairs({recipes,retired or {}})do + for _,row in ipairs(list)do + if not events[row.event]then deliveryRules[#deliveryRules+1]=row;events[row.event]=true end + end + end + for i,row in ipairs(deliveryRules)do + local deliver=key('deliver',i) + deliveries[#deliveries+1]={'checkevent',row.event};deliveries[#deliveries+1]={'iftrue',deliver} + local body=copy(d.template);body[d.giftAt][2]=item(row.ball);body[d.clearAt][2]=row.event + table.insert(body,d.clearAt+1,{'apricornconverted',row.apricorn,row.ball,row.event}) + generated[deliver]=body + end + choices[#choices+1]={'sjump',d.roles.cancel} + local touched,memberCount,pendingCount={ },0,0 + for label,rows in pairs(scripts)do + if type(rows)=='table'then + local result,i,changed={},1,false + while i<=#rows do + local op=rows[i] + if chain(rows,i,'checkitem',d.items,#d.base,d.roles.ask) then + for _,row in ipairs(recipes)do + result[#result+1]={'checkitem',item(row.apricorn)} + result[#result+1]={'iftrue',d.roles.ask} + end + i=i+#d.base*2;changed=true;memberCount=memberCount+1 + elseif chain(rows,i,'checkevent',d.events,#d.base) then + for _,row in ipairs(deliveries)do result[#result+1]=copy(row)end + i=i+#d.base*2;changed=true;pendingCount=pendingCount+1 + else result[#result+1]=op;i=i+1 end + end + -- Ask is an actual queued root. Its original prefix includes the real + -- selected item and native zero/cancel branch; never substitute an ID. + if label==d.roles.ask then + result={};for n=1,d.zero do result[n]=copy(rows[n])end + for _,row in ipairs(choices)do result[#result+1]=copy(row)end + changed=true + end + if changed then out[label]=copy(result);touched[label]=true end + end + end + assert(memberCount>0 and pendingCount>0,'apricorns: native item/event consumers are absent') + for label,rows in pairs(generated)do out[label]=rows;touched[label]=true end + return out,touched +end +return M diff --git a/src/world/Gen2Apricorns.lua b/src/world/Gen2Apricorns.lua new file mode 100644 index 000000000..bc7268d4c --- /dev/null +++ b/src/world/Gen2Apricorns.lua @@ -0,0 +1,43 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +-- Native recipe order for Kurt's selection; ball delivery remains script-owned. +local M={} +local Version=require('src.core.GameVersion') +local function stock(version)return version=='gold' or version=='silver' or version=='crystal'end +local function item(value) + local id=type(value)=='string' and value:match('^ITEM_(%d%d%d)$') + id=tonumber(id) + return id and id>0 and id<255 and id or nil +end +function M.rows(data) + local pool=data and data.map_scripts + local source=pool and pool.apricornBalls + local out={} + if source~=nil then + if type(source)~='table' or pool.apricornInfo and pool.apricornInfo.complete==false then return out end + for _,row in ipairs(source)do + if type(row)~='table' or not item(row.apricorn) or not item(row.ball) then return {} end + out[#out+1]={index=#out+1,apricorn=row.apricorn,ball=row.ball} + end + return out + end + -- Historical caches have no recipe table. Keep selection available until + -- reimport; do not invent their missing ball mappings. + local legacy={85,89,92,93,97,99,101} + for i,id in ipairs(legacy)do out[i]={index=i,apricorn=string.format('ITEM_%03d',id)}end + return out +end +function M.inBag(data,save) + local out={} + local nativeStock=stock(Version.get()) + for _,row in ipairs(M.rows(data))do + local n=tonumber(save and save.inventory and save.inventory[row.apricorn]) + if n and n==n and n=1 then + local quantity=math.floor(n) + if nativeStock then quantity=math.min(99,quantity)end + out[#out+1]={index=row.index,apricorn=row.apricorn,ball=row.ball, + item=item(row.apricorn),quantity=quantity} + end + end + return out +end +return M diff --git a/src/world/Gen2Decorations.lua b/src/world/Gen2Decorations.lua new file mode 100644 index 000000000..3f87b4942 --- /dev/null +++ b/src/world/Gen2Decorations.lua @@ -0,0 +1,221 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native ownership flags, placed bytes and callback presentation are distinct. +-- Attribute/name rows come from the current cartridge, never a retail table. +local V=require('src.core.GameVersion') +local Strings=require('src.core.Strings') +local M={} +M.slots={'bed','carpet','plant','poster','console','bigDoll','leftOrnament','rightOrnament'} +M.objectSlots={ + {slot='console',sprite=0,flag=1857},{slot='leftOrnament',sprite=1,flag=1858}, + {slot='rightOrnament',sprite=2,flag=1859},{slot='bigDoll',sprite=3,flag=1860}, +} +local groups={ + {'BED','bed'},{'CARPET','carpet'},{'PLANT','plant'},{'POSTER','poster'}, + {'GAME CONSOLE','console'},{'ORNAMENT','ornament'},{'BIG DOLL','bigDoll'}, +} +function M.byte(id) + local n=(type(id)=='number'or type(id)=='string')and tonumber(id) + return n and n%1==0 and n>=0 and n<=255 and n or nil +end +local kinds={'BED','CARPET','PLANT','POSTER','CONSOLE','BIG_DOLL','DOLL'} +local slotFor={BED='bed',CARPET='carpet',PLANT='plant',POSTER='poster',CONSOLE='console',BIG_DOLL='bigDoll'} +local actions={} +for _,kind in ipairs(kinds)do + actions[#actions+1]='SET_UP_'..kind;actions[#actions+1]='PUT_AWAY_'..kind +end +function M.actionName(n)return actions[n]end +function M.action(name) + if type(name)~='string'then return nil end + local kind=name:match('^SET_UP_(.+)$');local put=false + if not kind then kind=name:match('^PUT_AWAY_(.+)$');put=true end + if kind=='DOLL'then return{ornament=true,put=put}end + return slotFor[kind] and {slot=slotFor[kind],put=put}or nil +end +function M.pool(data)return data and data.map_scripts end +function M.ready(data) + local p=M.pool(data);local info=p and p.decorationInfo + return V.generation(V.get())==2 and type(p)=='table' and type(p.decorations)=='table' and + type(info)=='table' and info.complete==true or false +end +function M.attributes(data,id) + if not M.ready(data)then return nil end + id=M.byte(id);if not id then return nil end + local rows=M.pool(data).decorations + return rows[id]or rows[tostring(id)] +end +function M.state(data,save) + if not M.ready(data)or type(save)~='table'then return nil end + if save.decorations==nil then + local info=M.pool(data).decorationInfo + if not info.defaultsComplete then return nil end + save.decorations=require('src.mods.Merge').deepCopy(info.defaults or{}) + end + return type(save.decorations)=='table'and save.decorations or nil +end +local names={ + [2]=Strings.source('%s BED'),[3]=Strings.source('%s CARPET'), + [4]=Strings.source('%s POSTER'),[5]=Strings.source('%s DOLL'),[6]=Strings.source('BIG %s'), +} +function M.name(data,id,monName) + id=M.byte(id) + local row=M.attributes(data,id) + if not row then return ''end + local name=row.name + if row.type>=4 then + if monName then name=monName(name)or name + else + local info=M.pool(data).decorationInfo + local key=info.sourceNames and info.sourceNames[id]==name and info.species and info.species[id]or name + local mon=data.pokemon and data.pokemon[key] + name=mon and mon.name or name + end + end + return names[row.type] and Strings(names[row.type],name)or name +end +function M.owns(data,save,id) + id=M.byte(id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + return type(save)=='table' and type(save.flags)=='table' and id~=0 and action and not action.put and require('src.script.Flags').get(save, + require('src.script.Gen2Flags').eventFlag(row.flag))or false +end +function M.give(data,save,id) + id=M.byte(id) + local row=M.attributes(data,id) + if type(save)~='table' or id==0 or not row or not M.action(row.action)or M.action(row.action).put then return false end + save.flags=save.flags or{} + require('src.script.Flags').set(save,require('src.script.Gen2Flags').eventFlag(row.flag));return true +end +function M.giveFlag(data,save,n) + local info=M.pool(data)and M.pool(data).decorationInfo + local id=info and info.gifts and info.gifts[(tonumber(n)or -1)+1] + return id~=nil and M.give(data,save,id)or false +end +function M.choose(data,save,id,side) + id=M.byte(id) + local state=M.state(data,save);local row=M.attributes(data,id) + local action=row and M.action(row.action) + if id==0 or not(state and action)then return false end + if not action.put and not M.owns(data,save,id)then return false end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return false end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local value=action.put and 0 or id + local changed=(state[slot]or 0)~=value + state[slot]=value + if changed and action.ornament and not action.put then + local other=side=='left'and 'rightOrnament'or 'leftOrnament' + if state[other]==id then state[other]=0 end + end + return changed +end +-- Public menu metadata is derived from the current native rows, including +-- registry additions/removals. No independent retail catalogue is seeded. +function M.categories(data) + if not M.ready(data)then return{}end + local indexed={} + for raw in pairs(M.pool(data).decorations)do + local id=M.byte(raw);if id and id~=0 then indexed[id]=true end + end + local out={} + for _,group in ipairs(groups)do + local category={label=group[1],slot=group[2],members={},headers={}} + for id in pairs(indexed)do + local row=M.attributes(data,id);local action=row and M.action(row.action) + if action and(action.slot or 'ornament')==category.slot then + local list=action.put and category.headers or category.members;list[#list+1]=id + end + end + table.sort(category.members);table.sort(category.headers) + category.id=category.headers[1];out[#out+1]=category + end + return out +end +function M.placement(data,state,id,side,monName) + id=M.byte(id) + local row=M.attributes(data,id);local action=row and M.action(row.action) + if type(state)~='table'or not action or id==0 then return nil end + local slot=action.slot + if action.ornament then + if side~='left'and side~='right'then return nil end + slot=side=='left'and 'leftOrnament'or 'rightOrnament' + end + local old=M.byte(state[slot])or 0 + local value=action.put and 0 or id + local role=action.put and(old==0 and 'empty'or 'put')or + old==id and 'same'or old==0 and 'setup'or 'replace' + return{slot=slot,value=value,changed=old~=value,role=role, + buffers={[3]=M.name(data,role=='setup'and id or old,monName),[4]=M.name(data,id,monName)}} +end +function M.feedbackPages(data,placement) + if not placement then return{}end + local programs=M.pool(data)and M.pool(data).decorationPrograms + local id=programs and programs.texts and programs.texts[placement.role] + if not id then return{}end + local private=data._portableScriptText and data._portableScriptText[id] + local text=private and private.value or not private and data.text and data.text[id] + if type(text)~='string'then return{}end + text=text:gsub('{RAM:wStringBuffer([34])}',function(n)return placement.buffers[tonumber(n)]end) + text=text:gsub('{DONE}',''):gsub('{PROMPT}',''):gsub('\v','\n') + local pages={} + for page in(text..'\f'):gmatch('(.-)\f')do if page~=''then pages[#pages+1]=page end end + return pages +end +function M.layoutVisibility(data,state) + local out={} + for _,binding in ipairs(M.objectSlots)do + local id=type(state)=='table'and M.byte(state[binding.slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + out[#out+1]={sprite=binding.sprite,flag=binding.flag,hidden=not row,byte=row and row.sprite or nil} + end + return out +end +function M.layoutTiles(data,state) + local out={} + local function paint(slot,x,y) + local id=type(state)=='table'and M.byte(state[slot])or 0 + local row=id and id~=0 and M.attributes(data,id) + if row then out[#out+1]={x=x,y=y,block=row.sprite};return row.sprite end + end + paint('bed',0,2);paint('plant',3,2);paint('poster',3,0) + local carpet=paint('carpet',0,0) + if carpet then + out[#out+1]={x=0,y=1,block=(carpet+1)%256} + out[#out+1]={x=1,y=1,block=(carpet+2)%256} + out[#out+1]={x=2,y=1,block=(carpet+1)%256} + end + return out +end +function M.posterVisible(state)return((state and state.poster)or 0)~=0 end +-- These callbacks deliberately preserve the VM's current script/check vars. +function M.visibility(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') + for _,row in ipairs(M.layoutVisibility(data,state))do + if not row.hidden then + C.g2_variablesprite(ctx,row.sprite,row.byte);C.clear_flag(ctx,Flags.eventFlag(row.flag)) + else C.set_flag(ctx,Flags.eventFlag(row.flag))end + end +end +function M.tiles(ctx) + local data=ctx.game.data;local state=M.state(data,ctx.save) + if not state then return end + ctx.save.flags=ctx.save.flags or{} + local C=require('src.script.Commands') + for _,row in ipairs(M.layoutTiles(data,state))do C.replace_block(ctx,row.x,row.y,row.block)end + local flag=require('src.script.Gen2Flags').eventFlag(716) + if M.posterVisible(state)then C.set_flag(ctx,flag)else C.clear_flag(ctx,flag)end +end +function M.description(data,save,kind) + local state=M.state(data,save);local programs=M.pool(data)and M.pool(data).decorationPrograms + if not state or type(programs)~='table'then return nil end + if kind==0 then return programs.posters and programs.posters[state.poster or 0]or programs.null end + if kind==3 then return programs.big end + local slot=({[1]='leftOrnament',[2]='rightOrnament',[4]='console'})[kind] + if slot then return programs.ornament,M.name(data,state[slot]or 0)end +end +return M diff --git a/src/world/Gen2Landmarks.lua b/src/world/Gen2Landmarks.lua new file mode 100644 index 000000000..2ce8d3b55 --- /dev/null +++ b/src/world/Gen2Landmarks.lua @@ -0,0 +1,76 @@ +-- Original adapter/selection logic by Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +-- Constant identities/order only, not cartridge coordinates, names or art. +-- Orders: pret/pokegold and pret/pokecrystal constants/landmark_constants.asm. +local Version=require('src.core.GameVersion') +local M={} +local gold={} +for name in ([[ + SPECIAL NEW_BARK_TOWN ROUTE_29 CHERRYGROVE_CITY ROUTE_30 ROUTE_31 + VIOLET_CITY SPROUT_TOWER ROUTE_32 RUINS_OF_ALPH UNION_CAVE ROUTE_33 + AZALEA_TOWN SLOWPOKE_WELL ILEX_FOREST ROUTE_34 GOLDENROD_CITY RADIO_TOWER + ROUTE_35 NATIONAL_PARK ROUTE_36 ROUTE_37 ECRUTEAK_CITY TIN_TOWER BURNED_TOWER + ROUTE_38 ROUTE_39 OLIVINE_CITY LIGHTHOUSE ROUTE_40 WHIRL_ISLANDS ROUTE_41 + CIANWOOD_CITY ROUTE_42 MT_MORTAR MAHOGANY_TOWN ROUTE_43 LAKE_OF_RAGE + ROUTE_44 ICE_PATH BLACKTHORN_CITY DRAGONS_DEN ROUTE_45 DARK_CAVE ROUTE_46 + SILVER_CAVE PALLET_TOWN ROUTE_1 VIRIDIAN_CITY ROUTE_2 PEWTER_CITY ROUTE_3 + MT_MOON ROUTE_4 CERULEAN_CITY ROUTE_24 ROUTE_25 ROUTE_5 UNDERGROUND_PATH + ROUTE_6 VERMILION_CITY DIGLETTS_CAVE ROUTE_7 ROUTE_8 ROUTE_9 ROCK_TUNNEL + ROUTE_10 POWER_PLANT LAVENDER_TOWN LAV_RADIO_TOWER CELADON_CITY SAFFRON_CITY + ROUTE_11 ROUTE_12 ROUTE_13 ROUTE_14 ROUTE_15 ROUTE_16 ROUTE_17 ROUTE_18 + FUCHSIA_CITY ROUTE_19 ROUTE_20 SEAFOAM_ISLANDS CINNABAR_ISLAND ROUTE_21 + ROUTE_22 VICTORY_ROAD ROUTE_23 INDIGO_PLATEAU ROUTE_26 ROUTE_27 TOHJO_FALLS + ROUTE_28 FAST_SHIP +]]):gmatch('%S+')do gold[#gold+1]='LANDMARK_'..name end +local function validOrder(source) + if type(source)~='table' or #source==0 or #source>256 then return nil end + local result,seen={},{} + for i=1,#source do + local id=source[i] + if type(id)~='string' or id=='' or seen[id]then return nil end + seen[id]=true;result[i]=id + end + for key in pairs(source)do + if type(key)~='number' or key%1~=0 or key<1 or key>#source then return nil end + end + return result +end +function M.order(version,metadata) + local declared=metadata and (metadata.landmarkOrder or metadata.constants and metadata.constants.landmarkOrder) + if declared~=nil then + local order=validOrder(declared) + if not order then return {},'invalid landmark identity order' end + return order + end + if version~='gold' and version~='silver' and version~='crystal'then return{}end + local result={};for i,id in ipairs(gold)do result[i]=id end + if version=='crystal'then table.insert(result,30,'LANDMARK_BATTLE_TOWER')end + return result +end +function M.town(data)return data and data.field and data.field.townMap end +function M.dataOrder(data) + local town=M.town(data) + if town and town.landmarkOrder~=nil then return validOrder(town.landmarkOrder) or{}end + return M.order(Version.get(),data and data.constants and {landmarkOrder=data.constants.landmarkOrder}) +end +function M.index(data,id) + local town=M.town(data);local rows=town and town.landmarks + local found + for key,row in pairs(rows or{})do + if type(row)=='table' and (row.landmarkId or row.id)==id and tonumber(row.index or key)then + local n=tonumber(row.index or key) + if found and found~=n then return nil end + found=n + end + end + if found then return found,true end + for i,name in ipairs(M.dataOrder(data))do if name==id then return i-1,false end end +end +function M.kantoStart(data) + local index,native=M.index(data,'LANDMARK_PALLET_TOWN') + local town=M.town(data) + if index and (native or town and town.landmarkOrder~=nil or + data and data.constants and data.constants.landmarkOrder~=nil)then return index end + local pallet=data and data.maps and data.maps.PALLET_TOWN + return tonumber(pallet and pallet.landmark) or index or (Version.get()=='crystal' and 0x2f or 0x2e) +end +return M diff --git a/src/world/Gen2MomShopping.lua b/src/world/Gen2MomShopping.lua new file mode 100644 index 000000000..ab075abdf --- /dev/null +++ b/src/world/Gen2MomShopping.lua @@ -0,0 +1,139 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +-- Native accounts, ROM shopping rows and original phone programs share one save. +local Version=require('src.core.GameVersion') +local M={MAX_MONEY=999999} +local function integer(value,lo,hi) + return type(value)=='number'and value%1==0 and value>=lo and value<=hi and value or nil +end +local function money(value)return integer(value,0,M.MAX_MONEY)or 0 end +function M.stock() + local v=Version.get();return v=='gold'or v=='silver'or v=='crystal' +end +function M.mode(save) + local mode=integer(save.g2MomSavingMode,0,3) + local flag=save.flags and save.flags.ENGINE_MOM_SAVING_MONEY + if mode==nil then mode=flag~=nil and(flag and 1 or 0)or(save.g2MomSaving and 1 or 0) + elseif flag~=nil then mode=mode-mode%2+(flag and 1 or 0)end + return mode +end +function M.prepareBank(save) + if not M.stock()then return end + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + local active=save.flags.ENGINE_MOM_ACTIVE + if active==nil and save.g2MomBankOpen==true then Flags.set(save,'ENGINE_MOM_ACTIVE') + elseif active==true then save.g2MomBankOpen=true + elseif active==false then save.g2MomBankOpen=nil end + if save.flags.ENGINE_MOM_SAVING_MONEY==nil and(save.g2MomSaving~=nil or save.g2MomSavingMode~=nil)then + if M.mode(save)%2==1 then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end + end + save.g2MomSaving=M.mode(save)~=0 +end +function M.setSaving(save,on) + save.g2MomSaving=on==true + if not M.stock()then return end + save.g2MomSavingMode=on and 1 or 0 + save.flags=save.flags or{} + local Flags=require('src.script.Flags') + if on then Flags.set(save,'ENGINE_MOM_SAVING_MONEY')else Flags.clear(save,'ENGINE_MOM_SAVING_MONEY')end +end +function M.openBank(save) + save.g2MomBankOpen=true + if M.stock()then + save.flags=save.flags or{};require('src.script.Flags').set(save,'ENGINE_MOM_ACTIVE') + end +end +-- WinTrainerBattle deals four quarters; near-cap overflow is lost in the +-- selected account, not redirected to the other one. +function M.reward(save,total) + if not M.stock()or not integer(total,0,0xffffff*4)then return nil end + M.prepareBank(save) + local quarter=math.floor(total/4) + local mode=money(save.g2MomMoney)0 and type(p.ladder)=='table'and #p.ladder>0 +end +function M.caller(data) + local found + for id,row in pairs(data and data.map_scripts and data.map_scripts.phone or{})do + if integer(id,1,255)and type(row)=='table'and(row.class==nil or row.class==0)and(row.contactNumber or row.trainer)==1 then + if found then return nil end;found=id + end + end + return found +end +function M.state(data,save) + if not M.ready(data)then return nil end + if save.g2MomShopping==nil then save.g2MomShopping={whichItem=0,whichSet=0,triggerBalance=M.pool(data).step}end + local state=save.g2MomShopping + if type(state)~='table'or not integer(state.whichItem,0,255)or not integer(state.triggerBalance,0,M.MAX_MONEY)then return nil end + return state +end +function M.pick(data,save,random) + local state=M.state(data,save);if not state then return nil end + local p=M.pool(data);local saved=money(save.g2MomMoney) + state.whichSet=0 + local row=p.ladder[state.whichItem+1] + if row and saved>=row.trigger then return{row=row,set=2}end + while state.triggerBalancecap then return false end + save.pcItems=pc;pc[id]=held+1;return true +end +function M.tryBuy(data,save,opts) + opts=opts or{} + if not M.ready(data)or opts.phoneService~=true or opts.queued then return nil end + local caller=M.caller(data);if not caller then return nil end + local p=M.pool(data);local VM=require('src.script.Gen2ScriptVM') + local itemScript=p.programs and VM.compile(data,p.programs.item) + local dollScript=p.programs and VM.compile(data,p.programs.doll) + if not(itemScript and dollScript)then return nil end + local choice=M.pick(data,save,opts.random);if not choice then return nil end + if not receive(data,save,choice.row)then return nil end + save.g2MomMoney=math.max(0,money(save.g2MomMoney)-choice.row.cost) + local state=M.state(data,save) + if choice.set==2 then state.whichItem=(state.whichItem+1)%256 end + return{kind=choice.row.kind,item=choice.row.item,cost=choice.row.cost,set=choice.set, + saved=save.g2MomMoney,caller=caller,script=choice.row.kind==2 and dollScript or itemScript} +end +return M diff --git a/src/world/Gen2PhoneBook.lua b/src/world/Gen2PhoneBook.lua new file mode 100644 index 000000000..db0ba6d25 --- /dev/null +++ b/src/world/Gen2PhoneBook.lua @@ -0,0 +1,116 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-book.md. +-- Native ten-byte order beside the existing numeric registration set. Reads +-- retain legacy overflow entries; no save or contact ID is renumbered. +local M = { SIZE=10 } +local function valid(id) + return type(id)=='number' and id%1==0 and id>=1 and id<=255 +end +local function registered(save) + if type(save.g2Phone)~='table' then save.g2Phone={} end + return save.g2Phone +end +function M.slots(save) + local set=registered(save) + local slots=type(save.g2PhoneSlots)=='table' and save.g2PhoneSlots or {} + save.g2PhoneSlots=slots + local seen={} + for i=1,M.SIZE do + local id=slots[i] + if valid(id) and set[id] and not seen[id] then seen[id]=true + else slots[i]=0 end + end + local pending={} + for id,on in pairs(set) do + if on and valid(id) and not seen[id] then pending[#pending+1]=id end + end + table.sort(pending) + local cursor=1 + for i=1,M.SIZE do + if slots[i]==0 and pending[cursor] then slots[i]=pending[cursor];cursor=cursor+1 end + end + return slots +end +function M.contacts(save) + local slots,out=M.slots(save),{} + for i=1,M.SIZE do out[i]=slots[i] end + return out +end +function M.has(save,id) + if id==0 then + for _,value in ipairs(M.slots(save)) do if value==0 then return true end end + return false + end + return valid(id) and registered(save)[id] and true or false +end +function M.permanent(data) + local out={} + local pool=data and data.map_scripts and data.map_scripts.phone + local constants=data and data.constants or {} + local order=constants.phoneContactOrder or {} + local version=require('src.core.GameVersion').get() + local retail=version=='gold' or version=='silver' or version=='crystal' + for id,row in pairs(pool or {}) do + if valid(id) and type(row)=='table' and not (row.class and row.class~=0) then + local number=row.contactNumber + if number==1 or number==4 or number==nil and + (order[id+1]=='PHONE_MOM' or order[id+1]=='PHONE_ELM' + or retail and constants.phoneContactOrder==nil and (id==1 or id==4)) then out[id]=true end + end + end + -- Before import, the retail constants still reserve their two slots. A + -- custom cartridge receives no guessed retail identities. + if pool==nil and retail and constants.phoneContactOrder==nil then out[1],out[4]=true,true end + return out +end +function M.remainingSlots(data,save,id) + local missing=0 + for permanent in pairs(M.permanent(data)) do + if permanent~=id and not M.has(save,permanent) then missing=missing+1 end + end + return math.max(0,M.SIZE-missing) +end +function M.openSlot(data,save,id) + local slots=M.slots(save) + for i=1,M.remainingSlots(data,save,id) do if slots[i]==0 then return i end end +end +function M.add(data,save,id) + if not valid(id) then return false,'unknown' end + local pool=data and data.map_scripts and data.map_scripts.phone + if pool and type(pool[id])~='table' then return false,'unknown' end + if M.has(save,id) then return false,'already' end + local slot=M.openSlot(data,save,id) + if not slot then return false,'full' end + registered(save)[id]=true;save.g2PhoneSlots[slot]=id + return true +end +function M.remove(save,id) + local slots=M.slots(save) + for i=1,M.SIZE do + if slots[i]==id then + slots[i]=0 + if id~=0 then registered(save)[id]=nil end + return true + end + end + -- An old oversized flat set remains recoverable, including deletion of an + -- entry that could not fit into the ten hardware slots yet. + if valid(id) and registered(save)[id] then registered(save)[id]=nil;return true end + return false +end +function M.deleteAt(save,slot) + if type(slot)~='number' or slot%1~=0 or slot<1 or slot>M.SIZE then return false end + local slots=M.slots(save);local id=slots[slot] + if id==0 then return false end + registered(save)[id]=nil;slots[slot]=0 + local write=1 + for read=1,M.SIZE do + if slots[read]~=0 then slots[write]=slots[read];write=write+1 end + end + for i=write,M.SIZE do slots[i]=0 end + return true +end +function M.canDelete(data,id) + local pool=data and data.map_scripts and data.map_scripts.phone + return valid(id) and pool and type(pool[id])=='table' and not M.permanent(data)[id] or false +end +return M diff --git a/src/world/Gen2PhoneService.lua b/src/world/Gen2PhoneService.lua new file mode 100644 index 000000000..bd9d96c66 --- /dev/null +++ b/src/world/Gen2PhoneService.lua @@ -0,0 +1,11 @@ +local PhoneService = {} +function PhoneService.available(def) + if not def then return false end + if def.phoneService~=nil then return def.phoneService==true end + -- New imports retain the complete byte. Older authored datasets may have + -- stored it in mapPalette itself; old ROM caches retain their legacy rule + -- until reimport because their discarded bits cannot be reconstructed. + local raw=tonumber(def.mapPaletteBits) or tonumber(def.mapPalette) or 0 + return math.floor(raw/16)%16==0 +end +return PhoneService diff --git a/src/world/Gen2Radio.lua b/src/world/Gen2Radio.lua new file mode 100644 index 000000000..f9eb6a1ef --- /dev/null +++ b/src/world/Gen2Radio.lua @@ -0,0 +1,103 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Station identities/MapRadio numbers are shared by both native radio UIs. +-- Program text stays in the existing native Pokegear implementation. +local Copy = require("src.mods.Merge").deepCopy +local R = {} +local literal=setmetatable({},{__mode="k"}) +local WALL = { + "OAKS_POKEMON_TALK", "POKEDEX_SHOW", "POKEMON_MUSIC", "LUCKY_CHANNEL", + "UNOWN_RADIO", "PLACES_AND_PEOPLE", "LETS_ALL_SING", "ROCKET_RADIO", +} +local NAMES = { + OAKS_POKEMON_TALK="OAK'S POKéMON TALK", POKEDEX_SHOW="POKéDEX SHOW", + POKEMON_MUSIC="POKéMON MUSIC", LUCKY_CHANNEL="LUCKY CHANNEL", + UNOWN_RADIO="UNOWN RADIO", PLACES_AND_PEOPLE="PLACES & PEOPLE", + LETS_ALL_SING="LET'S ALL SING", ROCKET_RADIO="ROCKET RADIO", + POKE_FLUTE_RADIO="POKé FLUTE", EVOLUTION_RADIO="EVOLUTION RADIO", + BUENAS_PASSWORD="BUENA'S PASSWORD", +} +local GEAR = { "OAKS_POKEMON_TALK", "POKEDEX_SHOW", "POKEMON_MUSIC", + "LUCKY_CHANNEL", "BUENAS_PASSWORD", "PLACES_AND_PEOPLE" } + +function R.defaults() + local rows={} + for id,name in pairs(NAMES)do rows[id]={name=name} end + for channel,id in ipairs(WALL)do rows[id].channel=channel end + return rows +end + +function R.records(data) + local rows=data and data.gen2RadioChannels + -- An explicitly empty mounted table is authoritative, including removals. + return type(rows)=="table" and rows or R.defaults() +end + +function R.literalState(data)return data and literal[data]end +function R.setLiteralState(data,state)if data then literal[data]=state end end +function R.isLiteral(data,id) + local state=data and literal[data] + return state and state[id]==true or state==nil and data and type(data.gen2RadioChannels)=="table" +end + +function R.channelRecord(data,channel) + local ids={} + for id,row in pairs(R.records(data))do + if type(row)=="table" and row.channel==channel then ids[#ids+1]=id end + end + table.sort(ids) + local id=ids[1] + return id and Copy(R.records(data)[id]),id +end + +function R.stationName(data,id) + local row=R.records(data)[id] + if type(row)~="table" then return nil end + return row.name or NAMES[id] or id +end + +function R.resolve(data,channel,map,tod) + if channel~=0 then + local _,id=R.channelRecord(data,channel) + if id then return id end + else + local maps=data and data.maps or {} + local pallet=maps.PALLET_TOWN + local boundary=tonumber(pallet and pallet.landmark) + or (require("src.core.GameVersion").get()=="crystal" and 0x2f or 0x2e) + if tonumber(map and map.landmark) and tonumber(map.landmark)>=boundary then + return R.stationName(data,"PLACES_AND_PEOPLE") and "PLACES_AND_PEOPLE" + end + if tod=="MORNING" or tod=="MORN" then + return R.stationName(data,"POKEDEX_SHOW") and "POKEDEX_SHOW" + end + end + return R.stationName(data,"OAKS_POKEMON_TALK") and "OAKS_POKEMON_TALK" +end + +function R.gearList(data,save,programs,expn) + local rows=R.records(data) + local list={} + for i,id in ipairs(GEAR)do + if type(rows[id])=="table" then + local entry=Copy(programs[i] or {}) + entry.id,entry.name,entry.nameLiteral=id,R.stationName(data,id),R.isLiteral(data,id) + list[#list+1]=entry + end + end + if expn and type(rows.POKE_FLUTE_RADIO)=="table" then + list[#list+1]={id="POKE_FLUTE_RADIO",name=R.stationName(data,"POKE_FLUTE_RADIO"),nameLiteral=R.isLiteral(data,"POKE_FLUTE_RADIO"), + knob=78,lines={"Playing the","POKé FLUTE...","A soothing melody.","..."},flute=true} + end + return list +end + +function R.notify(data,id,channel,source) + local name=id and R.stationName(data,id) + local Runtime=require("src.mods.Runtime") + if name and Runtime.wants("radio.channel") then + Runtime.emit("radio.channel",{station=id,channel=channel,name=name,source=source}) + end +end + +R.STATIONS=WALL +return R diff --git a/src/world/Gen2RadioAudio.lua b/src/world/Gen2RadioAudio.lua new file mode 100644 index 000000000..0327f824c --- /dev/null +++ b/src/world/Gen2RadioAudio.lua @@ -0,0 +1,52 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Native station playback owns the map cue after leaving the radio. This +-- covers channel entry/exit; cartridge show-driven song changes are separate. +local Music = require("src.core.Music") +local M = {} +local SONGS = { + OAKS_POKEMON_TALK = "Music_ProfOaksPokemonTalk", + POKEDEX_SHOW = "Music_PokemonCenter", + POKEMON_MUSIC = "Music_TitleScreen", + LUCKY_CHANNEL = "Music_GameCorner", + PLACES_AND_PEOPLE = "Music_ViridianCity", + LETS_ALL_SING = "Music_Bicycle", + ROCKET_RADIO = "Music_RocketTheme", + POKE_FLUTE_RADIO = "Music_PokeFluteChannel", + UNOWN_RADIO = "Music_RuinsOfAlphRadio", + EVOLUTION_RADIO = "Music_LakeOfRageRocketRadio", + BUENAS_PASSWORD = "Music_BuenasPassword", +} +M.FLUTE = SONGS.POKE_FLUTE_RADIO + +function M.tune(game, station) + if not require("src.core.GameVersion").isGen2() then return nil end + local song = SONGS[station] + local songs = game.data and game.data.audio and game.data.audio.songs + Music.stop() + if song and songs and songs[song] then + Music.play(game.data, song) + -- Observe the actual selected cue: an audio hook can replace/silence it, + -- and a missing or failed Source must never manufacture flute playback. + local current = Music.current() + if current then return { song = current } end + end + return { restore = true } +end + +function M.finish(game, playing) + if not playing then return end + if playing.restore and Music.current() == nil then + Music.restoreMap(game.data) + elseif Music.current() == playing.song then + Music.setMapSong(playing.song) + end +end + +function M.snorlaxAwake(player) + if Music.current() ~= M.FLUTE or not player then return false end + local x, y = player.cellX, player.cellY + return (x == 33 and y == 8) or (x == 34 and y == 10) + or (x == 35 and y == 10) or (x == 36 and (y == 8 or y == 9)) +end + +return M diff --git a/src/world/LiveMaps.lua b/src/world/LiveMaps.lua new file mode 100644 index 000000000..9c312c578 --- /dev/null +++ b/src/world/LiveMaps.lua @@ -0,0 +1,35 @@ +-- Detached residency snapshots. Offsets are world pixels, as in Gen1Recomp. +local Runtime = require("src.mods.Runtime") +local M = {} +function M.snapshot(world) + local out = {} + if not (world and world.map) then + return out + end + out[1] = { mapId = world.map.id, ox = 0, oy = 0, active = true } + for _, neighbor in ipairs(world.neighbors or {}) do + out[#out + 1] = { + mapId = neighbor.map and neighbor.map.id or neighbor.id, + ox = neighbor.ox or (neighbor.cx or 0) * 16, + oy = neighbor.oy or (neighbor.cy or 0) * 16, + active = false, + } + end + return out +end +function M.preparing(world) + if not world.map or world.preparingLiveMaps or not Runtime.wants("world.live_maps_preparing") then + return + end + local previous = world.preparingLiveMaps + world.preparingLiveMaps = true + Runtime.emit("world.live_maps_preparing", { mapId = world.map.id, maps = M.snapshot(world) }) + world.preparingLiveMaps = previous +end +function M.updated(world) + if not world.map or world.preparingLiveMaps or not Runtime.wants("world.live_maps_updated") then + return + end + Runtime.emit("world.live_maps_updated", { mapId = world.map.id, maps = M.snapshot(world) }) +end +return M 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/NpcControls.lua b/src/world/NpcControls.lua new file mode 100644 index 000000000..d5a08d952 --- /dev/null +++ b/src/world/NpcControls.lua @@ -0,0 +1,49 @@ +-- Original contribution by Roxas2712. See LICENSE-ROXAS2712.md. +-- Shared native actor operations. Facades retain their own admission policy. +local Collision = require("src.world.Collision") +local copy = require("src.mods.Merge").deepCopy +local C = {} +function C.isMoving(actor) + return not not actor.moving +end +function C.canStep(actor, map, peers, direction) + return not not Collision.canMove(map, peers or {}, actor, direction) +end +function C.stepNow(actor, direction) + if actor.moving then + return nil, "already moving" + end + actor.facing = direction + actor.targetX, actor.targetY = Collision.target(actor.cellX, actor.cellY, direction) + actor.moving, actor.progress = true, 0 + return true +end +function C.placeAt(actor, x, y, facing) + actor.moving, actor.marching, actor.progress = false, false, 0 + actor.targetX, actor.targetY = nil, nil + actor.cellX, actor.cellY, actor.px, actor.py = x, y, x * 16, y * 16 + if facing then + actor.facing = facing + end + return true +end +function C.setPassable(actor, passable) + actor.passable = passable + return true +end +function C.setAppearance(actor, game, id, req) + if not (game.data and game.data.sprites and game.data.sprites[id]) then + return nil, "unknown sprite: " .. tostring(id) + end + -- Resolve first, then commit. A renderer failure cannot half-change a live + -- actor or its imported template. This is the existing API-3 native path. + local proxy = { id = actor.id, def = copy(actor.def) } + proxy.def.sprite = id + proxy.def.graphicsId, proxy.def.graphics, proxy.def.graphicsVar, proxy.def.varSprite = nil, nil, nil, nil + local nativeNpc = (req or require)("src.world.NPC") + nativeNpc.refreshSprite(proxy, game.data) + actor.def, actor.sprite, actor.appearance = proxy.def, proxy.sprite, id + actor.big = proxy.sprite and proxy.sprite.big or false + return true +end +return C diff --git a/src/world/OptionalFollower.lua b/src/world/OptionalFollower.lua new file mode 100644 index 000000000..192007a00 --- /dev/null +++ b/src/world/OptionalFollower.lua @@ -0,0 +1,161 @@ +-- 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 +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 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..640858ae8 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") @@ -20,6 +22,7 @@ local Logger = require("src.core.Logger") local Probe = require("src.core.Probe") local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") +local LiveMaps = require("src.world.LiveMaps") local NPC = require("src.world.NPC") local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") @@ -28,6 +31,7 @@ local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") local GameVersion = require("src.core.GameVersion") +local Gen2Clock = require("src.core.Gen2Clock") local Tilt = require("src.render.Tilt") local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") @@ -283,7 +287,7 @@ local function objectTodBit() local ow = Game and Game.overworld local bit = ow and tonumber(ow.objectTodBit) if bit then return bit end - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) if period and tonumber(period.bit) then return tonumber(period.bit) end return TOD_BITS[objectTimeOfDay()] or TOD_BITS.DAY end @@ -2186,6 +2190,10 @@ function OverworldState:neighborReach() return rw, rh, vw, vh end +function OverworldState:liveMaps() + return LiveMaps.snapshot(self) +end + function OverworldState:rebuildNeighbors() local mapId = self.map.id self.neighbors = {} @@ -2202,11 +2210,13 @@ function OverworldState:rebuildNeighbors() hops, reachW, reachH)) do keep[n.id] = true local m = MapLoader.load(Game.data, n.id) - table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy }) + table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy, cx = n.ox / 16, cy = n.oy / 16 }) end -- bound resident memory: drop maps behind us that are neither current nor -- a drawn neighbor, releasing their window batch / border image / atlas MapLoader.trim(keep) + LiveMaps.preparing(self) + -- visual-only NPCs on connected maps (survey zoom): same spawn filter -- as a real map entry, but they never join self.entities -- no sight @@ -2236,7 +2246,6 @@ function OverworldState:rebuildNeighbors() -- the seam offset in CELLS. Everything in this engine steps on a 16px -- grid whatever the dataset's block size, and a connection offset is a -- whole number of blocks, so this is always exact. - nb.cx, nb.cy = nb.ox / 16, nb.oy / 16 for _, obj in ipairs(nb.map.def.objects or {}) do if objectVisible(Game.save, nb.map.id, obj) and not offMap(nb.map.def, obj) then local npc = pooledNPC(self.npcPool, Game.data, nb.map.id, obj) @@ -2248,6 +2257,7 @@ function OverworldState:rebuildNeighbors() end end self.cast = nil + LiveMaps.updated(self) end -- ONE CAST, IN EVERY LOADED MAP'S OWN CELLS. @@ -2391,7 +2401,7 @@ local function sameTod(tod) return tod end -- < 4 NITE, < 10 MORN, < 18 DAY, else NITE. Gen 1 has no clock at all, so -- only Gen 2 consults the host one. local function clockTimeOfDay() - local hour = tonumber(os.date("%H")) or 12 + local hour = Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) if hour < 4 then return "NITE" end if hour < 10 then return "MORNING" end if hour < 18 then return "DAY" end @@ -2404,7 +2414,7 @@ OverworldState.clockTimeOfDay = clockTimeOfDay -- The cartridge's MAPOBJECT_TIMEOFDAY bit for the hour on the clock right now. function OverworldState.clockTimeOfDayBit() - local period = todPeriodAt(tonumber(os.date("%H")) or 12) + local period = todPeriodAt(Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil)) return (period and tonumber(period.bit)) or TOD_BITS.DAY end @@ -2427,6 +2437,7 @@ function OverworldState:syncGen2Tod(tod) end function OverworldState:timeOfDay() + if GameVersion.isGen2() then Gen2Clock.weekday(Game and Game.save) end local tod = GameVersion.isGen2() and clockTimeOfDay() or (self.tod or "DAY") if not Runtime.wantsHook("world.tod") then if tod ~= self.tod then @@ -2463,6 +2474,12 @@ function OverworldState:timeOfDay() return self.tod end +-- Legacy callers and native scripts read the same saved RTC offset. Both +-- dot and colon calls are observational; no cached second clock on World. +function OverworldState.hour() return Gen2Clock.hour(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.minute() return Gen2Clock.minute(GameVersion.isGen2() and Game and Game.save or nil) end +function OverworldState.weekday() return Gen2Clock.weekday(GameVersion.isGen2() and Game and Game.save or nil) end + function OverworldState:paletteNameFor(map) local palettes = FieldDefaults.field(Game.data, "palettes") local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset) @@ -3424,6 +3441,34 @@ function OverworldState:checkSpecialPhoneCall() Game.save.g2CurCaller = caller self:queueScript(script, { phoneCaller = caller, onDone = function() Game.save.g2CurCaller = nil end }) + Gen2Commands.announceIncomingCall(Game.data, caller, Game.save.g2SpecialCallActive) +end + +-- A purchase is already committed at trainer reload. Its original phone +-- program waits for the trainer's queued dialogue and overlays to finish. +function OverworldState:checkMomShoppingCall() + local pending=self.g2MomCallPending + if not pending then return false end + if pending.save~=Game.save or pending.data~=Game.data then self.g2MomCallPending=nil;return false end + local top=Game.stack and Game.stack.top and Game.stack:top() + if top and top~=self then return false end + if self.player and(self.player.moving or self.player.targetX or self.player.stairExit or self.player.inputLocked)then return false end + if self.transitioning or self.runner:isRunning()or #(self.scriptMoves or{})>0 or self.engaging or self.emote or + self.teleportOut or self.whirlSpin or self.gen4Waterfall or self.flyAnim or self.fishing or + self.pendingScripts and self.pendingScripts[1]then return false end + self.g2MomCallPending=nil + local purchase=pending.purchase + Game.save.g2CurCaller=purchase.caller + pcall(function()require("src.core.Sound").play(Game.data,"Call")end) + self:queueScript(purchase.script,{phoneCaller=purchase.caller,onDone=function() + if Game.save~=pending.save or Game.data~=pending.data then return end + Game.save.g2CurCaller=nil + require("src.script.Gen2Commands").resetCallDelay(Game.save,math.floor(require("src.core.SaveData").playSeconds(Game.save)/60)) + pcall(function()require("src.core.Sound").play(Game.data,"Hang_Up")end) + end}) + local commands=require("src.script.Gen2Commands") + if commands.announceIncomingCall then commands.announceIncomingCall(Game.data,purchase.caller)end + return true end -- CheckPhoneCall (36:$4074): the random incoming call, rolled on every step @@ -3459,6 +3504,7 @@ function OverworldState:checkIncomingPhoneCall() Game.save.g2CurCaller = nil pcall(function() require("src.core.Sound").play(Game.data, "Hang_Up") end) end }) + Gen2Commands.announceIncomingCall(Game.data, id) end function OverworldState:update(dt) @@ -3793,6 +3839,7 @@ function OverworldState:update(dt) -- not an event, and the check is two flag reads. self:applyForcedBike() self:checkSpecialPhoneCall() + self:checkMomShoppingCall() self:checkBugContestClock() -- world.tick: the per-frame seam for mods that simulate something in the -- overworld rather than draw it. @@ -7263,6 +7310,12 @@ function OverworldState:npcAtCell(cx, cy) return nil end +-- The continuation receives the original opaque world and NPC handles. +-- Keep it outside interact so an unhooked A press allocates no closure. +local function vanillaTalk(world, target) + world:talkTo(target) +end + -- what the A press resolved to, for world.interacted's listeners -- A LINE THE CARTRIDGE ITSELF PRINTS, named by the import rather than typed -- here. `constants.gen3` holds a TEXT_ key and the text pool holds the @@ -7470,7 +7523,7 @@ function OverworldState:interact() -- rules, answered mid-step like the Pikachu above if not npc.hidden then require("src.world.HgssFollowMon").talk(Game, self, npc) end elseif not npc.moving then - self:talkTo(npc) + Runtime.call("world.talk", vanillaTalk, self, npc) end interacted(self, fx, fy, "npc", npc) return @@ -11817,6 +11870,8 @@ function OverworldState:trainerDefeated(npc) return false end +OverworldState.prepareTrainerBattle = require("src.battle.TrainerPreparation").prepare + -- Run the pre-battle text -> battle -> won text -> flags sequence. function OverworldState:engageTrainer(npc, onDone) local d = npc.def @@ -11831,7 +11886,7 @@ function OverworldState:engageTrainer(npc, onDone) local wonText = header and header.won and Game.data.text[header.won] local BattleState = require("src.battle.BattleState") - Game.stack:push(TextBox.new(Game, battleText, function() + local function startBattle(options) -- A TEAM THE AUTHOR BUILT WINS OVER AN INDEX INTO SOMEBODY ELSE'S. -- -- `trainerParty` selects one of the parties the cartridge shipped for @@ -11842,10 +11897,10 @@ function OverworldState:engageTrainer(npc, onDone) local battle if type(d.trainerTeam) == "table" and #d.trainerTeam > 0 then battle = BattleState.newEditorTrainer(Game, d.trainerTeam, - d.trainerClass, d.trainerName) + d.trainerClass, d.trainerName, options) end battle = battle or BattleState.newTrainer(Game, d.trainerClass, - d.trainerParty) + d.trainerParty, options) -- PrintEndBattleText (home/trainers.asm:341) is called from -- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle -- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer @@ -11874,6 +11929,34 @@ function OverworldState:engageTrainer(npc, onDone) end end self:pushBattle(battle) + end + Game.stack:push(TextBox.new(Game, battleText, function() + local liveGame, liveSave, liveData, liveStack, liveSession = Game, Game.save, Game.data, Game.stack, Game.sessionStartedAt + local map, def = self.map, npc.def + local class, party, npcId, team = d.trainerClass, d.trainerParty, npc.id, d.trainerTeam + local wasEngaging = self.engaging + self.engaging = true + OverworldState.prepareTrainerBattle(Game, { + trainerClass = class, partyIndex = party or 1, mapId = map.id, npcId = npc.id, + }, function(options) + self.engaging = wasEngaging + startBattle(options) + end, function() + self.engaging = wasEngaging + self.spotHush = { cellX = self.player.cellX, cellY = self.player.cellY, [npc] = true } + if onDone then onDone() end + end, function() + if Game ~= liveGame or self.map ~= map or npc.id ~= npcId or npc.def ~= def + or d.trainerTeam ~= team or def.trainerClass ~= class + or def.trainerParty ~= party then return false end + for _, actor in ipairs(self.npcs or {}) do if actor == npc then return true end end + return false + end, function() + if Game == liveGame and Game.save == liveSave and Game.data == liveData + and Game.stack == liveStack and Game.sessionStartedAt == liveSession and self.map == map then + self.engaging, npc.frozen = false, false + end + end) end)) end @@ -13088,6 +13171,12 @@ function OverworldState:hatchEgg(hatched) elseif GameVersion.isGen3() and Game.save then require("src.pokemon.Gen3GameStats").increment(Game.save, 13) -- GAME_STAT_HATCHED_EGGS end + local hatchObserver=GameVersion.isGen2() and Runtime.wants("egg.hatched") + local originalEgg,eggSlot + if hatchObserver then + originalEgg=require('src.mods.Merge').deepCopy(hatched) + for slot,mon in ipairs(Game.save.party or{})do if mon==hatched then eggSlot=slot;break end end + end local Pokemon = require("src.pokemon.Pokemon") local def = Game.data.pokemon[hatched.species] hatched.isEgg = nil @@ -13119,6 +13208,10 @@ function OverworldState:hatchEgg(hatched) local name = def and def.name or hatched.species local function finished() Runtime.emit("pokemon.egg_hatched", { mon = hatched }) + if hatchObserver then + Runtime.emit("egg.hatched",{mon=hatched,egg=originalEgg,slot=eggSlot, + species=originalEgg.species,nickname=hatched.nickname}) + end end local hatchText = Strings("Huh?\f%s hatched\nfrom the EGG!", name) local askText = gen2 and Strings("Give a nickname to\n%s?", name) or nil @@ -14827,6 +14920,19 @@ function OverworldState:gen4FieldResumed() end function OverworldState:afterBattle(result, battle) + if result=="win" and battle and battle.kind=="trainer" and not battle.linkBattle and not battle.battleTower and + not battle.g2MomHandled and require("src.world.Gen2MomShopping").stock()then + battle.g2MomHandled=true + if not self.g2MomCallPending then + local purchase=require("src.world.Gen2MomShopping").tryBuy(Game.data,Game.save,{ + phoneService=require("src.world.Gen2PhoneService").available(self.map and self.map.def), + queued=self.pendingScripts and self.pendingScripts[1]~=nil, + }) + if purchase then self.g2MomCallPending={save=Game.save,data=Game.data,purchase=purchase}end + end + end + + self.gen4EncounterAttempts = 0 -- a battle a script did not start (a wild one, a trainer's sight) comes -- back to the field here; a scripted one already did in its onFinish @@ -15374,7 +15480,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.arriveWarp = nil local fromId = self.map.id local fromSection = self.map.def and self.map.def.regionMapSection - Game.stack:push(Transition.new(Game, function() + local transition = Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) self:noteGen2Spawn(fromId) -- FIRERED'S SIDE STAIRS (ExitStairsMovement): you land ON the stair tile @@ -15564,7 +15670,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- side, which is what the scripts already do. if self.player then self.player.inputLocked = false end if onDone then onDone() end - end)) + end) + -- API 3 exposes the Gen 3 no-fade option. Reuse the exact native midpoint + -- and completion path; ordinary/native callers keep their existing fade. + if opts and opts.fade == false then + transition.onMidpoint() + transition.onDone() + else + Game.stack:push(transition) + end end -- Re-read a map record after its data changed (WorldAPI:invalidateMap, @@ -17223,6 +17337,827 @@ function OverworldState:poseBerryTrees() end end +-- 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, 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 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 + -- 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 + 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, 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, gymFog) + end + end + return onTop +end + function OverworldState:drawWorld() -- 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 @@ -17286,6 +18221,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 +18231,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 +18249,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 +18331,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 +18391,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 +18402,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 +18416,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 +18449,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 +18505,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 +18531,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 +18543,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 @@ -18237,115 +18554,15 @@ function OverworldState:drawWorld() shadows = require("src.render.Gen4Shadows").forMap(Game.data) or false self.gen4Shadows = shadows end - -- A GYM'S FOG ON THE CHARACTERS (Hearthome's dark rooms, Canalave's depth - -- fog): the brightness the DS's fog leaves a billboard, nil for none - -- (src/world/Gen4GymFx.lua spriteFog) + -- Native DS fog is evaluated once per pass and passed through the + -- private hoisted entity path, including its rollback implementation. 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, gymFog) end end @@ -18400,16 +18617,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 + if Performance.extraCap("reflections")~=false and 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 +18725,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 +18747,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 +18765,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, 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, 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 +18804,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 +18823,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, 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 +18855,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 +18870,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 +18899,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 +18915,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 +18935,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 +18958,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 +18978,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 +18986,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,8 +18998,10 @@ 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 + -- The cursor the DECORATE row hands the player: the decoration's own -- footprint, outlined, blinking, anchored at its bottom-left the way the -- save stores it. diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 708f386b6..100cc8ccb 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -17,6 +17,7 @@ local Collision = require("src.world.Collision") local GameVersion = require("src.core.GameVersion") +local ModRuntime = require("src.mods.Runtime") local PikachuFollower = {} @@ -166,6 +167,7 @@ local function remove(ow) for j, e in ipairs(ow.entities or {}) do if e == npc then table.remove(ow.entities, j) break end end + ow.cast = nil end -- spawn cell: directly behind the player's facing when that cell is @@ -189,11 +191,16 @@ function PikachuFollower.current(ow) end function PikachuFollower.onMapEntered(game, ow, opts) + local generation = GameVersion.generation() + if generation == 2 or generation == 3 then + return require("src.world.OptionalFollower").onMapEntered(game, ow, opts, true) + end + if generation ~= 1 then return 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 remove(ow) - if not shouldSpawn(game, ow) then return end + if not ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then return end -- opts.keepPikachu is the follower a connection crossing kept alive: -- LoadMapHeader's connection path sets wPikachuSpawnState = 2 and bit 4 -- of wPikachuOverworldStateFlags, so SchedulePikachuSpawnForAfterText @@ -205,6 +212,7 @@ function PikachuFollower.onMapEntered(game, ow, opts) if keep then table.insert(ow.npcs, keep) table.insert(ow.entities, keep) + ow.cast = nil return end local x, y = spawnCell(ow) @@ -212,6 +220,7 @@ function PikachuFollower.onMapEntered(game, ow, opts) table.insert(ow.npcs, npc) -- entities is the draw list; passable keeps it out of collision table.insert(ow.entities, npc) + ow.cast = nil ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY } end @@ -395,14 +404,19 @@ 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) + local generation = GameVersion.generation() + if generation == 2 or generation == 3 then + return require("src.world.OptionalFollower").update(game, ow) + end + if generation ~= 1 then return end if ow.pikaHop then return end -- the counter hop owns the follower (#417) if ow.pikachuBillsScene then return end local npc = findFollower(ow) if not npc then - if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end + if ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then PikachuFollower.onMapEntered(game, ow) end return end - if not shouldSpawn(game, ow) then + if not ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then remove(ow) return end @@ -681,6 +695,7 @@ local function bubbleIndex(game, name) end function PikachuFollower.talk(game, ow, npc, done) + if GameVersion.generation() ~= 1 then return false end -- pikachu_follow.asm steps the follower on the player's own walk clock, -- so it is never mid-tile while the player stands and can always be -- addressed; this port's follow is a frame late, so land the step here diff --git a/src/world/RoamMons.lua b/src/world/RoamMons.lua index 0286109bb..a04d4a846 100644 --- a/src/world/RoamMons.lua +++ b/src/world/RoamMons.lua @@ -104,6 +104,11 @@ function RoamMons.check(save, mapId, onWater, rng) -- which is the same statement. UpdateRoamMons stores the id it hopped to, -- so both sides of this are already normalised. if info.mapId ~= mapId then return nil end + local Runtime=require('src.mods.Runtime') + if Runtime.wants("roamer.encountered")then + Runtime.emit("roamer.encountered",{index=a,slot=info,species=info.species or name, + level=info.level or RoamMons.LEVEL,mapId=mapId}) + end return name, info end diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index f21419ae9..f2fddcada 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,17 +6,24 @@ -- 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 NpcControls = require("src.world.NpcControls") +local Collision = require("src.world.Collision") +local Version = require("src.core.GameVersion") +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 +65,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) @@ -362,6 +404,79 @@ function Handle:elevation() return self.npc.gen3Elevation end +-- New immediate controls retain the legacy handle and its existing methods. +-- A retained handle cannot apply these controls to a later native cohort. +local function npcCoordinate(v) + return type(v) == "number" and v == v and v >= 0 and v <= 1000000 and v % 1 == 0 +end +local function control(handle, name, a, b, c) + local context = handle._controlContext + local api, game, ow, actor = context[1], context[2], context[3], context[4] + if + not ow.map + or handle.ow ~= ow + or handle.npc ~= actor + or api.game ~= game + or game.data ~= context[5] + or game.save ~= context[6] + or Version.get() ~= context[8] + or api:overworld() ~= ow + or ow.map ~= context[7] + or ow.map.id ~= context[10] + or game.overworld ~= context[11] + or api._npcAlive and (not api._npcAlive() or Runtime.hooks ~= context[9]) + then + return nil, "NPC handle is stale" + end + local found = false + for _, candidate in ipairs(ow.npcs or {}) do + if candidate == actor then + found = true + break + end + end + if not found then + return nil, "NPC handle is stale" + end + if name == "stepNow" or name == "canStep" then + if not Collision.DELTA[a] then + return nil, "invalid direction" + end + end + if name == "placeAt" then + if not npcCoordinate(a) or not npcCoordinate(b) or c ~= nil and not Collision.DELTA[c] then + return nil, "invalid coordinates" + end + end + if name == "canStep" then + return NpcControls.canStep(actor, ow.map, ow.cast or ow.entities, a) + end + if name == "isMoving" then + return NpcControls.isMoving(actor) + end + if name == "setAppearance" then + return NpcControls.setAppearance(actor, game, a) + end + if name == "setPassable" then + return NpcControls.setPassable(actor, not not a) + end + return NpcControls[name](actor, a, b, c) +end +for _, method in ipairs({ "stepNow", "canStep", "placeAt", "isMoving", "setPassable", "setAppearance" }) do + local name = method + Handle[name] = function(self, ...) + local previous = Runtime.currentMod + Runtime.currentMod = nil + local result = { pcall(control, self, name, ...) } + Runtime.currentMod = previous + if not result[1] then + local ok, message = pcall(tostring, result[2]) + return nil, "NPC operation failed: " .. (ok and message or "unprintable error") + end + return result[2], result[3] + end +end + function WorldAPI:npc(mapId, indexOrName) local ow = self:overworld() if not ow then return nil, NO_OVERWORLD end @@ -369,7 +484,9 @@ function WorldAPI:npc(mapId, indexOrName) for _, npc in ipairs(ow.npcs or {}) do if npc.def.index == indexOrName or npc.def.name == indexOrName or npc.id == indexOrName then - return setmetatable({ ow = ow, npc = npc, id = npc.id }, Handle) + return setmetatable({ ow = ow, npc = npc, id = npc.id, + _controlContext = { self, self.game, ow, npc, self.game.data, self.game.save, + ow.map, Version.get(), Runtime.hooks, ow.map and ow.map.id, self.game.overworld } }, Handle) end end return nil, "no such object: " .. tostring(indexOrName) @@ -386,6 +503,14 @@ function WorldAPI:queueScript(rows, extra) return true end +-- Public field mutations are kept behind the same free-roam boundary. +function WorldAPI:canReorderParty() return Actions.canReorderParty(self.game, self:overworld()) end +function WorldAPI:reorderParty(fromSlot, toSlot) return Actions.reorderParty(self.game, self:overworld(), fromSlot, toSlot) end +function WorldAPI:availableFieldActions() return Actions.availableFieldActions(self.game, self:overworld()) end +function WorldAPI:useFieldAction(id, opts) return Actions.useFieldAction(self.game, self:overworld(), id, opts) end +function WorldAPI:canFly() return Actions.canFly(self.game, self:overworld()) end +function WorldAPI:flyTo(mapId) return Actions.flyTo(self.game, self:overworld(), mapId) end + -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) diff --git a/src/world/WorldActions.lua b/src/world/WorldActions.lua new file mode 100644 index 000000000..3244aa8a6 --- /dev/null +++ b/src/world/WorldActions.lua @@ -0,0 +1,471 @@ +-- Supported mutations go through the existing gameplay entry points. Shared +-- guards keep companion UIs out of screens/scripts/transitions that own input. +local GameVersion = require("src.core.GameVersion") +local Map = require("src.world.Map") +local FieldDefaults = require("src.world.FieldDefaults") +local Party = require("src.pokemon.Party") +local A = {} +local METHODS = { + canReorderParty = true, + reorderParty = true, + availableFieldActions = true, + useFieldAction = true, + canFly = true, + flyTo = true, +} +local LOCKS = { + "transitioning", + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen4Waterfall", + "gen5SeamPending", + "gen3Decorate", + "frameScriptThisTick", + "_modScriptLock", +} +local pending = setmetatable({}, { __mode = "k" }) +local planning = setmetatable({}, { __mode = "k" }) +local function integer(v, lo, hi) + return type(v) == "number" and v == v and v % 1 == 0 and v >= lo and v <= hi +end +function A.supports(name) + return type(name) == "string" and METHODS[name] == true +end + +local function ready(game, ow) + if not (game and game.save) then + return nil, "no save" + end + if not (ow and ow.map and ow.player) then + return nil, "no overworld" + end + local token = pending[game] + if token and token.state then + local held = false + for _, state in ipairs(game.stack and game.stack.states or {}) do + if state == token.state then + held = true + end + end + if game.stack and game.stack.top and game.stack:top() == token.state then + held = true + end + if + not held + or game.save ~= token.save + or game.data ~= token.data + or ow.map ~= token.map + or ow.player ~= token.player + then + pending[game] = nil + end + end + if not (game.stack and game.stack.top and game.stack:top() == ow) then + return nil, "world is busy" + end + if + pending[game] + or planning[game] + or (game.overworld and game.overworld ~= ow) + or game.linkSession + or (game.linkNet and not game.linkNet.closed) + then + return nil, "world is busy" + end + for _, key in ipairs(LOCKS) do + if ow[key] then + return nil, "world is busy" + end + end + local player = ow.player + if player.moving or player.inputLocked or player.stairExit or player.spinning or player.fieldMove then + return nil, "world is busy" + end + if not (ow.runner and type(ow.runner.isRunning) == "function") then + return nil, "world is busy" + end + if ow.runner:isRunning() or #(ow.scriptMoves or {}) > 0 or #(ow.pendingScripts or {}) > 0 then + return nil, "world is busy" + end + return true +end +-- Shared admission guard for the API-3 warp adapter. +A.ready = ready +local function guardedCall(game, fn, ...) + planning[game] = true + local result = { pcall(fn, game, ...) } + planning[game] = nil + if not result[1] then + error(result[2], 0) + end + return result[2], result[3] +end +local function party(game) + return game and game.save and game.save.party or {} +end +local function member(game, mon) + if type(mon) ~= "table" or Party.isEgg(mon) then + return nil + end + for _, p in ipairs(party(game)) do + if p == mon then + return mon + end + end +end +local function user(game, ow, move) + if type(ow.partyKnows) ~= "function" then + return nil + end + return member(game, ow:partyKnows(move)) +end +local function context(game, ow) + local p = ow.player + return { + save = game.save, + data = game.data, + map = ow.map, + player = p, + x = p.cellX, + y = p.cellY, + facing = p.facing, + version = GameVersion.get(), + } +end +local function same(game, ow, c) + return game.save == c.save + and game.data == c.data + and ow.map == c.map + and ow.player == c.player + and ow.player.cellX == c.x + and ow.player.cellY == c.y + and ow.player.facing == c.facing + and GameVersion.get() == c.version +end +local function currentWorld(game, ow) + -- A state replaced while a cut-in was active must not receive its effect. + return game.stack and game.stack.top and game.stack:top() == ow +end +local function present(game, ow, mon, check, run) + local c = context(game, ow) + local called = false + local token = { save = game.save, data = game.data, map = ow.map, player = ow.player } + pending[game] = token + local result, reason + local function finish() + if called then + return + end + called = true + if pending[game] ~= token then + return + end + pending[game] = nil + if + not currentWorld(game, ow) + or not same(game, ow, c) + or not ready(game, ow) + or not member(game, mon) + then + result, reason = nil, "world changed" + return + end + if not check() then + result, reason = nil, "action unavailable" + return + end + -- A capability hook invoked by check() may have changed the session. + if not same(game, ow, c) or not ready(game, ow) then + result, reason = nil, "world changed" + return + end + local answer = guardedCall(game, run) + if answer == false then + result, reason = nil, "action unavailable" + else + result = true + end + end + if GameVersion.generation() == 3 then + -- Never retry an engine callback that threw after invoking finish(). + local ok, shown = pcall(function() + return require("src.world.Gen3FieldMove").show(game, mon, finish) + end) + if not ok then + pending[game] = nil + error(shown, 0) + end + if shown then + token.state = game.stack:top() + return true + end + end + finish() + return result, reason +end + +function A.canReorderParty(game, ow) + return #party(game) > 1 and ready(game, ow) == true +end +function A.reorderParty(game, ow, from, to) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + local p = party(game) + if + not integer(from, 1, Party.MAX) + or not integer(to, 1, Party.MAX) + or type(p[from]) ~= "table" + or type(p[to]) ~= "table" + then + return nil, "invalid party slot" + end + -- Match the native party menus: swap references, never reconstruct mons. + if from ~= to then + p[from], p[to] = p[to], p[from] + end + return true +end + +local function outside(game, ow) + return Map.isOutside(ow.map.def, FieldDefaults.field(game.data, "outsideTilesets")) +end +local function validSpot(data, id, x, y, gen) + local def = data and data.maps and data.maps[id] + if not def or not integer(def.width, 1, 1000000) or not integer(def.height, 1, 1000000) then + return false + end + local tileset = data.tilesets and data.tilesets[def.tileset] + local cells = tileset and tileset.blockCells or (gen >= 3 and 1 or 2) + return integer(cells, 1, 1000000) + and integer(x, 0, def.width * cells - 1) + and integer(y, 0, def.height * cells - 1) +end +local function flyPlan(game, ow, mapId) + local gen = GameVersion.generation() + local data, save = game.data or {}, game.save + local mon + if gen >= 4 then + local F = require("src.world.Gen4FieldMoves") + for _, p in ipairs(party(game)) do + if not Party.isEgg(p) and F.knows(p, "FLY") then + mon = p + break + end + end + if not mon then + return nil, "fly unavailable" + end + if gen == 5 then + local Fly = require("src.world.Gen5Fly") + if not Fly.allowed(ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, loc in ipairs(Fly.destinations(data, save)) do + local dest = Fly.destination(data, loc) + if dest and (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = dest, ds = true } + end + end + else + local Fly = require("src.world.Gen4Fly") + local costume = require("src.inventory.Gen4FieldChecks").inCostume(data, save) + if costume or not Fly.check(data, save, ow.map.def) or type(ow.gen4FlyTo) ~= "function" then + return nil, "fly unavailable" + end + for _, dest in ipairs(Fly.destinations(data, save)) do + if (not mapId or dest.map == mapId) and validSpot(data, dest.map, dest.x, dest.y, gen) then + return { mon = mon, dest = { map = dest.map, x = dest.x, y = dest.y }, ds = true } + end + end + end + else + mon = user(game, ow, "FLY") + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not mon or not travel or type(ow.flyTo) ~= "function" then + return nil, "fly unavailable" + end + local field = data.field or {} + for _, id in ipairs(field.flyOrder or {}) do + local def = data.maps and data.maps[id] + local spot = field.flyWarps and field.flyWarps[id] + if + (not mapId or mapId == id) + and save.visited + and save.visited[id] + and spot + and def + and (Map.isOutdoor(def) or def.tileset == "PLATEAU") + and validSpot(data, id, spot.x, spot.y, gen) + then + return { mon = mon, dest = { map = id, x = spot.x, y = spot.y } } + end + end + end + return nil, "destination unavailable" +end +function A.canFly(game, ow) + if not ready(game, ow) then + return false + end + local c = context(game, ow) + return guardedCall(game, flyPlan, ow) ~= nil and same(game, ow, c) and ready(game, ow) == true +end +function A.flyTo(game, ow, mapId) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(mapId) ~= "string" or mapId == "" then + return nil, "invalid destination" + end + local c = context(game, ow) + local plan, why = guardedCall(game, flyPlan, ow, mapId) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, flyPlan, ow, mapId) + return fresh and fresh.mon == plan.mon and fresh.dest.x == plan.dest.x and fresh.dest.y == plan.dest.y + end, function() + if plan.ds then + return ow:gen4FlyTo(plan.dest, plan.mon) + end + return ow:flyTo(plan.dest.map, plan.mon) + end) +end + +local ORDER = { "cut", "surf", "dig", "teleport" } +local function fieldPlan(game, ow, id) + local gen = GameVersion.generation() + if gen >= 4 then + return nil, "DS field actions require a native script adapter" + end + if id == "cut" then + if gen == 3 then + return nil, "Gen 3 Cut requires an object-script adapter" + end + local mon = user(game, ow, "CUT") + if + mon + and type(ow.useCutFieldMove) == "function" + and ow:useCutFieldMove() == "ok" + and type(ow.tryCut) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:tryCut(x, y) + end, + } + end + elseif id == "surf" then + local mon = user(game, ow, "SURF") + if + mon + and not ow.player.surfing + and type(ow.useSurfFieldMove) == "function" + and ow:useSurfFieldMove() == "ok" + and type(ow.trySurf) == "function" + then + return { + mon = mon, + run = function() + local x, y = ow.player:facingCell() + return ow:trySurf(x, y, nil, mon) + end, + } + end + elseif id == "dig" or id == "teleport" then + local mon = user(game, ow, id:upper()) + if not mon or type(ow.beginTeleportOut) ~= "function" then + return nil, "field action unavailable" + end + local escape = false + if id == "teleport" then + local travel = gen == 3 and require("src.world.Gen3FieldRules").allowsTravel(ow.map.def) + or (gen < 3 and outside(game, ow)) + if not travel or not game.save.lastHeal then + return nil, "field action unavailable" + end + elseif gen == 1 then + local tiles = { FOREST = true, CEMETERY = true, CAVERN = true, FACILITY = true, INTERIOR = true } + if not tiles[ow.map.def.tileset] or ow.map.id == "AGATHAS_ROOM" or not game.save.lastHeal then + return nil, "field action unavailable" + end + else + local point = type(ow.escapePoint) == "function" and ow:escapePoint() + if not point then + return nil, "field action unavailable" + end + if gen == 3 then + if not require("src.world.Gen3FieldRules").allowsEscape(ow.map.def, point) then + return nil, "field action unavailable" + end + elseif ow.map.def.environment ~= 4 and ow.map.def.environment ~= 7 then + return nil, "field action unavailable" + end + escape = true + end + return { + mon = mon, + run = function() + return ow:beginTeleportOut(nil, { escape = escape or nil }) + end, + } + end + return nil, "field action unavailable" +end +function A.availableFieldActions(game, ow) + local ok, reason = ready(game, ow) + if not ok then + return {}, reason + end + local c = context(game, ow) + local out = {} + for _, id in ipairs(ORDER) do + if guardedCall(game, fieldPlan, ow, id) then + out[#out + 1] = { id = id, label = id:upper() } + end + if not same(game, ow, c) or not ready(game, ow) then + return {}, "world changed" + end + end + return out +end +function A.useFieldAction(game, ow, id, opts) + local ok, reason = ready(game, ow) + if not ok then + return nil, reason + end + if type(id) ~= "string" then + return nil, "invalid field action" + end + if opts ~= nil and (type(opts) ~= "table" or next(opts) ~= nil or getmetatable(opts) ~= nil) then + return nil, "unsupported field action options" + end + local c = context(game, ow) + local plan, why = guardedCall(game, fieldPlan, ow, id) + if not plan then + return nil, why + end + if not same(game, ow, c) or not ready(game, ow) then + return nil, "world changed" + end + return present(game, ow, plan.mon, function() + local fresh = guardedCall(game, fieldPlan, ow, id) + return fresh and fresh.mon == plan.mon + end, plan.run) +end +return A diff --git a/tests/api3_contract/mods/edits/main.lua b/tests/api3_contract/mods/edits/main.lua new file mode 100644 index 000000000..d26c5321e --- /dev/null +++ b/tests/api3_contract/mods/edits/main.lua @@ -0,0 +1,37 @@ +-- Identical source on both engines; native layout stays in the driver. +local mod = ... +local world = mod.world +assert(mod:supports("world", "toggleObject")) +assert(mod:supports("world", "replaceBlock")) +local ok, why = world:toggleObject("HOME", "NPC", false) +assert(ok == nil and why == "no game") +ok, why = world:replaceBlock(0, 0, 7) +assert(ok == nil and why == "no game") +local events, reenter, nested = {}, false, nil +mod.events:on("world.object_toggled", function(ev) + events[#events + 1] = { kind = "object", mapId = ev.mapId, ref = ev.objName, visible = ev.visible } + if reenter then + nested = { world:replaceBlock(0, 0, 7) } + end +end) +mod.events:on("world.block_replaced", function(ev) + events[#events + 1] = { kind = "block", mapId = ev.mapId, x = ev.bx, y = ev.by, block = ev.block } +end) +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.replace = function(...) + return world:replaceBlock(...) +end +mod.exports.read = function(...) + return world:activeBlockAt(...) +end +mod.exports.events = function() + return events +end +mod.exports.reenter = function(value) + reenter = value +end +mod.exports.nested = function() + return nested +end diff --git a/tests/api3_contract/mods/edits/manifest.json b/tests/api3_contract/mods/edits/manifest.json new file mode 100644 index 000000000..8d0912a07 --- /dev/null +++ b/tests/api3_contract/mods/edits/manifest.json @@ -0,0 +1 @@ +{"id":"edits","name":"Portable World Edits","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/npcs/main.lua b/tests/api3_contract/mods/npcs/main.lua new file mode 100644 index 000000000..696dfaafe --- /dev/null +++ b/tests/api3_contract/mods/npcs/main.lua @@ -0,0 +1,28 @@ +-- Same public-only mod on both engines. Native timing stays in the driver. +local mod = ... +local world = mod.world +for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + assert(mod:supports("world", name)) +end +local ok, why = world:npc("HOME", 1) +assert(ok == nil and why == "no game") +local calls, args = 0, nil +mod.exports.spawn = function(...) + return world:spawnNpc(...) +end +mod.exports.remove = function(...) + return world:removeNpc(...) +end +mod.exports.npc = function(...) + return world:npc(...) +end +mod.exports.toggle = function(...) + return world:toggleObject(...) +end +mod.exports.done = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.result = function() + return { calls = calls, args = args } +end diff --git a/tests/api3_contract/mods/npcs/manifest.json b/tests/api3_contract/mods/npcs/manifest.json new file mode 100644 index 000000000..16a8a3457 --- /dev/null +++ b/tests/api3_contract/mods/npcs/manifest.json @@ -0,0 +1 @@ +{"id":"npcs","name":"Portable NPC Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/portable/main.lua b/tests/api3_contract/mods/portable/main.lua new file mode 100644 index 000000000..a428b942f --- /dev/null +++ b/tests/api3_contract/mods/portable/main.lua @@ -0,0 +1,110 @@ +-- This exact entry and manifest run on both engines. No engine imports, +-- format switches or engine-specific branches belong in this fixture. +local mod = ... +local world, battle = mod.world, mod.battle +assert(type(world) == "table" and type(battle) == "table", "early handles") +local function refused(value, reason) + assert(value == nil and type(reason) == "string" and #reason > 0, tostring(reason)) +end +refused(world:party()) +refused(battle:snapshot()) +local no, why = mod:supports("world", "not_a_method") +assert(no == false and type(why) == "string") +assert(mod:supports("content", "pokemon")) +assert(mod:supports("hooks", "evolution.check")) +assert(mod:supports("events", "pokemon.received")) +assert(mod:supports("world", "party")) +-- Known API-3 names are registrable even when this host cannot execute them. +mod.hooks:wrap("render.output", function(next, ctx) + return next(ctx) +end) +mod.events:on("checkpoint.restored", function() end) +local c = mod.content +assert(c.pokemon:has("MEW")) +assert(c.maps:supports() and c.maps.available) +assert(c.maps:has("ABSENT_MAP") == false) +local map = assert(c.maps:get("PROBE")) +map.width = 99 +assert(c.maps:get("PROBE").width == 2) +for id, row in c.maps:each() do + if id == "api3Probe" then + row.value = 88 + end +end +assert(c.maps:get("PROBE").width == 2) +local before = assert(c.pokemon:get("MEW")) +assert(before.id == "MEW" and before.baseStats.hp == 100) +assert(before.level1Moves[1] == "POUND") +assert(before.baseStats.special == 100) +assert(c.moves:get("POUND").effect == "NO_ADDITIONAL_EFFECT") +before.baseStats.hp = 222 +assert(c.pokemon:get("MEW").baseStats.hp == 100, "detached read") +assert(c.pokemon:patch("MEW", { baseStats = { hp = 101 } })) +assert(c.moves:patch("POUND", { power = 45 })) +assert(c.items:patch("POTION", { price = 123 })) +local found = false +for id, row in c.pokemon:each() do + if id == "MEW" then + found = true + assert(row.baseStats.hp == 101) + end +end +assert(found) +local report = { species = "MEW", hp = 101, move = "POUND", power = 45, price = 123 } +local function monCheck(mon) + assert(mon.species == "MEW" and mon.nickname == "Scout" and mon.name == "Scout") + assert(mon.status == "PSN", "short status") + assert(type(mon.shiny) == "boolean", "shiny presentation") + assert(mon.stats.special == 24 and mon.stats.hp == 40, "common stats") + assert(mon.moves[1].id == "POUND" and mon.moves[1].pp == 30 and mon.moves[1].maxPp == 35) +end +mod.events:on("pokemon.received", function(payload) + monCheck(payload.mon) + payload.mon.hp = 1 + report.event = true +end) +mod.hooks:wrap("evolution.check", function(next, game, mon, evolution, trigger) + monCheck(mon) + mon.hp, mon.status = 27, "BRN" + mon.stats.special = 31 + report.hook = true + report.special = 31 + return next(game, mon, evolution, trigger) +end) +mod.events:on("mod.portable.probe", function(payload) + report.custom = payload.value +end) +mod.events:emit("mod.portable.probe", { value = 7 }) +mod.exports.exercise = function() + local first = assert(world:party()) + monCheck(first[1]) + first[1].hp = 1 + assert(world:party()[1].hp == 30, "detached party") + report.party = true + report.status, report.shiny = first[1].status, first[1].shiny + assert(world:getFlag("mod:contract.flag") == false) + assert(world:setFlag("mod:contract.flag", true)) + assert(world:getFlag("mod:contract.flag") == true) + assert(world:setFlag("mod:contract.flag", false)) + assert(world:getFlag("mod:contract.flag") == false) + report.flags = true +end +mod.exports.snapshot = function() + local first = assert(battle:snapshot()) + assert(first.kind == "wild" and first.prompt == "menu") + monCheck(first.player) + assert(first.player.hp == 30 and first.player.maxHp == 40) + assert(type(first.revision) == "number" and first.revision > 0) + first.player.hp = 1 + assert(battle:snapshot().player.hp == 30, "detached battle snapshot") + refused(battle:submit({ id = 1, revision = first.revision, kind = "not_an_intent" })) + report.battle = + { kind = first.kind, prompt = first.prompt, species = first.player.species, status = first.player.status } +end +mod.exports.late = function() + local ok, value, reason = pcall(function() + return c.moves:patch("POUND", { power = 99 }) + end) + assert(not ok or (value == nil and type(reason) == "string"), "frozen content") +end +mod.exports.world, mod.exports.battle, mod.exports.report = world, battle, report diff --git a/tests/api3_contract/mods/portable/manifest.json b/tests/api3_contract/mods/portable/manifest.json new file mode 100644 index 000000000..d98d5f821 --- /dev/null +++ b/tests/api3_contract/mods/portable/manifest.json @@ -0,0 +1 @@ +{"id":"portable","name":"Portable API contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/mods/rollback/main.lua b/tests/api3_contract/mods/rollback/main.lua new file mode 100644 index 000000000..daf6abe4a --- /dev/null +++ b/tests/api3_contract/mods/rollback/main.lua @@ -0,0 +1,6 @@ +local mod = ... +assert(mod.content.pokemon:patch("MEW", { baseStats = { hp = 199 } })) +mod.events:on("mod.portable.probe", function() + error("failed subscription leaked") +end) +error("intentional API 3 rollback") diff --git a/tests/api3_contract/mods/rollback/manifest.json b/tests/api3_contract/mods/rollback/manifest.json new file mode 100644 index 000000000..ab532b029 --- /dev/null +++ b/tests/api3_contract/mods/rollback/manifest.json @@ -0,0 +1 @@ +{"id":"rollback","name":"Rollback contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua","dependencies":["portable"]} diff --git a/tests/api3_contract/mods/warp/main.lua b/tests/api3_contract/mods/warp/main.lua new file mode 100644 index 000000000..2dd8de861 --- /dev/null +++ b/tests/api3_contract/mods/warp/main.lua @@ -0,0 +1,23 @@ +-- Identical public-only mod on both engines: the harness owns native timing. +local mod = ... +local world = mod.world +assert(mod:supports("world", "warpTo")) +local early, reason = world:warpTo("DEST", 0, 0) +assert(early == nil and reason == "no game") +local calls, args, events = 0, nil, {} +mod.events:on("map.entered", function(ev) + events[#events + 1] = { mapId = ev.mapId, fromMapId = ev.fromMapId, via = ev.via } +end) +mod.exports.request = function(...) + return world:warpTo(...) +end +mod.exports.notify = function(...) + calls = calls + 1 + args = { n = select("#", ...), first = (...), third = select(3, ...) } +end +mod.exports.results = function() + return { calls = calls, args = args, events = events } +end +mod.exports.current = function() + return world:current() +end diff --git a/tests/api3_contract/mods/warp/manifest.json b/tests/api3_contract/mods/warp/manifest.json new file mode 100644 index 000000000..6bc2f80ea --- /dev/null +++ b/tests/api3_contract/mods/warp/manifest.json @@ -0,0 +1 @@ +{"id":"warp","name":"Portable Warp Contract","version":"1.0.0","api":3,"games":["gen1","gen2","gen3"],"entry":"main.lua"} diff --git a/tests/api3_contract/npcs.lua b/tests/api3_contract/npcs.lua new file mode 100644 index 000000000..1e3170249 --- /dev/null +++ b/tests/api3_contract/npcs.lua @@ -0,0 +1,320 @@ +-- Real loaders/native public adapters with native actor/map/timing seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version, modId) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/npcs%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, modId or "npcs") + local Runtime = require("src.mods.Runtime") + love = love or require("tests.love_stub") + -- Render construction has no bearing on the public state contract. + local Renderer = require("src.render.SpriteRenderer") + local savedRenderer = Renderer.new + Renderer.new = function(def) + return { def = def, big = false } + end + f.restoreRenderer = function() + Renderer.new = savedRenderer + end + local game, ow = f.game, f.ow + game.data.sprites = { + PROBE = { image = "fixture.png", frames = 6, walker = true }, + ALT = { image = "alt.png", frames = 6, walker = true }, + } + game.data.gen2Sprites = game.data.sprites + ow.game, ow.sprites = game, game.data.sprites + local tiles = { + blocks = { [2] = {} }, + walkable = { 0 }, + collision = { [2] = { 0, 0, 0, 0 } }, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + for i = 1, 16 do + tiles.blocks[2][i] = 0 + end + if not (backend == "gen1" and f.gen == 2) then + tiles.collision = nil + end + for _, id in ipairs({ f.home, f.dest }) do + local def = game.data.maps[id] + def.width, def.height, def.objects, def.blocks = 8, 8, {}, {} + for i = 1, 64 do + def.blocks[i] = 1 + end + end + local base = { + index = 1, + localId = 1, + name = "IMPORTED", + sprite = "PROBE", + x = 6, + y = 6, + movement = "STAY", + range = "DOWN", + } + game.data.maps[f.home].objects = { base } + if backend == "gen1" and f.gen == 2 then + ow.map = require("src.world.gen2.Map").new(game.data.maps[f.home], tiles) + elseif not (backend == "gen1" and f.gen == 3) then + ow.map = require("src.world.Map").new(game.data.maps[f.home], tiles) + end + ow.npcs, ow.entities, ow.npcPool = {}, { f.player }, {} + f.player.cellX, f.player.cellY = 7, 7 + function f:make(id, row) + local actor + if backend == "gen1" and self.gen == 2 then + actor = require("src.world.gen2.Npc").new(id, row, game.data.sprites[row.sprite]) + elseif backend == "gen1" and self.gen == 3 then + actor = require("src.core.game3.object_prepare").instance(row, { mapId = id }) + actor.id, actor.publicId = id .. "_obj_" .. row.index, id .. "_obj_" .. row.index + else + actor = require("src.world.NPC").new(game.data, id, row) + end + function actor:queueApiMoves(_, callback) + f:begin(self, "march", nil, 1, callback) + return true + end + return actor + end + f.imported = f:make(f.home, base) + ow.npcs[1] = f.imported + ow.entities[2] = f.imported + ow.npcPool[f.imported.id] = f.imported + function f:begin(actor, kind, dir, count, callback) + self.pending = { actor = actor, kind = kind, dir = dir, count = count, callback = callback } + self.busy = true + actor.moving = true + if backend == "gen2" or self.gen == 1 then + local entry = + { entity = actor, dir = dir, remaining = count, onDone = callback, inPlace = kind == "march" } + ow.scriptMoves[#ow.scriptMoves + 1] = entry + end + if self.synchronous then + self:complete("sync", nil, "tail") + end + if self.failMovement then + return false, "movement refused" + end + return true + end + function ow:scriptMove(actor, dir, count, callback) + return f:begin(actor, "move", dir, count, callback) + end + function ow:marchInPlace(actor, callback) + return f:begin(actor, "march", nil, 1, callback) + end + function ow:beginMovement(objectId, bytes, callback) + local actor + for _, candidate in ipairs(self.npcs) do + if candidate.def.index + 1 == objectId then + actor = candidate + end + end + local dir = ({ [0x0C] = "down", [0x0D] = "up", [0x0E] = "left", [0x0F] = "right" })[bytes[1]] + return f:begin(assert(actor), "move", assert(dir), #bytes - 1, callback) + end + function ow:applySpritePalette(actor) + f.paletteActor = actor + end + function f:complete(...) + local pending = self.pending + assert(pending, "no pending move") + local actor = pending.actor + if pending.kind == "move" then + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[pending.dir] + actor.cellX, actor.cellY = + actor.cellX + delta[1] * pending.count, actor.cellY + delta[2] * pending.count + end + actor.moving, actor.marching = false, false + actor.px, actor.py = actor.cellX * 16, actor.cellY * 16 + self.busy = false + ow.scriptMoves = {} + if pending.callback then + pending.callback(...) + end + end + function f:spawn(id, row, owner) + local def = assert(game.data.maps[id]) + local index = 0 + for _, existing in ipairs(def.objects) do + index = math.max(index, existing.index) + end + row.index, row.localId, row.runtime, row.owner = index + 1, index + 1, true, owner + def.objects[#def.objects + 1] = row + local publicId = id .. "_obj_" .. row.index + if id == self.home then + local actor = self:make(id, row) + ow.npcs[#ow.npcs + 1] = actor + ow.entities[#ow.entities + 1] = actor + ow.npcPool[publicId] = actor + if self.byId then + self.byId[row.localId] = actor + self.order[#self.order + 1] = row.localId + end + end + return publicId + end + function ow:addRuntimeObject(id, row, owner) + return f:spawn(id, row, owner) + end + function f:remove(id, owner) + for _, def in pairs(game.data.maps) do + for i, row in ipairs(def.objects or {}) do + if row.runtime and def.id .. "_obj_" .. row.index == id then + if row.owner ~= owner then + return nil, "not owned by " .. tostring(owner) + end + table.remove(def.objects, i) + for _, list in ipairs({ ow.npcs, ow.entities }) do + for j = #list, 1, -1 do + if list[j].id == id then + table.remove(list, j) + end + end + end + ow.npcPool[id] = nil + if self.byId then + self.byId[row.localId] = nil + for j = #self.order, 1, -1 do + if self.order[j] == row.localId then + table.remove(self.order, j) + end + end + end + return true + end + end + end + return nil, "no runtime object " .. tostring(id) + end + function ow:removeRuntimeObject(id, owner) + return f:remove(id, owner) + end + if backend == "gen1" and f.gen == 3 then + f.byId, f.order = { [1] = f.imported }, { 1 } + package.loaded["src.core.game3.objects"] = { + _byId = f.byId, + _order = f.order, + addRuntimeObject = function(id, row, owner) + return f:spawn(id, row, owner) + end, + removeRuntimeObject = function(id, owner) + return f:remove(id, owner) + end, + scriptFace = function(actor, dir) + actor.facing = dir + end, + scriptStep = function(actor, dir) + local delta = ({ up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } })[dir] + actor.facing = dir + actor.targetX = actor.cellX + delta[1] + actor.targetY = actor.cellY + delta[2] + actor.moving = true + end, + startTrack = function(id, actions, callback) + local count = 0 + for _, action in ipairs(actions) do + if action.kind == "step" then + count = count + 1 + end + end + return f:begin(f.byId[id], count > 0 and "move" or "march", actions[1].dir, count, callback) + end, + } + package.loaded["src.core.game3.collision"] = { + canEnter = function() + return true + end, + isWater = function() + return false + end, + } + end + function f:update() + return self.loader.hooks:call("input.step", function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local f = M.fixture(root, backend, version) + local ex = f.exports + local report = { invalid = {}, actions = {} } + local function refused(value, why) + assert(value == nil and type(why) == "string" and #why > 0, tostring(why)) + end + refused(ex.spawn("UNKNOWN", { x = 0, y = 0 })) + refused(ex.spawn(f.home, { x = -1, y = 0 })) + refused(ex.spawn(f.home, { x = 0.5, y = 0 })) + refused(ex.npc(f.home, false)) + refused(ex.remove(false)) + assert(#f.game.data.maps[f.home].objects == 1 and #f.ow.npcs == 1) + local definition = { name = "RUNTIME", sprite = "PROBE", x = 0, y = 0, movement = "STAY", range = "DOWN" } + local id = assert(ex.spawn(f.home, definition)) + local handle = assert(ex.npc(f.home, id)) + assert(handle.id == id and ex.npc(f.home, "RUNTIME").id == id) + definition.x, definition.sprite = 99, "changed" + local actor = f.ow.npcPool[id] + assert(actor.cellX == 0 and actor.def.sprite == "PROBE") + for _, method in ipairs({ "scriptMove", "face", "stepNow", "canStep" }) do + refused(handle[method](handle, "north")) + end + refused(handle:scriptMove("right", 0)) + refused(handle:scriptMove("right", 1025)) + refused(handle:scriptMove("right", 1, false)) + refused(handle:marchInPlace(false)) + refused(handle:placeAt(-1, 0)) + refused(handle:setPassable("yes")) + refused(handle:setAppearance("")) + assert(handle:face("right")) + local allowed, blocked = handle:canStep("right") + assert(allowed == true, tostring(allowed) .. ": " .. tostring(blocked)) + assert(handle:setPassable(true) and actor.passable == true) + assert(handle:setAppearance("ALT")) + assert(handle:position() == 0 and handle:isMoving() == false) + assert(handle:stepNow("right")) + assert(handle:isMoving() == true and actor.targetX == 1) + refused(handle:stepNow("right")) + assert(handle:placeAt(2, 2, "up")) + assert(handle:isMoving() == false and actor.px == 32 and actor.py == 32) + local x, y = handle:position() + assert(x == 2 and y == 2) + assert(handle:scriptMove("right", 2, ex.done)) + assert(ex.result().calls == 0) + f:complete("done", nil, "tail") + x, y = handle:position() + assert(x == 4 and y == 2) + assert(ex.result().calls == 1 and ex.result().args.n == 3 and ex.result().args.third == "tail") + assert(handle:marchInPlace(ex.done)) + f:complete("march") + x, y = handle:position() + assert(x == 4 and y == 2 and ex.result().calls == 2) + assert(handle:scriptMove("left")) + f:complete() + assert(handle:position() == 3 and ex.result().calls == 2) + assert(handle:marchInPlace()) + f:complete() + assert(handle:placeAt(4, 2)) + local imported = assert(ex.npc(f.home, "IMPORTED")) + refused(ex.remove(imported.id)) + local other = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + refused(ex.remove(other)) + assert(ex.remove(id)) + refused(ex.npc(f.home, id)) + assert(ex.npc(f.home, other) and ex.npc(f.home, imported.id)) + report.actions = { + x = x, + y = y, + passable = actor.passable, + callbacks = ex.result().calls, + definitionCopied = true, + ownedRemoval = true, + } + report.invalid = true + assert(#f.loader.errors == 0) + f.restoreRenderer() + print("API3_CONTRACT " .. require("src.link.Json").encode(report)) + return report +end +return M diff --git a/tests/api3_contract/run.lua b/tests/api3_contract/run.lua new file mode 100644 index 000000000..c9fe68f51 --- /dev/null +++ b/tests/api3_contract/run.lua @@ -0,0 +1,257 @@ +-- Shared harness; only fixture construction knows the native engine layout. +local M = {} +local function read(path) + local f = assert(io.open(path, "rb")) + local body = f:read("*a") + f:close() + return body +end +local function dataFor(backend, generation) + if backend == "gen1" and generation == 3 then + local fixture = require("tests.modkit.sdk").gen3Data() + fixture.maps.PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } + return fixture + end + local stats = { hp = 100, attack = 100, defense = 100, speed = 100, special = 100 } + local mon = { + id = "MEW", + name = "MEW", + dex = 151, + types = { "PSYCHIC" }, + baseStats = stats, + catchRate = 45, + baseExp = 64, + growthRate = "MEDIUM_FAST", + spriteFront = "fixture.png", + spriteBack = "fixture.png", + frontSize = 5, + level1Moves = { "POUND" }, + learnset = {}, + evolutions = {}, + } + local effect = "NO_ADDITIONAL_EFFECT" + if generation > 1 then + stats.spatk, stats.spdef = 100, 100 + end + if backend == "gen1" and generation == 2 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 100, 100 + mon.level1Moves, mon.learnset, mon.frontSize = nil, nil, nil + mon.levelMoves, mon.picSize = { { level = 1, move = "POUND" } }, 5 + effect = "EFFECT_NORMAL_HIT" + end + local pokemon = { MEW = mon } + if backend == "gen1" and generation == 2 then + pokemon.growthRates = + { MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 } } + end + return { + pokemon = pokemon, + moves = { + POUND = { + id = "POUND", + name = "POUND", + type = "NORMAL", + power = 40, + accuracy = 100, + pp = 35, + effect = effect, + }, + }, + items = { POTION = { id = "POTION", name = "POTION", price = 300, tossable = true } }, + maps = { PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } } }, + trainers = {}, + encounters = {}, + audio = {}, + type_chart = { types = { NORMAL = {}, PSYCHIC = {} }, matchups = {} }, + gen2Maps = backend == "gen1" and generation == 2 and { + PROBE = { id = "PROBE", width = 2, height = 2, tileset = "FIX", blocks = { 1 } }, + } or nil, + } +end +local function nativeMon(backend, gen) + local stats = { hp = 40, attack = 21, defense = 22, speed = 23, special = 24 } + local mon = { + species = "MEW", + nickname = "Scout", + name = "Native name", + level = 20, + hp = 30, + status = "PSN", + stats = stats, + moves = { { id = "POUND", pp = 30, ppUps = 0 } }, + } + if gen > 1 then + stats.spatk, stats.spdef = 24, 25 + end + if backend == "gen1" and gen > 1 then + stats.special, stats.spatk, stats.spdef = nil, nil, nil + stats.specialAttack, stats.specialDefense = 24, 25 + mon.status = gen == 2 and "poison" or 8 + if gen == 3 then + mon.species, mon.moves[1].id = 151, 1 + end + end + return mon +end +function M.run(root, backend, version, failing, scaffold) + local Loader = require("src.mods.Loader") + local Runtime = require("src.mods.Runtime") + local Version = require("src.core.GameVersion") + local Json = require("src.link.Json") + Version.set(version) + local generation = Version.generation(version) + local data = dataFor(backend, generation) + local files = {} + for _, id in ipairs(failing and { "portable", "rollback" } or { "portable" }) do + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/" .. id .. "/" .. name] = read(root .. "/mods/" .. id .. "/" .. name) + end + end + local fs = { + read = function(p) + return files[p] + end, + load = function(p) + return loadstring(assert(files[p]), "@" .. p) + end, + getInfo = function(p) + if files[p] then + return { type = "file", size = #files[p] } + end + for key in pairs(files) do + if key:sub(1, #p + 1) == p .. "/" then + return { type = "directory" } + end + end + end, + getDirectoryItems = function(p) + if p == "mods" then + return failing and { "portable", "rollback" } or { "portable" } + end + return {} + end, + } + local loader = Loader.new({ fs = fs, dev = false, generation = generation, version = version }) + local game + loader._game = function(_, loadedOnly) + if backend == "gen2" then + assert(loadedOnly, "API 3 must not bootstrap Game") + end + return game + end + local ok = loader:load(data) + if scaffold then + assert(ok, table.concat(loader.errors, "; ")) + assert(loader.mods.portable.manifest.api == 3, "generated manifest selects API 3") + local power = backend == "gen1" and generation == 3 and data.gen3Moves._rom[1].power + or data.moves.POUND.power + assert(power == 45, "generated entry changed the native move") + local report = { scaffold = true, power = power } + print("API3_CONTRACT " .. Json.encode(report)) + return report + end + local errorCount = #loader.errors + local exported = assert(loader.exports.portable, table.concat(loader.errors, "; ")) + assert(exported.report, table.concat(loader.errors, "; ")) + if failing then + assert(not ok and table.concat(loader.errors, "; "):find("intentional API 3 rollback", 1, true)) + else + assert(ok, table.concat(loader.errors, "; ")) + end + local nativeHp = backend == "gen1" and generation == 3 and data.gen3Pokemon._stats[151].hp + or data.pokemon.MEW.baseStats.hp + assert(nativeHp == 101, "rollback/native merge") + assert( + (backend == "gen1" and generation == 2 and data.gen2Maps or data.maps).PROBE.width == 2, + "passthrough reads remain detached" + ) + local raw = nativeMon(backend, generation) + local save = { party = { raw }, flags = {}, modData = {}, money = 0 } + game = { data = data, save = save, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = save + end + exported.exercise() + Runtime.emit("pokemon.received", { mon = raw }) + assert(raw.hp == 30, "event read did not mutate native Pokemon") + loader.hooks:call("evolution.check", function(_, mon) + assert(mon == raw and mon.hp == 27, "native hook forwarding") + assert( + mon.status + == ( + backend == "gen1" and generation == 3 and 16 + or backend == "gen1" and generation == 2 and "burn" + or "BRN" + ), + "native status writeback" + ) + local attack = mon.stats.spatk or mon.stats.specialAttack or mon.stats.special + local defense = mon.stats.spdef or mon.stats.specialDefense or mon.stats.special + assert(attack == 31 and defense == 31, "common Special writes both later stats") + return "UNCHANGED" + end, game, raw, {}, {}) + assert(raw.hp == 27) + exported.late() + Runtime.emit("mod.portable.probe", { value = 8 }) + assert(exported.report.custom == 8, "custom callback reached live owner") + assert(#loader.errors == errorCount, "no callback errors or failed-owner subscription leaks") + if generation == 1 then + require("src.battle.TypeChart").load(data) + local player, enemy = nativeMon(backend, generation), nativeMon(backend, generation) + local function battler(mon) + return { + mon = mon, + species = mon.species, + name = mon.nickname, + curStats = mon.stats, + curTypes = { "PSYCHIC" }, + curMoves = mon.moves, + stages = {}, + } + end + local battle = { + isBattleState = true, + game = game, + data = data, + kind = "wild", + phase = "menu", + queue = {}, + player = battler(player), + enemy = battler(enemy), + enemyParty = { enemy }, + ruleset = {}, + turnCount = 0, + sides = { { battlers = {} }, { battlers = {} } }, + } + function battle:battleKind() + return "wild" + end + function battle:visibleText() + return {} + end + function battle:effectRecord() + return { accuracyChecked = true } + end + game.save.party, game.save.inventory = { player }, {} + game.stack.states = { battle } + exported.snapshot() + assert(player.hp == 30, "invalid intent never dispatched") + end + -- The entry-time world handle must follow a later Game instead of retaining the old save. + local replacement = nativeMon(backend, generation) + replacement.hp = 19 + local nextSave = { party = { replacement }, modData = {}, flags = {} } + game = { data = data, save = nextSave, stack = { states = {} } } + if backend == "gen1" and generation == 3 then + game.session = nextSave + end + assert(exported.world:party()[1].hp == 19, "captured facade follows current game") + assert(exported.battle:snapshot() == nil, "no battle") + local result = exported.report + assert(result.event and result.hook and result.party and result.custom == 8) + print("API3_CONTRACT " .. Json.encode(result)) + return result +end +M.dataFor = dataFor +return M diff --git a/tests/api3_contract/warp.lua b/tests/api3_contract/warp.lua new file mode 100644 index 000000000..5e7493d19 --- /dev/null +++ b/tests/api3_contract/warp.lua @@ -0,0 +1,388 @@ +-- Shared driver, real production loaders/adapters, native-shaped world seams. +-- No backend distinction is passed into the mod files or public assertions. +local M = {} +local unpack = table.unpack or unpack +local function read(path) + local f = assert(io.open(path, "rb")) + local value = f:read("*a") + f:close() + return value +end +function M.fixture(root, backend, version, modId) + modId = modId or "warp" + local Version = require("src.core.GameVersion") + local Runtime = require("src.mods.Runtime") + Version.set(version) + local gen = Version.generation(version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/warp%.lua$") + local data = dofile(codeRoot .. "/run.lua").dataFor(backend, gen) + local prefix = gen == 3 and (version == "emerald" and "EM_" or "FR_") or "" + local home, dest = prefix .. "HOME", prefix .. "DEST" + for _, id in ipairs({ home, dest }) do + data.maps[id] = { + id = id, + width = 2, + height = 3, + tileset = "FIX", + blocks = { 1 }, + midLayout = gen == 3 and { width = 2, height = 3 } or nil, + } + if data.gen2Maps then + data.gen2Maps[id] = data.maps[id] + end + end + local files = {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + files["mods/" .. modId .. "/" .. name] = read(root .. "/mods/" .. modId .. "/" .. name) + end + local fs = { + read = function(path) + return files[path] + end, + load = function(path) + return loadstring(assert(files[path]), "@" .. path) + end, + getInfo = function(path) + if files[path] then + return { type = "file", size = #files[path] } + end + if path == "mods" or path == "mods/" .. modId then + return { type = "directory" } + end + end, + getDirectoryItems = function(path) + return path == "mods" and { modId } or {} + end, + } + local loader = require("src.mods.Loader").new({ fs = fs, dev = false, generation = gen, version = version }) + local f = { loader = loader, gen = gen, home = home, dest = dest, trace = {}, busy = false } + loader._game = function(_, loadedOnly) + assert(backend ~= "gen2" or loadedOnly, "no Game bootstrap") + return f.live + end + assert(loader:load(data), table.concat(loader.errors, "; ")) + f.exports = assert(loader.exports[modId]) + local save = { party = {}, flags = {}, modData = {} } + local player = { cellX = 0, cellY = 0, facing = "left" } + local ow = { + isOverworld = true, + player = player, + map = { id = home, def = data.maps[home] }, + maps = data.maps, + runner = { + isRunning = function() + return false + end, + }, + scriptMoves = {}, + } + local stack = { states = { ow } } + function stack:top() + return self.states[#self.states] + end + local game = { data = data, save = save, session = save, stack = stack, phase = "field", overworld = ow } + f.game, f.live, f.ow, f.player = game, game, ow, player + function f:dispatch(id, x, y, facing, opts) + self.trace[#self.trace + 1] = { + mapId = id, + x = x, + y = y, + facing = facing, + arrive = opts.arrive, + keepMusic = opts.keepMusic, + fade = opts.fade, + } + self.callback = opts.onDone + self.destination = { id, x, y, facing } + self.busy, ow.transitioning = true, true + if self.synchronous then + self:arrive() + self:complete("sync", nil, "tail") + end + if self.failure == "throw" then + error("native warp probe", 0) + end + if self.failure == "refuse" then + return false, "warp refused" + end + return true + end + function ow:startWarpTo(id, x, y, facing, onDone, opts) + local arrive = self.arriveWarp + self.arriveWarp = nil + local ok, why = f:dispatch( + id, + x, + y, + facing, + { onDone = onDone, arrive = arrive, keepMusic = opts and opts.keepMusic, fade = opts and opts.fade } + ) + if not ok then + return ok, why + end + -- Native Gen 1/controller returns no value on successful admission. + end + function ow:acceptsMenuInput() + return not (f.busy or player.moving) + end + function ow:warpToMapId(id, x, y, facing, opts) + return f:dispatch(id, x, y, facing, opts or {}) + end + if backend == "gen1" and gen == 2 then + game.world = ow + end + if backend == "gen1" and gen == 3 then + local field = { running = true, _game = game, _session = save, _locks = {} } + f.field = field + package.loaded["src.core.game3.field"] = field + package.loaded["src.core.game3.runtime"] = { + getSession = function() + return game.session + end, + uiBusy = function() + return f.busy + end, + } + local map = { + current = home, + currentDef = function() + return data.maps[f.map.current] + end, + } + f.map = map + package.loaded["src.core.game3.map"] = map + package.loaded["src.core.game3.player"] = player + package.loaded["src.core.game3.scripting.space"] = {} + package.loaded["src.core.game3.battle.init"] = { + isActive = function() + return false + end, + } + package.loaded["src.core.game3.warp"] = { + isBusy = function() + return f.busy + end, + request = function(_, owner, id, x, y, facing, opts) + assert(owner == game, "native request retains Game") + return f:dispatch(id, x, y, facing, opts) + end, + } + end + function f:arrive(id) + local d = assert(self.destination) + local from = ow.map.id + Runtime.emit("map.exited", { mapId = from, toMapId = id or d[1] }) + ow.map = { id = id or d[1], def = data.maps[id or d[1]] } + player.cellX, player.cellY, player.facing = d[2], d[3], d[4] + if self.map then + self.map.current = ow.map.id + end + Runtime.emit("map.entered", { mapId = ow.map.id, fromMapId = from, via = "warp", map = ow.map }) + end + function f:complete(...) + self.busy, ow.transitioning = false, false + if self.callback then + self.callback(...) + end + end + function f:update() + local name = backend == "gen1" and "core.update" or "input.step" + return loader.hooks:call(name, function() + return "updated", nil, "tail" + end, game, 0) + end + return f +end +function M.run(root, backend, version) + local Runtime = require("src.mods.Runtime") + local Json = require("src.link.Json") + local report = { invalid = {}, busy = {}, successful = {} } + local function fixture() + return M.fixture(root, backend, version) + end + local f = fixture() + local function refused(args, expected) + local before = #f.trace + local ok, why = f.exports.request(unpack(args, 1, args.n or #args)) + assert(ok == nil and why == expected, tostring(why) .. " ~= " .. expected) + assert(#f.trace == before and f.ow.map.id == f.home and f.player.cellX == 0) + assert(f.ow.arriveWarp == nil and #f.exports.results().events == 0) + report.invalid[#report.invalid + 1] = why + end + refused({ false, 0, 0 }, "invalid map id") + refused({ "", 0, 0 }, "invalid map id") + refused({ "bad\nmap", 0, 0 }, "invalid map id") + refused({ string.rep("x", 257), 0, 0 }, "invalid map id") + refused({ "UNKNOWN", 0, 0 }, "unknown map: UNKNOWN") + for _, coordinate in ipairs({ -1, 0.5, math.huge, -math.huge, 0 / 0, "0", false }) do + refused({ f.dest, coordinate, 0 }, "invalid coordinates") + refused({ f.dest, 0, coordinate }, "invalid coordinates") + end + local scale = f.gen == 3 and 1 or 2 + refused({ f.dest, 2 * scale, 0 }, "coordinates out of bounds") + refused({ f.dest, 0, 3 * scale }, "coordinates out of bounds") + refused({ f.dest, 0, 0, "north" }, "invalid facing") + refused({ f.dest, 0, 0, "up", false }, "invalid options") + refused({ f.dest, 0, 0, "up", { arrive = "boat" } }, "invalid arrival") + refused({ f.dest, 0, 0, "up", { onDone = false } }, "invalid callback") + refused({ f.dest, 0, 0, "up", { keepMusic = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { fade = 1 } }, "invalid options") + refused({ f.dest, 0, 0, "up", { extra = true } }, "unknown warp option: extra") + if f.gen ~= 3 then + refused({ f.dest, 0, 0, "up", { fade = false } }, "unsupported fade") + end + for _, lock in ipairs({ "transitioning", "moving", "script", "overlay", "modscript" }) do + f = fixture() + if lock == "transitioning" then + f.ow.transitioning, f.busy = true, true + if f.field then + f.field.locked = true + end + elseif lock == "moving" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + f.busy = true + if f.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + elseif lock == "overlay" then + f.game.stack.states[2] = {} + f.busy = true + else + f.ow._modScriptLock = {} + f.busy = true + if f.field then + f.field._modScriptLock = {} + end + end + local ok, why = f.exports.request(f.dest, 0, 0) + assert(ok == nil and type(why) == "string" and #f.trace == 0, lock .. ": " .. tostring(why)) + report.busy[#report.busy + 1] = lock + end + for _, options in ipairs({ + {}, + { arrive = "fly", keepMusic = true }, + { arrive = "teleport", keepMusic = false }, + }) do + f = fixture() + options.onDone = f.exports.notify + assert(f.exports.request(f.dest, 2 * scale - 1, 3 * scale - 1, "right", options)) + assert(f.exports.results().calls == 0 and #f.trace == 1) + local expectedArrival, expectedMusic = options.arrive, options.keepMusic + options.arrive, options.keepMusic, options.onDone = + "changed", true, function() + error("mutable options") + end + assert(f.trace[1].arrive == expectedArrival and f.trace[1].keepMusic == expectedMusic) + f:arrive() + f:complete("done", nil, "tail") + f:complete("replay") + assert(f.exports.results().calls == 1 and f.exports.results().args.n == 3) + assert(f.exports.results().args.first == "done" and f.exports.results().args.third == "tail") + local location = assert(f.exports.current()) + assert(location.mapId == f.dest and location.x == 2 * scale - 1 and location.y == 3 * scale - 1) + report.successful[#report.successful + 1] = { native = f.trace[1], result = f.exports.results() } + end + f = fixture() + assert(f.exports.request(f.dest, 0, 0)) + assert(f.trace[1].facing == (f.gen == 1 and "down" or "left")) + f:arrive() + f:complete() + report.defaultFacing = f.trace[1].facing + if f.gen == 3 then + f = fixture() + assert(f.exports.request("DEST", 1, 2, "up", { fade = false, onDone = f.exports.notify })) + assert(f.trace[1].mapId == f.dest and f.trace[1].fade == false) + f:arrive() + f:complete() + report.gen3Alias = true + end + f = fixture() + f.synchronous = true + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + assert(f.exports.results().calls == 1 and f.exports.results().args.first == "sync") + report.synchronous = true + for _, change in ipairs({ "game", "save", "world", "bus", "owner", "other_map" }) do + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify })) + f:arrive() + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "save" then + local old = f.game.save + f.game.save = {} + f.game.session = f.game.save + restore = function() + f.game.save = old + f.game.session = old + end + elseif change == "world" then + if backend == "gen1" and f.gen == 3 then + local old = package.loaded["src.core.game3.field"] + package.loaded["src.core.game3.field"] = { running = true } + restore = function() + package.loaded["src.core.game3.field"] = old + end + else + local old = f.game.stack.states + f.game.stack.states = {} + f.game.overworld = nil + f.game.world = nil + restore = function() + f.game.stack.states = old + f.game.overworld = f.ow + if f.gen == 2 then + f.game.world = f.ow + end + end + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "owner" then + f.loader:_rollback("warp") + else + f:arrive(f.home) + end + f:update() + -- A stale native completion must suppress the callback permanently. + f:complete("stale") + if restore then + restore() + end + f:complete("revived") + assert(f.exports.results().calls == 0, change .. " callback survived invalidation") + end + report.stale = true + f = fixture() + assert(f.exports.request(f.dest, 0, 0, "up", { + onDone = function() + error("authored warp failure", 0) + end, + })) + f:arrive() + f:complete() + f:complete() + assert(#f.loader.errors == 1 and f.loader.errors[1]:find("warp: world warp callback failed:", 1, true)) + assert(Runtime.currentMod == nil) + report.attribution = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/api3_contract/worker.lua b/tests/api3_contract/worker.lua new file mode 100644 index 000000000..b288ae8c1 --- /dev/null +++ b/tests/api3_contract/worker.lua @@ -0,0 +1,28 @@ +-- Separate process per engine/version: modules and runtime buses cannot leak. +package.path = "./?.lua;./?/init.lua;" .. package.path +love = nil +local root, backend, version, mode = arg[1], arg[2], arg[3], arg[4] +assert( + root + and backend + and version + and ( + mode == "normal" + or mode == "rollback" + or mode == "scaffold" + or mode == "warp" + or mode == "world_edits" + or mode == "npcs" + ), + "invalid worker arguments" +) +local codeRoot = (arg[0]:gsub("\\", "/")):match("^(.*)/worker%.lua$") +if mode == "npcs" then + dofile(codeRoot .. "/npcs.lua").run(root, backend, version) +elseif mode == "world_edits" then + dofile(codeRoot .. "/world_edits.lua").run(root, backend, version) +elseif mode == "warp" then + dofile(codeRoot .. "/warp.lua").run(root, backend, version) +else + dofile(codeRoot .. "/run.lua").run(root, backend, version, mode == "rollback", mode == "scaffold") +end diff --git a/tests/api3_contract/world_edits.lua b/tests/api3_contract/world_edits.lua new file mode 100644 index 000000000..7d77c555b --- /dev/null +++ b/tests/api3_contract/world_edits.lua @@ -0,0 +1,230 @@ +-- Real loaders, adapters and host commands, with native-shaped map/render seams. +local M = {} +local unpack = table.unpack or unpack +function M.fixture(root, backend, version) + local codeRoot = (debug.getinfo(1, "S").source:sub(2)):match("^(.*)/world_edits%.lua$") + local f = dofile(codeRoot .. "/warp.lua").fixture(root, backend, version, "edits") + local Runtime = require("src.mods.Runtime") + local ow, game = f.ow, f.game + local def = game.data.maps[f.home] + local flagged = + { index = 4, localId = 4, name = "NPC", id = "npc", x = 1, y = 1, eventFlag = "FLAG_A", flag = 7 } + local flagless = { index = 8, localId = 8, name = "TEMP", x = 0, y = 1 } + def.objects = { flagged, flagless } + def.blocks = { 7, 8, 7, 8, 7, 8 } + local tiles = { + walkable = {}, + blocks = { [1] = {}, [8] = {}, [9] = {}, [256] = {} }, + collision = {}, + blockCells = f.gen == 3 and 1 or 2, + blockTiles = f.gen == 3 and 2 or 4, + } + f.flagged, f.flagless, f.visible = flagged, flagless, { NPC = true, TEMP = true } + f.rebuilds, f.syncs, f.reloads = 0, 0, 0 + if backend == "gen1" and f.gen == 2 then + -- Native Gen2 object definitions use list keys; public refs are still 1-based. + flagged.index, flagless.index, flagged.eventFlag = 1, 2, 7 + ow.map = require("src.world.gen2.Map").new(def, tiles) + elseif backend == "gen1" and f.gen == 3 then + -- Native Gen3 uses the reflected layout and field override service. + local overrides = {} + def.midLayout.midAt = function(_, x, y) + return def.blocks[y * def.width + x + 1] + end + f.field.metatileOverrideAt = function(_, x, y) + return overrides[y * 1024 + x] + end + f.field.setMetatile = function(x, y, block) + overrides[y * 1024 + x] = { metatile = block } + f.rebuilds = f.rebuilds + 1 + Runtime.emit("world.block_replaced", { mapId = f.home, bx = x, by = y, block = block }) + end + package.loaded["src.core.game3.objects"] = { + _defs = { flagged, flagless }, + addObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = true + if obj.flag then + game.save.flags[obj.flag] = false + end + end, + removeObject = function(id) + local obj = id == flagged.localId and flagged or flagless + f.visible[obj.name] = false + if obj.flag then + game.save.flags[obj.flag] = true + end + end, + } + else + ow.map = require("src.world.Map").new(def, tiles) + end + ow.map.renderer = { + rebuild = function() + f.rebuilds = f.rebuilds + 1 + end, + } + function ow:replaceBlock(x, y, block) + self.map:setBlock(x, y, block) + self.map.renderer:rebuild() + self.map.blocksDirty = nil + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:changeBlock(x, y, block) + self.map.blocks[y * self.map.width + x + 1] = block + self.map.renderer:rebuild() + Runtime.emit("world.block_replaced", { mapId = self.map.id, bx = x, by = y, block = block }) + end + function ow:appearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = true + if obj.eventFlag then + game.save.flags[obj.eventFlag] = false + end + end + function ow:disappearObject(id) + local obj = def.objects[id - 1] + f.visible[obj.name] = false + if obj.eventFlag then + game.save.flags[obj.eventFlag] = true + end + end + function ow:syncObjectVisibility(obj) + f.syncs = f.syncs + 1 + local save = game.save + local session = f.gen == 2 and save.g2ObjectToggles or save.gen3SessionObjects + local key = f.gen == 2 and obj.name or obj.index + local value = session and session.mapId == f.home and session[key] + if obj.eventFlag and save.flags and save.flags[obj.eventFlag] ~= nil then + value = not save.flags[obj.eventFlag] + end + f.visible[obj.name] = value == nil and true or value + end + function ow:setMap(id, x, y, facing, opts) + f.reloads = f.reloads + 1 + assert(id == f.home and x == 0 and y == 0 and facing == "left", "seamless refresh preserves player") + assert(opts.seamless and opts.keepMusic and opts.via == "reload") + for _, obj in ipairs(def.objects) do + local toggles = game.save.objectToggles[id] + if toggles[obj.name] ~= nil then + f.visible[obj.name] = toggles[obj.name] + end + end + end + function f:flagValue() + local key = backend == "gen1" and self.gen > 1 and 7 or "FLAG_A" + return game.save.flags[key] + end + return f +end +function M.run(root, backend, version) + local Json = require("src.link.Json") + local f = M.fixture(root, backend, version) + local report = { invalid = {}, toggles = {}, blocks = {} } + local function unchanged() + assert(#f.exports.events() == 0 and f.rebuilds == 0 and f.reloads == 0 and f.syncs == 0) + assert(next(f.game.save.flags) == nil and f.game.save.objectToggles == nil) + assert(f.visible.NPC and f.visible.TEMP and f.player.cellX == 0 and f.player.cellY == 0) + end + local function refusal(method, args, reason) + local ok, why = f.exports[method](unpack(args, 1, args.n or #args)) + assert(ok == nil and why == reason, tostring(why) .. " ~= " .. reason) + unchanged() + report.invalid[#report.invalid + 1] = why + end + for _, bad in ipairs({ false, "", "bad\nname", string.rep("x", 257) }) do + refusal("toggle", { bad, "NPC", false }, "invalid object toggle") + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0 }) do + refusal("toggle", { f.home, bad, false }, "invalid object toggle") + end + refusal("toggle", { "UNKNOWN", "NPC", false }, "unknown map: UNKNOWN") + refusal("toggle", { f.home, "NPC", "yes" }, "invalid object toggle") + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "7", false, f.gen == 3 and 1024 or 256 }) do + refusal("replace", { 0, 0, bad }, "invalid block id") + end + for _, bad in ipairs({ -1, 0.5, math.huge, 0 / 0, "0", false }) do + refusal("replace", { bad, 0, 7 }, "invalid block coordinates") + refusal("replace", { 0, bad, 7 }, "invalid block coordinates") + end + refusal("replace", { 2, 0, 7 }, "block coordinates out of bounds") + refusal("replace", { 0, 3, 7 }, "block coordinates out of bounds") + if f.gen ~= 3 then + refusal("replace", { 0, 0, 9 }, "unknown block id") + end + if f.gen > 1 then + refusal("toggle", { f.dest, "NPC", false }, "map is not active: " .. f.dest) + refusal("toggle", { f.home, "UNKNOWN", false }, "no such object: UNKNOWN") + refusal("toggle", { f.home, 0, false }, "no such object: 0") + end + for _, state in ipairs({ "overlay", "moving", "script", "transition" }) do + local other = M.fixture(root, backend, version) + if state == "overlay" then + other.game.stack.states[2] = {} + other.busy = true + elseif state == "moving" then + other.player.moving = true + elseif state == "script" then + other.busy = true + other.ow.runner.isRunning = function() + return true + end + if other.field then + package.loaded["src.core.game3.scripting.space"] = + { vm = { + isRunning = function() + return true + end, + } } + end + else + other.busy, other.ow.transitioning = true, true + if other.field then + other.field.locked = true + end + end + for _, method in ipairs({ "toggle", "replace" }) do + local ok, why + if method == "toggle" then + ok, why = other.exports.toggle(other.home, "NPC", false) + else + ok, why = other.exports.replace(0, 0, 7) + end + assert(ok == nil and type(why) == "string" and #other.exports.events() == 0) + end + end + f = M.fixture(root, backend, version) + for _, visible in ipairs({ false, true, true, false }) do + assert(f.exports.toggle(f.home, "NPC", visible)) + assert(f.visible.NPC == visible and f.visible.TEMP == true) + if f.gen > 1 then + assert(f:flagValue() == not visible) + end + report.toggles[#report.toggles + 1] = { visible = f.visible.NPC, flag = f:flagValue() } + end + -- Numeric refs retain native generation meaning: Gen1 a key, Gen2 list + -- position, Gen3 local id. A portable mod queries the active map definition. + if f.gen > 1 then + assert(f.exports.toggle(f.home, f.gen == 2 and 2 or 8, false)) + assert(not f.visible.TEMP) + assert(f.exports.toggle(f.home, "TEMP", true)) + assert(f.visible.TEMP) + else + assert(f.exports.toggle(f.dest, "LATER", false)) + assert(f.game.save.objectToggles[f.dest].LATER == false) + end + for _, op in ipairs({ { 0, 0, 8 }, { 1, 2, f.gen == 3 and 1023 or 255 }, { 0, 0, 0 } }) do + assert(f.exports.replace(unpack(op))) + local value, metadata = f.exports.read(f.home, op[1], op[2]) + assert(value == op[3] and metadata.coordinateBase == 0 and metadata.tileSize == (f.gen == 3 and 16 or 32)) + report.blocks[#report.blocks + 1] = { x = op[1], y = op[2], block = value, kind = metadata.kind } + end + assert(f.rebuilds == 3 and f.player.cellX == 0 and f.player.cellY == 0) + assert(#f.loader.errors == 0) + report.events = f.exports.events() + report.busy = true + print("API3_CONTRACT " .. Json.encode(report)) + return report +end +return M diff --git a/tests/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/battle_bottom_ui/cases.lua b/tests/battle_bottom_ui/cases.lua new file mode 100644 index 000000000..827922827 --- /dev/null +++ b/tests/battle_bottom_ui/cases.lua @@ -0,0 +1,129 @@ +local M = {} +function M.run(f) + local T, b, api, e = f.T, f.battle, f.public, f.exports + local rows = {} + local function observe(label, expected) + T.eq(f.visible(), expected, label .. " native predicate") + local drawn = f.draw() + T.eq(drawn, expected, label .. " native bottom draw") + rows[#rows + 1] = { label = label, visible = drawn } + end + if api.supports then + T.eq(api:supports("hooks", "battle.bottom_ui_visible"), true, "native capability") + end + f.stack.states = { b } + observe("native", true) + T.eq(e.seen.state, b, "original battle handle in every callback format") + T.eq(e.seen.extra, 0, "no invented arguments") + e.hide(true) + observe("hidden", false) + T.eq(f.stack:top(), b, "hidden bottom leaves native state on stack") + local polls = e.seen.calls + f.input:keypressed("z") + f.input:step() + T.eq(f.input:wasPressed("a"), true, "predicate does not consume native input") + T.eq(e.seen.calls, polls, "input does not query rendering predicate") + f.input:keyreleased("z") + f.input:step() + local text, choice = f.textBox(), f.choiceBox() + f.stack.states = { b, text, choice } + T.eq(f.inherited(text, true), false, "text inherits enclosing battle false") + T.eq(e.seen.state, b, "inherited decision receives enclosing battle") + T.eq(f.inherited(choice, false), false, "choice inherits enclosing battle false") + e.hide(false) + T.eq(f.inherited(text, true), true, "text returns when enclosing battle visible") + T.eq(e.seen.state, text, "visible battle permits individual text predicate") + local off = api.hooks:wrap("battle.bottom_ui_visible", function(next, state) + if state == text then + return false + end + return next(state) + end, 100) + T.eq(f.inherited(text, true), false, "individual text can hide without hiding battle") + T.eq(f.inherited(choice, false), true, "individual text decision does not hide choice") + off() + 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("battle.bottom_ui_visible", function() + return case.value + end, 100) + T.eq(f.visible(), true, case.name .. " result remains visible") + off() + end + off = api.hooks:wrap("battle.bottom_ui_visible", function() + return false, true + end, 100) + T.eq(f.visible(), false, "first result alone controls visibility") + off() + local replacement = { isBattle = true, contract = "replacement" } + off = api.hooks:wrap("battle.bottom_ui_visible", function(next, state) + T.eq(state, b, "outer sees original battle") + return next(replacement) + end, 100) + T.eq(f.visible(), true, "next forwards replacement predicate argument") + T.eq(e.seen.state, replacement, "downstream sees replacement identity") + off() + local order = {} + local outer = api.hooks:wrap("battle.bottom_ui_visible", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("battle.bottom_ui_visible", function(next, state) + order[#order + 1] = "inner" + T.eq(state, b, "next() retains current handle") + return next() + end, 10) + T.eq(f.visible(), true, "forwarded default true") + T.same(order, { "outer", "inner", "after" }, "descending priority") + outer() + inner() + e.hide(true) + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("battle.bottom_ui_visible", function(next) + if after then + next() + end + error("intentional bottom UI predicate failure") + end, 100) + T.eq(f.visible(), false, "wrapper failure preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("battle.bottom_ui_visible", function(next) + next() + return next() + end, 100) + polls = e.seen.calls + T.eq(f.visible(), false, "API-3 cached false") + T.eq(e.seen.calls, polls + 1, "API-3 repeated next does not replay downstream") + off() + end + e.hide(false) + observe("restored", true) + e.stop() + e.stop() + polls = e.seen.calls + observe("unsubscribed", true) + T.eq(e.seen.calls, polls, "unsubscribe is idempotent") + api.hooks:wrap("battle.bottom_ui_visible", function() + return false + end, 100) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("battle.bottom_ui_visible", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_bottom_owner") + f.result.loader.hooks:removeOwner("portable_battle_bottom_ui") + T.eq(f.visible(), true, "owner cleanup restores native visibility") + T.eq(peerCalls, 1, "owner cleanup preserves another mod") + peer() + return rows +end +return M diff --git a/tests/battle_bottom_ui/fixture.lua b/tests/battle_bottom_ui/fixture.lua new file mode 100644 index 000000000..f83de5d31 --- /dev/null +++ b/tests/battle_bottom_ui/fixture.lua @@ -0,0 +1,161 @@ +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_battle_bottom_ui/" .. path, "rb")) + files["mods/portable_battle_bottom_ui/" .. path] = file:read("*a") + file:close() + end + local Json = require("src.link.Json") + local manifest = assert(Json.decode(files["mods/portable_battle_bottom_ui/manifest.json"])) + manifest.api, manifest.callback_format = api, format + files["mods/portable_battle_bottom_ui/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_battle_bottom_ui" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local restores = {} + local function patch(owner, name, value) + local old = owner[name] + restores[#restores + 1] = function() + owner[name] = old + end + owner[name] = value + end + local counters = { ink = 0, field = 0, actors = 0, hud = 0 } + local function ink() + counters.ink = counters.ink + 1 + end + local Font = require("src.render.Font") + for _, key in ipairs({ "drawBox", "drawDialogueBox", "draw", "drawCode", "drawHPBar" }) do + patch(Font, key, ink) + end + local Input = require("src.core.Input") + Input:init() + local stack = setmetatable({}, { __index = require("src.core.StateStack") }) + stack:init() + local game = { stack = stack, input = Input, data = data, save = { options = {} }, options = {} } + local class = + require(backend == "gen1" and generation == 2 and "src.ui.gen2.BattleState" or "src.battle.BattleState") + local battle = setmetatable({ + game = game, + data = data, + save = game.save, + phase = "contract", + frame = 0, + shown = {}, + fx = {}, + battle = { player = {}, enemy = {} }, + printMessage = function() end, + }, { __index = class }) + local Ui + local visibility = require("src.battle.UIVisibility") + local drawBottom + if backend == "gen1" and generation == 2 then + local Chrome = require("src.ui.gen2.Chrome") + patch(Chrome, "box", ink) + patch(Chrome, "printThrough", ink) + drawBottom = function() + battle:drawBottom(0) + end + elseif backend == "gen1" and generation == 3 then + Ui = require("src.core.game3.battle.ui") + Ui.reset({ headless = true }) + Ui.bindState(battle, { options = {} }) + local Chrome = require("src.ui.game3.battle_chrome") + patch(Chrome, "drawPanel", ink) + patch(Chrome, "drawPostDexBg", function() + counters.field = counters.field + 1 + end) + local image = love.graphics.newImage("bottom-contract-sprite.png") + Ui._caughtDexScene = { + pal = { + fx = function() + return nil + end, + }, + sprite = { img = image, x = 0, y = 0, scaleY = 1 }, + } + local draw = love.graphics.draw + patch(love.graphics, "draw", function(img, ...) + if img == image then + counters.actors = counters.actors + 1 + end + return draw(img, ...) + end) + drawBottom = function() + Ui.draw(240, 160, true) + end + else + drawBottom = function() + battle:drawTextArea() + end + end + local TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local function textBox() + return TextBox.new(game, "PORTABLE", nil, { drawFrame = true }) + end + local function choiceBox(callback) + return ChoiceBox.new(game, callback or function() end, { noSound = true }) + end + local exports = result.loader.exports.portable_battle_bottom_ui + return { + T = T, + result = result, + game = game, + battle = battle, + stack = stack, + backend = backend, + generation = generation, + api = api, + format = format, + exports = exports, + public = exports and exports.api, + patch = patch, + input = Input, + counters = counters, + textBox = textBox, + choiceBox = choiceBox, + visible = function() + if Ui then + return Ui.bottomUIVisible() + end + return battle:bottomUIVisible() + end, + inherited = function(state, query) + if Ui then + return Ui.bottomUIVisible(state, query) + end + return visibility.bottomVisible(state, query) + end, + draw = function() + for key in pairs(counters) do + counters[key] = 0 + end + drawBottom() + return counters.ink > 0 + end, + close = function() + if Ui then + Ui.reset({ headless = true }) + end + for i = #restores, 1, -1 do + restores[i]() + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/battle_bottom_ui/worker.lua b/tests/battle_bottom_ui/worker.lua new file mode 100644 index 000000000..844290d40 --- /dev/null +++ b/tests/battle_bottom_ui/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/battle_bottom_ui/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/battle_bottom_ui/cases.lua").run(f) +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("battle.bottom_ui_visible"), false, "no-mod Runtime") +bare.T.eq(bare.visible(), true, "no-mod default true") +bare.T.eq(bare.draw(), true, "no-mod native bottom draw") +bare.close() +print("BATTLE_BOTTOM_UI_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable battle.bottom_ui_visible contract") diff --git a/tests/battle_grid/cases.lua b/tests/battle_grid/cases.lua new file mode 100644 index 000000000..8ec5ba52d --- /dev/null +++ b/tests/battle_grid/cases.lua @@ -0,0 +1,95 @@ +local M = {} +function M.run(f) + local T, e = f.T, f.exports + local rows = {} + if e.api.supports then + T.eq(e.api:supports("hooks", "battle.move_grid_navigation"), true, "native capability") + end + -- The exact same consumer drives every slot, direction and missing-cell + -- shape through the native controller. Expected values are contract data. + for _, mode in ipairs({ "grid", "list" }) do + e.mode(mode) + T.eq(f.grid(), mode == "grid", "public mode selection") + T.eq(e.seen.battle, f.handle, "original opaque battle/screen handle") + for count = 1, 4 do + f.count(count) + for index = 1, count do + for _, dir in ipairs({ "left", "right", "up", "down" }) do + f.index(index) + local expected = index + if mode == "list" then + if dir == "up" then + expected = index > 1 and index - 1 or count + end + if dir == "down" then + expected = index < count and index + 1 or 1 + end + else + if dir == "left" or dir == "right" then + expected = index % 2 == 1 and index + 1 or index - 1 + else + expected = index <= 2 and index + 2 or index - 2 + end + if expected > count then + expected = index + end + end + T.eq(f.press(dir), expected, "native " .. mode .. " " .. count .. "/" .. index .. " " .. dir) + rows[#rows + 1] = { mode = mode, count = count, index = index, direction = dir, target = f.index() } + end + end + end + end + f.count(4) + for _, row in ipairs({ {}, { value = false }, { value = 0 }, { value = "" }, { value = {} } }) do + local off = e.api.hooks:wrap("battle.move_grid_navigation", function() + return row.value + end, 100) + T.eq(f.grid(), false, "only literal true enables grid") + off() + end + e.mode("grid") + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("battle.move_grid_navigation", function(next) + if after then + next() + end + error("intentional navigation predicate failure") + end, 100) + local calls = e.seen.calls + T.eq(f.grid(), true, "failure preserves downstream grid") + T.eq(e.seen.calls, calls + 1, "failure does not replay downstream") + off() + end + if f.generation == 3 then + f.double(true) + e.mode("grid") + for count = 1, 4 do + f.count(count) + for index = 1, count do + for _, dir in ipairs({ "left", "right", "up", "down" }) do + f.index(index) + local target = index + if dir == "left" and index % 2 == 0 then + target = index - 1 + elseif dir == "right" and index % 2 == 1 then + target = index + 1 + elseif dir == "up" and index >= 3 then + target = index - 2 + elseif dir == "down" and index <= 2 then + target = index + 2 + end + if target > count then + target = index + end + T.eq(f.press(dir), target, "native GBA doubles edge and sparse row") + rows[#rows + 1] = + { mode = "double", count = count, index = index, direction = dir, target = f.index() } + end + end + end + f.double(false) + end + return rows +end +return M diff --git a/tests/battle_grid/fixture.lua b/tests/battle_grid/fixture.lua new file mode 100644 index 000000000..395d9c1ab --- /dev/null +++ b/tests/battle_grid/fixture.lua @@ -0,0 +1,96 @@ +local M = {} +function M.new(root, backend, version, api, format) + local f = dofile(root .. "/tests/battle_auxiliary/shared.lua").new( + root, + backend, + version, + api, + format, + "portable_battle_grid" + ) + local b, game = f.battle(), f.game + local Ui, screen + if backend == "gen1" and f.generation == 3 then + Ui = require("src.core.game3.battle.ui") + Ui._mode, Ui._moveIndex = "moves", 1 + f.handle = b + f.grid = function() + return Ui.moveGridNavigation() + end + f.index = function(value) + if value then + Ui._moveIndex = value + end + return Ui._moveIndex + end + f.count = function(n) + b.player.mon.moves, b.player.mon.pp, b.player.mon.maxPp = {}, {}, {} + for i = 1, n do + b.player.mon.moves[i] = 33 + b.player.mon.pp[i] = 35 + b.player.mon.maxPp[i] = 35 + end + end + f.double = function(value) + b.double = value + if value then + f.update = function() + Ui.handleInput(game.input) + end + else + f.update = function() + require("src.core.game3.battle").update(0, game) + end + end + end + elseif backend == "gen1" and f.generation == 2 then + screen = game.stack:top() + screen.phase, screen.moveIndex = "moves", 1 + f.handle = screen + f.grid = function() + return screen:moveGridNavigation() + end + f.index = function(value) + if value then + screen.moveIndex = value + end + return screen.moveIndex + end + f.count = function(n) + b.player.moves = {} + for i = 1, n do + b.player.moves[i] = "SPLASH" + end + end + else + b.phase, b.moveIndex = "moveSelect", 1 + f.handle = b + f.grid = function() + return b:moveGridNavigation() + end + f.index = function(value) + if value then + b.moveIndex = value + end + return b.moveIndex + end + f.count = function(n) + local moves = {} + for i = 1, n do + moves[i] = { id = "FIX_TACKLE", pp = 35 } + end + b.player.curMoves, b.player.mon.moves = moves, moves + end + f.double = function(value) + b.double = value + end + end + f.count(4) + f.press = function(direction) + game.input.pressed = direction and { [direction] = true } or {} + f.update() + return f.index() + end + return f +end +return M diff --git a/tests/battle_grid/worker.lua b/tests/battle_grid/worker.lua new file mode 100644 index 000000000..f2dbeae4c --- /dev/null +++ b/tests/battle_grid/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/battle_grid/fixture.lua").new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/battle_grid/cases.lua").run(f) +f.close() +print("BATTLE_GRID_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable battle.move_grid_navigation native contract") 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..7dc183f1a --- /dev/null +++ b/tests/engine/api3_compat.lua @@ -0,0 +1,235 @@ +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") + T.check(pcall(function() + m.events:on("intro.boot.copyright", function() end) + end), "API-2 accepts the additive native boot notification") +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({ + "spawnNpc", + "removeNpc", + "npc", + "warpTo", + "toggleObject", + "replaceBlock", + "party", + "current", + "activeBlockAt", + "liveMaps", + "queueScript", + "getFlag", + "setFlag", + }) do + T.eq(m:supports("world", name), true, "implemented world method " .. name) + end + for _, name in ipairs({ + "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) + for _, name in ipairs({ "spawnNpc", "removeNpc", "npc" }) do + T.eq(m:supports("world", name), false, "no shared NPC API promised for " .. version) + end + T.eq(m:supports("world", "warpTo"), false, "no shared warp promised for " .. version) + T.eq(m:supports("world", "toggleObject"), false, "no shared object edits promised for " .. version) + T.eq(m:supports("world", "replaceBlock"), false, "no shared block edits promised for " .. version) + local value, why = m.world:party() + T.eq(value, nil, "unsupported generation call refuses") + T.check(why:find("unsupported:", 1, true), "unsupported generation is explicit") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 entry compatibility") diff --git a/tests/engine/api3_entry.lua b/tests/engine/api3_entry.lua new file mode 100644 index 000000000..7eb1a2712 --- /dev/null +++ b/tests/engine/api3_entry.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.run") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, love } +love = nil +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + for _, failing in ipairs({ false, true }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version, failing) + T.check(ok, version .. (failing and " rollback: " or ": ") .. tostring(why)) + end +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, love = saved[2], saved[3], saved[4], saved[5] +T.finish("API 3 cross-engine contracts") diff --git a/tests/engine/api3_npc.lua b/tests/engine/api3_npc.lua new file mode 100644 index 000000000..f3c4844c4 --- /dev/null +++ b/tests/engine/api3_npc.lua @@ -0,0 +1,423 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.npcs") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local Renderer = require("src.render.SpriteRenderer") +local originalRenderer = Renderer.new +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +local function spawn(f) + local id = assert( + f.exports.spawn( + f.home, + { x = 0, y = 0, name = "OWN", sprite = "PROBE", movement = "STAY", range = "DOWN" } + ) + ) + return assert(f.exports.npc(f.home, id)), f.ow.npcPool[id] +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared NPC contract: " .. tostring(why)) +end +local cycle = { x = 0, y = 0 } +cycle.self = cycle +local meta = setmetatable({ x = 0, y = 0 }, {}) +local deep = { x = 0, y = 0 } +local child = deep +for _ = 1, 18 do + child.next = {} + child = child.next +end +local wide = { x = 0, y = 0 } +for i = 1, 260 do + wide[i] = i +end +for _, row in ipairs({ + cycle, + meta, + deep, + wide, + { x = 0, y = 0, handler = function() end }, + { x = math.huge, y = 0 }, + { x = 0 / 0, y = 0 }, + { x = 1000001, y = 0 }, + { x = 0, y = -1 }, + { x = 0, y = 0.5 }, + { x = 0, y = 0, name = "bad\nname" }, + { x = 0, y = 0, sprite = "missing" }, + { x = 0, y = 0, facing = "north" }, + { x = 0, y = 0, payload = string.rep("x", 4097) }, +}) do + local f = fixture() + local value, why = f.exports.spawn(f.home, row) + T.eq(value, nil, "invalid definition refused") + T.check(type(why) == "string", "definition failure explained") + T.eq(#f.game.data.maps[f.home].objects, 1, "invalid definition adds no template") + T.eq(#f.ow.npcs, 1, "invalid definition adds no actor") +end +for _, version in ipairs({ "red", "gold", "firered" }) do + for _, lock in ipairs({ "overlay", "player", "script", "link", "parallel", "controller" }) do + local f = fixture(version) + local handle, actor = spawn(f) + if lock == "overlay" then + f.game.stack.states[2] = {} + elseif lock == "player" then + f.player.moving = true + elseif lock == "script" then + f.ow.runner.isRunning = function() + return true + end + elseif lock == "link" then + f.game.linkSession = {} + elseif lock == "parallel" then + f.ow.parallelRunners = { {} } + else + f.ow._modScriptLock = {} + end + T.eq(handle:position(), 0, "busy world permits position read") + T.eq(handle:isMoving(), false, "busy world permits motion read") + T.eq(handle:face("left"), nil, lock .. " blocks mutation") + T.eq(f.exports.spawn(f.home, { x = 0, y = 0 }), nil, lock .. " blocks spawn") + T.eq(f.exports.remove(handle.id), nil, lock .. " blocks removal") + T.eq(actor.facing, "down", "refusal does not change actor") + end +end +for _, method in ipairs({ "face", "stepNow", "scriptMove", "canStep" }) do + for _, bad in ipairs({ "north", false, {}, 1 }) do + local f = fixture() + local handle = spawn(f) + local ok, why = handle[method](handle, bad) + T.eq(ok, nil, "bad direction refused for " .. method) + T.eq(why, "invalid direction", "bad direction diagnosed") + T.eq(#f.ow.scriptMoves, 0, "no queued movement on invalid direction") + end +end +for _, bad in ipairs({ 0, -1, 0.5, math.huge, 0 / 0, 1025, "1" }) do + local f = fixture() + local handle = spawn(f) + T.eq(handle:scriptMove("up", bad), nil, "invalid move count refused") + T.eq(#f.ow.scriptMoves, 0, "invalid count has no effect") +end +do + local f = fixture() + local handle, actor = spawn(f) + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + local value, why = handle:face("left") + T.eq(value, nil, "native construction cannot reenter NPC mutation") + T.eq(why, "world is busy", "NPC reentry diagnosed") + value, why = f.exports.toggle(f.home, "IMPORTED", false) + T.eq(value, nil, "NPC construction cannot reenter object edit") + T.eq(why, "world is busy", "shared mutation guard diagnosed") + return native(self, ...) + end + T.check(f.exports.spawn(f.home, { x = 2, y = 2, sprite = "PROBE" }), "outer construction succeeds") + T.eq(actor.facing, "down", "reentry leaves actor untouched") + T.check(handle:face("right"), "construction releases guard") + local constructor = Renderer.new + Renderer.new = function() + error("renderer failed", 0) + end + local def, sprite = actor.def, actor.sprite + T.eq(handle:setAppearance("ALT"), nil, "failed renderer construction contained") + T.eq(actor.def, def, "failed renderer retains original definition") + T.eq(actor.sprite, sprite, "failed renderer retains original sprite") + Renderer.new = constructor + T.check(handle:face("up"), "renderer exception releases guard") +end +for _, change in ipairs({ "game", "save", "data", "stack", "world", "map", "actor", "version", "bus" }) do + local f = fixture() + local handle, actor = spawn(f) + T.check(handle:scriptMove("right", 2, f.exports.done), "movement starts before " .. change) + local callback = f.pending.callback + local restore + if change == "game" then + local old = f.live + f.live = {} + restore = function() + f.live = old + end + elseif change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + elseif change == "world" then + local old = f.game.overworld + f.game.overworld = {} + restore = function() + f.game.overworld = old + end + elseif change == "bus" then + local old = Runtime.events + Runtime.events = require("src.mods.Events").new() + restore = function() + Runtime.events = old + end + elseif change == "actor" then + table.remove(f.ow.npcs, #f.ow.npcs) + restore = function() + f.ow.npcs[#f.ow.npcs + 1] = actor + end + else + local object = (change == "map") and f.ow or f.game + local old = object[change] + object[change] = {} + restore = function() + object[change] = old + end + end + f:update() + T.eq(#f.ow.scriptMoves, 0, "changed context retires only owned movement") + restore() + callback("stale") + callback("replayed") + T.eq(f.exports.result().calls, 0, "restored " .. change .. " cannot revive callback") +end +for _, event in ipairs({ "map.exited", "map.entered" }) do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right", 1, f.exports.done), "movement starts before map lifecycle") + local callback = f.pending.callback + Runtime.emit(event, { mapId = f.home }) + T.eq(#f.ow.scriptMoves, 0, "map lifecycle retires movement immediately") + callback() + T.eq(f.exports.result().calls, 0, "old map callback suppressed") +end +for _, action in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + local handle, actor = spawn(f) + local foreign = f:spawn(f.home, { name = "OTHER", sprite = "PROBE", x = 5, y = 5 }, "other") + T.check(handle:scriptMove("right", 1, f.exports.done), "queue before " .. action) + local callback = f.pending.callback + local foreignEntry = { + entity = f.imported, + remaining = 1, + onDone = function() + error("do not invoke") + end, + } + f.ow.scriptMoves[#f.ow.scriptMoves + 1] = foreignEntry + if action == "rollback" then + f.loader:_rollback("npcs") + elseif action == "reload" then + fixture() + else + Loader.teardown() + end + T.eq(#f.ow.scriptMoves, 1, "unload removes only owned queue entries") + T.eq(f.ow.scriptMoves[1], foreignEntry, "foreign movement retained") + T.eq(#f.game.data.maps[f.home].objects, 2, "unload removes only own runtime template") + T.eq(f.ow.npcPool[handle.id], nil, "own actor removed from native pool") + T.check(f.ow.npcPool[foreign], "foreign owner retained") + callback() + T.eq(f.exports.result().calls, 0, "unload suppresses native callback") + T.eq(handle:position(), nil, "unloaded handle retired") + T.eq(f.exports.npc(f.home, "IMPORTED"), nil, "unloaded facade retired") +end +do + local f = fixture() + local handle, actor = spawn(f) + T.check(f.exports.remove(handle.id), "own removal succeeds") + local replacement = assert(f.exports.spawn(f.home, { name = "NEW", sprite = "PROBE", x = 1, y = 1 })) + T.eq(replacement, handle.id, "native ID can be reused") + local value, why = handle:face("left") + T.eq(value, nil, "old handle cannot change replacement with same ID") + T.eq(why, "NPC handle is stale", "stale handle diagnosed") + T.eq(f.exports.npc(f.home, replacement):position(), 1, "replacement remains untouched") +end +do + local f = fixture() + local handle, actor = spawn(f) + f.synchronous = true + T.check( + handle:scriptMove("right", 1, function(...) + T.eq(Runtime.currentMod, "npcs", "movement callback attributed") + f.exports.done(...) + f.synchronous = false + T.check(handle:face("up"), "synchronous callback runs after commit guard released") + end), + "synchronous move accepted" + ) + T.eq(f.exports.result().calls, 1, "synchronous callback called once") + T.eq(f.exports.result().args.n, 3, "nil argument holes preserved") + f.pending.callback() + T.eq(f.exports.result().calls, 1, "duplicate completion ignored") + T.eq(Runtime.currentMod, nil, "callback restores runtime owner") +end +for _, err in ipairs({ + "authored failure", + setmetatable({}, { + __tostring = function() + error("hostile tostring") + end, + }), +}) do + local f = fixture() + local handle = spawn(f) + T.check( + handle:scriptMove("right", 1, function() + error(err, 0) + end), + "throwing callback admitted" + ) + T.check( + pcall(function() + f:complete() + f.pending.callback() + end), + "callback errors cannot escape native completion" + ) + T.eq(#f.loader.errors, 1, "callback error attributed once") + T.check( + f.loader.errors[1]:find("npcs: NPC movement callback failed:", 1, true), + "callback error names owner" + ) +end +do + local f = fixture() + local handle = spawn(f) + f.failMovement, f.synchronous = true, true + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "native refusal never counts as admission") + T.eq(f.exports.result().calls, 0, "callback deferred until native result") + T.eq(#f.ow.scriptMoves, 0, "native refusal leaves no owned queue entry") +end +do + local f = fixture() + local handle = spawn(f) + local native = f.ow.scriptMove + function f.ow:scriptMove(...) + Runtime.emit("map.exited", { mapId = f.home }) + return native(self, ...) + end + T.eq(handle:scriptMove("right", 1, f.exports.done), nil, "map change during admission refuses completion") + T.eq(#f.ow.scriptMoves, 0, "late-created owned entry cleaned after cancellation race") +end +do + local f = fixture() + local handle = spawn(f) + T.check(handle:scriptMove("right"), "optional count/callback accepted") + f:complete() + T.eq(handle:position(), 1, "default count is one") + T.eq(#f.loader.errors, 0, "omitted callback never treated as a callable") +end +-- Real native object creation/removal, NPC updates and movement queue. +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.addRuntimeObject, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.addRuntimeObject, i, game) + return function() + debug.setupvalue(OW.addRuntimeObject, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + f.ow.addRuntimeObject, f.ow.removeRuntimeObject = OW.addRuntimeObject, OW.removeRuntimeObject + f.ow.scriptMove, f.ow.marchInPlace = OW.scriptMove, OW.marchInPlace + local handle, actor = spawn(f) + actor.frozen = true + T.check(handle:scriptMove("right", 2, f.exports.done), version .. " native movement starts") + for _ = 1, 40 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "native queue walks exactly two cells") + T.eq(f.exports.result().calls, 1, "native completion exactly once") + T.eq(#f.ow.scriptMoves, 0, "native movement releases world input") + T.check(handle:marchInPlace(f.exports.done), "native march starts") + for _ = 1, 20 do + OW.updateScriptMoves(f.ow) + actor:update(f.ow.map, f.ow.entities) + end + T.eq(handle:position(), 2, "march does not translate") + T.eq(f.exports.result().calls, 2, "native march completes") + T.check(handle:setAppearance("ALT"), "native renderer refresh path") + T.eq(actor.def.sprite, "ALT", "actor appearance changed") + T.eq( + f.game.data.maps[f.home].objects[2].sprite, + "PROBE", + "native template data not rewritten by appearance" + ) + T.check(f.exports.remove(handle.id), "native removal succeeds") + T.eq(#f.ow.npcs, 1, "native removal retains imported actor") + restore() +end +do + local f = fixture() + local native = f.ow.addRuntimeObject + function f.ow:addRuntimeObject(...) + native(self, ...) + error("creation failure", 0) + end + T.eq(f.exports.spawn(f.home, { sprite = "PROBE", x = 0, y = 0 }), nil, "creation exception contained") + T.eq(#f.game.data.maps[f.home].objects, 1, "failed creation template rolled back") + T.eq(#f.ow.npcs, 1, "failed creation actor rolled back") + T.eq(next(f.ow.npcPool), f.imported.id, "failed creation leaves pool unchanged") +end +do + local f = fixture() + local row = + { name = "LATER", sprite = "PROBE", x = 1, y = 1, owner = "forged", runtime = false, index = 900 } + local id = assert(f.exports.spawn(f.dest, row)) + local record = f.game.data.maps[f.dest].objects[1] + T.eq(record.owner, "npcs", "caller cannot forge runtime ownership") + T.eq(record.runtime, true, "native runtime marker assigned") + T.eq(record.index, 1, "native identity replaces supplied index") + T.eq(#f.ow.npcs, 1, "inactive spawn adds no active actor") + local actor = f:make(f.dest, record) + actor.def = require("src.mods.Merge").deepCopy(record) -- Native placement/appearance clones. + local map = require("src.world.Map").new(f.game.data.maps[f.dest], f.ow.map.tileset) + f.ow.ghosts = { { npc = actor, map = map } } + f.ow.npcPool[id] = actor + local handle = assert(f.exports.npc(f.dest, id)) + T.eq(handle:position(), 1, "live neighbor uses its own coordinate frame") + T.eq(handle:canStep("right"), true, "neighbor collision uses neighbor map") + local homeHandle, homeActor = spawn(f) + homeActor.cellX, homeActor.cellY = 2, 1 + local nb = { map = map, cx = 0, cy = 0, peers = { actor } } + f.ow.neighbors = { nb } + f.ow.ghosts[1].nb = nb + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), false, "neighbor cast sees translated primary actor") + T.eq(homeHandle:canStep("left"), false, "primary cast sees translated neighbor actor") + homeActor.passable = true + actor.passable = true + require("src.world.OverworldController").updateCast(f.ow) + T.eq(handle:canStep("right"), true, "neighbor cast respects primary passability") + T.eq(homeHandle:canStep("left"), true, "primary cast respects neighbor passability") + T.check(handle:face("left"), "neighbor can face") + local ok, why = handle:scriptMove("right", 1) + T.eq(ok, nil, "neighbor track refused") + T.eq(why, "movement tracks require the active map", "neighbor restriction explicit") + Loader.teardown() + T.eq(#f.game.data.maps[f.dest].objects, 0, "inactive template cleaned on unload") + T.eq(#f.ow.ghosts, 0, "later neighbor actor clone cleaned on unload") + T.eq(f.ow.npcPool[id], nil, "neighbor pool cleaned on unload") +end +do + local f = fixture() + local handle, actor = spawn(f) + handle.id = f.imported.id + T.check(handle:face("left"), "public ID field cannot redirect private handle binding") + T.eq(actor.facing, "left", "original binding changed") + T.eq(f.imported.facing, "down", "imported actor unaffected") +end +Renderer.new = originalRenderer +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 public NPC actions") diff --git a/tests/engine/api3_playthrough_storage.lua b/tests/engine/api3_playthrough_storage.lua new file mode 100644 index 000000000..ee24e1524 --- /dev/null +++ b/tests/engine/api3_playthrough_storage.lua @@ -0,0 +1,156 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Fixture = dofile("tests/playthrough_storage/fixture.lua") +local Cases = dofile("tests/playthrough_storage/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version) + Cases.run(f, version) + dofile("tests/playthrough_storage/lifecycle.lua").run(f, version) + f.close() +end +-- Drive the actual native save/slot lifecycle, not synthetic storage handles. +for _, version in ipairs({ "red", "gold", "firered", "platinum" }) do + local f = Fixture.new(".", "gen2", version) + local S, e, fs, files = f.Save, f.e, f.fs, f.files + local function new() + return { save = S.newGame({ version = version }) } + end + local vanilla = new() + T.eq(vanilla.save.meta.playthroughId, nil, "no-mod New Game has no storage identity") + T.check(S.save(vanilla.save), "vanilla progress saves") + local loaded = assert(S.load(version)) + T.eq(loaded.meta.playthroughId, nil, "unused vanilla load remains identity-free") + local progress = {} + for path, bytes in pairs(files) do + if path:match("^save[^/]*%.lua$") then + progress[path] = bytes + end + end + local first = { save = loaded } + local id = assert(e.context(first)).playthroughId + T.check(type(id) == "string" and id ~= "", "legacy storage receives opaque identity") + for path, bytes in pairs(progress) do + T.eq(files[path], bytes, "legacy allocation never rewrites progress") + end + T.check(e.write(first, "progress/value", { n = 12 }), "legacy mod state writes") + T.check(S.saveOptions(S.defaultOptions(), fs), "stale options snapshot writes") + local persisted = S.loadOptions(fs).playthroughIds + T.eq( + persisted and persisted[version] and persisted[version].legacy, + id, + "stale option writers cannot erase a requested storage identity" + ) + S.resetSlotState() + local resumed = { save = assert(S.load(version)) } + T.eq(e.context(resumed).playthroughId, id, "legacy promotion/restart retains identity") + T.eq(e.read(resumed, "progress/value").n, 12, "legacy state survives slot migration") + T.check(S.save(resumed.save, {}), "identified progress saves") + S.resetSlotState() + resumed.save = assert(S.load(version)) + T.eq(resumed.save.meta.playthroughId, id, "normal save stamps and reloads identity") + local boot = new() + local ghost = e.context(boot).playthroughId + T.check(ghost ~= id, "boot/new-game skeleton has separate identity") + boot.stack = { states = { { screenId = "TitleState" } } } + local title = assert(e.selected(boot)) + T.eq(title:context().playthroughId, id, "init-time storage cannot replace title's real save") + T.eq(title:read("progress/value").n, 12, "title reads selected durable mod state") + T.eq(type(title:context().normalSavedAt), "number", "title includes normal save chronology") + fs.fail = ".tmp" + T.eq(S.save(boot.save, {}), false, "failed normal save remains a failed save") + fs.fail = nil + T.eq(e.selected(boot):context().playthroughId, id, "failed normal save cannot replace title identity") + T.check(S.save(boot.save, {}), "new playthrough commits") + local nextBoot = new() + nextBoot.stack = boot.stack + T.eq(e.selected(nextBoot):context().playthroughId, ghost, "committed new game becomes title selection") + local sourceId = boot.save.meta.playthroughId + local sourceSlot = S.activeSlot(version) + local copySlot = assert(S.createSlot(version)) + T.check(S.writeSlot(version, copySlot, boot.save), "identified save imports to another slot") + T.eq(boot.save.meta.playthroughId, sourceId, "import never mutates source identity") + S.setActiveSlot(version, copySlot) + local imported = { save = assert(S.load(version)) } + T.check(e.context(imported).playthroughId ~= sourceId, "duplicate slot receives distinct storage scope") + T.eq( + S.buildMeta({}, imported.save.meta).playthroughId, + imported.save.meta.playthroughId, + "mod-set stamp retains identity" + ) + local external = S.loadOptions(fs) + external.saveSlots[version].active = sourceSlot + S.saveOptions(external, fs) + local localTitle = new() + localTitle.stack = boot.stack + T.eq( + e.selected(localTitle):context().playthroughId, + imported.save.meta.playthroughId, + "another launcher's selection cannot reroute this process's native slot" + ) + S.setActiveSlot(version, copySlot) + T.check(S.deleteSlot(version, copySlot), "identified slot deletes") + local opts = S.loadOptions(fs) + T.eq(opts.playthroughIds[version][copySlot], nil, "deleted slot loses routing binding") + S.setActiveSlot(version, sourceSlot) + S.resetSlotState() + T.eq(S.load(version).meta.playthroughId, sourceId, "original slot remains unchanged") + f.close() +end +-- Identity allocation must not change the next gameplay RNG result. +do + local f = Fixture.new(".", "gen2", "red") + local game = { save = f.Save.newGame({ version = "red" }) } + math.randomseed(318) + local expected = math.random() + math.randomseed(318) + f.e.context(game) + T.eq(math.random(), expected, "storage identity never consumes gameplay RNG") + f.close() +end +-- Reject an unavailable persistence backend before attempting identity I/O. +do + local f = Fixture.new(".", "gen2", "red") + local write = f.fs.write + f.fs.write = nil + local _, code = f.e.context({ save = { version = "red" } }) + T.eq(code, "storage_unavailable", "read-only backend cannot allocate a scope") + f.fs.write = write + f.fs.fail = ".tmp" + local game = { save = f.Save.newGame({ version = "red" }) } + local context = f.e.context(game) + T.eq(context, nil, "failed identity write cannot claim durable scope") + T.eq(game.save.meta.playthroughId, nil, "failed allocation leaves no committed identity") + f.close() +end +-- API 1/2 retain the original installation-wide cache semantics and bytes. +for _, api in ipairs({ 1, 2 }) do + local f = Fixture.new(".", "gen2", "red") + local Store = require("src.mods.Storage") + local legacy = Store.new("portable_storage", f.fs) + T.check(legacy:write({}, "old/value", { n = api }), "legacy cache writes") + T.eq(legacy:read({}, "old/value").n, api, "legacy cache survives") + local Json = require("src.link.Json") + local path = "mods/portable_storage/manifest.json" + local manifest = Json.decode(f.files[path]) + manifest.api = api + f.files[path] = Json.encode(manifest) + local loaded = T.sdk.loadMods( + { "mods/portable_storage" }, + { fs = f.fs, generation = 1, data = T.fixtures.fresh(), dev = false } + ) + T.eq(#loaded.errors, 0, "legacy SDK consumer loads") + T.eq( + loaded.loader.exports.portable_storage.read({}, "old/value").n, + api, + "legacy SDK uses unchanged cache namespace" + ) + local _, code = f.e.read(f.game("new_scope"), "old/value") + T.eq(code, "not_found", "portable storage does not guess legacy cache ownership") + T.eq(legacy:read({}, "old/value").n, api, "portable reads retain legacy cache bytes") + loaded.release() + f.close() +end +Version.set(old) +T.finish("API-3 portable storage and native playthrough lifecycle") diff --git a/tests/engine/api3_storage_transactions.lua b/tests/engine/api3_storage_transactions.lua new file mode 100644 index 000000000..e873f568e --- /dev/null +++ b/tests/engine/api3_storage_transactions.lua @@ -0,0 +1,312 @@ +-- Public mod.storage contract: data-only transactions, namespace isolation, +-- deterministic listing, recovery, and failure retention. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("mod storage") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local Storage = require("src.mods.PlaythroughStorage") +local Version = require("src.core.Version") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local function manifest(id) + return ('{"id":"%s","name":"%s","version":"1.0.0",'):format(id, id) + .. '"entry":"main.lua","api":3,"profile":"content"}' +end + +local function memfs(files) + local fs = { files = files, failTmp = false, failMain = false } + + function fs.read(path) + return files[path] + end + function fs.write(path, body) + if fs.failTmp and path:sub(-4) == ".tmp" then + return false, "tmp denied" + end + if fs.failMain and (path:sub(-4) == ".lua" or path:sub(-4) == ".bin") then + return false, "main denied" + end + files[path] = body + return true + end + function fs.remove(path) + files[path] = nil + return true + end + function fs.createDirectory() + return true + end + function fs.getInfo(path) + if files[path] then + return { type = "file" } + end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + return { type = "directory" } + end + end + return nil + end + function fs.load(path) + if not files[path] then + return nil, "no file: " .. path + end + return load(files[path], path) + end + function fs.getDirectoryItems(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end + return fs +end + +local function game(version, playthroughId) + return { + save = { + version = version, + meta = { format = 4, mods = {}, playthroughId = playthroughId }, + }, + } +end + +local files = { + ["mods/alpha/manifest.json"] = manifest("alpha"), + -- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua) + ["mods/alpha/main.lua"] = [[ +return function(mod) mod.exports.storage = mod.storage end +]], + ["mods/beta/manifest.json"] = manifest("beta"), + ["mods/beta/main.lua"] = [[ +return function(mod) mod.exports.storage = mod.storage end +]], +} +local fs = memfs(files) +local loader = Loader.new({ fs = fs }) +local current = game("red", "play-a") +loader.game = current +T.check(loader:load({}) == true, "storage fixture mods load") + +local alpha = (loader.exports.alpha or {}).storage +local beta = (loader.exports.beta or {}).storage +T.check( + type(alpha) == "table" and type(beta) == "table", + "Loader exposes mod.storage through the public mod object" +) +if type(alpha) ~= "table" or type(beta) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + T.finish() +end + +-- Removing scope identity or exposing a mutable private slot id breaks this. +local context = alpha:context(current) +T.same(context, { + engineVersion = Version.engine, + gameVersion = "red", + playthroughId = "play-a", +}, "context exposes stable engine/game/playthrough compatibility identity") + +-- Data-only write/read. The literal expected table is independent of storage. +local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } } +local ok, code, message = alpha:write(current, "states/quick/q1", payload) +T.check(ok == true, "data-only payload writes: " .. tostring(code or message)) +local loaded = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "stored payload roundtrips as data") +T.check( + loaded ~= payload and loaded.nested ~= payload.nested, + "read returns decoded data rather than the caller's live table" +) + +T.check( + type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function", + "mod.storage exposes opaque byte read/write methods" +) +if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then + local binary = "MESH\0\1\255\128\nreturn _G.MOD_STORAGE_EXECUTED = true" + local binaryOk, binaryCode, binaryMessage = alpha:writeBytes(current, "states/quick/blob", binary) + T.check(binaryOk == true, "opaque bytes write exactly: " .. tostring(binaryCode or binaryMessage)) + local binaryLoaded, binaryReadCode = alpha:readBytes(current, "states/quick/blob") + T.eq(binaryLoaded, binary, "opaque bytes round-trip without text or Lua decoding") + T.eq(binaryReadCode, nil, "successful opaque byte read has no error") + T.eq(_G.MOD_STORAGE_EXECUTED, nil, "Lua-looking opaque bytes are never executed") + + local emptyOk = alpha:writeBytes(current, "binary/empty", "") + T.check(emptyOk == true, "empty opaque byte payloads are valid") + T.eq(alpha:readBytes(current, "binary/empty"), "", "empty opaque byte payloads round-trip") + + local badBytes, badBytesCode = alpha:writeBytes(current, "binary/bad-type", { byte = true }) + T.check(not badBytes and badBytesCode == "invalid_bytes", "non-string opaque payloads are rejected") + + local savedLimit = Storage.MAX_BYTES + Storage.MAX_BYTES = 4 + local tooLarge, tooLargeCode = alpha:writeBytes(current, "binary/too-large", "12345") + Storage.MAX_BYTES = savedLimit + T.check( + not tooLarge and tooLargeCode == "size_limit", + "opaque payloads over the per-key limit are rejected" + ) + + local tableConflict, tableConflictCode = alpha:writeBytes(current, "states/quick/q1", "table-key-conflict") + T.check( + not tableConflict and tableConflictCode == "type_conflict", + "bytes cannot replace a table record without deletion" + ) + local wrongType, wrongTypeCode = alpha:read(current, "states/quick/blob") + T.check( + wrongType == nil and wrongTypeCode == "type_mismatch", + "table reads identify byte records as the wrong storage type" + ) +end + +local bad, badCode = alpha:write(current, "states/bad", { callback = function() end }) +T.check(not bad and badCode == "encode_failed", "functions are rejected with a stable data-only error") + +local escaped, escapedCode = alpha:write(current, "../escape", {}) +T.check(not escaped and escapedCode == "invalid_key", "path traversal is rejected before persistence") + +-- Logical enumeration is deterministic and prefix-scoped. +T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta") +T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha") +T.check(alpha:write(current, "settings", { enabled = true }), "write settings") +local keys = alpha:list(current, "states/quick") +T.same( + keys, + { "states/quick/alpha", "states/quick/blob", "states/quick/q1", "states/quick/zeta" }, + "list returns sorted logical keys under the requested prefix" +) + +-- Mod, playthrough, and game namespaces cannot observe each other. +local missing, missingCode = beta:read(current, "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", "another mod cannot read the first mod's payload") +if type(alpha.readBytes) == "function" then + missing, missingCode = beta:readBytes(current, "states/quick/blob") + T.check( + missing == nil and missingCode == "not_found", + "another mod cannot read the first mod's opaque payload" + ) +end +missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", "another playthrough cannot read the payload") +missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1") +T.check(missing == nil and missingCode == "not_found", "another game version cannot read the payload") +if type(alpha.readBytes) == "function" then + missing, missingCode = alpha:readBytes(game("red", "play-b"), "states/quick/blob") + T.check(missing == nil and missingCode == "not_found", "another playthrough cannot read the opaque payload") + missing, missingCode = alpha:readBytes(game("blue", "play-a"), "states/quick/blob") + T.check( + missing == nil and missingCode == "not_found", + "another game version cannot read the opaque payload" + ) +end + +-- Find the implementation-owned file only to inject corruption; assertions stay +-- on public read behavior, not the path shape. +local function mainFor(fragment) + for path in pairs(files) do + if path:find(fragment, 1, true) and path:sub(-4) == ".lua" then + return path + end + end +end + +local function byteMainFor(fragment) + for path in pairs(files) do + if path:find(fragment, 1, true) and path:sub(-4) == ".bin" then + return path + end + end +end + +local q1Main = mainFor("q1") +T.check(type(q1Main) == "string", "failure fixture locates the persisted q1") +files[q1Main] = "not a serialized table" +loaded, code = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "corrupt main recovers the last verified payload") +T.eq(code, nil, "successful recovery is a normal read") + +-- A failed replacement cannot destroy the prior verified value. +T.check(alpha:write(current, "replace", { version = 1 }), "seed replace value") +fs.failTmp = true +ok, code = alpha:write(current, "replace", { version = 2 }) +fs.failTmp = false +T.check(not ok and code == "write_failed", "staging failure is reported") +T.same(alpha:read(current, "replace"), { version = 1 }, "staging failure leaves the prior value readable") + +if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then + T.check(alpha:writeBytes(current, "binary/recover", "old-bytes"), "seed opaque recovery value") + local recoverMain = byteMainFor("binary/recover") + T.check(type(recoverMain) == "string", "failure fixture locates opaque recovery data") + files[recoverMain] = nil + T.eq( + alpha:readBytes(current, "binary/recover"), + "old-bytes", + "missing opaque main recovers the last verified backup" + ) + + T.check(alpha:writeBytes(current, "binary/replace", "version-1"), "seed opaque replacement value") + fs.failTmp = true + ok, code = alpha:writeBytes(current, "binary/replace", "version-2") + fs.failTmp = false + T.check(not ok and code == "write_failed", "opaque staging failure is reported") + T.eq( + alpha:readBytes(current, "binary/replace"), + "version-1", + "opaque staging failure leaves the prior value readable" + ) + + fs.failMain = true + ok, code = alpha:writeBytes(current, "binary/replace", "version-3") + fs.failMain = false + T.check(not ok and code == "write_failed", "opaque replacement failure is reported") + T.eq( + alpha:readBytes(current, "binary/replace"), + "version-1", + "opaque replacement failure leaves the prior value readable" + ) + + local byteConflict, byteConflictCode = alpha:write(current, "binary/replace", { version = 3 }) + T.check( + not byteConflict and byteConflictCode == "type_conflict", + "tables cannot replace a byte record without deletion" + ) + + T.check(alpha:writeBytes(current, "binary/delete", "delete-me"), "seed opaque delete target") + T.check(alpha:delete(current, "binary/delete") == true, "delete removes an opaque record") + missing, missingCode = alpha:readBytes(current, "binary/delete") + T.check(missing == nil and missingCode == "not_found", "deleted opaque key is unavailable") +end + +-- Delete is exact and idempotent-not-found is explicit. +T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target") +T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor") +T.check(alpha:delete(current, "delete/me") == true, "delete removes its target") +missing, missingCode = alpha:read(current, "delete/me") +T.check(missing == nil and missingCode == "not_found", "deleted key is unavailable") +T.same(alpha:read(current, "delete/keep"), { yes = true }, "delete leaves neighboring keys untouched") + +-- No-mod parity: constructing/loading an empty loader creates no storage bytes. +local emptyFiles, emptyFs = {}, nil +emptyFs = memfs(emptyFiles) +local emptyLoader = Loader.new({ fs = emptyFs }) +emptyLoader.game = current +T.check(emptyLoader:load({}) == true, "no-mod loader still boots") +T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files") + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil + +T.finish() diff --git a/tests/engine/api3_warp.lua b/tests/engine/api3_warp.lua new file mode 100644 index 000000000..0bc8ddbb1 --- /dev/null +++ b/tests/engine/api3_warp.lua @@ -0,0 +1,249 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.warp") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared warp contract: " .. tostring(why)) +end +for _, lock in ipairs({ + "flyAnim", + "teleportOut", + "engaging", + "emote", + "pikaHop", + "healAnim", + "whirlSpin", + "gen3Decorate", + "gen3Locked", + "frameScriptThisTick", + "_modScriptLock", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "scriptMoves", + "stairExit", + "spinning", + "inputLocked", + "fieldMove", + "linkSession", + "linkNet", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "stairExit" or lock == "spinning" or lock == "inputLocked" or lock == "fieldMove" then + f.player[lock] = true + elseif lock:find("Runners") or lock:find("Queue") or lock == "pendingScripts" or lock == "scriptMoves" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, lock .. " declines admission") + T.eq(why, "world is busy", lock .. " explains busy state") + T.eq(#f.trace, 0, lock .. " never dispatches") +end +do + local f = fixture() + f.game.save = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing save refuses") + f.live = nil + T.eq(f.exports.request(f.dest, 0, 0), nil, "missing game refuses") + T.eq(#f.trace, 0, "missing context never dispatches") +end +for _, size in ipairs({ false, -1, 0, 0.5, math.huge, 0 / 0 }) do + local f = fixture() + f.game.data.maps[f.dest].width = size + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, "malformed map dimensions refuse") + T.eq(why, "invalid map dimensions", "dimensions diagnosed before native work") + T.eq(#f.trace, 0, "malformed maps never dispatch") +end +do + local f = fixture() + local opts = setmetatable({}, { + __index = function() + error("must not run") + end, + }) + local ok, why = f.exports.request(f.dest, 0, 0, "down", opts) + T.eq(ok, nil, "metatable options rejected") + T.eq(why, "invalid options", "plain options required") + T.eq(#f.trace, 0, "metatable never reaches native warp") +end +for _, change in ipairs({ "data", "stack", "player", "version" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start " .. change) + f:arrive() + local restore + if change == "version" then + Version.set("blue") + restore = function() + Version.set("red") + end + else + local obj = change == "player" and f.ow or f.game + local previous = obj[change] + obj[change] = {} + restore = function() + obj[change] = previous + end + end + f:update() + restore() + f:complete() + T.eq(f.exports.results().calls, 0, change .. " cannot revive callback after observed change") +end +for _, failure in ipairs({ "refuse", "throw" }) do + local f = fixture() + f.failure, f.synchronous = failure, true + f.ow.arriveWarp = "original" + local ok, why = f.exports.request(f.dest, 0, 0, "up", { arrive = "fly", onDone = f.exports.notify }) + T.eq(ok, nil, "native " .. failure .. " is not admitted") + T.check(type(why) == "string", "native failure explained") + T.eq(f.exports.results().calls, 0, "synchronous callback waits for native acceptance") + T.eq(f.ow.arriveWarp, "original", "failed dispatch restores borrowed arrival field") + f:complete() + T.eq(f.exports.results().calls, 0, "failed callback cannot replay") +end +do + local f = fixture() + f.synchronous = true + local ok = f.exports.request(f.dest, 0, 0, "up", { + onDone = function(...) + T.eq(Runtime.currentMod, "warp", "callback attributed to owning mod") + f.exports.notify(...) + f.synchronous = false + T.check( + f.exports.request(f.home, 1, 1, "down", { onDone = f.exports.notify }), + "callback may start next warp" + ) + end, + }) + T.check(ok, "synchronous native arrival admitted") + T.eq(#f.trace, 2, "one reentrant request dispatches once") + T.eq(Runtime.currentMod, nil, "callback restores previous runtime owner") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 2, "old completion does not clear next request") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before " .. operation) + f:arrive() + if operation == "rollback" then + f.loader:_rollback("warp") + elseif operation == "reload" then + fixture() -- A new loader/session retires every previous facade. + else + Loader.teardown() + end + f:complete() + T.eq(f.exports.results().calls, 0, operation .. " retires callback") + local ok, why = f.exports.request(f.dest, 0, 0) + T.eq(ok, nil, operation .. " retires facade") + T.eq(why, "warp facade is retired", "stale facade diagnostic") +end +for _, direction in ipairs({ "up", "down", "left", "right" }) do + local f = fixture() + T.check(f.exports.request(f.home, 1, 1, direction, { onDone = f.exports.notify }), "same-map " .. direction) + T.eq(f.trace[1].facing, direction, "direction reaches native warp unchanged") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 1, "same-map arrival not mistaken for stale completion") +end +do + local f = fixture() + T.check(f.exports.request(f.dest, 0, 0, "up", { onDone = f.exports.notify }), "start before false arrival") + f:complete() + T.eq(f.exports.results().calls, 0, "completion on old map never invokes mod") + f:arrive() + f:complete() + T.eq(f.exports.results().calls, 0, "false completion cannot be revived by later arrival") +end +-- Exercise the actual native transition; only map materialization is synthetic. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local Transition = require("src.render.Transition") +local StateStack = require("src.core.StateStack") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.startWarpTo, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.startWarpTo, i, game) + return function() + debug.setupvalue(OW.startWarpTo, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, fade in ipairs({ true, false }) do + if fade or Version.generation(version) == 3 then + local f = fixture(version) + setmetatable(f.game.stack, { __index = StateStack }) + f.ow.startWarpTo = OW.startWarpTo + local restore = bind(f.game) + function f.ow:frlgBehaviours() + return false + end + function f.ow:noteGen2Spawn(from) + self.notedFrom = from + end + function f.ow:setMap(id, x, y, facing, opts) + f.destination = { id, x, y, facing } + T.eq(opts.via, "warp", "native transition retains warp reason") + T.eq(opts.keepMusic, true, "native transition retains music request") + f:arrive() + end + local opts = { onDone = f.exports.notify, keepMusic = true } + if f.gen == 3 then + opts.fade = fade + end + T.check(f.exports.request(f.dest, 1, 2, "right", opts), "actual native warp admitted") + if fade then + local state = f.game.stack:top() + T.eq(getmetatable(state), Transition, "ordinary warp pushes native fade") + T.eq(f.exports.results().calls, 0, "callback waits for native transition") + for _ = 1, state.frames + state.framesIn + 2 do + if f.game.stack:top() == state then + state:update(1 / 60) + end + end + else + T.eq(#f.game.stack.states, 1, "no-fade warp creates no transition screen") + end + T.eq(f.ow.map.id, f.dest, "native transition arrives at destination") + T.eq(f.ow.notedFrom, f.home, "native spawn bookkeeping retained") + T.same(f.ow.warpEntryCell, { x = 1, y = 2 }, "native arrival tile guard retained") + T.eq(f.ow.transitioning, false, "native transition releases lock") + T.eq(f.exports.results().calls, 1, "actual native callback runs exactly once") + restore() + end + end +end +-- Legacy public facade continues to ignore fade, rather than opting into API 3. +do + local f = fixture("firered") + local native = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(native:warpTo(f.dest, -1, 0, "north", { fade = false }), "legacy dispatch behavior unchanged") + T.eq(f.trace[1].fade, nil, "legacy fade option remains ignored") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 portable warp") diff --git a/tests/engine/api3_world_edits.lua b/tests/engine/api3_world_edits.lua new file mode 100644 index 000000000..3ef071813 --- /dev/null +++ b/tests/engine/api3_world_edits.lua @@ -0,0 +1,241 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local Contract = require("tests.api3_contract.world_edits") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local Loader = require("src.mods.Loader") +local saved = { Version.get(), Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love } +love = nil +local function fixture(version) + return Contract.fixture("tests/api3_contract", "gen2", version or "red") +end +for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald" }) do + local ok, why = pcall(Contract.run, "tests/api3_contract", "gen2", version) + T.check(ok, version .. " shared object/block contracts: " .. tostring(why)) +end +for _, version in ipairs({ "red", "gold", "firered", "emerald" }) do + for _, key in ipairs({ "flags", "objectToggles", "g2ObjectToggles", "gen3Spawned", "gen3SessionObjects" }) do + local gen = Version.generation(version) + if + gen == 1 and (key == "objectToggles" or key == "flags") + or gen == 2 and (key == "flags" or key == "g2ObjectToggles" or key == "objectToggles") + or gen == 3 + and (key == "flags" or key == "gen3Spawned" or key == "gen3SessionObjects" or key == "objectToggles") + then + local f = fixture(version) + f.game.save[key] = "keep" + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, key .. " malformed store rejected") + T.check(type(why) == "string", "store refusal explained") + T.eq(f.game.save[key], "keep", "malformed store retained") + T.eq(#f.exports.events(), 0, "malformed store emits no event") + T.eq(f.visible.NPC, true, "malformed store leaves native object untouched") + end + end +end +for _, version in ipairs({ "red", "firered" }) do + local f = fixture(version) + local key = f.gen == 1 and "objectToggles" or "gen3Spawned" + f.game.save[key] = { [f.home] = "keep" } + T.eq(f.exports.toggle(f.home, "NPC", false), nil, "malformed nested bucket rejected") + T.eq(f.game.save[key][f.home], "keep", "nested bucket never replaced") + T.eq(#f.exports.events(), 0, "no event for malformed nested bucket") +end +for _, lock in ipairs({ + "linkSession", + "linkNet", + "gen3Locked", + "parallelRunners", + "parallelQueue", + "pendingScripts", + "_modScriptLock", +}) do + local f = fixture() + if lock == "linkSession" then + f.game.linkSession = {} + elseif lock == "linkNet" then + f.game.linkNet = { closed = false } + elseif lock == "parallelRunners" or lock == "parallelQueue" or lock == "pendingScripts" then + f.ow[lock] = { {} } + else + f.ow[lock] = true + end + T.eq(f.exports.toggle(f.home, "NPC", false), nil, lock .. " blocks objects") + T.eq(f.exports.replace(0, 0, 7), nil, lock .. " blocks blocks") + T.eq(#f.exports.events(), 0, "busy requests never emit effects") +end +for _, failure in ipairs({ + "renderer", + "coordinates", + "geometry", + "current_block", + "objects", + "object_runtime", +}) do + local f = fixture("gold") + if failure == "renderer" then + f.ow.map.renderer = nil + elseif failure == "coordinates" then + f.ow.map.def.width = 0 + elseif failure == "geometry" then + f.ow.map.blockCells, f.ow.map.blockTiles = 1, 2 + elseif failure == "current_block" then + f.ow.map.def.blocks[1] = nil + elseif failure == "objects" then + f.ow.map.def.objects = nil + else + f.ow.syncObjectVisibility = nil + end + local ok, why + if failure == "objects" or failure == "object_runtime" then + ok, why = f.exports.toggle(f.home, "NPC", false) + else + ok, why = f.exports.replace(0, 0, 7) + end + T.eq(ok, nil, failure .. " refuses safely") + T.check(type(why) == "string", "native context refusal explained") + T.eq(#f.exports.events(), 0, "invalid context emits nothing") + T.eq(f.rebuilds + f.syncs + f.reloads, 0, "invalid context performs no native work") +end +for _, operation in ipairs({ "rollback", "reload", "teardown" }) do + local f = fixture() + if operation == "rollback" then + f.loader:_rollback("edits") + elseif operation == "reload" then + fixture() + else + Loader.teardown() + end + local ok, why = f.exports.toggle(f.home, "NPC", false) + T.eq(ok, nil, operation .. " retires object action") + T.eq(why, "world facade is retired", "retired object diagnostic") + ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, operation .. " retires block action") + T.eq(why, "world facade is retired", "retired block diagnostic") + T.eq(#f.exports.events(), 0, "retired facade emits nothing") +end +do + local f = fixture() + f.exports.reenter(true) + T.check(f.exports.toggle(f.home, "NPC", false), "outer edit accepted") + T.eq(f.exports.nested()[1], nil, "event cannot reenter a partially committed edit") + T.eq(f.exports.nested()[2], "world is busy", "reentry refused explicitly") + T.eq(f.rebuilds, 0, "nested edit never redraws") + T.eq(#f.exports.events(), 1, "outer edit emits once") + f.exports.reenter(false) + T.check(f.exports.replace(0, 0, 8), "guard released after successful edit") + local rebuild = f.ow.map.renderer.rebuild + f.ow.map.renderer.rebuild = function() + error("draw probe", 0) + end + local ok, why = f.exports.replace(0, 0, 7) + T.eq(ok, nil, "native redraw failure contained") + T.check(why:find("draw probe", 1, true), "native failure explained") + f.ow.map.renderer.rebuild = rebuild + T.check(f.exports.replace(0, 0, 8), "guard released after native exception") +end +-- Packed Gen3 source reads remain observational; replacement uses instance patches. +do + local f = fixture("emerald") + f.ow.map.def.blocks = string.rep(string.char(7, 0), 6) + f.ow.map.def._blockArray = nil + f.ow.map.collisionPatch[1] = 1 + f.ow.map.shutCells = { ["0:0"] = true } + T.check(f.exports.replace(0, 0, 8), "packed metatile map writable") + T.eq(f.exports.read(f.home, 0, 0), 8, "reads see metatile patch") + T.eq(f.ow.map.def.blocks, string.rep(string.char(7, 0), 6), "generated source bytes untouched") + T.eq(f.ow.map.def._blockArray, nil, "validation does not decode/cache source") + T.eq(f.ow.map.collisionPatch[1], 0, "native default clears stale collision override") + T.eq(f.ow.map.shutCells["0:0"], nil, "native default clears stale shut cell") +end +-- Native controller methods; no actor construction or graphics are mocked. +love = require("tests.love_stub") +local OW = require("src.world.OverworldController") +local function bind(game) + for i = 1, 100 do + local name, old = debug.getupvalue(OW.syncObjectVisibility, i) + if not name then + break + end + if name == "Game" then + debug.setupvalue(OW.syncObjectVisibility, i, game) + return function() + debug.setupvalue(OW.syncObjectVisibility, i, old) + end + end + end + error("native Game upvalue missing") +end +for _, version in ipairs({ "gold", "silver", "crystal", "firered", "emerald" }) do + local f = fixture(version) + local restore = bind(f.game) + setmetatable(f.ow, { __index = OW }) + f.ow.syncObjectVisibility = OW.syncObjectVisibility + f.ow.replaceBlock = OW.replaceBlock + local function actor(def, x) + return { + id = f.home .. "_obj_" .. def.index, + def = def, + cellX = x, + cellY = 1, + px = x * 16, + py = 16, + facing = "right", + } + end + local target, sibling = actor(f.flagged, 3), actor(f.flagless, 2) + -- Distinct live actors share one persistent flag; only the named actor moves. + f.flagless.eventFlag = f.flagged.eventFlag + f.ow.npcs = { target, sibling } + f.ow.entities = { f.player, target, sibling } + f.ow.npcPool = { [target.id] = target, [sibling.id] = sibling } + T.check(f.exports.toggle(f.home, "NPC", false), version .. " actual native removal") + T.eq(#f.ow.npcs, 1, "only target removed") + T.eq(f.ow.npcs[1], sibling, "shared-flag sibling retained live") + T.eq(sibling.cellX, 2, "unrelated actor not relocated") + T.check(f.exports.toggle(f.home, "NPC", true), "actual native respawn") + T.eq(#f.ow.npcs, 2, "one actor restored") + T.eq(target.cellX, 3, "pooled position retained") + T.eq(target.facing, "right", "pooled facing retained") + f.flagless.eventFlag = nil + T.check(f.exports.toggle(f.home, "TEMP", false), "flagless object hidden for visit") + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), false, "native spawn predicate sees session hide") + f.game.save.g2ObjectToggles = nil + f.game.save.gen3SessionObjects = nil + T.eq(OW.objectVisible(f.game.save, f.home, f.flagless), true, "flagless default restored after visit reset") + T.check(f.exports.replace(1, 2, 7), "actual native block/redraw path") + T.eq(f.ow.map:blockAt(1, 2), 7, "actual Map returns patched block") + T.eq(f.ow.map.def.blocks[6], 8, "generated map record retained") + T.eq(f.ow.map.blocksDirty, nil, "native redraw consumes dirty flag") + restore() +end +for _, version in ipairs({ "firered", "emerald" }) do + local f = fixture(version) + T.check(f.exports.toggle("HOME", "NPC", false), "unprefixed Gen3 active map resolves") + T.eq(f.exports.events()[1].mapId, f.home, "event reports resolved native map ID") +end +do + local f = fixture("gold") + require("src.script.Gen2Commands") + local Commands = require("src.script.Commands") + Commands.g2ResetObjectBase() + local ctx = { game = f.game, save = f.game.save, overworld = f.ow } + Commands.g2_object(ctx, 2, true) -- Cache the native script numbering first. + f.game.data.field = { objectScriptBase = 1 } + T.check(f.exports.toggle(f.home, 1, false), "public slot resolves independently of cached script IDs") + T.eq(f.visible.NPC, false, "current native template receives visibility effect") + Commands.g2_object(ctx, 1, true) + T.eq(f.visible.NPC, false, "public adapter leaves existing script-ID cache unchanged") + Commands.g2ResetObjectBase() +end +-- Existing native/API-2 facade retains its own coercion/validation behavior. +do + local f = fixture() + local api = require("src.world.WorldAPI").new(f.game, "legacy") + T.check(api:toggleObject("UNKNOWN", "missing", "truthy"), "legacy off-map toggle unchanged") + T.eq(f.game.save.objectToggles.UNKNOWN.missing, true, "legacy boolean coercion unchanged") +end +Version.set(saved[1]) +Runtime.events, Runtime.hooks, Runtime.errors, Runtime.currentMod, love = + saved[2], saved[3], saved[4], saved[5], saved[6] +T.finish("API 3 object and block edits") diff --git a/tests/engine/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..2502dc60e --- /dev/null +++ b/tests/engine/battle_auxiliary.lua @@ -0,0 +1,131 @@ +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") + 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_bottom_ui.lua b/tests/engine/battle_bottom_ui.lua new file mode 100644 index 000000000..ab44a6a05 --- /dev/null +++ b/tests/engine/battle_bottom_ui.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/battle_bottom_ui/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/battle_bottom_ui/cases.lua").run(f) +f.close() +f.T.finish("portable battle.bottom_ui_visible") diff --git a/tests/engine/battle_bottom_ui_composition.lua b/tests/engine/battle_bottom_ui_composition.lua new file mode 100644 index 000000000..b539665b1 --- /dev/null +++ b/tests/engine/battle_bottom_ui_composition.lua @@ -0,0 +1,132 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/battle_bottom_ui/fixture.lua") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, layout in ipairs({ "classic", "wide" }) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game = f.game + game.save = SaveData.newGame() + game.save.options.battleLayout = layout + game.save.party = { Pokemon.new(game.data, "FIXMON_A", 20) } + f.result.loader.game = game + Font.load(game.data) + local b = Battle.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.frame, b.queue = "messages", 0, {} + b.current, b.shown, b.fx = {}, {}, {} + b.animPlaying = true + b.animPlayer = { stepIndex = 1, steps = { { sprites = {} } } } + game.stack.states = { b } + local marks = {} + local function mark(name) + marks[name] = (marks[name] or 0) + 1 + end + f.patch(b, "drawPicsLayer", function() + mark("actors") + end) + f.patch(b, "drawHUDs", function() + mark("hud") + end) + f.patch(b, "drawAnimLayer", function() + mark("animation") + end) + f.patch(b, "drawBattleField", function() + mark("field") + end) + f.patch(b, "colorMode", function() + return false + end) + local rectangle = love.graphics.rectangle + f.patch(love.graphics, "rectangle", function(mode, x, y, w, h, ...) + if mode == "fill" and w >= 160 and h >= 120 then + mark("field") + end + return rectangle(mode, x, y, w, h, ...) + end) + for _, name in ipairs({ "drawBox", "drawDialogueBox" }) do + f.patch(Font, name, function(tx, ty) + if ty >= 12 then + mark("bottom") + end + end) + end + local Hud = require("src.render.HudTiles") + f.patch(Hud, "tile", function() + mark("hud") + end) + f.patch(Hud, "drawHPBar", function() + mark("hud") + end) + local G4 = require("src.battle.Gen4Battle") + f.patch(G4, "drawBattlers", function() + mark("actors") + end) + f.patch(G4, "drawHealthboxes", function() + mark("hud") + return true + end) + f.patch(G4, "drawParticles", function() + mark("animation") + end) + local function frame(hidden) + f.exports.hide(hidden) + marks = {} + b:draw() + local out = {} + for key, value in pairs(marks) do + out[key] = value + end + return out + end + local native, hidden = frame(false), frame(true) + T.check((native.bottom or 0) > 0, version .. "/" .. layout .. " native bottom backing exists") + T.eq(hidden.bottom or 0, 0, version .. "/" .. layout .. " actual compositor omits bottom backing") + for _, part in ipairs({ "field", "actors", "hud" }) do + T.check((native[part] or 0) > 0, part .. " exists before hiding") + T.eq(hidden[part], native[part], part .. " dispatch unchanged when hiding bottom") + end + T.eq(hidden.animation, native.animation, "animation dispatch unchanged") + -- Palette-canvas branch uses the same bottom gate as the flat GB path. + if f.generation <= 2 and layout == "classic" then + b.colorMode = function() + return true + end + f.patch(b, "drawZonePass", function() + mark("palette") + end) + local visibleColor, hiddenColor = frame(false), frame(true) + T.check((visibleColor.bottom or 0) > 0, "palette compositor has native bottom") + T.eq(hiddenColor.bottom or 0, 0, "palette compositor hides bottom") + T.eq(hiddenColor.palette, visibleColor.palette, "palette pass is preserved") + T.eq(hiddenColor.actors, visibleColor.actors, "palette actor pass is preserved") + end + -- Exercise the real battle update with identical inputs in both modes. + b.animPlayer, b.animPlaying, b.current = nil, false, nil + b.phase, b.menuIndex, b.gen4CursorActive = "menu", 1, true + local function move(hiddenBottom) + f.exports.hide(hiddenBottom) + b.menuIndex, b.frame, b.gen4MenuColumn = 1, 0, nil + f.input:reset() + f.input:keypressed("down") + f.input:step() + local polls = f.exports.seen.calls + b:update(1 / 60) + T.eq(f.exports.seen.calls, polls, "native update never queries draw predicate") + return b.menuIndex, b.frame + end + local index, ticks = move(false) + local hiddenIndex, hiddenTicks = move(true) + T.neq(index, 1, "native menu handles directional input") + T.eq(hiddenIndex, index, "hidden native menu handles same input") + T.eq(hiddenTicks, ticks, "hidden native battle advances same logic clock") + f.close() + end +end +Version.set(originalVersion) +T.finish("battle.bottom_ui_visible native composition") diff --git a/tests/engine/battle_bottom_ui_routing.lua b/tests/engine/battle_bottom_ui_routing.lua new file mode 100644 index 000000000..aa7594d48 --- /dev/null +++ b/tests/engine/battle_bottom_ui_routing.lua @@ -0,0 +1,203 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/battle_bottom_ui/fixture.lua") +local Cases = dofile("tests/battle_bottom_ui/cases.lua") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Loader = require("src.mods.Loader") +local Battle = require("src.battle.BattleState") +local TextBox = require("src.render.TextBox") +local Font = require("src.render.Font") +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, b, g, stack = f.exports, f.battle, f.game, f.stack + T.eq(b.isBattleState, true, "real Gen2 prototype supplies battle classification") + T.eq(rawget(b, "isBattle"), nil, "inheritance does not rely on a fabricated isBattle marker") + local anchors = 0 + g.renderer = { + setUIAnchor = function() + anchors = anchors + 1 + end, + } + local text, choice = f.textBox(), f.choiceBox() + stack.states = { b, text, choice } + e.hide(true) + f.counters.ink = 0 + text:draw() + choice:draw() + T.eq(f.counters.ink, 0, "hidden battle suppresses actual text/choice backings and glyphs") + T.eq(anchors, 0, "hidden overlays do not leave a render anchor") + T.eq(e.seen.state, b, "inherited overlay decision queries native enclosing battle") + e.hide(false) + text:draw() + choice:draw() + T.check(f.counters.ink > 0, "unhidden actual text/choice draws again") + T.check(anchors > 0, "unhidden text resumes native anchoring") + -- A field choice has no battle owner and must never become a hook state. + stack.states = { text, choice } + local polls = e.seen.calls + f.counters.ink = 0 + e.hide(true) + choice:draw() + T.check(f.counters.ink > 0, "field choice remains visible under an active bottom predicate") + T.eq(e.seen.calls, polls, "field choice does not query battle hook") + text:draw() + T.eq(e.seen.state, text, "field text retains its individual predicate contract") + -- A battle above a text state is not its owner. + stack.states = { text, b, choice } + e.hide(false) + local off = f.public.hooks:wrap("battle.bottom_ui_visible", function(next, state) + if state == b then + return false + end + return next(state) + end, 100) + T.eq( + require("src.battle.UIVisibility").bottomVisible(text, true), + true, + "battle above a text state is not inherited" + ) + off() + -- Native hidden YES/NO input still completes its own delayed callback. + local answer + choice = f.choiceBox(function(value) + answer = value + end) + stack.states = { b, text, choice } + e.hide(true) + f.input:keypressed("z") + f.input:step() + polls = e.seen.calls + stack:update(1 / 60) + f.input:keyreleased("z") + f.input:step() + for _ = 1, 40 do + if stack:top() == choice then + stack:update(1 / 60) + end + end + T.eq(answer, true, "hidden native YES/NO confirms through real input and hold frames") + T.eq(stack:top(), text, "hidden choice pops exactly itself") + T.eq(e.seen.calls, polls, "native choice update never queries rendering predicate") + -- Hidden typewriter text remains a live native foreground state. + text = f.textBox() + stack.states = { b, text } + local chars = text.charIndex or 0 + polls = e.seen.calls + for _ = 1, 60 do + stack:update(1 / 60) + end + T.check((text.charIndex or 0) > chars or text.done, "hidden native text continues typing") + T.eq(e.seen.calls, polls, "native typewriter update never queries draw predicate") + -- No menu-row scissor may remain after the corresponding UI is hidden. + local clips = 0 + f.patch(love.graphics, "getScissor", function() + return nil + end) + f.patch(love.graphics, "intersectScissor", function() + clips = clips + 1 + end) + for _, phase in ipairs({ "moveSelect", "mimicSelect" }) do + b.phase = phase + local before = clips + e.hide(false) + b:drawPicsLayer(0, 0, 0) + T.eq(clips, before + 1, "visible native move menu clips its rows") + before = clips + e.hide(true) + b:drawPicsLayer(0, 0, 0) + T.eq(clips, before, "hidden native move menu removes its own clip") + e.hide(false) + b:drawPicsLayer(0, 0, 0, nil, true) + T.eq(clips, before, "explicit skipMenuClip retains native behavior") + end + b.phase = "contract" + e.hide(false) + 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 == "battle.bottom_ui_visible" then + calls = calls + 1 + end + return originalCall(name, ...) +end +T.eq(Runtime.wantsHook("battle.bottom_ui_visible"), false, "no-mod runtime") +T.eq(bare.visible(), true, "no-mod default") +T.eq(bare.draw(), true, "no-mod actual bottom draw") +T.eq(calls, 0, "unsubscribed path never dispatches Runtime.call") +Runtime.call = originalCall +bare.close() +-- Preserve the pre-existing private claim API while adding the public hook. +local legacy = Fixture.new(".", "gen2", "red", 3, "unified") +local Visibility = require("src.battle.UIVisibility") +Visibility.reset() +T.eq(Visibility.claimBottom(nil, function() end), false, "nil legacy owner rejected") +T.eq(Visibility.claimBottom("bad", false), false, "non-callable legacy claim rejected") +local queried, queryKind +T.eq( + Visibility.claimBottom("legacy_owner", function(state, isTextBox) + queried, queryKind = state, isTextBox + return false + end), + true, + "legacy claim accepted" +) +local polls = legacy.exports.seen.calls +T.eq(legacy.visible(), false, "legacy denial wins over public default true") +T.eq(legacy.exports.seen.calls, polls, "legacy denial bypasses hook") +T.eq(queried, legacy.battle, "legacy claim receives original state") +T.eq(queryKind, true, "legacy predicate receives original kind flag") +T.same(Visibility.bottomClaimants(), { "legacy_owner" }, "claimant introspection preserved") +local owners = Visibility.bottomClaimants() +owners[1] = "changed" +T.same(Visibility.bottomClaimants(), { "legacy_owner" }, "claimant result is detached") +T.eq( + Visibility.claimBottom("legacy_owner", function() + return true + end), + true, + "legacy owner replacement" +) +legacy.exports.hide(true) +T.eq(legacy.visible(), false, "public hook may still hide after legacy approval") +legacy.exports.hide(false) +T.eq(legacy.visible(), true, "both layers must allow drawing") +T.eq(Visibility.releaseBottom("legacy_owner"), true, "legacy release reports removed owner") +T.eq(Visibility.releaseBottom("legacy_owner"), false, "legacy release is idempotent") +Visibility.claimBottom("throwing", function() + error("intentional legacy claim failure") +end) +T.eq(legacy.visible(), true, "throwing legacy claim fails open") +T.same(Visibility.bottomClaimants(), {}, "throwing legacy claim is retired") +Visibility.claimBottom("reset_me", function() + return false +end) +Visibility.reset() +T.eq(legacy.visible(), true, "legacy reset restores ordinary visibility") +legacy.close() +local files = { + ["mods/broken_bottom/manifest.json"] = [[{"id":"broken_bottom","name":"Broken Bottom", + "version":"1.0.0","api":3,"entry":"main.lua"}]], + ["mods/broken_bottom/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("battle.bottom_ui_visible", function() return false end) + error("intentional bottom entry failure") + ]], +} +local failed = T.sdk.loadMods( + { "mods/broken_bottom" }, + { fs = T.sdk.memfs(files), data = T.fixtures.fresh() } +) +T.check(#failed.errors > 0, "failed entry is reported") +T.eq(Runtime.wantsHook("battle.bottom_ui_visible"), false, "entry rollback removes predicate") +Loader.teardown() +T.eq(Runtime.wantsHook("battle.bottom_ui_visible"), false, "teardown removes predicate") +failed.release() +Version.set(originalVersion) +T.finish("battle.bottom_ui_visible native routing") diff --git a/tests/engine/battle_grid_navigation.lua b/tests/engine/battle_grid_navigation.lua new file mode 100644 index 000000000..7818c1e63 --- /dev/null +++ b/tests/engine/battle_grid_navigation.lua @@ -0,0 +1,69 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/battle_grid/fixture.lua") +local Cases = dofile("tests/battle_grid/cases.lua") +local Version = require("src.core.GameVersion") +local Navigation = require("src.battle.MoveNavigation") +local old = 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 b, e, game = f.battle(), f.exports, f.game + local native = Version.generation() >= 3 + T.eq(f.grid(), native, "native default is unchanged") + Cases.run(f) + if mode[1] >= 3 then + local off = e.api.hooks:wrap("battle.move_grid_navigation", function(next) + next() + return next() + end, 100) + local calls = e.seen.calls + T.eq(f.grid(), true, "API 3 retains downstream grid") + T.eq(e.seen.calls, calls + 1, "API 3 repeated next is cached") + off() + end + e.mode("grid") + b.phase, b.mimicMoves, b.mimicIndex = "mimicSelect", b.player.curMoves, 1 + f.press("down") + T.eq(b.mimicIndex, 3, "native Mimic copy menu uses shared grid") + e.mode("list") + b.mimicIndex = 1 + f.press("down") + T.eq(b.mimicIndex, 2, "native Mimic copy menu can use list") + b.phase = "moveSelect" + if Version.generation() <= 2 then + game.save.options.battleLayout = "wide" + e.mode("list") + local calls = e.seen.calls + T.eq(f.grid(), Version.generation() == 1, "Gen1 wide stays fixed; Gen2 wide can choose list") + if Version.generation() == 1 then + T.eq(e.seen.calls, calls, "Gen1 wide bypasses predicate") + end + game.save.options.battleLayout = "classic" + end + local calls = e.seen.calls + e.stop() + e.stop() + T.eq(f.grid(), native, "unsubscribe restores native mode") + T.eq(e.seen.calls, calls, "unsubscribe stops callbacks") + if Version.isGen3() then + f.double(true) + f.count(4) + f.index(1) + T.eq(f.press("left"), 1, "native GBA double clamps left without wrappers") + T.eq(f.press("up"), 1, "native GBA double clamps top without wrappers") + T.eq(f.press("right"), 2, "native GBA double moves to existing neighbour") + T.eq(f.press("down"), 4, "native GBA double moves to lower row") + f.double(false) + end + local input = setmetatable({}, { + __index = function() + error("empty grid read input") + end, + }) + T.eq(Navigation.navigate(b, 1, 0, input), nil, "empty grid does not read input") + f.close() + end +end +Version.set(old) +T.finish("portable battle.move_grid_navigation native controllers") 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/compat_module_aliases.lua b/tests/engine/compat_module_aliases.lua new file mode 100644 index 000000000..d1d1c8f43 --- /dev/null +++ b/tests/engine/compat_module_aliases.lua @@ -0,0 +1,59 @@ +local T=require('tests.harness') +love=require('tests.love_stub') +local C=require('src.core.ModCompat') +C.install() +T.eq(require('src.world.gen2.WorldAPI'),require('src.world.WorldAPI'),'world API alias preserves object identity') +if loadfile('src/world/OptionalFollower.lua') then + T.eq(require('src.world.gen2.Follower'),require('src.world.OptionalFollower'),'Gen2 follower alias preserves object identity') +else + T.raises(function()require('src.world.gen2.Follower')end,'src.world.OptionalFollower','alias identifies an unavailable native follower provider') +end +local sourcePlayer=require('src.core.game3.player') +local fallbackPlayer=require('src.compat.game3.Player') +if loadfile('src/core/game3/player.lua')then + T.neq(sourcePlayer,fallbackPlayer,'concrete live player provider takes precedence over the field fallback') +else + T.eq(sourcePlayer,fallbackPlayer,'host without a concrete player provider receives the live field fallback') +end +local names=require('src.mods.Gen1Compat') +for _,pair in ipairs({{'src.world.game3.Follower','src.compat.game3.Follower'}, + {'src.core.game3.pokemon','src.compat.game3.Pokemon'}, + {'src.core.gen2.Unown','src.compat.gen2.Unown'}})do + local source,adapter=require(pair[1]),require(pair[2]) + if loadfile(pair[1]:gsub('%.','/')..'.lua')then + T.neq(source,adapter,'concrete provider outranks the fallback '..pair[1]) + else + T.eq(source,adapter,'native adapter preserves require identity '..pair[1]) + end +end + +for name in pairs(names.UNAVAILABLE)do + if names.hasProvider(name)then + local ok,module=pcall(require,name) + T.check(ok,'concrete host provider loads '..name) + T.eq(type(module),'table','concrete host provider remains a module '..name) + T.eq(names.unavailable(name),nil,'existing provider is not marked unavailable '..name) + T.eq(require(name),module,'existing provider retains require identity '..name) + T.check(names.hasProvider(name),'provider remains available '..name) + else + local ok,reason=pcall(require,name) + T.check(not ok,'unsupported import fails '..name) + T.check(tostring(reason):find(name,1,true)~=nil,'failure identifies module '..name) + T.check(tostring(reason):find('mystery_dungeon_follower',1,true)~=nil,'failure identifies affected consumer '..name) + T.check(not tostring(reason):find('no file ',1,true),'failure does not masquerade as a filesystem search '..name) + local again,detail=pcall(require,name) + T.check(not again and reason==detail,'repeat import retains the exact diagnostic '..name) + end +end + +local providers=package.loaders or package.searchers +local provided={marker='native-late-provider'} +local lateName='src.compat.fixture.LateProvider' +names.UNAVAILABLE[lateName]={'contract_fixture'} +local late=function(name)if name==lateName then return function()return provided end end end +providers[#providers+1]=late +T.eq(require(lateName),provided,'late concrete provider outranks the missing-module descriptor') +T.check(names.hasProvider(lateName),'loaded provider is recognized') +T.eq(names.unavailable(lateName),nil,'loaded provider has no missing-module diagnostic') +table.remove(providers);package.loaded[lateName]=nil;names.UNAVAILABLE[lateName]=nil +T.finish() 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/decoded_script_profile.lua b/tests/engine/decoded_script_profile.lua new file mode 100644 index 000000000..4a1fac628 --- /dev/null +++ b/tests/engine/decoded_script_profile.lua @@ -0,0 +1,427 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Hooks = require("src.mods.Hooks") +local Events = require("src.mods.Events") +local Runtime = require("src.mods.Runtime") +local Runner = require("src.script.ScriptRunner") +local Decoded = require("src.script.DecodedScript") +local C2 = require("src.script.Gen2ScriptVM") +local C3 = require("src.script.Gen3ScriptVM") +local function fixture(gen, scripts) + Version.set(gen == 2 and "gold" or "firered") + local data = require("tests.fixtures.upstream_mods.data").fresh() + data.map_scripts = { source = gen == 3 and "RomExtractorGen3" or "RomExtractorGen2", scripts = scripts, maps = {} } + local game = { data = data, save = { flags = {}, party = {}, gen3Vars = {} } } + local world = { map = { id = "NATIVE" } } + local runner = Runner.new(game, world) + local hooks, decoded = Hooks.new(), Hooks.new() + hooks._decodedHooks = decoded + Runtime.install(Events.new(), hooks, {}) + return game, runner, (gen == 2 and C2 or C3).compile(data, "ENTRY"), hooks, decoded +end +for _, gen in ipairs({ 2, 3 }) do + local set, finish = gen == 2 and "setval" or "setvar", gen == 2 and "endall" or "end" + local row = gen == 2 and { set, 4 } or { set, 0x4002, 4 } + local game, runner, rows, native, decoded = fixture(gen, { ENTRY = { row, { finish } } }) + local calls, order, expected = 0, {}, 4 + native:wrap("script.command", function(next, ctx, op, args) + order[#order + 1] = op + T.eq(ctx.script, rows, "native callback retains the original compiled script identity") + return next(ctx, op, args) + end) + runner:exec(rows, runner:makeContext()) + T.eq(calls, 0, "native-only runs never dispatch decoded callbacks") + local baseline = table.concat(order, ",") + order = {} + decoded:wrap("script.command", function(next, ctx, op, ir) + calls = calls + 1 + local result = next(ctx, op, ir) + if op == set then + T.eq( + gen == 2 and ctx.g2Var or ctx.save.gen3Vars[0x4002], + expected, + "post-next observes the native command effects" + ) + end + return result + end) + runner:exec(rows, runner:makeContext()) + T.eq(calls, 2, "one decoded callback per source command") + T.eq(table.concat(order, ","), baseline, "native command order is unchanged with a transparent profile") + local prepared = Decoded.prepare(rows) + T.same(prepared, rows, "transparent compiler trace preserves every native row and argument") + native:removeOwner(nil) -- no changes: test-owned native callback remains installed + local replacement = Hooks.new() + replacement._decodedHooks = decoded + Runtime.install(Events.new(), replacement, {}) + decoded:wrap("script.command", function(next, ctx, op, ir) + if op == set then + local copy = {} + for k, v in pairs(ir) do + copy[k] = v + end + copy[gen == 2 and 2 or 3] = 151 + return next(ctx, op, copy) + end + return next(ctx, op, ir) + end, 100) + expected = 151 + local editedContext = runner:makeContext() + runner:exec(rows, editedContext) + T.eq( + gen == 2 and editedContext.g2Var or game.save.gen3Vars[0x4002], + 151, + "edited decoded operands reach native execution" + ) + T.eq(row[gen == 2 and 2 or 3], 4, "hook rewrites never mutate imported source rows") +end +-- Calls lower to several rows; a wrapper surrounds the complete command once. +local game, runner, rows, native, decoded = fixture(3, { + ENTRY = { { "call", "SUB" }, { "setvar", 0x4010, 7 }, { "end" } }, + SUB = { { "setvar", 0x4011, 8 }, { "return" } }, +}) +local seen = {} +decoded:wrap("script.command", function(next, ctx, op, ir) + seen[#seen + 1] = op + if op == "setvar" and ir[2] == 0x4011 then + return next(ctx, "addvar", { "addvar", 0x4011, 9 }) + end + return next(ctx, op, ir) +end) +runner:exec(rows, runner:makeContext()) +T.same( + seen, + { "call", "setvar", "return", "setvar", "end" }, + "multi-row call, edited opcode and return keep decoded order" +) +T.eq(game.save.gen3Vars[0x4011], 9, "opcode replacements execute their own lowering") +T.eq(game.save.gen3Vars[0x4010], 7, "recompilation retains active call return addresses") +-- Zero-row loadword supplies a later std. A veto skips the entire grouped command. +local game2, run2, rows2, _, dec2 = + fixture(3, { ENTRY = { + { "nop" }, + { "loadword", 0, "TEXT_A" }, + { "setvar", 0x4020, 3 }, + { "end" }, + } }) +local count = 0 +dec2:wrap("script.command", function(next, ctx, op, ir) + count = count + 1 + if op == "setvar" then + return + end + if op == "loadword" then + ir[3] = "TEXT_B" + end + return next(ctx, op, ir) +end) +run2:exec(rows2, run2:makeContext()) +T.eq(count, 4, "zero-row operations are visible exactly once") +T.eq(game2.save.gen3Vars[0x4020], nil, "veto skips native effects") +-- The actual Loader routes only the explicitly selected mod onto the decoded bus. +local SDK = require("tests.modkit.sdk") +local Json = require("src.link.Json") +local Loader = require("src.mods.Loader") +Version.set("firered") +local fs = SDK.memfs({ + ["options.lua"] = 'return {mods={probe=true},modCallbackProfiles={probe="decoded"}}', + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = [[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,row) + if op=='setvar' then row.value=99 end + return next(ctx,op,row) + end) + mod.events:on('pokemon.before_give',function(gift) + mod.exports.map=gift.ctx.overworld.map.id + gift.level=20;gift.nickname='PROFILE' + end) + end]], +}) +local data = require("tests.fixtures.upstream_mods.data").fresh() +data.maps.MAP_G04_N03 = { id = "MAP_G04_N03", group = 4, number = 3 } +local loader = Loader.new({ fs = fs, dev = false }) +T.eq(loader:load(data), true, "actual Loader accepts an external per-mod profile") +T.eq(loader.hooks.chains["script.command"], nil, "selected hook does not alter the native chain") +T.check(loader._decodedHooks.chains["script.command"] ~= nil, "selected hook registers on its private bus") +data.map_scripts = + { source = "RomExtractorGen3", scripts = { ENTRY = { { "setvar", 0x4002, 4 }, { "end" } } }, maps = {} } +local g = { data = data, save = { flags = {}, party = {}, gen3Vars = {} }, mods = loader } +local world = { map = { id = "MAP_G04_N03", def = data.maps.MAP_G04_N03 } } +local r = Runner.new(g, world) +r:exec(C3.compile(data, "ENTRY"), r:makeContext()) +T.eq(g.save.gen3Vars[0x4002], 99, "named row edit travels through Loader and compiler to native state") +local gift = { ctx = r:makeContext(), species = "CHARMANDER", level = 5 } +loader.events:emit("pokemon.before_give", gift) +T.eq(loader.exports.probe.map, "OAKS_LAB", "profile translates the verified native lab slot") +T.eq(world.map.id, "MAP_G04_N03", "native map identity is never renamed") +T.eq(gift.level, 20, "profile gift edits reach the existing gift event") +T.eq(gift.nickname, "PROFILE", "profile nickname edits commit") +loader:_rollback("probe") +T.eq(loader._decodedHooks.chains["script.command"], nil, "rollback retires the per-mod decoded registration") + +-- Every visit starts from original IR, even when an earlier visit rewrote it. +do + local g, r, program, _, bus = fixture(3, { + ENTRY = { { "setvar", 0x4030, 0 }, { "goto", "LOOP" } }, + LOOP = { + { "addvar", 0x4030, 1 }, + { "setvar", 0x4031, 4 }, + { "compare_var_to_value", 0x4030, 3 }, + { "goto_if", 0, "LOOP" }, + { "end" }, + }, + }) + local values = {} + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "setvar" and ir[2] == 0x4031 then + values[#values + 1] = ir[3] + if #values == 1 then + ir[3] = 151 + elseif #values == 2 then + return + end + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + T.same(values, { 4, 4, 4 }, "loops offer original source operands after both rewrites and vetoes") + T.eq(g.save.gen3Vars[0x4030], 3, "rewritten loop retains its branch and completion") + T.eq(g.save.gen3Vars[0x4031], 4, "transparent later visit restores the original command") +end +-- A vetoed compile-time text load must not leak into a later callstd. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "loadword", 0, "TEXT_A" }, { "callstd", 4 }, { "end" } } }) + local shown = 0 + local Commands = require("src.script.Commands") + local old = Commands.g3_show_text + Commands.g3_show_text = function() + shown = shown + 1 + end + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "loadword" then + return + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + Commands.g3_show_text = old + T.eq(shown, 0, "veto removes compile-time text state from subsequent lowering") +end +-- Gen-2 selected profile keeps the fourth decoded row and lossless byte edits. +do + Version.set("gold") + local files = { + ["options.lua"] = "return {mods={probe=true}}", + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + }), + ["mods/probe/main.lua"] = [=[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,args,cmd) + if op=='setval' then + assert(cmd.op=='setval' and cmd.value==4 and args[1]==4) + args[1]=77 + mod.exports.retained=next + end + return next(ctx,op,args,cmd) + end) + mod.events:once('pokemon.before_give',function(gift)mod.exports.gifts=(mod.exports.gifts or 0)+1 end) + end]=], + } + local d = require("tests.fixtures.upstream_mods.data").fresh() + local l = Loader.new({ fs = SDK.memfs(files), dev = false, callbackProfiles = { probe = "decoded" } }) + T.eq(l:load(d), true, "constructor profile works without editing a manifest or options file") + d.map_scripts = { source = "RomExtractorGen2", scripts = { ENTRY = { { "setval", 4 }, { "endall" } } }, maps = {} } + local g = { data = d, save = { flags = {}, party = {} } } + local r = Runner.new(g, {}) + local ctx = r:makeContext() + r:exec(C2.compile(d, "ENTRY"), ctx) + T.eq(ctx.g2Var, 77, "Gen-2 raw byte edits are decoded before native lowering") + T.raises(function() + l.exports.probe.retained() + end, "only valid during", "retained next cannot replay a completed command") + l.events:emit("pokemon.before_give", { ctx = ctx, species = "CHARMANDER", level = 5 }) + l.events:emit("pokemon.before_give", { ctx = ctx, species = "CHARMANDER", level = 5 }) + T.eq(l.exports.probe.gifts, 1, "profiled once subscription retires on the original native event bus") +end +-- Deep source operands never escape into a retained callback record. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "nop", { nested = 1 } }, { "end" } } }) + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "nop" then + ir[2].nested = 2 + end + return next(ctx, op, ir) + end) + r:exec(program, r:makeContext()) + T.eq(g.data.map_scripts.scripts.ENTRY[1][2].nested, 1, "nested source operands remain unchanged") +end + +-- Gift records include the cartridge species number without changing native +-- event records; numeric edits and variable-backed gifts resolve through data. +do + Version.set("firered") + local d = require("tests.fixtures.upstream_mods.data").fresh() + d.constants.speciesOrder = { [4] = "CHARMANDER", [151] = "MEW" } + local files = { + ["options.lua"] = 'return {mods={probe=true},modCallbackProfiles={probe="decoded"}}', + ["mods/probe/manifest.json"] = Json.encode({ + id = "probe", + name = "Probe", + version = "1.0.0", + entry = "main.lua", + }), + ["mods/probe/main.lua"] = [=[return function(mod) + mod.events:on('pokemon.before_give',function(gift) + assert(gift.speciesId==4) + gift.species=151;gift.level=120;gift.nickname='NUMERIC' + end) + end]=], + } + local l = Loader.new({ fs = SDK.memfs(files), dev = false }) + assert(l:load(d)) + local g = { data = d, save = { gen3Vars = { [0x4002] = 4 } } } + local ctx = { game = g, save = g.save, script = { { "g3_give_pokemon", 0x4002, 5, 0 } }, pc = 1 } + local gift = { ctx = ctx, species = "CHARMANDER", level = 5 } + local nativeSeen + l.events:on("pokemon.before_give", function(row) + nativeSeen = row + end, -100, "native") + l.events:emit("pokemon.before_give", gift) + T.eq(gift.species, "MEW", "numeric profiled gift species resolves through this dataset") + T.eq(gift.level, 100, "decoded gift level stays in the supported range") + T.eq(g.save.gen3Vars[0x4002], 151, "variable-backed native gift retains the edited species number") + T.eq(nativeSeen, gift, "native listeners retain the exact original gift record") + T.eq(gift.speciesId, nil, "profile-only species metadata never leaks into native event records") +end + +-- Hook and group scopes survive a real runner suspension across updates. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "delay", 2 }, { "setvar", 0x4050, 9 }, { "end" } } }) + local phases = {} + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "delay" then + phases[#phases + 1] = "before" + end + local value = next(ctx, op, ir) + if op == "delay" then + phases[#phases + 1] = "after" + end + return value + end) + r:run(program) + T.eq(r:isRunning(), true, "profiled native delay suspends the actual runner") + T.same(phases, { "before" }, "hook remains before next while native command is suspended") + T.eq(g.save.gen3Vars[0x4050], nil, "later operation cannot run during the delay") + r:update() + r:update() + T.eq(r:isRunning(), false, "native updates finish the profiled delay") + T.same(phases, { "before", "after" }, "post-next executes exactly once after resumption") + T.eq(g.save.gen3Vars[0x4050], 9, "native continuation executes after resumption") +end + +-- Provenance must not keep a retired dataset alive through the compiler cache. +local weak, retained +local function retired(keepRows) + local g, r, program = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + local refs = setmetatable({ g.data, program }, { __mode = "v" }) + return refs, keepRows and program or nil +end +weak = retired(false) +collectgarbage("collect") +collectgarbage("collect") +T.eq(weak[1], nil, "private provenance releases an unowned dataset") +T.eq(weak[2], nil, "private provenance releases an unowned compiled program") +weak, retained = retired(true) +collectgarbage("collect") +collectgarbage("collect") +T.eq(weak[1], nil, "a retained native row list cannot retain the entire source dataset") +T.raises(function() + Decoded.prepare(retained) +end, "retired", "decoded execution refuses a retired source cohort") + +do + local g, r, program = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + g.data.map_scripts = { source = "RomExtractorGen3", scripts = { ENTRY = { { "end" } } }, maps = {} } + T.raises(function() + Decoded.prepare(program) + end, "retired", "a replaced source pool retires old compiled provenance") +end +-- Swallowing an error below next cannot retry partial native side effects. +do + local g, r, program, _, bus = fixture(3, { ENTRY = { { "setvar", 0x4060, 1 }, { "end" } } }) + local Commands = require("src.script.Commands") + local original, count = Commands.g3_setvar, 0 + Commands.g3_setvar = function() + count = count + 1 + error("owned native failure") + end + bus:wrap("script.command", function(next, ctx, op, ir) + pcall(next, ctx, op, ir) + end) + T.raises(function() + r:exec(program, r:makeContext({ source = { strict = true } })) + end, "did not complete", "a mod cannot swallow a failing native group and restart it") + Commands.g3_setvar = original + T.eq(count, 1, "native side effects occur at most once across a swallowed failure") +end + +for _, gen in ipairs({ 2, 3 }) do + local compiler = gen == 2 and C2 or C3 + local g, r, program, _, bus = fixture( + gen, + { ENTRY = gen == 2 and { { "setval", 4 }, { "endall" } } or { { "setvar", 0x4070, 4 }, { "end" } } } + ) + T.eq(compiler.compile(g.data, "ENTRY", true), program, "public compiler continues to ignore surplus arguments") + local original, calls = compiler.compile, 0 + compiler.compile = function(data, entry) + calls = calls + 1 + return original(data, entry) + end + program = compiler.compile(g.data, "ENTRY") + bus:wrap("script.command", function(next, ctx, op, ir) + if op == "setval" then + ir[2] = 7 + elseif op == "setvar" then + ir[3] = 7 + end + return next(ctx, op, ir) + end) + local ctx = r:makeContext() + r:exec(program, ctx) + T.eq( + gen == 2 and ctx.g2Var or g.save.gen3Vars[0x4070], + 7, + "a transparent public compiler wrapper cannot suppress decoded callbacks" + ) + T.eq(calls, 1, "private tracing does not replay public compiler wrappers") + compiler.compile = original +end + +-- The decoded Gen-2 call contract runs its subroutine before post-next code. +do + local g, r, program, _, bus = fixture(2, { + ENTRY = { { "scall", "SUB" }, { "setval", 7 }, { "endall" } }, + SUB = { { "setval", 9 }, { "pause", 2 }, { "end" } }, + }) + local observed + bus:wrap("script.command", function(next, ctx, op, ir) + local result = next(ctx, op, ir) + if op == "scall" then + observed = ctx.g2Var + end + return result + end) + r:run(program) + T.eq(observed, nil, "Gen-2 call hook remains suspended with its subroutine") + T.eq(r:isRunning(), true, "nested native wait keeps the caller suspended") + r:update() + r:update() + local ctx = r.ctx + T.eq(observed, 9, "Gen-2 post-next sees the completed subroutine state") + T.eq(ctx.g2Var, 7, "the caller continues after its decoded call hook closes") +end +T.finish() 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/follower_spawn.lua b/tests/engine/follower_spawn.lua new file mode 100644 index 000000000..23bc6a17d --- /dev/null +++ b/tests/engine/follower_spawn.lua @@ -0,0 +1,99 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Fixture, Cases = dofile("tests/follower/fixture.lua"), dofile("tests/follower/cases.lua") +local old = 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 gen, e = f.gen, f.e + T.eq(e.api:supports("hooks", "world.follower.spawn"), gen <= 3, "honest native generation capability") + if gen <= 3 then + Cases.run(f) + -- Preserve unrelated actors, native map seam identity and in-flight + -- interpolation, including the trail's rebase alias. + local off = e.api.hooks:wrap("world.follower.spawn", function() + return true + end, 100) + local bystander = { id = "native-object" } + f.world.npcs[#f.world.npcs + 1] = bystander + f.world.entities[#f.world.entities + 1] = bystander + f.tick() + local npc = f.current() + local trail = f.world.pikachuTrail + local tx, ty = trail.x, trail.y + f.world.cast = { "stale" } + f.enter({ keepPikachu = npc }) + T.eq(f.current(), npc, "connection retains the actual companion") + T.eq(f.world.pikachuTrail, trail, "connection retains buffered trail") + T.eq(f.world.cast, nil, "map entry invalidates entity cast") + f.controller.rebase(f.world, -3, 2) + T.eq(f.world.pikachuTrail.x, tx - 3, "rebase mutates trail x in place") + T.eq(f.world.pikachuTrail.y, ty + 2, "rebase mutates trail y in place") + T.eq(npc.px, npc.cellX * 16, "rebased native pixel x agrees with cell") + if gen >= 2 then + T.eq(f.world.followerTrail, trail, "optional companion shares rebase alias") + T.eq( + f.controller.talk(f.game, f.world, npc), + false, + "optional companion cannot enter Yellow dialogue" + ) + end + off() + local deny = e.api.hooks:wrap("world.follower.spawn", function() + return false + end, 100) + f.tick() + T.eq(f.world.npcs[1], bystander, "denial preserves unrelated native NPC") + T.eq(f.world.entities[2], bystander, "denial preserves unrelated draw entry") + deny() + if gen >= 2 then + -- Fallback artwork must not add a content alias or share the player's + -- mutable renderer. This is an asset seam, not an alternative mod API. + f.game.data.sprites.SPRITE_PIKACHU = nil + local def = f.player.spriteDef + f.player.spriteDef = nil + f.player.sprite = require("src.render.SpriteRenderer").new(def, "fixture-player") + local allow = e.api.hooks:wrap("world.follower.spawn", function() + return true + end, 100) + f.enter() + T.eq(f.current().sprite.def, def, "missing optional sheet uses player definition") + T.neq(f.current().sprite, f.player.sprite, "follower owns a separate renderer") + T.eq(f.game.data.sprites.SPRITE_PIKACHU, nil, "fallback never mutates content registry") + if gen == 3 then + f.player.cellX, f.player.cellY, f.player.px, f.player.py = 20, 20, 320, 320 + f.tick() + T.eq(f.current().cellX, 20, "same-map GBA teleport resets companion x") + T.eq(f.current().cellY, 20, "same-map GBA teleport resets companion y") + end + allow() + end + else + local escort = { id = "scripted-escort", gen4Follower = true } + f.world.npcs, f.world.entities = { escort }, { f.player, escort } + local calls = 0 + local off = e.api.hooks:wrap("world.follower.spawn", function() + calls = calls + 1 + return true + end) + f.enter() + f.tick() + T.eq(calls, 0, "DS has no promised follower producer") + T.eq(f.world.npcs[1], escort, "DS native escort is untouched") + off() + end + f.close() + local failed = Fixture.new(".", "gen2", version, mode[1], mode[2], true) + T.check(#failed.result.errors > 0, "entry rollback is reported") + failed.tick() + T.eq(failed.current() ~= nil, version == "yellow", "failed mod leaves native follower default") + for _, entry in ipairs(Runtime.hooks.chains["world.follower.spawn"] or {}) do + T.check(entry.owner ~= "portable_follower", "entry rollback removes owned hook") + end + failed.close() + end +end +Version.set(old) +T.finish("portable native follower predicate") 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/gbc_color_correction.lua b/tests/engine/gbc_color_correction.lua new file mode 100644 index 000000000..532fe449a --- /dev/null +++ b/tests/engine/gbc_color_correction.lua @@ -0,0 +1,111 @@ +-- No ROM/GPU needed. Reference digests were generated by compiling SameBoy's +-- unmodified GB_convert_rgb15 at 26a56bd6a122a6318a456abd80558408742d5adf, +-- CGB_E, neutral temperature, RGB byte order, RGB555 values 0..32767. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local C = require("src.render.GbcColorCorrection") +local SaveData = require("src.core.SaveData") +local GenOptions = require("src.core.GenOptions") +local Version = require("src.core.GameVersion") +local Palette = require("src.render.PaletteFX") + +T.eq(SaveData.defaultOptions().gbcColorCorrection, "off", "existing installations keep their colours") +T.eq(SaveData.mergeOptions({}).gbcColorCorrection, "off", "old options default to OFF") +T.eq(C.normalize("invalid"), "off", "unknown profile falls back to OFF") +T.eq(C.normalize(true), "off", "invalid option type falls back to OFF") +T.eq(C.cycle(nil, 1), "accurate", "first step selects Modern Accurate") +T.eq(C.cycle("accurate", 1), "harsh", "next step selects Harsh Reality") +T.eq(C.cycle("harsh", 1), "off", "forward wraps to OFF") +T.eq(C.cycle("off", -1), "harsh", "left cycles backwards") +local opts = { gbcColorCorrection = "invalid", untouched = true } +C.applyOptions(opts) +T.eq(C.mode, "off", "applyOptions sanitizes the live mode") +T.eq(opts.gbcColorCorrection, "invalid", "presentation never rewrites saved settings") +T.check(opts.untouched, "unrelated options survive") + +local expected = { accurate = 37176544, harsh = 1187375320 } +for _, mode in ipairs({ "accurate", "harsh" }) do + local hash, bounded = 0, true + for b = 0, 31 do for g = 0, 31 do for r = 0, 31 do + local nr, ng, nb = C.convert5(r, g, b, mode) + for _, v in ipairs({ nr, ng, nb }) do + bounded = bounded and v >= 0 and v <= 255 and v == math.floor(v) + hash = (hash * 33 + v) % 4294967296 + end + end end end + T.eq(hash, expected[mode], mode .. " matches all 32768 upstream RGB555 colours") + T.check(bounded, mode .. " always returns valid integer RGB888") +end +T.same({ C.convert5(0, 0, 0, "accurate") }, { 0, 0, 0 }, "accurate keeps black") +T.same({ C.convert5(31, 31, 31, "accurate") }, { 255, 255, 255 }, "accurate keeps white") +T.same({ C.convert5(0, 0, 0, "harsh") }, { 45, 41, 38 }, "LCD profile has the upstream black point") +T.same({ C.convert5(31, 31, 31, "harsh") }, { 161, 166, 156 }, "LCD profile has the upstream white point") +T.same({ C.convert5(31, 0, 16, "off") }, { 255, 0, 132 }, "OFF reference uses uncorrected RGB555 expansion") + +local wire = SaveData.encode({ gbcColorCorrection = "harsh", speed = 2 }) +T.eq(SaveData.decode(wire).gbcColorCorrection, "harsh", "selection survives serialization") +local scoped = { gbcColorCorrection = "off", perGeneration = { [2] = { gbcColorCorrection = "accurate" } } } +T.eq(GenOptions.resolve(scoped, 1).gbcColorCorrection, "off", "Gen 1 keeps its shared value") +T.eq(GenOptions.resolve(scoped, 2).gbcColorCorrection, "accurate", "Gen 2 can override its correction") +T.eq(GenOptions.resolve(scoped, 3).gbcColorCorrection, "off", "Gen 3 keeps its shared value") + +_G.love = require("tests.love_stub") +local Options = require("src.ui.OptionsMenu") +local function rowFor(version) + Version.set(version) + local game = { save = { options = SaveData.defaultOptions() }, data = {} } + for _, row in ipairs(Options.buildRows(game)) do + if row.id == "gbcColorCorrection" then return row, game end + end +end +T.eq(rowFor("red"), nil, "correction is not offered on Gen 1") +T.eq(rowFor("emerald"), nil, "correction is not offered on Gen 3") +local row, game = rowFor("gold") +T.check(row ~= nil, "Gold offers the correction row") +T.eq(row.value(game), "OFF", "in-game row initially shows OFF") +row.step(game, 1) +T.eq(game.save.options.gbcColorCorrection, "accurate", "in-game row stores the selected profile") +T.eq(C.mode, "accurate", "in-game row applies live") +row.step(game, -1) +T.eq(C.mode, "off", "in-game left restores OFF") +local writes = 0 +game.input = { wasPressed = function(_, key) return key == "right" end } +game.writeOptions = function() writes = writes + 1 end +local menu = Options.new(game) +for i, entry in ipairs(menu.rows) do + if entry.id == "gbcColorCorrection" then menu.index = i end +end +menu:update(1 / 60) +T.eq(C.mode, "accurate", "mapped menu input changes the live profile") +T.eq(writes, 1, "mapped menu input persists through the normal options writer") +T.check(rowFor("silver") ~= nil, "Silver offers the correction row") +T.check(rowFor("crystal") ~= nil, "Crystal offers the correction row") +local RI = require("src.import.RomImporter") +local defaults +for _, entry in ipairs(RI.SETTINGS_ROWS) do + if entry.id == "gbcColorCorrection" then defaults = entry end +end +T.same(defaults.values, { "off", "accurate", "harsh" }, "launcher offers the same profiles") + +-- No GPU use at all on OFF, other generations or intentionally mono modes. +local allocations = 0 +love.graphics.newShader = function() allocations = allocations + 1; error("no shaders") end +Version.set("gold") +Palette.setMode("gbc") +C.setMode("off") +local input = {} +T.eq(C.apply(input), input, "OFF returns the original surface") +C.setMode("accurate") +Version.set("red") +T.eq(C.apply(input), input, "other games return the original surface") +Version.set("gold") +Palette.setMode("classic") +T.eq(C.apply(input), input, "mono palettes return the original surface") +T.eq(allocations, 0, "inert paths allocate no shaders or canvases") +Palette.setMode("gbc") +T.eq(C.apply(input), input, "shader compilation failure preserves the frame") +T.eq(C.apply(input), input, "unsupported GPU remains a safe pass-through") +T.eq(allocations, 1, "unsupported shader is not retried each frame") +C.setMode("off") +Version.set("red") +T.finish("GBC colour correction") 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/gen2_apricorn_content.lua b/tests/engine/gen2_apricorn_content.lua new file mode 100644 index 000000000..687ad29c8 --- /dev/null +++ b/tests/engine/gen2_apricorn_content.lua @@ -0,0 +1,289 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +-- Authored IR, not cartridge scripts or dialogue. Exercise the real loader, +-- native VM, menus, flags, inventory and observer boundaries together. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Stack=require('src.core.StateStack');local Flags=require('src.script.Gen2Flags') +local P=require('src.world.Gen2ApricornPrograms');local Merge=require('src.mods.Merge') +local Commands=require('src.script.Commands');local Sound=require('src.core.Sound') +local oldText,oldSound=Commands.show_text,Sound.play +Commands.show_text=function()end;Sound.play=function()end -- external IO only +local function working(version)return version=='crystal' and 80 or 79 end +local function fixture(version) + local d=T.fixtures.fresh() + for i=1,7 do + local key=string.format('ITEM_%03d',110+i) + d.items[key]={id=key,name='APRICORN '..i,pocket='ITEM'} + end + d.items.ITEM_173={id='ITEM_173',name='BERRY',pocket='ITEM'} + for i=201,202 do + local key=string.format('ITEM_%03d',i) + d.items[key]={id=key,name='BALL '..i,pocket='BALL',ball=true} + end + local ask={{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'}} + local scripts={ASK=ask,GAVE={{'setflag',working(version)},{'end'}},CANCEL={{'setval',0},{'end'}}, + WAIT={{'setval',98},{'end'}},FULL={{'setval',99},{'end'}},DONE={{'end'}}} + local pending,rows={},{} + for i=1,7 do + local key=string.format('ITEM_%03d',110+i) + rows[i]={apricorn=key,ball='ITEM_201',index=i} + if i>1 then ask[#ask+1]={'ifequal',110+i,'CHOOSE'..i}end + scripts['CHOOSE'..i]={{'setevent',599+i},{'sjump','GAVE'}} + pending[#pending+1]={'checkevent',599+i};pending[#pending+1]={'iftrue','DELIVER'..i} + scripts['DELIVER'..i]={{'checkflag',working(version)},{'iftrue','WAIT'}, + version=='crystal' and {'verbosegiveitemvar',201,22} or {'verbosegiveitem',201,1}, + {'iffalse','FULL'},{'clearevent',599+i},{'sjump','DONE'}} + end + ask[#ask+1]={'setevent',600};ask[#ask+1]={'sjump','GAVE'} + for i=1,7 do pending[#pending+1]={'checkitem',110+i};pending[#pending+1]={'iftrue','ASK'}end + pending[#pending+1]={'end'} + scripts.PENDING=pending;scripts.OVERLAPPING=Merge.deepCopy(pending) + d.map_scripts={scripts=scripts,apricornBalls=rows,apricornInfo={complete=true}, + apricornPrograms={ask='ASK',pending='PENDING',gave='GAVE',cancel='CANCEL'}} + return d +end +local function load(d,sources,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + for _,entry in ipairs(sources or{})do + local id,source=entry[1],entry[2];local path='mods/'..id + files[path..'/manifest.json']=Json.encode({id=id,name=id,version='1.0.0',api=mode and mode[1] or 2, + content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files[path..'/main.lua']='local mod=...;mod.exports.api=mod;'..source;paths[#paths+1]=path + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d,inventory) + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='map'}) + return {data=d,save={inventory=inventory or{},flags={}},stack=stack,overworld={}} +end +local function run(g,key) + local runner=Runner.new(g,g.overworld);runner:run(assert(VM.compile(g.data,key)));return runner +end +local function press(g,key) + g.input={wasPressed=function(_,k)return k==key end};g.stack:update(0) +end +local recipe=[[{apricorn='BERRY',ball='BALL_202',event=5000,index=8}]] +local source=[[ + local p=mod.content["apricorns"] + assert(p:supports()) + assert(p:get('APRICORN_1').apricorn=='APRICORN_1') + local v=p:get('APRICORN_1');v.ball='LEAK' + assert(p:get('APRICORN_1').ball=='BALL_201') + local n=0;for id,row in p:each()do n=n+1;row.event=65534 end;assert(n==7) + p:register('BERRY_RECIPE',]]..recipe..[[) + p:patch('APRICORN_1',{ball='BALL_202',event=5100,index=1}) + mod.exports.seen={} + mod.exports.stop=mod.events:on("apricorn.converted",function(e) + mod.exports.seen[#mod.exports.seen+1]={apricorn=e.apricorn,ball=e.ball,event=e.event} + e.apricorn='LEAK';e.ball='LEAK';e.event=123;return false + end) +]] +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture(version);local originalScripts=d.map_scripts.scripts + local originalRows=d.map_scripts.apricornBalls + local oldCompiled=assert(VM.compile(d,'PENDING')) + local r=load(d,{{'recipes',source}},mode);local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' native registry and event registration load') + if #r.errors==0 then + local e=r.loader.exports.recipes + T.eq(#d.map_scripts.apricornBalls,8,label..' actual selector receives all eight native recipes') + T.eq(d.map_scripts.apricornBalls[8].apricorn,'ITEM_173',label..' semantic input resolves to a real native item byte') + T.check(d.map_scripts.scripts~=originalScripts,label..' projection changes VM cache identity') + T.check(VM.compile(d,'PENDING')~=oldCompiled,label..' an already compiled root is recompiled') + T.eq(originalScripts.ASK[3][2],112,label..' original native branches are not overwritten') + local g=game(d,{ITEM_173=3}) + local runner=run(g,'OVERLAPPING') + T.eq(g.stack:top().items[1].label,'BERRY x3',label..' overlapping native membership root accepts the eighth item') + press(g,'a') + if version=='crystal'then press(g,'down');press(g,'down');press(g,'a')end + local quantity=version=='crystal' and 3 or 1 + T.eq(runner:isRunning(),false,label..' actual native selection finishes') + T.eq(runner.ctx.g2Var,173,label..' VM retains actual item byte without fake ordinal remapping') + T.eq(g.save.g2KurtApricorns,quantity,label..' native handover quantity survives projection') + T.eq(g.save.flags[Flags.eventFlag(5000)],true,label..' native selection sets the authored word-sized event') + T.eq(g.save.flags[Flags.scriptFlag(working(version))],true,label..' native GAVE working flag is preserved') + run(g,'PENDING') + T.eq(g.save.inventory.ITEM_202,nil,label..' working native branch defers delivery') + T.eq(#e.seen,0,label..' waiting job cannot emit a converted event') + require('src.script.Gen2Daily').onNewDay(g.save) + T.eq(g.save.flags[Flags.scriptFlag(working(version))],nil,label..' actual daily reset clears native work') + g.save.inventory.ITEM_202=99;run(g,'PENDING') + T.eq(g.save.flags[Flags.eventFlag(5000)],true,label..' actual bag refusal retains the job event') + T.eq(#e.seen,0,label..' refused native gift cannot emit a converted event') + g.save.inventory.ITEM_202=nil;run(g,'PENDING') + T.eq(g.save.inventory.ITEM_202,quantity,label..' actual VM gives the authored ball in confirmed quantity') + T.eq(g.save.inventory.ITEM_201,nil,label..' template ball is not accidentally awarded') + T.eq(g.save.flags[Flags.eventFlag(5000)],false,label..' real native clear precedes completion') + T.eq(#e.seen,1,label..' successful native delivery emits exactly once') + local observed=e.seen[1] + T.eq(observed.apricorn,mode[1]==3 and 'BERRY' or 'ITEM_173',label..' observer receives the API-specific item identity') + T.eq(observed.ball,mode[1]==3 and 'BALL_202' or 'ITEM_202',label..' observer receives the actual delivered ball') + T.eq(observed.event,5000,label..' observer receives the actual cleared event') + run(g,'PENDING');T.eq(#e.seen,1,label..' cleared event prevents duplicate completion') + g=game(d,{ITEM_173=3});runner=run(g,'PENDING');press(g,'a') + if version=='crystal'then + press(g,'b');T.eq(g.save.inventory.ITEM_173,3,label..' quantity cancellation consumes nothing') + press(g,'b') + else -- Gold/Silver have already confirmed their one-item transaction + T.eq(g.save.inventory.ITEM_173,2,label..' Gold/Silver remain one-at-a-time') + end + if version=='crystal'then + T.eq(g.save.flags[Flags.eventFlag(5000)],nil,label..' cancellation cannot create a pending job') + end + local api=e.api.content.apricorns + T.check(not pcall(function()api:patch('APRICORN_1',{event=5300})end),label..' frozen content refuses late writes') + end + finish(r) + local clean=load(d) + T.eq(#clean.errors,0,label..' unloading reloads cleanly') + T.eq(d.map_scripts.scripts,originalScripts,label..' disabling restores original native script table') + T.eq(d.map_scripts.apricornBalls,originalRows,label..' disabling restores original native recipe table') + finish(clean) + end +end +V.set('crystal') +for _,bad in ipairs({ + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=65535,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=65536})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=-1,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=0/0,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=math.huge})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=601,index=8})", + "p:register('BAD',{apricorn='APRICORN_2',ball='BALL_202',event=5000,index=8})", + "p:register('BAD',{apricorn='MISSING',ball='BALL_202',event=5000,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='MISSING',event=5000,index=8})", + "p:register('BAD',{apricorn='BERRY',ball='BALL_202',event=5000,index=8,typo=true})", + "p:patch('APRICORN_1',{apricorn=mod.DELETE})", + "local x={};x.apricorn=x;p:register('BAD',x)", + "p:register('BAD',"..recipe..");error('entry failure')", +})do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'broken',"local p=mod.content.apricorns;p:patch('APRICORN_1',{event=5100});"..bad}}) + T.check(#r.errors>0,'invalid authored recipe is attributed to its owner: '..bad) + T.eq(d.map_scripts.scripts,scripts,'failed owner cannot leak projected native scripts') + T.eq(d.map_scripts.apricornBalls,rows,'failed owner rolls back prior valid recipe patches') + finish(r) +end +do + local d=fixture('crystal') + local r=load(d,{{'a_recipe',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5200,index=8})]]}, + {'z_item',[[mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0,pocket='ITEM'})]]}}) + T.eq(#r.errors,0,'semantic native item references may be provided by a later mod') + T.eq(d.map_scripts.apricornBalls[8] and d.map_scripts.apricornBalls[8].apricorn,'ITEM_174', + 'forward item reference resolves before actual native projection') + local g=game(d,{ITEM_174=1});local runner=run(g,'PENDING');press(g,'a');press(g,'a') + T.eq(runner.ctx.g2Var,174,'later native item executes through actual selection byte') + T.eq(g.save.flags[Flags.eventFlag(5200)],true,'forward recipe enters its actual pending event') + finish(r) +end +do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'a_recipe',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5200,index=8})]]}, + {'z_item',[[mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0,pocket='ITEM'});error('supplier failed')]]}}) + T.check(#r.errors>=2,'failed item supplier also attributes unresolved recipe to its owner') + T.eq(d.items.ITEM_174,nil,'failed supplier leaves no actual native item behind') + T.eq(d.map_scripts.scripts,scripts,'failed forward recipe leaves native script identity intact') + T.eq(d.map_scripts.apricornBalls,rows,'failed forward recipe leaves native table intact') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'no_byte',[[ + mod.content.items:register('AUTHORED_FRUIT',{id='AUTHORED_FRUIT',name='Authored fruit',price=0,pocket='ITEM'}) + mod.content.apricorns:register('UNINDEXED', + {apricorn='AUTHORED_FRUIT',ball='BALL_202',event=5200,index=8}) + ]]}}) + T.check(#r.errors>0,'an arbitrary semantic item cannot fabricate a native ROM byte') + T.eq(d.items.AUTHORED_FRUIT,nil,'unindexed recipe failure rolls back its item registration') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'a_first',[[ + mod.content.apricorns:register('FIRST',{apricorn='BERRY',ball='BALL_202',event=5200,index=8}) + ]]},{'z_conflict',[[ + mod.content.apricorns:register('SECOND',{apricorn='ITEM_173',ball='ITEM_201',event=5300,index=9}) + ]]}}) + T.check(#r.errors>0,'semantic/native aliases cannot register the same byte twice') + T.eq(d.map_scripts.apricornBalls[8] and d.map_scripts.apricornBalls[8].event,5200, + 'later conflicting owner rolls back without discarding the earlier valid owner') + T.eq(#d.map_scripts.apricornBalls,8,'duplicate alias never enters native menu') + finish(r) +end +do + local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'a_supplier',[[ + mod.content.items:register('ITEM_174',{id='ITEM_174',name='LATE FRUIT',price=0}) + mod.content.apricorns:register('BAD',{apricorn='BERRY',ball='MISSING',event=5200,index=8}) + ]]},{'b_consumer',[[mod.content.apricorns:register('FORWARD', + {apricorn='LATE_FRUIT',ball='BALL_202',event=5300,index=9})]]}}) + T.check(#r.errors>=2,'late recipe failure revalidates consumers after its item supplier rolls back') + T.eq(d.items.ITEM_174,nil,'late validation rolls back supplier item content') + T.eq(d.map_scripts.scripts,scripts,'cascading forward-reference failure leaves no projected script table') + T.eq(d.map_scripts.apricornBalls,rows,'cascading failure leaves pristine native rows') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'removed',[[mod.content.apricorns:remove('APRICORN_1') + mod.exports.count=0;mod.events:on("apricorn.converted",function()mod.exports.count=mod.exports.count+1 end)]]}}) + T.eq(#r.errors,0,'removal projects native membership and pending branches') + T.eq(#d.map_scripts.apricornBalls,6,'removed recipe cannot be newly selected') + local g=game(d,{ITEM_111=2});run(g,'PENDING') + T.eq(g.stack:top().name,'map','removed item no longer opens the native selector') + g.save.flags[Flags.eventFlag(600)]=true;g.save.g2KurtApricorns=2;run(g,'PENDING') + T.eq(g.save.inventory.ITEM_201,2,'removed recipe still delivers an already pending native job') + T.eq(r.loader.exports.removed.count,1,'retired pending job completes only after native gift') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,{{'recipes',source}}) + local scripts=d.map_scripts.scripts;local label + for key in pairs(scripts)do if key:match('^engine:apricorn:')then label=key;break end end + scripts[label][1]={'setval',77};local external=scripts[label] + d.map_scripts.apricornBalls[1].ball='ITEM_201' + finish(r);local clean=load(d) + T.eq(d.map_scripts.scripts[label],external,'reload preserves an externally edited generated label') + T.eq(d.map_scripts.apricornBalls[1].ball,'ITEM_201','reload preserves externally edited native rows') + T.eq(#d.map_scripts.apricornBalls,7,'one external edit cannot retain unchanged mod-only recipes on unload') + T.eq(d.map_scripts.apricornBalls[2].event,nil,'unload restores unchanged original records beside external edits') + finish(clean) +end +do + local d=fixture('crystal');local r=load(d,{{'recipes',source}}) + local added=d.map_scripts.apricornBalls[8];added.ball='ITEM_201' + table.remove(d.map_scripts.apricornBalls,2) + finish(r);local clean=load(d) + T.eq(#d.map_scripts.apricornBalls,7,'unload preserves external deletion and an externally edited addition') + T.eq(d.map_scripts.apricornBalls[1].event,nil,'other authored edits still retire beside an external deletion') + T.eq(d.map_scripts.apricornBalls[7],added,'external edit gives the added native record independent ownership') + finish(clean) +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture('crystal');local scripts,rows=d.map_scripts.scripts,d.map_scripts.apricornBalls + local r=load(d,{{'isolation',[[local p=mod.content["apricorns"];assert(not p:supports()) + assert(p:get('APRICORN_1')==nil);p:register('NO_WRITE',]]..recipe..[[) + assert(not p:supports())]]}}) + T.eq(#r.errors,0,version..' unsupported handle is readable and reports unavailable') + T.eq(d.map_scripts.scripts,scripts,version..' cannot mutate a lookalike native script pool') + T.eq(d.map_scripts.apricornBalls,rows,version..' cannot acquire Gen2 native recipes') + finish(r) +end +V.set('crystal') +do + local d=fixture('crystal');d.map_scripts.apricornPrograms=nil + local r=load(d,{{'oldcache',"mod.content.apricorns:register('NEW',"..recipe..")"}}) + T.check(#r.errors>0,'old cache reports missing native Kurt bindings to the writing owner') + finish(r) + r=load(d,{{'observer',"mod.events:on('apricorn.converted',function()end)"}}) + T.eq(#r.errors,0,'an observer alone cannot prevent an older cache from booting') + finish(r) +end +Commands.show_text=oldText;Sound.play=oldSound;V.set(original) +T.finish('native apricorn registry, actual Kurt VM transactions, completion observers and reload isolation') diff --git a/tests/engine/gen2_apricorn_native.lua b/tests/engine/gen2_apricorn_native.lua new file mode 100644 index 000000000..5a15f1da0 --- /dev/null +++ b/tests/engine/gen2_apricorn_native.lua @@ -0,0 +1,192 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local E=require('src.import.RomExtractorGen2');local Writer=require('src.import.LuaWriter') +local A=require('src.world.Gen2Apricorns');local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner');local Stack=require('src.core.StateStack') +local Commands=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') +local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do + local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end + if not name then break end +end +assert(markerFor) +local function extractor(version,bytes,address,length) + address=address or 0x4000 + local raw=string.rep('\0',address)..string.char(unpack(bytes)) + if length and #raw0 and 3-count or nil,version..' actual bag toss removes exactly the confirmed quantity') + T.eq(g.save.inventory.ITEM_119,1,version..' selection cannot consume another apricorn') + T.eq(g.stack:top().name,'map',version..' actual Menu/Runner returns to its native owner') + g,r=gameFor(version,{ITEM_117=3});g.save.g2KurtApricorns=0 + press(g,'down');press(g,'a') + T.eq(r.ctx.g2Var,0,version..' explicit CANCEL returns false') + T.eq(g.save.inventory.ITEM_117,3,version..' CANCEL preserves all bag items') + T.eq(g.save.g2KurtApricorns,0,version..' CANCEL cannot retain a previous quantity') + g,r=gameFor(version,{}) + T.eq(r:isRunning(),false,version..' empty native bag does not open an empty menu') + T.eq(r.ctx.g2Var,0,version..' empty native bag returns false') +end +do + local g,r=gameFor('crystal',{ITEM_117=3,ITEM_119=1}) + press(g,'a');press(g,'b') + T.eq(r:isRunning(),true,'Crystal quantity cancel returns to the actual apricorn menu') + T.eq(g.stack:top().items[1].label,'AUTHORED APRICORN x3','quantity cancel restores native list') + T.eq(g.save.inventory.ITEM_117,3,'quantity cancel does not silently take one apricorn') + press(g,'down');press(g,'a') + T.eq(#g.stack:top().items,1,'Crystal asks for quantity even when only one is held') + press(g,'a') + T.eq(r.ctx.g2Var,119,'selection after quantity cancel can choose a different actual item') + T.eq(g.save.inventory.ITEM_117,3,'reselection preserves the original apricorn stack') + T.eq(g.save.inventory.ITEM_119,nil,'reselection consumes the confirmed other apricorn') + g,r=gameFor('crystal',{ITEM_117=2});press(g,'a');g.save.inventory.ITEM_117=nil;press(g,'a') + T.eq(r.ctx.g2Var,0,'concurrent native bag removal cannot create a paid Kurt job') + T.eq(g.save.g2KurtApricorns,0,'failed revalidation cannot mint a job quantity') + g,r=gameFor('crystal',{ITEM_117=3});press(g,'a');press(g,'down');press(g,'down');press(g,'a') + local event=Flags.eventFlag(604);g.save.flags={[event]=true};g.save.inventory.ITEM_202=97 + local function collect() + local runner=Runner.new(g,g.overworld);runner:run(assert(VM.compile(g.data,'COLLECT')));return runner + end + collect() + T.eq(g.save.inventory.ITEM_202,97,'actual verbosegiveitemvar refuses the whole overflowing ball stack') + T.eq(g.save.flags[event],true,'failed native ball delivery preserves the recipe event') + g.save.inventory.ITEM_202=96;collect() + T.eq(g.save.inventory.ITEM_202,99,'actual native delivery grants the confirmed three-ball quantity') + T.eq(g.save.flags[event],false,'successful native script clears its real event') + collect();T.eq(g.save.inventory.ITEM_202,99,'native event guard prevents a duplicate ball collection') + g,r=gameFor('crystal',{ITEM_117=3,ITEM_119=1}) + press(g,'down');press(g,'a');press(g,'b') + T.eq(g.stack:top().index,2,'Crystal quantity cancel retains the selected apricorn cursor') + press(g,'b') + T.eq(r.ctx.g2Var,0,'cancelling the restored list cancels the native transaction') + T.eq(g.save.inventory.ITEM_119,1,'cancelling restored list cannot remove the selected apricorn') + g,r=gameFor('crystal',{ITEM_117=200});press(g,'a') + T.eq(#g.stack:top().items,99,'native Crystal quantity caps at99 for an oversized historical stack') + T.eq(g.stack:top().maxVisible,5,'quantity input uses bounded scrolling instead of extending offscreen') + press(g,'up');press(g,'a') + T.eq(g.save.g2KurtApricorns,99,'actual Menu wrap can confirm the native maximum quantity') + T.eq(g.save.inventory.ITEM_117,101,'capped native handover removes no unconfirmed excess') + local rows,inventory={},{} + for i=1,7 do + local key=string.format('ITEM_%03d',110+i);rows[i]={index=i,apricorn=key,ball='ITEM_202'};inventory[key]=1 + end + g,r=gameFor('crystal',inventory,rows) + T.eq(#g.stack:top().items,8,'full native list retains seven recipes and CANCEL') + T.eq(g.stack:top().maxVisible,4,'Crystal recipe list uses four visible scrolling rows') + press(g,'up') + T.eq(g.stack:top().index,8,'actual recipe cursor wraps to CANCEL') + T.eq(g.stack:top().scroll,4,'actual recipe scroll keeps wrapped CANCEL visible') + press(g,'a');T.eq(r.ctx.g2Var,0,'visible wrapped CANCEL exits the native special') +end +for _,version in ipairs({'prism','polishedcrystal'})do + local g,r=gameFor(version,{ITEM_085=2},{{index=1,apricorn='ITEM_085',ball='ITEM_202'}}) + press(g,'a');press(g,'b') + T.eq(r.ctx.g2Var,85,version..' unreviewed hack retains its legacy quantity-cancel protocol') + T.eq(g.save.g2KurtApricorns,1,version..' hack quantity-cancel behavior is not silently converted to Crystal') + T.eq(g.save.inventory.ITEM_085,1,version..' hack legacy selection still consumes its original single item') +end +Commands.show_text=oldText;require('src.core.Sound').play=oldSound;V.set(original) +T.finish('native ApricornBalls importer, actual Kurt menus, cartridge quantities and native ball delivery') diff --git a/tests/engine/gen2_apricorn_programs_import.lua b/tests/engine/gen2_apricorn_programs_import.lua new file mode 100644 index 000000000..4154e8726 --- /dev/null +++ b/tests/engine/gen2_apricorn_programs_import.lua @@ -0,0 +1,59 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-apricorn-selection.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local E=require('src.import.RomExtractorGen2') +local V=require('src.core.GameVersion');local original=V.get() +local Importer=require('src.import.RomImporter');local P=require('src.world.Gen2ApricornPrograms') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end;if not name then break end +end +assert(markerFor) +local names={'Kurt1.AskApricorn','Kurt1.GotLureBall','Kurt1.GaveKurtApricorns','Kurt1.Cancel'} +local function extractor(version,missing,outside) + local symbols={} + for i,name in ipairs(names)do if i~=missing then + symbols[name]={1,outside==i and 0x8000 or outside==-i and 0x100 or 0x4000+i} + end end + return E.new(string.rep('\0',0x8000),version,{symbols=symbols}) +end +local function pool()return{scripts={},queue={}}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local q=pool();local e=extractor(version) + local roles=e:gen2QueueApricornPrograms(q) + T.eq(#q.queue,4,version..' all native Kurt entry points enter the real decoder queue') + for _,role in ipairs({'ask','pending','gave','cancel'})do + T.check(type(roles[role])=='string',version..' '..role..' retains the actual native script key') + end + e:gen2QueueApricornPrograms(q) + T.eq(#q.queue,4,version..' repeated roots do not duplicate native decode work') + for i=1,4 do + q=pool();T.eq(extractor(version,i):gen2QueueApricornPrograms(q),nil,version..' missing role cannot advertise partial bindings') + T.eq(#q.queue,0,version..' missing role is discovered before any queue mutation') + q=pool();T.eq(extractor(version,nil,i):gen2QueueApricornPrograms(q),nil,version..' invalid bank address is rejected') + T.eq(#q.queue,0,version..' invalid role cannot add a partial native queue') + q=pool();T.eq(extractor(version,nil,-i):gen2QueueApricornPrograms(q),nil,version..' home-bank engine code is not native script bytecode') + T.eq(#q.queue,0,version..' home-bank rejection precedes queue mutation') + end + local marker=markerFor(version) + T.check(marker:find('gen2%-apricorn%-programs%-v1:')~=nil,version..' cache includes decoded Kurt binding revision') + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',marker:gsub('gen2%-apricorn%-programs%-v1:','')) + local report=Importer.readyReport(version) + T.eq(report.ok,false,version..' old recipe-only cache cannot bypass native program reimport') + T.check(report.why:find('another version',1,true)~=nil,version..' cache revision is checked before generated-file readiness') +end +for _,version in ipairs({'prism','polishedcrystal','red','emerald'})do + local q=pool();T.eq(extractor(version):gen2QueueApricornPrograms(q),nil,version..' foreign layout has no guessed Kurt bindings') + T.eq(#q.queue,0,version..' foreign decoder queue is unchanged') + T.check(not markerFor(version):find('gen2%-apricorn%-programs%-v1:'),version..' unrelated import marker is unchanged') +end +for _,ask in ipairs({{},{{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'},false}, + {{'special',86,'SelectApricornForKurt'},{'ifequal',0,'CANCEL'},false,{'sjump','GAVE'}}})do + local d={map_scripts={apricornPrograms={ask='ASK',pending='PENDING',gave='GAVE',cancel='CANCEL'}, + scripts={ASK=ask,PENDING={},GAVE={},CANCEL={}},apricornBalls={{apricorn='ITEM_111',ball='ITEM_201'}}}} + local ok,description,why=pcall(P.describe,d) + T.eq(ok,true,'malformed native selection reports a binding error without crashing mod-free load') + T.eq(description,nil,'malformed native selection cannot produce a writable native projection') + T.check(type(why)=='string','malformed selection explains the unavailable native target') +end +V.set(original) +T.finish('native Kurt program import queue, completeness, cache revisions and malformed signatures') diff --git a/tests/engine/gen2_clock.lua b/tests/engine/gen2_clock.lua new file mode 100644 index 000000000..7a64bc4ad --- /dev/null +++ b/tests/engine/gen2_clock.lua @@ -0,0 +1,124 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local V = require('src.core.GameVersion');local version = V.get() +require('src.core.ModCompat').install() +local Clock = require('src.core.gen2.Clock') +local P = require('src.render.PaletteFX');local W = require('src.world.OverworldController') +require('src.script.Gen2Commands');local Commands = require('src.script.Commands');local Loader = require('src.mods.Loader') +local Json = require('src.link.Json');local Save = require('src.core.SaveData') +local date,time = os.date,os.time;local elapsed=0;local baseTime=time() +os.time=function(value)if value then return time(value)end;return baseTime+elapsed end +local host = { hour=13, minute=20, day=2 } +os.date = function(format, now) + if now and now~=os.time() then return date(format,now) end + if format=='%H' then return tostring(host.hour) end + if format=='%M' then return tostring(host.minute) end + if format=='%w' then return tostring(host.day) end + return date(format,now) +end +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.timeOfDay,i) + if name=='Game' then slot=i;oldGame=value;break end + if not name then break end +end +assert(slot) +local nativeGame=require('src.core.Game');local oldSave=nativeGame.save +T.eq(Clock,require('src.core.Gen2Clock'),'public name shares actual native clock') +T.eq(Clock.minutes(),800,'no save reads host RTC') +T.eq(Clock.isSet({}),false,'old save has no fabricated clock anchor') +local malformed={rtc='invalid'} +T.eq(Clock.hour(malformed),13,'malformed optional clock falls back to host') +T.eq(Clock.setTime(malformed,0/0,10),false,'NaN cannot poison saved clock') +T.eq(malformed.rtc,'invalid','refused write is atomic') +T.eq(Clock.setTime(false,10,0),false,'no saved state means no write') +T.eq(Clock.setWeekday({},math.huge),false,'infinite weekday rejected') +T.eq(Clock.weekdayName(0),nil,'weekday label API is one-based') +for _,edition in ipairs({'gold','silver','crystal'}) do + V.set(edition);host.hour=13;host.minute=20;host.day=2;elapsed=0 + local save={playTime=0,g2DayOffset=3};local untouched=Json.encode(save) + T.eq(Clock.hour(save),13,edition..' legacy hour retains host behavior') + T.eq(Clock.weekday(save),5,edition..' legacy native weekday offset retained') + T.eq(Json.encode(save),untouched,edition..' clock reads do not seed saved state') + T.eq(Clock.setTime(save,23,55),true,edition..' clock setting anchors native RTC') + T.eq(Clock.setWeekday(save,6),true,edition..' day picker stores native RTC base') + T.eq(Clock.hour(save),23,edition..' saved hour is observed immediately') + T.eq(Clock.minute(save),55,edition..' saved minute is observed immediately') + T.eq(Clock.weekday(save),6,edition..' weekday is observed immediately') + T.eq(Clock.isSet(save),true,edition..' explicit clock setting is distinguishable') + T.eq(save.g2DayOffset,save.rtc.startDay,edition..' old native day readers retain shared offset') + host.minute=30;elapsed=600 + T.eq(Clock.minutes(save),5,edition..' RTC keeps ticking and wraps at midnight') + T.eq(Clock.weekday(save),0,edition..' saved weekday rolls over at game midnight before host midnight') + local round=assert(Save.decode(Save.encode(save))) + T.eq(Clock.minutes(round),5,edition..' save roundtrip retains RTC minute base') + T.eq(Clock.weekday(round),0,edition..' save roundtrip retains RTC weekday base') + local game={data=T.fixtures.fresh(),save=save} + debug.setupvalue(W.timeOfDay,slot,game);nativeGame.save=save + local world=setmetatable({map={id='RTC',def={mapPalette=0}},player={cellX=0,cellY=0}}, {__index=W}) + -- Control only graphics IO; native clock, period selection and commands run. + world.syncGen2Tod=function()end + for _,case in ipairs({{3,59,'NITE',4},{4,0,'MORNING',1},{9,59,'MORNING',1}, + {10,0,'DAY',2},{17,59,'DAY',2},{18,0,'NITE',4}})do + Clock.setTime(save,case[1],case[2]) + T.eq(world:timeOfDay(),case[3],edition..' native world reads saved boundary '..case[1]) + T.eq(W.clockTimeOfDayBit(),case[4],edition..' object-load bit uses same saved boundary '..case[1]) + T.eq(world.hour(),case[1],edition..' legacy world dot hour agrees') + T.eq(world:minute(),case[2],edition..' legacy world colon minute agrees') + T.eq(P.clockDaytime(),case[3]=='MORNING' and 'MORN' or case[3],edition..' scene palette raw RTC agrees') + T.eq(P.clockDaytime(12),'DAY',edition..' explicit scene hour remains authoritative') + local ctx={save=save,overworld=world} + Commands.g2_readvar(ctx,10);T.eq(ctx.g2Var,case[1],edition..' actual VAR_HOUR agrees') + Commands.g2_readvar(ctx,11);T.eq(ctx.g2Var,Clock.weekday(save),edition..' actual VAR_WEEKDAY agrees') + Commands.g2_checktime(ctx,case[4]);T.eq(ctx.lastCheck,true,edition..' actual checktime agrees') + T.eq(require('src.pokemon.Evolution').isNight(save),case[4]==4,edition..' happiness time gate agrees') + end + -- Native clock-card composition must show RTC, even with zero play time. + Clock.setTime(save,21,47);Clock.setWeekday(save,4) + local text={};local Gear=require('src.ui.PokegearMenu') + local gear=setmetatable({game=game,blink=0,gfx={cards={clock={}}}}, {__index=Gear}) + for _,method in ipairs({'drawTilemap','drawStrip','textbox','printBoxText','drawModeArrow'})do gear[method]=function()end end + gear.text=function(_,value,x,y)text[x..':'..y]=value end + gear:drawClock() + T.eq(text['6:6'],'THURSDAY',edition..' actual clock card shows selected weekday') + T.eq(text['6:8'],' 9',edition..' actual clock card shows RTC hour rather than play hours') + T.eq(text['9:8'],'47',edition..' actual clock card shows RTC minute rather than play minutes') + T.eq(text['12:8'],'PM',edition..' actual clock card shows RTC half-day') + for _,mode in ipairs({{1,'native'},{2,'native'},{2,'unified'},{3,'unified'}}) do + local files={['mods/rtc/manifest.json']=Json.encode({id='rtc',name='Native RTC observer',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/rtc/main.lua']=[[ + local mod=...;assert(mod:supports('events','clock.day_changed')) + local Clock=require('src.core.gen2.Clock');mod.exports.events={} + mod.exports.stop=mod.events:on('clock.day_changed',function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99;return false + end) + ]]} + local result=T.sdk.loadMods({'mods/rtc'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + local label=edition..'/'..mode[1]..'/'..mode[2] + T.eq(#result.errors,0,label..' actual loader admits public clock module/event') + local e=result.loader.exports.rtc;local own={};e.save=own + Clock.weekday(own);T.eq(#e.events,0,label..' first read is not a fictitious rollover') + host.day=(host.day+1)%7 + Clock.weekday(own);T.eq(#e.events,1,label..' real read observes rollover exactly once') + T.eq(e.events[1].reason,'rollover',label..' rollover reason supplied') + Clock.weekday(own);T.eq(#e.events,1,label..' reentrant/repeated reads do not repeat event') + Clock.weekday({});T.eq(#e.events,1,label..' another profile starts silently') + local selected=(Clock.weekday(own)+2)%7 + Clock.setWeekday(own,selected) + T.eq(#e.events,2,label..' native day setter observes one change') + T.eq(e.events[2].reason,'set',label..' explicit setter reason supplied') + T.eq(Clock.weekday(own),selected,label..' listener mutation cannot corrupt native weekday') + e.stop();host.day=(host.day+1)%7;Clock.weekday(own) + T.eq(#e.events,2,label..' unsubscribe retires actual observation') + Loader.teardown();result.release() + end +end +for _,edition in ipairs({'red','firered','emerald','heartgold'})do + V.set(edition) + T.eq(require('src.mods.Capabilities').supports('events','clock.day_changed',edition),false,edition..' native RTC event not falsely advertised') +end +nativeGame.save=oldSave;debug.setupvalue(W.timeOfDay,slot,oldGame) +Clock.resetObservations();os.date=date;os.time=time;V.set(version) +T.finish('shared native Gen2 saved RTC, actual world/scripts/clock-card and public event') diff --git a/tests/engine/gen2_clock_lifecycle.lua b/tests/engine/gen2_clock_lifecycle.lua new file mode 100644 index 000000000..1815bf1da --- /dev/null +++ b/tests/engine/gen2_clock_lifecycle.lua @@ -0,0 +1,119 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local version=V.get() +local C=require('src.core.Gen2Clock');local D=require('src.script.Gen2Daily') +local Save=require('src.core.SaveData');local Json=require('src.link.Json') +local date,time=os.date,os.time +-- A deterministic host clock. Date-table normalization still uses the OS +-- calendar, including real DST rules; elapsed time is independently advanced. +local now=time({year=2026,month=10,day=20,hour=13,min=20,sec=0}) +local origin=now;local shift=0 +os.time=function(value)if value then return time(value)end;return now end +os.date=function(format,stamp)return date(format,(stamp or now)+shift)end +for _,edition in ipairs({'gold','silver','crystal'})do + V.set(edition);now=origin;shift=0 + local save={flags={},g2FruitTrees={used=true}} + T.eq(C.setTime(save,23,59),true,edition..' player can anchor before midnight') + T.eq(C.setWeekday(save,4),true,edition..' player chooses Thursday independently of host') + local startDate=C.dateKey(save);local offset=save.rtc.startMinute + T.eq(C.hour(save),23,edition..' explicit hour visible immediately') + T.eq(C.minute(save),59,edition..' explicit minute visible immediately') + D.poll(save);save.g2FruitTrees.used=true + local before=save.g2DailyDay + now=now+59;D.poll(save) + T.eq(C.minute(save),59,edition..' RTC waits for the full elapsed minute') + T.eq(C.weekday(save),4,edition..' weekday remains before game midnight') + T.eq(save.g2FruitTrees.used,true,edition..' daily use retained before midnight') + now=now+1;D.poll(save) + T.eq(C.hour(save),0,edition..' elapsed RTC crosses midnight') + T.eq(C.minute(save),0,edition..' minute wraps with the day') + T.eq(C.weekday(save),5,edition..' weekday crosses at same game midnight') + T.eq(C.hostWeekday(),C.hostWeekday(origin),edition..' host has not crossed midnight') + T.eq(save.g2FruitTrees.used,nil,edition..' native daily reset occurs at game midnight') + T.eq(save.g2DailyDay~=before,true,edition..' daily calendar uses the game boundary') + T.eq(C.dateKey(save)~=startDate,true,edition..' RTC calendar advances at same boundary') + save.g2FruitTrees.used=true;D.poll(save) + T.eq(save.g2FruitTrees.used,true,edition..' repeated poll cannot repeat daily reset') + local round=assert(Save.decode(Save.encode(save))) + now=now+7*86400+123*60 + T.eq(C.weekday(round),5,edition..' offline week retains proper weekday') + T.eq(C.hour(round),2,edition..' offline elapsed hours retained after save reload') + T.eq(C.minute(round),3,edition..' offline elapsed minutes retained after save reload') + local stale=round.g2DailyDay;D.poll(round) + T.eq(round.g2DailyDay~=stale,true,edition..' offline whole week still resets daily identity') + T.eq(round.g2FruitTrees.used,nil,edition..' offline reset does not depend on changed weekday') + local expected=C.minutes(round);local day=C.weekday(round);local calendar=C.dateKey(round) + shift=3600 + T.eq(C.minutes(round),expected,edition..' automatic host DST jump cannot double-advance game RTC') + T.eq(C.weekday(round),day,edition..' host DST cannot independently shift game weekday') + T.eq(C.dateKey(round),calendar,edition..' host DST cannot expire daily identity') + shift=-3600 + T.eq(C.minutes(round),expected,edition..' backward host DST jump cannot freeze game RTC') + shift=0 + T.eq(C.setTime(round,7,15),true,edition..' explicit edit reanchors current native clock') + T.eq(C.weekday(round),day,edition..' editing time preserves the chosen current weekday') + T.eq(C.dateKey(round),calendar,edition..' editing time preserves current game calendar date') + T.eq(C.hour(round),7,edition..' edited hour visible') + T.eq(C.minute(round),15,edition..' edited minute visible') + now=now+2*86400+45*60 + T.eq(C.weekday(round),(day+2)%7,edition..' future days follow new anchor') + T.eq(C.minutes(round),8*60,edition..' new anchor keeps ticking') + local oldDate=C.dateKey(round);C.setWeekday(round,0) + T.eq(C.weekday(round),0,edition..' weekday editing after offline elapsed days is immediate') + T.eq(C.dateKey(round),oldDate,edition..' weekday wheel does not rewrite calendar age') + C.setTime(round,23,59);now=now+60 + T.eq(C.weekday(round),1,edition..' edited weekday and time cross next midnight together') + -- The weekly service has to expire on the selected in-game Friday, not a + -- host Friday that is several hours out of phase. + now=origin;local lottery={flags={}};C.setTime(lottery,23,59);C.setWeekday(lottery,4) + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' fresh lottery remains active Thursday') + now=now+60 + T.eq(C.weekday(lottery),5,edition..' weekly service reaches selected Friday') + T.eq(D.lotteryExpired(lottery),true,edition..' native lottery expires at game Friday midnight') + D.resetLottery(lottery);T.eq(D.lotteryExpired(lottery),false,edition..' Friday draw waits for next Friday') + now=now+6*86400 + T.eq(D.lotteryExpired(lottery),false,edition..' lottery does not expire a day early') + now=now+86400 + T.eq(D.lotteryExpired(lottery),true,edition..' next game Friday expires weekly draw') + -- Actual subscription dispatch must observe a game midnight even when + -- the host weekday is unchanged, and an offline full week too. + for _,api in ipairs({{1,'native'},{2,'native'},{2,'unified'},{3,'unified'}})do + local files={['mods/day/manifest.json']=Json.encode({id='day',name='Native midnight observer',version='1.0.0',api=api[1],content_format=api[2],entry='main.lua',affects_link=false}), + ['mods/day/main.lua']=[[ + local mod=...;mod.exports.events={} + local Clock=require('src.core.gen2.Clock') + mod.events:on("clock.day_changed",function(e) + mod.exports.events[#mod.exports.events+1]={day=e.day,previous=e.previous,reason=e.reason} + assert(Clock.weekday(mod.exports.save)==e.day) + e.day=99 + end) + ]]} + local loaded=T.sdk.loadMods({'mods/day'},{data=T.fixtures.fresh(),fs=T.sdk.memfs(files)}) + T.eq(#loaded.errors,0,edition..' real loader admits midnight observer') + local export=loaded.loader.exports.day;local own={};export.save=own + now=origin;C.setTime(own,23,59);C.setWeekday(own,4) + -- Explicit staging changes may emit; only changes after the first settled + -- observation are under test here. + C.weekday(own);local n=#export.events;now=now+60;C.weekday(own) + T.eq(#export.events,n+1,edition..' midnight event dispatched once through actual runtime') + T.eq(export.events[n+1].day,5,edition..' midnight event carries new game weekday') + T.eq(export.events[n+1].previous,4,edition..' midnight event retains previous game weekday') + C.weekday(own);T.eq(#export.events,n+1,edition..' reentrant/repeated reads cannot repeat midnight') + now=now+7*86400;C.weekday(own) + T.eq(#export.events,n+2,edition..' offline full week still emits one observed day change') + T.eq(export.events[n+2].day,5,edition..' full-week event correctly has same weekday') + require('src.mods.Loader').teardown();loaded.release() + end + -- Old offsets and old saves remain read-only on load, without introducing + -- an epoch guessed from today's time into another player's save. + local old={rtc={startMinute=offset,startDay=2},g2DayOffset=2} + local bytes=Json.encode(old) + T.eq(C.minutes(old),(C.hostMinutes()+offset)%1440,edition..' old minute offset retains exact semantics') + T.eq(C.weekday(old),(C.hostWeekday()+2)%7,edition..' old weekday offset retains exact semantics') + C.dateKey(old);T.eq(Json.encode(old),bytes,edition..' old save reads do not migrate or mutate state') + local malformed={rtc={anchorTime='bad',anchorMinute=0,anchorDay=0,startMinute=0}} + T.eq(C.minutes(malformed),C.hostMinutes(),edition..' malformed optional anchor falls back to old clock') +end +os.date=date;os.time=time;C.resetObservations();V.set(version) +T.finish('native RTC midnight, offline calendar, host DST isolation and actual daily/lottery lifecycle') diff --git a/tests/engine/gen2_decoration_content.lua b/tests/engine/gen2_decoration_content.lua new file mode 100644 index 000000000..2b5162754 --- /dev/null +++ b/tests/engine/gen2_decoration_content.lua @@ -0,0 +1,194 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local old=V.get() +local Native=require('src.world.Gen2Decorations');local E=require('src.import.RomExtractorGen2') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Commands=require('src.script.Commands');local Flags=require('src.script.Gen2Flags') +local Stack=require('src.core.StateStack');local TextBox=require('src.render.TextBox') +local Merge=require('src.mods.Merge') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='CANCEL',flag=0,sprite=0}, + [1]={type=1,name='STORE BED',action='PUT_AWAY_BED',flag=0,sprite=0}, + [2]={type=2,name='SYNTHETIC',action='SET_UP_BED',flag=500,sprite=0}, + [3]={type=2,name='OTHER',action='SET_UP_BED',flag=501,sprite=44}, + [4]={type=1,name='STORE DOLL',action='PUT_AWAY_DOLL',flag=0,sprite=0}, + [5]={type=5,name='MODEL',action='SET_UP_DOLL',flag=502,sprite=18}, + [6]={type=1,name='CONSOLE',action='SET_UP_CONSOLE',flag=503,sprite=20}, + [7]={type=1,name='MAP',action='SET_UP_POSTER',flag=504,sprite=22}, + [8]={type=1,name='RUG',action='SET_UP_CARPET',flag=505,sprite=255}, + [9]={type=1,name='PLANT',action='SET_UP_PLANT',flag=506,sprite=24}, + [10]={type=6,name='MODEL',action='SET_UP_BIG_DOLL',flag=507,sprite=26}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=2,poster=7}, + giftsComplete=true,gifts={2,3,5},sourceNames={[5]='MODEL'},species={[5]='SPECIES_001'}}, + decorationPrograms={posters={[7]='POSTER'},null='NULL',ornament='ORNAMENT',big='BIG', + texts={put='PUT',empty='EMPTY',setup='SET',replace='REPLACE',same='SAME',sidePut='SIDE_PUT',sideSetup='SIDE_SET'}}, + scripts={CALLBACK={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'endcallback'}}, + DESC={{'describedecoration',1}},POSTER={{'setval',70},{'end'}},NULL={{'setval',71},{'end'}}, + ORNAMENT={{'setval',72},{'end'}},BIG={{'setval',73},{'end'}}, + PC={{'special',0,'PlayersHousePC'},{'iftrue','RELOAD'},{'end'}},RELOAD={{'warp','NONE',0,0,0},{'end'}}}} + d.text={PUT='{RAM:wStringBuffer3}',SET='{RAM:wStringBuffer3}',REPLACE='{RAM:wStringBuffer3}/{RAM:wStringBuffer4}', + SAME='SYNTHETIC SAME',EMPTY='SYNTHETIC EMPTY',SIDE_SET='SYNTHETIC SIDE SET',SIDE_PUT='SYNTHETIC SIDE PUT'} + d.pokemon.SPECIES_001={name='RENAMED'} + return d +end +local function load(d,code,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/deco/manifest.json']=Json.encode({id='deco',name='Synthetic decorations',version='1.0.0', + api=mode and mode[1]or 2,content_format=mode and mode[2]or 'native',entry='main.lua',affects_link=false}) + files['mods/deco/main.lua']='local mod=...;mod.exports.api=mod;'..code;paths={'mods/deco'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d) + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='map'}) + local g={data=d,save={flags={},inventory={}},stack=stack,input={wasPressed=function()return false end}} + g.overworld={map={id='ROOM'},player={cellX=4,cellY=5,facing='left'},paint={},reloaded=0} + function g.overworld:replaceBlock(x,y,n)self.paint[x..','..y]=n end + function g.overworld:reloadMap(id)assert(id=='ROOM');self.reloaded=self.reloaded+1;Native.visibility({game=g,save=g.save});Native.tiles({game=g,save=g.save,overworld=self})end + return g +end +local function run(g,label) + local r=Runner.new(g,g.overworld);r:run(assert(VM.compile(g.data,label)));return r +end +local function choose(g,label) + local menu=g.stack:top() + for i,row in ipairs(menu.items)do if row.label==label then menu.index=i;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0) + g.input={wasPressed=function()return false end};return end end + error('missing menu choice '..label) +end +local function dismiss(g) + local box=g.stack:top();assert(getmetatable(box)==TextBox);box.done=true;g.input={wasPressed=function(_,key)return key=='a'end};g.stack:update(0);g.input={wasPressed=function()return false end} +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture();local before=d.map_scripts.decorations + local r=load(d,[[ + local p=mod.content["decorations"] + assert(p:supports()) + local copy=p:get('deco:2');copy.sprite=199;assert(p:get('deco:2').sprite==0) + p:patch('deco:2',{sprite=88}) + p:register('deco:200',{type=1,name='ADDED',flag=900,action='SET_UP_PLANT',sprite=77}) + ]],mode) + local label=version..' API '..mode[1]..' '..mode[2] + T.eq(#r.errors,0,label..' actual Loader accepts native DECO registry') + T.eq(d.map_scripts.decorations[2].sprite,88,label..' patch reaches actual native table') + T.eq(d.map_scripts.decorations[200].flag,900,label..' new byte reaches actual native table') + local g=game(d);T.eq(Native.state(d,g.save).bed,2,label..' initializes cartridge default once') + T.check(Native.give(d,g.save,200),label..' give owns authored record through actual event') + T.eq(g.save.flags[Flags.eventFlag(900)],true,label..' ownership is the authored flag') + T.check(Native.choose(d,g.save,200),label..' owned authored placement succeeds') + run(g,'CALLBACK') + T.eq(g.overworld.paint['3,2'],77,label..' actual VM callback paints authored block') + T.eq(g.overworld.paint['0,2'],88,label..' actual VM callback paints patched bed') + T.eq(g.save.flags[Flags.eventFlag(1857)],true,label..' empty console object is hidden') + T.eq(g.save.flags[Flags.eventFlag(716)],true,label..' placed poster enables native event') + finish(r) + local clean=load(d) + T.eq(d.map_scripts.decorations,before,label..' disabling restores pristine table identity') + T.eq(d.map_scripts.decorations[200],nil,label..' disabling removes owned addition') + finish(clean) + end + local d=fixture();local g=game(d) + T.eq(Native.choose(d,g.save,3),false,version..' unowned placement fails') + T.eq(Native.give(d,g.save,0),false,version..' CANCEL cannot be awarded') + T.eq(Native.give(d,g.save,1),false,version..' PUT header cannot be awarded') + T.check(Native.giveFlag(d,g.save,1),version..' actual gift ordinal awards real DECO byte') + T.eq(Native.owns(d,g.save,3),true,version..' gift ownership reaches event bit') + T.eq(Native.name(d,5),'RENAMED DOLL',version..' imported species identity follows actual name') + d.map_scripts.decorations[5].name='AUTHORED';T.eq(Native.name(d,5),'AUTHORED DOLL',version..' authored name wins old imported source') + for id=2,10 do Native.give(d,g.save,id)end + local state=Native.state(d,g.save) + state.leftOrnament=5;state.rightOrnament=5 + T.eq(Native.choose(d,g.save,5,'left'),false,version..' already placed doll is not a change') + T.eq(state.rightOrnament,5,version..' already placed doll does not mutate opposite side') + state.leftOrnament=0 + T.eq(Native.choose(d,g.save,5,'left'),true,version..' owned doll moves sides') + T.eq(state.rightOrnament,0,version..' real move clears opposite duplicate') + Native.choose(d,g.save,6);Native.choose(d,g.save,8);Native.choose(d,g.save,10) + local callback=run(g,'CALLBACK') + T.eq(g.save.gen2VarSprites[0],20,version..' console writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[1],18,version..' left doll writes actual native variable sprite') + T.eq(g.save.gen2VarSprites[3],26,version..' big doll writes actual native variable sprite') + T.eq(g.overworld.paint['0,2'],0,version..' zero is valid block for nonzero placed DECO') + T.eq(g.overworld.paint['0,1'],0,version..' carpet offsets wrap at byte boundary') + T.eq(g.overworld.paint['1,1'],1,version..' carpet second offset wraps at byte boundary') + local desc=run(g,'DESC') + T.eq(desc.ctx.g2Var,72,version..' native describedecoration jumps to imported ornament script') + T.eq(g.stringBuffers[3],'AUTHORED DOLL',version..' native description fills real buffer three') + -- Real menus, native VM, TextBox and StateStack; only render/audio are harness IO. + g=game(fixture());Native.give(g.data,g.save,3);Native.give(g.data,g.save,5) + local pc=run(g,'PC') + T.check(pc:isRunning(),version..' native house-PC script waits for menu closure') + T.eq(pc.ctx.g2Var,nil,version..' native result is not guessed before selection') + choose(g,'DECORATION');choose(g,'BED');choose(g,'OTHER BED') + T.eq(Native.state(g.data,g.save).bed,2,version..' selection waits for original feedback box') + T.eq(g.stringBuffers[3],'SYNTHETIC BED',version..' replacement feedback buffers old name') + T.eq(g.stringBuffers[4],'OTHER BED',version..' replacement feedback buffers new name') + dismiss(g) + T.eq(g.save.decorations.bed,3,version..' acknowledging feedback commits native placed byte') + T.eq(g.overworld.reloaded,0,version..' furniture remains unchanged before PC exit') + choose(g,'ORNAMENT');choose(g,'RENAMED DOLL') + local prompt=g.stack:top();prompt.done=true;prompt:update(0) + choose(g,'RIGHT SIDE');dismiss(g) + T.eq(g.save.decorations.rightOrnament,5,version..' actual native side menu commits right doll') + choose(g,'EXIT') + T.check(pc:isRunning(),version..' leaving decoration submenu does not resume PC script') + choose(g,'LOG OFF') + T.eq(pc:isRunning(),false,version..' LOG OFF completes actual native PC script') + T.eq(pc.ctx.g2Var,1,version..' changed bit returns after menu closes') + T.eq(g.overworld.reloaded,1,version..' native NONE warp reloads exactly once') + T.eq(g.overworld.player.cellX,4,version..' NONE reload retains current cell rather than zero coordinates') + T.eq(g.overworld.paint['0,2'],44,version..' reload callback uses final selected bed') + T.eq(g.save.gen2VarSprites[2],18,version..' reload callback uses selected right doll') + g.data.map_scripts.scripts.FACE={{'warpfacing',3,'NONE',0,0,0},{'end'}} + g.overworld.player.facing='down';run(g,'FACE') + T.eq(g.overworld.player.facing,'right',version..' native NONE warpfacing applies its explicit direction') + g=game(fixture());pc=run(g,'PC');g.input={wasPressed=function(_,key)return key=='b'end};g.stack:update(0) + T.eq(pc.ctx.g2Var,0,version..' PC cancellation returns unchanged') + T.eq(g.overworld.reloaded,0,version..' PC cancellation cannot reload') +end +V.set('crystal') +for _,code in ipairs({ + "p:patch('deco:2',{sprite=256})", "p:patch('deco:2',{flag=65535})", "p:patch('deco:2',{type=0})", + "p:patch('deco:2',{action='GUESS'})", "p:patch('deco:2',{sprite=0/0})", "p:patch('deco:2',{flag=math.huge})", + "p:patch('deco:02',{sprite=3})", "p:patch('deco:256',{sprite=3})", "p:patch('deco:2',{unknown=true})", + "p:patch('deco:2',{sprite=44});error('entry failed')", +})do + local d=fixture();local r=load(d,'local p=mod.content["decorations"];'..code) + T.check(#r.errors>0,'invalid or failed decoration contribution is rejected') + T.eq(d.map_scripts.decorations[2].sprite,0,'failed entry rolls back all native decoration writes') + finish(r) +end +do + local d=fixture();local original=d.map_scripts.decorations + original['2']=Merge.deepCopy(original[2]) + local r=load(d,[[local p=mod.content["decorations"];p:remove('deco:2');p:patch('deco:3',{sprite=66}); + p:register('deco:200',{type=1,name='EXTERNAL',flag=901,action='SET_UP_PLANT',sprite=77})]]) + T.eq(d.map_scripts.decorations[2],nil,'remove clears numeric DECO alias') + T.eq(d.map_scripts.decorations['2'],nil,'remove clears string DECO alias') + d.map_scripts.decorations[200].sprite=99 + finish(r);local clean=load(d) + T.eq(d.map_scripts.decorations[2].sprite,0,'reload restores removed native numeric record') + T.eq(d.map_scripts.decorations['2'].sprite,0,'reload restores removed native string alias') + T.eq(d.map_scripts.decorations[3].sprite,44,'reload retires an unchanged owned patch') + T.eq(d.map_scripts.decorations[200].sprite,99,'reload preserves externally edited authored record independently') + finish(clean) +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local d=fixture();if version=='prism'or version=='polishedcrystal'then d.map_scripts.decorationInfo.complete=false end + local before=d.map_scripts.decorations + local r=load(d,[[local p=mod.content["decorations"];assert(not p:supports());local v,why=p:patch('deco:2',{sprite=99});assert(v==nil and why)]]) + T.eq(#r.errors,0,version..' unsupported native decoration target declines explicitly') + T.eq(d.map_scripts.decorations,before,version..' unsupported generation does not write native namespace') + finish(r) +end +V.set(old);T.finish('native Gen2 decoration content, room callbacks and house PC') diff --git a/tests/engine/gen2_decoration_facade.lua b/tests/engine/gen2_decoration_facade.lua new file mode 100644 index 000000000..108caedad --- /dev/null +++ b/tests/engine/gen2_decoration_facade.lua @@ -0,0 +1,160 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +require('src.core.ModCompat').install() +local D=require('src.core.gen2.Decorations') +local N=require('src.world.Gen2Decorations') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local F=require('src.script.Gen2Flags') +local function fixture() + local d=T.fixtures.fresh() + d.map_scripts={decorations={ + [0]={type=1,name='BACK',flag=0,sprite=0}, + [40]={type=1,name='STORE',flag=0,action='PUT_AWAY_BED',sprite=0}, + [41]={type=2,name='ONE',flag=620,action='SET_UP_BED',sprite=0}, + [42]={type=2,name='TWO',flag=621,action='SET_UP_BED',sprite=22}, + [80]={type=1,name='STORE DOLL',flag=0,action='PUT_AWAY_DOLL',sprite=0}, + [81]={type=5,name='MODEL',flag=622,action='SET_UP_DOLL',sprite=31}, + [90]={type=1,name='RUG',flag=623,action='SET_UP_CARPET',sprite=255}, + [91]={type=1,name='SCREEN',flag=624,action='SET_UP_CONSOLE',sprite=33}, + [92]={type=4,name='MODEL',flag=625,action='SET_UP_POSTER',sprite=34}, + [93]={type=6,name='MODEL',flag=626,action='SET_UP_BIG_DOLL',sprite=35}, + },decorationInfo={complete=true,defaultsComplete=true,defaults={bed=41,poster=92}, + giftsComplete=true,gifts={42,81},species={[81]='SPECIES_001',[92]='SPECIES_001',[93]='SPECIES_001'}, + sourceNames={[81]='MODEL',[92]='MODEL',[93]='MODEL'}}, + decorationPrograms={texts={setup='SET',put='PUT',empty='EMPTY',same='SAME',replace='REPLACE',noChoice='NONE'}}, + scripts={ROOM={{'special',0,'ToggleDecorationsVisibility'},{'special',0,'ToggleMaptileDecorations'},{'end'}}}} + d.pokemon.SPECIES_001={name='LIVE'} + d.text={SET='A:{RAM:wStringBuffer3}{DONE}',PUT='B:{RAM:wStringBuffer3}{PROMPT}', + EMPTY='C',SAME='D',REPLACE='E:{RAM:wStringBuffer3}\fF:{RAM:wStringBuffer4}{DONE}',NONE='G'} + return d +end +local function load(d,code) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if code then + files['mods/test/manifest.json']=Json.encode({id='test',name='Synthetic',version='1.0.0',api=2,entry='main.lua',affects_link=false}) + files['mods/test/main.lua']='local mod=...;mod.exports.deco=require("src.core.gen2.Decorations");'..code + paths={'mods/test'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function done(r)Loader.teardown();r.release()end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local d=fixture();local pristine=d.map_scripts.decorations + local r=load(d,[[ + assert(mod.exports.deco.ATTRIBUTES[41].sprite==0) + mod.content.decorations:patch('deco:42',{name='PATCHED',sprite=72}) + mod.content.decorations:register('deco:201',{type=1,name='AUTHORED',flag=900,action='SET_UP_BED',sprite=73}) + mod.content.decorations:remove('deco:41') + ]]) + T.eq(#r.errors,0,version..' real Loader imports public contract before entry executes') + T.eq(D,require('src.compat.gen2.Decorations'),version..' legacy module identity is the adapter') + T.check(D~=N,version..' incompatible signature is not a native alias') + T.eq(D.attributes(41),nil,version..' removed record never resurrects from retail fallback') + T.eq(D.ATTRIBUTES[41],nil,version..' current catalogue agrees with registry removal') + T.eq(D.attributes('42'),d.map_scripts.decorations[42],version..' string byte reads the one actual native record') + T.eq(D.name(42),'PATCHED BED',version..' merged name reaches public function') + T.eq(D.name('81'),'LIVE DOLL',version..' string identity still resolves imported species') + T.eq(D.name(81,function(base)return base=='MODEL'and 'CUSTOM'end),'CUSTOM DOLL',version..' legacy resolver is honored') + T.eq(D.name(92),'LIVE POSTER',version..' poster grammar') + T.eq(D.name(93),'BIG LIVE',version..' large doll grammar') + T.eq(D.name(999),'',version..' invalid byte has no invented name') + local save={flags={}};local state=D.state(save) + T.eq(state,save.decorations,version..' public state is the actual native placed-byte table') + T.same(state,{bed=41,poster=92},version..' defaults come from ROM despite non-retail identities') + T.same(D.state({decorations={}}),{},version..' explicitly empty native placement stays empty') + local events=D.events(save) + T.eq(D.owns(events,201),false,version..' new decoration starts unowned') + T.check(D.give(events,201),version..' numeric public event protocol awards authored record') + T.eq(save.flags[F.eventFlag(900)],true,version..' award reaches canonical native save flag') + T.eq(N.owns(d,save,201),true,version..' native ownership sees legacy award') + T.eq(D.give(events,0),false,version..' cancel cannot award flag zero') + T.eq(D.give(events,40),false,version..' put-away header cannot award flag zero') + T.eq(save.flags[F.eventFlag(0)],nil,version..' rejected award does not mutate map buffer') + T.eq(D.idForFlag(0),42,version..' gift ordinal follows native non-retail mapping') + T.check(D.giveFlag(events,1),version..' native ordinal awards doll') + T.eq(D.idForFlag(-1),nil,version..' invalid ordinal does not guess') + T.check(D.give(save,'42'),version..' direct native-save argument also grants ownership') + T.eq(N.owns(d,save,42),true,version..' direct award reaches actual native ownership') + local category=D.ownedCategories(events)[1] + T.same(category.members,{42,201},version..' categories contain actual merged IDs without removed member') + T.same(D.rows(events,category),{42,201,40,0},version..' owned members then actual header then cancel') + T.same(D.rows(events,{id=40,members={2}}),{42,201,40,0},version..' legacy plain category shape resolves native members') + T.eq(D.idFor(201),'deco:201',version..' canonical registry identity') + T.eq(D.NOTHING_TO_CHOOSE,'G',version..' feedback derives from actual ROM text ID') + local seen={};local owner={} + local count=D.registerInto({register=function(_,id,row,who)seen[id]=row;T.eq(who,owner,version..' seed keeps ownership')end},nil,owner) + T.eq(count,10,version..' seed uses current rows including additions and removals') + seen['deco:42'].name='DETACHED' + T.eq(D.name(42),'PATCHED BED',version..' seeding another registry cannot mutate native catalogue') + local changed,pages=D.apply(state,'42') + T.eq(changed,true,version..' public apply replaces placed native byte') + T.eq(state.bed,42,version..' numeric string never writes a string into SRAM state') + T.same(pages,{'E:','F:PATCHED BED'},version..' replacement uses both actual pages; removed old name is empty') + changed,pages=D.apply(state,42) + T.eq(changed,false,version..' same placement is not a change') + T.same(pages,{'D'},version..' same-placement feedback uses ROM role') + changed,pages=D.apply(state,40) + T.check(changed,version..' put-away changes filled slot') + T.same(pages,{'B:PATCHED BED'},version..' put-away names old item and removes end marker') + changed,pages=D.apply(state,40) + T.eq(changed,false,version..' empty put-away is not a change') + T.same(pages,{'C'},version..' empty feedback') + changed,pages=D.apply(state,201) + T.check(changed,version..' setup in empty slot') + T.same(pages,{'A:AUTHORED'},version..' setup uses original role with new item in buffer3') + changed,pages=D.apply(state,81,nil) + T.eq(changed,false,version..' cancelled side prompt has no placement') + T.same(pages,{},version..' cancelled side prompt has no feedback') + state.rightOrnament=81 + changed,pages=D.apply(state,81,'left') + T.check(changed,version..' chosen doll side changes') + T.eq(state.rightOrnament,81,version..' apply keeps separate legacy clearOtherSide contract') + D.clearOtherSide(state,81,'left');T.eq(state.rightOrnament,0,version..' explicit clear removes opposite duplicate') + state.console=91;state.bigDoll=93;state.carpet=90 + local paint={};local game={data=d,save=save};local world={replaceBlock=function(_,x,y,block)paint[x..','..y]=block end} + local runner=Runner.new(game,world);runner:run(assert(VM.compile(d,'ROOM'))) + for _,row in ipairs(D.tiles(state))do T.eq(paint[row.x..','..row.y],row.block,version..' actual VM tiles agree with public layout')end + T.eq(paint['0,2'],73,version..' legacy placement is visible to actual room callback') + T.eq(paint['0,1'],0,version..' wrapped carpet block remains a valid zero byte') + for _,row in ipairs(D.visibility(state))do + T.eq(save.flags[F.eventFlag(row.flag)],row.hidden,version..' actual room visibility matches public plan') + if not row.hidden then T.eq(save.gen2VarSprites[row.sprite],row.byte,version..' public sprite byte matches native callback')end + end + T.eq(D.posterVisible(state),true,version..' poster visibility agrees with native event') + T.eq(save.flags[F.eventFlag(716)],true,version..' native poster callback reads same placed state') + d.text.SET='H:{RAM:wStringBuffer3}{DONE}' + state.bed=0;local _,fresh=D.apply(state,42) + T.same(fresh,{'H:PATCHED BED'},version..' text edits after binding are live') + d._portableScriptText={SET={}};state.bed=0 + local _,masked=D.apply(state,42) + T.same(masked,{},version..' explicitly removed script text does not fall back') + done(r) + T.eq(D.attributes(42),nil,version..' teardown retires data binding') + T.eq(events:set(900,false),false,version..' retained numeric adapter cannot mutate after unload') + T.eq(save.flags[F.eventFlag(900)],true,version..' stale adapter leaves prior save intact') + local nextData=fixture();nextData.map_scripts.decorationInfo.defaults={bed=42} + local clean=load(nextData) + T.same(D.state({}),{bed=42},version..' next native dataset supplies its own defaults') + T.eq(D.attributes(201),nil,version..' old mod addition does not leak into new dataset') + T.eq(events:set(900,false),false,version..' old adapter cannot become live after rebinding') + V.set('red');V.set(version) + T.eq(D.attributes(42),nil,version..' edition switch and return cannot revive stale binding') + done(clean) + local reset=load(d) + T.eq(d.map_scripts.decorations,pristine,version..' reload preserves real registry rollback identity') + T.eq(D.attributes(41),pristine[41],version..' removed record returns only when native registry restores it') + done(reset) +end +V.set('crystal') +local unsupported={map_scripts={decorations={[2]={name='NOT IMPORTED'}}}} +T.eq(D.useRegistry(unsupported),false,'partial cache does not enable legacy native facade') +T.eq(D.state({}),nil,'missing native defaults do not invent retail bedroom') +T.same(D.CATEGORIES,{},'unbound catalogue has no copied retail categories') +V.set(original) +T.finish('native Gen2 decoration facade') diff --git a/tests/engine/gen2_decoration_import.lua b/tests/engine/gen2_decoration_import.lua new file mode 100644 index 000000000..fe2409fb1 --- /dev/null +++ b/tests/engine/gen2_decoration_import.lua @@ -0,0 +1,113 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Native=require('src.world.Gen2Decorations') +local S=require('src.save_convert.Gen2Save');local Json=require('src.link.Json') +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={DecorationAttributes={1,0x4000},DecorationNames={1,0x401e}, + PokemonNames={1,0x4300},InitDecorations={1,0x4100},DecorationIDs={1,0x4200}, + wDecoBed={1,0xd000},wDecoPoster={1,0xd003},DecorationDesc_PosterPointers={1,0x4400}, + ['DecorationDesc_OrnamentOrConsole.OrnamentConsoleScript']={1,0x4500}, + ['DecorationDesc_GiantOrnament.BigDollScript']={1,0x4501},DecorationDesc_NullPoster={1,0x4502}, + PutAwayTheDecoText={1,0x4600},NothingToPutAwayText={1,0x4602},SetUpTheDecoText={1,0x4604}} + -- Authored test cartridge, not a retail attribute catalogue. + put(0x4000,string.char(1,0,0,0,0,0,1,1,2,0,0,0,2,2,1,0x34,0x12,0, + 1,1,8,0,0,0,4,1,7,0x35,0x12,77)) + put(0x401e,string.char(128,0x50,129,0x50,130,0x50)) + put(0x4100,string.char(0x3e,2,0xea,0,0xd0,0x3e,4,0xea,3,0xd0,0xc9)) + put(0x4200,string.char(2,4,255));put(0x4300,string.char(131,0x50)) + put(0x4400,string.char(4,3,0x45,255));put(0x4500,string.char(0x91,0x91,0x91,0x91)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols}) + function e:readSourceTable(k)assert(k=='charmap');return{['128']='X',['129']='Y',['130']='Z',['131']='Q'}end + return e +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local rows,info=e:gen2Decorations() + T.check(info.complete,version..' native attribute boundary defines valid table') + T.eq(info.rowsRead,5,version..' reader imports exact authored row count') + T.eq(rows[0].name,'X',version..' DECO zero remains in actual native table') + T.eq(rows[2].name,'Z',version..' names use actual character decoder') + T.eq(rows[2].flag,0x1234,version..' event identity is native little-endian word') + T.eq(rows[2].sprite,0,version..' block zero survives real import') + T.eq(rows[4].name,'Q',version..' species name comes from cartridge indexed table') + T.eq(info.species[4],'SPECIES_001',version..' raw species identity has no National Dex inference') + T.eq(info.defaults.bed,2,version..' bed default comes from native init operands') + T.eq(info.defaults.poster,4,version..' poster default comes from native init operands') + T.eq(#info.gifts,2,version..' gift ordinal table is ROM-derived') + T.eq(info.gifts[2],4,version..' gift ordinal retains actual DECO byte') + local q={scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0} + local roles=e:gen2QueueDecorationPrograms(q) + T.eq(#q.queue,4,version..' actual queue includes three description roles and poster') + T.check(roles.texts.setup:match('^TEXT_S'),version..' native feedback enters shared text import') + T.check(e._gen2ScriptTexts[roles.texts.setup]~=nil,version..' feedback text is registered for actual extractor') + e:gen2QueueDecorationPrograms(q);T.eq(#q.queue,4,version..' duplicate queue calls are idempotent') + local n=1;while n<=#q.queue do local job=q.queue[n];e:gen2DecodeScript(job[1],job[2],job[3],q);n=n+1 end + T.eq(q.desyncs,0,version..' actual bytecode decoder accepts queued description programs') + for _,edit in ipairs({ + function(s)s.DecorationNames[2]=0x401f end, + function(s)s.DecorationNames[1]=2 end, + function(s)s.DecorationAttributes=nil end, + function(s,put)put(0x4000,string.char(0))end, + function(s,put)put(0x4002,string.char(15))end, + function(s,put)put(0x4003,string.char(255,255))end, + function(s,put)put(0x401e,string.rep(string.char(128),129))end, + function(s)s.PokemonNames=nil end, + })do + local bad,why=fixture(version,edit):gen2Decorations() + T.eq(why.complete,false,version..' malformed attribute/name input cannot become ready') + T.eq(next(bad),nil,version..' malformed attribute/name input publishes no prefix') + end + local _,missing=fixture(version,function(s)s.InitDecorations=nil end):gen2Decorations() + T.eq(missing.complete,true,version..' missing init does not erase valid attribute table') + T.eq(missing.defaultsComplete,false,version..' missing init cannot invent retail defaults') + local _,wrong=fixture(version,function(s)s.wDecoBed[2]=0xd100 end):gen2Decorations() + T.eq(wrong.defaultsComplete,false,version..' init stores must target actual WRAM symbols') + local _,badGifts=fixture(version,function(_,put)put(0x4200,string.rep(string.char(2),256))end):gen2Decorations() + T.eq(badGifts.giftsComplete,false,version..' unterminated gifts cannot be claimed complete') + T.eq(badGifts.gifts,nil,version..' malformed gifts publish no prefix') + q={scripts={},queue={}} + T.eq(fixture(version,function(s)s.DecorationDesc_NullPoster=nil end):gen2QueueDecorationPrograms(q),nil,version..' missing description role declines whole binding') + T.eq(#q.queue,0,version..' missing role is rejected before queue mutation') + q={scripts={},queue={}} + T.eq(fixture(version,function(_,put)put(0x4400,string.char(4,0,0))end):gen2QueueDecorationPrograms(q),nil,version..' invalid poster pointer declines binding') + T.eq(#q.queue,0,version..' invalid poster table leaves queue untouched') + -- Resolve the precise native NONE pair, without accepting an unknown map. + e=fixture(version);function e:gen2MapIndex()return{}end;function e:gen2Trainers()return{}end + local p={scripts={TEST={{'warp',0,0,0,0},{'warpfacing',2,0,0,0,0},{'warp',0,1,0,0},{'blackoutmod',0,0}}}} + e:gen2ResolveScriptMapIds({}, {},p) + T.eq(p.scripts.TEST[1][2],'NONE',version..' only actual NONE warp becomes reload') + T.eq(p.scripts.TEST[2][3],'NONE',version..' warpfacing uses its own map-pair slot') + T.eq(p.scripts.TEST[3][2],0,version..' unknown map is not turned into a reload') + T.eq(p.scripts.TEST[4][2],0,version..' non-warp zero pair is not reinterpreted') + -- Independent manifest WRAM addresses verify the save codec's physical order. + local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(Json.decode(f:read('*a')));f:close() + S.setLayout(version);S.setCharmap({['128']='A'}) + local d=T.fixtures.fresh();local save={player={name='A'},decorations={bed=1,carpet=2,plant=3,poster=4, + console=5,leftOrnament=6,rightOrnament=7,bigDoll=255}} + local raw=S.encode(save,d) + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + for _,key in ipairs(Native.slots)do + local symbol='wDeco'..key:sub(1,1):upper()..key:sub(2) + local address=manifest.symbols[symbol][2];local offset=0x2009+address-anchor + T.eq(raw:byte(offset+1),save.decorations[key],version..' export matches independent '..symbol..' address') + end + local decoded=S.decode(raw,d) + for _,key in ipairs(Native.slots)do T.eq(decoded.decorations[key],save.decorations[key],version..' import preserves exact '..key..' byte')end + local round=S.encode(decoded,d,raw);local warnings=S.decode(round,d).warnings + local checksumOk=true;for _,w in ipairs(warnings)do if w:match('checksum')then checksumOk=false end end + T.check(checksumOk,version..' decoration export retains valid native checksum') + local saved=decoded.decorations;decoded.decorations=nil + local untouched=S.encode(decoded,d,raw) + for i=0,7 do T.eq(untouched:byte(S.OFFSETS.decorations+i+1),raw:byte(S.OFFSETS.decorations+i+1),version..' absent legacy state preserves imported decoration template')end + decoded.decorations=saved;decoded.decorations.bed=256 + T.check(not pcall(S.encode,decoded,d,raw),version..' invalid placed byte cannot wrap and corrupt native export') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);T.eq(fixture(version):gen2Decorations(),nil,version..' unreviewed layout imports no stock catalogue') + S.setLayout(version);T.eq(S.OFFSETS.decorations,nil,version..' unknown save layout has no guessed decoration offset') +end +V.set(old);S.setLayout(old);T.finish('native Gen2 decoration ROM producers and eight-byte SRAM codec') diff --git a/tests/engine/gen2_follower_predicate.lua b/tests/engine/gen2_follower_predicate.lua new file mode 100644 index 000000000..d845f342e --- /dev/null +++ b/tests/engine/gen2_follower_predicate.lua @@ -0,0 +1,117 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +require("src.core.ModCompat").install() +local Follower = require("src.world.gen2.Follower") +local Fixture = dofile("tests/follower/fixture.lua") +local oldVersion = Version.get() +T.eq(Follower, require("src.world.OptionalFollower"), "alias is the actual native companion") + +-- Use the real mod loader, native NPCs, maps, interpolation and map-entry +-- producer. Only cartridge art/data and the outer application are fixtures. +local function load(f, failing) + local entry = [[ + local mod = ... + local F = require("src.world.gen2.Follower") + mod.exports.api = mod + mod.exports.allow, mod.exports.calls = false, 0 + local previous = F.setShouldSpawn(function(game, world) + mod.exports.calls = mod.exports.calls + 1 + mod.exports.game, mod.exports.world = game, world + return mod.exports.allow + end) + mod.exports.previous = previous + ]] + if failing then entry = entry .. '\nerror("intentional follower entry failure")' end + return T.sdk.loadMods({ "mods/follower_setter" }, { + data = T.fixtures.fresh(), + fs = T.sdk.memfs({ + ["mods/follower_setter/manifest.json"] = [[{ + "id":"follower_setter","name":"Native follower setter", + "version":"1.0.0","api":2,"entry":"main.lua","affects_link":false + }]], + ["mods/follower_setter/main.lua"] = entry, + }), + }) +end + +for _, version in ipairs({ "gold", "silver", "crystal", "prism" }) do + local f = Fixture.new(".", "gen2", version, 2, "native") + f.close() -- remove the fixture's portable hook before loading the setter mod + local nativeDefault = Follower.setShouldSpawn(nil) + local result = load(f) + T.eq(#result.errors, 0, "legacy setter mod loads on " .. version) + local e = result.loader.exports.follower_setter + T.eq(e.previous, nativeDefault, "setter returns the actual previous predicate") + f.tick() + T.eq(Follower.current(f.world), nil, "false predicate keeps vanilla companion absent") + T.eq(e.game, f.game, "predicate receives native game") + T.eq(e.world, f.world, "predicate receives native world") + for _, value in ipairs({ true, 0, "" }) do + e.allow = value + f.tick() + local npc = Follower.current(f.world) + T.check(npc ~= nil, "Lua-truthy predicate spawns real native NPC") + T.eq(npc, f.controller.current(f.world), "native controller and alias share the same NPC") + T.eq(npc, f.world.follower, "world references that same companion") + T.eq(npc.passable, true, "optional companion cannot block native movement") + T.eq(f.world.npcs[#f.world.npcs], npc, "normal NPC loop owns companion updates") + T.eq(f.world.entities[#f.world.entities], npc, "normal draw list owns companion") + end + local npc = Follower.current(f.world) + local bystander = { id = "ordinary-object" } + f.world.npcs[#f.world.npcs + 1] = bystander + f.world.entities[#f.world.entities + 1] = bystander + local startX = npc.px + for _ = 1, 2 do + local p = f.player + p.targetX, p.targetY, p.moving, p.facing = p.cellX + 1, p.cellY, true, "right" + for frame = 1, 4 do f.tick() end + p.cellX, p.px, p.targetX, p.targetY, p.moving = p.cellX + 1, (p.cellX + 1) * 16, nil, nil, false + end + T.check(npc.px > startX, "native interpolation follows committed player destinations") + local trail = f.world.followerTrail + f.enter({ keepFollower = npc }) + T.eq(Follower.current(f.world), npc, "native connection retains companion identity") + T.eq(f.world.followerTrail, trail, "connection retains the live buffered trail") + local x, y = npc.px, npc.py + f.controller.rebase(f.world, -2, 1) + T.eq(npc.px, x - 32, "connection rebase moves real companion x") + T.eq(npc.py, y + 16, "connection rebase moves real companion y") + local off = e.api.hooks:wrap("world.follower.spawn", function() return false end, 100) + f.tick() + T.eq(Follower.current(f.world), nil, "public hook can deny the legacy predicate") + T.eq(f.world.npcs[1], bystander, "denial retains unrelated NPC") + off() + f.tick() + T.check(Follower.current(f.world) ~= nil, "unsubscribe returns to legacy predicate") + require("src.mods.Loader").teardown() + result.release() + T.eq(Follower.setShouldSpawn(nil), nativeDefault, "real mod unload restores original predicate") + local calls = e.calls + f.tick() + T.eq(e.calls, calls, "unloaded callback no longer runs") + T.eq(Follower.current(f.world), nil, "unload removes companion through native update") + T.eq(f.world.npcs[1], bystander, "unload preserves map objects") + local failed = load(f, true) + T.check(#failed.errors > 0, "failed entry is reported") + T.eq(Follower.setShouldSpawn(nil), nativeDefault, "failed entry rolls back legacy setter") + f.tick() + T.eq(Follower.current(f.world), nil, "failed mod cannot leave a companion running") + failed.release() +end + +-- Installing a Gen2 predicate must not replace Yellow's native companion or +-- enable an optional GBA companion. Both remain under their existing owners. +for _, version in ipairs({ "red", "yellow", "firered" }) do + local f = Fixture.new(".", "gen2", version, 2, "native") + local calls = 0 + local previous = Follower.setShouldSpawn(function() calls = calls + 1; return true end) + f.tick() + T.eq(calls, 0, "Gen2 predicate is not called for " .. version) + T.eq(f.current() ~= nil, version == "yellow", "other generation retains native follower default") + Follower.setShouldSpawn(previous) + f.close() +end +Version.set(oldVersion) +T.finish("native Gen2 follower setter lifecycle") diff --git a/tests/engine/gen2_landmark_content.lua b/tests/engine/gen2_landmark_content.lua new file mode 100644 index 000000000..d7476c38d --- /dev/null +++ b/tests/engine/gen2_landmark_content.lua @@ -0,0 +1,232 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-landmarks.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Native=require('src.world.Gen2Landmarks');local E=require('src.import.RomExtractorGen2') +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local VM=require('src.script.Gen2ScriptVM');local Runner=require('src.script.ScriptRunner') +local Gear=require('src.ui.PokegearMenu');local TownMap=require('src.ui.TownMap') +local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i) + if name=='markerFor'then markerFor=value;break end;if not name then break end +end +assert(markerFor) +local function extractor(version,order) + local records={} + for i=0,95 do records[#records+1]=string.char(i%160,(i+20)%144,0,0x50)end + local raw=string.rep('\0',0x4000)..table.concat(records) + raw=raw..string.rep('\0',0x5000-#raw)..string.char(0x80,0x80,0x50)..string.rep('\0',0x2ffd) + local e=E.new(raw,version,{symbols={Landmarks={1,0x4000}},landmarkOrder=order}) + function e:readSourceTable(key)assert(key=='charmap');return{['128']='A'}end -- authored source-table IO + return e +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local order=Native.order(version);local count=version=='crystal' and 96 or 95 + T.eq(#order,count,version..' exact native landmark count includes the final ship') + T.eq(order[30],version=='crystal' and 'LANDMARK_BATTLE_TOWER' or 'LANDMARK_ROUTE_40',version..' Duellturm insertion is edition-specific') + T.eq(order[#order],'LANDMARK_FAST_SHIP',version..' final identity remains the native ship') + local e=extractor(version);local rows=e:gen2Landmarks() + T.eq(rows[count-1].id,'LANDMARK_FAST_SHIP',version..' real ROM reader reaches final native row') + T.eq(rows[count-1].index,count-1,version..' importer records actual map-header byte') + T.eq(rows[count-1].name,'AA',version..' actual native text decoder supplies display name') + T.eq(rows[count-1].x,(count-1)%160,version..' raw OAM x coordinate is not converted to a tile cell') + T.eq(rows[count-1].y,(count+19)%144,version..' raw OAM y coordinate is unchanged') + T.eq(rows[count],nil,version..' stock import cannot read beyond its actual landmark count') + order[1]='LEAK';T.eq(Native.order(version)[1],'LANDMARK_SPECIAL',version..' identity views are detached') + local town=e:gen2TownMap() + T.eq(town.landmarks[count-1].id,'LANDMARK_FAST_SHIP',version..' missing picture does not discard valid script names') + T.eq(town.landmarkOrder[count],'LANDMARK_FAST_SHIP',version..' actual town-map output carries identity order') + T.eq(town.johto,nil,version..' missing picture cannot invent fallback map art') + local marker=markerFor(version) + T.check(marker:find('gen2%-landmark%-identities%-v1:')~=nil,version..' new native identity cache revision is present') + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',marker:gsub('gen2%-landmark%-identities%-v1:','')) + T.eq(Importer.readyReport(version).ok,false,version..' old truncated identity-less cache requires reimport') +end +for _,version in ipairs({'prism','polishedcrystal','red'})do + T.eq(#Native.order(version),0,version..' unreviewed layout cannot acquire stock landmark identities') + T.eq(Native.order(version,{landmarkOrder={'AUTHORED_ZERO','AUTHORED_ONE'}})[2],'AUTHORED_ONE',version..' explicit manifest identities remain authoritative') +end +T.eq(extractor('crystal',{'DUPLICATE','DUPLICATE'}):gen2Landmarks(),nil,'invalid declared identities cannot silently fall back to retail count') +T.eq(extractor('crystal',{'ONLY'}):gen2Landmarks()[1],nil,'explicit custom count bounds actual ROM reads') +local function fixture(version) + local d=T.fixtures.fresh();local pallet=version=='crystal' and 47 or 46 + d.field.townMap={johto='qa-johto-'..version,kanto='qa-kanto-'..version,playerIcon='qa-player-'..version, + landmarkOrder=Native.order(version),landmarks={ + [0]={id='LANDMARK_SPECIAL',index=0,name='NOWHERE',x=0,y=0}, + [1]={id='LANDMARK_NEW_BARK_TOWN',index=1,name='LOCAL HOME',x=20,y=32}, + [2]={id='LANDMARK_ROUTE_29',index=2,name='LOCAL ROAD',x=40,y=48}, + [pallet]={id='LANDMARK_PALLET_TOWN',index=pallet,name='LOCAL PALLET',x=72,y=80}, + [pallet-1]={id='LANDMARK_SILVER_CAVE',index=pallet-1,name='LOCAL CAVE',x=88,y=96}}} + d.maps.HOME={id='HOME',landmark=1};d.maps.CAVE={id='CAVE',landmark=pallet-1} + d.maps.PALLET_TOWN={id='PALLET_TOWN',landmark=pallet};d.maps.EXTRA={id='EXTRA',landmark=200} + d.map_scripts={scripts={HERE={{'getcurlandmarkname',0},{'end'}},THERE={{'getlandmarkname',200,0},{'end'}}}} + return d +end +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local files,paths={},{} + if source then + files['mods/landmarks/manifest.json']=Json.encode({id='landmarks',name='Landmarks',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files['mods/landmarks/main.lua']='local mod=...;mod.exports.api=mod;'..source;paths={'mods/landmarks'} + end + return T.sdk.loadMods(paths,{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function game(d,map) + return {data=d,save={inventory={}},overworld={map={id=map,def=d.maps[map]}}, + stack={pop=function()end},input={wasPressed=function()return false end}} +end +local function run(g,label) + local r=Runner.new(g,g.overworld);r:run(assert(VM.compile(g.data,label)));return r +end +local oldImage,oldDraw=love.graphics.newImage,love.graphics.draw +local paints={} +love.graphics.newImage=function(path)return{path=path,getDimensions=function()return 160,144 end}end +love.graphics.draw=function(image,x,y)paints[#paints+1]={path=image.path,x=x,y=y}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + do + local d=fixture(version);local before=d.field.townMap.landmarks;local clean=load(d) + T.eq(#clean.errors,0,version..' no-mod native boot stays valid') + T.eq(d.field.townMap.landmarks,before,version..' no-mod load leaves native target identity untouched') + finish(clean) + end + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture(version);local before=d.field.townMap.landmarks + local r=load(d,[[local p=mod.content["landmarks"] + assert(p:supports()) + assert(p:get('LANDMARK_ROUTE_29').name=='LOCAL ROAD') + local v=p:get('LANDMARK_ROUTE_29');v.name='LEAK' + for _,row in p:each()do row.x=999 end + assert(p:get('LANDMARK_ROUTE_29').x==40) + p:patch('LANDMARK_NEW_BARK_TOWN',{name='AUTHORED\nHOME',x=52,y=68}) + p:register('LANDMARK_EXTRA',{name='AUTHORED EXTRA',x=100,y=104,index=200}) + p:remove('LANDMARK_ROUTE_29') + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' public landmark records load through real mod API') + T.eq(d.field.townMap.landmarks[1].name,'AUTHORED\nHOME',label..' native numeric row receives authored text') + T.eq(d.field.townMap.landmarks[2],nil,label..' removed row cannot survive by fallback') + T.eq(d.field.townMap.landmarks[200].landmarkId,'LANDMARK_EXTRA',label..' new identity binds actual byte200') + T.eq(before[1].name,'LOCAL HOME',label..' source native row is not mutated') + local g=game(d,'HOME');run(g,'HERE') + T.eq(g.stringBuffers and g.stringBuffers[3],'AUTHORED HOME',label..' actual current-landmark VM reads authored native name') + run(g,'THERE');T.eq(g.stringBuffers and g.stringBuffers[3],'AUTHORED EXTRA',label..' actual named-landmark VM reads registered byte') + local screen=TownMap.new(g) + T.eq(screen.mode,'gen2',label..' real map controller consumes native table') + T.eq(screen.byMap.HOME.px,48,label..' actual map marker applies native OAM origin once') + T.eq(screen.byMap.HOME.py,56,label..' native marker y keeps raw coordinate units') + T.eq(screen.byMap.PALLET_TOWN.region,'kanto',label..' first actual Kanto byte selects Kanto') + T.eq(screen.byMap.CAVE.region,'johto',label..' preceding cave remains on Johto') + T.eq(screen.byMap.EXTRA.name,'AUTHORED EXTRA',label..' native map controller consumes added marker') + local text + local gear=setmetatable({game=g,drawStrip=function()end,drawModeArrow=function()end, + text=function(_,value)text=value end},{__index=Gear}) + paints={};gear:drawMap() + T.eq(text,'AUTHORED HOME',label..' actual Pokegear label reads the same authored name') + T.eq(paints[1].path,d.field.townMap.johto,label..' actual Pokegear paints the correct Johto image') + T.eq(paints[2].x,44,label..' actual Pokegear player sprite consumes authored OAM x') + T.eq(paints[2].y,52,label..' actual Pokegear player sprite consumes authored OAM y') + g.overworld.map={id='PALLET_TOWN',def=d.maps.PALLET_TOWN};paints={};gear:drawMap() + T.eq(paints[1].path,d.field.townMap.kanto,label..' Pokegear correctly paints Gold/Silver first Kanto town too') + local api=r.loader.exports.landmarks.api.content.landmarks + T.check(not pcall(function()api:patch('LANDMARK_EXTRA',{name='LATE'})end),label..' content freezes after load') + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks,before,label..' disabling restores original numeric table identity') + T.eq(d.field.townMap.landmarks[200],nil,label..' disabling removes the authored byte') + finish(clean) + end +end +V.set('crystal') +for _,source in ipairs({ + "p:register('BAD',{name='BAD',x=1,y=1,index=256})", + "p:register('BAD',{name='BAD',x=-1,y=1,index=200})", + "p:register('BAD',{name='BAD',x=0/0,y=1,index=200})", + "p:register('BAD',{name='BAD',x=1,y=math.huge,index=200})", + "p:register('BAD',{name='BAD',x=1,y=1,index=200,typo=true})", + "p:patch('LANDMARK_NEW_BARK_TOWN',{name=mod.DELETE})", + "local x={};x.name=x;p:register('BAD',x)", + "error('entry failed')", +})do + local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,"local p=mod.content.landmarks;p:patch('LANDMARK_NEW_BARK_TOWN',{name='FAIL'});"..source) + T.check(#r.errors>0,'invalid native record is attributed: '..source) + T.eq(d.field.townMap.landmarks,before,'failed entry rolls back prior valid landmark edits') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,[[local p=mod.content.landmarks + p:register('Z_NEW',{name='Z',x=10,y=20,index=200}) + p:register('A_NEW',{name='A',x=30,y=40,index=200}) + p:register('A_SHADOW',{name='SHADOW',x=99,y=99,index=1}) + ]]) + T.eq(#r.errors,0,'reference-compatible duplicate index claims are deterministic') + T.eq(d.field.townMap.landmarks[1].name,'LOCAL HOME','native identity wins its own slot') + T.eq(d.field.townMap.landmarks[200].name,'A','lexically earlier newcomer wins other slot') + finish(r) +end +do + local d=fixture('crystal');local r=load(d,[[mod.content.landmarks:patch('LANDMARK_NEW_BARK_TOWN',{index=201})]]) + T.eq(#r.errors,0,'reference record may move to an unused native index') + T.eq(d.field.townMap.landmarks[201].landmarkId,'LANDMARK_NEW_BARK_TOWN','moved identity reaches new native index') + T.eq(d.field.townMap.landmarks[1].name,'LOCAL HOME','reference order remains fallback for unclaimed original slot') + finish(r) +end +do + local d=fixture('crystal');d.field.townMap.landmarks['2']=d.field.townMap.landmarks[2] + d.map_scripts.scripts.ZERO={{'getlandmarkname',0,0},{'end'}} + local before=d.field.townMap.landmarks;local r=load(d,[[local p=mod.content.landmarks + p:remove('LANDMARK_ROUTE_29');p:patch('LANDMARK_SPECIAL',{name='AUTHORED ZERO'}) + ]]) + T.eq(#r.errors,0,'numeric/string native aliases and byte zero remain supported') + T.eq(d.field.townMap.landmarks[2],nil,'removal drops numeric native slot') + T.eq(d.field.townMap.landmarks['2'],nil,'string fallback cannot resurrect removed landmark') + local g=game(d,'HOME');run(g,'ZERO') + T.eq(g.stringBuffers[3],'AUTHORED ZERO','actual native script names byte zero rather than treating it as absent') + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks,before,'unload restores original numeric/string alias layout') + T.eq(before['2'],before[2],'restored aliases retain their original shared record') + finish(clean) +end +do + local d=fixture('crystal');local r=load(d,[[mod.content.landmarks:patch('LANDMARK_PALLET_TOWN',{index=48})]]) + T.eq(#r.errors,0,'moving the logical first Kanto landmark is a native byte edit') + T.eq(Native.kantoStart(d),48,'region boundary consumes authored landmark index metadata') + local g=game(d,'PALLET_TOWN');local screen=TownMap.new(g) + T.eq(screen.byMap.PALLET_TOWN.region,'johto','town map follows the same authored boundary') + paints={};setmetatable({game=g,drawStrip=function()end,drawModeArrow=function()end,text=function()end},{__index=Gear}):drawMap() + T.eq(paints[1].path,d.field.townMap.johto,'actual Pokegear background consumes authored boundary') + finish(r) +end +do + local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,[[mod.content.landmarks:patch('LANDMARK_NEW_BARK_TOWN',{name='OWN'}) + mod.content.landmarks:register('EXTRA',{name='EXTRA',x=100,y=100,index=200})]]) + local row=d.field.townMap.landmarks[1];row.name='EXTERNAL' + finish(r);local clean=load(d) + T.eq(d.field.townMap.landmarks[1],row,'reload preserves externally edited native record identity') + T.eq(row.name,'EXTERNAL','reload preserves external text') + T.eq(d.field.townMap.landmarks[200],nil,'external edit does not retain unchanged owned addition') + T.eq(d.field.townMap.landmarks[2],before[2],'unrelated original record identity restores') + finish(clean) +end +do + local d=T.fixtures.fresh();d.field=nil + local r=load(d,[[mod.content.landmarks:register('NEW',{name='NEW',x=20,y=30,index=200})]]) + T.eq(#r.errors,0,'indexed authored names can create a native script target without map art') + T.eq(d.field.townMap.landmarks[200].name,'NEW','new native target is readable') + finish(r);local clean=load(d) + T.eq(d.field,nil,'unload retires exclusively owned rows, town and field namespaces') + finish(clean) +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture('crystal');local before=d.field.townMap.landmarks + local r=load(d,[[local p=mod.content.landmarks;assert(not p:supports());assert(p:get('LANDMARK_ROUTE_29')==nil) + p:register('NO_WRITE',{name='NO',x=20,y=30,index=200})]]) + T.eq(#r.errors,0,version..' absent native target is reported through public handle') + T.eq(d.field.townMap.landmarks,before,version..' cannot mutate lookalike Gen2 town map') + finish(r) +end +love.graphics.newImage=oldImage;love.graphics.draw=oldDraw;V.set(original) +T.finish('native landmark identities, real map/gear/name consumers, reload ownership and edition boundaries') diff --git a/tests/engine/gen2_lifecycle_callbacks.lua b/tests/engine/gen2_lifecycle_callbacks.lua new file mode 100644 index 000000000..3481dd1f4 --- /dev/null +++ b/tests/engine/gen2_lifecycle_callbacks.lua @@ -0,0 +1,242 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Owned data, real Loader/buses and production state transitions. IO only is +-- controlled; no callback producer is replaced with a test implementation. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local oldVersion=V.get();V.set('crystal') +local Json=require('src.link.Json');local Runtime=require('src.mods.Runtime') +local data=T.fixtures.fresh() +local source=[[ +local mod=... +local state={events={},odds=nil,nickname=nil,claim=false,quit=false,enemy=nil} +mod.exports.state=state +for _,name in ipairs({"happiness.changed","pokerus.infected","roamer.encountered","roamer.moved", + "breeding.egg_created","egg.hatched","unown.unlocked","bug_contest.scored", + "intro.boot.copyright","intro.boot.gamefreak","intro.boot.movie","intro.boot.movie_ended","intro.boot.title"})do + mod.events:on(name,function(value) + state.events[name]=state.events[name]or{};table.insert(state.events[name],value) + end) +end +mod.hooks:wrap("breeding.compatibility",function(next,c) + state.compatibility=c + if state.odds~=nil then return state.odds end + return next(c) +end) +mod.hooks:wrap("catch.nickname",function(next,mon,c) + state.catch={mon=mon,context=c} + if state.nickname~=nil then return state.nickname end + return next(mon,c) +end) +mod.hooks:wrap("catch.party_full",function(next,c) + state.full=c;return state.claim or next(c) +end) +mod.hooks:wrap("battle.style",function(next,battle)state.styleBattle=battle;return state.style or next(battle)end) +mod.hooks:wrap("battle.enemy_switch_or_item",function(next,battle) + state.battle=battle;if state.enemy~=nil then return state.enemy end;return next(battle) +end) +mod.hooks:wrap("core.quit_to_launcher",function(next)if state.quit then return false end;return next()end) +]] +local run=T.sdk.loadMods({'mods/lifecycle'},{data=data,fs=T.sdk.memfs({ + ['mods/lifecycle/manifest.json']=Json.encode({id='lifecycle',name='Lifecycle',version='1.0.0',api=2,entry='main.lua',games={'gen2'}}), + ['mods/lifecycle/main.lua']=source, +})}) +T.eq(#run.errors,0,'lifecycle observer loads through ordinary API-2 registration') +local state=assert(run.loader.exports.lifecycle.state) +local function events(name)return state.events[name]or{}end +local function last(name)local list=events(name);return list[#list]end +local Pokemon=require('src.pokemon.Pokemon') +local mon=Pokemon.new(data,'FIXMON_A',10,function(a)return a end);mon.happiness=254 +local Friendship=require('src.pokemon.Gen2Friendship') +Friendship.change(mon,'VITAMIN') +local changed=last("happiness.changed") +T.eq(changed.mon,mon,'friendship observer receives the live mon') +T.eq(changed.from,254,'friendship records the prior byte') +T.eq(changed.to,255,'friendship records the clamped byte') +T.eq(changed.delta,1,'friendship reports the actual change') +T.eq(changed.event,2,'vitamin maps to the public native event index') +T.eq(changed.reason,'event','friendship identifies event changes') +Friendship.change(mon,'VITAMIN');T.eq(#events("happiness.changed"),1,'clamped no-op does not announce a change') +mon.happiness=80 +local save={party={mon},flags={},player={name='TEST',id=1},pokedex={seen={},owned={}},options={}} +Friendship.stepCycle(save);T.eq(#events("happiness.changed"),1,'first 256-step cycle cannot announce a point') +Friendship.stepCycle(save);T.eq(last("happiness.changed").reason,'step','actual second cycle announces its point') +T.eq(last("happiness.changed").event,nil,'step changes carry no event index') + +local Roam=require('src.world.RoamMons') +save.g2RoamReleased=true;save.g2Roam={RAIKOU={mapId='OWN_MAP',active=true,level=40}} +T.eq(Roam.check(save,'OTHER_MAP',false,function()return 1 end),nil,'wrong map cannot announce a beast') +T.eq(#events("roamer.encountered"),0,'failed encounter stays silent') +T.eq(Roam.check(save,'OWN_MAP',false,function()return 1 end),'RAIKOU','real slot selection encounters the beast') +T.eq(last("roamer.encountered").slot,save.g2Roam.RAIKOU,'roamer record remains live') +T.eq(last("roamer.encountered").index,1,'roamer retains cartridge slot order') + +local Commands=require('src.script.Gen2Commands') +save.g2Roam.RAIKOU.mapId='ROUTE_29';save.g2RoamLastMap='ROUTE_40' +local oldWorldRandom=love.math.random +love.math.random=function(low,high)if low==0 and high==31 then return 1 end;return low end +Commands.g2_update_roam_positions({save=save,game={data=data,overworld={map={id='ROUTE_32'}}}}) +love.math.random=oldWorldRandom +T.eq(last("roamer.moved").from,'ROUTE_29','native route movement records the previous map') +T.eq(last("roamer.moved").to,'ROUTE_30','native route movement records its selected neighbor') +T.eq(last("roamer.moved").slot,save.g2Roam.RAIKOU,'movement observer sees the live native beast') + +local Contest=require('src.world.BugContest') +save.g2BugContest={caught=mon,scores={[2]={score=1,species='FIXMON_B'}}} +local game={data=data,save=save} +local place,board=Contest.judge(game) +T.eq(last("bug_contest.scored").place,place or 0,'contest announces the actual placing') +T.eq(last("bug_contest.scored").results,board,'contest announces the native sorted board') +T.eq(last("bug_contest.scored").score,Contest.score(mon),'contest announces the native computed score') + +local Virus=require('src.pokemon.Gen2Pokerus') +local party={{pokerus=0x32},{},{pokerus=0x20}} +local rolls={0,255};local cursor=0 +local function random()cursor=cursor+1;return assert(rolls[cursor],'unexpected infection RNG')end +T.eq(Virus.afterBattle({party=party},random),2,'actual active-byte spread writes the clean neighbor') +T.eq(party[2].pokerus,0x34,'spread derives days from its carrier strain') +T.eq(last("pokerus.infected").mon,party[2],'infection observer sees the written party record') +T.eq(last("pokerus.infected").party,party,'infection observer retains party identity') +T.eq(last("pokerus.infected").source,'spread','infection identifies its source') +Virus.advance(party,4) +T.eq(party[2].pokerus,0x30,'daily expiry keeps the immune strain') +T.eq(party[3].pokerus,0x20,'expiry preserves an already cured byte') +T.eq(#events("pokerus.infected"),1,'expiry cannot announce an infection') +local Flags=require('src.script.Gen2Flags') +local contracted={party={{}},flags={[Flags.engineFlag(21)]=true}} +rolls={0,2,0,19};cursor=0 +T.eq(Virus.afterBattle(contracted,random),1,'native rare roll contracts after the Goldenrod gate') +T.eq(last("pokerus.infected").source,'contracted','de novo infection has its own source') +T.eq(last("pokerus.infected").strain,4,'de novo strain comes from the sampled byte') +T.eq(last("pokerus.infected").days,1,'de novo countdown is stored before observer dispatch') +local start=os.time({year=2026,month=10,day=20,hour=12}) +Virus.poll(contracted,start);T.eq(contracted.party[1].pokerus,0x41,'first clock poll only seeds the clock') +Virus.poll(contracted,start+86400*2);T.eq(contracted.party[1].pokerus,0x40,'elapsed days cure the actual party byte') +local shifted={party={{pokerus=0x34}},rtc={anchorTime=start,anchorMinute=1439, + anchorDay=2,anchorDate='2026-10-20'}} +Virus.poll(shifted,start);Virus.poll(shifted,start+120) +T.eq(shifted.party[1].pokerus,0x33,'infection countdown follows saved midnight while the host is still at noon') +local Caps=require('src.mods.Capabilities') +T.eq(Caps.supports('events','happiness.changed','prism'),false,'unimplemented hack friendship is not advertised') +T.eq(Caps.supports('events','pokerus.infected','polishedcrystal'),false,'unimplemented hack infection is not advertised') + + +local DayCare=require('src.pokemon.DayCare') +local a=Pokemon.new(data,'FIXMON_A',10,function(n)return n end) +local b=Pokemon.new(data,'FIXMON_A',10,function(n)return n end) +a.otId,b.otId=1,2;a.dvs.defense,b.dvs.defense=1,2 +DayCare.deposit(save,1,a);DayCare.deposit(save,2,b) +T.eq(DayCare.compatibility(data,save),4,'native text tier remains unchanged by a forwarding hook') +T.eq(state.compatibility.mon1,a,'compatibility context names the first resident') +T.eq(state.compatibility.mon2,b,'compatibility context names the second resident') +T.eq(state.compatibility.dayCare,true,'compatibility identifies a native Day Care query') +state.odds=1;local pen=DayCare.store(save);pen.stepsToEgg=1 +local oldRandom=math.random +math.random=function(low,high)if low==0 and high==255 then return 0 end;return low or 0 end +local made=DayCare.step(data,save) +math.random=oldRandom +T.eq(made,true,'an authored non-stock byte reaches the real egg-producing step') +T.eq(last("breeding.egg_created").egg,pen.egg,'egg event identifies the actual pending egg') +T.eq(last("breeding.egg_created").compatibility,1,'egg event retains the exact authored probability') +T.eq(last("breeding.egg_created").mother,b,'egg event identifies the real mother') +state.odds=nil + +-- Actual queue construction at the native catch boundary, before any UI IO. +local Battle=require('src.battle.BattleState') +local function caught() + local foe=Pokemon.new(data,'FIXMON_B',10,function(n)return n end) + return setmetatable({game=game,data=data,enemy={mon=foe,name='OWN FOE'},queue={},kind='wild'},Battle),foe +end +state.nickname='OWNED NAME EXCEEDS LIMIT' +local battle,foe=caught();battle:storeCaughtMon() +T.eq(foe.nickname,'OWNED NAME','nickname contract applies the native ten-character limit') +T.eq(state.catch.mon,foe,'nickname hook receives the actual caught record') +T.eq(state.catch.context.battle,battle,'nickname context identifies the actual battle') +state.claim=true;save.party={mon,mon,mon,mon,mon,mon} +battle,foe=caught();battle:storeCaughtMon() +T.eq(state.full.mon,foe,'full-party hook receives the caught record') +T.eq(#save.party,6,'claimed catch cannot overfill the party') +T.eq(save.boxes,nil,'claimed catch does not also deposit to a native box') +state.claim=false;state.nickname=nil + +local unownDef=require('src.mods.Merge').deepCopy(data.pokemon.FIXMON_A) +unownDef.id,unownDef.dex,unownDef.evolutions='SPECIES_201',201,{} +unownDef.translationSourceName='UNOWN' +unownDef.forms={};for n=1,26 do unownDef.forms[n]={}end +data.pokemon.SPECIES_201=unownDef;data.unown_dex={words={'OWN WORD'}} +local unown=Pokemon.new(data,'SPECIES_201',10,function(n)return n end) +unown.dvs={attack=0,defense=0,speed=0,special=0} +battle=caught();battle.enemy.mon=unown;state.claim=true;battle:storeCaughtMon() +T.eq(last("unown.unlocked").letter,1,'first native Unown capture unlocks its actual DV letter') +T.eq(last("unown.unlocked").word,'OWN WORD','Unown event reads the current imported word table') +T.eq(last("unown.unlocked").count,1,'Unown event reports the native ordered dex count') +battle=caught();battle.enemy.mon=unown;battle:storeCaughtMon() +T.eq(#events("unown.unlocked"),1,'catching a duplicate letter cannot unlock it again') +state.claim=false + +-- Dispatch the trainer decision through the real native enemy-action path. +battle,foe=caught();battle.player={mon=mon};battle.enemyParty={foe,Pokemon.new(data,'FIXMON_A',10,function(n)return n end)} +battle.enemyIndex=1;battle.trainer={id='OWN_AI'};battle.kind='trainer' +data.ai_classes=data.ai_classes or{};data.ai_classes.OWN_AI={} +state.enemy={kind='switch',index=2} +local action=battle:vanillaEnemyAction() +T.eq(action.special,'aiSwitch','canonical switch decisions enter the native action queue') +T.eq(action.index,2,'native switch retains the requested party slot') +T.eq(state.battle,battle,'enemy decision receives the actual battle') +state.enemy=true;action=battle:vanillaEnemyAction() +T.eq(action.special,'modEnemyTurn','a claimed enemy turn has an explicit native no-op action') +battle:executeAction(battle.enemy,battle.player,action) +T.eq(#battle.queue,0,'claimed enemy turn cannot also select a move') +state.enemy=nil +state.style='set';battle.expPaidPerSlot=true;battle.enemy.mon.hp=0 +battle:enemyMonFainted() +T.eq(state.styleBattle,battle,'native trainer replacement asks the style hook') + +-- Native hatching mutates the party record, then its real TextBox completion +-- boundary emits both old and new events. No producer is invoked directly. +local W=require('src.world.OverworldController');local slot,previous +for n=1,100 do local key,value=debug.getupvalue(W.hatchEgg,n) + if not key then break end;if key=='Game'then slot,previous=n,value;break end +end +assert(slot) +game.stack={states={}};function game.stack:push(screen)table.insert(self.states,screen)end +local egg=Pokemon.new(data,'FIXMON_A',5,function(n)return n end) +egg.isEgg=true;egg.eggSteps=1;save.party={egg} +debug.setupvalue(W.hatchEgg,slot,game) +T.eq(W.hatchEgg({},egg),true,'native hatching admits the actual party egg') +T.eq(#events("egg.hatched"),0,'egg event waits for the actual completion boundary') +game.stack.states[1].onDone();game.stack.states[2].choice(false) +local hatched=last("egg.hatched") +T.eq(hatched.mon,egg,'hatch event identifies the live party mon') +T.check(hatched.egg~=egg and hatched.egg.isEgg,'hatch event retains the original egg state') +T.eq(hatched.slot,1,'hatch event retains its party slot') +debug.setupvalue(W.hatchEgg,slot,previous) + +local Host=require('src.core.HostShell');local Music=require('src.core.Music') +local oldQuit,oldStop=Host.toLauncher,Music.stop;local quits=0 +Host.toLauncher=function()quits=quits+1 end;Music.stop=function()end -- external process/audio IO +local NativeGame=require('src.core.Game') +state.quit=true;T.eq(NativeGame:returnToLauncher(),false,'quit hook may retain the current game') +T.eq(quits,0,'a swallowed quit never reaches the host') +state.quit=false;NativeGame:returnToLauncher();T.eq(quits,1,'a forwarded quit reaches the host exactly once') +Host.toLauncher,Music.stop=oldQuit,oldStop + +local Intro=require('src.ui.Gen2Intro') +local stack={pop=function()end} +local introGame={data={field={intro={water='tests/fixture_data/assets/fixmon_a_front.png'}}}, + stack=stack,input={wasPressed=function()return false end}} +local intro=Intro.new(introGame,function()end);intro:enter() +T.eq(last("intro.boot.copyright").screen,intro,'first native branding stage announces copyright') +intro.scene=2;intro:enterScene() +T.eq(last("intro.boot.gamefreak").game,introGame,'second native branding stage identifies its game') +intro.scene=3;intro:enterScene() +T.eq(last("intro.boot.movie").screen,intro,'actual movie stage announces its screen') +intro._bootFrames=9;intro._bootSkipped=true;intro:finish();intro:finish() +T.eq(#events("intro.boot.movie_ended"),1,'movie completion cannot replay after a repeated finish') +T.eq(last("intro.boot.movie_ended").frames,9,'movie completion carries its frame count') +T.eq(last("intro.boot.movie_ended").skipped,true,'movie completion carries its skip decision') +local title=setmetatable({game=introGame,title={}},{__index=require('src.ui.TitleState')}) +title:enter();T.eq(last("intro.boot.title").screen,title,'actual title enter announces its screen') + + +require('src.mods.Loader').teardown();run.release();V.set(oldVersion) +T.finish('native Gen2 lifecycle callbacks through public mod registrations') diff --git a/tests/engine/gen2_mom_import.lua b/tests/engine/gen2_mom_import.lua new file mode 100644 index 000000000..5824fb69b --- /dev/null +++ b/tests/engine/gen2_mom_import.lua @@ -0,0 +1,104 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local old=V.get() +local E=require('src.import.RomExtractorGen2');local Importer=require('src.import.RomImporter') +local markerFor +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i);if name=='markerFor'then markerFor=value;break end;if not name then break end end +assert(markerFor) +local function fixture(version,edit) + local raw=string.rep('\0',0x8000) + local function put(at,bytes)raw=raw:sub(1,at)..bytes..raw:sub(at+#bytes+1)end + local symbols={MomItems_1={1,0x4000},['MomItems_1.End']={1,0x4010},MomItems_2={1,0x4010},['MomItems_2.End']={1,0x4028}, + ['CheckBalance_MomItem2.check_have_2300']={1,0x4100},hMoneyTemp={0,0xff80}, + ['Mom_GetScriptPointer.ItemScript']={1,0x4200},['Mom_GetScriptPointer.DollScript']={1,0x4201}, + PhoneContacts={1,0x4400},SpecialPhoneCallList={1,0x4418},NonTrainerCallerNames={1,0x4500}, + GotMoneyForWinningText={1,0x4300},SentSomeToMomText={1,0x4301},SentHalfToMomText={1,0x4302},SentAllToMomText={1,0x4303}} + put(0x4000,string.char(0,0,0,0,0,3,1,17,0,0,0,0,0,5,1,18, + 0,0,100,0,0,20,1,17,0,0,200,0,0,70,2,14,0,1,244,0,0,30,1,18)) + put(0x4100,string.char(0x21,0x80,0xff,0x36,0,0x23,0x36,0,0x23,0x36,17)) + put(0x4200,string.char(0x91,0x91)) + put(0x440c,string.char(0,1));put(0x4502,string.char(0x10,0x45));put(0x4510,string.char(128,0x50)) + if edit then edit(symbols,put)end + local e=E.new(raw,version,{symbols=symbols});e._gen2ScriptTexts={} + function e:readSourceTable(key)assert(key=='charmap');return{['128']='Q'}end + return e +end +local function pool()return{scripts={},queue={},movements={},instructions=0,desyncs=0,failures=0}end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local e=fixture(version);local q=pool();local rows=e:gen2MomShopping(q) + T.eq(rows.complete,true,version..' actual Mom importer accepts bounded native tables') + local phone=e:gen2PhoneContacts(nil) + T.eq(phone[1].contactNumber,1,version..' actual non-trainer contact identity survives localized name extraction') + T.eq(phone[1].class,0,version..' actual imported Mom contact retains native non-trainer class') + T.eq(#rows.random,2,version..' random count comes from its end symbol') + T.eq(#rows.ladder,3,version..' ladder count comes from its end symbol') + T.eq(rows.ladder[3].trigger,500,version..' money trigger is a big-endian triple') + T.eq(rows.ladder[2].kind,2,version..' kind distinguishes real DECO byte from native item') + T.eq(rows.ladder[2].item,14,version..' row retains actual byte rather than a guessed species') + T.eq(rows.step,17,version..' random threshold is read from actual instructions, not a retail literal') + T.eq(#q.queue,2,version..' both original phone scripts enter native decode queue') + T.check(e._gen2ScriptTexts[rows.rewardTexts[1]]~=nil,version..' native reward prose reaches shared text importer') + e:gen2MomShopping(q);T.eq(#q.queue,2,version..' repeated import queues each script once') + for _,job in ipairs(q.queue)do e:gen2DecodeScript(job[1],job[2],job[3],q)end + T.eq(q.desyncs,0,version..' native queued scripts decode without desync') + T.check(markerFor(version):find('gen2%-mom%-shopping%-native%-v1:')~=nil,version..' new cache requires purchase data') + for _,edit in ipairs({ + function(s)s.MomItems_1=nil end, + function(s)s['MomItems_2.End']=nil end, + function(s)s['MomItems_1.End'][2]=0x4011 end, + function(s)s['MomItems_1.End'][1]=2 end, + function(s)s['MomItems_2.End'][2]=0x400f end, + function(s,put)put(0x4000,string.char(255,255,255))end, + function(s,put)put(0x4003,string.char(255,255,255))end, + function(s,put)put(0x4006,string.char(3))end, + function(s,put)put(0x4007,string.char(0))end, + function(s,put)put(0x4104,string.char(0))put(0x4107,string.char(0))put(0x410a,string.char(0))end, + function(s)s.hMoneyTemp[2]=0xff81 end, + function(s)s['CheckBalance_MomItem2.check_have_2300']=nil end, + function(s)s['Mom_GetScriptPointer.DollScript']=nil end, + function(s)s['Mom_GetScriptPointer.ItemScript'][2]=0x100 end, + })do + local bad=fixture(version,edit);local q=pool();local result=bad:gen2MomShopping(q) + T.eq(result.complete,false,version..' malformed or incomplete input cannot claim a native purchase binding') + T.eq(result.random,nil,version..' malformed binding publishes no partial retail table') + T.eq(#q.queue,0,version..' invalid binding does not partially mutate script queue') + end +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + local q=pool();T.eq(fixture(version):gen2MomShopping(q),nil,version..' unreviewed layout has no inferred retail shopping') + T.eq(#q.queue,0,version..' unrelated script queue stays unchanged') + T.check(not markerFor(version):find('gen2%-mom%-shopping%-native%-v1:'),version..' unrelated cache remains unchanged') +end +local Service=require('src.world.Gen2PhoneService');local Writer=require('src.import.LuaWriter') +local function extractor(version,palette,compact) + local raw=string.rep('\0',0x8000) + local function put(at,v)raw=raw:sub(1,at)..string.char(v)..raw:sub(at+2)end + put(0x4000,2);put(0x4001,0x40) + local header=compact and {1,2,0,0x50,1,1,palette}or{1,1,2,0,0x50,1,1,palette,0} + for i,v in ipairs(header)do put(0x4001+i,v)end + local layout=compact and {mapHeaderBytes=7,mapHeaderAttrAt=2,mapHeaderTilesetAt=0,mapHeaderEnvAt=1,mapHeaderLandmarkAt=4,mapHeaderMusicAt=5,mapHeaderPaletteAt=6,mapHeaderFishAt=-1,mapAttributesBank=2}or{} + return E.new(raw,version,{symbols={MapGroupPointers={1,0x4000},Native_MapAttributes={compact and 2 or 1,0x5000}},layout=layout}) +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for raw=0,255 do + local e=extractor(version,raw);local _,labels=e:gen2MapIndex();local header=assert(labels.Native) + T.eq(header.palette,raw%16,'native header retains the unchanged palette nibble') + T.eq(header.paletteBits,raw,'native header retains the complete real byte') + T.eq(header.phoneService,raw<16,'native header decodes the actual no-service nibble') + T.eq(Service.available({mapPalette=header.palette,mapPaletteBits=header.paletteBits,phoneService=header.phoneService}),raw<16,'runtime consumes the native imported no-service verdict') + end + for _,raw in ipairs({1,0x11,0xF4})do + local e=extractor(version,raw);local maps={NATIVE={id='NATIVE',label='Native',width=1,height=1,warps={}}};local encoded + -- Input table/file output are IO seams. Header lookup, map lowering and + -- Lua serialization are the actual importer, including false booleans. + function e:readSourceTable(name)return name=='maps'and maps or{}end + function e:write(name,value)if name=='maps'then encoded=Writer.encode(value)end end + e:extractMapsFromRom();local decoded=assert(loadstring(assert(encoded)))().NATIVE + T.eq(decoded.mapPalette,raw%16,'full map lowering preserves the palette rule') + T.eq(decoded.mapPaletteBits,raw,'full map lowering retains header bits') + T.eq(decoded.phoneService,raw<16,'serialization does not lose an explicit no-service false') + end +end +V.set(old);T.finish('native Mom table boundaries, threshold instruction operands, script queues and cache revision') diff --git a/tests/engine/gen2_mom_shopping.lua b/tests/engine/gen2_mom_shopping.lua new file mode 100644 index 000000000..a140cce25 --- /dev/null +++ b/tests/engine/gen2_mom_shopping.lua @@ -0,0 +1,201 @@ +-- Copyright (c) 2026 Roxas2712. See contribution permission: +-- https://github.com/Roxas2712/Gen2Recomped/blob/7867399c753186778bd4b8816ba37573357e4b40/LICENSE-ROXAS2712.md +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Mom=require('src.world.Gen2MomShopping');local Native=require('src.world.Gen2Decorations') +local Flags=require('src.script.Gen2Flags');local Runner=require('src.script.ScriptRunner') +local W=require('src.world.OverworldController');local Stack=require('src.core.StateStack') +local Map=require('src.world.Map');local Pokemon=require('src.pokemon.Pokemon') +local Battle=require('src.battle.BattleState');local S=require('src.save_convert.Gen2Save') +local E=require('src.import.RomExtractorGen2');local J=require('src.link.Json') +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end -- audio device IO +local slot,oldGame +for i=1,100 do local name,value=debug.getupvalue(W.afterBattle,i);if name=='Game'then slot=i;oldGame=value;break end end +assert(slot) +local function data() + local d=T.fixtures.fresh() + d.items.ITEM_017={id='ITEM_017',index=17,name='SYNTHETIC A',pocket='ITEM'} + d.items.ITEM_018={id='ITEM_018',index=18,name='SYNTHETIC B',pocket='ITEM'} + d.map_scripts={momShopping={complete=true,step=17, + random={{trigger=0,cost=3,kind=1,item=17},{trigger=0,cost=5,kind=1,item=18}}, + ladder={{trigger=100,cost=20,kind=1,item=17},{trigger=200,cost=70,kind=2,item=14},{trigger=500,cost=30,kind=1,item=18}}, + programs={item='ITEM_PHONE',doll='DOLL_PHONE'},rewardTexts={[0]='REWARD0',[1]='REWARD1',[2]='REWARD2',[3]='REWARD3'}}, + phone={[3]={class=0,contactNumber=1,name='SYNTHETIC MOM'}}, + decorations={[14]={type=1,name='SYNTHETIC DOLL',action='SET_UP_DOLL',flag=900,sprite=18}}, + decorationInfo={complete=true},scripts={BANK={{'special',0,'BankOfMom'},{'end'}},ITEM_PHONE={{'setevent',901},{'end'}},DOLL_PHONE={{'setevent',902},{'end'}}}} + d.text={REWARD0='SYNTHETIC {PLAYER} TOTAL {NUM}',REWARD1='SYNTHETIC SOME {PLAYER} TOTAL {NUM}', + REWARD2='SYNTHETIC HALF {PLAYER} TOTAL {NUM}',REWARD3='SYNTHETIC ALL {PLAYER} TOTAL {NUM}'} + return d +end +local function save(balance) + return{flags={},money=0,g2MomMoney=balance or 0,inventory={},pcItems={},party={},player={name='QA'},pokedex={seen={},owned={}}} +end +local function buy(d,s,opts) + opts=opts or{};opts.phoneService=opts.phoneService~=false;return Mom.tryBuy(d,s,opts) +end +local function game(d,s) + local def={id='QA_MOM',generation=2,width=2,height=2,blocks={1,1,1,1},warps={},mapPalette=1,phoneService=true} + local ts={id='FLOOR',blockCells=2,collision={255,255,255,255,0,0,0,0},walkable={0},grassTiles={},waterTiles={}} + d.maps.QA_MOM=def + local w=setmetatable({map=Map.new(def,ts),player={cellX=0,cellY=0,facing='down'},scriptMoves={},pendingScripts={},npcs={}},{__index=W}) + local stack=setmetatable({states={w}},{__index=Stack}) -- world already entered in this fixture + local g={data=d,save=s,stack=stack,world=w,overworld=w,input={wasPressed=function()return false end}};w.runner=Runner.new(g,w) + debug.setupvalue(W.afterBattle,slot,g);return g,w +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for mode=0,3 do + for _,coin in ipairs({1,2})do + local s=save(100);s.money=10;s.g2MomSavingMode=mode + local quarters=({[0]=0,1,2,4})[mode];local award=Mom.reward(s,900*coin) + T.eq(s.g2MomMoney,100+225*coin*quarters,version..' native mode '..mode..' sends actual quarters to Mom') + T.eq(s.money,10+225*coin*(4-quarters),version..' wallet receives only its actual share') + T.eq(award.total,900*coin,version..' displayed total remains complete prize') + T.eq(award.mode,mode,version..' native text uses actual savings mode') + end + end + local s=save(999999);s.g2MomSaving=true + T.eq(Mom.reward(s,900).mode,0,version..' maxed savings disables skimming') + T.eq(s.money,900,version..' maxed savings sends all quarters to wallet') + s=save(999998);s.g2MomSaving=true;s.money=999998 + Mom.reward(s,900) + T.eq(s.g2MomMoney,999999,version..' near-cap Mom account saturates') + T.eq(s.money,999999,version..' wallet saturates separately') + s=save();s.g2MomSaving=true;s.flags.ENGINE_MOM_SAVING_MONEY=false + T.eq(Mom.mode(s),0,version..' explicit native flag overrides legacy saving boolean') + s.g2MomSavingMode=2;T.eq(Mom.mode(s),2,version..' HALF bit survives an unset SOME bit') + s.flags.ENGINE_MOM_SAVING_MONEY=true;T.eq(Mom.mode(s),3,version..' native setflag changes only bit zero') + Mom.setSaving(s,false);T.eq(Mom.mode(s),0,version..' bank stop clears saving mode') + Mom.openBank(s);Mom.setSaving(s,true) + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' bank opening reaches native active flag') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' bank start reaches native SOME flag') + local previous=save();previous.g2MomBankOpen=true;previous.g2MomSaving=true + Mom.prepareBank(previous) + T.eq(previous.flags.ENGINE_MOM_ACTIVE,true,version..' earlier native preview account migrates to active flag') + T.eq(previous.flags.ENGINE_MOM_SAVING_MONEY,true,version..' earlier native preview saving choice migrates to native flag') + previous.flags.ENGINE_MOM_ACTIVE=false;Mom.prepareBank(previous) + T.eq(previous.g2MomBankOpen,nil,version..' explicit native reset overrides legacy open boolean') + local legacy=data();legacy.map_scripts.phone[3].contactNumber=nil;legacy.map_scripts.phone[3].trainer=1 + T.eq(Mom.caller(legacy),3,version..' original non-trainer contact byte remains supported') + local d=data();s=save() + local bankGame,bankWorld=game(d,s) + local bank=Runner.new(bankGame,bankWorld);bank:run(assert(require('src.script.Gen2ScriptVM').compile(d,'BANK'))) + local box=bankGame.stack:top();box.done=true;box:update(0) + local yes=bankGame.stack:top();bankGame.input={wasPressed=function(_,key)return key=='a'end};yes.index=1;yes:update(0);bankGame.input={wasPressed=function()return false end};for _=1,20 do if bankGame.stack:top()==yes then yes:update(0)end end + local feedback=bankGame.stack:top();feedback.done=true;bankGame.input={wasPressed=function(_,key)return key=='a'end};feedback:update(0) + T.eq(bank:isRunning(),false,version..' actual BankOfMom UI completes first saving choice') + T.eq(s.flags.ENGINE_MOM_SAVING_MONEY,true,version..' actual BankOfMom UI writes native SOME flag') + T.eq(s.flags.ENGINE_MOM_ACTIVE,true,version..' actual BankOfMom UI opens native account') + T.eq(Mom.mode(s),1,version..' actual BankOfMom UI affects reward allocation mode') + local d=data();s=save(100) + local purchase=buy(d,s) + T.eq(purchase.kind,1,version..' first ladder purchase is native item') + T.eq(s.pcItems.ITEM_017,1,version..' native item byte resolves current dataset') + T.eq(s.g2MomMoney,80,version..' price deducted once') + T.eq(s.g2MomShopping.whichItem,1,version..' ladder advances only on delivery') + T.eq(s.g2MomShopping.whichSet,0,version..' ladder uses native zero set') + T.eq(purchase.caller,3,version..' Mom caller is current native contact identity') + s.g2MomMoney=200;purchase=buy(d,s) + T.eq(s.flags[Flags.eventFlag(900)],true,version..' doll purchase grants actual ownership flag') + T.eq(s.g2MomMoney,130,version..' doll price is deducted') + T.eq(s.g2MomShopping.whichItem,2,version..' doll advances ladder once') + T.eq(s.decorations,nil,version..' doll ownership does not place it in the room') + local g,w=game(d,s);local r=Runner.new(g,w);r:run(purchase.script) + T.eq(s.flags[Flags.eventFlag(902)],true,version..' doll notification runs native compiled role') + s=save(101);purchase=buy(d,s) + T.check(purchase~=nil,version..' ladder takes precedence over random threshold') + s=save(34);s.g2MomShopping={whichItem=255,triggerBalance=17} + local called=0;purchase=buy(d,s,{random=function(n)called=called+1;return n-1 end}) + T.eq(purchase.item,18,version..' exact multiple selects current random table row') + T.eq(called,1,version..' random purchase consumes one bounded roll') + T.eq(s.g2MomShopping.triggerBalance,51,version..' consumed exact threshold advances once') + T.eq(s.g2MomShopping.whichSet,2,version..' native random set is selected row plus one') + T.eq(s.g2MomShopping.whichItem,255,version..' random buy cannot consume ladder index') + T.eq(s.g2MomMoney,29,version..' random purchase deducts its own price') + s=save(35);s.g2MomShopping={whichItem=255,triggerBalance=17} + T.eq(buy(d,s),nil,version..' overshot multiple cannot buy') + T.eq(s.g2MomShopping.triggerBalance,51,version..' overshot threshold advances above current balance') + s=save(100);d.field.pcItemCap=1;s.pcItems.ITEM_018=99 + T.eq(buy(d,s),nil,version..' full PC rejects new stack') + T.eq(s.g2MomMoney,100,version..' refusal does not spend money') + T.eq(s.g2MomShopping.whichItem,0,version..' refusal does not advance ladder') + s.pcItems={ITEM_017=98};T.check(buy(d,s)~=nil,version..' existing stack with room accepts purchase') + T.eq(s.pcItems.ITEM_017,99,version..' item reaches last slot in actual stack') + s=save(100);s.pcItems.ITEM_017=99 + T.eq(buy(d,s),nil,version..' a full same-item stack needs another native PC stack') + s=save(200);s.g2MomShopping={whichItem=1,triggerBalance=17};s.pcItems.ITEM_018=99 + T.check(buy(d,s)~=nil,version..' doll ownership is independent of PC capacity') + s=save(100);T.eq(buy(d,s,{phoneService=false}),nil,version..' no reception suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' no reception cannot consume threshold or initialize purchase state') + T.eq(buy(d,s,{queued=true}),nil,version..' occupied native map-reentry queue suppresses purchase') + T.eq(s.g2MomShopping,nil,version..' occupied queue cannot mutate purchase state') + d.map_scripts.scripts.ITEM_PHONE=nil + T.eq(buy(d,s),nil,version..' missing original phone program cannot grant a silent purchase') + T.eq(s.pcItems.ITEM_017,nil,version..' missing native program leaves inventory unchanged') + -- Actual battle win uses this account split and the cartridge-selected text. + d=data();require('src.battle.TypeChart').load(d);s=save(10) + s.party={Pokemon.new(d,'FIXMON_A',10)};s.g2MomSavingMode=1 + g,w=game(d,s) + local battle=Battle.newTrainer(g,'OPP_FIX_YOUNGSTER',1);battle.participants={} + for _,mon in ipairs(battle.enemyParty)do mon.hp=0 end + battle.enemyIndex=#battle.enemyParty;battle.enemy.mon=battle.enemyParty[battle.enemyIndex] + battle:enemyMonFainted() + T.eq(battle.result,'win',version..' real battle native win branch completes') + T.check(s.g2MomMoney>10,version..' real battle win actually increases Mom account') + T.eq(battle.g2MomAward.mode,1,version..' real battle records applied savings mode') + local found=false + for _,row in ipairs(battle.queue)do if row.text and row.text:find('SYNTHETIC SOME QA TOTAL ',1,true)then found=true end end + T.check(found,version..' real battle presents actual imported SOME reward text') + -- Actual world post-battle path commits once and waits behind native dialogue. + d=data();s=save(200);s.party={Pokemon.new(d,'FIXMON_A',10)} + s.g2MomShopping={whichItem=1,triggerBalance=17};g,w=game(d,s) + battle={kind='trainer',leveledUp={}} + w:afterBattle('win',battle) + T.eq(s.g2MomMoney,130,version..' actual trainer return purchases eligible doll') + T.check(w.g2MomCallPending~=nil,version..' actual trainer return defers original notification') + w:afterBattle('win',battle);T.eq(s.g2MomMoney,130,version..' repeated callback cannot buy twice') + w.pendingScripts={{script={{'end'}}}} + T.eq(w:checkMomShoppingCall(),false,version..' trainer after-dialogue queue keeps phone waiting') + w.pendingScripts={};w.player.moving=true + T.eq(w:checkMomShoppingCall(),false,version..' native phone does not interrupt a partial movement') + w.player.moving=false;g.stack:push({name='dialogue'}) + T.eq(w:checkMomShoppingCall(),false,version..' actual overlay owner keeps phone waiting') + g.stack:pop();T.eq(w:checkMomShoppingCall(),true,version..' idle native world queues original doll script') + T.eq(s.g2CurCaller,3,version..' deferred native call sets actual caller') + T.eq(w:checkMomShoppingCall(),false,version..' notification queues once') + local entry=w.pendingScripts[1];T.check(entry~=nil,version..' actual native FIFO receives notification') + if entry then w.runner:run(entry.script,entry.extra)end + T.eq(s.flags[Flags.eventFlag(902)],true,version..' actual queued original role executes') + T.eq(s.g2CurCaller,nil,version..' completed native call releases caller') + T.eq(s.g2CallCycles,0,version..' completed native call resets receive timer') + -- Physical SRAM offsets checked against the independent shipped symbols. + S.setLayout(version);S.setCharmap({['128']='A'}) + s.g2MomMoney=123456;s.g2MomSavingMode=2;s.g2MomSaving=true;s.g2MomBankOpen=true + s.flags.ENGINE_MOM_SAVING_MONEY=false;s.flags.ENGINE_MOM_ACTIVE=true + s.g2MomShopping={whichItem=2,whichSet=1,triggerBalance=98765} + local bytes=S.encode(s,d);local f=assert(io.open('tools/rom_manifest_'..version..'.json','rb'));local manifest=assert(J.decode(f:read('*a')));f:close() + local anchor=version=='crystal'and 0xd47b or 0xd1a1 + local function offset(key)return 0x2009+manifest.symbols[key][2]-anchor end + local function three(at)local a,b,c=bytes:byte(at+1,at+3);return a*65536+b*256+c end + T.eq(three(offset('wMomsMoney')),123456,version..' Mom account uses actual three-byte SRAM field') + T.eq(bytes:byte(offset('wMomSavingMoney')+1),130,version..' saving mode and active bit preserve native byte') + T.eq(bytes:byte(offset('wWhichMomItem')+1),2,version..' native ladder index exports at independent address') + T.eq(three(offset('wMomItemTriggerBalance')),98765,version..' native threshold exports at independent address') + local back=S.decode(bytes,d) + T.eq(back.g2MomMoney,123456,version..' native balance round-trips') + T.eq(Mom.mode(back),2,version..' HALF mode round-trips without becoming SOME') + T.eq(back.g2MomBankOpen,true,version..' bank active state round-trips') + T.eq(back.g2MomShopping.whichItem,2,version..' ladder progress round-trips') + T.eq(back.g2MomShopping.whichSet,1,version..' random row round-trips') + T.eq(back.g2MomShopping.triggerBalance,98765,version..' threshold progress round-trips') + local again=S.encode(back,d,bytes);local decoded=S.decode(again,d) + local good=true;for _,warning in ipairs(decoded.warnings)do if warning:find('checksum')then good=false end end + T.check(good,version..' Mom fields remain covered by valid native checksum') +end +for _,version in ipairs({'red','emerald','prism','polishedcrystal'})do + V.set(version);local s=save(10);T.eq(Mom.reward(s,900),nil,version..' native GSC account split is isolated') + T.eq(Mom.tryBuy(data(),s,{phoneService=true}),nil,version..' foreign edition cannot buy retail GSC gifts') + S.setLayout(version);T.eq(S.OFFSETS.momMoney,nil,version..' unreviewed SRAM layout has no guessed Mom fields') +end +Sound.play=oldSound;debug.setupvalue(W.afterBattle,slot,oldGame);V.set(original);S.setLayout(original) +T.finish('native GSC Mom accounts, purchases, trainer return, phone queue and SRAM') diff --git a/tests/engine/gen2_phone_book.lua b/tests/engine/gen2_phone_book.lua new file mode 100644 index 000000000..06f472afd --- /dev/null +++ b/tests/engine/gen2_phone_book.lua @@ -0,0 +1,140 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-book.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Book=require('src.world.Gen2PhoneBook') +local Calls=require('src.script.Gen2Commands') +local Gear=require('src.ui.PokegearMenu') +local function data() + local d=T.fixtures.fresh();d.map_scripts={phone={},scripts={STUB={{'end'}}}} + for id=1,24 do d.map_scripts.phone[id]={name='CONTACT'..id,class=1,trainer=1, + receive='STUB',receiveTime=7,call='STUB',callTime=7,group=1,number=1} end + d.map_scripts.phone[1]={name='MOM',contactNumber=1} + d.map_scripts.phone[4]={name='ELM',contactNumber=4} + return d +end +local function same(actual,expected,label) + T.eq(#actual,#expected,label..' length') + for i,id in ipairs(expected) do T.eq(actual[i],id,label..' slot'..i) end +end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version);local d,save=data(),{} + local label=version..': ' + T.eq(Book.remainingSlots(d,save,2),8,label..' absent permanent numbers reserve two slots') + T.eq(Book.remainingSlots(d,save,1),9,label..' a permanent number does not reserve against itself') + T.eq(Book.has(save,0),true,label..' native empty-byte membership semantics retained') + local trainerIds={2,3,5,6,7,8,9,10} + for _,id in ipairs(trainerIds) do T.eq(Book.add(d,save,id),true,label..' ordinary number admitted within unreserved run') end + T.eq(Book.add(d,save,11),false,label..' ninth trainer cannot consume missing permanent slots') + T.eq(Book.add(d,save,1),true,label..' Mom can occupy her reserved slot') + T.eq(Book.add(d,save,4),true,label..' Elm can occupy his reserved slot') + same(Book.contacts(save),{2,3,5,6,7,8,9,10,1,4},label..' insertion order reaches all ten slots') + T.eq(Book.has(save,0),false,label..' full native array contains no empty byte') + local ok,why=Book.add(d,save,2);T.eq(ok,false,label..' duplicate refused');T.eq(why,'already',label..' duplicate reason retained') + T.eq(Book.add(d,save,0),false,label..' zero never becomes a registered contact') + T.eq(Book.add(d,save,255),false,label..' absent contact cannot become callable') + T.eq(Book.canDelete(d,1),false,label..' Mom is permanent in the native UI') + T.eq(Book.canDelete(d,4),false,label..' Elm is permanent in the native UI') + T.eq(Book.canDelete(d,2),true,label..' trainer number is deletable') + T.eq(Book.remove(save,5),true,label..' script deletion succeeds') + T.eq(Book.contacts(save)[3],0,label..' script deletion leaves a physical hole') + T.eq(Book.contacts(save)[4],6,label..' script deletion does not reorder later slots') + T.eq(Book.add(d,save,11),true,label..' next addition occupies the first hole') + T.eq(Book.contacts(save)[3],11,label..' reusing a hole keeps remaining order') + T.eq(Book.deleteAt(save,2),true,label..' Pokegear deletion succeeds') + same(Book.contacts(save),{2,11,6,7,8,9,10,1,4,0},label..' only Pokegear deletion compacts') + T.eq(save.g2Phone[3],nil,label..' deletion updates the existing numeric set') + local detached=Book.contacts(save);detached[1]=24 + T.eq(Book.contacts(save)[1],2,label..' returned slot arrays are detached') + save.g2Phone[23]=true + T.eq(Book.contacts(save)[10],23,label..' old/native set writes are adopted without reordering') + save.g2Phone[11]=nil + T.eq(Book.contacts(save)[2],0,label..' external native deletion is not resurrected') + local selected=Calls.availableCallers(d,save,{group=2,number=2},'DAY') + same(selected,{2,6,7,8,9,10,23},label..' actual incoming selector walks physical registration order') + -- Actual imported-opcode compiler and native ScriptRunner, including the + -- askforphonenumber result branches. Only the yes/no prompt is an IO seam. + local fresh={};local game={data=d,save=fresh,overworld={}} + local serial=0 + local function run(rows) + serial=serial+1;local key='BOOK'..serial + d.map_scripts.scripts[key]=rows + local lowered=assert(require('src.script.Gen2ScriptVM').compile(d,key)) + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(lowered) + T.eq(runner:isRunning(),false,label..' native book script returns') + return runner.ctx + end + local ctx=run({{'addcellnum',7},{'checkcellnum',7},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual native add/check writes the book') + T.eq(Book.contacts(fresh)[1],7,label..' VM and UI share native slot ownership') + ctx=run({{'delcellnum',7},{'checkcellnum',7},{'end'}}) + T.eq(ctx.g2Var,0,label..' actual native delete clears membership') + ctx=run({{'checkcellnum',0},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual native checkcellnum sees an empty slot') + local Commands=require('src.script.Commands');local ask=Commands.ask + local accepted=true;Commands.ask=function(c)c.lastCheck=accepted end + ctx=run({{'askforphonenumber',8},{'end'}}) + T.eq(ctx.g2Var,0,label..' actual accepted ask returns CONTACT_GOT') + ctx=run({{'askforphonenumber',8},{'end'}}) + T.eq(ctx.g2Var,1,label..' actual duplicate ask returns CONTACTS_FULL') + accepted=false;ctx=run({{'askforphonenumber',9},{'end'}}) + T.eq(ctx.g2Var,2,label..' actual refused ask returns CONTACT_REFUSED') + T.eq(fresh.g2Phone[9],nil,label..' refusal does not mutate native membership') + Commands.ask=ask +end +V.set('crystal') +do + local d=data();d.map_scripts.phone[1]={class=1,name='TRAINER1'} + d.map_scripts.phone[4]={class=1,name='TRAINER4'} + d.map_scripts.phone[12]={name='AUTHORED MOM',contactNumber=1} + d.map_scripts.phone[14]={name='AUTHORED ELM',contactNumber=4} + T.eq(Book.canDelete(d,1),true,'native trainer identity wins over the old Mom slot') + T.eq(Book.canDelete(d,4),true,'native trainer identity wins over the old Elm slot') + T.eq(Book.canDelete(d,12),false,'explicit relocated Mom identity is permanent') + T.eq(Book.canDelete(d,14),false,'explicit relocated Elm identity is permanent') + d.constants.phoneContactOrder={};d.map_scripts.phone[1]={name='CUSTOM'} + T.eq(Book.canDelete(d,1),true,'authored empty identity metadata does not receive retail guesses') +end +do + local d=data();local save={g2Phone={[7]=true,[2]=true,[5]=true}} + same(Book.contacts(save),{2,5,7,0,0,0,0,0,0,0},'legacy set receives deterministic initial slot order') + save.g2PhoneSlots={7,2,2,0,0,0,0,0,0,0} + same(Book.contacts(save),{7,2,5,0,0,0,0,0,0,0},'stored order survives and duplicate slots are repaired') + local gear=setmetatable({game={data=d,save=save},contact=1},{__index=Gear}) + local rows=gear:contacts();T.eq(#rows,10,'actual native card exposes ten physical slots') + T.eq(rows[1].id,7,'actual native card does not sort away registration order') + T.eq(rows[4].id,0,'empty native row is not callable') + T.eq(rows[4].name,'----------','empty native row is displayed explicitly') + local empty=setmetatable({game={data=d,save={}}},{__index=Gear}):contacts() + T.eq(empty[1].id,0,'empty book cannot invent a Mom registration') + T.eq(empty[4].id,0,'empty book cannot invent an Elm registration') + local key;gear.cards={{id='PHONE'}};gear.index=1;gear.blink=0 + gear.game.input={wasPressed=function(_,value)return key==value end} + gear.game.stack={pop=function()end} + local Sound=require('src.core.Sound');local sound=Sound.play;Sound.play=function()end + gear.contact=4;key='a';gear:update(0) + T.eq(gear.phoneSubmenu,nil,'real native A input cannot call an empty slot') + gear.contact=2;key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[2],nil,'real native DELETE input removes the selected physical slot') + same(Book.contacts(save),{7,5,0,0,0,0,0,0,0,0},'real Pokegear delete compacts the shared book') + Sound.play=sound +end +do + local d=data();local save={g2Phone={},g2PhoneSlots={2,3,5,6,7,8,9,10,1,4}} + for _,id in ipairs(save.g2PhoneSlots)do save.g2Phone[id]=true end + local gear=setmetatable({game={data=d,save=save},contact=10,gfx={}},{__index=Gear}) + local drawn={};gear.drawStrip=function()end;gear.textbox=function()end + gear.printBoxText=function()end;gear.drawModeArrow=function()end + gear.text=function(_,name,_,y)drawn[name]=y end + local Font=require('src.render.Font');local old={Font.draw,Font.drawCode,Font.beginTint,Font.endTint} + local graphics=love.graphics;love.graphics={setColor=function()end,rectangle=function()end} + Font.draw=function(name,_,y)drawn[name]=y/8 end + Font.drawCode=function()end;Font.beginTint=function()end;Font.endTint=function()end + gear:drawPhone() + T.eq(drawn.ELM,11,'actual native drawing keeps selected slot ten visible') + T.eq(drawn.MOM,10,'actual native drawing retains slot nine beside it') + T.eq(drawn.CONTACT2,nil,'scrolling does not draw hidden slot one') + Font.draw,Font.drawCode,Font.beginTint,Font.endTint=unpack(old);love.graphics=graphics +end +V.set(original) +T.finish('native ordered phone slots, permanent reservations, actual VM and Pokegear') diff --git a/tests/engine/gen2_phone_call_selection.lua b/tests/engine/gen2_phone_call_selection.lua new file mode 100644 index 000000000..fbf148c8a --- /dev/null +++ b/tests/engine/gen2_phone_call_selection.lua @@ -0,0 +1,78 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-call-selection.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Calls=require('src.script.Gen2Commands') +local V=require('src.core.GameVersion');local original=V.get() +local bit=require('bit') +local consumed=0 +local function unused()consumed=consumed+1;return 0 end +T.eq(Calls.chooseRandomCaller(nil,unused),nil,'missing candidate list has no caller') +T.eq(Calls.chooseRandomCaller({},unused),nil,'empty candidate list has no caller') +T.eq(consumed,0,'empty selection consumes no random byte') +for n=1,10 do + local callers={};for i=1,n do callers[i]=100+i end + for byte=0,255 do + local used=0 + local got=Calls.chooseRandomCaller(callers,function()used=used+1;return byte end) + -- Independent bytewise SWAP / AND $1f / division remainder oracle. + local swapped=bit.band(bit.bor(bit.lshift(byte,4),bit.rshift(byte,4)),255) + local expected=callers[bit.band(swapped,31)%n+1] + T.eq(got,expected,'native byte mapping n='..n..' byte='..byte) + T.eq(used,1,'native choice consumes exactly one byte') + end +end +local old=math.random +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + local data={map_scripts={scripts={},phone={}}} + local ids={9,7,11} + for i,id in ipairs(ids) do + local label='NATIVE_RECEIVE_'..version..'_'..id + data.map_scripts.phone[id]={receive=label,receiveTime=2,group=2,number=1} + data.map_scripts.scripts[label]={{'setval',80+i},{'end'}} + end + local function save() + return {g2LastCallMinute=0,g2Phone={[9]=true,[7]=true,[11]=true}, + g2PhoneSlots={9,7,11,0,0,0,0,0,0,0}} + end + local map={group=1,number=1,phoneService=true} + local function randomBytes(bytes) + local used=0 + math.random=function(a,b) + T.eq(a,0,'phone RNG requests an unsigned byte lower bound') + T.eq(b,255,'phone RNG requests an unsigned byte upper bound') + used=used+1;assert(bytes[used]~=nil,'unexpected phone RNG draw') + return bytes[used] + end + return function()return used end + end + -- Byte $10 swaps to $01, choosing slot two, not sorted numeric ID order. + local draws=randomBytes({0,16});local s=save() + local id,rows=Calls.rollIncomingCall(data,s,map,'DAY',30,false) + T.eq(id,7,version..' actual incoming path selects the native saved-slot sample') + T.eq(draws(),2,version..' gate and caller choice consume one byte each') + T.eq(s.g2CallCycles,0,version..' accepted call resets the receive cycle') + T.eq(s.g2LastCallMinute,30,version..' accepted call restarts at the actual minute') + local runner=require('src.script.ScriptRunner').new({data=data,save=s},{}) + runner:run(rows) + T.eq(runner.ctx.g2Var,82,version..' selected native receive program actually executes') + draws=randomBytes({128});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,false),nil,version..' failed coin flip suppresses choice') + T.eq(draws(),1,version..' failed coin flip consumes no caller-choice byte') + T.eq(s.g2CallCycles,1,version..' failed coin flip still consumes timer expiry') + draws=randomBytes({0});s=save() + T.eq(Calls.rollIncomingCall(data,s,{phoneService=false,mapPalette=0x10},'DAY',30,false),nil,version..' missing service suppresses choice') + T.eq(draws(),1,version..' missing service consumes no caller-choice byte') + draws=randomBytes({0});s=save();s.g2Phone={};s.g2PhoneSlots={} + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,false),nil,version..' empty native book suppresses choice') + T.eq(draws(),1,version..' empty native book consumes no caller-choice byte') + draws=randomBytes({});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',30,true),nil,version..' entrance blocks before timer and RNG') + T.eq(draws(),0,version..' entrance consumes no random bytes') + T.eq(s.g2LastCallMinute,0,version..' entrance preserves pending timer') + draws=randomBytes({});s=save() + T.eq(Calls.rollIncomingCall(data,s,map,'DAY',1,false),nil,version..' unexpired timer suppresses gate and selection') + T.eq(draws(),0,version..' unexpired timer consumes no random bytes') +end +math.random=old;V.set(original) +T.finish('native phone caller byte selection, saved order, timer gates and actual receive programs') diff --git a/tests/engine/gen2_phone_content.lua b/tests/engine/gen2_phone_content.lua new file mode 100644 index 000000000..044b6896d --- /dev/null +++ b/tests/engine/gen2_phone_content.lua @@ -0,0 +1,174 @@ +-- Copyright (c) 2026 Roxas2712. See the offered PR contribution permission in docs/native-gen2-phone-content.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local C=require('src.script.Gen2Commands');local Gear=require('src.ui.PokegearMenu') +local function fixture() + local d=T.fixtures.fresh() + d.constants.phoneContactOrder={'PHONE_00','PHONE_MOM','PHONE_JOEY','PHONE_UNUSED'} + d.constants.trainerClassOrder={'YOUNGSTER'} + d.constants.trainerClassMembers={YOUNGSTER={'JOEY1'}} + d.maps.PALLET_TOWN={group=1,number=1};d.maps.ROUTE_31={group=1,number=2} + d.trainers={OPP_LOCALIZED={index=1,name='LOCAL CLASS',partyNames={'LOCAL JOEY'}}} + d.map_scripts={phoneInfo={size=4},phone={ + [1]={name='NATIVE MOM',contactNumber=1,group=1,number=1,call='OUT',callTime=7,receive='IN',receiveTime=0}, + [2]={class=1,trainer=1,group=1,number=2,call='OUT',callTime=7,receive='IN',receiveTime=7}, + },scripts={OUT={{'setval',41},{'end'}},IN={{'setval',42},{'end'}}}} + return d +end +local function load(d,source,api,format) + for root in pairs(require('src.mods.Builtins').namespaceRoots()) do d[root]=nil end + local files={} + if source then + files['mods/phone/manifest.json']=Json.encode({id='phone',name='Phone content',version='1.0.0', + api=api or 2,content_format=format or 'native',entry='main.lua',affects_link=false}) + files['mods/phone/main.lua']='local mod=...;mod.exports.api=mod;'..source + end + return T.sdk.loadMods(source and {'mods/phone'} or {},{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r) Loader.teardown();r.release() end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local d=fixture();local native=d.map_scripts.phone[2] + local r=load(d,[[ + assert(mod.content.phone_contacts:supports()) + local p=mod.content["phone_contacts"] + assert(p:get('PHONE_MOM').number==1) + assert(p:get('PHONE_MOM').map=='PALLET_TOWN') + assert(p:get('PHONE_MOM').callee=='OUT' and p:get('PHONE_MOM').caller=='IN') + assert(p:get('PHONE_JOEY').class=='YOUNGSTER') + assert(p:get('PHONE_JOEY').member=='JOEY1') + local row=p:get('PHONE_MOM');row.name='LEAK' + assert(p:get('PHONE_MOM').name=='NATIVE MOM') + for id,value in p:each() do value.name='LEAK' end + assert(p:get('PHONE_MOM').name=='NATIVE MOM') + p:patch('PHONE_MOM',{name='AUTHORED MOM',map='ROUTE_31',callerTime=7}) + p:register('PHONE_EXTRA',{name='EXTRA',map='PALLET_TOWN',calleeTime=7,callerTime=7,calleeKey='OUT',callerKey='IN'}) + mod.exports.index=p:get('PHONE_EXTRA').index + p:remove('PHONE_JOEY') + ]],mode[1],mode[2]) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' real loader accepts semantic contacts') + local e=r.loader.exports.phone;local index=e and e.index + T.eq(index,4,label..' added contact skips all native holes') + T.eq(C.phoneName(d,1),'AUTHORED MOM',label..' native name consumer uses authored row') + T.eq(d.map_scripts.phone[1].contactNumber,1,label..' contact number stays separate') + T.eq(d.map_scripts.phone[1].number,2,label..' actual map number changes') + T.eq(d.map_scripts.phone[1].receiveTime,7,label..' native incoming time changes') + T.eq(C.phoneName(d,2),nil,label..' native removed contact stays absent') + T.eq(native.trainer,1,label..' source row remains unchanged') + if index then + local game={data=d,save={g2Phone={[index]=true}},overworld={}} + local gear=setmetatable({game=game},{__index=Gear}) + T.eq(#gear:contacts(),10,label..' actual native phone book preserves ten physical slots') + T.eq(gear:contacts()[1].id,index,label..' registered addition occupies the first native slot') + T.eq(gear:contacts()[2].id,0,label..' unused native slot cannot invent a callable contact') + for _,direction in ipairs({'call','receive'}) do + local rows=direction=='call' and C.phoneCallScript(d,index) or C.phoneReceiveScript(d,index) + T.check(rows~=nil,label..' '..direction..' compiles through native VM') + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(rows or {}) + T.eq(runner.ctx.g2Var,direction=='call' and 41 or 42,label..' '..direction..' executes correct native direction') + end + T.eq(C.availableCallers(d,game.save,d.maps.ROUTE_31,'DAY')[1],index,label..' native incoming selector sees added contact') + T.eq(#C.availableCallers(d,game.save,d.maps.PALLET_TOWN,'DAY'),0,label..' same-map incoming suppression remains native') + end + finish(r) + local clean=load(d) + T.eq(#clean.errors,0,label..' disabling/reloading succeeds') + T.eq(C.phoneName(d,1),'NATIVE MOM',label..' disabling restores base name') + T.eq(C.phoneName(d,2),'LOCAL JOEY',label..' disabling restores native trainer') + T.eq(d.map_scripts.phone[4],nil,label..' disabling removes added numeric row') + finish(clean) + end +end +V.set('crystal') +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + local row=p:get('PHONE_MOM');row.calleeKey='IN';p:override('PHONE_MOM',row) + ]]) + T.eq(#r.errors,0,'read/edit/write accepts an explicit script key alongside the prior alias') + T.eq(d.map_scripts.phone[1].call,'IN','explicit calleeKey owns the effective outgoing script') + finish(r) +end +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + p:patch('PHONE_MOM',{callee='IN',caller='OUT',callerTime=7}) + ]]) + T.eq(#r.errors,0,'caller and callee aliases are accepted') + T.eq(d.map_scripts.phone[1].call,'IN','callee alias replaces previous outgoing key') + T.eq(d.map_scripts.phone[1].receive,'OUT','caller alias replaces previous incoming key') + local p=r.loader.exports.phone.api.content.phone_contacts + T.eq(p:get('PHONE_MOM').callee,'IN','public callee alias reflects the effective native key') + T.eq(p:get('PHONE_MOM').caller,'OUT','public caller alias reflects the effective native key') + finish(r) +end +do + local d=fixture();local r=load(d,[[local p=mod.content.phone_contacts + p:register('FIRST',{name='FIRST',index=255}) + p:register('SECOND',{name='SECOND'}) + assert(p:get('SECOND').index==4) + p:remove('SECOND') + p:register('THIRD',{name='THIRD'}) + assert(p:get('THIRD').index==5) + p:patch('PHONE_JOEY',{name='TRANSLATED'}) + ]]) + T.eq(#r.errors,0,'explicit high index and removed additions retain reservations') + T.eq(C.phoneName(d,255),'FIRST','native byte upper bound can be used explicitly') + T.eq(C.phoneName(d,4),nil,'added then removed contact leaves no callable row') + T.eq(C.phoneName(d,5),'THIRD','next addition cannot steal removed contact index') + T.eq(d.map_scripts.phone[2].class,1,'display override retains canonical trainer identity') + T.eq(d.map_scripts.phone[2].trainer,1,'display override retains trainer member') + local api=r.loader.exports.phone.api.content.phone_contacts + T.check(not pcall(function() api:patch('PHONE_MOM',{name='LATE'}) end),'frozen content rejects late mutation') + T.eq(C.phoneName(d,1),'NATIVE MOM','late mutation cannot change native data') + d.map_scripts.phone[2].name='EXTERNAL AFTER LOAD' + finish(r) + local clean=load(d) + T.eq(C.phoneName(d,2),'EXTERNAL AFTER LOAD','reload preserves an externally edited native contact') + T.eq(d.map_scripts.phone[255],nil,'reload retires unchanged added high-index contact') + finish(clean) +end +do + local d=fixture();d.map_scripts=nil + local r=load(d,"mod.content.phone_contacts:register('NEW',{name='NEW'})") + T.eq(#r.errors,0,'a named silent contact needs no native script pool') + T.eq(C.phoneName(d,4),'NEW','new native book respects explicit metadata floor') + finish(r) + local clean=load(d) + T.eq(d.map_scripts,nil,'reload removes an exclusively owned empty pool namespace') + finish(clean) +end +for _,source in ipairs({ + "p:register('BAD',{name='BAD',index=3})", + "p:register('BAD',{name='BAD',index=2})", + "p:patch('PHONE_MOM',{index=7})", + "p:register('BAD',{name='BAD',callerTime=7})", + "p:register('BAD',{name='BAD',callerTime=7,callerKey='MISSING'})", + "p:register('BAD',{class='YOUNGSTER',member='MISSING'})", + "p:register('BAD',{name='BAD',map='MISSING'})", + "p:register('BAD',{name='BAD',callerTime=0/0})", + "local x={};x.x=x;p:register('BAD',x)", + "p:register('BAD',{name='BAD',index=256})", + "p:register('BAD',{name='BAD',index=0})", + "p:register('BAD',{name='BAD',unknownField=true})", + "p:register('BAD',{class='YOUNGSTER',member='JOEY1',number=1})", + "p:patch('PHONE_MOM',{name='FAILED'});error('entry failure')", +}) do + local d=fixture();local r=load(d,'local p=mod.content.phone_contacts;'..source) + T.check(#r.errors>0,'invalid entry is rejected and attributed: '..source) + T.eq(C.phoneName(d,1),'NATIVE MOM','failed entry rolls back earlier phone operations') + T.eq(d.map_scripts.phone[4],nil,'failed entry cannot leak a native numeric contact') + finish(r) +end +for _,version in ipairs({'red','firered','emerald','heartgold'}) do + V.set(version);local d=fixture();local originalRow=d.map_scripts.phone[1] + local r=load(d,[[local p=mod.content.phone_contacts;assert(not p:supports());assert(p:get('PHONE_MOM')==nil);p:register('OTHER',{name='NO WRITE'})]]) + T.eq(#r.errors,0,version..' unsupported target reports through capability handle') + T.eq(d.map_scripts.phone[1],originalRow,version..' other generations retain native-looking data unchanged') + T.eq(d.map_scripts.phone[4],nil,version..' cannot acquire a Gen2 phone book') + finish(r) +end +V.set(original) +T.finish('semantic native phone contacts, actual VM/phone book, rollback and isolation') diff --git a/tests/engine/gen2_phone_identities.lua b/tests/engine/gen2_phone_identities.lua new file mode 100644 index 000000000..4b1350476 --- /dev/null +++ b/tests/engine/gen2_phone_identities.lua @@ -0,0 +1,111 @@ +-- Copyright (c) 2026 Roxas2712. See offered PR contribution permission. +-- Synthetic native ROM rows distinguish contact numbers from map numbers. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Ids=require('src.import.Gen2PhoneIdentities') +local Extractor=require('src.import.RomExtractorGen2') +local V=require('src.core.GameVersion');local initial=V.get() +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local ids=Ids.forVersion(version) + T.eq(#ids.phoneContactOrder,version=='crystal' and 38 or 37,version..' retail row bound includes holes') + T.eq(ids.phoneContactOrder[9],'PHONE_UNUSED',version..' reserved contact hole is explicit') + T.eq(ids.phoneContactOrder[2],'PHONE_MOM',version..' contact identity is numeric-order metadata') + T.eq(ids.phoneContactOrder[3],'PHONE_OAK',version..' retain reference spelling without renaming native slot2') + ids.phoneContactOrder[2]='LEAK';ids.trainerClassMembers.FALKNER[1]='LEAK' + T.eq(Ids.forVersion(version).phoneContactOrder[2],'PHONE_MOM',version..' contact metadata detached') + T.neq(Ids.forVersion(version).trainerClassMembers.FALKNER[1],'LEAK',version..' nested trainer metadata detached') + local bytes={};for i=1,0x8000 do bytes[i]=0 end + local function put(at,values)for i,v in ipairs(values)do bytes[at+i]=v end end + -- Four rows: unused0, non-trainer1, trainer2 and a reserved unused3. + put(0x4000+12,{0,2,9,42,7,1,0x00,0x50,2,1,0x10,0x50}) + put(0x4000+24,{12,3,5,6,1,1,0x20,0x50,4,1,0x30,0x50}) + -- Pointer slot2 names the actual imported display string; it is deliberately + -- unrelated to the legacy PHONE_OAK compatibility identifier at index2. + put(0x4204,{0x00,0x43});put(0x4300,{0x81,0x88,0x8A,0x84,0x7F,0x92,0x87,0x8E,0x8F,0x50}) + -- Crystal's late non-trainer record at native index37 names slot5. Earlier + -- code read only names1..4 and silently discarded this entire native row. + put(0x4000+37*12,{0,5,3,77,7,1,0x40,0x50,7,1,0x50,0x50}) + put(0x420a,{0x10,0x43});put(0x4310,{0x81,0x94,0x84,0x8d,0x80,0x50}) + local chunks={};for i=1,#bytes,256 do chunks[#chunks+1]=string.char(unpack(bytes,i,math.min(i+255,#bytes)))end + local e=Extractor.new(table.concat(chunks),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x4030},NonTrainerCallerNames={1,0x4200}}}) + function e:readSourceTable()return {}end + local queued={} + function e:gen2QueueScript(bank,address) + local id=string.format('S%02X_%04X',bank,address);queued[#queued+1]=id;return id + end + local rows,info=e:gen2PhoneContacts({}) + T.eq(info.size,4,version..' imported full native bound retains unused slots') + T.eq(info.rowsRead,4,version..' actual imported row count is explicit') + T.eq(rows[0],nil,version..' unused0 is not invented as a callable contact') + T.eq(rows[3],nil,version..' reserved hole is retained by bound, not a fake contact') + T.eq(rows[1].contactNumber,2,version..' actual non-trainer identity retained') + T.eq(rows[1].number,42,version..' actual map number remains separate') + T.eq(rows[1].group,9,version..' actual map group remains native') + T.eq(rows[1].name,'BIKE SHOP',version..' native display text remains authoritative') + T.eq(info.nonTrainerNames[2],'BIKE SHOP',version..' imported non-trainer lookup retained') + T.eq(rows[1].call,'S01_5000',version..' callee pointer remains outgoing') + T.eq(rows[1].callTime,7,version..' callee time remains outgoing') + T.eq(rows[1].receive,'S01_5010',version..' caller pointer remains incoming') + T.eq(rows[1].receiveTime,2,version..' caller time remains incoming') + T.eq(rows[2].class,12,version..' native trainer class is numeric and unchanged') + T.eq(rows[2].trainer,3,version..' native roster member is unchanged') + T.eq(rows[2].contactNumber,nil,version..' trainer member is not a non-trainer contact number') + T.eq(rows[2].number,6,version..' trainer map number is not replaced by member') + T.eq(#queued,4,version..' both native phone directions are queued') + local constants=e:constants() + T.eq(constants.phoneContactOrder[2],'PHONE_MOM',version..' actual constants producer installs phone identities') + T.eq(constants.generation,2,version..' new metadata preserves native generation') + local empty=Extractor.new(string.rep('\0',0x8000),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x4030}}}) + local none,bounds=empty:gen2PhoneContacts({}) + T.eq(none,nil,version..' empty native book has no fake callable records') + T.eq(bounds.size,4,version..' empty native book still reserves its complete row range') + local buena=Extractor.new(table.concat(chunks),version,{symbols={PhoneContacts={1,0x4000},SpecialPhoneCallList={1,0x41c8},NonTrainerCallerNames={1,0x4200}}}) + local book,meta=buena:gen2PhoneContacts({scripts={},queue={}}) + T.eq(book[37] and book[37].name,'BUENA',version..' referenced name5 retains the complete native contact') + T.eq(book[37] and book[37].contactNumber,5,version..' late non-trainer keeps its own identity') + T.eq(book[37] and book[37].number,77,version..' late non-trainer map number remains independent') + T.eq(meta.size,38,version..' full native row range remains reserved') + local data=T.fixtures.fresh();data.map_scripts={phone=book,scripts={S01_5040={{'setval',41},{'end'}},S01_5050={{'setval',42},{'end'}}}} + local C=require('src.script.Gen2Commands') + T.eq(C.phoneName(data,37),'BUENA',version..' real native phone-name consumer can resolve the restored contact') + local game={data=data,save={},overworld={}} + for _,direction in ipairs({'call','receive'})do + local rows=direction=='call' and C.phoneCallScript(data,37) or C.phoneReceiveScript(data,37) + T.check(rows~=nil,version..' restored '..direction..' script is actually compiled') + local runner=require('src.script.ScriptRunner').new(game,game.overworld);runner:run(rows or {}) + T.eq(runner.ctx.g2Var,direction=='call' and 41 or 42,version..' real native '..direction..' executes correct script') + T.eq(runner:isRunning(),false,version..' native '..direction..' finishes') + end +end +for _,version in ipairs({'red','firered','emerald','prism','polishedcrystal'})do + T.eq(Ids.forVersion(version),nil,version..' does not receive guessed retail identities') +end +local authored={phoneContactOrder={'CUSTOM'},trainerClassOrder={'CUSTOM_CLASS'},trainerClassMembers={CUSTOM_CLASS={'ONE'}}} +local manifest={constants=authored};local mounted={} +Ids.install(mounted,manifest,'prism') +T.eq(mounted.phoneContactOrder[1],'CUSTOM','manifest explicitly describes an alternative cartridge') +authored.trainerClassMembers.CUSTOM_CLASS[1]='LEAK' +T.eq(mounted.trainerClassMembers.CUSTOM_CLASS[1],'ONE','manifest roster metadata is detached') +local original={phoneContactOrder={'NATIVE'}};Ids.install(original,manifest,'gold') +T.eq(original.phoneContactOrder[1],'NATIVE','existing authored constants remain authoritative') +local custom={trainerClassOrder={'CUSTOM'}};Ids.install(custom,nil,'gold') +T.eq(custom.trainerClassMembers,nil,'partial authored roster cannot inherit unrelated retail members') +T.eq(custom.phoneContactOrder,nil,'partial authored identity set cannot guess retail contact names') +local Importer=require('src.import.RomImporter');local marker +for i=1,100 do local name,value=debug.getupvalue(Importer.readyReport,i);if not name then break end;if name=='markerFor'then marker=value;break end end +assert(marker) +for _,version in ipairs({'gold','silver','crystal'})do + local current=marker(version) + T.check(current:find('gen2-phone-identities-v1:',1,true)~=nil,version..' existing native import must rebuild once') + local old=current:gsub('gen2%-phone%-identities%-v1:','') + local V=require('src.core.GameVersion');local previous=V.get();V.set(version) + love.filesystem.write(V.cachePrefix(version)..'rom-cache.complete',old) + T.eq(Importer.readyReport(version).ok,false,version..' real readiness gate rejects missing phone metadata') + V.set(previous) +end +for _,version in ipairs({'red','firered','emerald','prism','polishedcrystal'})do + T.check(not marker(version):find('gen2-phone-identities-v1:',1,true),version..' unrelated cache marker is unchanged') +end +V.set(initial) +T.finish('native phone/trainer identities and reserved-row import metadata') diff --git a/tests/engine/gen2_phone_list_hook.lua b/tests/engine/gen2_phone_list_hook.lua new file mode 100644 index 000000000..b1f0c959b --- /dev/null +++ b/tests/engine/gen2_phone_list_hook.lua @@ -0,0 +1,117 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-list-hook.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local Gear=require('src.ui.PokegearMenu');local Book=require('src.world.Gen2PhoneBook') +local Calls=require('src.script.Gen2Commands') +local function fixture() + local d=T.fixtures.fresh();d.constants.phoneContactOrder={'PHONE_00','PHONE_MOM','U2','U3','PHONE_ELM','U5','U6','PHONE_A','U8','PHONE_B'} + d.map_scripts={phoneInfo={size=10},scripts={STUB={{'end'}}},phone={ + [1]={name='MOM',contactNumber=1},[4]={name='ELM',contactNumber=4}, + [7]={name='A',receive='STUB',receiveTime=7,group=1,number=1}, + [9]={name='B',receive='STUB',receiveTime=7,group=1,number=1}}} + return d +end +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots()) do d[root]=nil end + local files={} + if source then + files['mods/phone_hook/manifest.json']=Json.encode({id='phone_hook',name='Phone list',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}) + files['mods/phone_hook/main.lua']='local mod=...;mod.exports.api=mod;'..source + end + return T.sdk.loadMods(source and {'mods/phone_hook'} or {},{data=d,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +for _,version in ipairs({'gold','silver','crystal'}) do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}}) do + local d=fixture();local r=load(d,[[ + local added=mod.content.phone_contacts:register('PHONE_NEW',{name='AUTHORED NEW'}) + mod.exports.index=added.index + mod.exports.stop=mod.hooks:wrap("phone.contact_list",function(next,save,list) + mod.exports.calls=(mod.exports.calls or 0)+1;mod.exports.save=save;mod.exports.received=list + local rows=next(save,list);rows[1]=9;rows[2]=0;rows[3]='7';rows[10]=added.index + return rows + end) + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' real public hook registration succeeds') + local save={g2Phone={[7]=true,[9]=true},g2PhoneSlots={7,9,0,0,0,0,0,0,0,0}} + local gear=setmetatable({game={data=d,save=save}},{__index=Gear}) + local rows=gear:contacts();local e=r.loader.exports.phone_hook + T.eq(e.calls,1,label..' native card dispatches the hook exactly once') + T.eq(e.save,save,label..' opaque native save handle retains reference signature') + T.eq(#rows,10,label..' presentation retains ten slots') + T.eq(rows[1].id,9,label..' display hook can reorder native contacts') + T.eq(rows[1].slot,2,label..' reordered row retains its actual physical slot') + T.eq(rows[2].id,0,label..' display hook can hide a row') + T.eq(rows[3].id,7,label..' numeric contact spellings resolve through native records') + T.eq(rows[3].slot,1,label..' reordered selected ID does not acquire a different native slot') + T.eq(rows[10].name,'AUTHORED NEW',label..' hook sees actual native content registration') + T.eq(rows[10].slot,nil,label..' an injected display row cannot borrow another physical slot') + T.eq(save.g2Phone[e.index],nil,label..' showing a contact does not register it') + T.eq(Book.contacts(save)[1],7,label..' saved registration order remains native') + local incoming=Calls.availableCallers(d,save,{group=2,number=1},'DAY') + T.eq(incoming[1],7,label..' incoming caller selection ignores display reorder') + T.eq(incoming[2],9,label..' hidden display contact remains eligible for native incoming calls') + T.eq(e.calls,1,label..' incoming call selection does not dispatch the display hook') + e.received[1]=255;rows[1].name='READ MUTATION' + T.eq(Book.contacts(save)[1],7,label..' received display arrays cannot mutate the native book') + T.eq(d.map_scripts.phone[9].name,'B',label..' returned UI rows cannot mutate native contacts') + local key;gear.cards={{id='PHONE'}};gear.index=1;gear.blink=0;gear.contact=1 + gear.game.input={wasPressed=function(_,value)return key==value end};gear.game.stack={pop=function()end} + local Sound=require('src.core.Sound');local old=Sound.play;Sound.play=function()end + key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[9],nil,label..' actual DELETE targets the reordered contact ID') + T.eq(save.g2Phone[7],true,label..' display position cannot delete the unrelated physical contact') + gear.contact=1;key='a';gear:update(0);key='down';gear:update(0);key='a';gear:update(0) + T.eq(save.g2Phone[7],true,label..' deleting an injected non-member never consumes another native slot') + Sound.play=old + e.stop();rows=gear:contacts() + T.eq(rows[1].id,7,label..' unsubscribe restores the actual native presentation') + T.eq(rows[2].id,0,label..' unsubscribe does not recreate the deleted contact') + finish(r) + end +end +V.set('crystal') +for _,expression in ipairs({'nil','false','"bad"','{9}','{9,7,0,0,0,0,0,0,0,0,0}'}) do + local d=fixture();local r=load(d,'mod.hooks:wrap("phone.contact_list",function()return '..expression..' end)') + T.eq(#r.errors,0,'invalid display return does not prevent entry loading') + local save={g2Phone={[7]=true}};local rows=setmetatable({game={data=d,save=save}},{__index=Gear}):contacts() + T.eq(rows[1].id,7,'wrong return type/length keeps the real native display: '..expression) + T.eq(#rows,10,'wrong return type/length keeps native slot count') + finish(r) +end +do + local d=fixture();local r=load(d,[[mod.hooks:wrap("phone.contact_list",function() + return {999,-1,1.5,0/0,math.huge,{},true,'7','bad',9} + end)]]) + local save={g2Phone={[7]=true,[9]=true}} + local rows=setmetatable({game={data=d,save=save}},{__index=Gear}):contacts() + for i=1,7 do T.eq(rows[i].id,0,'invalid callback contact becomes a blank at slot'..i) end + T.eq(rows[8].id,7,'valid numeric string still resolves') + T.eq(rows[9].id,0,'invalid text becomes a blank') + T.eq(rows[10].id,9,'valid last native contact is retained') + T.eq(save.g2Phone[7],true,'sanitization preserves native registration') + finish(r) +end +do + local d=fixture();local r=load(d,[[mod.hooks:wrap("phone.contact_list",function()return {9,0,0,0,0,0,0,0,0,0}end);error('entry failure')]]) + T.check(#r.errors>0,'failed entry is reported') + local rows=setmetatable({game={data=d,save={g2Phone={[7]=true}}}},{__index=Gear}):contacts() + T.eq(rows[1].id,7,'failed-entry rollback removes its phone hook') + finish(r) +end +for _,version in ipairs({'red','firered','emerald','heartgold'}) do + V.set(version);local d=fixture();d.field.phone={contacts={{id=99,name='LEGACY'}}} + local r=load(d,[[mod.exports.calls=0 + mod.hooks:wrap("phone.contact_list",function(next,...)mod.exports.calls=mod.exports.calls+1;return next(...)end)]]) + T.eq(#r.errors,0,version..' hook registration remains available without a native call site') + local rows=setmetatable({game={data=d,save={}}},{__index=Gear}):contacts() + T.eq(rows[1].name,'LEGACY',version..' native-looking phone records do not replace authored presentation') + T.eq(r.loader.exports.phone_hook.calls,0,version..' cannot dispatch a Gen2-only display hook') + finish(r) +end +V.set(original) +T.finish('native phone.contact_list hook, actual card/delete, saved-book isolation and rollback') diff --git a/tests/engine/gen2_phone_received_event.lua b/tests/engine/gen2_phone_received_event.lua new file mode 100644 index 000000000..72bddc0dd --- /dev/null +++ b/tests/engine/gen2_phone_received_event.lua @@ -0,0 +1,150 @@ +-- Copyright (c) 2026 Roxas2712. See the PR contribution permission in docs/native-gen2-phone-received-event.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local original=V.get() +local Loader=require('src.mods.Loader');local Json=require('src.link.Json') +local Calls=require('src.script.Gen2Commands');local Runner=require('src.script.ScriptRunner') +local World=require('src.world.OverworldController') +local gameSlot,oldGame +for i=1,100 do + local name,value=debug.getupvalue(World.checkIncomingPhoneCall,i) + if name=='Game' then gameSlot=i;oldGame=value;break end + if not name then break end +end +assert(gameSlot,'actual native controller Game upvalue') +local function load(d,source,mode) + for root in pairs(require('src.mods.Builtins').namespaceRoots())do d[root]=nil end + local fs={['mods/phone_event/manifest.json']=Json.encode({id='phone_event',name='Phone event',version='1.0.0', + api=mode and mode[1] or 2,content_format=mode and mode[2] or 'native',entry='main.lua',affects_link=false}), + ['mods/phone_event/main.lua']='local mod=...;mod.exports.api=mod;'..source} + return T.sdk.loadMods({'mods/phone_event'},{data=d,fs=T.sdk.memfs(fs)}) +end +local function fixture() + local d=T.fixtures.fresh();d.constants.trainerClassOrder={'YOUNGSTER'} + d.constants.trainerClassMembers={YOUNGSTER={'JOEY1'}} + d.trainers={OPP_LOCAL={index=1,name='LOCAL CLASS',partyNames={'LOCAL JOEY'}}} + d.map_scripts={phone={[7]={class=1,trainer=1,call='OUT',callTime=7,receive='IN',receiveTime=7,group=2,number=1}, + [4]={name='LOCAL ELM',contactNumber=4,receive='ELM',receiveTime=0}}, + specialCalls={[1]={caller=4,script='SPECIAL',outside=true}}, + scripts={OUT={{'setval',74},{'end'}},IN={{'setval',71},{'end'}},ELM={{'setval',72},{'end'}}, + SPECIAL={{'setval',73},{'specialphonecall',0},{'end'}}}} + return d +end +local function gameFor(d) + local g={data=d,save={playTime=1800,g2LastCallMinute=0,g2Phone={[7]=true}}} + local w=setmetatable({map={id='HERE',def={group=1,number=1,phoneService=true,environment=1}, + warpAtCell=function()return nil end},player={cellX=0,cellY=0},scriptMoves={}, + timeOfDay=function()return 'DAY'end},{__index=World}) + w.runner=Runner.new(g,w);g.overworld=w + debug.setupvalue(World.checkIncomingPhoneCall,gameSlot,g) + return g,w +end +-- Upstream controller methods return no admission value. Observe their +-- actual queue growth without changing that native return protocol. +local function incoming(w) + local count=#(w.pendingScripts or {}) + w:checkIncomingPhoneCall() + return #(w.pendingScripts or {})>count +end +local function special(w) + local count=#(w.pendingScripts or {}) + w:checkSpecialPhoneCall() + return #(w.pendingScripts or {})>count +end +local Sound=require('src.core.Sound');local oldSound=Sound.play;Sound.play=function()end +local oldRandom=math.random;math.random=function()return 0 end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture() + T.eq(Calls.phoneName(d,7),'LOCAL JOEY','unmodified contact still reads the actual native roster') + local r=load(d,[[ + mod.exports.seen={} + mod.content.phone_contacts:patch('phone:7',{name='AUTHORED JOEY'}) + mod.exports.stop=mod.events:on("phone.call_received",function(e) + mod.exports.seen[#mod.exports.seen+1]={contact=e.contact,name=e.name,className=e.className, + special=e.special,scriptKey=e.scriptKey,call={kind=e.call.kind,contact=e.call.contact, + direction=e.call.direction,class=e.call.class,member=e.call.member,scriptKey=e.call.scriptKey}} + e.contact=255;e.call.contact=255;e.call.scriptKey='MUTATED';return false + end) + ]],mode) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' public event registration succeeds') + local e=r.loader.exports.phone_event;local g,w=gameFor(d) + T.eq(incoming(w),true,label..' actual native controller admits a random call') + T.eq(#e.seen,1,label..' admitted random call emits exactly once') + local seen=e.seen[1] + T.eq(seen.contact,7,label..' notification exposes the selected native contact ID') + T.eq(seen.name,'AUTHORED JOEY',label..' caller name consumes the actual native content override') + T.eq(seen.className,'LOCAL CLASS',label..' caller class uses native display text') + T.eq(seen.special,nil,label..' random call has no fabricated special ID') + T.eq(seen.scriptKey,'IN',label..' notification names the actual compiled native key') + T.eq(seen.call.contact,7,label..' descriptor and notification agree') + T.eq(seen.call.direction,'incoming',label..' descriptor retains incoming direction') + T.eq(seen.call.class,'YOUNGSTER',label..' descriptor class uses explicit canonical metadata') + T.eq(seen.call.member,'JOEY1',label..' descriptor member uses explicit canonical metadata') + T.eq(g.save.g2CurCaller,7,label..' observer mutation cannot change the real caller') + T.eq(w.pendingScripts[1].extra.phoneCaller,7,label..' observer mutation cannot redirect the queued call') + w.runner:run(w.pendingScripts[1].script,w.pendingScripts[1].extra) + T.eq(w.runner.ctx.g2Var,71,label..' actual selected receive program still executes') + T.eq(g.save.g2CurCaller,nil,label..' actual completion clears caller ownership') + T.eq(d.map_scripts.phone[7].receive,'IN',label..' observer mutation cannot rewrite imported contact') + g,w=gameFor(d);g.save.g2SpecialCall=1 + T.eq(special(w),true,label..' actual controller admits the queued outside special') + T.eq(#e.seen,2,label..' admitted special emits exactly once') + seen=e.seen[2] + T.eq(seen.contact,4,label..' special exposes its actual caller') + T.eq(seen.name,'LOCAL ELM',label..' non-trainer name comes from actual contact data') + T.eq(seen.className,nil,label..' non-trainer has no fabricated trainer class') + T.eq(seen.special,1,label..' special ID survives disarming of its pending queue') + T.eq(seen.scriptKey,'SPECIAL',label..' wrapper script overrides contact receive key') + T.eq(w.pendingScripts[1].extra.phoneCaller,4,label..' observer mutation cannot redirect the special') + w.runner:run(w.pendingScripts[1].script,w.pendingScripts[1].extra) + T.eq(w.runner.ctx.g2Var,73,label..' actual special receive program executes') + T.eq(g.save.g2SpecialCallActive,nil,label..' native specialphonecall NONE clears active ownership') + T.eq(g.save.g2CurCaller,nil,label..' special completion clears real caller ownership') + T.eq(special(w),false,label..' completed special cannot notify twice') + T.eq(#e.seen,2,label..' completed queue has no phantom event') + g,w=gameFor(d);w.transitioning=true + T.eq(incoming(w),false,label..' transition rejects a random candidate') + g.save.g2SpecialCall=1 + T.eq(special(w),false,label..' transition rejects a special candidate') + g,w=gameFor(d);g.save.g2SpecialCall=1;w.map.def.environment=3 + T.eq(special(w),false,label..' indoor map keeps an outside-only call pending') + T.eq(g.save.g2SpecialCall,1,label..' rejected special remains queued') + T.eq(#e.seen,2,label..' rejected candidates never notify') + g,w=gameFor(d) + local gear=setmetatable({game=g},{__index=require('src.ui.PokegearMenu')}) + T.eq(gear:runCallScript(7),true,label..' actual outgoing native call executes') + T.eq(#e.seen,2,label..' outgoing call cannot raise an incoming notification') + e.stop();g,w=gameFor(d) + T.eq(incoming(w),true,label..' unsubscribe preserves native incoming behavior') + T.eq(#e.seen,2,label..' unsubscribe stops event delivery') + Loader.teardown();r.release() + end +end +for _,version in ipairs({'red','firered','emerald','heartgold'})do + V.set(version);local d=fixture();local r=load(d,[[ + mod.exports.count=0 + mod.events:on("phone.call_received",function()mod.exports.count=mod.exports.count+1 end) + ]]) + T.eq(#r.errors,0,version..' native listener registration remains available without a native target') + local g,w=gameFor(d);g.save.g2SpecialCall=1 + T.eq(incoming(w),false,version..' native random call gate remains isolated') + T.eq(special(w),false,version..' native special call gate remains isolated') + T.eq(r.loader.exports.phone_event.count,0,version..' no unrelated generation event') + Loader.teardown();r.release() +end +do + V.set('crystal');local d=fixture();local r=load(d,[[ + mod.events:on("phone.call_received",function()error('rolled-back listener ran')end) + error('entry failure') + ]]) + T.check(#r.errors>0,'failed entry is reported') + T.eq(require('src.mods.Runtime').wants("phone.call_received"),false,'failed entry rolls back its actual event listener') + local g,w=gameFor(d) + T.eq(incoming(w),true,'failed listener cannot block the actual native incoming call') + Loader.teardown();r.release() +end +debug.setupvalue(World.checkIncomingPhoneCall,gameSlot,oldGame) +Sound.play=oldSound;math.random=oldRandom;V.set(original) +T.finish('native phone.call_received from actual random/special controller queues and receive programs') diff --git a/tests/engine/gen2_pokegear_presentation.lua b/tests/engine/gen2_pokegear_presentation.lua new file mode 100644 index 000000000..305d80b69 --- /dev/null +++ b/tests/engine/gen2_pokegear_presentation.lua @@ -0,0 +1,56 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get();V.set('crystal') +local Gear=require('src.ui.PokegearMenu') +local Radio=require('src.world.Gen2Radio') +local data=T.fixtures.fresh();data.gen2RadioChannels=Radio.defaults() +data.gen2RadioChannels.OAKS_POKEMON_TALK.name='AUTHORED RADIO' +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local game={data=data,save={flags={ENGINE_RADIO_CARD=true}},stack=stack} +local function press(menu,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + menu:update(1/60) +end +-- A source renderer must observe the card reached through native input, +-- not an independent presentation cursor defaulting to CLOCK. +local menu=Gear.new(game);stack:push(menu) +T.eq(menu:card(),menu:current(),'source and native consumers share selected row') +T.eq(menu:cardLabel(),'CLOCK','source label resolves actual selected native card') +for _=1,#menu.cards do if menu:current().id~='RADIO' then press(menu,'right')end end +T.eq(menu:card().id,'RADIO','native card navigation reaches the source renderer') +local stations=menu:stations() +T.eq(stations[1].name,'AUTHORED RADIO','source tuner consumes mounted authored name') +T.eq(stations[1].station,'OAKS_POKEMON_TALK','source tuner receives stable station identity') +T.eq(stations[1].frequency,'04.5','frequency derives from existing native knob units') +T.eq(menu:currentStation().station,'OAKS_POKEMON_TALK','source selection reads native tuner cursor') +T.check(menu.radio and menu.radio.top=='PROF.OAK: Hello!','source panel sees the actual native program line') +local view=menu.radio;press(menu,'a') +T.eq(menu.radio,view,'advancing a program retains its live presentation object') +T.eq(menu.radio.top,'Wild #MON habits.','native line advancement reaches source panel') +press(menu,'up') +T.eq(menu:currentStation().station,'POKEDEX_SHOW','native retune changes source selection') +T.eq(menu.radio.station,'POKEDEX_SHOW','native retune changes the presented broadcast') +T.eq(menu.radio.top,'DJ MARY: Hi!','retune resets visible program text') +stations[1].name='LEAK';stations[1].lines[1]='LEAK' +T.eq(menu:stations()[1].name,'AUTHORED RADIO','source station rows are detached') +T.eq(menu:stations()[1].lines[1],'PROF.OAK: Hello!','source line snapshots cannot corrupt program text') +press(menu,'left');T.eq(menu.radio,nil,'leaving radio clears its active display') +press(menu,'right');T.check(menu.radio~=nil,'returning to radio restores a current program view') +press(menu,'b');T.eq(menu.radio,nil,'closing stops presenting a stale broadcast') +T.eq(stack:top(),nil,'native close retains its original stack ownership') +T.eq(menu:drawsWidescreen(),false,'unmodified native presentation stays on its original canvas') +local wide=menu.drawWidescreen +menu.drawWidescreen=function()end +T.eq(menu:drawsWidescreen(),true,'replacement window drawer opts into source presentation') +menu.drawWidescreen=wide +T.eq(menu:drawsWidescreen(),false,'removing replacement restores native composition') +data.gen2RadioChannels={};menu=Gear.new(game) +for _=1,#menu.cards do if menu:current().id~='RADIO' then press(menu,'right')end end +T.eq(#menu:stations(),0,'removed stations stay absent from source presentation') +T.eq(menu:currentStation(),nil,'empty tuner has no invented source selection') +T.eq(menu.radio,nil,'empty tuner has no fabricated broadcast') +V.set('red');menu=Gear.new(game) +T.eq(#menu:stations(),0,'other generation cannot expose native GSC stations') +V.set(original) +T.finish('native Pokegear input and source presentation agree') diff --git a/tests/engine/gen2_radio_audio.lua b/tests/engine/gen2_radio_audio.lua new file mode 100644 index 000000000..d0626b092 --- /dev/null +++ b/tests/engine/gen2_radio_audio.lua @@ -0,0 +1,127 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get() +-- Replace only the device. Actual Music, native input, StateStack, VM and +-- ScriptRunner own every transition below. +local oldAudio=love.audio +local starts=0 +love.audio={newSource=function(file) + local s={file=file,playing=false} + function s:play()self.playing=true;starts=starts+1 end + function s:stop()self.playing=false end + function s:pause()self.playing=false end + function s:isPlaying()return self.playing end + function s:setLooping()end + function s:setVolume()end + function s:setFilter()end + return s +end} +local Music=require('src.core.Music') +local Radio=require('src.world.Gen2Radio') +local Audio=require('src.world.Gen2RadioAudio') +local Gear=require('src.ui.PokegearMenu') +local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner') +local Stack=require('src.core.StateStack') +local function gameFor() + local data=T.fixtures.fresh();data.gen2RadioChannels=Radio.defaults() + data.audio={songs={},mapSongs={A='MAP_A',B='MAP_B'},battle={wild='BATTLE'}} + for _,id in ipairs({'MAP_A','MAP_B','BATTLE','ALT','Music_PokeFluteChannel', + 'Music_ProfOaksPokemonTalk','Music_PokemonCenter','Music_RocketTheme'})do + data.audio.songs[id]={file=id..'.ogg'} + end + local stack=setmetatable({},{__index=Stack});stack:init();stack:push({name='parent'}) + return {data=data,save={flags={ENGINE_RADIO_CARD=true,ENGINE_EXPN_CARD=true}}, + stack=stack,overworld={player={cellX=33,cellY=8},map={def={landmark=1}}}} +end +local function press(game,screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local function open(game) + local menu=Gear.new(game);game.stack:push(menu) + for _=1,#menu.cards do if menu:current().id~='RADIO' then press(game,menu,'right')end end + return menu +end +local function flute(game,menu) + for _=1,#menu:stationList()do + if not (menu:currentStation() and menu:currentStation().flute)then press(game,menu,'up')end + end +end +local function gate(game,want,label) + local runner=Runner.new(game,game.overworld) + runner:run(assert(VM.compile({map_scripts={scripts={probe={{'special',95,'SnorlaxAwake'},{'end'}}}}},'probe'))) + T.eq(runner.ctx.g2Var,want and 1 or 0,label..' native byte') + T.eq(runner.ctx.lastCheck,want,label..' native branch') + T.eq(runner:isRunning(),false,label..' VM terminates') +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);Music.reload();local game=gameFor() + Music.playMap(game.data,'A',false,false) + gate(game,false,version..' EXPN without music refuses') + game.save.g2RadioChannel='POKE_FLUTE';game.save.g2MapMusic='MUSIC_POKE_FLUTE_CHANNEL' + gate(game,false,version..' stale save hints refuse') + local menu=open(game) + T.eq(Music.current(),'Music_ProfOaksPokemonTalk',version..' real native tuner starts its station') + local count=starts;press(game,menu);press(game,menu,'a') + T.eq(starts,count,version..' idle/text advancement does not restart audio') + flute(game,menu) + T.eq(Music.current(),Audio.FLUTE,version..' tuner plays actual flute Source') + T.eq(Music.mapSong(),'MAP_A',version..' open tuner retains previous map owner') + for _,cell in ipairs({{33,8},{34,10},{35,10},{36,8},{36,9}})do + game.overworld.player.cellX,game.overworld.player.cellY=cell[1],cell[2] + gate(game,true,version..' each native adjacent cell') + end + game.overworld.player.cellX,game.overworld.player.cellY=0,0 + game.overworld.player.x,game.overworld.player.y=33,8 + gate(game,false,version..' render coordinates cannot bypass local proximity') + game.overworld.player.cellX,game.overworld.player.cellY=33,8 + press(game,menu,'b') + T.eq(Music.mapSong(),Audio.FLUTE,version..' normal close transfers radio ownership') + T.eq(Music.current(),Audio.FLUTE,version..' normal close keeps flute playing') + Music.playBattle(game.data,'wild');gate(game,false,version..' battle music cannot wake') + Music.restoreMap(game.data);gate(game,true,version..' battle restoration retains tuned flute') + Music.playMap(game.data,'B',false,false) + T.eq(Music.mapSong(),'MAP_B',version..' next map replaces radio owner') + gate(game,false,version..' next map cannot retain stale flute admission') + menu=open(game);flute(game,menu);press(game,menu,'down') + T.eq(Music.current(),nil,version..' missing channel audio cannot leave flute sounding') + gate(game,false,version..' failed retune cannot keep flute admission') + press(game,menu,'b');T.eq(Music.current(),'MAP_B',version..' failed station restores map on exit') + game.save.flags.ENGINE_EXPN_CARD=nil;menu=open(game) + for _,station in ipairs(menu:stationList())do T.eq(station.flute,nil,version..' flute stays locked without EXPN')end + press(game,menu,'b') +end +V.set('crystal');Music.reload();local game=gameFor();Music.playMap(game.data,'A') +local menu=open(game);flute(game,menu);Music.play(game.data,'ALT');press(game,menu,'b') +T.eq(Music.current(),'ALT','external playback is not replaced when closing covered owner') +T.eq(Music.mapSong(),'MAP_A','external owner prevents stale flute transfer') +-- A mod's music selection hook remains authoritative and does not become a +-- forged flute song just because the dial points at that channel. +local Runtime=require('src.mods.Runtime');local oldHooks=Runtime.hooks +Runtime.hooks=require('src.mods.Hooks').new() +Runtime.hooks:wrap('music.select',function(nextSong,song,ctx) + local selected=nextSong(song,ctx);return selected==Audio.FLUTE and 'ALT' or selected +end,nil,'radio-audio-test') +menu=open(game);flute(game,menu) +T.eq(Music.current(),'ALT','station tuning respects actual hook-selected audio') +gate(game,false,'hook replacement is not flute playback') +press(game,menu,'b');T.eq(Music.mapSong(),'ALT','actual selected song transfers to map owner') +Runtime.hooks=oldHooks +Music.playMap(game.data,'A') +local runner=Runner.new(game,game.overworld) +runner:run(assert(VM.compile({map_scripts={scripts={wall={{'setval',8},{'special',40,'MapRadio'},{'setval',77},{'end'}}}}},'wall'))) +local wall=game.stack:top() +T.eq(Music.current(),'Music_RocketTheme','native imported wall-radio special plays its channel') +T.eq(runner:isRunning(),true,'wall music does not bypass native announcement wait') +for _=1,100 do press(game,wall)end +press(game,wall,'b') +T.eq(runner.ctx.g2Var,77,'wall close resumes native script after transferring music') +T.eq(Music.mapSong(),'Music_RocketTheme','wall close transfers actual channel music') +T.eq(Music.current(),'Music_RocketTheme','wall song continues after close') +local current=Music.current();V.set('red') +T.eq(Audio.tune(game,'POKE_FLUTE_RADIO'),nil,'other generations reject native station tuning') +T.eq(Music.current(),current,'generation rejection leaves current audio intact') +Music.reload();love.audio=oldAudio;V.set(original) +T.finish('native radio audio ownership and actual Snorlax VM admission') diff --git a/tests/engine/gen2_radio_content.lua b/tests/engine/gen2_radio_content.lua new file mode 100644 index 000000000..a5fa1054e --- /dev/null +++ b/tests/engine/gen2_radio_content.lua @@ -0,0 +1,170 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion') +local Loader=require('src.mods.Loader') +local Json=require('src.link.Json') +local Radio=require('src.world.Gen2Radio') +local Gear=require('src.ui.PokegearMenu') +local Runtime=require('src.mods.Runtime') +local original=V.get() +local function size(t)local n=0;for _ in pairs(t or {})do n=n+1 end;return n end +local function load(data,source,api,format,extra) + -- The real hot-reload path reconstructs engine-seeded namespaces before + -- loading mods. Retain the mounted radio target under test. + for root in pairs(require('src.mods.Builtins').namespaceRoots())do data[root]=nil end + local files={} + if source then + files['mods/radio/manifest.json']=Json.encode({id='radio',name='Radio contract',version='1.0.0', + api=api or 2,content_format=format or 'native',entry='main.lua',affects_link=false}) + files['mods/radio/main.lua']='local mod=...;mod.exports.api=mod; '..source + end + for k,v in pairs(extra or {})do files[k]=v end + return T.sdk.loadMods(source and {'mods/radio'} or {},{data=data,fs=T.sdk.memfs(files)}) +end +local function finish(r)Loader.teardown();r.release()end +local function fixture() + local d=T.fixtures.fresh();d.maps.PALLET_TOWN={landmark=47};return d +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version) + local d=fixture();local r=load(d) + T.eq(#r.errors,0,'no-mod load succeeds: '..version) + T.eq(d.gen2RadioChannels,nil,'no-mod virtual base creates no namespace') + T.eq(size(Radio.records(d)),11,'all native station identities are readable') + local gear=setmetatable({game={data=d,save={}}},{__index=Gear}) + T.eq(#gear:stationList(),6,'vanilla native tuner count retained') + T.eq(gear:stationList()[1].name,"OAK'S POKéMON TALK",'native program name retained') + T.eq(Radio.resolve(d,0,{landmark=1},'MORNING'),'POKEDEX_SHOW','morning Pokemon Channel') + T.eq(Radio.resolve(d,0,{landmark=1},'DAY'),'OAKS_POKEMON_TALK','day Pokemon Channel') + T.eq(Radio.resolve(d,0,{landmark=47},'MORNING'),'PLACES_AND_PEOPLE','imported Kanto boundary wins over time') + gear.game.save.flags={ENGINE_EXPN_CARD=true} + T.eq(#gear:stationList(),7,'EXPN keeps the native flute slot') + finish(r) +end +V.set('crystal') +for _,mode in ipairs({{1,'native'},{2,'native'}})do + local d=fixture() + local r=load(d,[[ + local radio=mod.content["radio_channels"] + local row=assert(radio:get('OAKS_POKEMON_TALK'));row.name='BORROWED' + assert(radio:get('OAKS_POKEMON_TALK').name~='BORROWED') + for id,value in radio:each()do value.name='ITERATOR MUTATION' end + assert(radio:get('OAKS_POKEMON_TALK').name~='ITERATOR MUTATION') + radio:patch('OAKS_POKEMON_TALK',{name='AUTHORED TALK'}) + radio:register('PIRATE_RADIO',{channel=9,name='PIRATE RADIO'}) + radio:remove('POKEDEX_SHOW') + radio:patch('LUCKY_CHANNEL',{channel=11}) + mod.events:on("radio.channel",function(e) + mod.exports.event={station=e.station,channel=e.channel,name=e.name,source=e.source} + e.name='CALLBACK MUTATION' + end) + ]],mode[1],mode[2]) + T.eq(#r.errors,0,'real loader accepts radio contract '..mode[1]..'/'..mode[2]) + local e=r.loader.exports.radio + T.eq(d.gen2RadioChannels.OAKS_POKEMON_TALK.name,'AUTHORED TALK','merge reaches actual native station records') + T.eq(Radio.resolve(d,9),'PIRATE_RADIO','wall radio consumes a new registered station') + T.eq(Radio.resolve(d,11),'LUCKY_CHANNEL','wall radio consumes a changed channel number') + T.eq(Radio.channelRecord(d,4),nil,'previous channel number is not retained') + T.eq(Radio.resolve(d,0,{landmark=1},'MORNING'),nil,'removed morning station cannot resurrect') + local gear=setmetatable({game={data=d,save={}}},{__index=Gear}) + T.eq(#gear:stationList(),5,'native tuner consumes removal') + T.eq(gear:stationList()[1].name,'AUTHORED TALK','native tuner consumes authored name') + T.eq(gear:stationList()[1].nameLiteral,true,'authored text bypasses engine string catalog') + T.eq(Radio.isLiteral(d,'LUCKY_CHANNEL'),false,'channel-only patch retains built-in translation ownership') + Radio.notify(d,'PIRATE_RADIO',9,'map_radio') + T.eq(e.event.station,'PIRATE_RADIO','real mod event receives station identity') + T.eq(e.event.name,'PIRATE RADIO','real mod event receives authored station name') + T.eq(e.event.channel,9,'real mod event receives script dial number') + T.eq(e.event.source,'map_radio','real mod event identifies wall producer') + T.eq(Radio.stationName(d,'PIRATE_RADIO'),'PIRATE RADIO','callback mutation cannot edit content') + local borrowed=e.api.content.radio_channels:get('PIRATE_RADIO');borrowed.name='OUTSIDE' + T.eq(Radio.stationName(d,'PIRATE_RADIO'),'PIRATE RADIO','post-load get is detached') + T.raises(function()e.api.content.radio_channels:patch('PIRATE_RADIO',{name='LATE'})end,'frozen','station mutations freeze after load') + finish(r) + r=load(d) + T.eq(d.gen2RadioChannels,nil,'reload without mods removes generated namespace') + T.eq(Radio.resolve(d,2),'POKEDEX_SHOW','reload restores removed native station') + T.eq(Radio.channelRecord(d,9),nil,'reload removes mod-only station') + finish(r) +end +-- Failed entries must roll back earlier valid edits and event subscriptions. +for _,bad in ipairs({ + "r:register('DUPLICATE',{channel=1,name='bad'})", + "r:patch('LUCKY_CHANNEL',{channel=256})", + "r:patch('LUCKY_CHANNEL',{channel=0/0})", + "r:patch('LUCKY_CHANNEL',{name=123})", + "local cycle={};cycle.self=cycle;r:register('CYCLE',cycle)", + "r:register('ROCKET_RADIO',{channel=10})", +})do + local d=fixture();local r=load(d,"local r=mod.content.radio_channels;r:patch('OAKS_POKEMON_TALK',{name='SHOULD ROLLBACK'});"..bad) + T.eq(#r.errors,1,'malformed/ambiguous station fails one mod') + T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),"OAK'S POKéMON TALK",'failed entry cannot leak prior valid edit') + T.eq(d.gen2RadioChannels,nil,'failed entry leaves no namespace') + finish(r) +end +-- External edits between hot reloads survive without retaining old mod rows. +local d=fixture();local r=load(d,[[ + mod.content.radio_channels:register('PIRATE_RADIO',{channel=9,name='MOD'}) + mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='MOD TALK'}) +]]) +d.gen2RadioChannels.OAKS_POKEMON_TALK.name='EXTERNAL' +d.gen2RadioChannels.LUCKY_CHANNEL=nil +finish(r);r=load(d) +T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),'EXTERNAL','external edit survives reload') +T.eq(Radio.channelRecord(d,9),nil,'external edit does not pin stale mod-only rows') +T.eq(d.gen2RadioChannels.LUCKY_CHANNEL,nil,'external removal survives reload') +T.eq(Radio.isLiteral(d,'OAKS_POKEMON_TALK'),true,'external authored name stays literal') +finish(r) +d=fixture();d.gen2RadioChannels={AUTHORED={channel=22,name='BASE'}} +local base=d.gen2RadioChannels.AUTHORED +r=load(d,[[ + local r=mod.content.radio_channels + local row={channel=23,name='REPLACEMENT'} + r:override('AUTHORED',row);row.name='LEAK' + r:patch('AUTHORED',{name=mod.DELETE}) + r:register('TEMP',{channel=24,name='TEMP'}) +]]) +T.eq(#r.errors,0,'override and optional field deletion are valid') +T.eq(d.gen2RadioChannels.AUTHORED.name,nil,'DELETE removes optional authored name') +T.eq(d.gen2RadioChannels.AUTHORED.channel,23,'override replaces original dial position') +T.eq(Radio.isLiteral(d,'AUTHORED'),false,'deleted authored name no longer claims literal ownership') +finish(r);r=load(d) +T.eq(d.gen2RadioChannels.AUTHORED,base,'unmodified authored base row identity is restored') +T.eq(d.gen2RadioChannels.AUTHORED.name,'BASE','reload restores original authored name') +T.eq(d.gen2RadioChannels.TEMP,nil,'reload removes temporary station from authored dataset') +finish(r) +-- A failed contribution must not roll back a preceding successful owner. +d=fixture() +local extras={ + ['mods/broken/manifest.json']=Json.encode({id='broken',name='Broken radio',version='1',api=2,entry='main.lua',dependencies={'radio'}}), + ['mods/broken/main.lua']=[[ + local mod=...;mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='BROKEN'}) + mod.events:on("radio.channel",function()error('leaked failed listener')end) + mod.content.radio_channels:register('BAD',{channel=1}) + ]], +} +r=load(d,"mod.content.radio_channels:patch('OAKS_POKEMON_TALK',{name='GOOD'})",2,'native',extras) +T.eq(#r.errors,1,'only dependent broken contributor fails') +T.eq(Radio.stationName(d,'OAKS_POKEMON_TALK'),'GOOD','successful contribution survives later rollback') +local errors=#r.errors;Radio.notify(d,'OAKS_POKEMON_TALK',1,'map_radio') +T.eq(#r.errors,errors,'failed contributor listener is removed with its operations') +finish(r) +d=fixture();d.gen2RadioChannels={};r=load(d) +T.eq(size(Radio.records(d)),0,'explicit empty authored namespace stays authoritative') +T.eq(#setmetatable({game={data=d,save={}}},{__index=Gear}):stationList(),0,'empty authored tuner cannot resurrect defaults') +finish(r) +for _,version in ipairs({'red','emerald','platinum','black'})do + V.set(version);d=T.fixtures.fresh() + r=load(d,[[ + assert(mod.content.radio_channels:get('OAKS_POKEMON_TALK')==nil) + assert(mod.content.radio_channels:each()()==nil) + assert(mod.content.radio_channels:register('NOT_NATIVE',{channel=9})==nil) + ]],2,'native') + T.eq(#r.errors,0,'other target has honest unsupported contract: '..version) + T.eq(d.gen2RadioChannels,nil,'other generation creates no radio target') + T.eq(next(r.loader.content.radio_channels.ops),nil,'other generation appends no operation') + finish(r) +end +V.set(original) +T.finish('native radio content, consumers, target isolation and rollback') diff --git a/tests/engine/gen2_radio_runtime.lua b/tests/engine/gen2_radio_runtime.lua new file mode 100644 index 000000000..c791f3dbe --- /dev/null +++ b/tests/engine/gen2_radio_runtime.lua @@ -0,0 +1,88 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local V=require('src.core.GameVersion');local original=V.get();V.set('crystal') +require('src.core.ModCompat').install() +local Radio=require('src.world.Gen2Radio') +local Wall=require('src.ui.gen2.MapRadio') +local Gear=require('src.ui.PokegearMenu') +local VM=require('src.script.Gen2ScriptVM') +local Runner=require('src.script.ScriptRunner') +local Runtime=require('src.mods.Runtime') +local oldEvents=Runtime.events;local events=require('src.mods.Events').new();Runtime.events=events +local emitted={};events:on("radio.channel",function(e)emitted[#emitted+1]=e end) +local data=T.fixtures.fresh();require('src.render.Font').load(data) +data.maps.PALLET_TOWN={landmark=47} +data.gen2RadioChannels=Radio.defaults();data.gen2RadioChannels.TEST_RADIO={channel=9,name='TEST RADIO'} +local game={data=data,save={},stack=setmetatable({},{__index=require('src.core.StateStack')}),overworld={map={def={landmark=1}}}} +game.stack:init() +function game.overworld:timeOfDay()return 'MORNING'end +local function press(screen,key) + game.input={wasPressed=function(_,k)return k==key end,isDown=function()return false end} + screen:update(1/60) +end +local parent={name='test parent'};game.stack:push(parent) +for _,channel in ipairs({0,1,9})do + local completed=0 + local ir={map_scripts={scripts={probe={{'setval',channel},{'special',0,'MapRadio'},{'setval',77},{'end'}}}}} + local rows=assert(VM.compile(ir,'probe')) + local runner=Runner.new(game,game.overworld) + local before=#emitted + runner:run(rows,{lastCheck=false,onDone=function()completed=completed+1 end}) + local wall=game.stack:top() + T.eq(getmetatable(wall),Wall,'actual special opens the native wall UI') + T.check(runner:isRunning(),'native VM waits for wall radio input') + T.eq(runner.ctx.g2Var,channel,'opening preserves the script dial byte') + T.eq(runner.ctx.lastCheck,channel~=0,'opening retains the result of the preceding setval') + T.eq(#emitted,before+1,'actual tune emits once') + T.eq(emitted[#emitted].channel,channel,'event preserves actual script channel') + T.eq(emitted[#emitted].source,'map_radio','event identifies native wall special') + T.eq(wall.station,channel==0 and 'POKEDEX_SHOW' or channel==9 and 'TEST_RADIO' or 'OAKS_POKEMON_TALK','native imported-map/time resolution') + T.eq(game.save.g2Roamers,nil,'opening wall radio does not initialize unrelated roaming state') + wall:draw() + for _=1,100 do press(wall,'b')end + T.eq(game.stack:top(),wall,'hundred-frame station announcement ignores exit input') + T.eq(completed,0,'announcement cannot resume script early') + wall:draw() + local cover={name='cover'};game.stack:push(cover) + T.eq(wall:close(),false,'covered radio cannot pop another screen') + T.eq(completed,0,'covered radio cannot resume its parent') + T.eq(game.stack:top(),cover,'covered close preserves top owner') + game.stack:pop();press(wall,channel==9 and 'a' or 'b') + T.eq(game.stack:top(),parent,'closing returns to the existing parent') + T.eq(completed,1,'native script resumes exactly once') + T.eq(runner.ctx.g2Var,77,'real VM executes the instruction following the special') + T.eq(runner:isRunning(),false,'real script terminates') + wall:close();T.eq(completed,1,'repeated close cannot replay script continuation') + T.eq(#emitted,before+1,'draw and close do not emit extra tuning events') +end +game.overworld.map.def.landmark=47 +local wall=Wall.new(game,{channel=0}) +T.eq(wall.station,'PLACES_AND_PEOPLE','real loaded map definition selects Kanto') +local alternate={flags={ENGINE_EXPN_CARD=true}} +wall=Wall.new(game,{channel=9,save=alternate}) +T.eq(wall.game.save,alternate,'caller-owned save is retained') +T.eq(game.save==alternate,false,'alternate save does not replace player save') +data.gen2RadioChannels={} +local completed=0;local runner=Runner.new(game,game.overworld) +runner:run(assert(VM.compile({map_scripts={scripts={empty={{'setval',1},{'special',0,'MapRadio'},{'end'}}}}},'empty')), + {lastCheck=false,onDone=function()completed=completed+1 end}) +T.eq(game.stack:top(),parent,'empty station registry cannot strand an empty UI') +T.eq(completed,1,'unavailable station allows script to continue') +T.eq(runner.ctx.lastCheck,true,'unavailable station preserves preceding setval result') +data.gen2RadioChannels=Radio.defaults() +game.save.flags={ENGINE_RADIO_CARD=true} +local gear=Gear.new(game) +for i,card in ipairs(gear.cards)do if card.id=='RADIO' then gear.index=i end end +T.eq(gear:current().id,'RADIO','actual owned card selects native radio') +local before=#emitted +press(gear);press(gear) +T.eq(#emitted,before+1,'Pokegear emits initial tune once') +T.eq(emitted[#emitted].source,'pokegear','Pokegear identifies the other real producer') +press(gear,'up') +T.eq(#emitted,before+2,'changing native tuner emits once') +T.eq(emitted[#emitted].station,'POKEDEX_SHOW','tuner event carries station identity') +press(gear,'a');T.eq(#emitted,before+2,'advancing program text does not retune') +V.set('red');T.raises(function()Wall.new(game)end,'Gen2 host','wall radio rejects another host') +Runtime.events=oldEvents;V.set(original) +T.finish('native radio VM, station lifecycle, Pokegear input and events') diff --git a/tests/engine/gen2_unown.lua b/tests/engine/gen2_unown.lua new file mode 100644 index 000000000..336e97dd2 --- /dev/null +++ b/tests/engine/gen2_unown.lua @@ -0,0 +1,147 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local V=require('src.core.GameVersion');local previous=V.get() +require('src.core.ModCompat').install() +require('src.script.Gen2Commands') +local U=require('src.core.gen2.Unown');local N=require('src.pokemon.Gen2Unown') +local Json=require('src.link.Json');local Loader=require('src.mods.Loader') +local Game=require('src.core.Game');local originalData=Game.data +local function fixture() + local d=T.fixtures.fresh();local def=require('src.mods.Merge').deepCopy(d.pokemon.FIXMON_A) + def.id='SPECIES_201';def.index=201;def.dex=201;def.name='UNOWN';def.forms={} + for i=1,26 do def.forms[i]={spriteFront='native-front-'..i,spriteBack='native-back-'..i}end + d.pokemon.SPECIES_201=def;d.constants.speciesOrder={[201]='SPECIES_201'} + + d.unown_dex={words={}};for i=1,26 do d.unown_dex.words[i]='AUTHORED-'..i end + return d +end +for _,version in ipairs({'gold','silver','crystal'})do + V.set(version);local d=fixture();Game.data=d + T.neq(U,N,version..' reference spelling is an explicit native adapter') + for i=1,26 do + local dvs=U.dvsForLetter(i);local mon={species='SPECIES_201',dvs=dvs} + T.eq(U.index(U.name(i)),i,version..' complete letter identity roundtrip') + T.eq(U.letterFromDVs(dvs),i,version..' inverse has correct native DV letter') + T.eq(require('src.pokemon.Sprites').formIndex(d.pokemon.SPECIES_201,mon),i,version..' native sprite form shares DV selection') + T.eq(U.monLetter(mon),i,version..' public mon identity reads current native dataset') + T.eq(U.word(i),'AUTHORED-'..i,version..' words are imported data, not a retail catalogue') + T.eq(U.formSprite(d.pokemon,i),d.pokemon.SPECIES_201.forms[i].spriteFront,version..' native form art path returned') + T.eq(U.formSprite(d.pokemon,i,true),d.pokemon.SPECIES_201.forms[i].spriteBack,version..' native back art path returned') + end + T.eq(U.forms(d.pokemon).A,d.pokemon.SPECIES_201.forms[1],version..' public named form resolves actual native first row') + T.eq(U.forms(d.pokemon).Z,d.pokemon.SPECIES_201.forms[26],version..' public named form resolves actual native last row') + local save={pokedex={unownForms={[1]=true,[3]=true,[5]=false,[99]=true}}};local before=Json.encode(save) + T.same(U.dex(save),{1,3},version..' older set-only save remains readable') + T.eq(U.count(save),2,version..' invalid/false form flags are not real letters') + T.eq(Json.encode(save),before,version..' reading an older save does not migrate it') + local detached=U.dex(save);detached[1]=26;T.same(U.dex(save),{1,3},version..' public list reads are detached') + T.check(U.registerCatch(save,{species='SPECIES_201',dvs=U.dvsForLetter(4)}),version..' newly received native mon records its form') + T.same(save.pokedex.unownDex,{1,3,4},version..' previous set-only letters retained before new caught form') + T.eq(save.unownDex,nil,version..' no parallel reference save list is created') + T.eq(U.registerCatch(save,{species='SPECIES_201',dvs=U.dvsForLetter(4)}),false,version..' second catch of same form is silent') + save.pokedex.unownDex={4,4,0,27};save.pokedex.unownForms[2]=true + T.same(U.dex(save),{4,1,2,3},version..' partial or malformed old ordered data retains known order and missing old forms') + local ctx={save=save};require('src.script.Commands').g2_readvar(ctx,14) + T.eq(ctx.g2Var,U.count(save),version..' native VAR_UNOWNCOUNT shares public count') + local screen=require('src.ui.PokedexUnownMode').new({data=d,save=save}) + T.same(screen.letters,U.dex(save),version..' native dex page shares public caught order') + local stored={species='SPECIES_201',dvs=U.dvsForLetter(2),unownLetter=3} + T.eq(U.monLetter(stored),3,version..' authored stored letter has reference precedence') + T.eq(require('src.pokemon.Sprites').formIndex(d.pokemon.SPECIES_201,stored),3,version..' stored letter also reaches native rendering') + local oldMon={species='SPECIES_201',unownLetter=7} + T.eq(U.monLetter(oldMon),7,version..' older stored-form record remains readable without DVs') + T.eq(require('src.pokemon.Sprites').formIndex(d.pokemon.SPECIES_201,oldMon),7,version..' native art agrees for an older stored-form record') + for _,mode in ipairs({{1,'native'},{2,'native'}})do + local files={['mods/forms/manifest.json']=Json.encode({id='forms',name='Native form observer',version='1.0.0',api=mode[1],content_format=mode[2],entry='main.lua',affects_link=false}), + ['mods/forms/main.lua']=[[ + local mod=...;local Unown=require('src.core.gen2.Unown') + mod.exports.events={};mod.exports.read=Unown.dex + mod.events:on("unown.unlocked",function(e) + mod.exports.events[#mod.exports.events+1]={letter=e.letter,name=e.name,word=e.word,count=e.count} + assert(Unown.caught(mod.exports.save,e.letter)) + assert(not Unown.updateDex(mod.exports.save,e.letter)) + e.letter=99;return false + end) + ]]} + local live=fixture();Game.data=live + local r=T.sdk.loadMods({'mods/forms'},{data=live,fs=T.sdk.memfs(files)}) + local label=version..'/'..mode[1]..'/'..mode[2] + T.eq(#r.errors,0,label..' actual Loader admits native module and public event') + local out=r.loader.exports.forms;out.save={} + T.check(N.registerCatch(out.save,{species='SPECIES_201',dvs=U.dvsForLetter(9)},live),label..' real native update reaches loaded consumer') + T.same(out.events,{{letter=9,name='I',word='AUTHORED-9',count=1}},label..' exactly one completed form notification') + T.same(out.read(out.save),{9},label..' listener mutation/return does not change the native form') + T.eq(N.registerCatch(out.save,{species='FIXMON_A',dvs=U.dvsForLetter(10)},d),false,label..' another species does not register an Unown form') + T.eq(#out.events,1,label..' non-form catch remains silent') + Loader.teardown();r.release() + end + Game.data=d + -- Native transfers run for real; only deferred UI/audio are omitted. + local Battle=require('src.battle.BattleState');local Boxes=require('src.pokemon.Boxes');Boxes.load(nil) + local function gameFor(fullParty,fullBoxes) + local save={player={name='QA',id=1},party={},pokedex={owned={},seen={}},flags={}} + if fullParty then for i=1,6 do save.party[i]={species='FIXMON_A'}end end + if fullBoxes then + local boxes=Boxes.ensure(save) + for _,box in ipairs(boxes)do for i=1,Boxes.capacity()do box[i]={species='FIXMON_A'}end end + end + return {save=save,data=d,stack={push=function()end}} + end + local function capture(game,letter) + local mon={species='SPECIES_201',level=5,dvs=U.dvsForLetter(letter)} + local battle=setmetatable({game=game,enemy={mon=mon,name='FORM'}, + restoreMimicked=function()end,sayNext=function()end,uiNext=function()end},{__index=Battle}) + battle:storeCaughtMon();return mon + end + local g=gameFor(false,false);local caught=capture(g,6) + T.eq(g.save.party[1],caught,version..' real battle capture enters party') + T.same(U.dex(g.save),{6},version..' party capture registers native form') + g=gameFor(true,false);caught=capture(g,7) + T.eq(g.save.boxes[1][1],caught,version..' real full-party capture enters native PC box') + T.same(U.dex(g.save),{7},version..' box capture registers native form') + g=gameFor(true,true);capture(g,8) + T.same(U.dex(g.save),{},version..' refused full-storage capture cannot publish a form') + local Commands=require('src.script.Commands') + for _,fullParty in ipairs({false,true})do + g=gameFor(fullParty,false) + local ctx={game=g,save=g.save} + Commands.give_pokemon(ctx,'SPECIES_201',5,true) + local received=fullParty and g.save.boxes[1][1]or g.save.party[1] + T.check(received~=nil,version..' actual gift transfer completes') + T.same(U.dex(g.save),{U.monLetter(received,d)},version..' actual gift records received native form') + end + g=gameFor(false,false) + Commands.give_pokemon({game=g,save=g.save},'SPECIES_201',5,true,{egg=true}) + T.same(U.dex(g.save),{},version..' actual egg receipt cannot register a caught form') +end +-- Independently pack the actual two DV bytes and mask hardware bit positions. +local bit=require('bit') +for first=0,255 do for second=0,255 do + local packed=bit.lshift(bit.band(first,0x60),1)+bit.lshift(bit.band(first,0x06),3) + +bit.rshift(bit.band(second,0x60),3)+bit.rshift(bit.band(second,0x06),1) + local dvs={attack=bit.rshift(first,4),defense=bit.band(first,15),speed=bit.rshift(second,4),special=bit.band(second,15)} + T.eq(U.letterFromDVs(dvs),math.floor(packed/10)+1,'all cartridge DV byte combinations preserve native form') +end end +for _,invalid in ipairs({false,0,27,-1,1.5,'','AA',0/0})do + T.eq(U.index(invalid),nil,'malformed letter has no identity') + T.eq(U.name(invalid),nil,'malformed letter has no label') + T.eq(U.updateDex({},invalid),false,'malformed form cannot enter native save') +end +T.eq(U.letterFromDVs(nil),nil,'missing DVs are not letter A') +T.eq(U.letterFromDVs({attack=math.huge}),nil,'invalid DVs cannot reach renderer') +T.eq(U.registerCatch({}, {species='UNOWN',isEgg=true,dvs=U.dvsForLetter(1)}),false,'egg has no caught form') +T.eq(U.registerCatch({}, {species='UNOWN',eggSteps=3,dvs=U.dvsForLetter(1)}),false,'native egg counter also prevents registration when an old flag is missing') +for _,broken in ipairs({{pokedex=false},{pokedex='bad'},{pokedex={unownForms='bad'}}})do + local bytes=Json.encode(broken);T.eq(U.updateDex(broken,1),false,'malformed native state refuses a write') + T.eq(Json.encode(broken),bytes,'refused malformed-state write is atomic') +end +T.eq(U.word(1,{unown_dex=false}),nil,'malformed imported word record is unavailable') +Game.data={pokemon={}};T.eq(U.word(1),nil,'data reload does not revive old imported words') +for _,other in ipairs({'prism','polishedcrystal','red','firered','heartgold'})do + V.set(other) + T.eq(U.updateDex({},1),false,other..' existing form model remains unchanged') +end +V.set('red');local save={};T.eq(U.updateDex(save,1),false,'native GSC save writes are generation gated') +T.same(save,{},'unsupported game is not mutated') +Game.data=originalData;V.set(previous) +T.finish('native GSC Unown identity, imported words, ordered dex and public observation') 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_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/gen4_performance_merge.lua b/tests/engine/gen4_performance_merge.lua new file mode 100644 index 000000000..ddf7221cd --- /dev/null +++ b/tests/engine/gen4_performance_merge.lua @@ -0,0 +1,64 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- New native fog and poison-step bands must survive the draw-path merge. +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Performance = require("src.core.Performance") +local Fixture = require("tests.world_draw.fixture") +local GymFx = require("src.world.Gen4GymFx") +Version.set("platinum") +local originalFog = GymFx.spriteFog +for _, enabled in ipairs({ false, true }) do + Performance.worldDrawHoist = enabled + local scene = Fixture.new(9) + scene.world.map.def = { gen4DepthFog = {} } + local fogBuilds, drawn = 0, 0 + GymFx.spriteFog = function() + fogBuilds = fogBuilds + 1 + return function() return 0.25 end + end + for _, entity in ipairs(scene.world.entities) do + entity.draw = function() + drawn = drawn + 1 + local r, g, b, a = love.graphics.getColor() + T.same({ r, g, b, a }, { 0.25, 0.25, 0.25, 1 }, "native fog reaches each ordinary and top-layer sprite") + end + end + scene.world:drawWorld() + T.eq(fogBuilds, 1, "native fog provider is evaluated once per world pass") + T.eq(drawn, 9, "all native sprites retain their draw") + T.same({ love.graphics.getColor() }, { 1, 1, 1, 1 }, "sprite tint is restored after fog") +end +GymFx.spriteFog = originalFog +local Renderer = require("src.render.Renderer") +Renderer:init() +local originalBlit = Renderer.blitCanvas +for _, enabled in ipairs({ false, true }) do + Performance.hotPathHoist = enabled + Renderer:beginFrame() + Renderer:beginWorldPass() + Renderer:endWorldPass() + Renderer.worldWobble = 2 + local bands, positive, negative = 0, false, false + local firstX + Renderer.blitCanvas = function(self, canvas, sx, sy, zones, zsx, zsy, ox, oy, cx, cy, cw, ch, dpiX, dpiY) + if canvas ~= Renderer.worldCanvas then + return originalBlit(self, canvas, sx, sy, zones, zsx, zsy, ox, oy, cx, cy, cw, ch, dpiX, dpiY) + end + T.eq(self, Renderer, "native bands keep the renderer receiver") + T.eq(canvas, Renderer.worldCanvas, "native bands keep the world canvas") + T.check(type(dpiX) == "number" and type(dpiY) == "number", "band calls keep both DPI arguments") + T.check(type(cy) == "number" and ch > 0, "band calls retain a nonempty clipped region") + bands = bands + 1 + firstX = firstX or ox + if ox > firstX then positive = true end + if ox < firstX then negative = true end + return originalBlit(self, canvas, sx, sy, zones, zsx, zsy, ox, oy, cx, cy, cw, ch, dpiX, dpiY) + end + Renderer:endFrame() + T.check(bands > 2, "native poison wobble retains multiple world bands") + T.check(positive or negative, "native bands alternate their horizontal displacement") + T.eq(Renderer.worldWobble, nil, "native poison wobble remains a one-frame effect") +end +Renderer.blitCanvas = originalBlit +T.finish("current native Gen4 render integration") 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/legacy_npc_controls.lua b/tests/engine/legacy_npc_controls.lua new file mode 100644 index 000000000..a70ed9da9 --- /dev/null +++ b/tests/engine/legacy_npc_controls.lua @@ -0,0 +1,134 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Contract = require("tests.api3_contract.npcs") +local Runtime = require("src.mods.Runtime") +local Version = require("src.core.GameVersion") +local function fixture(version) + local f = Contract.fixture("tests/fixtures/legacy_npc_controls", "gen2", version, "legacy") + -- API 2 materializes its legacy world lazily; the fixture already owns a + -- constructed game by the time the exported lookup is called. + f.loader._game = function() + return f.live + end + f.legacy = f.loader + f.handle = assert(f.loader.exports.legacy.npc(f.home, "IMPORTED")) + return f +end +for _, version in ipairs({ "red", "gold", "firered" }) do + local f = fixture(version) + local h, actor = f.handle, f.imported + T.eq(h.ow, f.ow, "legacy world field remains the native world") + T.eq(h.npc, actor, "legacy actor field retains its original identity") + T.eq(h.id, actor.id, "legacy public identity is preserved") + T.eq(h:isMoving(), false, "new motion query reads native state") + T.eq(h:placeAt(1, 1, "right"), true, "immediate placement is available to API 2") + T.eq(actor.px, 16, "placement synchronizes native pixel coordinates") + T.eq(actor.py, 16, "placement synchronizes native pixel coordinates") + T.eq(h:canStep("right"), true, "query uses actual native map collision") + local queue = #f.ow.scriptMoves + T.eq(h:stepNow("right"), true, "immediate stepping starts native motion") + T.eq(#f.ow.scriptMoves, queue, "immediate step creates no script queue entry") + T.eq(actor.targetX, 2, "native target follows the requested direction") + T.eq(h:isMoving(), true, "motion query sees the started step") + T.eq(h:stepNow("down"), nil, "an active step cannot be overwritten") + for _ = 1, 16 do + actor:update(f.ow.map, f.ow.entities) + end + T.eq(actor.cellX, 2, "actual NPC updates complete the step") + T.eq(h:isMoving(), false, "actual NPC updates clear native motion") + T.eq(h:stepNow("right"), true, "another native step starts") + T.eq(h:placeAt(3, 4, "up"), true, "placement cancels an in-flight step") + T.eq(actor.targetX, nil, "placement clears the old native target") + T.eq(actor.progress, 0, "placement clears the old step phase") + T.eq(actor.moving, false, "placement clears the old movement flag") + T.eq(actor.facing, "up", "placement applies optional facing") + local peer = + f:make(f.home, { index = 2, name = "PEER", sprite = "PROBE", x = 4, y = 4, movement = "STAY", range = "DOWN" }) + f.ow.npcs[#f.ow.npcs + 1] = peer + f.ow.entities[#f.ow.entities + 1] = peer + T.eq(h:canStep("right"), false, "collision query sees a native peer") + local other = assert(f.legacy.exports.legacy.npc(f.home, "PEER")) + T.eq(other:setPassable(true), true, "API-2 passability control reaches the native actor") + T.eq(h:canStep("right"), true, "native collision honors the changed peer passability") + T.eq(other:setPassable(false), true, "passability can be restored") + T.eq(h:canStep("right"), false, "restored passability restores native blocking") + local def = actor.def + T.eq(h:setAppearance("ALT"), true, "appearance resolves through the native sprite path") + T.eq(actor.appearance, "ALT", "native appearance identity updates") + T.eq(actor.def.sprite, "ALT", "native actor definition uses the chosen sprite") + T.eq(def.sprite, "PROBE", "imported shared template is retained") + local changed = actor.def + T.eq(h:setAppearance("MISSING"), nil, "missing appearance is declined") + T.eq(actor.def, changed, "a declined appearance cannot half-change the actor") + T.eq(h:stepNow("north"), nil, "bad directions are declined") + T.eq(h:placeAt(0 / 0, 2), nil, "non-finite coordinates are declined") + T.eq(actor.cellX, 3, "invalid input leaves the native cell unchanged") + local x, y = h:position() + T.eq(x, 3, "existing position method remains available") + T.eq(y, 4, "existing position method retains its returns") + T.eq(h:face("left"), true, "existing facing method remains available") + T.eq(actor.facing, "left", "existing facing method retains its native effect") + Runtime.currentMod = "legacy" + T.eq(h:isMoving(), false, "new control is callable under mod attribution") + T.eq(Runtime.currentMod, "legacy", "new control restores caller attribution") + Runtime.currentMod = nil + f.game.save = {} + T.eq(h:placeAt(0, 0), nil, "retained new controls decline a replacement save") + T.eq(actor.cellX, 3, "stale control cannot change the previous native actor") + f.restoreRenderer() +end +for _, change in ipairs({ "rollback", "disabled", "map", "map_id", "fast_world", "removed", "version", "bus" }) do + local f = fixture("gold") + local h = f.handle + if change == "rollback" then + f.legacy:_rollback("legacy") + elseif change == "disabled" then + f.legacy.mods.legacy.enabled = false + elseif change == "map" then + f.ow.map = { id = "CHANGED" } + elseif change == "map_id" then + f.ow.map.id = "CHANGED" + elseif change == "fast_world" then + f.game.overworld = {} + elseif change == "removed" then + f.ow.npcs = {} + elseif change == "version" then + Version.set("silver") + else + Runtime.install(require("src.mods.Events").new(), require("src.mods.Hooks").new(), {}) + end + T.eq(h:setPassable(true), nil, change .. " retires new controls") + T.eq(f.imported.passable, nil, change .. " does not change the old native actor") + f.restoreRenderer() +end + +do + local f = fixture("gold") + local Renderer = require("src.render.SpriteRenderer") + local old = Renderer.new + local def, sprite = f.imported.def, f.imported.sprite + Renderer.new = function() + error("owned sprite failure") + end + Runtime.currentMod = "legacy" + local ok, why = f.handle:setAppearance("ALT") + T.eq(ok, nil, "native sprite failures are contained") + T.check(why:find("owned sprite failure", 1, true) ~= nil, "native sprite failure remains diagnosable") + T.eq(Runtime.currentMod, "legacy", "failed native control restores caller attribution") + T.eq(f.imported.def, def, "failed native sprite resolution retains the old definition") + T.eq(f.imported.sprite, sprite, "failed native sprite resolution retains the old renderer") + Runtime.currentMod = nil + Renderer.new = old + f.restoreRenderer() +end + +do + local f = fixture("gold") + f.ow.map = nil + local h = assert(f.legacy.exports.legacy.npc(f.home, "IMPORTED")) + local value, why = h:isMoving() + T.eq(value, nil, "legacy lookup without a map cannot grant new controls") + T.eq(why, "NPC handle is stale", "missing-map control has a stable refusal") + f.restoreRenderer() +end +T.finish() diff --git a/tests/engine/level_visible.lua b/tests/engine/level_visible.lua new file mode 100644 index 000000000..c575768b0 --- /dev/null +++ b/tests/engine/level_visible.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/level_visible/fixture.lua") +local f = Fixture.new(".", "gen2", "red", 3, "unified") +-- Public registration is executable proof for the hook coverage audit. +local off = f.exports.api.hooks:wrap("pokemon.level_visible", function(next) + return next() +end) +dofile("tests/level_visible/cases.lua").run(f) +off() +f.close() +f.T.finish("portable pokemon.level_visible") diff --git a/tests/engine/level_visible_contract.lua b/tests/engine/level_visible_contract.lua new file mode 100644 index 000000000..dc6dd0a54 --- /dev/null +++ b/tests/engine/level_visible_contract.lua @@ -0,0 +1,39 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/level_visible/fixture.lua") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local Display = require("src.ui.LevelDisplay") +local Capabilities = require("src.mods.Capabilities") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + local supported = Version.generation(version) <= 3 + T.eq( + Capabilities.supports("hooks", "pokemon.level_visible", version), + supported, + version .. " native capability" + ) + if supported then + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + dofile("tests/level_visible/native.lua").run(f) + dofile("tests/level_visible/cases.lua").run(f) + f.close() + end + local f = Fixture.new(".", "gen2", version, 3, "unified", true) + local calls = 0 + f.patch(Runtime, "call", function() + calls = calls + 1 + end) + local opaque = setmetatable({}, { + __index = function() + error("no-mod path touched mon") + end, + }) + T.eq(Display.visible(opaque, "party", opaque), true, "unsubscribed default") + T.eq(calls, 0, "no-mod hot path skips dispatch and record adaptation") + f.close() + end +end +Version.set(originalVersion) +T.finish("pokemon.level_visible capability and SDK modes") diff --git a/tests/engine/level_visible_renderers.lua b/tests/engine/level_visible_renderers.lua new file mode 100644 index 000000000..e2c010418 --- /dev/null +++ b/tests/engine/level_visible_renderers.lua @@ -0,0 +1,289 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/level_visible/fixture.lua") +local Version = require("src.core.GameVersion") +local Font, Hud = require("src.render.Font"), require("src.render.HudTiles") +local Battle = require("src.battle.BattleState") +local G3 = require("src.battle.Gen3Battle") +-- Upvalue leaf spies require interpreted calls: LuaJIT can retain old leaf closures. +if jit then + jit.off(Battle.drawHUDs, true) +end +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + if Version.generation(version) <= 3 then + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + local game, mon, e = f.game, f.mon, f.exports + game.save = require("src.core.SaveData").newGame() + game.save.party = { mon } + mon.nickname, mon.hp, mon.stats.hp = "SCOUT", 60, 60 + f.result.loader.game = game + f.patch(love.graphics, "polygon", function() end) + f.patch(love.graphics, "getScissor", function() end) + Font.load(game.data) + local rows = {} + local function text(s, x, y) + rows[#rows + 1] = { text = tostring(s), x = x, y = y } + return Font.width(s) + end + f.patch(Font, "draw", text) + f.patch(Font, "drawCode", function(code, x, y) + rows[#rows + 1] = { code = code, x = x, y = y } + end) + for _, key in ipairs({ "tile", "statusTile" }) do + f.patch(Hud, key, function(code, x, y) + rows[#rows + 1] = { code = code, x = x, y = y } + end) + end + local function capture(draw) + rows = {} + draw() + return rows + end + local function has(list, needle) + for _, row in ipairs(list) do + if row.text and row.text:find(needle, 1, true) then + return true + end + end + return false + end + local function levelCount(list) + local count = 0 + for _, row in ipairs(list) do + if + row.text + and ( + row.text == "42" + or row.text == "43" + or row.text == "100" + or row.text == "Lv42" + or row.text == "Lv100" + ) + then + count = count + 1 + end + end + return count + end + local b = Battle.newWild(game, "FIXMON_C", 42) + b.player.isPlayer, b.enemy.isPlayer = true, false + b:syncSides() + b.phase, b.frame, b.shown, b.fx = "contract", 0, {}, {} + b.showPlayerBack, b.showEnemyTrainer, b.enemySendingOut, b.introBalls = false, false, false, false + b.growInScale = function() + return nil + end + b.colorMode = function() + return false + end + b.demoHidesPlayer = function() + return false + end + -- The classic compositor captures its tile leaf during module load. + for i = 1, 100 do + local name, value = debug.getupvalue(Battle.drawHUDs, i) + if not name then + break + end + if name == "hudTile" then + debug.setupvalue(Battle.drawHUDs, i, Hud.tile) + local close = f.close + f.close = function() + debug.setupvalue(Battle.drawHUDs, i, value) + close() + end + break + end + end + local nativeSeen = {} + local off = f.result.loader.hooks:wrap("pokemon.level_visible", function(next, m, ctx) + T.eq(ctx.game, game, "native producer supplies live Game") + T.check(m == mon or m == b.enemy.mon or m == b.player.mon, "native producer supplies actual mon") + if ctx.where == "battle.player" or ctx.where == "battle.enemy" then + T.eq( + ctx.where, + m == b.enemy.mon and "battle.enemy" or "battle.player", + "native side, including partners" + ) + end + nativeSeen[ctx.where] = (nativeSeen[ctx.where] or 0) + 1 + return next(m, ctx) + end, 1000) + local function cycle(label, surfaces, draw, expected) + for _, where in ipairs(surfaces) do + e.hide(where, false) + end + local stock = capture(draw) + T.eq(levelCount(stock), expected, version .. " " .. label .. " native level count") + nativeSeen = {} + for _, where in ipairs(surfaces) do + e.hide(where, true) + end + local hidden = capture(draw) + T.eq(levelCount(hidden), 0, label .. " omits level digits") + for _, row in ipairs(hidden) do + T.check(row.code ~= 0x6E and row.code ~= 0x70, label .. " no orphan level/TO glyph") + T.check(row.text ~= "TO" and not (row.text or ""):find("Lv42", 1, true), "no orphan label") + end + for _, where in ipairs(surfaces) do + T.check((nativeSeen[where] or 0) > 0, label .. " producer identifies " .. where) + e.hide(where, false) + end + T.check(has(hidden, "SCOUT") or has(hidden, b.player.name), label .. " nickname remains") + local restored = capture(draw) + T.same(restored, stock, label .. " restores exact printed rows") + end + if Version.generation() <= 2 then + cycle("classic battle", { "battle.player", "battle.enemy" }, function() + b:drawHUDs(0) + end, 2) + cycle("wide battle", { "battle.player", "battle.enemy" }, function() + local wide = require("src.battle.WideBattle") + wide.gen2Panel(b, b.player, 9 * 8, 7 * 8, true, false) + wide.gen2Panel(b, b.enemy, 0, 0, false, false) + end, 2) + -- One surface can hide without hiding the other side. + e.hide("battle.enemy", true) + T.eq( + levelCount(capture(function() + b:drawHUDs(0) + end)), + 1, + "per-side native battle omission" + ) + e.hide("battle.player", true) + b.player.shownStatus, b.enemy.shownStatus = "PSN", "PSN" + T.check( + has( + capture(function() + b:drawHUDs(0) + end), + "PSN" + ), + "hidden levels retain battle status" + ) + b.player.shownStatus, b.enemy.shownStatus = nil, nil + local party = require("src.ui.PartyMenu").new(game) + cycle("party", { "party" }, function() + party:draw() + end, 1) + mon.level = 100 + cycle("party level 100", { "party" }, function() + party:draw() + end, 1) + mon.level = 42 + local summary = require("src.ui.SummaryMenu").new(game, mon) + for page = 1, Version.isGen2() and 3 or 2 do + summary.page = page + local count = Version.isGen2() and (page == 1 and 2 or 1) or 1 + cycle("summary page " .. page, { "summary" }, function() + summary:draw() + end, count) + end + mon.isEgg = true + local calls = e.seen.calls + capture(function() + party:draw() + end) + T.eq(e.seen.calls, calls, "egg does not query or invent a level") + mon.isEgg = nil + else + cycle("GBA no-art battle", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 2) + e.hide("battle.player", true) + e.hide("battle.enemy", true) + b.player.shownStatus, b.enemy.shownStatus = "PSN", "PSN" + T.check( + has( + capture(function() + G3.drawHUDs(b, 0) + end), + "PSN" + ), + "GBA fallback preserves status text" + ) + b.player.shownStatus, b.enemy.shownStatus = nil, nil + local image = love.graphics.newCanvas(128, 64) + f.patch(require("src.render.Assets"), "image", function() + return image + end) + local record = { images = { player = "panel", opponent = "panel" } } + game.data.constants.gen3BattleHud = record + cycle("GBA panel battle", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 2) + local w = { + name = { x = 8, y = 3, w = 56 }, + level = { x = 64, y = 3 }, + bar = { x = 40, y = 16 }, + label = { x = 24, y = 16 }, + } + record.windows = { player = w, opponent = w } + cycle("GBA native windows", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 2) + record.windows = nil + game.data.constants.gen3BattlerCoords = { + healthbox = { + playerLeft = { x = 159, y = 76 }, + playerRight = { x = 171, y = 101 }, + opponentLeft = { x = 44, y = 19 }, + opponentRight = { x = 32, y = 44 }, + }, + } + b.isDouble = function() + return true + end + local partner = { name = "SCOUT", mon = b.player.mon, isPlayer = true, shownHP = mon.hp } + b.battlerAt = function(self, pos) + return pos == 2 and partner or (pos % 2 == 0 and self.player or self.enemy) + end + cycle("GBA doubles", { "battle.player", "battle.enemy" }, function() + G3.drawHUDs(b, 0) + end, 4) + local party = require("src.ui.Gen3PartyMenu").new(game) + local panel = { x = 0, y = 0, wide = true, width = 100, index = 1 } + cycle("GBA party slot", { "party" }, function() + party:drawMember(mon, panel, false) + end, 1) + local summary = require("src.ui.Gen3SummaryMenu").new(game, { mon = mon }) + cycle("GBA summary fallback", { "summary" }, function() + summary:drawHeader() + end, 1) + summary.hasArt = true + cycle("GBA summary art", { "summary" }, function() + summary:drawHeader() + end, 1) + local labels = {} + for i = 1, 20 do + labels[i] = { x = 0, y = 0 } + end + game.data.constants.gen3SummaryWindows = { labels = labels } + cycle("GBA summary windows", { "summary" }, function() + summary:drawHeader() + end, 1) + if version == "firered" then + summary.page = 3 + cycle("FireRed summary", { "summary" }, function() + summary:drawFireRed({}) + end, 1) + summary.moveSelect = true + e.hide("summary", true) + local calls = e.seen.calls + local detailRows = capture(function() + summary:drawFireRed({}) + end) + T.eq(has(detailRows, "Lv42"), false, "move detail native omission") + T.eq(e.seen.calls, calls, "move detail cannot invent a level") + end + end + off() + f.close() + end + end +end +Version.set(originalVersion) +T.finish("pokemon.level_visible native renderers") diff --git a/tests/engine/live_maps_lifecycle.lua b/tests/engine/live_maps_lifecycle.lua new file mode 100644 index 000000000..077832730 --- /dev/null +++ b/tests/engine/live_maps_lifecycle.lua @@ -0,0 +1,112 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Runtime = require("src.mods.Runtime") +local LiveMaps = require("src.world.LiveMaps") +local Fixture = dofile("tests/live_maps/fixture.lua") +local Cases = dofile("tests/live_maps/cases.lua") +local old = 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]) + f.commonEvents = true -- Gen2's real shared overworld produces events on every host. + local world, e = f.world, f.e + T.eq(e.api:supports("events", "world.live_maps_preparing"), true, "native preparing capability") + T.eq(e.api:supports("events", "world.live_maps_updated"), true, "native updated capability") + Cases.run(f) + if mode[1] < 3 then + local maps = e.maps() + T.eq(maps[2].x, 2 * f.pixel / 16, "legacy reader retains cell coordinates") + T.eq(maps[2].ox, nil, "legacy reader shape is preserved") + else + T.eq(e.api:supports("world", "liveMaps"), true, "API-3 reader supports native host") + local neighbors = world.neighbors + world.neighbors = { neighbors[1], neighbors[1], { map = neighbors[1].map, ox = -16, oy = 32 } } + local placements = e.maps() + T.eq(#placements, 4, "API-3 retains native placement order and duplicates") + T.eq(placements[4].ox, -16, "same map at another offset is retained") + world.neighbors = neighbors + f.game.world, f.game.overworld = nil, nil + local missing, why = e.maps() + T.eq(missing, nil, "API-3 query without a world is safe") + T.eq(why, "no overworld", "API-3 missing-world reason") + f.game.world, f.game.overworld = world, world + end + local once = 0 + e.api.events:once("world.live_maps_updated", function() + once = once + 1 + end) + f.rebuild() + f.rebuild() + T.eq(once, 1, "once unsubscribes from native event") + local calls = 0 + local off = e.api.events:on("world.live_maps_preparing", function() + calls = calls + 1 + T.eq(world.preparingLiveMaps, true, "preparation callback owns preparation phase") + local legacy = require("src.mods.WorldReads").liveMaps(world) + T.eq(legacy[2].x, 2 * f.pixel / 16, "legacy offsets are ready during preparation") + f.game.data.maps.EAST.objects = { + { + index = 1, + name = "LIVE_GHOST", + sprite = "SPRITE_FIX_NPC", + movement = "STAY", + range = "NONE", + x = 0, + y = 0, + }, + } + end) + f.rebuild() + T.eq(calls, 1, "preparing runs once before native ghost construction") + T.eq(#world.ghosts, 1, "preparation changes reach actual ghost builder") + T.eq(world.ghosts[1].npc.id, "EAST_obj_1", "native NPC was constructed") + off() + local before = #e.events + local failure = e.api.events:on("world.live_maps_preparing", function() + error("intentional listener failure") + end, 100) + f.rebuild() + T.eq(#e.events, before + 2, "failed listener does not prevent either lifecycle notification") + T.eq(world.preparingLiveMaps, nil, "failed listener leaves no preparation flag") + failure() + before = #e.events + local nested = e.api.events:on("world.live_maps_preparing", function() + f.rebuild() + end, 100) + f.rebuild() + T.eq(#e.events, before + 2, "nested rebuild does not recursively notify preparation") + T.eq(world.preparingLiveMaps, nil, "nested rebuild restores preparation flag") + nested() + before = #e.events + e.stop() + e.stop() + f.rebuild() + T.eq(#e.events, before, "consumer unsubscribe stops native delivery") + f.close() + local failed = Fixture.new(".", "gen2", version, mode[1], mode[2], true) + T.check(#failed.result.errors > 0, "entry failure is recorded") + failed.rebuild() + for _, name in ipairs({ "world.live_maps_preparing", "world.live_maps_updated" }) do + for _, entry in ipairs(Runtime.events.listeners[name] or {}) do + T.check(entry.owner ~= "portable_live_maps", "entry rollback removes owned listener") + end + end + failed.close() + end +end +-- No-listener rebuilds never allocate a snapshot; data-only reads never load maps. +do + local f = Fixture.new(".", "gen2", "red", 3, "unified") + Runtime.install(require("src.mods.Events").new(), require("src.mods.Hooks").new(), {}) + local snapshot = LiveMaps.snapshot + LiveMaps.snapshot = function() + error("unsubscribed snapshot allocation") + end + local ok = pcall(f.rebuild) + LiveMaps.snapshot = snapshot + T.eq(ok, true, "native hot path skips snapshots without listeners") + f.close() +end +Version.set(old) +T.finish("portable live-map events and API-3 pixel placements") 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/manifest_force_enable.lua b/tests/engine/manifest_force_enable.lua new file mode 100644 index 000000000..8fed30338 --- /dev/null +++ b/tests/engine/manifest_force_enable.lua @@ -0,0 +1,31 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit');local Loader=require('src.mods.Loader') +local V=require('src.core.GameVersion');local previous=V.get();V.set('gold') +local Json=require('src.link.Json');local Manifest=require('src.mods.Manifest') +local manifest={id='force_probe',name='Force probe',version='1.0.0',api=2,entry='main.lua', + games={'gen2'},generations={2},force_enable_env='OWN_FORCE_ENABLE'} +T.eq(Manifest.validate(manifest).force_enable_env,'OWN_FORCE_ENABLE','manifest retains the optional variable name') +local getenv=os.getenv;local enabled +os.getenv=function(key)if key=='OWN_FORCE_ENABLE'then return enabled end;return getenv(key)end +local function load(opts) + local files={['mods/force_probe/manifest.json']=Json.encode(manifest), + ['mods/force_probe/main.lua']='local mod=...;mod.exports.loaded=true', + ['options.lua']='return {mods={force_probe=false}}'} + local loader=Loader.new({fs=T.sdk.memfs(files),dev=false,noMods=opts and opts.noMods}) + assert(loader:load(T.fixtures.fresh())) + return loader +end +for _,value in ipairs({'0','true','1'})do + enabled=value;local loader=load() + T.eq(loader.exports.force_probe~=nil,value=='1','only the explicit enabled environment value overrides a saved disable') + Loader.teardown() +end +enabled='1';local plain=load({noMods=true}) +T.eq(plain.exports.force_probe,nil,'no-mod boot keeps the discovery boundary') +Loader.teardown() +manifest.games={'gen3'};manifest.generations={3};local wrong=load() +T.eq(wrong.exports.force_probe,nil,'force enable cannot bypass the declared generation target') +Loader.teardown() +os.getenv=getenv;V.set(previous) +T.finish('manifest force-enable policy through the production Loader') 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..b12a94eb7 --- /dev/null +++ b/tests/engine/mod_supports.lua @@ -0,0 +1,256 @@ +-- 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'), + version .. ': absent Gen1Recomp API-3 features are not advertised') + T.eq(yes(api, 'events', 'egg.hatched'), GameVersion.generation(version)==2, + version .. ': egg hatch observation follows the native Gen2 producer') + 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', 'trainer.before_battle'), true, version .. ': native trainer preparation seam') + T.eq(mod:supports('hooks', 'world.talk'), true, version .. ': native NPC talk seam') + T.eq(mod:supports('hooks', 'battle.status_hud_visible'), true, + version .. ': native GB/GBA/DS HUD seam') + T.eq(mod:supports('hooks', 'battle.overlay'), gen <= 3, + version .. ': GB/GBA-only overlay seam') + T.eq(mod:supports('hooks', 'ui.party.submenu'), gen <= 3, + version .. ': native party-menu seam') + T.eq(mod:supports('hooks', 'ui.pc.items'), gen <= 2, + version .. ': native PC seam') + T.eq(mod:supports('events', 'intro.birch_speech.started'), version == 'emerald', + version .. ': Emerald-only Birch intro') + T.eq(mod:supports('hooks', 'intro.oak_speech.build'), gen <= 2 and version ~= 'prism', + version .. ': Oak route excludes Prism and newer intros') +end + +-- A content query gates an actual registration through the entry API. +local entry = files['mods/probe/main.lua'] +files['mods/probe/main.lua'] = [[ +return function(mod) + if mod:supports('content', 'strings') then + mod.content.strings:register('QUERY_TEXT', 'Query succeeded') + end + if mod:supports('content', 'not_a_registry') then + error('unavailable registration must be skipped') + end +end +]] +local _, contentLoader, contentData = probe('crystal') +T.eq(contentData.strings.QUERY_TEXT, 'Query succeeded', 'supported registry accepts real registration') +T.eq(#contentLoader.errors, 0, 'unavailable content fallback loads without errors') +files['mods/probe/main.lua'] = entry + +-- A generation-specific query gates the real DS HUD call site. Returning +-- false avoids drawing and lets this run without a graphics context. +local Gen4Battle = require('src.battle.Gen4Battle') +for _, version in ipairs({ 'platinum', 'black' }) do + local mod = probe(version) + local calls = 0 + if mod:supports('hooks', 'battle.status_hud_visible') then + mod.hooks:wrap('battle.status_hud_visible', function() + calls = calls + 1 + return false + end) + end + T.eq(Gen4Battle.drawHealthboxes({}), true, version .. ': supported HUD hook suppresses native boxes') + T.eq(calls, 1, version .. ': native HUD call reaches mod once') +end + +-- A successful probe gates an actual native call: world.timeOfDay dispatches +-- the hook and resulting event. Queries themselves have no bus side effects. +api, loader = probe('crystal') +local calls, changes = 0, 0 +if api:supports('hooks', 'world.tod') then + api.hooks:wrap('world.tod', function(next, tod, ctx) + calls = calls + 1 + return 'TEST_NIGHT' + end) +end +if api:supports('events', 'world.tod_changed') then + api.events:on('world.tod_changed', function(payload) + changes = changes + 1 + T.eq(payload.tod, 'TEST_NIGHT', 'native event carries hook result') + end) +end +local Overworld = require('src.world.OverworldController') +local ow = setmetatable({ tod = 'DAY', map = { id = 'PROBE' }, player = {} }, { __index = Overworld }) +ow.syncGen2Tod = function() end +T.eq(ow:timeOfDay(), 'TEST_NIGHT', 'queried native hook executes through actual world method') +T.check(calls == 1 and changes == 1, 'native hook/event each dispatched once') +-- Unsubscribe and use the same native path: original time-of-day behavior. +loader.events:removeOwner('probe') +loader.hooks:removeOwner('probe') +GameVersion.set('red') +ow.tod = 'DAY' +T.eq(ow:timeOfDay(), 'DAY', 'unmodified native path retains vanilla result') +T.check(calls == 1 and changes == 1, 'no-mod path invokes no mod callbacks') + +GameVersion.set(originalVersion) +love = savedLove +Runtime.install(originalEvents, originalHooks, originalErrors) +T.finish('mod supports') diff --git a/tests/engine/native_mod_seams.lua b/tests/engine/native_mod_seams.lua new file mode 100644 index 000000000..6d3794059 --- /dev/null +++ b/tests/engine/native_mod_seams.lua @@ -0,0 +1,234 @@ +-- Native producers, observed through a sandboxed mod loaded by the real SDK. +-- Do not emit these events from the test: a missing call site must fail here. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") +local GameVersion = require("src.core.GameVersion") +local Overworld = require("src.world.OverworldController") +local BirchSpeech = require("src.ui.BirchSpeech") +local Gen4Battle = require("src.battle.Gen4Battle") +local Pokemon = require("src.pokemon.Pokemon") + +local savedVersion = GameVersion.get() +local savedGame = {} +for _, key in ipairs({ "data", "save", "stack", "input", "overworld", "world", "renderer" }) do + savedGame[key] = { Game[key] } +end +GameVersion.set("red") +local data = T.fixtures.fresh() +local run = T.sdk.loadMods( + { "mods/seam_probe" }, + { + data = data, + fs = T.sdk.memfs({ + ["mods/seam_probe/manifest.json"] = [[{ + "id":"seam_probe", "name":"Native seam probe", "version":"1.0.0", + "api":2, "entry":"main.lua", "affects_link":false + }]], + ["mods/seam_probe/main.lua"] = [[ + local mod = ... + local seen = {} + mod.exports.seen = seen + local names = { + "intro.birch_speech.started", "intro.birch_speech.finished", + "pokemon.egg_hatched", "world.blocks_replaced", "world.tick", + } + for _, name in ipairs(names) do + seen[name] = {} + mod.events:on(name, function(payload) + local events = seen[name] + events[#events + 1] = payload + end) + end + mod.exports.hideHUD = true + mod.exports.hudCalls = 0 + mod.hooks:wrap("battle.status_hud_visible", function(nextFn, battle) + mod.exports.hudCalls = mod.exports.hudCalls + 1 + mod.exports.battle = battle + if mod.exports.hideHUD then return false end + return nextFn(battle) + end) + ]], + }), + } +) +T.eq(#run.errors, 0, "the public-API probe loads without errors") +local exports = assert(run.loader.exports.seam_probe) +local seen = exports.seen +local stack = { states = {} } +function stack:push(state) + self.states[#self.states + 1] = state +end +function stack:pop() + return table.remove(self.states) +end +function stack:top() + return self.states[#self.states] +end +Game.data, Game.stack = data, stack +Game.save = { player = { map = "FIX_TOWN", name = "TEST", id = 123 }, options = {} } +Game.input = { + wasPressed = function() + return false + end, + isDown = function() + return false + end, +} +Game.renderer = { + worldViewSize = function() + return 160, 144 + end, +} + +-- enter() establishes the native module's Game reference and real runner. +-- Map loading is the ROM/graphics boundary; provide a tiny idle map there. +local map = { + id = "FIX_TOWN", + def = { width = 2, height = 2 }, + blockPatch = {}, + renderer = { + rebuilds = 0, + rebuild = function(self) + self.rebuilds = self.rebuilds + 1 + end, + }, + warpAtCell = function() + return nil + end, +} +function map:blockAt(bx, by) + return self.blockPatch[by * 2 + bx + 1] or 0 +end +local world = setmetatable({ + setMap = function(self) + self.map = map + self.player = { + cellX = 0, + cellY = 0, + px = 0, + py = 0, + update = function() + return false + end, + } + self.npcs, self.ghosts, self.entities, self.cast = {}, {}, {}, {} + end, +}, { __index = Overworld }) +world:enter("FIX_TOWN", 0, 0, "down") + +-- Birch start and finish reach subscribers at their real lifecycle boundary. +GameVersion.set("emerald") +data.constants.gen3Intro = { lines = { "WELCOME", "WORLD", "PLAYER", "GENDER" } } +data.text.WELCOME, data.text.GENDER = "Welcome!", "Boy or girl?" +local done = false +local speech = BirchSpeech.new(Game, function() + done = true +end) +stack:push(speech) +speech:enter() +T.eq(#seen["intro.birch_speech.started"], 1, "enter publishes one Birch start") +T.eq(seen["intro.birch_speech.started"][1].speech, speech, "start identifies the live speech") +T.check(stack:top() ~= speech, "start advances into the first real text box") +stack:pop() +speech.answers = { name = "TEST", gender = "girl" } +speech:finish() +T.eq(#seen["intro.birch_speech.finished"], 1, "finish publishes one Birch completion") +local finished = seen["intro.birch_speech.finished"][1] +T.eq(finished.speech, speech, "finish identifies the same speech") +T.eq(finished.answers, speech.answers, "finish carries the recorded answers") +T.check(done and #stack.states == 0, "finish then closes the speech and calls its continuation") + +-- Batch block changes publish only actual writes, clipped to map dimensions. +GameVersion.set("red") +T.eq(world:replaceBlocks({ 1, 0, 2, 0, 99 }), true, "native batch changes map blocks") +T.eq(#seen["world.blocks_replaced"], 1, "batch emits one event") +T.eq(seen["world.blocks_replaced"][1].mapId, "FIX_TOWN", "batch reports the map") +T.eq(seen["world.blocks_replaced"][1].count, 2, "batch counts changed cells only") +T.eq(map.blockPatch[1], 1, "first block is written") +T.eq(map.blockPatch[3], 2, "second changed block is written") +T.eq(map.blockPatch[5], nil, "extra input cannot extend the map") +T.eq(map.renderer.rebuilds, 1, "batch rebuilds once") +T.eq(world:replaceBlocks({ 1, 0, 2, 0 }), false, "identical batch does nothing") +T.eq(world:replaceBlocks(nil), false, "missing batch does nothing") +T.eq(#seen["world.blocks_replaced"], 1, "no-op batches publish no event") + +-- A headless logic update emits even though draw() was never called. +world:update(1 / 60) +T.eq(#seen["world.tick"], 1, "native update publishes one simulation tick") +local tick = seen["world.tick"][1] +T.eq(tick.dt, 1 / 60, "tick carries the simulation dt") +T.eq(tick.mapId, "FIX_TOWN", "tick carries the active map") +T.eq(tick.overworld, world, "tick identifies the native world") +T.eq(tick.scripted, false, "an idle runner is reported as idle") +world.runner:run({ { "wait", 5 } }) +T.eq(world.runner:isRunning(), true, "the native wait command holds a script open") +world:update(1 / 30) +T.eq(#seen["world.tick"], 2, "scripted updates still publish exactly one tick") +T.eq(seen["world.tick"][2].scripted, true, "tick reports the real running script") +T.eq(seen["world.tick"][2].dt, 1 / 30, "tick does not hardcode the frame interval") + +-- Hatching is announced after the native dialogue callback, with a healed +-- ordinary Pokemon rather than an egg. Use Emerald to avoid Johto's second +-- nickname prompt; the native hatch method itself remains under test. +GameVersion.set("emerald") +local egg = Pokemon.new(data, "FIXMON_A", 5, function(a) + return a +end) +egg.isEgg, egg.eggSteps, egg.eggCycles = true, 0, 0 +egg.hp, egg.status = 1, "PSN" +T.eq(world:hatchEgg(egg), true, "native hatch accepts the egg") +T.eq(#seen["pokemon.egg_hatched"], 0, "hatch is not announced before dialogue finishes") +local box = stack:pop() +T.check(box ~= nil, "hatch opens its native text box") +box.onDone() +T.eq(#seen["pokemon.egg_hatched"], 1, "dialogue completion publishes the hatch") +T.eq(seen["pokemon.egg_hatched"][1].mon, egg, "hatch identifies the live Pokemon") +T.eq(egg.isEgg, nil, "the announced Pokemon is no longer an egg") +T.eq(egg.eggSteps, nil, "hatch clears egg step bookkeeping") +T.eq(egg.eggCycles, nil, "hatch clears egg cycle bookkeeping") +T.eq(egg.hp, egg.stats.hp, "hatch heals HP before publication") +T.eq(egg.status, nil, "hatch cures status before publication") +T.eq(egg.happiness, 120, "hatch applies base happiness before publication") +T.eq(world:hatchEgg(nil), false, "missing egg is rejected") +T.eq(#seen["pokemon.egg_hatched"], 1, "rejected hatch does not publish") + +-- A public wrapper can suppress native Sinnoh/Unova healthboxes before any +-- battler/graphics access. Returning true prevents fallback Game Boy panels. +GameVersion.set("platinum") +local battle = {} +T.eq(Gen4Battle.drawHealthboxes(battle), true, "hidden native HUD is handled") +T.eq(exports.hudCalls, 1, "native healthbox draw consults the public hook") +T.eq(exports.battle, battle, "the hook receives the live battle") +exports.hideHUD = false +T.eq( + Gen4Battle.drawHealthboxes(battle), + false, + "delegating preserves the native fallback when healthbox art is absent" +) +T.eq(exports.hudCalls, 2, "delegation still goes through the public wrapper") +exports.hideHUD = true +GameVersion.set("black") +T.eq(Gen4Battle.drawHealthboxes(battle), true, "the shared native path also hides Unova boxes") +T.eq(exports.hudCalls, 3, "Unova consults the same public wrapper") + +T.eq(#run.errors, 0, "native callbacks produce no attributed mod errors") +run.loader.events:removeOwner("seam_probe") +run.loader.hooks:removeOwner("seam_probe") +for _, name in ipairs({ + "intro.birch_speech.started", + "intro.birch_speech.finished", + "pokemon.egg_hatched", + "world.blocks_replaced", + "world.tick", +}) do + T.eq(Runtime.wants(name), false, "unloading removes subscription: " .. name) +end +T.eq(Runtime.wantsHook("battle.status_hud_visible"), false, "unloading removes the HUD hook") +run.release() +GameVersion.set(savedVersion) +for key, value in pairs(savedGame) do + Game[key] = value[1] +end +T.finish("native public mod seams") diff --git a/tests/engine/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/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..f119718f3 --- /dev/null +++ b/tests/engine/pack_sprite_adapter.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 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 U=require('src.compat.gen2.Unown') +T.eq(U.monLetter({species='OTHER'}),nil,'Unown query rejects other species') +T.eq(U.monLetter({species='UNOWN',unownLetter='Z'}),26,'Unown query accepts a stored letter') +T.eq(U.monLetter({species='UNOWN',dvs={attack=0,defense=0,speed=0,special=0}}),1, + 'Unown query delegates the native DV selector') +local P=require('src.compat.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/palette_zone_cache.lua b/tests/engine/palette_zone_cache.lua new file mode 100644 index 000000000..de520e464 --- /dev/null +++ b/tests/engine/palette_zone_cache.lua @@ -0,0 +1,157 @@ +local T = require("tests.harness") +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Performance = require("src.core.Performance") +local P = require("src.render.PaletteFX") +local Renderer = require("src.render.Renderer") +local base = { { 255, 240, 220 }, { 180, 150, 120 }, { 80, 50, 30 }, { 5, 3, 1 } } +local function shader() + local records = {} + local methods = { + typeOf = function(_, kind) + return kind == "Shader" + end, + send = function(_, name, value) + records[#records + 1] = { name, { value[1], value[2], value[3] }, value } + end, + } + local object = methods + if newproxy then + object = newproxy(true) + getmetatable(object).__index = methods + end + return object, records +end +for _, version in ipairs({ "red", "gold" }) do + Version.set(version) + for _, mode in ipairs(P.MODES) do + P.mode = mode + for _, map in ipairs({ false, { [0] = 0, [1] = 3, [2] = 1, [3] = 2 } }) do + P.setShadeMap(map or nil) + local expected = P.effectiveColors(base) + local sh, records = shader() + local state = { rows = { {}, {}, {}, {} } } + P._sendColorsInBatch(sh, base, state) + for i = 1, 4 do + T.eq(records[i][1], "c" .. (i - 1), "uniform names remain unchanged") + for channel = 1, 3 do + T.eq(records[i][2][channel], expected[i][channel] / 255, version .. "/" .. mode .. " row mapping") + end + end + P._sendColorsInBatch(sh, base, state) + T.eq(#records, 4, "unchanged twelve floats skip redundant uploads") + end + end +end +Version.set("red") +P.mode = "gbc" +P.setShadeMap(nil) +local sh, records = shader() +local state = { rows = { {}, {}, {}, {} } } +P._sendColorsInBatch(sh, base, state) +base[2][1] = 181 +P._sendColorsInBatch(sh, base, state) +T.eq(#records, 5, "in-place palette mutation invalidates the affected row") +T.eq(records[5][1], "c1", "only changed row is uploaded") +local custom = P.effectiveColors +P.effectiveColors = function(c) + return { c[4], c[3], c[2], c[1] } +end +P._sendColorsInBatch(sh, base, state) +T.eq(records[#records][2][1], base[1][1] / 255, "public effectiveColors override is honored") +P.effectiveColors = custom +local oldPermute = P.permute +P.permute = function(c) + return { c[4], c[3], c[2], c[1] } +end +P._sendColorsInBatch(sh, base, state) +T.eq(records[#records][2][1], base[1][1] / 255, "public permute override is honored") +P.permute = oldPermute +local plain, plainRecords = shader() +Performance.paletteZoneCache = true +P.sendColors(plain, base) +P.sendColors(plain, base) +T.eq(#plainRecords, 8, "public calls always resend after possible external shader writes") +local fake = { + send = function(self, name, value) + self[#self + 1] = value + end, +} +P.sendColors(fake, base) +P.sendColors(fake, base) +T.check(fake[1] ~= fake[5], "consumer-provided send callbacks retain fresh public vectors") +local shaderFactory = P.shader +P.shader = function() + return sh +end +local zones = { + { x = 0, y = 0, w = 16, h = 16, colors = base }, + { x = 0, y = 0, w = 16, h = 16, colors = base }, + { + x = 0, + y = 0, + w = 16, + h = 16, + colors = base, + }, +} +local canvas = love.graphics.newCanvas(16, 16) +local function blit() + Renderer:blitCanvas(canvas, 1, 1, zones, 1, 1, 0, 0, 0, 0, 16, 16, 1, 1) +end +records = {} +local count = 0 +local sender = P.sendColors +P.sendColors = function(...) + count = count + 1 + return sender(...) +end +blit() +T.eq(count, 3, "public sendColors wrapper is still called for every original zone") +P.sendColors = sender +local real, sent = shader() +P.shader = function() + return real +end +Performance.paletteZoneCache = true +blit() +local perBlit = newproxy and 4 or 12 +T.eq(#sent, perBlit, "native contiguous blit skips repeated rows only on real shader-shaped userdata") +real:send("c0", { 0.4, 0.5, 0.6 }) +blit() +T.eq(#sent, perBlit * 2 + 1, "new blit resets validity after external raw shader writes") +T.same( + sent[perBlit + 2][2], + { base[1][1] / 255, base[1][2] / 255, base[1][3] / 255 }, + "first upload restores the native palette" +) +Performance.paletteZoneCache = false +local before = #sent +blit() +T.eq(#sent - before, 12, "P5 off restores four sends for every zone") +Performance.paletteZoneCache = true +local oldDraw = love.graphics.draw +love.graphics.draw = function(...) + return oldDraw(...) +end +before = #sent +blit() +T.eq(#sent - before, 12, "a graphics callback override disables cache assumptions") +love.graphics.draw = oldDraw +P.shader = shaderFactory +local q = { + setViewport = function(self, ...) + self.viewport = { ... } + end, +} +local draw = love.graphics.draw +local drawn +love.graphics.draw = function(...) + drawn = { ... } +end +Renderer:_drawZoneQuad(q, canvas, 2, 2, 2, 2, 0, 0, 3, 5, 20, 20, 1, 1, { x = 0, y = 0, w = 16, h = 16 }) +T.same(q.viewport, { 1.5, 2.5, 10, 10, 16, 16 }, "quad viewport crops source coordinates to the original clip") +T.eq(drawn[3], 3, "cropped quad retains horizontal origin mapping") +T.eq(drawn[4], 5, "cropped quad retains vertical origin mapping") +love.graphics.draw = draw +T.finish("palette uploads and zone quads") diff --git a/tests/engine/party_grid_navigation.lua b/tests/engine/party_grid_navigation.lua new file mode 100644 index 000000000..cf11024af --- /dev/null +++ b/tests/engine/party_grid_navigation.lua @@ -0,0 +1,101 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Fixture = dofile("tests/party_grid/fixture.lua") +local Cases = dofile("tests/party_grid/cases.lua") +local old = Version.get() +for _, version in ipairs(Version.ORDER) do + Version.set(version) + if Version.generation() <= 3 then + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + local f = Fixture.new(".", "gen2", version, mode[1], mode[2]) + Cases.run(f) + f.count(6) + f.index(1) + T.eq(f.press("right"), f.generation == 3 and 2 or 1, "unwrapped native horizontal navigation") + f.index(1) + T.eq(f.press("up"), f.generation == 3 and "cancel" or 6, "unwrapped native vertical navigation") + f.index(1) + local menu, api, game = f.menu, f.exports.api, f.game + local calls = 0 + local off = api.hooks:wrap("ui.party.grid_navigation", function() + calls = calls + 1 + return true + end) + if mode[1] >= 3 then + local outer = api.hooks:wrap("ui.party.grid_navigation", function(next) + next() + return next() + end, 100) + T.eq(f.grid(), true, "API 3 retains grid decision") + T.eq(calls, 1, "API 3 repeated next is cached") + outer() + end + f.count(5) + f.index(1) + menu.submenu, menu.subItems, menu.subIndex = + { { action = "CANCEL" }, { action = "CANCEL" } }, { "A", "B" }, 1 + local before = calls + f.press("down") + T.eq(f.index(), 1, "submenu owns cursor input") + T.eq(menu.subIndex, 2, "submenu uses its own navigation") + T.eq(calls, before, "submenu bypasses grid predicate") + menu.submenu = nil + local mon = game.save.party[1] + menu.heal = { mon = mon, shown = 0 } + before = calls + f.press("right") + T.eq(f.index(), 1, "healing animation owns input") + T.eq(calls, before, "healing bypasses grid predicate") + menu.heal = nil + if f.generation == 3 then + menu.noInput = true + f.press("right") + T.eq(f.index(), 1, "scripted no-input screen retains cursor") + T.eq(calls, before, "no-input bypasses predicate") + menu.noInput = false + local record = game.data.constants.gen3PartyMenu + record.slots[2].icon.y, record.slots[3].icon.y = 40, 80 + T.eq(f.press("right"), 2, "imported sprite coordinates determine spatial peer") + game.data.constants.gen3PartyMenu = nil + f.index(1) + T.eq(f.press("right"), 3, "old-cache fallback uses rendered panel positions") + game.data.constants.gen3PartyMenu = record + f.count(0) + f.index("cancel") + T.eq(f.press("up"), "cancel", "empty GBA party retains cancel") + else + f.count(0) + f.index(1) + T.eq(f.press("right"), 1, "empty GB party retains cursor") + end + f.count(6) + f.index(1) + local confirm, cancel = 0, 0 + local oldConfirm, oldCancel = menu.confirm, menu.cancel + menu.confirm = function() + confirm = confirm + 1 + end + menu.cancel = function() + cancel = cancel + 1 + end + f.press("a") + f.press("b") + T.eq(confirm, 1, "native confirm handler remains reachable") + T.eq(cancel, 1, "native cancel handler remains reachable") + f.press({ right = true, a = true }) + T.eq(confirm, f.generation == 3 and 2 or 1, "combined input retains generation-specific priority") + menu.confirm, menu.cancel = oldConfirm, oldCancel + off() + f.close() + end + else + T.eq( + require("src.mods.Capabilities").supports("hooks", "ui.party.grid_navigation", version), + false, + "DS has no shared party producer yet" + ) + end +end +Version.set(old) +T.finish("portable ui.party.grid_navigation native controllers") diff --git a/tests/engine/performance_low_budget.lua b/tests/engine/performance_low_budget.lua new file mode 100644 index 000000000..067059d77 --- /dev/null +++ b/tests/engine/performance_low_budget.lua @@ -0,0 +1,165 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +love = require("tests.love_stub") +local P = require("src.core.Performance") +local originalLove, originalJit = love, jit +local function device(os, arch, cores, w, h) + love = { + system = { + getOS = function() + return os + end, + getProcessorCount = function() + return cores + end, + }, + graphics = { + getPixelDimensions = function() + return w, h + end, + }, + } + jit = { arch = arch } +end +device("OS X", "arm64", 8, 3024, 1964) +T.eq(P.detect(), "high", "desktop ARM keeps native Retina policy") +device("Windows", "x64", 8, 3840, 2160) +T.eq(P.detect(), "balanced", "4K desktop fill receives a budget") +device("Android", "arm64", 8, 2560, 1440) +T.eq(P.detect(), "low", "large physical mobile frame receives LOW") +P.lowTierBudget = false +T.eq(P.detect(), "balanced", "rollback restores old mobile AUTO") +P.lowTierBudget = true +device("Windows", "x64", 2, 1920, 1080) +T.eq(P.detect(), "low", "large dual-core display receives LOW") +device("Windows", "x64", 2, 640, 480) +T.eq(P.detect(), "balanced", "small dual-core display keeps previous tier") +device("OS X", "arm64", 8, 0 / 0, 480) +T.eq(P.detect(), "high", "invalid pixel dimensions fall back") +love, jit = originalLove, originalJit +P.tier = "low" +for _, key in ipairs({ "shaderPresets", "colorCorrection", "reflections", "discordPresence" }) do + T.eq(P.extraCap(key), false, "LOW applies " .. key) +end +for tick = 0, 31 do + T.eq(P.paletteTick(tick), math.floor(tick / 4) * 4, "quarter palette holds sampled tick") +end +P.lowTierBudget = false +T.eq(P.extraCap("reflections"), nil, "rollback removes added live caps") +T.eq(P.paletteTick(7), 7, "rollback restores full palette ticks") +P.lowTierBudget = true +local Presets = require("src.render.ShaderPresets").new() +local input = {} +Presets.shader = {} +Presets.entry = { id = "owned" } +T.eq(Presets:active(), false, "LOW masks shader activity") +T.eq(Presets:apply(input, 1), input, "LOW returns untouched preset input") +T.eq(Presets.entry.id, "owned", "mask keeps selected preset") +P.tier = "high" +T.check(Presets:active(), "raising tier restores selected shader") +local C = require("src.render.GbcColorCorrection") +C.setMode("accurate") +P.tier = "low" +T.eq(C.active(), false, "LOW avoids correction resources") +T.eq(C.apply(input), input, "LOW returns original correction input") +T.eq(C.mode, "accurate", "LOW keeps selected correction mode") +local TR = require("src.render.TileRenderer") +local ticks, recolors = {}, 0 +local tiles = { + primary = { paletteCycles = {} }, + setPaletteTicks = function(_, tick) + ticks[#ticks + 1] = tick + return true + end, +} +local renderer = + setmetatable({ gen3 = { + tiles = tiles, + recolor = function() + recolors = recolors + 1 + end, + } }, TR) +for _ = 1, 12 do + TR.tick(1 / 60) + renderer:syncGen3Palettes() +end +T.same(ticks, { 0, 4, 8, 12 }, "actual sheet refresh samples held native clock without slowing it") +T.eq(recolors, 4, "quarter refresh does not recolor every tick") +T.eq(TR.animFrame(), 12, "native tile animation clock is unchanged") +P.tier = "high" +TR.tick(1 / 60) +renderer:syncGen3Palettes() +T.eq(ticks[#ticks], 13, "raising tier resumes current full-rate palette") +P.tier = "low" +P.lowTierBudget = false +TR.tick(1 / 60) +renderer:syncGen3Palettes() +T.eq(ticks[#ticks], 14, "rollback restores full palette refresh at LOW") +P.lowTierBudget = true +local Version = require("src.core.GameVersion") +Version.set("emerald") +local f = require("tests.world_draw.fixture").new(8) +P.tier = "high" +f.world:drawWorld() +local high = f.trace() +f.reset() +P.tier = "low" +f.world:drawWorld() +local function essentials(trace) + local rows = {} + for _, row in ipairs(trace) do + if not row:match("^reflection:") and not row:match("^drawReflectionCover:") then + rows[#rows + 1] = row + end + end + return rows +end +T.same(essentials(f.trace()), essentials(high), "LOW preserves entity, terrain, ripple and FX order/coordinates") +for _, row in ipairs(f.trace()) do + T.check(not row:match("^reflection:"), "LOW skips native actor reflections") +end +f.reset() +P.tier = "high" +f.world:drawWorld() +T.same(f.trace(), high, "raising tier restores full world reflection trace") +local Discord = require("src.core.DiscordPresence") +local state +for i = 1, 100 do + local name, value = debug.getupvalue(Discord.update, i) + if name == "state" then + state = value + break + end +end +T.check(state ~= nil, "presence state seam exists") +state.enabled = true +state.connected = true +state.socket = nil +P.tier = "low" +Discord.update(0) +T.eq(state.connected, false, "LOW retires connected IPC without polling") +local allocations, viewports = 0, {} +love.graphics.newQuad = function(x, y, w, h, iw, ih) + allocations = allocations + 1 + local q = {} + function q:setViewport(...) + self.viewport = { ... } + viewports[#viewports + 1] = self.viewport + end + q:setViewport(x, y, w, h, iw, ih) + return q +end +local Ui = require("src.render.UiQuad") +local a, b = {}, {} +local qa = Ui.get(a, "slot", nil, 0, 0, 8, 8, 64, 64) +T.eq(Ui.get(a, "slot", nil, 8, 0, 16, 8, 128, 64), qa, "owner slot reuses its quad") +T.same(qa.viewport, { 8, 0, 16, 8, 128, 64 }, "viewport tracks geometry and source extent") +T.check(Ui.get(b, "slot", nil, 0, 0, 8, 8, 64, 64) ~= qa, "owners do not share scratch quads") +T.check(Ui.get(a, "other", nil, 0, 0, 8, 8, 64, 64) ~= qa, "draw sites do not share scratch quads") +T.eq(allocations, 3, "stable owner/site has bounded quad allocation") +P.lowTierBudget = false +T.check(Ui.get(a, "slot", nil, 0, 0, 8, 8, 64, 64) ~= qa, "rollback restores fresh native quads") +P.lowTierBudget = true +P.tier = "high" +T.finish("LOW presentation caps, pixel policy and private UI quads") diff --git a/tests/engine/performance_tiers.lua b/tests/engine/performance_tiers.lua index befdeb2fd..14591e0c5 100644 --- a/tests/engine/performance_tiers.lua +++ b/tests/engine/performance_tiers.lua @@ -42,6 +42,17 @@ T.eq(Performance.detect(), "balanced", "iOS -> balanced") device("OS X", "x64", 8) T.eq(Performance.detect(), "high", "8-core desktop -> high") +device("OS X", "arm64", 8) +T.eq(Performance.detect(), "high", "Apple Silicon is a desktop, not an ARM handheld") +T.eq(Performance.resolve("auto"), "high", "Apple Silicon AUTO avoids the handheld cap") +Performance.applyOptions({ performance = "auto" }) +T.eq(Performance.tier, "high", "Apple Silicon publishes the resolved desktop tier") +T.eq(Performance.resolve("low"), "low", "explicit low remains selectable on Apple Silicon") +T.eq(Performance.resolve("balanced"), "balanced", "explicit balanced remains selectable on Apple Silicon") + +device("OS X", "arm64", 2) +T.eq(Performance.detect(), "balanced", "Apple Silicon retains the desktop core-count floor") + device("Windows", "x64", 2) T.eq(Performance.detect(), "balanced", "dual-core desktop -> balanced") 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/screen_space_ui.lua b/tests/engine/screen_space_ui.lua new file mode 100644 index 000000000..bae8bc2cc --- /dev/null +++ b/tests/engine/screen_space_ui.lua @@ -0,0 +1,39 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local UI=require('src.render.ScreenSpaceUI') +local stack=setmetatable({},{__index=require('src.core.StateStack')});stack:init() +local scene={states={}} +T.eq(UI.select(stack,scene,1),nil,'ordinary empty frame requests no window-resolution UI') +local trace={} +local parent={drawsWidescreen=function()return true end,drawWidescreen=function(_,w,h) + trace[#trace+1]='wide';T.eq(w,800,'window width reaches the advertised drawer');T.eq(h,720,'window height reaches the advertised drawer') + love.graphics.setColor(1,0,0,1) +end} +local overlay={draw=function()end} +scene.states={parent,overlay} +local owner,index=UI.select(stack,scene,1) +T.eq(owner,parent,'native overlay retains the visible wide parent') +T.eq(index,1,'wide owner position is preserved for overlay separation') +parent.drawsWidescreen=function()return false end +T.eq(UI.select(stack,scene,1),nil,'drawer alone does not opt in') +parent.drawsWidescreen=function()return true end +T.eq(UI.select(stack,scene,2),nil,'hidden below draw boundary is not resurrected') +local renderer={canvas={},WIDTH=160,HEIGHT=144,uiPresentation={x=0,y=0,scaleX=5,scaleY=5}} +function renderer:uiSize()return 160,144 end +function renderer:blitCanvas(canvas,sx,sy) + trace[#trace+1]='overlay';T.eq(canvas,self.canvas,'overlay reuses native canvas');T.eq(sx,5,'native overlay horizontal scale');T.eq(sy,5,'native overlay vertical scale') +end +local viewport={width=800,height=720,gameX=0,gameY=0,scale=5,dpiX=1,dpiY=1} +love.graphics.setColor(.2,.3,.4,1) +UI.draw(parent,renderer,viewport,nil,true) +T.same(trace,{'wide','overlay'},'native prompt is composited after its window-resolution parent') +local r,g,b=love.graphics.getColor();T.eq(r,.2,'drawer graphics state restored');T.eq(g,.3,'drawer cannot leak color');T.eq(b,.4,'overlay graphics state restored') +trace={};UI.draw(parent,renderer,viewport,nil,false) +T.same(trace,{'wide'},'frame without overlay does not duplicate the cartridge canvas') +parent.drawWidescreen=function()love.graphics.setColor(0,0,0,1);error('deliberate wide drawer failure')end +T.raises(function()UI.draw(parent,renderer,viewport,nil,false)end,'deliberate wide drawer failure','drawer errors remain observable') +T.eq(select(1,love.graphics.getColor()),.2,'failed drawer still restores graphics state') +local drew=0;UI.classic(function()drew=drew+1 end,renderer,800,720) +T.eq(drew,1,'classic fallback executes native draw exactly once') +T.finish('screen-space UI owner, native overlay ordering and graphics isolation') 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/shader_graph.lua b/tests/engine/shader_graph.lua new file mode 100644 index 000000000..768e4af06 --- /dev/null +++ b/tests/engine/shader_graph.lua @@ -0,0 +1,304 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Graph = require("src.render.ShaderGraph") +local Presets = require("src.render.ShaderPresets") +local Json = require("src.link.Json") +local function fixture() + local spec = { + format = 3, + name = "GRAPH", + parameters = { { id = "gain", name = "GAIN", min = 0, max = 1, step = 0.1, default = 0.5 } }, + passes = {}, + textures = { { id = "Tint", image = "t.png", filter = "linear", wrap = "repeat" } }, + } + for i = 1, 3 do + spec.passes[i] = { + shader = "f.glsl", + vertex = "v.glsl", + esShader = "ef.glsl", + esVertex = "ev.glsl", + filter = "nearest", + wrap = "clamp", + scaleX = { type = i == 3 and "viewport" or "source", value = i == 2 and 2 or 1 }, + scaleY = { type = i == 3 and "viewport" or "original", value = 1 }, + } + end + local files = { + ["f.glsl"] = "f", + ["v.glsl"] = "v", + ["ef.glsl"] = "ef", + ["ev.glsl"] = "ev", + ["t.png"] = "\137PNG\13\10\26\10\0\0\0\13IHDR\0\0\0\2\0\0\0\1\8\6" .. string.rep("\0", 7), + } + local objects, faults, uniforms, compiled = {}, {}, {}, {} + local current, stack, draws = nil, 0, 0 + local function resource(w, h, dpi) + local o = { w = w, h = h, dpi = dpi or 1 } + objects[#objects + 1] = o + function o:getWidth() + return self.w + end + function o:getHeight() + return self.h + end + function o:getDimensions() + return self.w, self.h + end + function o:getPixelDimensions() + return self.w * self.dpi, self.h * self.dpi + end + function o:getDPIScale() + return self.dpi + end + function o:setFilter(...) + self.filter = { ... } + end + function o:setWrap(...) + self.wrap = { ... } + return not faults.wrap + end + function o:release() + T.check(not self.released, "release once") + self.released = true + end + function o:newImageData() + if faults.readback then + error("readback") + end + return resource(0, 0) + end + function o:hasUniform() + return true + end + function o:send(k, v) + if faults.send then + error("uniform") + end + self.uniforms = self.uniforms or {} + self.uniforms[k] = v + uniforms[k] = v + end + return o + end + local g = { + getRendererInfo = function() + return faults.es and "OpenGL ES" or "OpenGL", "2.0" + end, + getSystemLimits = function() + return { texturesize = 8192 } + end, + validateShader = function(es, f, v) + compiled[#compiled + 1] = { es, f, v } + return not faults.validate, "bad dialect" + end, + newShader = function(f, v) + if faults.compile then + error("compile") + end + local o = resource(0, 0) + o.pair = { f, v } + return o + end, + newCanvas = function(w, h, opts) + if faults.allocate then + error("allocation") + end + return resource(w, h, opts.dpiscale) + end, + newImage = function() + if faults.decode then + error("decode") + end + return resource(2, 1) + end, + getCanvas = function() + return current + end, + setCanvas = function(c) + current = c + end, + push = function() + stack = stack + 1 + end, + pop = function() + stack = stack - 1 + end, + draw = function(image) + if faults.draw then + error("draw") + end + T.neq(image, current, "no render feedback") + draws = draws + 1 + end, + } + for _, k in ipairs({ + "origin", + "setScissor", + "setStencilTest", + "setDepthMode", + "setColorMask", + "setColor", + "setBlendMode", + "setShader", + "clear", + }) do + g[k] = function() end + end + local fs = { + getDirectoryItems = function() + return { "graph.json" } + end, + getInfo = function(path) + local name = path:match("[^/]+$") + local text = name == "graph.json" and Json.encode(spec) or files[name] + return text and { type = "file", size = #text } + end, + read = function(path) + local name = path:match("[^/]+$") + return name == "graph.json" and Json.encode(spec) or files[name] + end, + } + return Presets.new({ + graphics = g, + fs = fs, + fileData = function() + return resource(0, 0) + end, + }), + spec, + files, + faults, + uniforms, + objects, + resource, + g, + function() + return stack, current, draws + end, + compiled +end +for i, mutate in ipairs({ + function(s) + s.passes = {} + end, + function(s) + for n = 4, 9 do + s.passes[n] = s.passes[1] + end + end, + function(s) + s.passes[1].shader = "../bad.glsl" + end, + function(s) + s.passes[1].filter = "cubic" + end, + function(s) + s.passes[1].wrap = "bad" + end, + function(s) + s.passes[1].scaleX.value = 9 + end, + function(s) + s.passes[1].scaleY = { type = "absolute", value = 1.5 } + end, + function(s) + s.passes[1].scaleX = { type = "bad", value = 1 } + end, + function(s) + s.passes[1].scaleX.extra = true + end, + function(s) + s.passes[1].extra = true + end, + function(s) + s.textures[1].image = "../t.png" + end, + function(s) + s.textures[1].filter = "cubic" + end, + function(s) + s.textures[1].id = "bad id" + end, + function(s) + s.textures[1].extra = true + end, + function(s) + s.parameters[1].id = "g2_Lut0" + end, +}) do + local p, s = fixture() + mutate(s) + T.check(not p:activate("local:graph"), "invalid graph refused " .. i) +end +for _, fault in ipairs({ "validate", "compile", "allocate", "wrap", "decode", "send", "readback", "draw" }) do + local p, s, files, faults, u, objects = fixture() + T.check(p:activate("builtin:warm"), "working fallback") + local old = p.shader + faults[fault] = true + T.check(not p:activate("local:graph"), fault .. " preflight refuses") + T.eq(p.shader, old, "old preserved") + T.check(p:active(), "still active") + for _, o in ipairs(objects) do + if o ~= old then + T.check(o.released, "failed candidate releases partial resources") + end + end + p:off() +end +do + local p, s, files, f, u, objects, resource, g, state, compiled = fixture() + f.es = true + T.check(p:activate("local:graph"), "activate graph") + T.eq(#compiled, 6, "both dialects every pass") + T.same(p.graph.shaders[1].pair, { "ef", "ev" }, "current dialect pair") + local input = resource(40, 30, 2) + local sentinel = resource(1, 1) + g.setCanvas(sentinel) + local output = p:apply(input, 5) + T.neq(output, input, "graph output") + T.eq(output:getDPIScale(), 2, "DPI retained") + T.same(u.g2_OriginalSize, { 16, 12, 1 / 16, 1 / 12 }, "original native grid") + T.same(u.g2_SourceSize, { 32, 12, 1 / 32, 1 / 12 }, "source prior pass grid") + T.same(u.g2_Pass0Size, { 16, 12, 1 / 16, 1 / 12 }, "earlier grid") + T.same(u.g2_Lut0Size, { 2, 1, 0.5, 1 }, "LUT dimensions") + T.same(u.g2_OutputSize, { 80, 60, 1 / 80, 1 / 60 }, "viewport physical grid") + T.eq(u.g2_Original, p.graph.original, "original sampler") + T.eq(u.g2_Pass0, p.graph.targets[1], "earlier sampler") + T.same(p.graph.images[1].filter, { "linear", "linear" }, "LUT filter retained") + T.same(p.graph.images[1].wrap, { "repeat", "repeat" }, "LUT wrap retained") + T.eq(input.filter, nil, "borrowed filter untouched") + local stack, current = state() + T.eq(stack, 0, "balanced state") + T.eq(current, sentinel, "canvas restored") + local n = #objects + p:apply(input, 5) + T.eq(#objects, n, "stable graph reuses resources") + T.eq(p:apply(output, 5), output, "output feedback guard") + local old = p.graph.original + p:apply(input, 2.5) + T.check(old.released, "scale retires grid") + local opts = {} + p:setParameter(opts, "gain", 0.8) + p:apply(input, 2.5) + T.eq(u.g2_p_gain, 0.8, "live parameter shared across passes") + f.draw = true + T.eq(p:apply(input, 2.5), input, "render failure identity") + T.check(not p:active(), "render failure disables graph") + T.check(not input.released, "input owned by caller") + stack, current = state() + T.eq(stack, 0, "failure balances state") + T.eq(current, sentinel, "failure restores canvas") +end +do + local p, s, files, f, u, objects, resource = fixture() + T.check(p:activate("local:graph"), "budget candidate") + local input = resource(8192, 8192) + T.eq(p:apply(input, 1), input, "pixel budget identity") + T.check(not p:active(), "over budget retired") +end +for _, bad in ipairs({ "not png", "\137PNG\13\10\26\10" .. string.rep("\0", 30) }) do + local p, s, files = fixture() + files["t.png"] = bad + T.check(not p:activate("local:graph"), "PNG header checked before decoder") +end +T.finish("shader graph") diff --git a/tests/engine/shader_presets.lua b/tests/engine/shader_presets.lua new file mode 100644 index 000000000..bada90583 --- /dev/null +++ b/tests/engine/shader_presets.lua @@ -0,0 +1,436 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Presets = require("src.render.ShaderPresets") +local Json = require("src.link.Json") +local Menu = require("src.ui.ShaderPresetMenu") +local SaveData = require("src.core.SaveData") +local GenOptions = require("src.core.GenOptions") +local StateStack = require("src.core.StateStack") +local source = + "extern number amount; vec4 effect(vec4 c, Image t, vec2 u, vec2 p) { return Texel(t,u)*amount*c; }" +local metadata = { + format = 1, + name = "LOCAL EFFECT", + shader = "probe.glsl", + parameters = { + { id = "amount", name = "AMOUNT", min = 0, max = 1, step = 0.1, default = 0.5 }, + }, +} +local function clone(t) + if type(t) ~= "table" then + return t + end + local out = {} + for k, v in pairs(t) do + out[k] = clone(v) + end + return out +end +local function fixture() + local files = + { ["shader-presets/probe.json"] = Json.encode(metadata), ["shader-presets/probe.glsl"] = source } + local reads = 0 + local fs = { + getDirectoryItems = function() + return { "probe.json", "ignored.lua", "../escape.json" } + end, + getInfo = function(p) + if files[p] then + return { type = "file", size = #files[p] } + end + end, + read = function(p) + reads = reads + 1 + return files[p] + end, + } + local state = + { canvas = "original", shader = "old", color = "old", blend = "old", depth = "old", transform = "old" } + local stack = {} + local count = { shader = 0, canvas = 0, released = 0, draw = 0 } + local faults = {} + local function texture(w, h, dpi) + local t = { w = w, h = h, dpi = dpi or 1 } + function t:getDimensions() + return self.w, self.h + end + function t:getWidth() + return self.w + end + function t:getHeight() + return self.h + end + function t:getDPIScale() + return self.dpi + end + function t:getPixelDimensions() + return self.w * self.dpi, self.h * self.dpi + end + function t:setFilter() end + function t:release() + T.check(not self.released, "GPU object released once") + self.released = true + count.released = count.released + 1 + end + return t + end + local g = { + newShader = function(s) + count.shader = count.shader + 1 + if faults.compile then + error("compile failed") + end + local sh = { uniforms = {} } + function sh:hasUniform(id) + return not faults.uniform and s:find(id, 1, true) ~= nil + end + function sh:send(id, v) + if faults.send then + error("uniform failed") + end + self.uniforms[id] = v + end + function sh:release() + T.check(not self.released, "shader released once") + self.released = true + count.released = count.released + 1 + end + return sh + end, + newCanvas = function(w, h, opts) + if faults.canvas then + error("allocation failed") + end + count.canvas = count.canvas + 1 + return texture(w, h, opts.dpiscale) + end, + push = function() + if faults.push then + error("stack full") + end + stack[#stack + 1] = clone(state) + end, + pop = function() + state = table.remove(stack) + end, + setCanvas = function(v) + state.canvas = v + end, + setShader = function(v) + state.shader = v + end, + setColor = function() + state.color = "changed" + end, + setBlendMode = function() + state.blend = "changed" + end, + origin = function() + state.transform = "changed" + end, + setScissor = function() + state.scissor = "changed" + end, + setStencilTest = function() + state.stencil = "changed" + end, + setDepthMode = function() + state.depth = "changed" + end, + setColorMask = function() + state.mask = "changed" + end, + draw = function(t) + count.draw = count.draw + 1 + T.check(not t.released, "input remains valid") + if faults.draw then + error("draw failed") + end + end, + } + local p = Presets.new({ fs = fs, graphics = g }) + return p, + files, + fs, + g, + faults, + count, + texture, + function() + return reads + end, + function() + return state, #stack + end +end +-- OFF is a no-I/O/no-GPU identity path, including old save files. +do + local p, files, fs, g, faults, count, texture, reads = fixture() + local input = texture(10, 8) + T.check(p:applyOptions({}), "old options accepted") + T.eq(p:apply(input), input, "OFF exact identity") + T.eq(count.shader, 0, "OFF never compiles") + T.eq(count.canvas, 0, "OFF never allocates") + T.eq(reads(), 0, "OFF never reads files") + T.eq(p.scanned, nil, "OFF does not enumerate") + T.check(p:applyOptions({ shaderPreset = "off" }), "explicit OFF accepted") +end +-- Real catalogue: bundled records, local JSON, isolated errors, no Lua loader. +do + local p, files = fixture() + local records = p:refresh() + T.eq(#records, 3, "two builtins and one local preset") + T.eq(records[3].id, "local:probe", "stable namespace") + T.eq(records[3].parameters[1].default, 0.5, "parameters copied") + files["shader-presets/probe.json"] = "not JSON" + p:refresh() + T.check(p.byId["local:probe"].error, "malformed metadata shown as invalid") + for _, field in ipairs({ "shader", "name", "format", "parameters" }) do + local raw = clone(metadata) + raw[field] = ({ shader = "../escape.glsl", name = string.rep("a", 49), format = 2, parameters = "bad" })[field] + files["shader-presets/probe.json"] = Json.encode(raw) + p:refresh() + T.check(p.byId["local:probe"].error, "invalid " .. field) + end + for _, change in ipairs({ + { id = "sourceSize" }, + { id = "love_bad" }, + { step = 0 }, + { step = 1e-300 }, + { max = 0 }, + { min = -1e308, max = 1e308 }, + { default = 2 }, + }) do + local raw = clone(metadata) + for k, v in pairs(change) do + raw.parameters[1][k] = v + end + files["shader-presets/probe.json"] = Json.encode(raw) + p:refresh() + T.check(p.byId["local:probe"].error, "invalid parameter definition") + end +end +-- An off-grid metadata default is normalized once, so reset and first use +-- agree instead of silently producing different uniform values. +do + local p, files = fixture() + local raw = clone(metadata) + raw.parameters[1].default = 0.35 + raw.parameters[1].step = 0.2 + files["shader-presets/probe.json"] = Json.encode(raw) + p:activate("local:probe") + T.eq(p.values.amount, 0.4, "default snapped to step grid") + local menu = Menu.parameters({ save = { options = {} } }, p) + p:setParameter({}, "amount", 1) + menu.rows[1].reset() + T.eq(p.values.amount, 0.4, "reset agrees with normalized default") +end +-- Parameter normalization, saved identity, graphics state and target lifetime. +do + local p, files, fs, g, faults, count, texture, reads, state = fixture() + local opts = { shaderPresetParams = { ["local:probe"] = { amount = 0.72, unknown = 5 } } } + T.check(p:select(opts, "local:probe"), "local selection compiles") + T.eq(opts.shaderPreset, "local:probe", "selected ID persisted") + T.check(math.abs(p.values.amount - 0.7) < 1e-9, "restored value snaps to step") + local input = texture(100, 75, 2) + local out = p:apply(input, 6) + T.neq(out, input, "active effect allocates output") + T.eq(out:getDPIScale(), 2, "DPI preserved") + T.eq(out:getWidth(), 100, "units preserved") + T.eq(count.canvas, 1, "one allocation") + T.eq(p:apply(input, 6), out, "target reused") + T.same( + state(), + { canvas = "original", shader = "old", color = "old", blend = "old", depth = "old", transform = "old" }, + "graphics state restored" + ) + local _, depth = state() + T.eq(depth, 0, "graphics stack balanced") + p:setParameter(opts, "amount", 0.21) + p:apply(input, 6) + T.check(math.abs(p.shader.uniforms.amount - 0.2) < 1e-9, "new parameter sent") + T.eq(count.shader, 1, "parameter edit never recompiles") + T.eq(p:setParameter(opts, "missing", 1), nil, "unknown parameter refused") + p:setParameter(opts, "amount", math.huge) + T.eq(p.values.amount, 0.5, "non-finite parameter default") + p:setParameter(opts, "amount", -3) + T.eq(p.values.amount, 0, "parameter clamp") + local resized = p:apply(texture(100, 75, 1), 3) + T.check(out.released, "DPI change releases old target") + T.eq(resized:getDPIScale(), 1, "DPI change reallocates") + local released = count.released + p:select(opts, "off") + T.eq(count.released, released + 2, "OFF releases shader and canvas") + T.eq(p:active(), false, "OFF inactive") + T.eq(opts.shaderPresetParams["local:probe"].amount, 0, "OFF retains parameter settings") +end +for _, kind in ipairs({ "compile", "uniform", "send", "canvas", "push", "draw" }) do + local p, files, fs, g, faults, count, texture, reads, state = fixture() + local opts = { shaderPreset = "off" } + local startup = kind == "compile" or kind == "uniform" or kind == "send" + if startup then + faults[kind] = true + end + local selected = p:select(opts, "local:probe") + if startup then + T.eq(selected, nil, kind .. " rejected") + T.eq(opts.shaderPreset, "off", kind .. " not saved") + T.eq(p:active(), false, kind .. " stays OFF") + else + T.check(selected, kind .. " setup") + faults[kind] = true + local input = texture(10, 8) + T.eq(p:apply(input, 1), input, kind .. " exact fallback") + T.eq(p:active(), false, kind .. " retired") + local _, depth = state() + T.eq(depth, 0, kind .. " balanced stack") + T.eq(p:apply(input, 1), input, kind .. " no repeated failing work") + end + T.check(p.error and p.error ~= "", kind .. " records error") + faults[kind] = nil + T.check(p:select(opts, "local:probe"), kind .. " explicit retry") +end +-- Failed switches preserve the last working effect; missing saved files fall +-- back to OFF without changing a stored choice or touching gameplay data. +do + local p, files, fs, g, faults, count, texture = fixture() + local opts = { shaderPreset = "builtin:warm" } + T.check(p:applyOptions(opts), "working saved preset") + local sh = p.shader + faults.compile = true + T.eq(p:select(opts, "local:probe"), nil, "broken switch rejected") + T.eq(p.shader, sh, "working shader retained") + T.eq(opts.shaderPreset, "builtin:warm", "working saved choice retained") + faults.compile = nil + opts.shaderPreset = "local:deleted" + local before = clone(opts) + T.eq(p:applyOptions(opts), nil, "missing saved preset refused") + T.eq(p:active(), false, "missing saved preset clears previous game shader") + T.same(opts, before, "restore does not rewrite options") + files["shader-presets/probe.glsl"] = string.rep("x", 65537) + T.eq(p:activate("local:probe"), nil, "oversized source refused") +end +-- Selection, parameter controls/reset, error page, scrolling and back use the +-- real state stack; controls bypass the selected shader so they stay readable. +do + local p = fixture() + local key, writes = nil, 0 + local game = { + save = { options = {} }, + stack = setmetatable({ states = {} }, { __index = StateStack }), + input = { + wasPressed = function(_, k) + return k == key + end, + }, + writeOptions = function() + writes = writes + 1 + end, + } + local menu = Menu.new(game, p) + game.stack:push(menu) + T.eq(menu.shaderPresetBypass, true, "shader picker readable") + local function press(k) + key = k + game.stack:top():update() + key = nil + end + press("down") + press("a") + T.eq(game.save.options.shaderPreset, "builtin:warm", "A selects preset") + press("select") + local params = game.stack:top() + T.eq(params.isParameters, true, "SELECT opens parameters") + press("right") + T.check(p.values.strength > 0.4, "RIGHT edits") + press("select") + T.eq(p.values.strength, 0.4, "SELECT resets parameter") + press("b") + T.eq(game.stack:top(), menu, "back from parameters") + press("up") + press("a") + T.eq(game.save.options.shaderPreset, "off", "OFF selection") + press("up") + press("a") + T.eq(game.stack:top(), nil, "fixed BACK row exits") + T.check(writes >= 4, "changes persist immediately") + local msg = Menu.message(game, string.rep("long compile error ", 30)) + T.eq(#msg.rows, 4, "error page bounded") + for _, row in ipairs(msg.rows) do + T.check(#row.label <= 17 and #row.value() <= 17, "error text fits") + end +end +-- Normal save serialization and per-generation option folds preserve both keys. +do + local options = + { shaderPreset = "local:probe", shaderPresetParams = { ["local:probe"] = { amount = 0.8 } } } + local round = SaveData.mergeOptions(SaveData.decode(SaveData.encode(options))) + T.same(round.shaderPresetParams, options.shaderPresetParams, "parameter save round trip") + local base = { shaderPreset = "off", shaderPresetParams = {}, perGeneration = { [2] = options } } + local resolved = GenOptions.resolve(base, 2) + T.eq(resolved.shaderPreset, "local:probe", "generation-specific preset") + resolved.shaderPreset = "builtin:warm" + local folded = GenOptions.fold(base, resolved, 2) + T.eq(folded.shaderPreset, "off", "shared setting unchanged") + T.eq(folded.perGeneration[2].shaderPreset, "builtin:warm", "scoped write retained") +end +-- Options routing and emergency reset through the actual game entry point. +do + local Game = require("src.core.Game") + local rows = require("src.ui.OptionsMenu").buildRows({ save = { options = {} } }) + local row + for _, r in ipairs(rows) do + if r.id == "shaderPreset" then + row = r + end + end + T.check(row and row.activate, "shared shader options row") + Presets.entry = { name = string.rep("X", 48) } + T.check(128 + #row.value({}) * 8 <= 232, "preset value fits GBA value column with margin") + Presets.entry = nil + T.eq(require("src.ui.OptionsMenu").shaderPresetBypass, true, "GB options bypass") + T.eq(require("src.ui.Gen3Options").shaderPresetBypass, true, "GBA options bypass") + T.eq(require("src.ui.Gen4Options").shaderPresetBypass, true, "DS options bypass") + for _, spec in ipairs({ { "emerald", "src.ui.Gen3Options" }, { "platinum", "src.ui.Gen4Options" } }) do + local GV = require("src.core.GameVersion") + local before = GV.get() + GV.set(spec[1]) + local screen = require(spec[2]).new({ data = { constants = {} }, save = { options = {} } }) + local found = false + for _, r in ipairs(screen.rows) do + if r.key == "shaderPreset" and r.engine and r.engine.activate then + found = true + end + end + T.check(found, spec[1] .. " shader option reachable") + GV.set(before) + end + local writes = 0 + local game = { + save = { options = { shaderPreset = "local:bad" } }, + writeOptions = function() + writes = writes + 1 + end, + stack = { + top = function() + return { + onKeyPressed = function() + error("F8 was captured") + end, + } + end, + }, + } + Game.keypressed(game, "f8") + T.eq(game.save.options.shaderPreset, "off", "F8 saves OFF") + T.eq(writes, 1, "F8 writes once before screen capture") + local p = fixture() + p:activate("builtin:warm") + local menu = Menu.parameters(game, p) + p:off() + T.eq(menu.rows[1].value(), "OFF", "stale parameter page stays readable after reset") + menu.rows[1].step(game, 1) + menu.rows[1].reset() +end +Presets:off() +T.finish("shader presets") diff --git a/tests/engine/slang_presets.lua b/tests/engine/slang_presets.lua new file mode 100644 index 000000000..b21e39911 --- /dev/null +++ b/tests/engine/slang_presets.lua @@ -0,0 +1,234 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Json = require("src.link.Json") +local Presets = require("src.render.ShaderPresets") +local function fixture() + local meta = { + format = 2, + name = "IMPORTED", + shader = "f.glsl", + vertex = "v.glsl", + esShader = "ef.glsl", + esVertex = "ev.glsl", + filter = "linear", + wrap = "repeat", + parameters = { + { id = "gain", name = "GAIN", min = 0, max = 1, step = 0.1, default = 0.5 }, + { id = "unused", name = "UNUSED", min = 0, max = 1, step = 0.1, default = 0.5 }, + }, + } + local files = + { ["f.glsl"] = "fragment", ["v.glsl"] = "vertex", ["ef.glsl"] = "ES fragment", ["ev.glsl"] = "ES vertex" } + local fs = { + getDirectoryItems = function() + return { "test.json" } + end, + getInfo = function(path) + local name = path:match("[^/]+$") + local s = name == "test.json" and Json.encode(meta) or files[name] + if s then + return { type = "file", size = #s } + end + end, + read = function(path) + local name = path:match("[^/]+$") + return name == "test.json" and Json.encode(meta) or files[name] + end, + } + local objects, draws, stack, compiled = {}, {}, 0, {} + local current, faults = nil, {} + local function texture(w, h, dpi) + local o = { w = w, h = h, dpi = dpi or 1 } + objects[#objects + 1] = o + function o:getWidth() + return self.w + end + function o:getHeight() + return self.h + end + function o:getDimensions() + return self.w, self.h + end + function o:getDPIScale() + return self.dpi + end + function o:getPixelDimensions() + return self.w * self.dpi, self.h * self.dpi + end + function o:setFilter(a, b) + self.filter = { a, b } + end + function o:setWrap(a, b) + if faults.wrap then + error("unsupported wrap") + end + if faults.wrapFalse then + return false + end + self.wrap = { a, b } + return true + end + function o:release() + T.check(not self.released, "resource released once") + self.released = true + end + return o + end + local uniforms = {} + local g = { + getRendererInfo = function() + return faults.es and "OpenGL ES" or "OpenGL", "2.0" + end, + newShader = function(f, v) + if faults.compile then + error("bad vertex") + end + compiled[#compiled + 1] = { f, v } + local s = texture(0, 0) + function s:hasUniform(id) + return id ~= "g2_p_unused" + end + function s:send(id, value) + uniforms[id] = value + end + return s + end, + newCanvas = function(w, h, opts) + if faults.canvas then + error("allocation failed") + end + return texture(w, h, opts.dpiscale) + end, + push = function() + stack = stack + 1 + end, + pop = function() + stack = stack - 1 + end, + setCanvas = function(c) + current = c + end, + setShader = function() end, + origin = function() end, + setScissor = function() end, + setStencilTest = function() end, + setDepthMode = function() end, + setColorMask = function() end, + setBlendMode = function() end, + setColor = function() end, + draw = function(input, x, y, r, sx, sy) + if faults.draw then + error("draw failed") + end + T.check(input ~= current, "never samples current target") + draws[#draws + 1] = { input, sx, sy } + end, + } + return Presets.new({ fs = fs, graphics = g }), + meta, + files, + faults, + uniforms, + objects, + draws, + compiled, + texture, + function() + return stack + end +end +-- Both stages must exist; rejected choices preserve the current effect and options. +do + local p, meta, files, faults, uniforms, objects, draws, compiled = fixture() + T.check(p:activate("builtin:warm"), "activate fallback") + local old = p.shader + local opts = { shaderPreset = "builtin:warm" } + files["v.glsl"] = nil + T.check(not p:select(opts, "local:test"), "missing vertex rejected") + T.eq(p.shader, old, "missing vertex preserves working effect") + T.eq(opts.shaderPreset, "builtin:warm", "missing vertex preserves save") + files["v.glsl"] = "vertex" + faults.compile = true + T.check(not p:select(opts, "local:test"), "GPU compile failure rejected") + T.eq(p.shader, old, "compile failure preserves working effect") + faults.compile, faults.wrapFalse = nil, true + T.check(not p:select(opts, "local:test"), "GPU wrapping rejection aborts selection") + T.eq(p.shader, old, "unsupported wrapping preserves working effect") + T.eq(opts.shaderPreset, "builtin:warm", "unsupported wrapping preserves save") + faults.wrapFalse, faults.es = nil, true + T.check(p:select(opts, "local:test"), "GLES import selected") + T.same(compiled[#compiled], { "ES fragment", "ES vertex" }, "GLES selects matching pair") + T.eq(uniforms.g2_p_gain, 0.5, "parameter alias sent") + T.eq(uniforms.g2_p_unused, nil, "optimized-out parameter allowed") + T.check(p:setParameter(opts, "gain", 0.79), "public parameter ID") + T.check(math.abs(opts.shaderPresetParams["local:test"].gain - 0.8) < 0.001, "saved using public ID") + p:off() +end +-- Imported metadata cannot inject paths, filtering modes or reserved parameter names. +for _, change in ipairs({ + function(m) + m.vertex = "../escape.glsl" + end, + function(m) + m.esShader = false + end, + function(m) + m.esVertex = nil + end, + function(m) + m.filter = "cubic" + end, + function(m) + m.wrap = "unknown" + end, + function(m) + m.parameters[1].id = "g2_Source" + end, +}) do + local p, meta = fixture() + change(meta) + T.check(p:list()[3].error ~= nil, "malformed imported descriptor remains INVALID") + T.check(not p:activate("local:test"), "malformed imported descriptor cannot activate") +end +-- Physical input size, private filtering/wrapping, target lifetime and error recovery. +do + local p, meta, files, faults, uniforms, objects, draws, compiled, texture, stack = fixture() + local input = texture(80, 60, 2.75) + T.check(p:activate("local:test"), "desktop import selected") + T.same(compiled[1], { "fragment", "vertex" }, "desktop selects matching pair") + local output = p:apply(input, 11) + T.neq(output, input, "post-process returns output") + T.eq(output:getDPIScale(), 2.75, "DPI preserved") + T.same(uniforms.g2_SourceSize, { 20, 15, 1 / 20, 1 / 15 }, "physical native source size and reciprocals") + T.same(uniforms.g2_OriginalSize, uniforms.g2_SourceSize, "original is source for first pass") + T.same(uniforms.g2_OutputSize, { 220, 165, 1 / 220, 1 / 165 }, "physical output size and reciprocals") + T.same(uniforms.g2_FinalViewportSize, uniforms.g2_OutputSize, "single-pass final viewport") + local sampled = p.importInput + T.eq(uniforms.g2_Source, sampled, "source sampler receives private texture") + T.same(sampled.filter, { "linear", "linear" }, "preset filter only on private source") + T.same(sampled.wrap, { "repeat", "repeat" }, "preset wrap only on private source") + T.eq(input.filter, nil, "input texture untouched") + T.eq(p:apply(output, 11), output, "output feedback guarded") + T.eq(p:apply(sampled, 11), sampled, "private input feedback guarded") + local n = #objects + p:apply(input, 11) + T.eq(#objects, n, "stable dimensions reuse targets") + p:apply(input, 5.5) + T.check(sampled.released, "old native grid released on scale change") + T.eq(p.importInput:getWidth(), 40, "new native grid allocated") + faults.draw = true + T.eq(p:apply(input, 5.5), input, "draw error returns unfiltered input") + T.eq(stack(), 0, "draw error restores graphics stack") + T.check(not p:active() and p.importInput == nil, "draw error releases private resources") + T.check(not input.released, "caller input never released") +end +for _, fault in ipairs({ "wrap", "canvas" }) do + local p, meta, files, faults, uniforms, objects, draws, compiled, texture, stack = fixture() + T.check(p:activate("local:test"), "activate before " .. fault .. " failure") + faults[fault] = true + local input = texture(32, 24) + T.eq(p:apply(input, 2), input, fault .. " error returns input") + T.check(not p:active() and p.importInput == nil and p.output == nil, fault .. " error releases resources") + T.eq(stack(), 0, fault .. " error balances graphics stack") +end +T.finish("slang presets") 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/status_hud.lua b/tests/engine/status_hud.lua new file mode 100644 index 000000000..be15c53c8 --- /dev/null +++ b/tests/engine/status_hud.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/status_hud/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/status_hud/cases.lua").run(f) +f.close() +f.T.finish("portable battle.status_hud_visible") diff --git a/tests/engine/status_hud_composition.lua b/tests/engine/status_hud_composition.lua new file mode 100644 index 000000000..874d3d2e2 --- /dev/null +++ b/tests/engine/status_hud_composition.lua @@ -0,0 +1,138 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/status_hud/fixture.lua") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local Font = require("src.render.Font") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + for _, layout in ipairs({ "classic", "wide" }) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game = f.game + game.save = SaveData.newGame() + game.save.options.battleLayout = layout + game.save.party = { Pokemon.new(game.data, "FIXMON_A", 20) } + f.result.loader.game = game + Font.load(game.data) + local b = Battle.newWild(game, "FIXMON_C", 10) + b:syncSides() + b.phase, b.frame, b.queue = "messages", 0, {} + b.current, b.shown, b.fx = {}, {}, {} + b.showPlayerBack, b.showEnemyTrainer, b.enemySendingOut, b.introBalls = false, false, false, false + b.animPlaying = true + b.animPlayer = { stepIndex = 1, steps = { { sprites = {} } } } + game.stack.states = { b } + local marks = {} + local function mark(name) + marks[name] = (marks[name] or 0) + 1 + end + f.patch(b, "drawPicsLayer", function() + mark("actors") + end) + f.patch(b, "drawAnimLayer", function() + mark("animation") + end) + f.patch(b, "drawBattleField", function() + mark("field") + end) + f.patch(b, "colorMode", function() + return false + end) + local rectangle = love.graphics.rectangle + f.patch(love.graphics, "rectangle", function(mode, x, y, w, h, ...) + if mode == "fill" and w >= 160 and h >= 120 then + mark("field") + end + return rectangle(mode, x, y, w, h, ...) + end) + for _, name in ipairs({ "drawBox", "drawDialogueBox" }) do + f.patch(Font, name, function(tx, ty) + if ty >= 12 then + mark("bottom") + end + end) + end + f.patch(Font, "draw", function(_, _, y) + if type(y) == "number" and y < 104 then + mark("hud") + end + return 16 + end) + local G4 = require("src.battle.Gen4Battle") + f.patch(G4, "drawBattlers", function() + mark("actors") + end) + f.patch(G4, "drawParticles", function() + mark("animation") + end) + local function frame(hidden) + f.exports.hide(hidden) + marks = {} + b:draw() + local out = {} + for key, value in pairs(marks) do + out[key] = value + end + return out + end + local native, hidden = frame(false), frame(true) + T.check((native.bottom or 0) > 0, version .. "/" .. layout .. " native bottom backing exists") + T.eq(hidden.bottom, native.bottom, "status predicate preserves native bottom backing") + T.check((native.hud or 0) > 0, "native status text exists") + T.eq(hidden.hud or 0, 0, "actual compositor omits status text") + for _, part in ipairs({ "field", "actors" }) do + T.check((native[part] or 0) > 0, part .. " exists before hiding") + T.eq(hidden[part], native[part], part .. " dispatch unchanged when hiding status") + end + T.eq(hidden.animation, native.animation, "animation dispatch unchanged") + -- Palette-canvas branch uses the same status gate as the flat GB path. + if f.generation <= 2 and layout == "classic" then + b.colorMode = function() + return true + end + f.patch(b, "drawZonePass", function() + mark("palette") + end) + local visibleColor, hiddenColor = frame(false), frame(true) + T.check((visibleColor.bottom or 0) > 0, "palette compositor has native bottom") + T.eq(hiddenColor.bottom, visibleColor.bottom, "palette compositor preserves bottom") + T.check((visibleColor.hud or 0) > 0, "palette compositor has status text") + T.eq(hiddenColor.hud or 0, 0, "palette compositor hides status text") + T.eq(hiddenColor.palette, visibleColor.palette, "palette pass is preserved") + T.eq(hiddenColor.actors, visibleColor.actors, "palette actor pass is preserved") + end + -- Bottom UI and status predicates remain independent in both directions. + f.exports.hide(false) + local off = f.public.hooks:wrap("battle.bottom_ui_visible", function() + return false + end) + local bottomHidden = frame(false) + T.eq(bottomHidden.bottom or 0, 0, "bottom predicate independently hides menu") + T.eq(bottomHidden.hud, native.hud, "bottom predicate preserves native status") + off() + -- Exercise the real battle update with identical inputs in both modes. + b.animPlayer, b.animPlaying, b.current = nil, false, nil + b.phase, b.menuIndex, b.gen4CursorActive = "menu", 1, true + local function move(hiddenBottom) + f.exports.hide(hiddenBottom) + b.menuIndex, b.frame, b.gen4MenuColumn = 1, 0, nil + f.input:reset() + f.input:keypressed("down") + f.input:step() + local polls = f.exports.seen.calls + b:update(1 / 60) + T.eq(f.exports.seen.calls, polls, "native update never queries draw predicate") + return b.menuIndex, b.frame + end + local index, ticks = move(false) + local hiddenIndex, hiddenTicks = move(true) + T.neq(index, 1, "native menu handles directional input") + T.eq(hiddenIndex, index, "hidden HUD menu handles same input") + T.eq(hiddenTicks, ticks, "hidden native battle advances same logic clock") + f.close() + end +end +Version.set(originalVersion) +T.finish("battle.status_hud_visible native composition") diff --git a/tests/engine/status_hud_renderers.lua b/tests/engine/status_hud_renderers.lua new file mode 100644 index 000000000..3f450e709 --- /dev/null +++ b/tests/engine/status_hud_renderers.lua @@ -0,0 +1,137 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/status_hud/fixture.lua") +local Version = require("src.core.GameVersion") +local Visibility = require("src.battle.StatusHUDVisibility") +local Battle = require("src.battle.BattleState") +local G3 = require("src.battle.Gen3Battle") +local G4 = require("src.battle.Gen4Battle") +local G5 = require("src.battle.Gen5BattleHud") +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 b, e = f.battle, f.exports + T.eq(f.public:supports("hooks", "battle.status_hud_visible"), true, version .. " capability") + for _, owner in ipairs({ b, {} }) do + local calls = e.seen.calls + T.eq(Visibility.forBattle(owner), true, "native or partial owner visible") + T.eq(e.seen.calls, calls + 1, "one hook dispatch, including DS native method") + T.eq(e.seen.state, owner, "same opaque owner") + e.hide(true) + T.eq(Visibility.forBattle(owner), false, "native or partial owner hidden") + e.hide(false) + end + b.statusHUDVisible = function() + return false + end + local calls = e.seen.calls + T.eq(Visibility.forBattle(b), false, "legacy override veto") + T.eq(e.seen.calls, calls, "legacy veto precedes hook") + b.statusHUDVisible = function() + return true + end + e.hide(true) + T.eq(Visibility.forBattle(b), false, "hook veto survives legacy true") + b.statusHUDVisible = function() + error("legacy failure") + end + T.eq(Visibility.forBattle(b), false, "legacy failure retains public predicate") + b.statusHUDVisible = Battle.statusHUDVisible + e.hide(false) + if Version.isGen3() then + local Assets = require("src.render.Assets") + local image = love.graphics.newImage("contract-healthbox.png") + local summaryImage = love.graphics.newImage("contract-summary.png") + f.patch(Assets, "image", function(path) + return path == "summary" and summaryImage or image + end) + b.data.constants.gen3BattleHud = { + images = { + player = "panel", + opponent = "panel", + playerDoubles = "panel", + opponentDoubles = "panel", + }, + } + b.data.constants.gen3BattlerCoords = { + healthbox = { + playerLeft = { x = 159, y = 76 }, + playerRight = { x = 171, y = 101 }, + opponentLeft = { x = 44, y = 19 }, + opponentRight = { x = 32, y = 44 }, + }, + } + local panels, summaries = 0, 0 + f.patch(love.graphics, "draw", function(img) + if img == image then + panels = panels + 1 + end + if img == summaryImage then + summaries = summaries + 1 + end + end) + for _, double in ipairs({ false, true }) do + b.isDouble = function() + return double + end + b.battlerAt = function(self, pos) + return pos % 2 == 0 and self.player or self.enemy + end + panels = 0 + G3.drawHUDs(b, 0) + T.eq(panels, double and 4 or 2, "native GBA art for every active panel") + panels = 0 + e.hide(true) + G3.drawHUDs(b, 0) + T.eq(panels, 0, "native GBA single/double art entirely hidden") + e.hide(false) + end + -- Intro party summaries are independent of status panels. + b.data.constants.gen3BattleHud.partySummary = { + bar = "summary", + balls = "summary", + count = 1, + icons = { healthy = 0, empty = 0 }, + player = { barX = 0, barY = 0, ballX = 0, ballY = 0 }, + } + b.introBalls, b.playerParty = true, { b.player.mon } + summaries = 0 + e.hide(true) + G3.drawHUDs(b, 0) + T.check(summaries > 0, "hidden GBA status preserves party summary") + elseif Version.generation() >= 4 then + local delegated = 0 + f.patch(G5, "active", function() + return true + end) + f.patch(G5, "drawHealthboxes", function() + delegated = delegated + 1 + return true + end) + local calls = e.seen.calls + T.eq(G4.drawHealthboxes(b), true, "existing DS delegation handled") + T.eq(e.seen.calls, calls + 1, "DS true dispatches once") + T.eq(delegated, 1, "native DS healthbox delegate executes") + e.hide(true) + T.eq(G4.drawHealthboxes(b), true, "hidden DS handled, no GB fallback") + T.eq(delegated, 1, "hidden DS skips healthbox delegate") + else + local balls = 0 + b.introBalls, b.playerParty = true, { b.player.mon } + b.drawBallRow = function() + balls = balls + 1 + end + e.hide(true) + b:drawHUDs(0) + T.eq(balls, 1, "hidden GB status preserves intro party row") + b.introBalls, b.showEnemyBalls, b.enemyParty = false, true, { b.enemy.mon } + balls = 0 + b:drawHUDs(0) + T.eq(balls, 1, "hidden GB status preserves replacement party row") + end + f.close() + end +end +Version.set(originalVersion) +T.finish("battle.status_hud_visible native renderers") 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/trainer_party_scope.lua b/tests/engine/trainer_party_scope.lua new file mode 100644 index 000000000..f7765beb7 --- /dev/null +++ b/tests/engine/trainer_party_scope.lua @@ -0,0 +1,68 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/trainer_preparation/fixture.lua") +local Version = require("src.core.GameVersion") +local Battle = require("src.battle.BattleState") +local Scope = require("src.battle.PlayerPartyScope") +local Runtime = require("src.mods.Runtime") +local originalVersion = Version.get() +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game = f.game + local original, members = game.save.party, { game.save.party[1], game.save.party[2], game.save.party[3] } + f.start() + f.exports.continue({ playerPartyIndices = { 3, 2 } }) + local b = f.battle() + T.eq(b.game, game, "battle keeps original Game") + T.eq(b:playerPartyView()[1], members[3], "scope holds original first reference") + T.eq(b:benchFor(true)[2], members[2], "bench only contains scope") + T.eq(#b:benchFor(true), 2, "excluded mon absent from switch eligibility") + b.double = true + b:sendOutSecondPair() + T.eq(b:battlerAt(Battle.POS.PLAYER_RIGHT).mon, members[2], "double partner uses scoped eligible mon") + local id = require("src.battle.Gen4Battle").partyScreenId(b) or "PartyMenu" + local menu = b:buildScreen(id, { battle = b, onSwitch = function() end }) + local party = type(menu.party) == "function" and menu:party() or menu.party + T.eq(party, b.playerParty, "actual native party menu uses scope") + T.eq(#party, 2, "native menu cannot select excluded mon") + -- Native EXP eligibility ignores an excluded mon even if old participant + -- bookkeeping contains it. EXP.ALL cannot pay the excluded bench either. + b.queue = {} + b.participants = { [members[1]] = true, [members[2]] = true, [members[3]] = true } + game.save.inventory.EXP_ALL = 1 + local excludedExp = members[1].exp + local seen + local off = f.result.loader.hooks:wrap("battle.exp_award", function(next, ctx) + seen = ctx + return next(ctx) + end) + b:awardExp(b.enemy) + T.eq(seen.participants, 2, "native EXP counts selected participants") + T.eq(#seen.alive, 2, "native EXP contains selected living mons") + T.eq(members[1].exp, excludedExp, "excluded mon receives no EXP") + off() + members[2].hp, members[3].hp = 0, 0 + T.check(members[1].hp > 0, "excluded bench stays healthy") + b.result = nil + b:playerMonFainted() + T.eq(b.result, "lose", "scope exhaustion loses despite healthy excluded bench") + T.eq(game.save.party, original, "native loss never swaps save party") + T.same(game.save.party, members, "native loss never reorders save party") + for _, invalid in ipairs({ + {}, + { 1, 1 }, + { 0 }, + { 4 }, + { 1.5 }, + { [1] = 1, [3] = 2 }, + { 1, unexpected = true }, + "bad", + }) do + local selected, indices = Scope.select(game, invalid) + T.eq(selected, nil, "invalid scope falls back to full party") + T.eq(indices, nil, "invalid scope keeps no remap") + end + f.close() +end +Version.set(originalVersion) +T.finish("trainer battle-local player party scope") diff --git a/tests/engine/trainer_preparation.lua b/tests/engine/trainer_preparation.lua new file mode 100644 index 000000000..e25727c2f --- /dev/null +++ b/tests/engine/trainer_preparation.lua @@ -0,0 +1,21 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local Fixture = dofile("tests/trainer_preparation/fixture.lua") +local _, T = dofile("tests/trainer_preparation/cases.lua").run(function(noMod, failing) + return Fixture.new(".", "gen2", "red", 3, "unified", noMod, failing) +end) +-- Keep an executable public-API proof in the engine corpus as well as the +-- shared worker helpers (the coverage gate scans only its explicit tiers). +local f = Fixture.new(".", "gen2", "red", 3, "unified") +local mod, calls = f.exports.api, 0 +local off = mod.hooks:wrap("trainer.before_battle", function(next, ...) + calls = calls + 1 + return next(...) +end, 100) +f.start() +T.eq(calls, 1, "public API callback reached from native preparation") +T.eq(f.starts(), 0, "downstream preparation defers") +T.eq(f.exports.continue(), true, "public continuation starts native battle") +T.eq(f.starts(), 1, "public continuation constructs once") +off() +f.close() +T.finish("portable trainer.before_battle") diff --git a/tests/engine/trainer_preparation_native.lua b/tests/engine/trainer_preparation_native.lua new file mode 100644 index 000000000..ec25820d2 --- /dev/null +++ b/tests/engine/trainer_preparation_native.lua @@ -0,0 +1,245 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/trainer_preparation/fixture.lua") +local Version = require("src.core.GameVersion") +local World = require("src.world.OverworldController") +local Runner = require("src.script.ScriptRunner") +local Preparation = require("src.battle.TrainerPreparation") +local originalVersion = Version.get() +local function bind(fn, name, value) + for i = 1, 100 do + local key, old = debug.getupvalue(fn, i) + if not key then + break + end + if key == name then + debug.setupvalue(fn, i, value) + return function() + debug.setupvalue(fn, i, old) + end + end + end + error("missing native fixture binding " .. name) +end +local function setup(version, api, format) + local f = Fixture.new(".", "gen2", version, api, format) + local npc = { id = "TRAINER_1", def = { index = 1, trainerClass = "OPP_FIX_YOUNGSTER", trainerParty = 1 } } + local world = setmetatable({ + game = f.game, + npcs = { npc }, + map = { id = "FIX_ROUTE", def = { label = "FIX_ROUTE" } }, + player = { cellX = 4, cellY = 5 }, + engaging = false, + }, { __index = World }) + f.game.world, f.game.overworld = world, world + f.game.stack.states = { world } + local restore = bind(World.enter, "Game", f.game) + f.patch(f.game.data, "trainerHeader", function() + return { event = "TRAINER_WON", battle = "CHALLENGE", won = "WON" } + end) + f.game.data.text.CHALLENGE, f.game.data.text.WON = "A trainer challenge!", "You won!" + local battles, rewards, after = {}, 0, 0 + f.patch(world, "pushBattle", function(_, b) + battles[#battles + 1] = b + end) + f.patch(world, "checkVictoryRewards", function() + rewards = rewards + 1 + end) + f.patch(world, "afterBattle", function() + after = after + 1 + end) + f.patch(world, "runGen2AfterBattle", function() end) + f.world, f.npc, f.battles = world, npc, battles + f.finish = function() + restore() + f.close() + end + f.outcomes = function() + return rewards, after + end + return f +end +for _, version in ipairs(Version.ORDER) do + for _, mode in ipairs({ { 1, "native" }, { 2, "native" }, { 2, "unified" }, { 3, "unified" } }) do + for _, cancel in ipairs({ false, true }) do + local f = setup(version, mode[1], mode[2]) + local done = 0 + f.world:engageTrainer(f.npc, function() + done = done + 1 + end) + T.eq(f.exports.calls, 0, "pre-battle challenge precedes preparation") + local box = f.game.stack:top() + box.done = true + box:advance() + T.eq(f.exports.calls, 1, "native challenge completion dispatches once") + T.eq(#f.battles, 0, "deferral constructs/pushes no battle") + T.eq(f.world.engaging, true, "native field stays busy while deciding") + T.eq( + f.exports.continue(cancel and { cancel = true } or { playerPartyIndices = { 3, 2 } }), + true, + "native decision accepted" + ) + T.eq(f.exports.continue(), false, "native decision one-shot") + T.eq(f.world.engaging, false, "preparation releases its engagement guard") + T.eq(#f.battles, cancel and 0 or 1, "native construction only on resume") + if cancel then + T.eq(done, 1, "cancel releases encounter caller") + T.eq(f.game.save.flags.TRAINER_WON, nil, "cancel sets no defeat flag") + T.eq(f.world.spotHush.cellX, 4, "cancel suppresses same-tile sight retrigger") + T.eq(f.world.spotHush[f.npc], true, "sight scan suppresses this cancelled trainer") + else + local b = f.battles[1] + T.eq(b.player.mon, f.game.save.party[3], "native lead uses scoped party") + T.same(b.playerPartyIndices, { 3, 2 }, "native ordered scope") + b.onFinish("win") + T.eq(f.game.save.flags.TRAINER_WON, true, "native win sets its flag") + T.eq(f.game.save.defeatedTrainers.TRAINER_1, true, "native win records trainer defeat") + local rewards, after = f.outcomes() + T.eq(rewards, 1, "native win keeps rewards") + T.eq(after, 1, "native win keeps post-battle flow") + T.eq(done, 1, "native win releases encounter caller") + end + T.eq(Preparation.active(f.game), false, "native pending request discarded") + f.finish() + end + local f = setup(version, mode[1], mode[2]) + local runner = Runner.new(f.game, f.world) + runner:run({ { "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 }, { "set_flag", "AFTER_BATTLE" } }) + T.eq(f.exports.calls, 1, "actual script dispatches trainer gate") + T.eq(runner:isRunning(), true, "native script parks while preparing") + T.eq(#f.battles, 0, "script defers construction") + T.eq(f.exports.continue({ playerPartyIndices = { 2, 3 } }), true, "script continuation accepted") + T.eq(#f.battles, 1, "script constructs native battle once") + T.eq(f.exports.calls, 1, "resumed script never reintercepts itself") + T.eq(f.battles[1].player.mon, f.game.save.party[2], "script scope selects native lead") + T.eq(f.game.save.flags.AFTER_BATTLE, nil, "script still awaits actual battle outcome") + f.battles[1].onFinish("lose") + T.eq(runner:isRunning(), false, "native finish resumes script once") + T.eq(f.game.save.flags.AFTER_BATTLE, true, "native script continues after outcome") + f.finish() + for _, immediate in ipairs({ false, true }) do + for _, strict in ipairs({ false, true }) do + local cancelled = setup(version, mode[1], mode[2]) + local r = Runner.new(cancelled.game, cancelled.world) + local completed = 0 + if immediate then + cancelled.exports.mode, cancelled.exports.options = "immediate", { cancel = true } + end + r:run({ { "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1 }, { "set_flag", "UNSAFE_WIN" } }, { + source = strict and { modId = "strict_cancel_probe", strict = true } or nil, + onDone = function() + completed = completed + 1 + end, + afterScript = { + function() + cancelled.game.save.flags.UNSAFE_REWARD = true + end, + }, + }) + if not immediate then + cancelled.exports.continue({ cancel = true }) + end + T.eq(#cancelled.battles, 0, "cancelled script constructs no battle") + T.eq(r:isRunning(), false, "cancelled script releases coroutine") + T.eq(cancelled.game.save.flags.UNSAFE_WIN, nil, "cancel never runs win rows") + T.eq(cancelled.game.save.flags.UNSAFE_REWARD, nil, "cancel never runs deferred rewards") + T.eq(completed, 1, "cancel releases native caller once") + T.eq(r.ctx.lastCheck, false, "cancel has no successful battle result") + T.eq(#cancelled.result.errors, 0, "native cancellation is not a mod failure") + cancelled.finish() + end + end + for _, skip in ipairs({ "link", "firstBattle", "tutorialKind" }) do + local direct = setup(version, mode[1], mode[2]) + local r = Runner.new(direct.game, direct.world) + r:run({ { "start_battle", "trainer", "OPP_FIX_YOUNGSTER", 1, { [skip] = true } } }) + T.eq(direct.exports.calls, 0, skip .. " keeps native non-preparation route") + T.eq(#direct.battles, 1, skip .. " constructs native battle directly") + direct.finish() + end + end +end +-- Retained continuations cannot cross session or actor/script identities. +for _, change in ipairs({ + "save", + "stack", + "data", + "party", + "map", + "npc", + "npc id", + "trainer", + "team", + "session", + "owner", + "provider", +}) do + local f = setup("red", 3, "unified") + local done = 0 + f.world:engageTrainer(f.npc, function() + done = done + 1 + end) + local box = f.game.stack:top() + box.done = true + box:advance() + local resume = f.exports.continue + if change == "save" then + f.game.save = {} + elseif change == "stack" then + f.game.stack = {} + elseif change == "data" then + f.game.data = {} + elseif change == "party" then + f.game.save.party = {} + elseif change == "map" then + f.world.map = {} + elseif change == "npc" then + f.world.npcs = {} + elseif change == "npc id" then + f.npc.id = "OTHER" + elseif change == "team" then + f.npc.def.trainerTeam = {} + elseif change == "trainer" then + f.npc.def.trainerClass = "OTHER" + elseif change == "session" then + f.game.sessionStartedAt = 2 + elseif change == "owner" then + f.result.loader.hooks:removeOwner("portable_trainer_preparation") + elseif change == "provider" then + require("src.mods.Runtime").hooks = require("src.mods.Hooks").new() + end + T.eq(resume(), false, change .. " invalidates native continuation") + T.eq(#f.battles, 0, change .. " cannot construct battle") + T.eq(resume(), false, change .. " stale continuation stays retired") + T.eq(Preparation.active(f.game), false, change .. " releases pending request") + if change == "npc" or change == "npc id" or change == "team" or change == "trainer" then + T.eq(f.world.engaging, false, "retired actor releases its field guard") + end + f.finish() +end +local f = setup("red", 3, "unified") +f.world:engageTrainer(f.npc, function() end) +local box = f.game.stack:top() +box.done = true +box:advance() +f.result.loader.hooks:removeOwner("portable_trainer_preparation") +local peerCalls = 0 +local peer = f.result.loader.hooks:wrap("core.update", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) +end, 10, "other_core_owner") +local value, extra = require("src.mods.Runtime").call("core.update", function() + return "native", 7 +end) +T.eq(value, "native", "watcher preserves native update result") +T.eq(extra, 7, "watcher preserves native update tuple") +T.eq(peerCalls, 1, "watcher preserves another mod's callback") +require("src.mods.Runtime").call("core.update", function() end) +T.eq(peerCalls, 2, "other callback survives watcher removal") +peer() +T.eq(Preparation.active(f.game), false, "native update retires removed owner without resume") +T.eq(f.world.engaging, false, "native update releases removed owner's field guard") +T.eq(f.exports.continue(), false, "removed owner's continuation remains retired") +f.finish() +Version.set(originalVersion) +T.finish("trainer.before_battle native dialogue, script and lifetime") 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/engine/world_talk.lua b/tests/engine/world_talk.lua new file mode 100644 index 000000000..0a084d32e --- /dev/null +++ b/tests/engine/world_talk.lua @@ -0,0 +1,5 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local f = dofile("tests/world_talk/fixture.lua").new(".", "gen2", "red", 3, "unified") +dofile("tests/world_talk/cases.lua").run(f) +f.close() +f.T.finish("portable world.talk") diff --git a/tests/engine/world_talk_dialogue.lua b/tests/engine/world_talk_dialogue.lua new file mode 100644 index 000000000..d5b733e26 --- /dev/null +++ b/tests/engine/world_talk_dialogue.lua @@ -0,0 +1,87 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Fixture = dofile("tests/world_talk/fixture.lua") +local Version = require("src.core.GameVersion") +local World = require("src.world.OverworldController") +local Stack = require("src.core.StateStack") +local NPC = require("src.world.NPC") +local TextBox = require("src.render.TextBox") +local originalVersion = Version.get() +-- Bind the same module-local dependencies that enter() installs, without +-- loading an imported map/GPU. Neither interact nor talkTo is replaced. +local function bind(fn, name, value) + for i = 1, 100 do + local key, old = debug.getupvalue(fn, i) + if not key then + break + end + if key == name then + debug.setupvalue(fn, i, value) + return function() + debug.setupvalue(fn, i, old) + end + end + end + error("missing native fixture binding " .. name) +end +for _, version in ipairs(Version.ORDER) do + local f = Fixture.new(".", "gen2", version, 3, "unified") + local game, ow = f.game, f.world + game.stack = setmetatable({}, { __index = Stack }) + game.stack:init() + game.stack.states = { ow } + f.result.loader.game = game + game.world, game.overworld = ow, ow + game.save.flags, game.save.party = {}, {} + local mapId = ow.map.id + ow.map.def = game.data.maps[mapId] + ow.map.def.objects = {} + ow.map.def.label = mapId + ow.entities, ow.npcPool, ow.npcs = {}, {}, {} + local restoreGame = bind(World.enter, "Game", game) + local restoreScripts = bind(World.enter, "mapScripts", require("src.script.MapScripts")) + -- Exercise the native public world adapter and real runtime NPC constructor. + local api = require("src.world.WorldAPI").new(game, "portable_world_talk") + local id, why = api:spawnNpc(mapId, { + name = "GUIDE", + x = 4, + y = 5, + sprite = "SPRITE_YOUNGSTER", + movement = "STAY", + facing = "UP", + text = "A native conversation.", + }) + T.check(type(id) == "string", version .. " runtime spawn: " .. tostring(why)) + local npc = ow.npcs[1] + assert(npc, "native runtime actor materialized") + T.eq(getmetatable(npc), NPC, "real NPC constructor") + T.eq(npc.def.owner, "portable_world_talk", "runtime ownership preserved") + ow.talkTo = nil + f.exports.claim(id) + ow:interact() + T.eq(game.stack:top(), ow, "claimed runtime actor opens no native dialogue") + T.eq(npc.frozen, false, "claim does not acquire native freeze") + f.exports.claim(nil) + ow:interact() + local box = game.stack:top() + T.eq(getmetatable(box), TextBox, "fallthrough opens actual native TextBox") + T.eq(npc.frozen, true, "native conversation freezes actor") + T.eq(npc.facing, "down", "native conversation faces player") + box.done = true + box:advance() + T.eq(game.stack:top(), ow, "native confirmation closes dialogue") + T.eq(npc.frozen, false, "native completion unfreezes actor") + f.exports.stop() + ow:interact() + box = game.stack:top() + T.eq(getmetatable(box), TextBox, "unsubscribe preserves native dialogue") + box.done = true + box:advance() + T.eq(api:removeNpc(id), true, "runtime owner can remove its actor") + T.eq(#ow.npcs, 0, "runtime actor removed from interaction pool") + restoreScripts() + restoreGame() + f.close() +end +Version.set(originalVersion) +T.finish("world.talk native runtime NPC dialogue") diff --git a/tests/engine/world_talk_routing.lua b/tests/engine/world_talk_routing.lua new file mode 100644 index 000000000..ae4f0aa08 --- /dev/null +++ b/tests/engine/world_talk_routing.lua @@ -0,0 +1,86 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.modkit") +local Version = require("src.core.GameVersion") +local Fixture = dofile("tests/world_talk/fixture.lua") +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]) + dofile("tests/world_talk/cases.lua").run(f) + local probe = f.public.hooks:wrap("world.talk", function(next, ...) + f.exports.seen.calls = f.exports.seen.calls + 1 + return next(...) + end) + -- Preserve the engine's existing guards before the public seam. + for _, guard in ipairs({ "moving", "hidden", "gen5Blocker" }) do + local polls, calls = f.exports.seen.calls, #f.calls + f.patch(f.world.map, "signAtCell", function() + return nil + end) + f.patch(f.world, "tryPcTile", function() + return true + end) + if guard == "gen5Blocker" then + f.npc.def.gen5Blocker = true + else + f.npc[guard] = true + end + f.interact() + T.eq(f.exports.seen.calls, polls, guard .. " never queries talk hook") + T.eq(#f.calls, calls, guard .. " never reaches native talk") + f.npc[guard], f.npc.def.gen5Blocker = false, nil + end + if f.gen == 3 then + f.world.player.elevation, f.npc.elevation = 1, 2 + local polls, calls = f.exports.seen.calls, #f.calls + f.interact() + T.eq(f.exports.seen.calls, polls, "GBA wrong elevation never talks") + T.eq(#f.calls, calls, "GBA wrong elevation keeps native guard") + end + for _, follower in ipairs({ + { "pikachuFollower", "src.world.PikachuFollower" }, + { "hgssFollowMon", "src.world.HgssFollowMon" }, + }) do + local called, polls = 0, f.exports.seen.calls + f.patch(require(follower[2]), "talk", function() + called = called + 1 + end) + f.npc[follower[1]] = true + f.world.player.elevation, f.npc.elevation = nil, nil + f.interact() + T.eq(called, 1, "special follower keeps native dispatch") + T.eq(f.exports.seen.calls, polls, "special follower bypasses general NPC hook") + f.npc[follower[1]] = nil + end + probe() + local returned + f.patch(f.world, "talkTo", function() + return "native private result" + end) + local capture = f.public.hooks:wrap("world.talk", function(next) + returned = next() + return returned + end) + f.interact() + T.eq(returned, nil, "default continuation matches Gen1's void talk contract") + capture() + -- Original talk failures propagate without being retried as a mod error. + f.world.player.elevation, f.npc.elevation = nil, nil + local failures = 0 + f.patch(f.world, "talkTo", function() + failures = failures + 1 + error("native talk failure") + end) + local off = f.public.hooks:wrap("world.talk", function(next) + return next() + end) + local ok, why = pcall(f.interact) + T.eq(ok, false, "native failure propagates") + T.check(tostring(why):find("native talk failure", 1, true) ~= nil, "native failure retained") + T.eq(failures, 1, "native failure is never replayed") + off() + f.close() + end +end +Version.set(originalVersion) +T.finish("world.talk native routing") diff --git a/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua b/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua new file mode 100644 index 000000000..ee1762048 --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/mods/legacy/main.lua @@ -0,0 +1,18 @@ +return function(mod) + mod.exports.npc = function(...) + return mod.world:npc(...) + end + mod.exports.sample = function(mapId, ref, appearance) + local h = assert(mod.world:npc(mapId, ref)) + assert(h:placeAt(1, 1, "right")) + local allowed = h:canStep("right") + assert(h:setPassable(true)) + assert(h:stepNow("right")) + local moving = h:isMoving() + assert(h:placeAt(3, 4, "up")) + assert(h:setPassable(false)) + assert(h:setAppearance(appearance)) + local x, y = h:position() + return { x = x, y = y, allowed = allowed, moving = moving, stopped = not h:isMoving() } + end +end diff --git a/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json b/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json new file mode 100644 index 000000000..ac5706ad2 --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json @@ -0,0 +1 @@ +{"id":"legacy","name":"Legacy NPC controls fixture","version":"1.0.0","api":2,"entry":"main.lua","gen2compat":true,"games":["gen1","gen2","gen3"]} diff --git a/tests/fixtures/legacy_npc_controls/worker.lua b/tests/fixtures/legacy_npc_controls/worker.lua new file mode 100644 index 000000000..bd4bbcf1b --- /dev/null +++ b/tests/fixtures/legacy_npc_controls/worker.lua @@ -0,0 +1,16 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = assert(arg[1]), assert(arg[2]), assert(arg[3]) +love = require("tests.love_stub") +local f = dofile(root .. "/tests/api3_contract/npcs.lua").fixture( + root .. "/tests/fixtures/legacy_npc_controls", + backend, + version, + "legacy" +) +f.loader._game = function() + return f.live +end +local result = f.exports.sample(f.home, "IMPORTED", "ALT") +assert(result.x == 3 and result.y == 4 and result.allowed and result.moving and result.stopped) +f.restoreRenderer() +print("LEGACY_NPC_RESULT " .. require("src.link.Json").encode(result)) 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..b0269caf1 --- /dev/null +++ b/tests/fixtures/upstream_mods/data.lua @@ -0,0 +1,32 @@ +-- 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','PIKACHU'}) 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 + data.pokemon.PIKACHU.dex=25 + 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..fa6141723 --- /dev/null +++ b/tests/fixtures/upstream_mods/worker.lua @@ -0,0 +1,124 @@ +-- 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},modCallbackProfiles={[id]=id=='example_mew_starter' and 'decoded' or nil}})} +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 _,bus in ipairs({loader.hooks,loader._decodedHooks})do + for name in pairs(bus.chains)do assert(known[name],'unknown hook '..name);names[#names+1]=name end +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 +-- Authentic unchanged starter code now runs through the actual Gen-3 compiler, +-- runner, existing gift event and Pokémon constructor on test-owned data. +local functional +if loaded and id=='example_mew_starter' and version=='firered' then + data.maps.MAP_G04_N03={id='MAP_G04_N03',group=4,number=3} + data.constants.speciesOrder={[4]='CHARMANDER',[151]='MEW'} + data.map_scripts={source='RomExtractorGen3',scripts={ENTRY={ + {'setvar',0x4002,4},{'givemon',0x4002,5,0},{'end'}, + }},maps={}} + local save={flags={},party={},gen3Vars={},player={name='TEST',id=12345}, + pokedex={seen={},owned={}}} + local game={data=data,save=save,mods=loader} + local world={map={id='MAP_G04_N03',def=data.maps.MAP_G04_N03}} + local runner=require('src.script.ScriptRunner').new(game,world) + runner:exec(require('src.script.Gen3ScriptVM').compile(data,'ENTRY'),runner:makeContext()) + assert(save.gen3Vars[0x4002]==151,'unchanged starter hook did not rewrite the native variable') + local mon=assert(save.party[1],'native gift did not create a Pokémon') + assert(#save.party==1 and mon.species=='MEW' and mon.level==20 and mon.nickname=='HOGHEAD', + 'unchanged starter event did not produce the requested native gift') + assert(world.map.id=='MAP_G04_N03','profile renamed the native map') + functional={species=mon.species,level=mon.level,nickname=mon.nickname,variable=save.gen3Vars[0x4002]} +end +if loaded and id=='mystery_dungeon_follower' then + local Native=require('src.core.Game') + local previous={Native.data,Native.save,Native.overworld} + local image=love.graphics.newImage + love.graphics.newImage=function(path) + if path=='asset_packs/pmd_red/sprites/synthetic.png'then + return {getDimensions=function()return 16,24 end,getWidth=function()return 16 end,getHeight=function()return 24 end} + end + return image(path) + end + local npc={optionalFollower=true,pikachuFollower=true,px=0,py=0,moving=true} + local world={player={px=16,py=0,visible=true},npcs={npc}} + local save={party={{species='PIKACHU',hp=10,dvs={attack=0,defense=0,speed=0,special=0}}}} + Native.data,Native.save,Native.overworld=data,save,world + local count=#loader.errors + local spawn=loader.hooks:call('world.follower.spawn',function()return false end,Native,world) + assert(spawn and npc.sprite,'unchanged follower did not install its published pack renderer') + local stepped=loader.hooks:call('input.step',function(_,dt)return dt end,Native,0.25) + assert(stepped==0.25 and npc.sprite.pmdTicks==1,'unchanged follower did not advance its sprite') + assert(#loader.errors==count,table.concat(loader.errors,'; ')) + functional={spawned=true,stepped=true,anchorX=npc.sprite.anchorX,anchorY=npc.sprite.anchorY} + love.graphics.newImage=image + Native.data,Native.save,Native.overworld=unpack(previous) +end +print('FIXTURE_RESULT '..Json.encode({fixture=fixture,version=version,loaded=loaded and true or false,hooks=names,error=errorText,functional=functional})) +if not loaded then os.exit(1)end diff --git a/tests/follower/cases.lua b/tests/follower/cases.lua new file mode 100644 index 000000000..b5bbd9310 --- /dev/null +++ b/tests/follower/cases.lua @@ -0,0 +1,133 @@ +local M = {} +function M.run(f) + local T, state, rows = f.T, f.e.state, {} + local function observe(label) + local n = f.current() + rows[#rows + 1] = { label = label, present = n ~= nil } + end + f.tick() + T.eq(f.current() ~= nil, f.version == "yellow", "native default remains Yellow-only") + observe("native") + state.mode = "deny" + f.tick() + T.eq(f.current(), nil, "public predicate removes companion") + state.mode = "allow" + f.tick() + local npc = f.current() + T.check(npc ~= nil, "public predicate spawns actual native companion") + T.eq(npc.passable, true, "native follower never blocks player") + T.eq(state.game, f.game, "hook receives original opaque Game") + T.eq(state.world.player, f.player, "hook receives native player") + T.eq(state.world.map.id, f.world.map.id, "hook receives active map") + if not (f.backend == "gen1" and f.gen == 3) then + T.eq(state.world, f.world, "opaque world identity is preserved") + T.eq(f.world.npcs[#f.world.npcs], npc, "native update list owns follower") + T.eq(f.world.entities[#f.world.entities], npc, "native draw list owns follower") + end + observe("allow") + -- A moving player commits before the NPC controller. Check each native + -- interpolated frame, not a hand-called Runtime hook or a fake follower. + for _, dir in ipairs({ { "right", 1, 0 }, { "down", 0, 1 }, { "left", -1, 0 }, { "up", 0, -1 } }) do + local p = f.player + p.facing, p.targetX, p.targetY, p.moving = dir[1], p.cellX + dir[2], p.cellY + dir[3], true + for frame = 1, 4 do + f.tick() + npc = f.current() + T.check(npc ~= nil, "native companion survives movement") + rows[#rows + 1] = { dir = dir[1], frame = frame, x = npc.px, y = npc.py, moving = npc.moving == true } + end + p.cellX, p.cellY, p.px, p.py = p.targetX, p.targetY, p.targetX * 16, p.targetY * 16 + p.targetX, p.targetY, p.moving = nil, nil, false + end + state.mode = "deny" + f.tick() + T.eq(f.current(), nil, "denial removes follower during native update") + if not (f.backend == "gen1" and f.gen == 3) then + T.eq(#f.world.npcs, 0, "removal clears update list") + T.eq(#f.world.entities, 1, "removal retains player draw entry") + end + observe("deny") + state.mode = "allow" + f.enter() + T.check(f.current() ~= nil, "native map entry honors predicate") + observe("map entry") + if f.gen <= 2 then + local keep = f.current() + local x, y = keep.px, keep.py + f.enter({ keepPikachu = keep }) + T.eq(f.current(), keep, "native connection keeps companion identity") + f.controller.rebase(f.world, -2, 1) + T.eq(keep.px, x - 32, "native connection rebases companion x") + T.eq(keep.py, y + 16, "native connection rebases companion y") + rows[#rows + 1] = { label = "connection", dx = keep.px - x, dy = keep.py - y } + elseif f.gen == 3 then + f.player.cellX, f.player.cellY, f.player.px, f.player.py = 20, 20, 320, 320 + f.tick() + T.eq(f.current().cellX, 20, "GBA same-map teleport resets companion x") + T.eq(f.current().cellY, 20, "GBA same-map teleport resets companion y") + rows[#rows + 1] = { label = "teleport", x = f.current().px, y = f.current().py } + end + local off = f.e.api.hooks:wrap("world.follower.spawn", function() + return false + end, 100) + f.tick() + T.eq(f.current(), nil, "higher-priority wrapper controls native lifecycle") + off() + f.tick() + T.check(f.current() ~= nil, "unsubscribe restores consumer") + f.e.stop() + f.e.stop() + f.tick() + T.eq(f.current() ~= nil, f.version == "yellow", "unsubscribe restores native predicate") + observe("unsubscribed") + for _, value in ipairs({ false, true, 0, "", {} }) do + local restore = f.e.api.hooks:wrap("world.follower.spawn", function() + return value + end, 100) + f.tick() + T.eq(f.current() ~= nil, not not value, "native predicate uses Lua truthiness") + observe("truthiness:" .. type(value) .. ":" .. tostring(not not value)) + restore() + end + local nilResult = f.e.api.hooks:wrap("world.follower.spawn", function() + return nil + end, 100) + f.tick() + T.eq(f.current(), nil, "nil predicate result removes follower") + observe("nil") + nilResult() + for _, after in ipairs({ false, true }) do + local failing = f.e.api.hooks:wrap("world.follower.spawn", function(next, game, world) + if after then + next(game, world) + end + error("intentional wrapper failure") + end, 100) + f.tick() + T.eq(f.current() ~= nil, f.version == "yellow", "wrapper failure preserves downstream native default") + observe("failure:" .. tostring(after)) + failing() + end + if f.api >= 3 then + local calls = 0 + local count = f.e.api.hooks:wrap("world.follower.spawn", function() + calls = calls + 1 + return true + end, 50) + local replay = f.e.api.hooks:wrap("world.follower.spawn", function(next, game, world) + local before = calls + local a, b = next(game, world), next(game, world) + T.eq(a, true, "first next returns downstream predicate") + T.eq(b, a, "API-3 repeated next preserves result") + T.eq(calls, before + 1, "API-3 repeated next calls downstream once") + return a + end, 100) + f.tick() + T.check(f.current() ~= nil, "replayed next still drives native spawning") + replay() + count() + observe("replay") + end + return rows +end +return M diff --git a/tests/follower/fixture.lua b/tests/follower/fixture.lua new file mode 100644 index 000000000..3e9587938 --- /dev/null +++ b/tests/follower/fixture.lua @@ -0,0 +1,153 @@ +local M = {} +function M.new(root, backend, version, api, format, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation() + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + data.tilesets = data.tilesets or {} + data.tilesets.FIX_OUT = data.tilesets.FIX_OUT or T.fixtures.fresh().tilesets.FIX_OUT + data.sprites = data.sprites or {} + local sheet = T.fixtures.fresh().sprites.SPRITE_FIX_NPC + data.sprites.SPRITE_PIKACHU = sheet + data.gen2Sprites = data.gen2Sprites or {} + data.gen2Sprites.SPRITE_PIKACHU = sheet + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "manifest.json", "main.lua" }) do + local file = assert(io.open(root .. "/examples/portable_follower/" .. name, "rb")) + files["mods/portable_follower/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_follower/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_follower/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_follower/main.lua"] = files["mods/portable_follower/main.lua"] + .. '\nerror("entry rollback probe")' + end + local result = T.sdk.loadMods( + { "mods/portable_follower" }, + { fs = T.sdk.memfs(files), data = data, dev = false } + ) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local game = { + data = data, + save = { + version = version, + options = {}, + flags = { EVENT_GOT_STARTER = true, EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true }, + player = { id = 7, name = "PROBE" }, + party = { { species = "PIKACHU", hp = 10, otId = 7, ot = "PROBE" } }, + }, + } + local def = { + id = "FIX_TOWN", + width = 12, + height = 12, + tileset = "FIX_OUT", + blocks = {}, + objects = {}, + connections = {}, + } + for i = 1, 144 do + def.blocks[i] = 1 + end + data.tilesets.FIX_OUT.walkable = { 0, 1, 2 } + data.tilesets.FIX_OUT._walkableSet = nil + if backend == "gen1" and gen == 2 then + data.tilesets.FIX_OUT.collision = { {}, { 0, 0, 0, 0 } } + end + -- Gen1 GBA's real follower reads the Field session map ID, not a GB Map. + local map = backend == "gen1" and gen == 3 and { id = def.id } + or require("src.world.Map").new(def, data.tilesets.FIX_OUT) + local player = { + cellX = 4, + cellY = 4, + px = 64, + py = 64, + facing = "down", + stepFrames = 4, + stepFramesCur = 4, + moving = false, + spriteDef = sheet, + elevation = 3, + } + local world = { map = map, player = player, npcs = {}, entities = { player }, sprites = data.gen2Sprites } + local restores = {} + local function patch(owner, name, value) + local old = owner[name] + restores[#restores + 1] = function() + owner[name] = old + end + owner[name] = value + end + local controller + if backend == "gen1" and gen == 3 then + controller = require("src.world.game3.Follower") + local Field = require("src.core.game3.field") + player = require("src.core.game3.player") + for name, value in pairs(world.player) do + patch(player, name, value) + end + for _, name in ipairs({ "targetX", "targetY", "surfing" }) do + patch(player, name, nil) + end + world.player = player + patch(Field, "running", true) + patch(Field, "getSession", function() + return { map = world.map.id } + end) + controller.reset() + elseif backend == "gen1" and gen == 2 then + controller = require("src.world.gen2.Follower") + else + controller = require("src.world.PikachuFollower") + end + game.world, game.overworld, result.loader.game = world, world, game + local function current() + return controller.current(world) + end + local function tick() + local npc = current() + if npc and npc.moving and not (backend == "gen1" and gen == 3) then + npc:update(world.map, world.entities) + end + controller.update(game, world) + end + return { + T = T, + e = result.loader.exports.portable_follower, + result = result, + game = game, + world = world, + player = player, + controller = controller, + current = current, + tick = tick, + gen = gen, + version = version, + backend = backend, + api = api, + patch = patch, + enter = function(opts) + if backend == "gen1" and gen == 3 then + controller.onMapEntered() + tick() + else + controller.onMapEntered(game, world, opts, gen == 2) + end + end, + close = function() + if backend == "gen1" and gen == 3 then + controller.reset() + end + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/follower/worker.lua b/tests/follower/worker.lua new file mode 100644 index 000000000..f1710d4cc --- /dev/null +++ b/tests/follower/worker.lua @@ -0,0 +1,14 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/follower/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/follower/cases.lua").run(f) +f.close() +local failed = + dofile(root .. "/tests/follower/fixture.lua").new(root, backend, version, tonumber(api), format, true) +failed.T.check(#failed.result.errors > 0, "entry failure is recorded") +failed.tick() +failed.T.eq(failed.current() ~= nil, version == "yellow", "entry rollback restores native predicate") +rows[#rows + 1] = { label = "rollback", present = failed.current() ~= nil } +failed.close() +print("FOLLOWER_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable native follower") 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/level_visible/cases.lua b/tests/level_visible/cases.lua new file mode 100644 index 000000000..ad20a8643 --- /dev/null +++ b/tests/level_visible/cases.lua @@ -0,0 +1,104 @@ +local M = {} +function M.run(f) + local T, e, mon = f.T, f.exports, f.mon + local api, rows = e.api, {} + if api.supports then + T.eq(api:supports("hooks", "pokemon.level_visible"), true, "native capability") + end + for _, where in ipairs({ "battle.enemy", "battle.player", "party", "summary" }) do + local function observe(label, value) + T.eq(f.visible(where), value, where .. " " .. label) + rows[#rows + 1] = { where = where, label = label, visible = value } + end + observe("native", true) + T.eq(e.seen.context.where, where, "surface context") + T.eq(e.seen.mon.level, 42, "level in every callback format") + if f.api < 3 and f.format ~= "unified" then + T.eq(e.seen.mon, mon, "native mon identity") + T.eq(e.seen.context.game, f.game, "native Game identity") + else + T.eq(e.seen.mon.species, "FIXMON_A", "normalized species") + T.check(e.seen.mon ~= mon, "unified mon record") + end + e.hide(where, true) + observe("hidden", false) + local other = where == "party" and "summary" or "party" + T.eq(f.visible(other), true, "per-surface decision") + e.hide(where, false) + observe("restored", true) + end + for _, case in ipairs({ + { name = "nil" }, + { name = "true", value = true }, + { name = "zero", value = 0 }, + { name = "string", value = "" }, + { name = "table", value = {} }, + }) do + local off = api.hooks:wrap("pokemon.level_visible", function() + return case.value + end, 100) + T.eq(f.visible("party"), true, case.name .. " remains visible") + off() + end + local off = api.hooks:wrap("pokemon.level_visible", function() + return false, true + end, 100) + T.eq(f.visible("party"), false, "only first return controls visibility") + off() + local order = {} + local outer = api.hooks:wrap("pokemon.level_visible", function(next, m, ctx) + order[#order + 1] = "outer" + T.eq(ctx.where, "party", "outer context") + local result = next(m, ctx) + order[#order + 1] = "after" + return result + end, 20) + local inner = api.hooks:wrap("pokemon.level_visible", function(next) + order[#order + 1] = "inner" + return next() + end, 10) + T.eq(f.visible("party"), true, "next() retains arguments") + T.same(order, { "outer", "inner", "after" }, "priority and delegation") + outer() + inner() + e.hide("party", true) + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("pokemon.level_visible", function(next) + if after then + next() + end + error("intentional level predicate failure") + end, 100) + T.eq(f.visible("party"), false, "error preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("pokemon.level_visible", function(next) + next() + return next() + end, 100) + local calls = e.seen.calls + T.eq(f.visible("party"), false, "API 3 caches false") + T.eq(e.seen.calls, calls + 1, "API 3 does not replay downstream") + off() + end + e.stop() + e.stop() + local calls = e.seen.calls + T.eq(f.visible("party"), true, "unsubscribe restores default") + T.eq(e.seen.calls, calls, "idempotent unsubscribe") + api.hooks:wrap("pokemon.level_visible", function() + return false + end) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("pokemon.level_visible", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "level_peer") + f.result.loader.hooks:removeOwner("portable_level_visible") + T.eq(f.visible("party"), true, "owner cleanup restores default") + T.eq(peerCalls, 1, "owner cleanup preserves peer") + peer() + return rows +end +return M diff --git a/tests/level_visible/fixture.lua b/tests/level_visible/fixture.lua new file mode 100644 index 000000000..aaecf5211 --- /dev/null +++ b/tests/level_visible/fixture.lua @@ -0,0 +1,57 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod) + local T = require("tests.modkit") + local Version, Json = require("src.core.GameVersion"), require("src.link.Json") + Version.set(version) + local files = {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_level_visible/" .. name, "rb")) + files["mods/portable_level_visible/" .. name] = file:read("*a") + file:close() + end + local actualFormat = api >= 3 and "unified" or (backend == "gen1" and "native" or format) + local manifest = Json.decode(files["mods/portable_level_visible/manifest.json"]) + manifest.api, manifest.callback_format = api, backend == "gen2" and format or nil + files["mods/portable_level_visible/manifest.json"] = Json.encode(manifest) + local data = T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_level_visible" }, { + data = data, + generation = Version.generation(), + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local game = { data = data, save = { options = {} } } + local mon = require("src.pokemon.Pokemon").new(data, "FIXMON_A", 42) + local e = result.loader.exports.portable_level_visible + return { + T = T, + game = game, + mon = mon, + data = data, + result = result, + exports = e, + api = api, + format = actualFormat, + backend = backend, + patch = patch, + visible = function(where, value) + return require("src.ui.LevelDisplay").visible(value or mon, where, game) + end, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/level_visible/native.lua b/tests/level_visible/native.lua new file mode 100644 index 000000000..d73318787 --- /dev/null +++ b/tests/level_visible/native.lua @@ -0,0 +1,191 @@ +-- Exercise the actual battle producers, spying only at font/tile leaves. +local M = {} +local function upvalue(owner, wanted) + local visited = {} + local function search(fn) + if type(fn) ~= "function" or visited[fn] then + return + end + visited[fn] = true + for i = 1, 100 do + local name, value = debug.getupvalue(fn, i) + if not name then + break + end + if name == wanted then + return value + end + local found = search(value) + if found then + return found + end + end + end + for _, fn in pairs(owner) do + local found = search(fn) + if found then + return found + end + end + error("native producer missing: " .. wanted) +end +function M.run(f) + local T, Version = f.T, require("src.core.GameVersion") + local e, mon, game = f.exports, f.mon, f.game + local levels, labels, draw, b = 0, 0 + local restoreLeaf + local function ink(text) + text = tostring(text) + if text == "42" or text == "Lv42" or text == "42" then + levels = levels + 1 + end + if text:find("Lv42", 1, true) or text:find("42", 1, true) then + labels = labels + 1 + end + return 16 + end + if f.backend == "gen1" and Version.generation() == 3 then + require("src.import.gba.versions").select(Version.get()) + local Font = require("src.ui.game3.frlg_font") + f.patch(Font, "draw", ink) + f.patch(Font, "drawGlyph", function(code) + if code == Font.CHAR_LV_2 then + labels = labels + 1 + end + end) + f.patch(Font, "advance", function() + return 6 + end) + -- This is the private routine Healthbox.draw uses for singles and doubles; + -- keep it intact, with a real native state/session context. + local level = upvalue(require("src.core.game3.battle.healthbox"), "draw_level") + draw = function() + for _, side in ipairs({ "enemy", "player" }) do + level(mon.level, 0, 0, 0, mon, side, { session = { game = game } }) + end + end + else + local Font = require("src.render.Font") + Font.load(game.data) + f.patch(Font, "draw", ink) + f.patch(Font, "drawCode", function() end) + f.patch(Font, "drawBox", function() end) + local Hud = require("src.render.HudTiles") + f.patch(Hud, "tile", function(code) + if code == 0x6E then + labels = labels + 1 + end + end) + local class = require( + f.backend == "gen1" and Version.generation() == 2 and "src.ui.gen2.BattleState" + or "src.battle.BattleState" + ) + if jit and class.drawHUDs then + jit.off(class.drawHUDs, true) + end + -- BattleState captures this font/tile leaf when its module is loaded. + -- The SDK may already have loaded it before we install the recorder. + for i = 1, 100 do + local name, value = debug.getupvalue(class.drawHUDs or function() end, i) + if not name then + break + end + if name == "hudTile" then + debug.setupvalue(class.drawHUDs, i, Hud.tile) + restoreLeaf = function() + debug.setupvalue(class.drawHUDs, i, value) + end + break + end + end + b = setmetatable({ + game = game, + data = game.data, + phase = "contract", + frame = 0, + fx = {}, + ballRows = {}, + shown = {}, + showEnemyHud = true, + showPlayerHud = true, + player = { name = "SCOUT", mon = mon, shownHP = mon.hp, isPlayer = true }, + enemy = { name = "FOE", mon = mon, shownHP = mon.hp, isPlayer = false }, + }, { __index = class }) + if f.backend == "gen1" and Version.generation() == 2 then + local Chrome = require("src.ui.gen2.Chrome") + f.patch(Chrome, "printThrough", ink) + f.patch(Chrome, "printRightThrough", ink) + b.hud = { + available = function() + return true + end, + drawEnemyFrame = function() end, + drawPlayerFrame = function() end, + drawExpBar = function() end, + } + b.activeMon = function() + return mon + end + b.name = function() + return "SCOUT" + end + b.hudCleared = function() + return false + end + b.statusTag = function() end + b.genderSymbol = function() end + b.caughtMarkerVisible = function() + return false + end + b.hudHp = function() + return mon.hp + end + b.drawHpBar = function() end + draw = function() + b:drawEnemyHud() + b:drawPlayerHud() + end + else + b.growInScale = function() end + b.colorMode = function() + return false + end + b.demoHidesPlayer = function() + return false + end + draw = function() + b:drawHUDs(0) + end + end + end + local seen = {} + local off = f.result.loader.hooks:wrap("pokemon.level_visible", function(next, m, ctx) + T.eq(m, mon, "native battle supplies actual Pokemon") + T.eq(ctx.game, game, "native battle supplies current Game") + seen[ctx.where] = true + return next(m, ctx) + end, 1000) + local rows = {} + for _, phase in ipairs({ + { enemy = false, player = false, count = 2 }, + { enemy = true, player = false, count = 1 }, + { enemy = false, player = true, count = 1 }, + { enemy = true, player = true, count = 0 }, + { enemy = false, player = false, count = 2 }, + }) do + e.hide("battle.enemy", phase.enemy) + e.hide("battle.player", phase.player) + levels, labels, seen = 0, 0, {} + draw() + T.eq(levels, phase.count, "native battle level digits") + T.eq(labels, phase.count, "native battle level labels") + T.check(seen["battle.enemy"] and seen["battle.player"], "native battle surfaces") + rows[#rows + 1] = { enemy = phase.enemy, player = phase.player, levels = levels, labels = labels } + end + off() + if restoreLeaf then + restoreLeaf() + end + return rows +end +return M diff --git a/tests/level_visible/worker.lua b/tests/level_visible/worker.lua new file mode 100644 index 000000000..6ddd39985 --- /dev/null +++ b/tests/level_visible/worker.lua @@ -0,0 +1,15 @@ +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/level_visible/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = { + native = dofile(root .. "/tests/level_visible/native.lua").run(f), + predicate = dofile(root .. "/tests/level_visible/cases.lua").run(f), +} +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("pokemon.level_visible"), false, "bare Runtime") +bare.T.eq(bare.visible("party"), true, "no-mod default") +bare.close() +print("LEVEL_VISIBLE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable pokemon.level_visible") diff --git a/tests/live_maps/cases.lua b/tests/live_maps/cases.lua new file mode 100644 index 000000000..8b27c2020 --- /dev/null +++ b/tests/live_maps/cases.lua @@ -0,0 +1,41 @@ +local M = {} +function M.run(f) + local T, e, rows = f.T, f.e, {} + f.rebuild() + if f.commonEvents then + T.eq(#e.events, 2, "native rebuild emits one pair") + T.eq(e.events[1].phase, "preparing", "preparing arrives first") + T.eq(e.events[2].phase, "updated", "updated arrives last") + T.eq(f.phase[1].preparing, true, "native preparation flag is set") + T.eq(f.phase[1].ghosts, 1, "preparing precedes ghost rebuild") + T.eq(f.phase[2].preparing, false, "updated observes settled preparation") + T.eq(f.phase[2].ghosts, 0, "updated follows ghost rebuild") + for _, event in ipairs(e.events) do + T.eq(event.mapId, "ROOT", "event identifies active map") + T.eq(#event.maps, 3, "event includes active and connected maps") + T.eq(event.maps[1].mapId, "ROOT", "active placement first") + T.eq(event.maps[1].active, true, "active flag") + T.eq(event.maps[2].ox, 2 * f.pixel, "east offset is pixels") + T.eq(event.maps[2].oy, -f.pixel, "negative offset preserved") + rows[#rows + 1] = event + end + e.events[1].maps[1].mapId = "changed-copy" + T.eq(f.world.map.id, "ROOT", "event mutation cannot change native map") + e.events[1].maps[1].mapId = "ROOT" + end + if f.api >= 3 then + local before = f.loaderCalls() + local maps = assert(e.maps()) + T.eq(#maps, 3, "API-3 query reports loaded maps") + T.eq(maps[1].ox, 0, "API-3 active x is pixels") + T.eq(maps[2].ox, 2 * f.pixel, "API-3 neighbor x is pixels") + T.eq(maps[2].oy, -f.pixel, "API-3 neighbor y is pixels") + T.eq(maps[2].x, nil, "API-3 does not substitute cell coordinates") + T.eq(f.loaderCalls(), before, "query never loads a map") + maps[2].ox = 999 + T.eq(e.maps()[2].ox, 2 * f.pixel, "query returns detached placements") + rows[#rows + 1] = { query = e.maps() } + end + return rows +end +return M diff --git a/tests/live_maps/fixture.lua b/tests/live_maps/fixture.lua new file mode 100644 index 000000000..a8d58abb9 --- /dev/null +++ b/tests/live_maps/fixture.lua @@ -0,0 +1,189 @@ +local M = {} +function M.new(root, backend, version, api, format, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + assert(Version.VERSIONS[version], "unsupported fixture version") + Version.set(version) + local gen = Version.generation() + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local pixel = gen >= 3 and 16 or 32 + local defs = {} + for _, id in ipairs({ "ROOT", "EAST", "NORTH" }) do + defs[id] = { + id = id, + width = 2, + height = 2, + blockPx = pixel, + tileset = "FIX_OUT", + blocks = { 0, 0, 0, 0 }, + borderBlock = 0, + objects = {}, + connections = {}, + } + end + defs.ROOT.connections.east = { map = "EAST", offset = -1 } + defs.EAST.connections.north = { map = "NORTH", offset = -3 } + defs.NORTH.connections.south = { map = "ROOT", offset = 0 } + data.maps = defs + data.tilesets = data.tilesets or {} + data.tilesets.FIX_OUT = + { blockCells = pixel / 16, blockTiles = pixel / 8, blocks = {}, walkable = {}, id = "FIX_OUT" } + local files, Json = {}, require("src.link.Json") + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local f = assert(io.open(root .. "/examples/portable_live_maps/" .. name, "rb")) + files["mods/portable_live_maps/" .. name] = f:read("*a") + f:close() + end + local manifest = Json.decode(files["mods/portable_live_maps/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_live_maps/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_live_maps/main.lua"] = files["mods/portable_live_maps/main.lua"] + .. '\nerror("intentional entry failure")' + end + local result = T.sdk.loadMods({ "mods/portable_live_maps" }, { + fs = T.sdk.memfs(files), + generation = gen, + data = data, + dev = false, + }) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local function bind(fn, name, value) + for i = 1, 40 do + local key, old = debug.getupvalue(fn, i) + if not key then + break + end + if key == name then + debug.setupvalue(fn, i, value) + restores[#restores + 1] = function() + debug.setupvalue(fn, i, old) + end + return + end + end + error("missing native binding: " .. name) + end + local game = { + data = data, + save = { version = version, options = {}, flags = {}, party = {} }, + renderer = { + worldViewSize = function() + return 160, 144 + end, + }, + } + local maps = {} + local Map = require("src.world.Map") + for id, def in pairs(defs) do + maps[id] = Map.new(def, data.tilesets.FIX_OUT) + end + local world, rebuild, source, loaderCalls = nil, nil, nil, 0 + if backend == "gen1" and gen == 3 then + local Field, Runtime, Map3 = + require("src.core.game3.field"), require("src.core.game3.runtime"), require("src.core.game3.map") + game.phase, game.session = "field", { map = "ROOT" } + patch(Field, "running", true) + patch(Runtime, "getSession", function() + return game.session + end) + patch(Map3, "current", "ROOT") + source = { + { id = "ROOT", ox = 0, oy = 0 }, + { id = "EAST", ox = 2 * pixel, oy = -pixel }, + { id = "NORTH", ox = -pixel, oy = -3 * pixel }, + } + patch(Map3, "world", source) + world = { map = maps.ROOT, neighbors = {} } + rebuild = function() end -- Gen1 GBA has no producer for these events. + elseif backend == "gen1" and gen == 2 then + local World = require("src.world.gen2.World") + world = setmetatable({ + game = game, + map = maps.ROOT, + maps = defs, + tilesets = data.tilesets, + npcs = {}, + entities = {}, + npcPool = {}, + neighbors = {}, + ghosts = { "old" }, + pixelZoomScale = function() + return 1 + end, + imageFor = function() + return {} + end, + }, { __index = World }) + rebuild = function() + world:rebuildNeighbors() + world:rebuildPeople() + end + else + local World, Loader = require("src.world.OverworldController"), require("src.world.MapLoader") + world = setmetatable({ + game = game, + map = maps.ROOT, + npcs = {}, + entities = {}, + npcPool = {}, + neighbors = {}, + ghosts = { "old" }, + }, { __index = World }) + bind(World.rebuildNeighbors, "Game", game) + patch(Loader, "load", function(_, id) + loaderCalls = loaderCalls + 1 + return assert(maps[id]) + end) + patch(Loader, "trim", function() end) + rebuild = function() + world:rebuildNeighbors() + end + end + game.world, game.overworld = world, world + result.loader.game = game + local phase = {} + for _, name in ipairs({ "world.live_maps_preparing", "world.live_maps_updated" }) do + local label = name + result.loader.events:on(name, function() + phase[#phase + 1] = + { name = label, preparing = world.preparingLiveMaps == true, ghosts = #world.ghosts } + end, 100) + end + return { + T = T, + game = game, + world = world, + result = result, + e = result.loader.exports.portable_live_maps, + api = api, + gen = gen, + backend = backend, + pixel = pixel, + phase = phase, + rebuild = rebuild, + commonEvents = gen <= 2, + source = source, + patch = patch, + loaderCalls = function() + return loaderCalls + end, + close = function() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/live_maps/worker.lua b/tests/live_maps/worker.lua new file mode 100644 index 000000000..bb9fc7e68 --- /dev/null +++ b/tests/live_maps/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/live_maps/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/live_maps/cases.lua").run(f) +f.close() +print("LIVE_MAPS_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable live-map lifecycle") 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/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/party_grid/cases.lua b/tests/party_grid/cases.lua new file mode 100644 index 000000000..e21743d26 --- /dev/null +++ b/tests/party_grid/cases.lua @@ -0,0 +1,65 @@ +local M = {} +function M.run(f) + local T, e = f.T, f.exports + if e.api.supports then + T.eq(e.api:supports("hooks", "ui.party.grid_navigation"), true, "native capability") + end + T.eq(f.grid(), false, "native party navigation remains the default") + e.grid(true) + T.eq(f.grid(), true, "opt-in grid") + T.eq(e.seen.menu, f.menu, "original opaque menu") + local rows = {} + for count = 1, 6 do + f.count(count) + for index = 1, count + (f.generation == 3 and 1 or 0) do + local slot = index > count and "cancel" or index + for _, dir in ipairs({ "left", "right", "up", "down" }) do + f.index(slot) + local target = f.press(dir) + T.check( + target == "cancel" or (type(target) == "number" and target >= 1 and target <= count), + "native target is occupied or cancel" + ) + rows[#rows + 1] = { count = count, index = slot, direction = dir, target = target } + end + end + end + f.count(6) + f.index(1) + T.eq(f.press("right"), f.generation == 3 and 3 or 2, "lead's horizontal peer") + f.index(1) + T.eq(f.press("up"), f.generation == 3 and "cancel" or 5, "top edge follows native grid policy") + f.count(5) + f.index(5) + T.eq(f.press("down"), f.generation == 3 and "cancel" or 1, "odd final row") + for _, row in ipairs({ {}, { value = false }, { value = 0 }, { value = "" }, { value = {} } }) do + local off = e.api.hooks:wrap("ui.party.grid_navigation", function() + return row.value + end, 100) + T.eq(f.grid(), false, "only literal true enables grid") + off() + end + for _, after in ipairs({ false, true }) do + local off = e.api.hooks:wrap("ui.party.grid_navigation", function(next) + if after then + next() + end + error("intentional party navigation predicate failure") + end, 100) + local calls = e.seen.calls + T.eq(f.grid(), true, "failure preserves downstream decision") + T.eq(e.seen.calls, calls + 1, "failure does not replay downstream") + off() + end + local calls = e.seen.calls + f.battleMode(false) + T.eq(f.grid(), false, "field party does not opt in") + T.eq(e.seen.calls, calls, "field party bypasses battle predicate") + f.battleMode(true) + e.stop() + e.stop() + T.eq(f.grid(), false, "unsubscribe restores native controller") + T.eq(e.seen.calls, calls, "unsubscribe stops consumer") + return rows +end +return M diff --git a/tests/party_grid/fixture.lua b/tests/party_grid/fixture.lua new file mode 100644 index 000000000..4fbd83c25 --- /dev/null +++ b/tests/party_grid/fixture.lua @@ -0,0 +1,109 @@ +local M = {} +function M.new(root, backend, version, api, format) + local f = dofile(root .. "/tests/trainer_preparation/fixture.lua").new( + root, + backend, + version, + api, + format, + false, + false, + "portable_party_grid" + ) + f.start() + local game, battle, party = f.game, f.battle(), f.party() + local menu + local original = party[1] + if backend == "gen1" and f.generation == 3 then + menu = require("src.ui.game3.party_menu") + menu.show(party, nil, { session = game.session, battle = battle, mode = "battle_switch" }) + f.index = function(value) + if value then + menu.cursor = value == "cancel" and 7 or value + end + return menu.cursor == 7 and "cancel" or menu.cursor + end + f.count = function(n) + menu._party = {} + for i = 1, n do + menu._party[i] = original + end + end + local oldClose = f.close + f.close = function() + menu.open = false + oldClose() + end + f.update = function() + menu.handleInput(game.input) + end + f.battleMode = function(value) + menu._battle = value and battle or nil + end + elseif backend == "gen1" and f.generation == 2 then + menu = require("src.ui.gen2.PartyMenu").new(game, { party = party, battle = true }) + f.count = function(n) + menu.party = {} + for i = 1, n do + menu.party[i] = original + end + end + else + local class = f.generation == 3 and "src.ui.Gen3PartyMenu" or "src.ui.PartyMenu" + menu = require(class).new(game, { battle = battle, party = party }) + f.count = function(n) + local list = {} + for i = 1, n do + list[i] = original + end + if f.generation ~= 3 then + menu.party = list + end + battle.playerParty = list + end + if f.generation == 3 then + game.data.constants = game.data.constants or {} + local slots = {} + for i = 1, 6 do + slots[i] = { + window = { x = 0, y = 0, width = 80, height = 32 }, + icon = { x = i == 1 and 16 or 104, y = i == 1 and 40 or 18 + 24 * (i - 2) }, + } + end + game.data.constants.gen3PartyMenu = { slots = slots } + end + end + if not f.index then + f.index = function(value) + if value then + menu.index = value == "cancel" and #menu:party() + 1 or value + end + if f.generation == 3 and menu.index == #menu:party() + 1 then + return "cancel" + end + return menu.index + end + end + f.battleMode = f.battleMode or function(value) + menu.battle = value and battle or nil + end + f.update = f.update or function() + menu:update(0) + end + f.menu = menu + f.press = function(direction) + game.input.pressed = type(direction) == "table" and direction + or (direction and { [direction] = true } or {}) + f.update() + return f.index() + end + f.grid = function() + if backend == "gen1" and f.generation == 3 then + return menu.gridNavigation() + end + return menu:gridNavigation() + end + f.count(6) + return f +end +return M diff --git a/tests/party_grid/worker.lua b/tests/party_grid/worker.lua new file mode 100644 index 000000000..2e46e6fa5 --- /dev/null +++ b/tests/party_grid/worker.lua @@ -0,0 +1,7 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version, api, format = unpack(arg) +local f = dofile(root .. "/tests/party_grid/fixture.lua").new(root, backend, version, tonumber(api), format) +local rows = dofile(root .. "/tests/party_grid/cases.lua").run(f) +f.close() +print("PARTY_GRID_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable ui.party.grid_navigation native controllers") diff --git a/tests/playthrough_storage/cases.lua b/tests/playthrough_storage/cases.lua new file mode 100644 index 000000000..fbb46d0ef --- /dev/null +++ b/tests/playthrough_storage/cases.lua @@ -0,0 +1,96 @@ +local M = {} +function M.run(f, version) + local T, e, rows = f.T, f.e, {} + local function eq(actual, expected, label) + T.eq(actual, expected, label) + rows[#rows + 1] = { case = label, value = actual } + end + local a, b = f.game("first"), f.game("second") + local context = assert(e.context(a)) + eq(context.gameVersion, version, "context version") + eq(context.playthroughId, "first", "context identity") + eq(type(context.engineVersion), "string", "context engine version") + eq(context.root, nil, "no private root") + eq(e.write(a, "state/value", { n = 7, nested = { ready = true } }), true, "write record") + local got = assert(e.read(a, "state/value")) + got.nested.ready = false + eq(e.read(a, "state/value").nested.ready, true, "decoded copy isolation") + local _, code = e.read(b, "state/value") + eq(code, "not_found", "playthrough isolation") + _, code = f.other.read(a, "state/value") + eq(code, "not_found", "mod isolation") + local otherVersion = f.game("first") + otherVersion.save.version = version == "red" and "blue" or "red" + _, code = e.read(otherVersion, "state/value") + eq(code, "not_found", "version isolation") + local bytes = "CACHE\0\255\128 return MALICIOUS = true" + eq(e.writeBytes(a, "state/bytes", bytes), true, "write bytes") + eq(e.readBytes(a, "state/bytes") == bytes, true, "opaque byte roundtrip") + _, code = e.read(a, "state/bytes") + eq(code, "type_mismatch", "record-byte read mismatch") + _, code = e.write(a, "state/bytes", {}) + eq(code, "type_conflict", "record cannot replace bytes") + _, code = e.writeBytes(a, "state/value", "x") + eq(code, "type_conflict", "bytes cannot replace record") + eq(e.writeBytes(a, "state/empty", ""), true, "empty bytes write") + eq(e.readBytes(a, "state/empty"), "", "empty bytes read") + for _, key in ipairs({ "", "../escape", "/absolute", "double//slash", "tail/", "a.b", "a\\b" }) do + local ok + ok, code = e.write(a, key, {}) + eq(ok, false, "reject key " .. key) + eq(code, "invalid_key", "key error " .. key) + end + local ok + ok, code = e.write(a, "bad/value", { callback = function() end }) + eq(ok, false, "reject executable data") + eq(code, "encode_failed", "data-only error") + eq( + table.concat(assert(e.list(a, "state")), ","), + "state/bytes,state/empty,state/value", + "sorted unique listing" + ) + eq(table.concat(assert(e.list(a, "state/value")), ","), "state/value", "exact-key prefix") + local main + for path in pairs(f.files) do + if path:match("/first/portable_storage/state/value%.lua$") then + main = path + end + end + assert(main, "native persisted record missing") + f.files[main] = "corrupt" + eq(e.read(a, "state/value").n, 7, "backup recovery") + f.fs.fail = ".tmp" + ok, code = e.write(a, "state/value", { n = 8 }) + eq(ok, false, "staging failure") + eq(code, "write_failed", "staging error") + f.fs.fail = ".lua" + ok, code = e.write(a, "state/value", { n = 9 }) + eq(ok, false, "replacement failure") + eq(code, "write_failed", "replacement error") + f.fs.fail = nil + eq(e.read(a, "state/value").n, 7, "failed write retains data") + a.stack = { states = { { screenId = "TitleState" } } } + local selected = assert(e.selected(a)) + eq(selected:context().playthroughId, "first", "selected context") + eq(selected:read("state/value").n, 7, "selected read") + local detached = selected:context() + detached.playthroughId = "second" + a.save.meta.playthroughId = "second" + eq(selected:read("state/value").n, 7, "selected facade cannot be rerouted") + _, code = selected:read(b, "state/value") + eq(code, "invalid_key", "selected rejects injected game") + _, code = e.selected(b) + eq(code, "not_at_title", "selected title guard") + local empty = f.game(nil) + empty.stack = a.stack + _, code = e.selected(empty) + eq(code, "no_selected_playthrough", "selected does not allocate") + eq(e.delete(f.game("first"), "state/value"), true, "delete record") + _, code = selected:read("state/value") + eq(code, "not_found", "delete removes recovery copies") + eq(e.delete(f.game("first"), "state/bytes"), true, "delete bytes") + _, code = e.readBytes(f.game("first"), "state/bytes") + eq(code, "not_found", "delete removes byte recovery") + return rows +end +return M diff --git a/tests/playthrough_storage/fixture.lua b/tests/playthrough_storage/fixture.lua new file mode 100644 index 000000000..5a15f187b --- /dev/null +++ b/tests/playthrough_storage/fixture.lua @@ -0,0 +1,67 @@ +local M = {} +function M.new(root, backend, version) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + assert(Version.VERSIONS[version], "unsupported native game: " .. tostring(version)) + Version.set(version) + local Save = require("src.core.SaveData") + local Json = require("src.link.Json") + local files = {} + for _, id in ipairs({ "portable_storage", "other_storage" }) do + local function read(name) + local f = assert(io.open(root .. "/examples/portable_storage/" .. name, "rb")) + local s = f:read("*a") + f:close() + return s + end + local manifest = Json.decode(read("manifest.json")) + manifest.id = id + files["mods/" .. id .. "/manifest.json"] = Json.encode(manifest) + files["mods/" .. id .. "/main.lua"] = read("main.lua") + end + local fs = T.sdk.memfs(files) + local write = fs.write + fs.write = function(path, body) + if fs.fail and path:sub(-#fs.fail) == fs.fail then + return false, "intentional write denial" + end + return write(path, body) + end + fs.remove = function(path) + files[path] = nil + return true + end + fs.createDirectory = function() + return true + end + local oldFs, oldPortable = love.filesystem, Save.portableFs + love.filesystem, Save.portableFs = fs, function() + return fs + end + Save.resetSlotState() + local result = T.sdk.loadMods({ "mods/portable_storage", "mods/other_storage" }, { + fs = fs, + data = T.fixtures.fresh(), + generation = Version.generation(), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + return { + T = T, + fs = fs, + files = files, + Save = Save, + result = result, + e = result.loader.exports.portable_storage, + other = result.loader.exports.other_storage, + game = function(id) + return { save = { version = version, meta = { playthroughId = id } } } + end, + close = function() + result.release() + love.filesystem, Save.portableFs = oldFs, oldPortable + Save.resetSlotState() + end, + } +end +return M diff --git a/tests/playthrough_storage/lifecycle.lua b/tests/playthrough_storage/lifecycle.lua new file mode 100644 index 000000000..728f68b06 --- /dev/null +++ b/tests/playthrough_storage/lifecycle.lua @@ -0,0 +1,27 @@ +local M = {} +function M.run(f, version) + local T, e, S, rows = f.T, f.e, f.Save, {} + local function check(value, label) + T.eq(value, true, label) + rows[#rows + 1] = { case = label, value = value } + end + local a = { save = S.newGame({ version = version }) } + local first = assert(e.context(a)).playthroughId + check(e.write(a, "lifecycle/value", { n = 27 }), "lifecycle write") + check(S.save(a.save, {}), "native ordinary save") + S.resetSlotState() + local loaded = { save = assert(S.load(version)) } + check(e.context(loaded).playthroughId == first, "native reload preserves storage identity") + check(e.read(loaded, "lifecycle/value").n == 27, "native reload preserves mod record") + local b = { save = S.newGame({ version = version }) } + check(e.context(b).playthroughId ~= first, "new game receives separate namespace") + local value, code = e.read(b, "lifecycle/value") + check(value == nil and code == "not_found", "new game cannot inherit prior mod record") + loaded.stack = { states = { { screenId = "TitleState" } } } + local selected = assert(e.selected(loaded)) + check(selected:context().playthroughId == first, "title resolves selected identity") + check(selected:read("lifecycle/value").n == 27, "title resolves selected mod record") + check(type(selected:context().normalSavedAt) == "number", "title resolves ordinary-save chronology") + return rows +end +return M diff --git a/tests/playthrough_storage/worker.lua b/tests/playthrough_storage/worker.lua new file mode 100644 index 000000000..b39c7f088 --- /dev/null +++ b/tests/playthrough_storage/worker.lua @@ -0,0 +1,11 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local root, backend, version = unpack(arg) +local f = dofile(root .. "/tests/playthrough_storage/fixture.lua").new(root, backend, version) +local rows = dofile(root .. "/tests/playthrough_storage/cases.lua").run(f, version) +local lifecycle = dofile(root .. "/tests/playthrough_storage/lifecycle.lua").run(f, version) +for _, row in ipairs(lifecycle) do + rows[#rows + 1] = row +end +f.close() +print("STORAGE_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable playthrough storage") 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/status_hud/cases.lua b/tests/status_hud/cases.lua new file mode 100644 index 000000000..4dbf8c665 --- /dev/null +++ b/tests/status_hud/cases.lua @@ -0,0 +1,114 @@ +local M = {} +function M.run(f) + local T, b, api, e = f.T, f.battle, f.public, f.exports + local rows = {} + local function observe(label, expected) + T.eq(f.visible(), expected, label .. " native predicate") + local drawn = f.draw() + T.eq(drawn, expected, label .. " native HUD draw") + rows[#rows + 1] = { label = label, visible = drawn } + end + if api.supports then + T.eq(api:supports("hooks", "battle.status_hud_visible"), true, "native capability") + end + f.stack.states = { b } + observe("native", true) + T.eq(e.seen.state, b, "original battle handle in every callback format") + T.eq(e.seen.extra, 0, "no invented arguments") + e.hide(true) + observe("hidden", false) + T.eq(f.stack:top(), b, "hidden HUD leaves native state on stack") + local polls = e.seen.calls + f.input:keypressed("z") + f.input:step() + T.eq(f.input:wasPressed("a"), true, "predicate does not consume native input") + T.eq(e.seen.calls, polls, "input does not query rendering predicate") + f.input:keyreleased("z") + f.input:step() + local off + 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("battle.status_hud_visible", function() + return case.value + end, 100) + T.eq(f.visible(), true, case.name .. " result remains visible") + off() + end + off = api.hooks:wrap("battle.status_hud_visible", function() + return false, true + end, 100) + T.eq(f.visible(), false, "first result alone controls visibility") + off() + e.hide(false) + local replacement = { isBattle = true, contract = "replacement" } + off = api.hooks:wrap("battle.status_hud_visible", function(next, state) + T.eq(state, b, "outer sees original battle") + return next(replacement) + end, 100) + T.eq(f.visible(), true, "next forwards replacement predicate argument") + T.eq(e.seen.state, replacement, "downstream sees replacement identity") + off() + local order = {} + local outer = api.hooks:wrap("battle.status_hud_visible", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("battle.status_hud_visible", function(next, state) + order[#order + 1] = "inner" + T.eq(state, b, "next() retains current handle") + return next() + end, 10) + T.eq(f.visible(), true, "forwarded default true") + T.same(order, { "outer", "inner", "after" }, "descending priority") + outer() + inner() + e.hide(true) + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("battle.status_hud_visible", function(next) + if after then + next() + end + error("intentional status HUD predicate failure") + end, 100) + T.eq(f.visible(), false, "wrapper failure preserves downstream false") + off() + end + if f.api >= 3 then + off = api.hooks:wrap("battle.status_hud_visible", function(next) + next() + return next() + end, 100) + polls = e.seen.calls + T.eq(f.visible(), false, "API-3 cached false") + T.eq(e.seen.calls, polls + 1, "API-3 repeated next does not replay downstream") + off() + end + e.hide(false) + observe("restored", true) + e.stop() + e.stop() + polls = e.seen.calls + observe("unsubscribed", true) + T.eq(e.seen.calls, polls, "unsubscribe is idempotent") + api.hooks:wrap("battle.status_hud_visible", function() + return false + end, 100) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("battle.status_hud_visible", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_bottom_owner") + f.result.loader.hooks:removeOwner("portable_status_hud") + T.eq(f.visible(), true, "owner cleanup restores native visibility") + T.eq(peerCalls, 1, "owner cleanup preserves another mod") + peer() + return rows +end +return M diff --git a/tests/status_hud/fixture.lua b/tests/status_hud/fixture.lua new file mode 100644 index 000000000..8123de1a3 --- /dev/null +++ b/tests/status_hud/fixture.lua @@ -0,0 +1,198 @@ +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, Json = {}, require("src.link.Json") + for _, path in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_status_hud/" .. path, "rb")) + files["mods/portable_status_hud/" .. path] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_status_hud/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_status_hud/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_status_hud" }, { + data = data, + generation = generation, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + assert(#result.errors == 0, table.concat(result.errors, "\n")) + local restores, counters = {}, { ink = 0 } + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local function ink() + counters.ink = counters.ink + 1 + return 16 + end + local Input = require("src.core.Input") + Input:init() + local stack = setmetatable({}, { __index = require("src.core.StateStack") }) + stack:init() + local game = { stack = stack, input = Input, data = data, save = { options = {} }, options = {} } + local b, Ui, draw + if backend == "gen1" and generation == 3 then + patch(require("src.core.game3.battle.pic_coords"), "active", function() + return { front = {}, back = {}, elev = {} } + end) + local State = require("src.core.game3.battle.state") + local function mon(species) + return { + species = species, + nickname = "CONTRACT", + level = 20, + hp = 60, + maxHp = 60, + attack = 30, + defense = 30, + speed = 30, + spAtk = 30, + spDef = 30, + moves = { 33 }, + pp = { 35 }, + maxPp = { 35 }, + item = 0, + ability = 0, + } + end + b = State.new({ playerParty = { mon(4) }, foeParty = { mon(16) }, wild = true }) + Ui = require("src.core.game3.battle.ui") + Ui.reset({ headless = true }) + Ui.bindState(b, { options = {} }) + local Anim = require("src.core.game3.battle.anim") + Anim.reset({ headless = true }) + for id = 0, 1 do + Anim.setShown(id, State.battler(b, id)) + end + -- Spy at the healthbox leaf, leaving the native GBA compositor/predicate intact. + patch(require("src.core.game3.battle.healthbox"), "draw", ink) + draw = function() + Ui.draw(240, 160, true) + end + else + local class = + require(backend == "gen1" and generation == 2 and "src.ui.gen2.BattleState" or "src.battle.BattleState") + local function side() + return { + name = "CONTRACT", + mon = { species = "FIXMON_A", level = 20, hp = 60, stats = { hp = 60 } }, + shownHP = 60, + } + end + b = setmetatable({ + game = game, + data = data, + phase = "contract", + frame = 0, + shown = {}, + fx = {}, + player = side(), + enemy = side(), + ballRows = {}, + showEnemyHud = true, + showPlayerHud = true, + }, { __index = class }) + local Font = require("src.render.Font") + for _, name in ipairs({ "draw", "drawCode", "drawBox" }) do + patch(Font, name, ink) + end + if backend == "gen1" and generation == 2 then + local Chrome = require("src.ui.gen2.Chrome") + patch(Chrome, "printThrough", ink) + patch(Chrome, "printRightThrough", ink) + b.hud = { + available = function() + return true + end, + drawEnemyFrame = ink, + drawPlayerFrame = ink, + drawExpBar = ink, + } + b.activeMon = function(self, side) + return self[side].mon + end + b.name = function() + return "CONTRACT" + end + b.hudCleared = function() + return false + end + b.statusTag = function() + return nil + end + b.genderSymbol = function() + return nil + end + b.caughtMarkerVisible = function() + return false + end + b.hudHp = function() + return 60 + end + b.drawHpBar = ink + draw = function() + b:drawEnemyHud() + b:drawPlayerHud() + end + else + b.growInScale = function() + return nil + end + b.colorMode = function() + return false + end + b.demoHidesPlayer = function() + return false + end + draw = function() + b:drawHUDs(0) + end + end + end + local exports = result.loader.exports.portable_status_hud + return { + T = T, + result = result, + game = game, + battle = b, + stack = stack, + input = Input, + api = api, + generation = generation, + exports = exports, + public = exports and exports.api, + patch = patch, + visible = function() + if Ui then + return Ui.statusHUDVisible() + end + return b:statusHUDVisible() + end, + draw = function() + counters.ink = 0 + draw() + return counters.ink > 0 + end, + close = function() + if Ui then + Ui.reset({ headless = true }) + end + for i = #restores, 1, -1 do + restores[i]() + end + if result.loader._closeModInput then + result.loader:_closeModInput() + end + result.release() + end, + } +end +return M diff --git a/tests/status_hud/worker.lua b/tests/status_hud/worker.lua new file mode 100644 index 000000000..31ff676a8 --- /dev/null +++ b/tests/status_hud/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/status_hud/fixture.lua") +local f = fixture.new(root, backend, version, api, format) +local rows = dofile(root .. "/tests/status_hud/cases.lua").run(f) +f.close() +local bare = fixture.new(root, backend, version, api, format, true) +bare.T.eq(require("src.mods.Runtime").wantsHook("battle.status_hud_visible"), false, "no-mod Runtime") +bare.T.eq(bare.visible(), true, "no-mod default true") +bare.T.eq(bare.draw(), true, "no-mod native HUD draw") +bare.close() +print("STATUS_HUD_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable battle.status_hud_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/trainer_preparation/cases.lua b/tests/trainer_preparation/cases.lua new file mode 100644 index 000000000..bff3a297c --- /dev/null +++ b/tests/trainer_preparation/cases.lua @@ -0,0 +1,87 @@ +local M = {} +function M.run(new) + local rows, T = {}, nil + for _, mode in ipairs({ + "no-mod", + "observe", + "false", + "truthy", + "nil", + "defer", + "cancel", + "immediate", + "error", + "after error", + "unsubscribe", + "owner cleanup", + "rollback", + "invalid scope", + }) do + local f = new(mode == "no-mod", mode == "rollback") + T = f.T + local e, original = f.exports, f.game.save.party + local members = { original[1], original[2], original[3] } + if e then + e.mode = (mode == "cancel" or mode == "invalid scope") and "defer" or mode + e.options = { playerPartyIndices = { 3, 2 } } + if mode == "unsubscribe" then + e.stop() + e.stop() + end + if mode == "owner cleanup" then + f.result.loader.hooks:removeOwner("portable_trainer_preparation") + end + end + local off + if e and (mode == "truthy" or mode == "nil") then + local api = e.api + off = api.hooks:wrap("trainer.before_battle", function() + if mode == "truthy" then + return 1 + end + end, 100) + end + f.start() + local selected = mode == "defer" or mode == "immediate" + if mode == "defer" or mode == "cancel" or mode == "invalid scope" then + T.eq(f.starts(), 0, mode .. " constructs no battle before decision") + T.eq(e.calls, 1, mode .. " native hook fires once") + T.eq(type(e.context), "table", "data-only trainer context") + T.eq(e.context.partyIndex, 1, "native party index") + T.eq(e.extra, 0, "native hook arity") + if f.backend == "gen2" or f.api < 3 then + T.eq(e.game, f.game, "opaque original Game") + else + T.eq(e.game.sessionStartedAt, 1, "Gen1 API3 Game context boundary") + end + local options = mode == "cancel" and { cancel = true } + or { playerPartyIndices = mode == "invalid scope" and { 1, 1 } or { 3, 2 } } + T.eq(e.continue(options), true, "retained native continuation accepted") + T.eq(e.continue(), false, "retained native continuation one-shot") + end + local cancelled = mode == "cancel" + T.eq(f.starts(), cancelled and 0 or 1, mode .. " starts exactly once or cancels") + T.eq(f.cancels(), cancelled and 1 or 0, mode .. " cancellation callback") + T.eq(f.game.save.party, original, "save party table is never swapped") + T.same(f.game.save.party, members, "save party order and references unchanged") + for i, mon in ipairs(members) do + T.eq(f.game.save.party[i], mon, "save mon identity retained") + end + if not cancelled then + local party = assert(f.party(), "real native battle party") + T.eq(#party, selected and 2 or 3, mode .. " native battle party size") + T.eq(party[1].nickname, selected and "THREE" or "ONE", mode .. " native lead/order") + if selected then + T.eq(party[2].nickname, "TWO", "second selected member") + end + end + rows[#rows + 1] = + { mode = mode, starts = f.starts(), cancels = f.cancels(), size = f.party() and #f.party() or 0 } + if off then + off() + end + f.close() + end + return rows, T +end +return M diff --git a/tests/trainer_preparation/fixture.lua b/tests/trainer_preparation/fixture.lua new file mode 100644 index 000000000..59047ca47 --- /dev/null +++ b/tests/trainer_preparation/fixture.lua @@ -0,0 +1,321 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing, consumer) + consumer = consumer or "portable_trainer_preparation" + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation() + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/" .. consumer .. "/" .. name, "rb")) + files["mods/" .. consumer .. "/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/" .. consumer .. "/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/" .. consumer .. "/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/" .. consumer .. "/main.lua"] = files["mods/" .. consumer .. "/main.lua"] + .. '\nerror("intentional entry failure")' + end + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + if backend == "gen1" and gen == 2 then + data = { + version = version, + pokemon = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, constant = 0 }, + }, + MAGIKARP = { + id = "MAGIKARP", + name = "MAGIKARP", + index = 129, + dex = 129, + types = { "WATER", "WATER" }, + genderRatio = 127, + catchRate = 255, + baseExp = 20, + growthRate = "GROWTH_MEDIUM_FAST", + baseStats = { + hp = 80, + attack = 10, + defense = 55, + speed = 80, + specialAttack = 15, + specialDefense = 20, + }, + levelMoves = { { level = 1, move = "SPLASH" } }, + evolutions = {}, + }, + }, + moves = { + SPLASH = { + id = "SPLASH", + name = "SPLASH", + type = "NORMAL", + power = 0, + accuracy = 100, + pp = 40, + effect = "EFFECT_SPLASH", + }, + }, + type_chart = { + types = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + WATER = { id = "WATER", index = 21, category = "special" }, + }, + matchups = {}, + }, + items = {}, + } + end + local result = T.sdk.loadMods(noMod and {} or { "mods/" .. consumer }, { + data = data, + generation = gen, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local restores = {} + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local stack = setmetatable({}, { __index = require("src.core.StateStack") }) + stack:init() + local input = setmetatable({}, { __index = require("src.core.Input") }) + input:init() + local game = { + data = data, + stack = stack, + input = input, + sessionStartedAt = 1, + save = { + version = version, + party = {}, + player = { name = "PROBE" }, + options = {}, + flags = {}, + defeatedTrainers = {}, + engineFlags = {}, + inventory = {}, + money = 1000, + pokedex = { seen = {}, caught = {} }, + }, + } + local context = + { trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, mapId = "FIX_ROUTE", npcId = "TRAINER_1" } + local battle, starts, cancels, start, closeNative = nil, 0, 0 + if backend == "gen1" and gen == 3 then + require("src.import.gba.versions").select(version) + local P = require("src.core.game3.pokemon") + for key, value in pairs(data.gen3Pokemon) do + patch(P, key, value) + end + patch(P, "_front", {}) + patch(P, "_back", {}) + patch(P, "_national", { toNational = { [4] = 4, [16] = 16 } }) + patch(P, "_battleMoves", data.gen3Moves._rom) + patch(P, "_romMoveNames", data.gen3Pokemon._moveNames) + local function mon(species, nickname) + return { + species = species, + nickname = nickname, + level = 20, + hp = 60, + maxHp = 60, + attack = 30, + defense = 30, + speed = 30, + spAtk = 30, + spDef = 30, + moves = { 33 }, + pp = { 35 }, + maxPp = { 35 }, + ability = 0, + personality = 1, + growthRate = 0, + exp = 8000, + item = 0, + otId = 1, + otSecretId = 2, + } + end + local session = { + version = version, + party = { mon(4, "ONE"), mon(4, "TWO"), mon(4, "THREE") }, + name = "RED", + trainerId = 1, + secretId = 2, + flags = {}, + vars = {}, + options = {}, + } + game.session = session + game.save.party = session.party + local R = require("src.core.game3.runtime") + patch(R, "active", true) + patch(R, "session", session) + patch(R, "_game", game) + local Field = require("src.core.game3.field") + patch(Field, "running", false) + patch(Field, "_session", session) + patch(Field, "_game", game) + patch(Field, "_locks", {}) + patch(require("src.core.game3.map"), "current", nil) + local Space = require("src.core.game3.scripting.space") + patch(Space, "vm", nil) + patch(Space, "bundle", { + text = setmetatable({}, { + __index = function(t, key) + local ir = { { t = "text", s = tostring(key), v = tostring(key), text = tostring(key) } } + rawset(t, key, ir) + return ir + end, + }), + maps = {}, + events = {}, + }) + local B = require("src.core.game3.battle") + local Bridge = require("src.core.game3.battle_bridge") + B.reset() + Bridge.reset() + local foe = mon(16, "FOE") + foe.party = { mon(16, "FOE") } + foe.trainerClass, foe.trainerId = 1, 1 + patch(require("src.core.game3.scripting.trainers"), "_pack", { + classNames = { [0] = "TRAINER", [1] = "PROBE" }, + trainers = { + [0] = { + class = 0, + name = "TRAINER", + pic = 0, + party = { { species = 16, level = 5, moves = { 33 } } }, + }, + [1] = { class = 1, name = "PROBE", pic = 0, party = { { species = 16, level = 5, moves = { 33 } } } }, + }, + }) + local cache = require("src.core.game3.dataset").cache() + local read = cache.read + patch(cache, "read", function(self, path, ...) + if path:match("/battle_moves%.lua$") then + return "return {moves={[33]={effect=0,power=35,type=0,accuracy=100,pp=35,target=0,priority=0}}}" + end + return read(self, path, ...) + end) + result.loader.events:on("battle.started", function() + starts = starts + 1 + battle = B.getState() + end) + start = function() + return Bridge.start(nil, game, foe, { + headless = true, + fade = false, + autoFight = false, + cache = cache, + song = 1, + mapBehavior = 0, + noWhiteout = true, + cancel = function() + cancels = cancels + 1 + end, + failed = function(reason) + error("native trainer start: " .. tostring(reason)) + end, + }) + end + closeNative = function() + B.reset() + Bridge.reset() + R.drainDeferred() + end + elseif backend == "gen1" and gen == 2 then + local Mon = require("src.battle.gen2.Mon") + for i, name in ipairs({ "ONE", "TWO", "THREE" }) do + local m = Mon.new(data, "MAGIKARP", 20) + m.nickname = name + game.save.party[i] = m + end + local World = require("src.world.gen2.World") + local world = World.new(game) + game.world = world + local record = { + class = 1, + member = 1, + event = 700, + name = "PROBE", + className = "YOUNGSTER", + roster = { { species = "MAGIKARP", level = 5 } }, + } + world.vm = require("src.script.gen2.Vm").new({}, {}, world.events) + result.loader.events:on("battle.started", function(payload) + local native = payload.battle.battle or payload.battle + if native ~= battle then + battle = native + starts = starts + 1 + end + end) + start = function() + return world:startScriptedBattle(record, nil, function() + cancels = cancels + 1 + end) + end + else + local Save = require("src.core.SaveData") + game.save = Save.newGame() + local P = require("src.pokemon.Pokemon") + for i, name in ipairs({ "ONE", "TWO", "THREE" }) do + game.save.party[i] = P.new(data, "FIXMON_A", 20) + game.save.party[i].nickname = name + end + local World = require("src.world.OverworldController") + start = function() + return World.prepareTrainerBattle(game, context, function(options) + battle = require("src.battle.BattleState").newTrainer(game, "OPP_FIX_YOUNGSTER", 1, options) + starts = starts + 1 + end, function() + cancels = cancels + 1 + end) + end + end + result.loader.game = game + return { + T = T, + api = api, + backend = backend, + generation = gen, + result = result, + game = game, + exports = result.loader.exports[consumer], + patch = patch, + start = start, + starts = function() + return starts + end, + cancels = function() + return cancels + end, + party = function() + local b = battle + return b and (b.playerParty or b.party or (b.player and b.player.party) or game.save.party) or nil + end, + battle = function() + return battle + end, + close = function() + if closeNative then + closeNative() + end + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/trainer_preparation/worker.lua b/tests/trainer_preparation/worker.lua new file mode 100644 index 000000000..dbfe78eb0 --- /dev/null +++ b/tests/trainer_preparation/worker.lua @@ -0,0 +1,8 @@ +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/trainer_preparation/fixture.lua") +local rows, T = dofile(root .. "/tests/trainer_preparation/cases.lua").run(function(noMod, failing) + return Fixture.new(root, backend, version, api, format, noMod, failing) +end) +print("TRAINER_PREPARATION_CONTRACT " .. require("src.link.Json").encode(rows)) +T.finish("portable trainer.before_battle contract") 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/tests/world_talk/cases.lua b/tests/world_talk/cases.lua new file mode 100644 index 000000000..49898f92c --- /dev/null +++ b/tests/world_talk/cases.lua @@ -0,0 +1,118 @@ +local M = {} +function M.run(f) + local T, e, api = f.T, f.exports, f.public + local rows = {} + local function observe(label, claimed) + e.claim(claimed and f.npc.id or nil) + local before, eventCount = #f.calls, #f.events + f.interact() + local native = #f.calls - before + T.eq(native, claimed and 0 or 1, label .. " native continuation count") + T.eq(#f.events - eventCount, 1, label .. " native interaction event remains") + local event = f.events[#f.events] + T.eq(event.target, f.npc, label .. " event keeps resolved original actor") + T.eq(event.kind, "npc", label .. " event keeps NPC kind") + T.eq(event.mapId, "FIX_ROUTE", label .. " event keeps native map") + T.eq(e.seen.owner, f.owner, label .. " opaque native owner") + T.eq(e.seen.target, f.npc, label .. " original actor identity") + T.eq(e.seen.extra, 0, label .. " no extra arguments") + rows[#rows + 1] = { label = label, native = native } + end + if api.supports then + T.eq(api:supports("hooks", "world.talk"), true, "capability at mod entry") + end + observe("unclaimed", false) + observe("claimed", true) + T.eq(e.seen.claimed, 1, "same consumer can answer its NPC") + f.counter(true) + observe("counter claimed", true) + observe("counter default", false) + f.counter(false) + f.npc.def.runtime = true + observe("runtime claimed", true) + observe("runtime default", false) + f.npc.def.runtime = nil + -- Returning false does not mean fall through: calling next controls dispatch. + local off = api.hooks:wrap("world.talk", function() + return false + end, 100) + local before = #f.calls + f.interact() + T.eq(#f.calls, before, "false without next claims the interaction") + off() + local order = {} + local outer = api.hooks:wrap("world.talk", function(next) + order[#order + 1] = "outer" + local value = next() + order[#order + 1] = "after" + return value + end, 20) + local inner = api.hooks:wrap("world.talk", function(next, owner, target) + order[#order + 1] = "inner" + T.eq(owner, f.owner, "next() retains owner") + T.eq(target, f.npc, "next() retains actor") + return next() + end, 10) + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "chained next reaches native once") + T.same(order, { "outer", "inner", "after" }, "descending priority") + outer() + inner() + for _, after in ipairs({ false, true }) do + off = api.hooks:wrap("world.talk", function(next) + if after then + next() + end + error("intentional talk hook failure") + end, 100) + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "failure before/after next does not duplicate native talk") + off() + end + local replacement = { id = "REPLACEMENT" } + off = api.hooks:wrap("world.talk", function(next, owner) + return next(owner, replacement) + end, 100) + f.interact() + T.eq(e.seen.target, replacement, "explicit next arguments reach downstream opaque") + off() + if f.api >= 3 then + local savedNext + off = api.hooks:wrap("world.talk", function(next) + savedNext = next + local value = next() + T.eq(next(), value, "API 3 repeated continuation retains result") + return value + end, 100) + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "API 3 next is cached") + T.eq(pcall(savedNext), f.backend == "gen1", "native continuation lifetime boundary") + T.eq(#f.calls, before + 1, "late continuation never repeats native talk") + off() + end + observe("restored", false) + e.stop() + e.stop() + local polls = e.seen.calls + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "unsubscribe restores native route") + T.eq(e.seen.calls, polls, "unsubscribe idempotent") + api.hooks:wrap("world.talk", function() end) + local peerCalls = 0 + local peer = f.result.loader.hooks:wrap("world.talk", function(next, ...) + peerCalls = peerCalls + 1 + return next(...) + end, 10, "other_talk_owner") + f.result.loader.hooks:removeOwner("portable_world_talk") + before = #f.calls + f.interact() + T.eq(#f.calls, before + 1, "owner cleanup restores native talk") + T.eq(peerCalls, 1, "another owner's callback survives cleanup") + peer() + return rows +end +return M diff --git a/tests/world_talk/fixture.lua b/tests/world_talk/fixture.lua new file mode 100644 index 000000000..010adea82 --- /dev/null +++ b/tests/world_talk/fixture.lua @@ -0,0 +1,193 @@ +local M = {} +function M.new(root, backend, version, api, format, noMod, failing) + local T = require("tests.modkit") + local Version = require("src.core.GameVersion") + Version.set(version) + local gen = Version.generation() + local Json, files = require("src.link.Json"), {} + for _, name in ipairs({ "main.lua", "manifest.json" }) do + local file = assert(io.open(root .. "/examples/portable_world_talk/" .. name, "rb")) + files["mods/portable_world_talk/" .. name] = file:read("*a") + file:close() + end + local manifest = Json.decode(files["mods/portable_world_talk/manifest.json"]) + manifest.api, manifest.callback_format = api, format + files["mods/portable_world_talk/manifest.json"] = Json.encode(manifest) + if failing then + files["mods/portable_world_talk/main.lua"] = files["mods/portable_world_talk/main.lua"] + .. '\nerror("intentional talk entry failure")' + end + local data = backend == "gen1" and gen == 3 and T.sdk.gen3Data() or T.fixtures.fresh() + local result = T.sdk.loadMods(noMod and {} or { "mods/portable_world_talk" }, { + data = data, + generation = gen, + fs = T.sdk.memfs(noMod and {} or files), + dev = false, + }) + if not failing then + assert(#result.errors == 0, table.concat(result.errors, "\n")) + end + local restores, calls, events, counter = {}, {}, {}, false + local function patch(owner, key, value) + local old = owner[key] + restores[#restores + 1] = function() + owner[key] = old + end + owner[key] = value + end + local npc = { + id = "GUIDE", + localId = 1, + cellX = 4, + cellY = 5, + targetX = 4, + targetY = 5, + moving = false, + visible = true, + facing = "down", + def = { scriptKey = "TALK", index = 0 }, + } + local player = { + cellX = 4, + cellY = 6, + facing = "up", + moving = false, + facingCell = function() + return 4, 5 + end, + } + local game = { data = data, save = { version = version, map = "FIX_ROUTE", options = {} } } + local world, owner, interact + if backend == "gen1" and gen == 3 then + local Objects = require("src.core.game3.objects") + local Field = require("src.core.game3.field") + local Player = require("src.core.game3.player") + local Collision = require("src.core.game3.collision") + local CollisionStd = require("src.core.game3.scripting.collision_std") + local Space = require("src.core.game3.scripting.space") + game.session = game.save + for key, value in pairs({ + cellX = 4, + cellY = 6, + targetX = 4, + targetY = 6, + facing = "up", + moving = false, + surfing = false, + }) do + patch(Player, key, value) + end + patch(Player, "boulderPush", nil) + patch(Objects, "_byId", { [1] = npc }) + patch(Objects, "_order", { 1 }) + patch(Objects, "_mapId", "FIX_ROUTE") + patch(Field, "running", true) + patch(Field, "_game", game) + patch(Field, "_session", game.save) + patch(Field, "_locks", {}) + patch(Space, "active", true) + patch(Space, "vm", { + ctx = {}, + isRunning = function() + return false + end, + }) + patch(Space, "startScript", function(key) + assert(key == "TALK") + calls[#calls + 1] = npc + return true + end) + patch(require("src.core.game3.runtime"), "uiBusy", function() + return false + end) + patch(Collision, "cell", function() + return counter and 1 or 0 + end) + patch(CollisionStd, "isCounter", function(cell) + return cell == 1 + end) + owner, world = game, { player = player, npcs = { npc } } + interact = function() + return Field.interact(game) + end + elseif backend == "gen1" and gen == 2 then + local World = require("src.world.gen2.World") + world = setmetatable({ + game = game, + player = player, + npcs = { npc }, + map = { id = "FIX_ROUTE" }, + vm = { + running = function() + return false + end, + start = function(_, key) + assert(key == "TALK") + calls[#calls + 1] = npc + return true + end, + }, + facingObjectCell = function() + return 4, counter and 4 or 5 + end, + }, { __index = World }) + owner = world + interact = function() + return world:interactBody() + end + else + local World = require("src.world.OverworldController") + world = setmetatable({ + game = game, + player = player, + npcs = { npc }, + map = { + id = "FIX_ROUTE", + def = {}, + isCounterCell = function() + return counter + end, + }, + talkTo = function(_, target) + calls[#calls + 1] = target + end, + }, { __index = World }) + owner = world + interact = function() + return world:interact() + end + end + local off = result.loader.events:on("world.interacted", function(event) + events[#events + 1] = event + end) + local exports = result.loader.exports.portable_world_talk + return { + T = T, + result = result, + exports = exports, + public = exports and exports.api, + game = game, + world = world, + owner = owner, + npc = npc, + calls = calls, + events = events, + api = api, + gen = gen, + backend = backend, + patch = patch, + counter = function(value) + counter = value + npc.cellY, npc.targetY = value and 4 or 5, value and 4 or 5 + end, + interact = interact, + close = function() + off() + for i = #restores, 1, -1 do + restores[i]() + end + result.release() + end, + } +end +return M diff --git a/tests/world_talk/worker.lua b/tests/world_talk/worker.lua new file mode 100644 index 000000000..2e421d3ce --- /dev/null +++ b/tests/world_talk/worker.lua @@ -0,0 +1,32 @@ +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/world_talk/fixture.lua") +local f = Fixture.new(root, backend, version, api, format) +local rows +if backend == "gen1" and f.gen == 2 then + local before = #f.calls + f.interact() + f.T.eq(#f.calls, before + 1, "native Gold conversation runs") + f.T.eq(f.exports.seen.calls, 0, "native Gold has no world.talk producer despite catalog") + rows = { { label = "reference boundary", native = 1 } } +else + rows = dofile(root .. "/tests/world_talk/cases.lua").run(f) +end +f.close() +for _, failing in ipairs({ false, true }) do + local bare = Fixture.new(root, backend, version, api, format, not failing, failing) + bare.T.eq( + require("src.mods.Runtime").wantsHook("world.talk"), + false, + "no-mod / entry rollback unsubscribed" + ) + local before = #bare.calls + bare.interact() + bare.T.eq(#bare.calls, before + 1, "no-mod / entry rollback keeps native interaction") + if failing then + bare.T.check(#bare.result.errors > 0, "entry error reported") + end + bare.close() +end +print("WORLD_TALK_CONTRACT " .. require("src.link.Json").encode(rows)) +f.T.finish("portable world.talk contract") diff --git a/tools/api3-contract-check/check.py b/tools/api3-contract-check/check.py new file mode 100644 index 000000000..8b759d1e9 --- /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", "warp", "world_edits", "npcs"): + 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/audio-profile-check/NitroCheck.lua b/tools/audio-profile-check/NitroCheck.lua new file mode 100644 index 000000000..b5ba85c52 --- /dev/null +++ b/tools/audio-profile-check/NitroCheck.lua @@ -0,0 +1,160 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +return function(root) + local Audio = require("src.audio.NitroAudio") + local Sdat = require("src.import.Gen4Sdat") + local P = require("src.core.Performance") + local Fixture = assert(loadfile(root .. "/tools/audio-profile-check/NitroFixture.lua"))() + local Sequence = require("src.audio.NitroSequence") + local checks = 0 + local function check(ok, message) + assert(ok, message) + checks = checks + 1 + end + -- Direct engine comparison covers all samples and exact event/phase state. + for _, format in ipairs({ 0, 1, 2 }) do + for _, looping in ipairs({ false, true }) do + local arc = assert(Sdat.open(Fixture(format, looping))) + local before + local after = Audio._workerNew(arc, { nds = 0 }) + -- The baseline renderer retains ordinary arrays; do not compact its samples. + -- Use a separately constructed public legacy instance through its native + -- archive seam, keeping the SDAT decoder and sequencer unmodified. + local oldArchive = Audio.archive + Audio.archive = function() + return arc + end + P.audioWorkerOptimization = false + before = Audio.new({ audio = {} }, { nds = 0 }) + Audio.archive = oldArchive + before.tickPhase = 1 + before.variables = {} + before.looping = looping + after.looping = looping + local sent = {} + for _ = 1, 8 do + local sd, state, _, done = Audio._workerRender(after, 8192, sent) + local bytes = {} + for _ = 1, sd:getSampleCount() / 512 do + bytes[#bytes + 1] = before:render():getString() + end + check(table.concat(bytes) == sd:getString(), "DS PCM differs, format " .. format) + check(before.tickPhase == state.tickPhase and before.tempo == state.tempo, "DS tick phase differs") + check(before:variable(0) == after:variable(0), "DS local variable differs") + check(#before.voices == #state.voices, "DS voice count differs") + for n, v in ipairs(before.voices) do + for key, value in pairs(v) do + if key ~= "sample" then + check(value == state.voices[n][key], "DS voice field differs: " .. key) + end + end + end + if done then + check(before.ended and #before.voices == 0, "DS tail padding changed") + break + end + end + end + end + check(not Sequence.workerSafe(string.char(160, 128, 0, 0, 1, 0), 0), "random prefix admitted") + check(not Sequence.workerSafe(string.char(176, 16, 0, 0, 255), 0), "shared variable admitted") + check(Sequence.workerSafe(string.char(176, 0, 160, 0, 255), 0), "argument mistaken for random opcode") + P.audioWorkerOptimization = true + local arc = assert(Sdat.open(Fixture(0, true))) + local oldArchive = Audio.archive + Audio.archive = function() + return arc + end + local p = Audio.new({ audio = {} }, { nds = 0 }) + local q = Audio.new({ audio = {} }, { nds = 0 }) + Audio.archive = oldArchive + p:play() + q:play() + local deadline = love.timer.getTime() + 10 + repeat + Audio.update() + love.timer.sleep(0.001) + until #p.queue.buffers > 0 and #q.queue.buffers > 0 or love.timer.getTime() > deadline + check(#p.queue.buffers > 0 and #q.queue.buffers > 0, "DS worker did not feed independent players") + check(p.queue.capacity == 16 and p.queue.buffers[1]:getSampleCount() == 8192, "DS queue/block size changed") + check(rawget(p, "voices") == nil, "worker state was not kept private") + p:setVariable(0, 42) + check(p:variable(0) == 42, "DS setter did not update immediate query") + local voices = p.voices + check(type(voices) == "table" and rawget(p, "voices") == voices, "direct state read did not restore legacy fields") + check(type(p.samples) == "table", "legacy sample cache not restored") + for _, sample in pairs(p.samples) do + check(type(sample.pcm) == "table", "private PCM leaked through a state read") + end + p:stop() + q:stop() + check(not p:isPlaying() and not q:isPlaying(), "DS stopped player remained active") + Audio.archive = function() + return arc + end + local custom = Audio.new({ audio = {} }, { nds = 0 }) + local written = Audio.new({ audio = {} }, { nds = 0 }) + Audio.archive = oldArchive + custom.render = function() + return "owned custom renderer" + end + check( + type(rawget(custom, "tracks")) == "table" and custom:render() == "owned custom renderer", + "custom render did not select legacy transport" + ) + written.tempo = 73 + check( + rawget(written, "tempo") == 73 and type(written.tracks) == "table", + "direct state write did not select legacy transport" + ) + + Audio.archive=function()return arc end + local rolled=Audio.new({audio={}},{nds=0}) + Audio.archive=oldArchive + rolled:play();P.audioWorkerOptimization=false;rolled:update() + check(type(rawget(rolled,'voices'))=='table','P6 disable did not restore legacy state') + rolled:stop();P.audioWorkerOptimization=true + P.audioWorkerOptimization = false + Audio.archive = function() + return arc + end + local legacy = Audio.new({ audio = {} }, { nds = 0 }) + P.audioWorkerOptimization = true + local threaded = Audio.new({ audio = {} }, { nds = 0 }) + Audio.archive = oldArchive + legacy:play() + threaded:play() + local deadline2 = love.timer.getTime() + 10 + repeat + threaded:update() + love.timer.sleep(0.001) + until #threaded.queue.buffers == 16 or love.timer.getTime() > deadline2 + check(#threaded.queue.buffers == 16, "DS queue did not reach its bounded depth") + local offsets = { [legacy] = 0, [threaded] = 0 } + local function step(player) + offsets[player] = offsets[player] + 512 + local first = player.queue.buffers[1] + while first and offsets[player] >= first:getSampleCount() do + offsets[player] = offsets[player] - first:getSampleCount() + table.remove(player.queue.buffers, 1) + first = player.queue.buffers[1] + end + player:update() + end + return checks, + { + keepAlive = function() + threaded:update() + end, + legacy = function() + step(legacy) + end, + threaded = function() + assert(rawget(threaded, "voices") == nil, "DS benchmark left the worker") + step(threaded) + end, + close = function() + legacy:stop() + threaded:stop() + end, + } +end diff --git a/tools/audio-profile-check/NitroFixture.lua b/tools/audio-profile-check/NitroFixture.lua new file mode 100644 index 000000000..a6cad46e7 --- /dev/null +++ b/tools/audio-profile-check/NitroFixture.lua @@ -0,0 +1,93 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Owned, minimal SDAT/SSEQ/SBNK/SWAR bytes, with all three voice kinds. +return function(format, looping) + local b = {} + for n = 1, 2048 do + b[n] = 0 + end + local function byte(at, n) + b[at + 1] = n % 256 + end + local function word(at, n, size) + for i = 0, (size or 4) - 1 do + byte(at + i, n) + n = math.floor(n / 256) + end + end + local function text(at, s) + for n = 1, #s do + byte(at + n - 1, s:byte(n)) + end + end + text(0, "SDAT") + word(24, 64) + word(32, 512) + word(40, 1024) + word(64 + 8, 48) + word(64 + 16, 64) + word(64 + 20, 80) + for _, r in ipairs({ { 112, 128 }, { 128, 144 }, { 144, 160 } }) do + word(r[1], 1) + word(r[1] + 4, r[2]) + end + word(192, 0, 2) + word(196, 0, 2) + byte(198, 127) + word(208, 1, 2) + word(212, 0, 2) + word(214, 65535, 2) + word(216, 65535, 2) + word(218, 65535, 2) + word(224, 2, 2) + local events = + { 199, 0, 129, 0, 225, 120, 0, 176, 0, 3, 0, 60, 120, 40, 129, 1, 64, 110, 20, 129, 2, 67, 100, 10, 128, 50, 177, 0, 2, 0 } + if looping then + events[#events + 1] = 148 + events[#events + 1] = 0 + events[#events + 1] = 0 + events[#events + 1] = 0 + else + events[#events + 1] = 255 + end + text(1024, "SSEQ") + word(1024 + 24, 28) + for n, v in ipairs(events) do + byte(1024 + 28 + n - 1, v) + end + text(1200, "SBNK") + word(1200 + 56, 3) + for i = 0, 2 do + byte(1200 + 60 + i * 4, i + 1) + word(1200 + 61 + i * 4, 80 + i * 10, 2) + local at = 1200 + 80 + i * 10 + word(at, i == 0 and 0 or 3, 2) + word(at + 2, 0, 2) + byte(at + 4, 60) + byte(at + 9, 64) + end + text(1400, "SWAR") + word(1400 + 56, 1) + word(1400 + 60, 64) + local at = 1464 + byte(at, format or 0) + byte(at + 1, 1) + word(at + 2, 16384, 2) + word(at + 6, 1, 2) + word(at + 8, 31) + for n = 0, 127 do + byte(at + 12 + n, math.floor(math.sin(n * 0.17) * 100)) + end + if format == 2 then + word(at + 12, 0, 2) + word(at + 14, 0, 2) + end + for i, r in ipairs({ { 1024, 28 + #events }, { 1200, 128 }, { 1400, 204 } }) do + word(512 + 12 + (i - 1) * 16, r[1]) + word(512 + 16 + (i - 1) * 16, r[2]) + end + local out = {} + for n = 1, #b do + out[n] = string.char(b[n]) + end + return table.concat(out) +end diff --git a/tools/audio-profile-check/conf.lua b/tools/audio-profile-check/conf.lua new file mode 100644 index 000000000..7cf937501 --- /dev/null +++ b/tools/audio-profile-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("AUDIO_PROFILE_QA_GUARDED") == "1", "use the guarded audio checker") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Native PCM qualification" + t.window.width, t.window.height, t.window.vsync = 480, 240, 1 + t.modules.audio = false +end diff --git a/tools/audio-profile-check/main.lua b/tools/audio-profile-check/main.lua new file mode 100644 index 000000000..b46811e5a --- /dev/null +++ b/tools/audio-profile-check/main.lua @@ -0,0 +1,358 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root = assert(os.getenv("AUDIO_PROFILE_ROOT")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Pcm = require("src.core.AudioPcm") +local Performance = require("src.core.Performance") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local names = { "src.core.ChipSynth", "src.core.M4ASynth" } +local function engine() + return { + at = 0, + sample = function(self) + self.at = self.at + 1 + -- Include signed extremes and values near the native float/int boundary. + return self.at % 7 == 0 and -1 or (self.at % 65535 - 32767) / 32767 + end, + sampleStereo = function(self) + local value = self:sample() + return value, -value + end, + } +end +local checks = 0 +local function qualifyEffects() + -- The QA app lives in tools/. Adapt only module file resolution; the + -- actual worker and channel protocol still run in a fresh native Lua state. + local newThread = love.thread.newThread + love.thread.newThread = function(path) + assert(path == "src/core/chip_worker.lua", "unexpected worker") + local boot = ('local root=%q; package.path=root.."/?.lua;"..package.path; require("love.filesystem"); love.filesystem.load=function(path)return loadfile(root.."/"..path)end; assert(loadfile(root.."/src/core/chip_worker.lua"))()'):format( + root + ) + return newThread(love.filesystem.newFileData(boot, "audio_qa_worker.lua")) + end + local Synth = require("src.core.ChipSynth") + local Chip = require("src.core.ChipAudio") + local Sound = require("src.core.Sound") + local Runtime = require("src.mods.Runtime") + local Asm = require("src.audio.ChipAsm") + local effects, played, builds = {}, 0, 0 + love.audio = { + newQueueableSource = function(_, _, _, capacity) + local q = { capacity = capacity, buffers = {}, playing = false, volume = 1 } + function q:stop() + self.buffers = {} + self.playing = false + end + function q:play() + self.playing = true + end + function q:pause() + self.playing = false + end + function q:isPlaying() + return self.playing + end + function q:getFreeBufferCount() + return self.capacity - #self.buffers + end + function q:queue(sd) + assert(#self.buffers < self.capacity) + self.buffers[#self.buffers + 1] = sd + end + function q:setVolume(v) + self.volume = v + end + function q:getVolume() + return self.volume + end + return q + end, + newSource = function(sd) + builds = builds + 1 + local source = { sd = sd, volume = 1, plays = 0 } + function source:setVolume(v) + self.volume = v + end + function source:stop() + self.playing = false + end + function source:play() + self.plays = self.plays + 1 + self.playing = true + end + function source:isPlaying() + return self.playing + end + function source:release() + self.released = true + end + effects[#effects + 1] = source + return source + end, + } + local oldEmit, oldWants = Runtime.emit, Runtime.wants + Runtime.wants = function(name) + return name == "sound.played" + end + Runtime.emit = function(name) + if name == "sound.played" then + played = played + 1 + end + end + local beep = Asm.song({ + tempo = 0x100, + channels = { + { + hw = 1, + program = { + { duty = 2 }, + { notetype = { speed = 1, volume = 12, fade = 0 } }, + { octave = 4 }, + { note = "C", len = 1 }, + }, + }, + }, + }) + local data = { audio = { sfx = { beep = beep }, cries = { MON = { header = beep, pitch = 0, length = 128 } } } } + local function wait(count) + local deadline = love.timer.getTime() + 10 + repeat + Sound.update(data) + love.timer.sleep(0.001) + until builds >= count or love.timer.getTime() > deadline + assert(builds >= count, "prewarm worker did not deliver its PCM") + end + wait(2) + assert(played == 0 and effects[1].plays == 0 and effects[2].plays == 0, "prewarm played/emitted") + Performance.audioWorkerOptimization = false + local expected = Synth.renderEffectData(data, beep, { frequencyOffset = 0, frameTicks = 256 }) + Performance.audioWorkerOptimization = true + local before = builds + local source = assert(Sound.play(data, "beep")) + assert(builds == before and source.sd:getString() == expected:getString(), "prewarm did not reuse exact cached PCM") + assert(played == 1 and source.plays == 1, "real sound event/play count changed") + checks = checks + 4 + Sound.invalidate() + Chip.cancelEffects() + wait(before + 2) + -- A changed mix invalidates an unused speculative source, while used + -- sources keep the original cache semantics. + Chip.setChannelVolume(1, 0.4) + before = builds + assert(Sound.play(data, "beep")) + assert(builds == before + 1, "stale speculative mix was reused") + checks = checks + 1 + Sound.invalidate() + -- A wholly owned tiny M4A image: one sampled voice and a short sequence. + local function word(n) + local out = {} + for i = 1, 4 do + out[i] = string.char(n % 256) + n = math.floor(n / 256) + end + return table.concat(out) + end + local image = string.char(0, 60, 0, 0) .. word(0x08000040) .. string.rep(string.char(255), 4) + image = image .. string.rep("\0", 16 - #image) .. string.char(0xBD, 0, 0xBE, 127, 0xD4, 60, 127, 0x85, 0xB1) + image = image .. string.rep("\0", 64 - #image) .. word(0) .. word(32768 * 1024) .. word(0) .. word(128) + for n = 1, 128 do + image = image .. string.char(math.floor(math.sin(n * 0.17) * 100) % 256) + end + assert(love.filesystem.write("audio-owned-image.bin", image)) + data = { + audio = { + gen3 = { file = "audio-owned-image.bin", base = 0 }, + gen3Cries = { table = 0 }, + songs = { SONG_001 = { voicegroup = 0, tracks = { 16 } } }, + sfx = { sampled = { m4a = 1 } }, + cries = { SAMPLED = { m4a = 0 } }, + }, + } + before = builds + wait(before + 2) + local M4A = require("src.core.M4ASynth") + Performance.audioWorkerOptimization = false + local effect = M4A.renderEffect(data, data.audio.songs.SONG_001) + local cry = M4A.renderCry(data, 0) + Performance.audioWorkerOptimization = true + assert(effect and cry, "owned sampled fixture is silent/invalid") + assert(Sound.play(data, "sampled").sd:getString() == effect:getString(), "sampled worker effect PCM differs") + assert(Sound.playCry(data, "SAMPLED").sd:getString() == cry:getString(), "sampled worker cry PCM differs") + assert(builds == before + 2, "sampled effects did not reuse Sound cache") + checks = checks + 3 + Sound.invalidate() + before = builds + wait(before + 2) + -- Def-local edits cannot reuse speculative bytes made before that edit. + data.audio.sfx.sampled = { m4a = 99 } + before = builds + assert(Sound.play(data, "sampled") == nil and builds == before, "stale edited definition was played") + Sound.invalidate() + -- Cancelling in-flight jobs never admits their late SoundData into cache. + Sound.update(data) + Sound.suspend() + before = builds + local untilTime = love.timer.getTime() + 0.05 + repeat + Sound.update(data) + love.timer.sleep(0.001) + until love.timer.getTime() > untilTime + assert(builds == before, "late cancelled source entered a suspended cache") + checks = checks + 2 + before = builds + Sound.update(data) + assert(builds == before and Sound.play(data, "beep") == nil, "suspended prewarm built/played") + Sound.resume() + Chip.setChannelVolume(1, 1) + checks = checks + 1 + local dsChecks, ds = assert(loadfile(root .. "/tools/audio-profile-check/NitroCheck.lua"))()(root) + checks = checks + dsChecks + Sound.invalidate() + data = { audio = { sfx = {}, cries = {} } } + for i = 1, 240 do + data.audio.sfx["cue" .. i] = beep + end + before = builds + wait(before + 240) + Runtime.emit, Runtime.wants = oldEmit, oldWants + return { + ds = ds, + cold = function() + return Chip.newSfx(data, "cue1") + end, + warm = function(i) + return assert(Sound.play(data, "cue" .. i)) + end, + close = function() + ds.close() + Sound.invalidate() + Chip.shutdown() + love.thread.newThread = newThread + love.audio = nil + end, + } +end +local function qualify() + for _, name in ipairs(names) do + local synth = require(name) + for _, channels in ipairs({ 1, 2 }) do + local a, b = engine(), engine() + Performance.audioWorkerOptimization = false + local before = synth.soundData(a, 65536, channels) + Performance.audioWorkerOptimization = true + local after = synth.soundData(b, 65536, channels) + assert(before:getString() == after:getString(), name .. " PCM differs") + assert(a.at == b.at and a.at == 65536, "sample cadence changed") + assert(not jit.status() or Pcm.pointer(after), "native FFI path is unavailable") + before:release() + after:release() + checks = checks + 1 + end + end + local left, right = {}, {} + for n = 1, 65536 do + left[n], right[n] = (n % 65535 - 32767) / 32767, math.sin(n) * 0.9 + end + for _, stereo in ipairs({ false, true }) do + Performance.audioWorkerOptimization = false + local before = Pcm.arrays(left, stereo and right or nil, 65536, 32768) + Performance.audioWorkerOptimization = true + local after = Pcm.arrays(left, stereo and right or nil, 65536, 32768) + assert(before:getString() == after:getString(), "effect-array PCM differs") + before:release() + after:release() + checks = checks + 1 + end +end +local phase, frame, total = 1, 0, 0 +local results = {} +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + local chip = package.loaded["src.core.ChipAudio"] + if chip then + pcall(chip.shutdown) + end + return function() + return 1 + end +end +function love.run() + qualify() + local fx = qualifyEffects() + local labels = { + "setSample", + "automatic PCM", + "cold effect trigger", + "prewarmed effect trigger", + "legacy DS update", + "worker DS update", + } + return function() + love.event.pump() + for event in love.event.poll() do + if event == "quit" then + return 1 + end + end + if frame == 120 then + Profile.setEnabled(true) + end + Performance.audioWorkerOptimization = true + if phase ~= 6 then + fx.ds.keepAlive() + end + Performance.audioWorkerOptimization = phase >= 2 + local close = Profile.section( + phase <= 2 and "audio.PCM write (8192 stereo)" + or phase <= 4 and "audio.effect trigger (owned Source hardware)" + or "audio.DS update (512 samples/frame consumption)" + ) + local began = love.timer.getTime() + local sound + if phase <= 2 then + sound = Pcm.render(engine(), 8192, 2, 32768) + elseif phase == 3 then + fx.cold() + elseif phase == 4 then + fx.warm(frame + 1) + elseif phase == 5 then + fx.ds.legacy() + else + fx.ds.threaded() + end + if frame >= 120 then + total = total + love.timer.getTime() - began + end + close() + if sound then + sound:release() + end + love.graphics.clear(0.03, 0.04, 0.06) + love.graphics.print(labels[phase], 12, 12) + love.graphics.present() + if frame >= 120 then + Profile.frame() + end + frame = frame + 1 + if frame == 240 then + results[phase] = { mode = labels[phase], frames = 120, meanMs = total / 120 * 1000 } + if phase == 6 then + fx.close() + print("AUDIO_PCM_RESULT " .. Json.encode({ + phases = results, + checks = checks, + renderer = love.graphics.getRendererInfo(), + os = love.system.getOS(), + arch = jit.arch, + jitEnabled = jit.status(), + scope = "Native SoundData/worker, owned sample programs and Source hardware double; no device playback or cartridge music", + })) + return 0 + end + Profile.setEnabled(false) + phase, frame, total = phase + 1, 0, 0 + end + end +end diff --git a/tools/battle-auxiliary-contract-check/check.py b/tools/battle-auxiliary-contract-check/check.py new file mode 100644 index 000000000..f8c2fbefd --- /dev/null +++ b/tools/battle-auxiliary-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged auxiliary battle mod through both engines' native command menus.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/battle_auxiliary/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("BATTLE_AUXILIARY_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("BATTLE_AUXILIARY_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"auxiliary menu mismatch: {version}" + print(f"PASS {version}: matching native command-menu behavior across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_battle_auxiliary/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/battle-bottom-ui-contract-check/check.py b/tools/battle-bottom-ui-contract-check/check.py new file mode 100644 index 000000000..cac046c63 --- /dev/null +++ b/tools/battle-bottom-ui-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged bottom UI mod through both engines' native battle UI renderers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/battle_bottom_ui/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("BATTLE_BOTTOM_UI_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("BATTLE_BOTTOM_UI_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"battle bottom UI mismatch: {version}" + print(f"PASS {version}: matching native battle UI renderers across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_battle_bottom_ui/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/battle-grid-contract-check/check.py b/tools/battle-grid-contract-check/check.py new file mode 100644 index 000000000..194340128 --- /dev/null +++ b/tools/battle-grid-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged battle-grid mod through both engines' native command menus.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/battle_grid/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("BATTLE_GRID_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("BATTLE_GRID_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"battle navigation mismatch: {version}" + print(f"PASS {version}: matching native move-navigation behavior across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_battle_grid/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/check_async_get.py b/tools/check_async_get.py new file mode 100644 index 000000000..634786af6 --- /dev/null +++ b/tools/check_async_get.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Exercise real LOVE worker HTTP against only a task-owned loopback server.""" +import argparse, http.server, os, pathlib, subprocess, threading, time, tempfile, shutil, uuid, ssl, json +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): pass + def do_POST(self): + length=int(self.headers.get('Content-Length','0')) + if length>512*1024:self.send_error(413);return + body=self.rfile.read(length) + self.server.posts.append(dict(path=self.path,body=body,agent=self.headers.get('User-Agent'),mime=self.headers.get('Content-Type'))) + if self.path=='/redirect-post': + self.send_response(307);self.send_header('Location','/forbidden-post-target');self.send_header('Content-Length','0');self.end_headers();return + self.send_response(200);self.send_header('Content-Length','18');self.end_headers();self.wfile.write(b'private reply body') + def do_GET(self): + path=self.path.split('?')[0] + if path=='/slow': time.sleep(1.25) + if path=='/missing': self.send_error(404); return + if path=='/redirect': + self.send_response(302);self.send_header('Location','/binary');self.send_header('Content-Length','0');self.end_headers();return + if path=='/empty': + self.send_response(204);self.send_header('Content-Length','0');self.end_headers();return + if path=='/stream': + self.send_response(200);self.send_header('Content-Length',str(4*65536));self.end_headers() + try: + for _ in range(4):self.wfile.write(bytes(range(256))*256);self.wfile.flush();time.sleep(.25) + except (BrokenPipeError,ConnectionResetError,ConnectionAbortedError):pass + return + if path=='/partial': + self.send_response(200);self.send_header('Content-Length','999');self.end_headers();self.wfile.write(b'short');return + if path=='/large': + self.send_response(200);self.send_header('Content-Length',str(17*1024*1024));self.end_headers() + try: + for _ in range(272):self.wfile.write(bytes(range(256))*256) + except (BrokenPipeError,ConnectionResetError,ConnectionAbortedError):pass + return + body=b'\x00\xff\r\nGET binary' if path=='/binary' else b'slow' if path=='/slow' else b'x'*4096 + self.send_response(200) + if path!='/chunk': self.send_header('Content-Length',str(len(body))) + self.end_headers() + try: self.wfile.write(body) + except (BrokenPipeError,ConnectionResetError,ConnectionAbortedError): pass +def main(): + p=argparse.ArgumentParser();p.add_argument('--love',required=True);p.add_argument('--source-root',type=pathlib.Path) + p.add_argument('--tls',action='store_true',help='also exercise fixed-destination POST against an owned TLS fixture') + args=p.parse_args() + root=pathlib.Path(__file__).resolve().parents[1] + source=(args.source_root or root).resolve() + executable=pathlib.Path(args.love).resolve() + token=uuid.uuid4().hex + identity='oasc-native-http-qa-'+token + temporary=None + run=subprocess.run + # Local participating checkouts use the shared storage leases. Standalone + # upstream/ephemeral CI uses its own isolated temporary project instead. + if os.name!='nt' and not os.getenv('GITHUB_ACTIONS') and (root/'tools/qa_storage.py').is_file(): + from qa_storage import qa_source,qa_copy,qa_profile,qa_run + qa_source(root);qa_source(source);qa_source(executable) + project=qa_copy(root/'.local/qa'/('native-http-test-'+token)/'unit') + profile=(pathlib.Path.home()/'Library/Application Support/LOVE'/identity if os.uname().sysname=='Darwin' + else pathlib.Path.home()/'.local/share/love'/identity) + qa_profile(profile) + run=qa_run + project.mkdir(parents=True) + else: + temporary=tempfile.TemporaryDirectory(prefix='gen2recomp-get-check-') + project=pathlib.Path(temporary.name) + for name in ('src/net/Fetch.lua','src/net/AsyncGet.lua','src/net/get_worker.lua','src/net/BoundedHttp.lua','src/link/Json.lua','src/mods/LogReport.lua', + 'src/net/AsyncDownload.lua','src/net/DownloadTransport.lua','src/net/DownloadPublish.lua','src/net/download_worker.lua', + 'src/mods/Net.lua','src/core/HostShell.lua'): + target=project/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(source/name,target) + for name in ('main.lua','conf.lua','download_test_worker.lua'):shutil.copy2(root/'tools/async-get-check'/name,project/name) + server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler);server.posts=[] + cert=None + if args.tls: + config=project/'tls.cnf';cert=project/'tls.crt';key=project/'tls.key' + config.write_text('[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=localhost\n[ext]\nsubjectAltName=IP:127.0.0.1,DNS:localhost\n') + result=run(['openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','2','-config',str(config),'-keyout',str(key),'-out',str(cert)],capture_output=True,text=True,timeout=30) + result.check_returncode();key.chmod(0o600) + context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.load_cert_chain(cert,key) + server.socket=context.wrap_socket(server.socket,server_side=True) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + try: + env=os.environ.copy();env.update(GET_TEST_IDENTITY=identity,GET_TEST_URL=f'{"https" if args.tls else "http"}://127.0.0.1:{server.server_port}') + if cert: + untrusted=dict(env,GET_TEST_UNTRUSTED='1') + untrusted.pop('CURL_CA_BUNDLE',None) + rejected=run([str(executable),str(project)],env=untrusted,cwd=root,timeout=15) + rejected.check_returncode();assert not server.posts,'untrusted TLS sent a request body' + if cert:env['CURL_CA_BUNDLE']=str(cert) + result=run([str(executable),str(project)],env=env,cwd=root,timeout=45) + if args.tls and result.returncode==0: + text=[r for r in server.posts if r['path']=='/post-text'];encoded=[r for r in server.posts if r['path']=='/post-json'] + assert len(text)==1 and text[0]['body']==b'literal\x00\xff\r\n$(printf injected) "quote"' + assert text[0]['agent']=='gen2recomp-mod/log_text' and text[0]['mime']=='text/plain; charset=utf-8' + assert len(encoded)==1 + envelope=json.loads(encoded[0]['body']);assert envelope['mod']=='log_json' and envelope['body']=='\0'*65536 and envelope['format']=='json' + assert not any(r['path']=='/forbidden-post-target' for r in server.posts),'POST followed a redirect' + print('TLS POST: exact raw bytes, JSON envelope, identity and redirect refusal PASS',flush=True) + return result.returncode + finally: + server.shutdown();server.server_close() + if temporary is not None:temporary.cleanup() + +if __name__=='__main__':raise SystemExit(main()) diff --git a/tools/check_audio_profile.py b/tools/check_audio_profile.py new file mode 100644 index 000000000..600c7a770 --- /dev/null +++ b/tools/check_audio_profile.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Qualify native PCM writes without opening a user's audio/save profile.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love',type=Path,required=True) + parser.add_argument('--report',type=Path,required=True) + parser.add_argument('--qa-tools',type=Path) + args=parser.parse_args() + root=Path(__file__).resolve().parents[1] + sys.path.insert(0,str(args.qa_tools or root/'tools')) + from qa_storage import qa_source,qa_cache,qa_profile,qa_run + qa_source(root);qa_source(args.love.resolve()) + qa_cache(args.report.parent);args.report.parent.mkdir(parents=True,exist_ok=True) + names=('src/core/AudioPcm.lua','src/core/ChipSynth.lua','src/core/M4ASynth.lua','src/core/chip_worker.lua','src/core/ChipAudio.lua', + 'src/core/Sound.lua','src/core/Music.lua','src/core/Performance.lua', + 'src/audio/NitroAudio.lua','src/audio/NitroTransport.lua','src/audio/NitroSequence.lua', + 'src/audio/NitroWorker.lua','tools/audio-profile-check/NitroFixture.lua','tools/audio-profile-check/NitroCheck.lua', + 'tools/audio-profile-check/main.lua','tools/audio-profile-check/conf.lua') + hashes={name:hashlib.sha256((root/name).read_bytes()).hexdigest()for name in names} + identity='oasc-audio-pcm-qa-'+str(time.time_ns()) + qa_profile(Path.home()/'Library/Application Support/LOVE'/identity) + run=qa_run([str(args.love.resolve()),str(root/'tools/audio-profile-check')],cwd=root, + env=dict(os.environ,AUDIO_PROFILE_QA_GUARDED='1',AUDIO_PROFILE_ROOT=str(root),POKEPORT_IDENTITY=identity), + capture_output=True,text=True,timeout=90) + args.report.with_suffix('.log').write_text(run.stdout+run.stderr) + records=[line.removeprefix('AUDIO_PCM_RESULT ')for line in run.stdout.splitlines()if line.startswith('AUDIO_PCM_RESULT ')] + if run.returncode or len(records)!=1:raise RuntimeError('native PCM probe failed; see its log') + if any(hashlib.sha256((root/name).read_bytes()).hexdigest()!=value for name,value in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report=json.loads(records[0]);report.update(sha256=hashes,playerProfilesUsed=False) + args.report.write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2)) + + +if __name__=='__main__':main() diff --git a/tools/check_decoded_profiles.py b/tools/check_decoded_profiles.py new file mode 100644 index 000000000..7af5f6f0e --- /dev/null +++ b/tools/check_decoded_profiles.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Measure native script execution with an owned LÖVE window and F11 sections.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--npc-controls', action='store_true') + parser.add_argument('--baseline', required=True, help='local pre-M5 Git commit') + parser.add_argument('--report', type=Path, required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + files = ('src/script/ScriptRunner.lua', 'src/script/DecodedScript.lua', + 'src/script/Gen2ScriptVM.lua', 'src/script/Gen3ScriptVM.lua', + 'src/script/Commands.lua', 'src/mods/Loader.lua', 'src/mods/DecodedCallbacks.lua', + 'tools/decoded-profile-check/main.lua', 'tools/decoded-profile-check/conf.lua') + if args.npc_controls: + files = ('src/world/WorldAPI.lua', 'src/world/NpcControls.lua', 'src/mods/Api3NPC.lua', + 'src/mods/Loader.lua', 'tools/decoded-profile-check/main.lua', + 'tools/decoded-profile-check/conf.lua', 'tools/decoded-profile-check/npcs.lua', + 'tests/fixtures/legacy_npc_controls/mods/legacy/main.lua', + 'tests/fixtures/legacy_npc_controls/mods/legacy/manifest.json') + hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} + before = qa_run(['git', 'rev-parse', '--verify', args.baseline + '^{commit}'], cwd=root, + capture_output=True, text=True, check=True).stdout.strip() + baseline_path = 'src/world/WorldAPI.lua' if args.npc_controls else 'src/script/ScriptRunner.lua' + blob = qa_run(['git', 'show', before + ':' + baseline_path], cwd=root, + capture_output=True, check=True).stdout + baseline = args.report.parent / 'baseline-runner.lua' + baseline.write_bytes(blob) + identity = 'oasc-gen2recomp-qa-decoded-profile-' + str(time.time_ns()) + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/decoded-profile-check')], + cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, + DECODED_PROFILE_REPO=str(root), DECODED_PROFILE_QA_GUARDED='1', + DECODED_PROFILE_BASELINE=str(baseline.resolve()), + NPC_CONTROLS_QA='1' if args.npc_controls else '0'), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + prefix = 'M6_NATIVE_RESULT ' if args.npc_controls else 'M5_NATIVE_RESULT ' + records = [line[len(prefix):] for line in result.stdout.splitlines() if line.startswith(prefix)] + if result.returncode or len(records) != 1: + raise RuntimeError('native profile probe failed; see ' + str(args.report.with_suffix('.log'))) + if any(hashlib.sha256((root / name).read_bytes()).hexdigest() != digest for name, digest in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report = json.loads(records[0]) + report['baselinePath'] = baseline_path + report.update(baseline=before, baselineRunnerSha256=hashlib.sha256(blob).hexdigest(), + sha256=hashes, minimumFreeGiB=20, playerProfilesUsed=False) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/check_external_mod_fixtures.py b/tools/check_external_mod_fixtures.py new file mode 100644 index 000000000..f09f350b3 --- /dev/null +++ b/tools/check_external_mod_fixtures.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Authenticate external packages and exercise their unchanged entrypoints.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path, help='directory providing qa_storage.py and its companion modules') + parser.add_argument('--lua', default=os.environ.get('LUA', 'luajit')) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--phase', choices=('complete', 'aliases'), default='complete') + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + reference = args.reference.resolve() + if args.qa_tools: + sys.path.insert(0, str(args.qa_tools.resolve())) + try: + from qa_storage import qa_source, qa_cache, qa_run + except ImportError: + parser.error('local QA storage guard is required; provide --qa-tools') + qa_source(root) + qa_source(reference) + qa_cache(args.report.parent) + index = json.loads((root / 'tests/fixtures/upstream_mods/index.json').read_text()) + head = subprocess.check_output(['git', '-C', str(reference), 'rev-parse', 'HEAD'], text=True).strip() + if head != index['reference']: + parser.error('reference checkout does not match the authenticated commit') + results = [] + for fixture in index['fixtures']: + package = reference / 'mods' / fixture['path'] + files = {str(p.relative_to(package)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in package.rglob('*') if p.is_file()} + if files != fixture['files']: + raise RuntimeError('external package bytes differ: ' + fixture['path']) + if fixture['path'].startswith('examples/example_'): + shipped = root / 'mods' / fixture['path'] + for name, digest in fixture['files'].items(): + if hashlib.sha256((shipped / name).read_bytes()).hexdigest() != digest: + raise RuntimeError('shipped gallery bytes differ: ' + fixture['path'] + '/' + name) + versions = (('red', 'firered') if fixture['path'] == 'example_mew_starter' else + ('red', 'gold', 'crystal', 'firered') if fixture['path'] == 'mystery_dungeon_follower' else ('red',)) + for version in versions: + run = qa_run([args.lua, str(root / 'tests/fixtures/upstream_mods/worker.lua'), + str(reference), fixture['path'], version], cwd=root, + capture_output=True, text=True, timeout=60) + lines = [line[len('FIXTURE_RESULT '):] for line in run.stdout.splitlines() + if line.startswith('FIXTURE_RESULT ')] + if len(lines) != 1: + raise RuntimeError(f"{fixture['path']}/{version}: missing worker result\n{run.stdout}{run.stderr}") + result = json.loads(lines[0]) + result['exit_code'] = run.returncode + expected = ('Compatibility module src.import.pmd.Sprite is unavailable in this engine; ' + 'affected mods: mystery_dungeon_follower') + result['accepted_gap'] = (args.phase == 'aliases' and fixture['path'] == 'mystery_dungeon_follower' + and run.returncode == 1 and not result['loaded'] + and result['error'].endswith(expected)) + results.append(result) + print(('EXPECTED GAP ' if result['accepted_gap'] else + 'PASS ' if result['loaded'] and not run.returncode else 'FAIL ') + + fixture['path'] + '/' + version + ': ' + result['error'], flush=True) + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps({'reference': head, 'phase': args.phase, 'fixtures': results}, indent=2) + '\n') + return 0 if all((r['loaded'] and r['exit_code'] == 0) or r['accepted_gap'] for r in results) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/check_frame_pacing.py b/tools/check_frame_pacing.py new file mode 100644 index 000000000..cbe6b211c --- /dev/null +++ b/tools/check_frame_pacing.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Run owned-window VSync pacing measurements through the local QA guard.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + workload = parser.add_mutually_exclusive_group() + workload.add_argument('--gc', action='store_true', help='compare fixed-step GC with the P2 idle budget') + workload.add_argument('--hot-path', action='store_true', help='compare actual native update/draw dependency dispatch') + workload.add_argument('--world-draw', action='store_true', help='compare native world entity/FX dispatch over synthetic content') + workload.add_argument('--palette-zones', action='store_true', help='verify native zone pixels and compare palette upload batching') + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + try: + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + except ImportError: + parser.error('QA storage guard is required; provide --qa-tools') + qa_source(root) + qa_source(args.love.resolve()) + files = ('main.lua', 'src/core/FrameCap.lua', 'src/core/FrameProfile.lua', + 'tools/frame-pacing-check/main.lua', 'tools/frame-pacing-check/conf.lua') + if args.gc: + files += ('src/core/Performance.lua', 'src/core/GCFrameBudget.lua', 'src/core/Game.lua', + 'tools/frame-pacing-check/gc.lua') + if args.hot_path: + files += ('src/core/Game.lua', 'src/core/HotModules.lua', 'src/core/Performance.lua', + 'src/render/Renderer.lua', 'src/render/PaletteFX.lua', 'tools/frame-pacing-check/hot.lua') + if args.world_draw: + files += ('src/core/Game.lua', 'src/core/Performance.lua', 'src/core/HotModules.lua', + 'src/world/OverworldController.lua', 'src/render/Renderer.lua', + 'src/render/PaletteFX.lua', 'src/render/Pipelines.lua', + 'tests/world_draw/fixture.lua', 'tools/frame-pacing-check/world.lua') + if args.palette_zones: + files += ('src/core/Performance.lua', 'src/core/HotModules.lua', + 'src/render/Renderer.lua', 'src/render/PaletteFX.lua', + 'tools/frame-pacing-check/palette.lua') + hashes = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} + source = qa_run(['git', 'rev-parse', 'HEAD'], cwd=root, text=True, + capture_output=True, check=True).stdout.strip() + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + identity = 'oasc-gen2recomp-qa-frame-pacing-' + str(time.time_ns()) + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/frame-pacing-check')], + cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, + FRAME_PACING_REPO=str(root), FRAME_PACING_QA_GUARDED='1', + FRAME_PACING_GC='1' if args.gc else '0', + FRAME_PACING_HOT='1' if args.hot_path else '0', + FRAME_PACING_WORLD='1' if args.world_draw else '0', + FRAME_PACING_PALETTE='1' if args.palette_zones else '0'), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + prefix = 'P5_NATIVE_RESULT ' if args.palette_zones else 'P4_NATIVE_RESULT ' if args.world_draw else 'P3_NATIVE_RESULT ' if args.hot_path else 'P2_NATIVE_RESULT ' if args.gc else 'P1_NATIVE_RESULT ' + records = [line.removeprefix(prefix) for line in result.stdout.splitlines() if line.startswith(prefix)] + if result.returncode or len(records) != 1: + raise RuntimeError('native pacing probe failed; see ' + str(args.report.with_suffix('.log'))) + report = json.loads(records[0]) + if any(hashlib.sha256((root / name).read_bytes()).hexdigest() != digest for name, digest in hashes.items()): + raise RuntimeError('Measured source changed during the pacing probe') + if qa_run(['git', 'rev-parse', 'HEAD'], cwd=root, text=True, + capture_output=True, check=True).stdout.strip() != source: + raise RuntimeError('Measured commit changed during the pacing probe') + report['source'] = source + report['sha256'] = hashes + report['minimumFreeGiB'] = 20 + report['playerProfilesUsed'] = False + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/check_full_game_performance.py b/tools/check_full_game_performance.py new file mode 100644 index 000000000..4cba0e6cf --- /dev/null +++ b/tools/check_full_game_performance.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Profile independent optimization switches in a fresh native Emerald game.""" +# Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import argparse +import atexit +import hashlib +import io +import json +import os +from pathlib import Path +import sys +import tarfile +import time + +BOOTSTRAP = r''' +local identity=assert(os.getenv("POKEPORT_IDENTITY")) +assert(identity:match("^oasc%-gen2recomp%-qa%-perf%-"),"owned test identity required") +assert(love.filesystem.getIdentity()==identity,"isolated identity mismatch") +assert(love.filesystem.write("migrated-from-pokemon-love2d.txt","Isolated performance QA; migration bypassed.\n")) +local setIdentity=love.filesystem.setIdentity +love.filesystem.setIdentity=function(value,...) + assert(value==identity,"test rejected identity switch") + return setIdentity(value,...) +end +assert(require("src.core.SaveData").portableBaseDir()==nil,"portable saves forbidden") +assert(require("src.import.CacheFs").root()==nil,"external imports forbidden") +io.stdout:setvbuf("no") +love.errorhandler=function(err) + print(debug.traceback(tostring(err),2)) + return function()return 1 end +end +assert(loadfile("engine_main.lua"))() +''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--love", type=Path, required=True) + parser.add_argument("--emerald", type=Path, required=True) + parser.add_argument("--backend", choices=("opengl", "metal"), required=True) + parser.add_argument("--out", type=Path, required=True, + help="New directory under this checkout's .local/qa") + parser.add_argument("--qa-tools", type=Path, help="Shared qa_storage.py directory") + parser.add_argument("--map", default="MAP_G00_N15") + parser.add_argument("--x", type=int, default=10) + parser.add_argument("--y", type=int, default=10) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / "tools")) + from qa_storage import Storage + storage = Storage(root) + atexit.register(storage.close) + binary, rom = args.love.resolve(strict=True), args.emerald.resolve(strict=True) + driver = root / "tools/full_game_performance_driver.lua" + for path in (root, binary, rom, driver): + storage.source(path) + digest = hashlib.sha1(rom.read_bytes()).hexdigest() + expected = json.loads((root / "tools/rom_manifest_emerald.json").read_text())["romSha1"] + if digest != expected: + parser.error("Unsupported Emerald ROM revision") + run = args.out.resolve() + storage.cache(run) + source = run / "source" + storage.copy(source) + run.mkdir(parents=True, exist_ok=False) + source.mkdir() + identity = "oasc-gen2recomp-qa-perf-" + str(time.time_ns()) + storage.identity(identity) + head = storage.run(["git", "rev-parse", "HEAD"], cwd=root, check=True, + capture_output=True, text=True).stdout.strip() + assert not storage.run(["git", "status", "--porcelain"], cwd=root, check=True, + capture_output=True, text=True).stdout.strip(), "Commit the candidate first" + files = [p for folder in ("src", "mods/DRAMATIC_SHAPE") for p in (root / folder).rglob("*") + if p.is_file() and p.suffix in (".lua", ".json")] + hashes = {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in files} + archive = storage.run(["git", "archive", head, "main.lua", "conf.lua", "src", + "assets", "data", "tests", "tools", "mods/DRAMATIC_SHAPE"], + cwd=root, check=True, capture_output=True).stdout + with tarfile.open(fileobj=io.BytesIO(archive)) as tar: + for entry in tar.getmembers(): + assert not entry.name.startswith("/") and ".." not in Path(entry.name).parts + assert not entry.issym() and not entry.islnk(), "Frozen inputs must not alias player data" + tar.extractall(source) + (source / "main.lua").rename(source / "engine_main.lua") + # Scripted runs normally bypass software pacing. Enable its existing native + # body in this frozen test copy so P1 and P2 are actually exercised. + entry = source / "engine_main.lua" + text = entry.read_text() + marker = 'if os.getenv("POKEPORT_DRIVER") then return false end' + assert text.count(marker) == 1, "Unknown native pacing admission gate" + entry.write_text(text.replace(marker, + 'if os.getenv("POKEPORT_DRIVER") and os.getenv("PERF_NATIVE_PACING") ~= "1" then return false end')) + (source / "main.lua").write_text(BOOTSTRAP) + env = {k: v for k, v in os.environ.items() + if not k.startswith(("POKEPORT_", "PERF_", "PALETTE_GPU_"))} + env.update(POKEPORT_IDENTITY=identity, POKEPORT_VERSION="emerald", + POKEPORT_GRAPHICS_BACKEND=args.backend, PERF_BACKEND=args.backend, + POKEPORT_NO_BOOT_REPORT="1", POKEPORT_NO_DISCORD="1", + POKEPORT_NOTIFY_ONLY_UPDATES="1", POKEPORT_TOUCH="0", + PERF_NATIVE_PACING="1", + PERF_MAP=args.map, PERF_X=str(args.x), PERF_Y=str(args.y)) + print("Fresh owned performance run:", run, flush=True) + + def child(label, extra, timeout=900): + with (run / (label + ".log")).open("w") as log: + storage.run([str(binary), str(source)], cwd=source, env=dict(env, **extra), + stdout=log, stderr=log, timeout=timeout, check=True) + + child("import", dict(POKEPORT_IMPORT_ROM=str(rom), POKEPORT_IMPORT_ONLY="1", + POKEPORT_NO_MODS="1", PERF_OUT=str(run))) + print("Fresh import passed", flush=True) + report = dict(schema=1, sourceCommit=head, romSha1=digest, backend=args.backend, + sourceSha256=hashes, driverSha256=hashlib.sha256(driver.read_bytes()).hexdigest(), + minimumFreeGiB=20, games={}, freshIdentity=True, legacyMigrationBypassed=True, + testAdaptations=["Enable existing native pacing for the driver in the frozen copy", + "Quarter-pixel camera pan during P7; native update and draw remain active"]) + for mode, count in (("native", 14), ("voxel", 4)): + out = run / mode + out.mkdir() + child(mode, dict(POKEPORT_DRIVER=str(source / "tools/full_game_performance_driver.lua"), + PERF_MODE=mode, PERF_OUT=str(out), + **({"POKEPORT_NO_MODS": "1"} if mode == "native" else {}))) + data = json.loads((out / "report.json").read_text()) + assert data["passed"] and len(data["phases"]) == count + assert all(p["windows"] == 2 and p["present"]["samples"] == 240 for p in data["phases"]) + report["games"][mode] = data + print(mode, "passed:", count, "measured switch phases", flush=True) + assert all(hashlib.sha256((root / path).read_bytes()).hexdigest() == value + for path, value in hashes.items()) + assert digest == hashlib.sha1(rom.read_bytes()).hexdigest() + assert head == storage.run(["git", "rev-parse", "HEAD"], cwd=root, check=True, + capture_output=True, text=True).stdout.strip() + assert not storage.run(["git", "status", "--porcelain"], cwd=root, check=True, + capture_output=True, text=True).stdout.strip() + report["passed"] = True + (run / "result.json").write_text(json.dumps(report, indent=2) + "\n") + print("FULL_GAME_PERFORMANCE_PASS", run / "result.json", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tools/check_gen2_lifecycle_profile.py b/tools/check_gen2_lifecycle_profile.py new file mode 100644 index 000000000..d5d1aeb2a --- /dev/null +++ b/tools/check_gen2_lifecycle_profile.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Measure guarded native lifecycle producers before/after and with observers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love',type=Path,required=True) + parser.add_argument('--baseline',required=True) + parser.add_argument('--report',type=Path,required=True) + parser.add_argument('--qa-tools',type=Path) + args=parser.parse_args() + root=Path(__file__).resolve().parents[1] + sys.path.insert(0,str(args.qa_tools or root/'tools')) + from qa_storage import qa_source,qa_cache,qa_copy,qa_profile,qa_run + qa_source(root);qa_source(args.love.resolve()) + qa_cache(args.report.parent);args.report.parent.mkdir(parents=True,exist_ok=True) + snapshot=args.report.parent/'baseline' + qa_copy(snapshot);snapshot.mkdir(parents=True,exist_ok=True) + paths={'Gen2Friendship':'src/pokemon/Gen2Friendship.lua', + 'RoamMons':'src/world/RoamMons.lua','BugContest':'src/world/BugContest.lua'} + hashes={} + for name,path in paths.items(): + data=qa_run(['git','show',args.baseline+':'+path],cwd=root,capture_output=True,check=True).stdout + (snapshot/(name+'.lua')).write_bytes(data) + hashes[path]={'before':hashlib.sha256(data).hexdigest(), + 'after':hashlib.sha256((root/path).read_bytes()).hexdigest()} + identity='oasc-gen2-lifecycle-qa-'+str(time.time_ns()) + qa_profile(Path.home()/'Library/Application Support/LOVE'/identity) + run=qa_run([str(args.love.resolve()),str(root/'tools/gen2-lifecycle-profile')],cwd=root, + env=dict(os.environ,GEN2_LIFECYCLE_PROFILE_QA='1',GEN2_LIFECYCLE_ROOT=str(root), + GEN2_LIFECYCLE_BASELINE=str(snapshot.resolve()),POKEPORT_IDENTITY=identity), + capture_output=True,text=True,timeout=90) + args.report.with_suffix('.log').write_text(run.stdout+run.stderr) + profiles=[line.removeprefix('LIFECYCLE_PROFILE ')for line in run.stdout.splitlines() + if line.startswith('LIFECYCLE_PROFILE ')] + if run.returncode or len(profiles)!=1:raise RuntimeError('native lifecycle probe failed; see its log') + for path,digests in hashes.items(): + if hashlib.sha256((root/path).read_bytes()).hexdigest()!=digests['after']: + raise RuntimeError('measured source changed during the probe') + report=json.loads(profiles[0]);report.update(baseline=args.baseline,sha256=hashes,playerProfilesUsed=False) + args.report.write_text(json.dumps(report,indent=2)+'\n') + print(json.dumps(report,indent=2)) + + +if __name__=='__main__':main() diff --git a/tools/check_gen2_registry_contract.py b/tools/check_gen2_registry_contract.py new file mode 100644 index 000000000..a20dee711 --- /dev/null +++ b/tools/check_gen2_registry_contract.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Compare the five public Gen2 registry descriptors with a pinned backend.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--lua', required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + parser.add_argument('--love', type=Path) + parser.add_argument('--baseline', help='local commit for the optional native validation profile') + args=parser.parse_args() + root=Path(__file__).resolve().parents[1] + sys.path.insert(0,str(args.qa_tools or root/'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + reference=args.reference.resolve() + qa_source(root); qa_source(reference); qa_source(args.lua) + if args.love: + if not args.baseline: parser.error('--love requires --baseline') + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True,exist_ok=True) + ref=qa_run(['git','rev-parse','HEAD'],cwd=reference,capture_output=True,text=True,check=True).stdout.strip() + status=qa_run(['git','status','--porcelain','--untracked-files=no'],cwd=reference, + capture_output=True,text=True,check=True).stdout + if status.strip() or ref!='535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91': + raise RuntimeError('reference must be the clean pinned contract') + worker=root/'tools/gen2-registry-contract/fields.lua' + digest=hashlib.sha256(worker.read_bytes()).hexdigest() + results=[] + for engine in (reference,root): + run=qa_run([args.lua,str(worker)],cwd=engine,capture_output=True,text=True,timeout=60) + records=[line.removeprefix('REGISTRY_CONTRACT ') for line in run.stdout.splitlines() + if line.startswith('REGISTRY_CONTRACT ')] + if run.returncode or len(records)!=1: + raise RuntimeError(run.stdout+run.stderr) + results.append(json.loads(records[0])) + if results[0]!=results[1]: + raise RuntimeError('public registry descriptors differ: '+json.dumps(results,sort_keys=True)) + if hashlib.sha256(worker.read_bytes()).hexdigest()!=digest: + raise RuntimeError('contract consumer changed during the comparison') + report={'reference':ref,'consumerSha256':digest,'registries':results[1]} + if args.love: + baseline=qa_run(['git','show',args.baseline+':src/mods/Schemas.lua'],cwd=root, + capture_output=True,check=True).stdout + path=args.report.parent/'baseline-schemas.lua' + path.write_bytes(baseline) + identity='oasc-gen2-registry-qa-'+str(time.time_ns()) + qa_profile(Path.home()/'Library/Application Support/LOVE'/identity) + run=qa_run([str(args.love.resolve()),str(root/'tools/gen2-registry-contract')],cwd=root, + env=dict(os.environ,GEN2_REGISTRY_QA='1',GEN2_REGISTRY_ROOT=str(root), + GEN2_REGISTRY_BASELINE=str(path.resolve()),POKEPORT_IDENTITY=identity), + capture_output=True,text=True,timeout=60) + args.report.with_suffix('.log').write_text(run.stdout+run.stderr) + profiles=[line.removeprefix('REGISTRY_PROFILE ')for line in run.stdout.splitlines() + if line.startswith('REGISTRY_PROFILE ')] + if run.returncode or len(profiles)!=1:raise RuntimeError('native registry probe failed') + report['profile']=json.loads(profiles[0]) + report['baseline']=args.baseline + args.report.write_text(json.dumps(report,indent=2)+'\n') + print('PASS: five identical Gen2 registry descriptors') + + +if __name__=='__main__': + main() diff --git a/tools/check_legacy_npc_contract.py b/tools/check_legacy_npc_contract.py new file mode 100644 index 000000000..5e4f6b8a3 --- /dev/null +++ b/tools/check_legacy_npc_contract.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Compare the unchanged API-2 NPC consumer on both pinned native backends.""" +import argparse +import hashlib +import json +from pathlib import Path +import sys + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reference', type=Path, required=True) + parser.add_argument('--lua', required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root/'tools')) + from qa_storage import qa_source, qa_cache, qa_run + reference = args.reference.resolve() + qa_source(root); qa_source(reference); qa_source(args.lua) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + ref = qa_run(['git','rev-parse','HEAD'], cwd=reference, capture_output=True, text=True, check=True).stdout.strip() + status=qa_run(['git','status','--porcelain','--untracked-files=no'],cwd=reference, + capture_output=True,text=True,check=True).stdout + if status.strip(): raise RuntimeError('pinned reference has tracked modifications') + if ref != '535a5ec088c1ae1bf4d91dd3e35a56f64cf46d91': + raise RuntimeError('reference does not match the pinned NPC contract') + files=root/'tests/fixtures/legacy_npc_controls/mods/legacy' + hashes={p.name:hashlib.sha256(p.read_bytes()).hexdigest()for p in files.iterdir() if p.is_file()} + worker = root/'tests/fixtures/legacy_npc_controls/worker.lua' + records=[] + for version in ('red','blue','yellow','gold','silver','crystal','firered','emerald'): + results={} + for backend, engine in [('gen1',reference),('gen2',root)]: + result=qa_run([args.lua,str(worker),str(root),backend,version],cwd=engine, + capture_output=True,text=True,timeout=60) + lines=[line[len('LEGACY_NPC_RESULT '):]for line in result.stdout.splitlines() + if line.startswith('LEGACY_NPC_RESULT ')] + if result.returncode or len(lines)!=1: + raise RuntimeError(f'{backend}/{version}:\n{result.stdout}{result.stderr}') + results[backend]=json.loads(lines[0]) + if results['gen1']!=results['gen2']: + raise RuntimeError(f'{version}: NPC contract differs: {results}') + records.append({'version':version,'result':results['gen2']}) + print('PASS '+version,flush=True) + if {p.name:hashlib.sha256(p.read_bytes()).hexdigest()for p in files.iterdir() if p.is_file()} != hashes: + raise RuntimeError('NPC consumer bytes changed during the comparison') + args.report.write_text(json.dumps({'reference':ref,'consumerSha256':hashes,'comparisons':records},indent=2)+'\n') + + +if __name__=='__main__': + main() diff --git a/tools/check_low_tier.py b/tools/check_low_tier.py new file mode 100644 index 000000000..6c0b89b7f --- /dev/null +++ b/tools/check_low_tier.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Run owned native UI quad draws in an isolated QA profile.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + names = ['src/core/Performance.lua', 'src/render/UiQuad.lua', 'src/render/TextBox.lua', + 'src/ui/Gen3BagMenu.lua', 'src/ui/Gen3BoxMenu.lua', + 'tools/low-tier-check/main.lua', 'tools/low-tier-check/conf.lua'] + hashes = {n: hashlib.sha256((root / n).read_bytes()).hexdigest() for n in names} + identity = f'oasc-low-tier-qa-{time.time_ns()}' + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/low-tier-check')], + cwd=root, env=dict(os.environ, LOW_TIER_QA_GUARDED='1', + LOW_TIER_QA_ROOT=str(root), LOW_TIER_QA_OUT=str(args.report.parent.resolve()), POKEPORT_IDENTITY=identity), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + rows = [s.removeprefix('LOW_TIER_RESULT ') for s in result.stdout.splitlines() + if s.startswith('LOW_TIER_RESULT ')] + if result.returncode or len(rows) != 1: + raise RuntimeError('native LOW tier probe failed; see its log') + if any(hashlib.sha256((root / n).read_bytes()).hexdigest() != h for n, h in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report = json.loads(rows[0]) + report.update(sha256=hashes, playerProfilesUsed=False) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/check_render_budget.py b/tools/check_render_budget.py new file mode 100644 index 000000000..fa39a3aa1 --- /dev/null +++ b/tools/check_render_budget.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Run owned native render budgets in an isolated QA profile.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + names = [f'mods/DRAMATIC_SHAPE/lib/{n}.lua' for n in + ('RenderScale', 'WaterBudget', 'ShaderState', 'Water', 'VoxelScene', 'Voxel3D', 'Tier', 'TileShape')] + names += ['mods/DRAMATIC_SHAPE/manifest.json', 'mods/DRAMATIC_SHAPE/main.lua', + 'tools/render-budget-check/main.lua', 'tools/render-budget-check/conf.lua'] + hashes = {n: hashlib.sha256((root / n).read_bytes()).hexdigest() for n in names} + identity = f'oasc-render-budget-qa-{time.time_ns()}' + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/render-budget-check')], + cwd=root, env=dict(os.environ, RENDER_BUDGET_QA_GUARDED='1', + RENDER_BUDGET_QA_ROOT=str(root), RENDER_BUDGET_QA_OUT=str(args.report.parent.resolve()), POKEPORT_IDENTITY=identity), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + rows = [s.removeprefix('RENDER_BUDGET_RESULT ') for s in result.stdout.splitlines() + if s.startswith('RENDER_BUDGET_RESULT ')] + if result.returncode or len(rows) != 1: + raise RuntimeError('native render-budget probe failed; see its log') + if any(hashlib.sha256((root / n).read_bytes()).hexdigest() != h for n, h in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report = json.loads(rows[0]) + report.update(sha256=hashes, playerProfilesUsed=False) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/check_world_shadow_profile.py b/tools/check_world_shadow_profile.py new file mode 100644 index 000000000..77b52775f --- /dev/null +++ b/tools/check_world_shadow_profile.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Run owned native shadow geometry in an isolated QA profile.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + names = [f'mods/DRAMATIC_SHAPE/lib/{n}.lua' for n in + ('ShadowMap', 'WorldShadows', 'VoxelScene', 'Voxel3D', 'Tier')] + names += ['mods/DRAMATIC_SHAPE/manifest.json', 'mods/DRAMATIC_SHAPE/main.lua', + 'tools/world-shadow-check/main.lua', 'tools/world-shadow-check/conf.lua'] + hashes = {n: hashlib.sha256((root / n).read_bytes()).hexdigest() for n in names} + identity = f'oasc-world-shadow-qa-{time.time_ns()}' + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/world-shadow-check')], + cwd=root, env=dict(os.environ, WORLD_SHADOW_QA_GUARDED='1', + WORLD_SHADOW_QA_ROOT=str(root), WORLD_SHADOW_QA_OUT=str(args.report.parent.resolve()), POKEPORT_IDENTITY=identity), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + rows = [s.removeprefix('WORLD_SHADOW_RESULT ') for s in result.stdout.splitlines() + if s.startswith('WORLD_SHADOW_RESULT ')] + if result.returncode or len(rows) != 1: + raise RuntimeError('native shadow probe failed; see its log') + if any(hashlib.sha256((root / n).read_bytes()).hexdigest() != h for n, h in hashes.items()): + raise RuntimeError('measured source changed during the probe') + report = json.loads(rows[0]) + report.update(sha256=hashes, playerProfilesUsed=False) + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/core-update-contract-check/README.md b/tools/core-update-contract-check/README.md new file mode 100644 index 000000000..4dc330350 --- /dev/null +++ b/tools/core-update-contract-check/README.md @@ -0,0 +1,20 @@ +# Cross-engine core.update contract + +```sh +python3 tools/core-update-contract-check/check.py --gen1 /path/to/gen1recomp --lua luajit --report /tmp/core-update.json +``` + +Gen1 must be clean at `79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Both native +loaders execute `examples/portable_core_update/main.lua` unchanged. The tests +invoke each engine's actual love.update callback loaded from main.lua, with a +Game:update spy and fixtures for non-game owners supplied through test-only +upvalue injection. Neither native main.lua nor PlatformHooks is replaced. +Gen2's unrelated walker bookkeeping and Gen1's HostDisplay backend use a +counter so the test does not depend on external services. + +The checker covers Red, Gold, FireRed and Emerald, asserts forwarding, pause, +priority, argument replacement, errors, unsubscribe and host/driver routing, +then compares frame journals. `--format unified` selects unified API 2; +`--api 3 --format unified` selects API 3. `--gen2` selects an integration +checkout. No ROM, native window or renderer is needed, and no Gen1 source is +written. diff --git a/tools/core-update-contract-check/check.py b/tools/core-update-contract-check/check.py new file mode 100644 index 000000000..dec9d2568 --- /dev/null +++ b/tools/core-update-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged frame-update mod against both engines' native love.update routes.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/core_update/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("CORE_UPDATE_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("CORE_UPDATE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"frame routing mismatch: {version}" + print(f"PASS {version}: matching native frame routing across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_core_update/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/decoded-profile-check/conf.lua b/tools/decoded-profile-check/conf.lua new file mode 100644 index 000000000..eff68e98f --- /dev/null +++ b/tools/decoded-profile-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("DECODED_PROFILE_QA_GUARDED") == "1", "use tools/check_decoded_profiles.py") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Decoded script profile QA" + t.window.width, t.window.height, t.window.vsync = 320, 180, 1 + t.modules.audio = false +end diff --git a/tools/decoded-profile-check/main.lua b/tools/decoded-profile-check/main.lua new file mode 100644 index 000000000..e73a148aa --- /dev/null +++ b/tools/decoded-profile-check/main.lua @@ -0,0 +1,104 @@ +local root = assert(os.getenv("DECODED_PROFILE_REPO")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + return function() + return 1 + end +end +if os.getenv('NPC_CONTROLS_QA') == '1' then + require('tools.decoded-profile-check.npcs') + return +end +local Version = require("src.core.GameVersion") +Version.set("firered") +local Native = require("src.script.ScriptRunner") +local baseline = assert(io.open(assert(os.getenv("DECODED_PROFILE_BASELINE")), "rb")) +local original = assert(loadstring(baseline:read("*a"), "@baseline/ScriptRunner.lua"))() +baseline:close() +local Profile = require("src.core.FrameProfile") +local Runtime = require("src.mods.Runtime") +local Loader = require("src.mods.Loader") +local SDK = require("tests.modkit.sdk") +local Json = require("src.link.Json") +local Compiler = require("src.script.Gen3ScriptVM") +local source = [[return function(mod) + mod.hooks:wrap('script.command',function(next,ctx,op,row)return next(ctx,op,row)end) +end]] +local phase, frame, runner, rows, game, started, total = 1, 0, nil, nil, nil, 0, 0 +local results, WINDOW, WARMUP = {}, 120, 120 +local function start() + Profile.setEnabled(false) + local data = require("tests.fixtures.upstream_mods.data").fresh() + local files = { + ["mods/probe/manifest.json"] = Json.encode({ id = "probe", name = "Probe", version = "1.0.0", entry = "main.lua" }), + ["mods/probe/main.lua"] = source, + ["options.lua"] = "return {mods={probe=true}}", + } + local loader = + Loader.new({ fs = SDK.memfs(files), dev = false, callbackProfiles = { probe = phase == 3 and "decoded" + or "native" } }) + assert(loader:load(data)) + local ir = {} + for i = 1, 64 do + ir[i] = { "setvar", 0x4000 + i, i } + end + ir[#ir + 1] = { "end" } + data.map_scripts = { source = "RomExtractorGen3", scripts = { ENTRY = ir }, maps = {} } + game = { data = data, save = { gen3Vars = {}, flags = {}, party = {} }, mods = loader } + runner = (phase == 1 and original or Native).new(game, {}) + rows = Compiler.compile(data, "ENTRY") + frame, total = 0, 0 +end +function love.run() + start() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + Profile.setEnabled(true) + started = love.timer.getTime() + end + local close = Profile.section("script.exec (64 writes + end)") + local t = love.timer.getTime() + runner:exec(rows, runner:makeContext()) + if frame >= WARMUP then + total = total + love.timer.getTime() - t + end + close() + assert(game.save.gen3Vars[0x4040] == 64) + love.graphics.clear(0.03, 0.04, 0.06) + love.graphics.present() + if frame >= WARMUP then + Profile.frame() + end + frame = frame + 1 + if frame == WINDOW + WARMUP then + results[phase] = { + mode = ({ "before", "native", "decoded" })[phase], + frames = WINDOW, + scriptMeanMs = total / WINDOW * 1000, + seconds = love.timer.getTime() - started, + } + if phase == 3 then + print( + "M5_NATIVE_RESULT " + .. Json.encode({ + phases = results, + renderer = love.graphics.getRendererInfo(), + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + scope = "Native LÖVE ScriptRunner and actual per-mod Loader profile over 65 test-owned commands per frame; no world scene or cartridge gameplay", + }) + ) + return 0 + end + phase = phase + 1 + start() + end + end +end diff --git a/tools/decoded-profile-check/npcs.lua b/tools/decoded-profile-check/npcs.lua new file mode 100644 index 000000000..f667c0137 --- /dev/null +++ b/tools/decoded-profile-check/npcs.lua @@ -0,0 +1,88 @@ +-- Owned native CPU workload. Sprite construction is a deterministic seam; +-- map collision and actor updates use the production engine. +local Contract = require("tests.api3_contract.npcs") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local Native = require("src.world.WorldAPI") +local file = assert(io.open(assert(os.getenv("DECODED_PROFILE_BASELINE")), "rb")) +local Original = assert(loadstring(file:read("*a"), "@baseline/WorldAPI.lua"))() +file:close() +local phase, frame, fixture, total = 1, 0, nil, 0 +local results, WINDOW, WARMUP = {}, 120, 120 +local function start() + Profile.setEnabled(false) + package.loaded["src.world.WorldAPI"] = phase == 1 and Original or Native + fixture = Contract.fixture("tests/fixtures/legacy_npc_controls", "gen2", "gold", "legacy") + fixture.loader._game = function() + return fixture.live + end + -- Bind the actual API-2 loader facade before restoring the global module. + assert(fixture.exports.npc(fixture.home, "IMPORTED")) + package.loaded["src.world.WorldAPI"] = Native + frame, total = 0, 0 +end +function love.run() + start() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + Profile.setEnabled(true) + end + local label = phase == 3 and "npc.controls + native updates (64)" or "npc.lookup + legacy calls (64)" + local close = Profile.section(label) + local began = love.timer.getTime() + for _ = 1, 64 do + local h = assert(fixture.exports.npc(fixture.home, "IMPORTED")) + if phase == 3 then + assert(h:placeAt(1, 1, "right")) + assert(h:canStep("right")) + assert(h:setPassable(true)) + assert(h:stepNow("right")) + assert(h:isMoving()) + for _ = 1, 16 do + fixture.imported:update(fixture.ow.map, fixture.ow.entities) + end + assert(not h:isMoving()) + assert(h:setAppearance("ALT")) + else + h:position() + h:face("left") + end + end + if frame >= WARMUP then + total = total + love.timer.getTime() - began + end + close() + love.graphics.clear(0.03, 0.04, 0.06) + love.graphics.present() + if frame >= WARMUP then + Profile.frame() + end + frame = frame + 1 + if frame == WINDOW + WARMUP then + results[phase] = { + mode = ({ "before", "native", "controls" })[phase], + frames = WINDOW, + operationMeanMs = total / WINDOW * 1000, + } + fixture.restoreRenderer() + if phase == 3 then + print("M6_NATIVE_RESULT " .. Json.encode({ + phases = results, + renderer = love.graphics.getRendererInfo(), + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + scope = "Actual LÖVE host, production API-2 loader, native Map collision and NPC updates over owned data; deterministic sprite construction, no rendered artwork or cartridge scene", + })) + return 0 + end + phase = phase + 1 + start() + end + end +end diff --git a/tools/follower-contract-check/check.py b/tools/follower-contract-check/check.py new file mode 100644 index 000000000..a7b181f72 --- /dev/null +++ b/tools/follower-contract-check/check.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Compare the same public follower consumer through both native controllers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal"] + versions += ["firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/follower/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("FOLLOWER_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("FOLLOWER_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "native predicate/lifecycle/movement", + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"follower mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native follower observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_follower/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/frame-pacing-check/conf.lua b/tools/frame-pacing-check/conf.lua new file mode 100644 index 000000000..79551e6d7 --- /dev/null +++ b/tools/frame-pacing-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("FRAME_PACING_QA_GUARDED") == "1", "use tools/check_frame_pacing.py") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Frame pacing QA" + t.window.width, t.window.height, t.window.vsync = 320, 180, 1 + t.modules.audio = false +end diff --git a/tools/frame-pacing-check/gc.lua b/tools/frame-pacing-check/gc.lua new file mode 100644 index 000000000..80067fa1e --- /dev/null +++ b/tools/frame-pacing-check/gc.lua @@ -0,0 +1,124 @@ +local Cap = require("src.core.FrameCap") +local Performance = require("src.core.Performance") +local Budget = require("src.core.GCFrameBudget") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WINDOW, WARMUP = 120, 120 +local results, _trash = {}, nil +local witness = { key = "retained native data", value = 2712 } +local legacySteps, idleSteps, idleTotal, idleMax, heapPeak = 0, 0, 0, 0, 0 +local timer = { getTime = love.timer.getTime, sleep = love.timer.sleep } +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true +local originalPause = collectgarbage("setpause", 120) +collectgarbage("setpause", originalPause) +local originalMul = collectgarbage("setstepmul", 300) +collectgarbage("setstepmul", originalMul) + +local function resetStats() + legacySteps, idleSteps, idleTotal, idleMax, heapPeak = 0, 0, 0, 0, 0 +end +local function idle(clock, target) + local start = clock.getTime() + local steps = Budget.idle(clock, target) + if steps > 0 then + local duration = clock.getTime() - start + idleSteps, idleTotal = idleSteps + steps, idleTotal + duration + idleMax = math.max(idleMax, duration) + end +end +local function startPhase() + Profile.setEnabled(false) + Performance.gcBudget = phase == 2 + Budget.configure(Performance) + collectgarbage("collect") + frame = 0 + resetStats() + deadline = timer.getTime() +end +local function finishPhase() + assert(witness.key == "retained native data" and witness.value == 2712) + assert(_trash[512][2] == 512) + results[phase] = { + enabled = Performance.gcBudget, + frames = WINDOW, + seconds = timer.getTime() - started, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + legacySteps = legacySteps, + idleSteps = idleSteps, + idleMeanMs = idleTotal / WINDOW * 1000, + idleMaxMs = idleMax * 1000, + heapPeakKiB = heapPeak, + } + if phase == 2 then + Performance.gcBudget = false + Budget.configure(Performance) + local pause = collectgarbage("setpause", originalPause) + local mul = collectgarbage("setstepmul", originalMul) + assert(pause == originalPause and mul == originalMul, "disable did not restore GC tuning") + print("P2_NATIVE_RESULT " .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = love.graphics.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + originalPause = originalPause, + originalStepMul = originalMul, + phases = results, + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + resetStats() + Profile.setEnabled(true) + started, cpuStarted = timer.getTime(), os.clock() + end + Budget.beginFrame(Performance) + local close = Profile.section("update: allocation workload") + local rows = {} + for index = 1, 512 do + rows[index] = { frame, index, a = index * 2, b = index * 3 } + end + _trash = rows + close() + if not Performance.gcBudget then + close = Profile.section(" update: gc step") + collectgarbage("step", 1) + legacySteps = legacySteps + 1 + close() + end + love.graphics.clear(0.03, 0.04, 0.06) + close = Profile.section("present (GPU wait / vsync)") + love.graphics.present() + close() + close = Profile.section("frame pacing: sleep") + deadline = Cap.pace(timer, love.window, true, deadline, idle) + close() + frame = frame + 1 + if frame > WARMUP then + heapPeak = math.max(heapPeak, collectgarbage("count")) + Profile.frame() + end + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end diff --git a/tools/frame-pacing-check/hot.lua b/tools/frame-pacing-check/hot.lua new file mode 100644 index 000000000..8c8fdec9a --- /dev/null +++ b/tools/frame-pacing-check/hot.lua @@ -0,0 +1,114 @@ +local Version = require("src.core.GameVersion") +Version.set("red") +local Game = require("src.core.Game") +local Renderer = require("src.render.Renderer") +local Stack = require("src.core.StateStack") +local Performance = require("src.core.Performance") +local Cap = require("src.core.FrameCap") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local graphics = love.graphics +Game.data = require("tests.modkit.fixtures").fresh() +Game.save = { version = "red", options = {}, player = {}, modData = {} } +Game.overworld = {} +Game.stack = setmetatable( + { + states = { + { + draw = function() + graphics.setColor(0.2, 0.4, 0.6, 1) + graphics.rectangle("fill", 16, 16, 96, 80) + graphics.setColor(1, 1, 1, 1) + end, + }, + }, + }, + { __index = Stack } +) +Renderer:init() +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WINDOW, WARMUP = 120, 120 +local drawTotal, drawMax, requireCalls, requires = 0, 0, 0, {} +local results = {} +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true + +local function countRequires() + local original = require + requireCalls, requires = 0, {} + _G.require = function(name, ...) + requireCalls = requireCalls + 1 + requires[name] = (requires[name] or 0) + 1 + return original(name, ...) + end + local ok, err = pcall(Game.draw, Game) + _G.require = original + assert(ok, err) +end +local function startPhase() + Profile.setEnabled(false) + Performance.hotPathHoist = phase == 2 + frame, drawTotal, drawMax = 0, 0, 0 + deadline = love.timer.getTime() +end +local function finishPhase() + results[phase] = { + enabled = Performance.hotPathHoist, + frames = WINDOW, + seconds = love.timer.getTime() - started, + drawMeanMs = drawTotal / WINDOW * 1000, + drawMaxMs = drawMax * 1000, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + requireCalls = requireCalls, + requires = requires, + } + if phase == 2 then + print("P3_NATIVE_RESULT " .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = graphics.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + phases = results, + scope = "Native Game:draw and real canvas composition over owned synthetic content, no world/battle/mod scene", + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + countRequires() + Profile.setEnabled(true) + started, cpuStarted = love.timer.getTime(), os.clock() + end + local start = love.timer.getTime() + Game:draw() + if frame >= WARMUP then + local elapsed = love.timer.getTime() - start + drawTotal, drawMax = drawTotal + elapsed, math.max(drawMax, elapsed) + end + local close = Profile.section("present (GPU wait / vsync)") + graphics.present() + close() + deadline = Cap.pace(love.timer, love.window, true, deadline) + frame = frame + 1 + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end diff --git a/tools/frame-pacing-check/main.lua b/tools/frame-pacing-check/main.lua new file mode 100644 index 000000000..83722749e --- /dev/null +++ b/tools/frame-pacing-check/main.lua @@ -0,0 +1,114 @@ +local root = assert(os.getenv("FRAME_PACING_REPO")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +function love.errorhandler(message) + print(debug.traceback(tostring(message),2)) + return function()return 1 end +end +if os.getenv("FRAME_PACING_GC") == "1" then + require("tools.frame-pacing-check.gc") + return +end +if os.getenv("FRAME_PACING_HOT") == "1" then + require("tools.frame-pacing-check.hot") + return +end +if os.getenv("FRAME_PACING_WORLD") == "1" then + require("tools.frame-pacing-check.world") + return +end +if os.getenv("FRAME_PACING_PALETTE") == "1" then + require("tools.frame-pacing-check.palette") + return +end +local Cap = require("src.core.FrameCap") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local phase, frame, deadline, last, started, cpuStarted = 1, 0, 0, 0, 0, 0 +local sleeps, intervals, results = 0, {}, {} +local WINDOW, WARMUP = 120, 120 +local timer = { + getTime = love.timer.getTime, + sleep = function(dt) + sleeps = sleeps + 1 + love.timer.sleep(dt) + end, +} +local _, _, mode = love.window.getMode() +local refresh = tonumber(mode.refreshrate) or 0 +Cap.current = refresh > 0 and refresh or 60 +local function startPhase() + Profile.setEnabled(false) + Cap.vsyncPacing = phase == 2 + frame, sleeps, intervals = 0, 0, {} + deadline = timer.getTime() +end +local function finishPhase() + local long = 0 + for _, interval in ipairs(intervals) do + if refresh > 0 and interval > 1.5 / refresh then + long = long + 1 + end + end + table.sort(intervals) + results[phase] = { + enabled = Cap.vsyncPacing, + frames = WINDOW, + seconds = timer.getTime() - started, + medianMs = intervals[math.floor(#intervals / 2)] * 1000, + p95Ms = intervals[math.ceil(#intervals * 0.95)] * 1000, + maxMs = intervals[#intervals] * 1000, + framesOver1_5Periods = long, + extraSleepCalls = sleeps, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + } + if phase == 2 then + if refresh > 0 and love.window.getVSync() ~= 0 then + assert(sleeps == 0, "VSync phase still slept") + end + print("P1_NATIVE_RESULT " .. Json.encode({ + refresh = refresh, + vsync = love.window.getVSync(), + cap = Cap.current, + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = love.graphics.getRendererInfo(), + phases = results, + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + sleeps = 0 + Profile.setEnabled(true) + started, last = timer.getTime(), timer.getTime() + cpuStarted = os.clock() + end + love.graphics.clear(0.03, 0.04, 0.06) + local close = Profile.section("present (GPU wait / vsync)") + love.graphics.present() + close() + close = Profile.section("frame pacing: sleep") + deadline = Cap.pace(timer, love.window, true, deadline) + close() + frame = frame + 1 + if frame > WARMUP then + local now = timer.getTime() + intervals[#intervals + 1], last = now - last, now + Profile.frame() + end + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end diff --git a/tools/frame-pacing-check/palette.lua b/tools/frame-pacing-check/palette.lua new file mode 100644 index 000000000..8bc00e13c --- /dev/null +++ b/tools/frame-pacing-check/palette.lua @@ -0,0 +1,246 @@ +local Version = require("src.core.GameVersion") +local Performance = require("src.core.Performance") +local Renderer = require("src.render.Renderer") +local Palette = require("src.render.PaletteFX") +local Profile = require("src.core.FrameProfile") +local Cap = require("src.core.FrameCap") +local Json = require("src.link.Json") +local g = love.graphics +local colors = { { 255, 230, 200 }, { 180, 120, 80 }, { 50, 100, 150 }, { 5, 10, 15 } } +local other = { { 210, 240, 180 }, { 110, 160, 90 }, { 20, 70, 120 }, { 0, 0, 0 } } +local pixels = love.image.newImageData(32, 24) +pixels:mapPixel(function(x, y) + local shade = (math.floor(x / 2) + math.floor(y / 3)) % 4 / 3 + return shade, shade, shade, (x + y) % 7 == 0 and 0.5 or 1 +end) +local image = g.newImage(pixels) +image:setFilter("nearest", "nearest") +local function makeSource(density, filter) + local source = g.newCanvas(32, 24, { dpiscale = density }) + source:setFilter(filter or "nearest", filter or "nearest") + g.setCanvas(source) + g.origin() + g.clear(0, 0, 0, 0) + g.setColor(1, 1, 1, 1) + g.setBlendMode("replace") + g.draw(image) + g.setBlendMode("alpha") + return source +end +local source = makeSource(1) +local highSource = makeSource(2) +local linearSource = makeSource(1, "linear") +local zones = { + { x = 0, y = 0, w = 32, h = 8, colors = colors }, + { x = 0, y = 8, w = 32, h = 8, colors = colors }, + { x = 0, y = 16, w = 32, h = 8, colors = colors }, + { x = 5.2, y = 4.1, w = 15.6, h = 10.7, colors = other }, + { x = 10.5, y = 9.5, w = 5.2, h = 7.3, colors = false }, + { x = -3, y = -4, w = 8, h = 6, colors = colors }, + { x = 30, y = 21, w = 8, h = 6, colors = colors }, +} +local checked, quadCases, fallbackCases = 0, 0, 0 +local cases = {} +for _, density in ipairs({ 1, 1.25, 2.625 }) do + for _, scale in ipairs({ 1, 1.75, 2.625 }) do + for _, origin in ipairs({ 0, 3.2 }) do + cases[#cases + 1] = { density = density, scale = scale, x = origin, y = origin + 1.1 } + end + end +end +for _, kind in ipairs({ "anisotropic", "translate", "rotate", "highSource", "linearSource", "flip" }) do + cases[#cases + 1] = { density = 1.25, scale = 1.75, x = 3.2, y = 4.1, kind = kind } +end +for _, density in ipairs({ 1, 1.25, 2.625 }) do + cases[#cases + 1] = { density = density, scale = 3 / density, x = 2 / density, y = 3 / density } +end +local originalShader = Palette.shader +local function pixelChecks() + for _, version in ipairs({ "red", "gold" }) do + Version.set(version) + for _, mode in ipairs({ "gbc", "gbc_inv", "og_inv", "classic" }) do + Palette.mode = mode + for _, shade in ipairs({ false, { [0] = 2, [1] = 3, [2] = 3, [3] = 3 } }) do + Palette.setShadeMap(shade or nil) + for index, c in ipairs(cases) do + local target = g.newCanvas(96, 80, { dpiscale = c.density }) + local selected = c.kind == "highSource" and highSource + or c.kind == "linearSource" and linearSource + or source + local xScale = c.kind == "flip" and -c.scale or c.scale + local dpiY = c.kind == "anisotropic" and c.density * 1.05 or c.density + local before + local quadCount = 0 + for _, enabled in ipairs({ false, true }) do + Performance.paletteZoneCache = enabled + g.setCanvas(target) + g.origin() + g.clear(0.1, 0.2, 0.3, 1) + g.setColor(1, 1, 1, 1) + if c.kind == "translate" then + g.translate(1.2, 2.3) + end + if c.kind == "rotate" then + g.rotate(0.07) + end + local actualDraw = Renderer._drawZoneQuad + Renderer._drawZoneQuad = function(self, ...) + quadCount = quadCount + 1 + return actualDraw(self, ...) + end + Renderer:blitCanvas( + selected, + xScale, + c.scale, + zones, + c.scale, + c.scale, + c.x, + c.y, + 2.3, + 3.1, + 75.7, + 58.8, + c.density, + dpiY + ) + Renderer._drawZoneQuad = actualDraw + g.setCanvas() + g.origin() + local data = target:newImageData() + local bytes = data:getString() + data:release() + if not enabled then + before = bytes + else + if before ~= bytes then + local changed = 0 + for i = 1, #bytes do + if bytes:byte(i) ~= before:byte(i) then + changed = changed + 1 + end + end + error( + ("zone pixel mismatch %s/%s/shade=%s/case=%d/%s, %d bytes"):format( + version, + mode, + tostring(shade ~= false), + index, + c.kind or "native", + changed + ) + ) + end + end + end + target:release() + checked = checked + 1 + if quadCount > 0 then + quadCases = quadCases + 1 + else + fallbackCases = fallbackCases + 1 + end + end + end + end + end + assert(quadCases > 0 and fallbackCases > 0, "both optimized and compatibility geometry paths must be exercised") + Palette.mode = "gbc" + Palette.setShadeMap(nil) + Version.set("red") +end +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WARMUP, WINDOW = 120, 120 +local drawTotal, drawMax, heapStart = 0, 0, 0 +local results = {} +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true +local repeated = {} +for y = 0, 11 do + for x = 0, 7 do + repeated[#repeated + 1] = { x = x * 4, y = y * 2, w = 4, h = 2, colors = colors } + end +end +local function draw() + g.origin() + g.clear(0.1, 0.2, 0.3, 1) + g.setColor(1, 1, 1, 1) + Renderer:blitCanvas(source, 4, 4, repeated, 4, 4, 32, 32, 32, 32, 128, 96, 1, 1) + Profile.frame() +end +local function startPhase() + Profile.setEnabled(false) + Performance.paletteZoneCache = phase == 2 + collectgarbage("restart") + frame, drawTotal, drawMax = 0, 0, 0 + deadline = love.timer.getTime() +end +local function finishPhase() + results[phase] = { + enabled = Performance.paletteZoneCache, + frames = WINDOW, + seconds = love.timer.getTime() - started, + drawMeanMs = drawTotal / WINDOW * 1000, + drawMaxMs = drawMax * 1000, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + heapGrowthKiB = collectgarbage("count") - heapStart, + } + collectgarbage("restart") + if phase == 2 then + Palette.shader = originalShader + print("P5_NATIVE_RESULT " .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = g.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + pixelCases = checked, + quadCases = quadCases, + fallbackCases = fallbackCases, + zones = #repeated, + phases = results, + scope = "Exact GPU pixel comparisons over owned generated source; timed 96-zone native blits; GC stopped in measured windows, no ROM/mod/game scene", + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + pixelChecks() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + collectgarbage("collect") + collectgarbage("stop") + Profile.setEnabled(true) + heapStart = collectgarbage("count") + started, cpuStarted = love.timer.getTime(), os.clock() + end + local before = love.timer.getTime() + draw() + if frame >= WARMUP then + local elapsed = love.timer.getTime() - before + drawTotal, drawMax = drawTotal + elapsed, math.max(drawMax, elapsed) + end + local close = Profile.section("present (GPU wait / vsync)") + g.present() + close() + deadline = Cap.pace(love.timer, love.window, true, deadline) + frame = frame + 1 + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end diff --git a/tools/frame-pacing-check/world.lua b/tools/frame-pacing-check/world.lua new file mode 100644 index 000000000..6b4bd3ff5 --- /dev/null +++ b/tools/frame-pacing-check/world.lua @@ -0,0 +1,138 @@ +local Version = require("src.core.GameVersion") +Version.set("red") +local Renderer = require("src.render.Renderer") +local Performance = require("src.core.Performance") +local Cap = require("src.core.FrameCap") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local scene = require("tests.world_draw.fixture").new(64, { quiet = true }) +local world, graphics = scene.world, love.graphics +scene.game.renderer = Renderer +Renderer:init() +world.map.renderer.drawBorderFill = function(_, cx, cy, w, h) + graphics.setColor(0.2, 0.4, 0.5, 1) + graphics.rectangle("fill", 0, 0, w, h) +end +world.map.renderer.draw = function() + graphics.setColor(0.3, 0.5, 0.4, 1) + for y = 0, 8 do + for x = 0, 9 do + graphics.rectangle("fill", x * 16, y * 16, 14, 14) + end + end +end +world.map.renderer.drawReflectionCover = function(_, cx, cy, camX, camY) + graphics.setColor(0.2, 0.4, 0.5, 0.3) + graphics.rectangle("fill", cx * 16 - camX, cy * 16 - camY, 16, 16) +end +for i, e in ipairs(world.entities) do + e.px, e.py = (i % 10) * 16, math.floor(i / 10) * 16 + e.cellX, e.cellY = math.floor(e.px / 16), math.floor(e.py / 16) + e.draw = function(actor, cx, cy) + graphics.setColor(0.8, 0.3, 0.2, 1) + graphics.rectangle("fill", actor.px - cx, actor.py - cy, 8, 12) + end + e.drawReflection = function(actor, cx, cy) + graphics.setColor(0.8, 0.3, 0.2, 0.5) + graphics.rectangle("fill", actor.px - cx, actor.py - cy + 16, 8, 12) + end +end +local phase, frame, deadline, started, cpuStarted = 1, 0, 0, 0, 0 +local WINDOW, WARMUP = 120, 120 +local drawTotal, drawMax, protectedCalls, heapStart = 0, 0, 0, 0 +local results = {} +local _, _, mode = love.window.getMode() +Cap.current = tonumber(mode.refreshrate) or 60 +if Cap.current <= 0 then + Cap.current = 60 +end +Cap.vsyncPacing = true +local function draw() + local close = Profile.section("draw: whole frame") + Renderer:beginFrame(true) + Renderer:beginWorldPass() + world:drawWorld() + Renderer:endWorldPass() + Renderer:endFrame() + close() + Profile.frame() +end +local function startPhase() + Profile.setEnabled(false) + Performance.worldDrawHoist = phase == 2 + collectgarbage("restart") + frame, drawTotal, drawMax = 0, 0, 0 + deadline = love.timer.getTime() +end +local function finishPhase() + results[phase] = { + enabled = Performance.worldDrawHoist, + frames = WINDOW, + seconds = love.timer.getTime() - started, + drawMeanMs = drawTotal / WINDOW * 1000, + drawMaxMs = drawMax * 1000, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + protectedCalls = protectedCalls, + heapGrowthKiB = collectgarbage("count") - heapStart, + } + collectgarbage("restart") + if phase == 2 then + print( + "P4_NATIVE_RESULT " + .. Json.encode({ + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = graphics.getRendererInfo(), + refresh = mode.refreshrate, + vsync = love.window.getVSync(), + cap = Cap.current, + entities = #world.entities, + phases = results, + scope = "Native drawWorld/entity/reflection/FX loops with synthetic 64-actor rectangles; no ROM assets or mod scene; GC stopped during each measured window", + }) + ) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + local original = pcall + protectedCalls = 0 + _G.pcall = function(...) + protectedCalls = protectedCalls + 1 + return original(...) + end + draw() + _G.pcall = original + collectgarbage("collect") + collectgarbage("stop") + Profile.setEnabled(true) + heapStart = collectgarbage("count") + started, cpuStarted = love.timer.getTime(), os.clock() + end + local before = love.timer.getTime() + draw() + if frame >= WARMUP then + local elapsed = love.timer.getTime() - before + drawTotal, drawMax = drawTotal + elapsed, math.max(drawMax, elapsed) + end + local close = Profile.section("present (GPU wait / vsync)") + graphics.present() + close() + deadline = Cap.pace(love.timer, love.window, true, deadline) + frame = frame + 1 + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end diff --git a/tools/full_game_performance_driver.lua b/tools/full_game_performance_driver.lua new file mode 100644 index 000000000..e764d94d3 --- /dev/null +++ b/tools/full_game_performance_driver.lua @@ -0,0 +1,228 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Real imported content and native Game/Overworld; fixed 60 Hz simulation. +return function(game) + local U = require("tests.drivers.util") + local Json = require("src.link.Json") + local FP = require("src.core.FrameProfile") + local P = require("src.core.Performance") + local Logger = require("src.core.Logger") + local mode = assert(os.getenv("PERF_MODE")) + local out = assert(os.getenv("PERF_OUT")) + assert(love.filesystem.getIdentity():match("^oasc%-gen2recomp%-qa%-perf%-")) + local renderer, api, vendor, device = love.graphics.getRendererInfo() + assert(renderer:lower() == (os.getenv("PERF_BACKEND") or "metal") and not device:lower():find("software")) + local report = { + passed = false, + mode = mode, + edition = "emerald", + runtime = { love.getVersion() }, + architecture = jit.arch, + gpu = { renderer = renderer, api = api, vendor = vendor, device = device }, + simulationHz = 60, + phases = {}, + timing = "FrameProfile wall time; GPU submission, no completion fence; no screenshots inside intervals", + limitations = { + "sequential off/on samples; no causal whole-game speedup claim", + "Emerald does not exercise DS Nitro synthesis or GB palette uniforms", + "LOW native UI quad menus are covered by their separate allocation fixture", + }, + measurement = { warmupNative = 240, warmupVoxel = 1500, perSwitchWarmup = 180, samples = 240, profileWindow = 120 }, + } + U.freshSave(game) + game.save.options.performance = "high" + game.save.options.fpsCap = 240 + game.save.options.pipelines = game.save.options.pipelines or {} + game.save.options.pipelines.voxel = mode == "voxel" and 3 or 0 + game:applyOptions() + local _, _, flags = love.window.getMode() + flags.vsync = 0 + flags.highdpi = true + assert(love.window.setMode(640, 480, flags)) + require("src.core.FrameCap").apply(240) + U.teleport( + game, + os.getenv("PERF_MAP") or "MAP_G00_N15", + tonumber(os.getenv("PERF_X")) or 10, + tonumber(os.getenv("PERF_Y")) or 10, + "down" + ) + local V + if mode == "voxel" then + V = assert(_G.__DRAMATIC_SHAPE_V, "native loader did not load the unmodified mod") + V.require("Water").setting:sync("full") + V.require("RenderScale").setting:sync(1) + V.require("DayNight").setting:sync("day") + end + U.wait(mode == "voxel" and 1500 or 240) + if V then + local mesher = V.require("ChunkMesher") + local deadline, waited = love.timer.getTime() + 180, 0 + while + mesher.pending() > 0 or not (mesher.peek(game.overworld.map, false) or mesher.peek(game.overworld.map, true)) + do + assert(not mesher.buildFailure(game.overworld.map.id), "native terrain build failed") + assert(love.timer.getTime() < deadline, "native terrain did not become quiescent") + U.wait(60) + waited = waited + 60 + end + report.measurement.readyWaitFrames = waited + U.wait(240) + end + local active + local world, worldUpdate = game.overworld, game.overworld.update + world.update = function(self, ...) + worldUpdate(self, ...) + if active and active.card == "P7" then + -- Keep native simulation/draw, and exercise camera-driven invalidation. + self.camera.x = self.camera.x + #active.presentMs * 0.25 + end + end + local modPerf, count + if V then + modPerf = V.require("Perf") + count = modPerf.count + modPerf.count = function(name, n) + if active and name:sub(1, 7) == "shadow." then + active.shadowCasts[name] = (active.shadowCasts[name] or 0) + (n or 1) + end + return count(name, n) + end + end + local present = love.graphics.present + love.graphics.present = function(...) + local t = love.timer.getTime() + local result = present(...) + if active then + active.presentMs[#active.presentMs + 1] = (love.timer.getTime() - t) * 1000 + if active.previousPresent then + active.intervalsMs[#active.intervalsMs + 1] = (t - active.previousPresent) * 1000 + end + active.previousPresent = t + end + return result + end + local original = Logger.report + Logger.report = function(fmt, ...) + if active then + local args = { ... } + if fmt:match("^frame profile:") then + active.windows = active.windows + 1 + end + if fmt:find("%-34s", 1, true) then + local name, value = args[1], args[2] + local row = active.sections[name] or { sumMs = 0, windows = 0 } + row.sumMs = row.sumMs + value + row.windows = row.windows + 1 + active.sections[name] = row + end + end + return original(fmt, ...) + end + local function setCard(card, on) + if card == "P1" then + require("src.core.FrameCap").vsyncPacing = on + elseif card == "P2" then + P.gcBudget = on + collectgarbage("setpause", on and 120 or 200) + collectgarbage("setstepmul", on and 300 or 200) + elseif card == "P3" then + P.hotPathHoist = on + elseif card == "P4" then + P.worldDrawHoist = on + elseif card == "P5" then + P.paletteZoneCache = on + elseif card == "P6" then + P.audioWorkerOptimization = on + elseif card == "P7" then + V.shadowWorldAnchor = on + V.require("ShadowMap").invalidate() + elseif card == "P8" then + V.renderBudget = on + elseif card == "P9" then + P.lowTierBudget = on + game.save.options.performance = "low" + game:applyOptions() + require("src.core.FrameCap").apply(240) + end + end + local cards = mode == "native" and { "P1", "P2", "P3", "P4", "P5", "P6", "P9" } or { "P7", "P8" } + for _, card in ipairs(cards) do + for _, on in ipairs({ false, true }) do + setCard(card, on) + local paced = card == "P1" + love.window.setVSync(paced and 1 or 0) + require("src.core.FrameCap").apply(paced and 60 or 240) + U.wait(180) + collectgarbage("collect") + active = { + card = card, + enabled = on, + windows = 0, + sections = {}, + vsync = love.window.getVSync(), + presentMs = {}, + intervalsMs = {}, + fpsCap = require("src.core.FrameCap").current, + shadowCasts = {}, + cameraMotion = card == "P7" and "controlled 0.25 world-pixel pan per update" or "native camera follow", + } + FP.setEnabled(true) + FP.reset() + local start = love.timer.getTime() + U.wait(240) + FP.setEnabled(false) + local phase = active + active = nil + phase.elapsedSeconds = love.timer.getTime() - start + assert(phase.windows == 2 and #phase.presentMs == 240, "missing measured frames or FrameProfile windows") + assert(phase.sections["frame pacing: sleep"], "native pacing path was bypassed") + if card == "P7" then + assert( + phase.sections[" voxel: sun pass"] and phase.sections[" voxel: sun pass"].windows == 2, + "shadow measurement reached an unready fallback frame" + ) + assert(next(phase.shadowCasts), "native shadow passes did not execute") + end + for _, row in pairs(phase.sections) do + row.meanMs = row.sumMs / row.windows + row.sumMs = nil + end + local function distribution(values) + table.sort(values) + local sum = 0 + for _, v in ipairs(values) do + sum = sum + v + end + return { + samples = #values, + meanMs = sum / #values, + p50Ms = values[math.ceil(#values * 0.5)], + p95Ms = values[math.ceil(#values * 0.95)], + p99Ms = values[math.ceil(#values * 0.99)], + } + end + phase.present = distribution(phase.presentMs) + phase.intervals = distribution(phase.intervalsMs) + phase.presentMs, phase.intervalsMs, phase.previousPresent = nil, nil, nil + phase.world = game.overworld.map.id + phase.stack = require("src.core.StateStack").describe(game.stack:top()) + if V then + phase.voxelLevel = V.require("VoxelState").level + assert(phase.voxelLevel > 0) + end + report.phases[#report.phases + 1] = phase + print("PERF_PHASE", card, tostring(on), phase.elapsedSeconds) + end + setCard(card, true) + end + love.graphics.present = present + world.update = worldUpdate + if modPerf then + modPerf.count = count + end + Logger.report = original + report.passed = true + local f = assert(io.open(out .. "/report.json", "w")) + f:write(Json.encode(report)) + f:close() +end diff --git a/tools/gbc-color-check/conf.lua b/tools/gbc-color-check/conf.lua new file mode 100644 index 000000000..c51c84b19 --- /dev/null +++ b/tools/gbc-color-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "gen2-gbc-color-check" + t.window.title = "Gen2Recomped GBC colour checks" + t.window.width, t.window.height = 640, 576 + t.window.resizable = true + t.modules.audio = false +end diff --git a/tools/gbc-color-check/main.lua b/tools/gbc-color-check/main.lua new file mode 100644 index 000000000..30ac6eb67 --- /dev/null +++ b/tools/gbc-color-check/main.lua @@ -0,0 +1,252 @@ +-- Run from the repo root: love tools/gbc-color-check +-- Synthetic RGB data only. Optional GBC_COLOR_CHECK_SHOT=/absolute/path.png +-- writes the final three-panel comparison; no game assets or saves are read. +local root = love.filesystem.getSource():match("^(.*)/tools/gbc%-color%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path + +local C = require("src.render.GbcColorCorrection") +local Version = require("src.core.GameVersion") +local Palette = require("src.render.PaletteFX") +local Renderer = require("src.render.Renderer") +local Pipelines = require("src.render.Pipelines") +local FX = require("src.render.GBCFX") +local checks, failed, phase, capturing = 0, false, 1, false +local pictures = {} +local function check(ok, message) + checks = checks + 1 + assert(ok, message) +end + +local function canvasOf(data, dpi) + local image = love.graphics.newImage(data) + image:setFilter("nearest", "nearest") + local target = love.graphics.newCanvas(data:getWidth(), data:getHeight(), { dpiscale = dpi or 1 }) + love.graphics.push("all") + love.graphics.setCanvas(target) + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image) + love.graphics.pop() + image:release() + return target +end + +local function interpolate(r, g, b, mode) + local p, base, f = { r * 31, g * 31, b * 31 }, {}, {} + for i = 1, 3 do base[i] = math.floor(p[i]); f[i] = p[i] - base[i] end + local out = { 0, 0, 0 } + for dz = 0, 1 do for dy = 0, 1 do for dx = 0, 1 do + local value = { C.convert5(math.min(31, base[1] + dx), + math.min(31, base[2] + dy), math.min(31, base[3] + dz), mode) } + local weight = (dx == 0 and 1 - f[1] or f[1]) + * (dy == 0 and 1 - f[2] or f[2]) * (dz == 0 and 1 - f[3] or f[3]) + for i = 1, 3 do out[i] = out[i] + value[i] * weight / 255 end + end end end + return out +end + +local function near(actual, expected, tolerance) + return math.abs(actual - expected) <= tolerance +end + +local function run() + for _, gles in ipairs({ false, true }) do + local ok, reason = love.graphics.validateShader(gles, C.SHADER_SRC) + check(ok, "shader validation: " .. tostring(reason)) + end + Version.set("gold") + Palette.setMode("gbc") + local source = love.image.newImageData(256, 128) + for n = 0, 32767 do + local r, g, b = n % 32, math.floor(n / 32) % 32, math.floor(n / 1024) + source:setPixel(n % 256, math.floor(n / 256), + (r * 8 + math.floor(r / 4)) / 255, + (g * 8 + math.floor(g / 4)) / 255, + (b * 8 + math.floor(b / 4)) / 255, 1) + end + local input = canvasOf(source) + source:release() + C.setMode("off") + check(C.apply(input) == input, "OFF must preserve the exact canvas") + for _, mode in ipairs({ "accurate", "harsh" }) do + C.setMode(mode) + local corrected = C.apply(input) + check(corrected ~= input, mode .. " did not run on the GPU") + local pixels = corrected:newImageData() + local maxError = 0 + for n = 0, 32767 do + local expected = { C.convert5(n % 32, math.floor(n / 32) % 32, math.floor(n / 1024), mode) } + local actual = { pixels:getPixel(n % 256, math.floor(n / 256)) } + for i = 1, 3 do maxError = math.max(maxError, math.abs(actual[i] * 255 - expected[i])) end + end + pixels:release() + check(maxError < 1.01, mode .. " RGB555 GPU error exceeds one RGB888 unit: " .. maxError) + print(mode .. " GPU gamut max error: " .. maxError) + end + input:release() + + local gradient = love.image.newImageData(32, 1) + for i = 0, 31 do gradient:setPixel(i, 0, (80 + i) / 255, 63 / 255, 143 / 255, 128 / 255) end + input = canvasOf(gradient) + gradient:release() + C.setMode("accurate") + local previous = love.graphics.newCanvas(4, 4) + local oldShader = love.graphics.newShader("vec4 effect(vec4 c, Image t, vec2 u, vec2 p) { return Texel(t,u)*c; }") + love.graphics.setCanvas(previous) + love.graphics.setShader(oldShader) + love.graphics.setColor(0.2, 0.3, 0.4, 0.5) + love.graphics.setScissor(1, 2, 3, 4) + love.graphics.setBlendMode("add", "premultiplied") + love.graphics.setColorMask(false, true, false, true) + love.graphics.translate(7, 9) + local corrected = C.apply(input) + check(love.graphics.getCanvas() == previous, "caller canvas was not restored") + check(love.graphics.getShader() == oldShader, "caller shader was not restored") + check(near(select(1, love.graphics.getColor()), 0.2, 1e-6), "caller colour was not restored") + check(love.graphics.getScissor() == 1, "caller scissor was not restored") + check(love.graphics.getBlendMode() == "add", "caller blend mode was not restored") + check(love.graphics.getColorMask() == false, "caller colour mask was not restored") + local x, y = love.graphics.transformPoint(0, 0) + check(x == 7 and y == 9, "caller transform was not restored") + love.graphics.setCanvas() + love.graphics.setShader() + love.graphics.setScissor() + love.graphics.setBlendMode("alpha") + love.graphics.setColorMask(true, true, true, true) + love.graphics.origin() + love.graphics.setColor(1, 1, 1, 1) + local pixels = corrected:newImageData() + local first, last + for i = 0, 31 do + local r, g, b, a = pixels:getPixel(i, 0) + local expected = interpolate((80 + i) / 255, 63 / 255, 143 / 255, "accurate") + check(near(r, expected[1], 1.01 / 255) and near(g, expected[2], 1.01 / 255) + and near(b, expected[3], 1.01 / 255), "fractional RGB interpolation mismatch") + check(near(a, 128 / 255, 0.01 / 255), "alpha changed") + first, last = first or r, r + end + check(last > first, "true-colour gradient collapsed") + pixels:release() + input:release() + oldShader:release() + previous:release() + for _, size in ipairs({ { 4, 4, 2 }, { 9, 7, 1 } }) do + input = love.graphics.newCanvas(size[1], size[2], { dpiscale = size[3] }) + corrected = C.apply(input) + check(corrected:getWidth() == size[1] and corrected:getHeight() == size[2], "resize lost dimensions") + check(corrected:getDPIScale() == size[3], "DPI scale changed") + input:release() + end + + input = love.graphics.newCanvas(7, 3) + local draw = love.graphics.draw + love.graphics.draw = function() error("injected GPU draw failure") end + check(C.apply(input) == input, "draw failure did not fall back to the source") + love.graphics.draw = draw + check(love.graphics.getCanvas() == nil and love.graphics.getShader() == nil, + "draw failure leaked render state") + check(not C.active(), "failed target should not retry every frame") + C.setMode("accurate") + check(C.active(), "changing the setting must allow a target retry") + input:release() + input = love.graphics.newCanvas(7, 5) + local allocate = love.graphics.newCanvas + love.graphics.newCanvas = function() error("injected allocation failure") end + check(C.apply(input) == input, "allocation failure did not preserve the source") + love.graphics.newCanvas = allocate + check(not C.active(), "failed allocation should not retry every frame") + input:release() + C.setMode("accurate") + + -- Immutable copies for the synthetic, ROM-free visual comparison. + for _, mode in ipairs({ "off", "accurate", "harsh" }) do + source = love.image.newImageData(160, 144) + for yy = 0, 143 do for xx = 0, 159 do + local r, g, b + if yy < 72 then + r, g, b = xx / 159, yy / 71, 0.65 + else + local shade = math.floor(xx / 20) / 7 + r, g, b = 0.35 * shade, 0.25 * shade, 0.7 * shade + end + source:setPixel(xx, yy, r, g, b, 1) + end end + input = canvasOf(source) + source:release() + C.setMode(mode) + pixels = C.apply(input):newImageData() + pictures[mode] = love.graphics.newImage(pixels) + pictures[mode]:setFilter("nearest", "nearest") + pixels:release() + input:release() + end + C.setMode("off") + Renderer:init() +end + +function love.load() + local ok, err = xpcall(run, debug.traceback) + if not ok then failed = true; io.stderr:write(err .. "\n"); love.event.quit(1) end +end + +-- Exercise the real endFrame path, not an isolated shader canvas: correction +-- alone, with LCD FX, and the original OFF path on separate rendered frames. +function love.draw() + if failed or capturing then return end + if phase <= 3 then + local ok, err = xpcall(function() + local trace = {} + local oldApply, oldPresent, oldFX = C.apply, Pipelines.present, FX.present + C.apply = function(canvas) trace[#trace + 1] = "correction"; return oldApply(canvas) end + Pipelines.present = function(canvas, ctx) trace[#trace + 1] = "pipeline"; return oldPresent(canvas, ctx) end + FX.present = function(canvas, scale) trace[#trace + 1] = "lcd"; return oldFX(canvas, scale) end + C.setMode(phase < 3 and "accurate" or "off") + FX.setLevel(phase == 2 and 4 or 0) + Renderer:beginFrame() + love.graphics.clear(0.35, 0.2, 0.7, 1) + Renderer:endFrame() + C.apply, Pipelines.present, FX.present = oldApply, oldPresent, oldFX + local expected = phase == 1 and "correction,pipeline" + or (phase == 2 and "correction,pipeline,lcd" or "") + check(table.concat(trace, ",") == expected, "native pass order differs: " .. table.concat(trace, ",")) + end, debug.traceback) + if not ok then failed = true; io.stderr:write(err .. "\n"); love.event.quit(1); return end + local renderedPhase = phase + capturing = true + love.graphics.captureScreenshot(function(pixels) + local verified, verifyError = xpcall(function() + local r, g, b, a = pixels:getPixel(320, 288) + check(near(a, 1, 1 / 255), "native frame alpha changed") + if renderedPhase == 2 then + check(math.max(r, g, b) > 0.02, "LCD combination presented a black frame") + else + local expected = renderedPhase == 1 and interpolate(0.35, 0.2, 0.7, "accurate") + or { 0.35, 0.2, 0.7 } + check(near(r, expected[1], 2.01 / 255) and near(g, expected[2], 2.01 / 255) + and near(b, expected[3], 2.01 / 255), "native screen pixels do not match the selected profile") + end + end, debug.traceback) + if not verified then failed = true; io.stderr:write(verifyError .. "\n"); love.event.quit(1) end + phase, capturing = phase + 1, false + end) + return + end + love.graphics.clear(0.05, 0.05, 0.05, 1) + love.graphics.setColor(1, 1, 1, 1) + for i, mode in ipairs({ "off", "accurate", "harsh" }) do + love.graphics.print(mode, 10 + (i - 1) * 210, 20) + love.graphics.draw(pictures[mode], 10 + (i - 1) * 210, 50, 0, 1.25, 1.25) + end + print(checks .. " GPU checks passed (synthetic data)") + capturing = true + local path = os.getenv("GBC_COLOR_CHECK_SHOT") + if path then + love.graphics.captureScreenshot(function(data) + local encoded = data:encode("png") + local file = assert(io.open(path, "wb")) + file:write(encoded:getString()); file:close() + love.event.quit(0) + end) + else love.event.quit(0) end +end diff --git a/tools/gen2-lifecycle-profile/conf.lua b/tools/gen2-lifecycle-profile/conf.lua new file mode 100644 index 000000000..21e56512f --- /dev/null +++ b/tools/gen2-lifecycle-profile/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv('GEN2_LIFECYCLE_PROFILE_QA')=='1','use the guarded lifecycle checker') + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.title='Native Gen2 lifecycle profile' + t.window.width,t.window.height,t.window.vsync=480,240,1 + t.modules.audio=false +end diff --git a/tools/gen2-lifecycle-profile/main.lua b/tools/gen2-lifecycle-profile/main.lua new file mode 100644 index 000000000..0f5cb9125 --- /dev/null +++ b/tools/gen2-lifecycle-profile/main.lua @@ -0,0 +1,76 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root=assert(os.getenv('GEN2_LIFECYCLE_ROOT')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +require('src.core.GameVersion').set('crystal') +local Profile=require('src.core.FrameProfile') +local Json=require('src.link.Json') +local SDK=require('tests.modkit.sdk') +local Loader=require('src.mods.Loader') +local before,after={},{} +for _,name in ipairs({'Gen2Friendship','RoamMons','BugContest'})do + before[name]=assert(loadfile(assert(os.getenv('GEN2_LIFECYCLE_BASELINE'))..'/'..name..'.lua'))() + after[name]=require((name=='Gen2Friendship'and 'src.pokemon.'or 'src.world.')..name) +end +local phase,frame,total,loader,save,game,modules=1,0,0 +local results={} +local function start() + Profile.setEnabled(false) + local data=require('tests.modkit.fixtures').fresh() + local enabled=phase~=2 + local files={ + ['mods/probe/manifest.json']=Json.encode({id='probe',name='Probe',version='1.0.0',api=2,entry='main.lua',games={'gen2'}}), + ['mods/probe/main.lua']=[[local mod=... + mod.exports.count=0 + for _,name in ipairs({'happiness.changed','roamer.encountered','bug_contest.scored'})do + mod.events:on(name,function()mod.exports.count=mod.exports.count+1 end) + end]], + ['options.lua']='return {mods={probe='..tostring(enabled)..'}}', + } + loader=Loader.new({fs=SDK.memfs(files),dev=false});assert(loader:load(data)) + local mon={happiness=70,hp=100,maxHp=100,species='FIXMON_A',dvs={},stats={attack=50,defense=50,speed=50,special=50}} + save={party={mon},flags={},g2RoamReleased=true,g2Roam={RAIKOU={active=true,mapId='OWN_MAP',level=40}}, + g2BugContest={caught=mon,scores={}}} + game={data=data,save=save};modules=phase==1 and before or after + frame,total=0,0 +end +local function rng()return 1 end +function love.errorhandler(message) + print(debug.traceback(tostring(message),2));return function()return 1 end +end +function love.run() + start() + return function() + love.event.pump() + for event in love.event.poll()do if event=='quit'then return 1 end end + if frame==120 then Profile.setEnabled(true)end + local close=Profile.section('mods.lifecycle (192 transitions)') + local began=love.timer.getTime() + for _=1,64 do + local mon=save.party[1];mon.happiness=70 + modules.Gen2Friendship.change(mon,'LEVELUP') + assert(modules.RoamMons.check(save,'OWN_MAP',false,rng)=='RAIKOU') + modules.BugContest.judge(game) + end + if frame>=120 then total=total+love.timer.getTime()-began end + close() + love.graphics.clear(.03,.04,.06) + love.graphics.print('Native lifecycle: '..({'before','after, no observer','after, observer'})[phase],12,12) + love.graphics.present() + if frame>=120 then Profile.frame()end + frame=frame+1 + if frame==240 then + results[phase]={mode=({'before','after_no_observer','after_observer'})[phase], + frames=120,meanMs=total/120*1000,observations=loader.exports.probe and loader.exports.probe.count or 0} + assert(#loader.errors==0,table.concat(loader.errors,'; ')) + Loader.teardown() + if phase==3 then + assert(results[1].observations==0 and results[2].observations==0 and results[3].observations==240*192) + print('LIFECYCLE_PROFILE '..Json.encode({phases=results,renderer=love.graphics.getRendererInfo(), + os=love.system.getOS(),arch=jit.arch, + scope='Native Love host, real Loader observer and three production producers over owned data; no cartridge scene or FPS claim'})) + return 0 + end + phase=phase+1;start() + end + end +end diff --git a/tools/gen2-registry-contract/conf.lua b/tools/gen2-registry-contract/conf.lua new file mode 100644 index 000000000..a414765a8 --- /dev/null +++ b/tools/gen2-registry-contract/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv('GEN2_REGISTRY_QA')=='1','use the guarded registry checker') + t.identity=assert(os.getenv('POKEPORT_IDENTITY')) + t.window.title='Gen2 registry validation profile' + t.window.width,t.window.height,t.window.vsync=480,240,1 + t.modules.audio=false +end diff --git a/tools/gen2-registry-contract/fields.lua b/tools/gen2-registry-contract/fields.lua new file mode 100644 index 000000000..025db5579 --- /dev/null +++ b/tools/gen2-registry-contract/fields.lua @@ -0,0 +1,18 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Inspect public Gen2 descriptors without executing another engine's bodies. +package.path='./?.lua;./?/init.lua;'..package.path +local Schemas=require('src.mods.Schemas') +local Json=require('src.link.Json') +local function describe(field) + local out={kind=field.kind,min=field.min,max=field.max,registry=field.registry} + if field.inner then out.inner=describe(field.inner)end + return out +end +local out={} +for _,name in ipairs({'apricorns','decorations','landmarks','phone_contacts','radio_channels'})do + local spec=assert(Schemas.REGISTRIES[name],name) + local entry={semantics=spec.semantics,fields={}} + for key,field in pairs(spec.fields)do entry.fields[key]=describe(field)end + out[name]=entry +end +print('REGISTRY_CONTRACT '..Json.encode(out)) diff --git a/tools/gen2-registry-contract/main.lua b/tools/gen2-registry-contract/main.lua new file mode 100644 index 000000000..4a52777ac --- /dev/null +++ b/tools/gen2-registry-contract/main.lua @@ -0,0 +1,55 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root=assert(os.getenv('GEN2_REGISTRY_ROOT')) +package.path=root..'/?.lua;'..root..'/?/init.lua;'..package.path +local Profile=require('src.core.FrameProfile') +local Json=require('src.link.Json') +local file=assert(io.open(assert(os.getenv('GEN2_REGISTRY_BASELINE')),'rb')) +local before=assert(loadstring(file:read('*a'),'@baseline/Schemas.lua'))() +file:close() +local after=require('src.mods.Schemas') +local rows={ + apricorns={apricorn='FRUIT',ball='BALL',event=600,index=1}, + decorations={type=2,name='BED',action='SET_UP_BED',flag=500,sprite=0}, + landmarks={name='HOME',x=80,y=72,index=1}, + phone_contacts={index=2,name='CALLER',number=2,calleeTime=0,callerTime=0}, + radio_channels={channel=9,name='STATION'}, +} +local phase,frame,total=1,0,0 +local results={} +function love.errorhandler(message) + print(debug.traceback(tostring(message),2)) + return function()return 1 end +end +function love.run() + return function() + love.event.pump() + for name in love.event.poll()do if name=='quit'then return 1 end end + if frame==120 then Profile.setEnabled(true)end + local schema=phase==1 and before or after + local close=Profile.section('mods.registry.validate (320 records)') + local began=love.timer.getTime() + for _=1,64 do + for name,row in pairs(rows)do + assert(schema.check(schema.REGISTRIES[name],name,'QA',row,'register')) + end + end + if frame>=120 then total=total+love.timer.getTime()-began end + close() + love.graphics.clear(.03,.04,.06) + love.graphics.print('Gen2 registry validation: '..(phase==1 and 'before' or 'after'),12,12) + love.graphics.present() + if frame>=120 then Profile.frame()end + frame=frame+1 + if frame==240 then + results[phase]={mode=phase==1 and 'before' or 'after',frames=120,meanMs=total/120*1000} + if phase==2 then + print('REGISTRY_PROFILE '..Json.encode({phases=results,renderer=love.graphics.getRendererInfo(), + os=love.system.getOS(),arch=jit.arch, + scope='Owned records, native Love host and FrameProfile section; schema validation only, no gameplay scene'})) + return 0 + end + Profile.setEnabled(false) + phase,frame,total=2,0,0 + end + end +end diff --git a/tools/gen3_discover.py b/tools/gen3_discover.py index b3467fc64..40213a883 100644 --- a/tools/gen3_discover.py +++ b/tools/gen3_discover.py @@ -10,11 +10,15 @@ and then checked against a fact the table itself has to satisfy. Nothing here is trusted because it is "the address everyone quotes". +The separate terrainPalettePrograms field is an explicitly source-pinned +callback profile for an exact full ROM hash, not a structural discovery. + Usage: python3 gen3_discover.py [-o rom_manifest_emerald.json] """ import argparse import collections, hashlib, json, sys +from gen3_palette_profiles import profile_for_sha1 BASE = 0x08000000 NUM_SPECIES = 412 # named species slots, INCLUDING the 25 unused @@ -5447,6 +5451,9 @@ def main(): 'generation': 3, 'version': 'emerald', 'romSha1': sha1, + # This field is deliberately source-pinned and exact-hash gated, + # rather than a structural guess about callback side effects. + 'terrainPalettePrograms': profile_for_sha1(sha1), 'romHeader': {'title': data[0xA0:0xAC].decode('ascii', 'replace'), 'gameCode': code, 'makerCode': data[0xB0:0xB2].decode('ascii'), 'revision': data[0xBC]}, diff --git a/tools/gen3_palette_profiles.py b/tools/gen3_palette_profiles.py new file mode 100644 index 000000000..1f62e0e4a --- /dev/null +++ b/tools/gen3_palette_profiles.py @@ -0,0 +1,14 @@ +"""Curated, source-pinned terrain palette profiles, keyed by exact ROM SHA1. + +Unlike the structural table discovery, these audited callback classifications +are specific to a matching build. No palette bytes or executable code here. +""" +import copy +import json +from pathlib import Path + + +def profile_for_sha1(sha1): + path = Path(__file__).with_name("gen3_palette_program_profiles.json") + profiles = json.loads(path.read_text()) + return copy.deepcopy(profiles.get(sha1)) diff --git a/tools/gen3_palette_program_profiles.json b/tools/gen3_palette_program_profiles.json new file mode 100644 index 000000000..2de4134c8 --- /dev/null +++ b/tools/gen3_palette_program_profiles.json @@ -0,0 +1,155 @@ +{ + "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc": { + "game": "firered", + "programs": { + "459040": { + "id": "general", + "kind": "static", + "secondary": false + }, + "459180": { + "id": "celadon", + "kind": "static", + "secondary": true + }, + "459300": { + "id": "silph", + "kind": "static", + "secondary": true + }, + "459420": { + "id": "ember", + "kind": "static", + "secondary": true + }, + "459524": { + "id": "vermilion-gym", + "kind": "static", + "secondary": true + }, + "459624": { + "id": "celadon-gym", + "kind": "static", + "secondary": true + } + }, + "romSha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc", + "source": "https://github.com/pret/pokefirered/blob/d58bf398b2b83a326d23d14d531de7f45a1058bb/asm/tileset_anims.s", + "version": 1 + }, + "f3ae088181bf583e55daf962a92bb46f4f1d07b7": { + "game": "emerald", + "programs": { + "658288": { + "id": "general", + "kind": "static", + "secondary": false + }, + "658356": { + "id": "building", + "kind": "static", + "secondary": false + }, + "659516": { + "id": "rustboro", + "kind": "static", + "secondary": true + }, + "659640": { + "id": "dewford", + "kind": "static", + "secondary": true + }, + "659664": { + "id": "slateport", + "kind": "static", + "secondary": true + }, + "659688": { + "id": "mauville", + "kind": "static", + "secondary": true + }, + "659804": { + "id": "lavaridge", + "kind": "static", + "secondary": true + }, + "659848": { + "id": "ever-grande", + "kind": "static", + "secondary": true + }, + "659964": { + "id": "pacifidlog", + "kind": "static", + "secondary": true + }, + "660012": { + "id": "sootopolis", + "kind": "static", + "secondary": true + }, + "660036": { + "id": "underwater", + "kind": "static", + "secondary": true + }, + "660064": { + "id": "cave", + "kind": "static", + "secondary": true + }, + "660092": { + "id": "frontier-west", + "kind": "static", + "secondary": true + }, + "660116": { + "id": "frontier-east", + "kind": "static", + "secondary": true + }, + "660928": { + "id": "mauville-gym", + "kind": "static", + "secondary": true + }, + "660952": { + "id": "sootopolis-gym", + "kind": "static", + "secondary": true + }, + "660976": { + "id": "elite-four", + "kind": "static", + "secondary": true + }, + "661020": { + "id": "bike-shop", + "kind": "static", + "secondary": true + }, + "661044": { + "id": "battle-pyramid", + "kind": "static", + "secondary": true + }, + "661080": { + "count": 16, + "excludes": "cartridge fade and battle-transition task state", + "first": 128, + "frames": 4, + "id": "battle-dome-floor", + "kind": "frames", + "phase": 0, + "secondary": true, + "step": 4, + "table": 5392508 + } + }, + "romSha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7", + "source": "https://github.com/pret/pokeemerald/blob/3ec4d98de95a2aeb12f3c738b3496d0b3a830793/src/tileset_anims.c", + "version": 1 + } +} diff --git a/tools/input-hook-contract-check/README.md b/tools/input-hook-contract-check/README.md new file mode 100644 index 000000000..c8e0c2195 --- /dev/null +++ b/tools/input-hook-contract-check/README.md @@ -0,0 +1,16 @@ +# Cross-engine input hook contract + +Run `python3 tools/input-hook-contract-check/check.py --gen1 /path/to/gen1recomp +--lua luajit --report /tmp/input-contract.json` from Gen2Recomped. + +The Gen1 checkout must be clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. Both native loaders execute +`examples/portable_input/main.lua` unchanged, against the real Input singleton +and native Game/Game2/Game3 callbacks. Four host versions run in separate +processes. Each run asserts the contract and the checker compares resulting +held-state journals. No ROM or window is needed, and Gen1 is never patched. + +Use `--format unified` for API-2 callback-format integration and +`--api 3 --format unified` for the common API-3 entry. These modes require the +corresponding host features; unsupported manifests fail rather than being +silently downgraded. `--gen2` selects a separate integration checkout. diff --git a/tools/input-hook-contract-check/check.py b/tools/input-hook-contract-check/check.py new file mode 100644 index 000000000..e5a6fa67c --- /dev/null +++ b/tools/input-hook-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged input mod against both engines' native Game callbacks.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/input_hooks/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("INPUT_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("INPUT_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"input state mismatch: {version}" + print(f"PASS {version}: matching native input state across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_input/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/level-visible-contract-check/check.py b/tools/level-visible-contract-check/check.py new file mode 100644 index 000000000..c1d37b10a --- /dev/null +++ b/tools/level-visible-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged level visibility mod through both engines' real SDK and display predicate.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/level_visible/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("LEVEL_VISIBLE_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("LEVEL_VISIBLE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"level visibility mismatch: {version}" + print(f"PASS {version}: matching native level display predicates across {len(observations[0]['native']) + len(observations[0]['predicate'])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_level_visible/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/live-maps-contract-check/check.py b/tools/live-maps-contract-check/check.py new file mode 100644 index 000000000..1b7ddb3c6 --- /dev/null +++ b/tools/live-maps-contract-check/check.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Compare native live-map events and the unchanged API-3 pixel reader.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=3) + p.add_argument("--format", choices=["native", "unified"], default="unified") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + versions = ["red", "blue", "yellow", "gold", "silver", "crystal"] + if a.api >= 3: + versions += ["firered", "emerald"] + for version in versions: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/live_maps/worker.lua"), str(gen2), + backend, version, str(a.api), a.format], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("LIVE_MAPS_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("LIVE_MAPS_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "scope": "events+reader" if a.api >= 3 and version not in ["firered", "emerald"] + else ("reader" if a.api >= 3 else "events"), + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"live maps mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching native lifecycle/reader observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), "api": a.api, + "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_live_maps/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/logic-speed-contract-check/check.py b/tools/logic-speed-contract-check/check.py new file mode 100644 index 000000000..73482836e --- /dev/null +++ b/tools/logic-speed-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged speed mod against both engines' native logicSpeed methods.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/logic_speed/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("LOGIC_SPEED_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("LOGIC_SPEED_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"logic speed mismatch: {version}" + print(f"PASS {version}: matching native logic speed across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_logic_speed/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/low-tier-check/conf.lua b/tools/low-tier-check/conf.lua new file mode 100644 index 000000000..c5ca95861 --- /dev/null +++ b/tools/low-tier-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("LOW_TIER_QA_GUARDED") == "1", "use the guarded render-budget checker") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "LOW tier qualification" + t.window.width, t.window.height, t.window.vsync = 640, 480, 0 + t.modules.audio = false +end diff --git a/tools/low-tier-check/main.lua b/tools/low-tier-check/main.lua new file mode 100644 index 000000000..bb4ff6867 --- /dev/null +++ b/tools/low-tier-check/main.lua @@ -0,0 +1,178 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root = assert(os.getenv("LOW_TIER_QA_ROOT")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local P = require("src.core.Performance") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local Assets = require("src.render.Assets") +local Bag = require("src.ui.Gen3BagMenu") +local Box = require("src.ui.Gen3BoxMenu") +local Text = require("src.render.TextBox") +local phase, frame, total, allocations = 1, 0, 0, 0 +local function art(w, h) + local data = love.image.newImageData(w, h) + data:mapPixel(function(x, y) + return ((x * 13 + y * 3) % 256) / 255, ((x * 7 + y * 17) % 256) / 255, ((x * 19 + y * 11) % 256) / 255, 1 + end) + local image = love.graphics.newImage(data) + image:setFilter("nearest", "nearest") + data:release() + return image +end +local sheet, icon, arrow = art(128, 64), art(16, 64), art(30, 12) +Assets.image = function(path) + if path == "arrow" then + return arrow + end + return sheet +end +local bag = setmetatable( + { + pocket = 1, + game = { + save = { player = {} }, + data = { + items = {}, + constants = { + gen3Bag = { male = "owned", frames = 8, frameWidth = 16, frameHeight = 16 }, + gen3ItemIcons = { image = "owned", size = 16, cols = 8, count = 32 }, + }, + }, + }, + }, + Bag +) +local box = setmetatable({ + t = 0, + iconFor = function() + return icon, 16 + end, +}, Box) +local text = setmetatable( + { + boxTx = 0, + boxTy = 0, + boxTw = 20, + boxTh = 6, + drawFrame = false, + shown = { {} }, + line1Y = 184, + line2Y = 200, + textX = 12, + waiting = true, + promptArrowAtPen = true, + promptArrow = { path = "arrow", width = 10, height = 12, frames = 3 }, + promptArrowImage = arrow, + blink = 0, + }, + Text +) +local target = love.graphics.newCanvas(320, 240, { dpiscale = 1 }) +local originalQuad = love.graphics.newQuad +love.graphics.newQuad = function(...) + allocations = allocations + 1 + return originalQuad(...) +end +local function draw() + love.graphics.setCanvas(target) + love.graphics.clear(0, 0, 0, 1) + love.graphics.setColor(1, 1, 1, 1) + for i = 0, 63 do + bag.pocket = i % 8 + 1 + local img, q = bag:bagFrame() + love.graphics.draw(img, q, (i % 16) * 20, math.floor(i / 16) * 20) + img, q = bag:itemIcon(nil, i % 32) + love.graphics.draw(img, q, (i % 16) * 20, 90 + math.floor(i / 16) * 20) + box.t = i * 0.1 + box:drawIcon({}, (i % 16) * 20 + 8, 166) + local crop = box:frlgQuad(sheet, (i % 8) * 16, 16, 16, 16) + love.graphics.draw(sheet, crop, (i % 16) * 20, 210) + end + text.blink = frame % 36 + text:draw() + love.graphics.setCanvas() +end +local checks, results = 0, {} +local function check(ok, msg) + assert(ok, msg) + checks = checks + 1 +end +local function qualify() + for _, tick in ipairs({ 0, 9, 18, 27 }) do + frame = tick + P.lowTierBudget = false + draw() + local before = target:newImageData():getString() + P.lowTierBudget = true + draw() + check(target:newImageData():getString() == before, "native UI pixels changed after quad reuse") + end + local owner = require("src.render.UiQuad") + local a, b = {}, {} + local qa = owner.get(a, "test", sheet, 0, 0, 16, 16, 128, 64) + local qb = owner.get(b, "test", sheet, 16, 16, 16, 16, 128, 64) + check(qa ~= qb, "native quad owner storage overlaps") +end +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + return function() + return 1 + end +end +function love.run() + qualify() + frame, allocations = 0, 0 + return function() + love.event.pump() + for e in love.event.poll() do + if e == "quit" then + return 1 + end + end + P.lowTierBudget = phase == 2 + if frame == 120 then + Profile.setEnabled(true) + allocations = 0 + end + local close = Profile.section("native UI quads (owned art)") + local began = love.timer.getTime() + draw() + if frame >= 120 then + total = total + love.timer.getTime() - began + end + close() + local fence = target:newImageData(1, 1, 0, 0, 1, 1) + fence:release() + if frame >= 120 then + Profile.frame() + end + frame = frame + 1 + if frame == 240 then + results[phase] = { + mode = phase == 1 and "legacy" or "cached", + frames = 120, + warmupFrames = 120, + meanMs = total / 120 * 1000, + quadAllocations = allocations, + } + if phase == 2 then + check( + results[1].quadAllocations > 0 and results[2].quadAllocations == 0, + "native quad allocations were not eliminated after warmup" + ) + print( + "LOW_TIER_RESULT " + .. Json.encode({ + phases = results, + checks = checks, + renderer = love.graphics.getRendererInfo(), + scope = "Actual bag, box and textbox draw producers with owned artwork; CPU draw submission, no ROM or full-game FPS", + }) + ) + return 0 + end + Profile.setEnabled(false) + phase, frame, total, allocations = 2, 0, 0, 0 + end + end +end diff --git a/tools/mod-input-contract-check/README.md b/tools/mod-input-contract-check/README.md new file mode 100644 index 000000000..b6ac022a6 --- /dev/null +++ b/tools/mod-input-contract-check/README.md @@ -0,0 +1,19 @@ +# Cross-engine mod.input contract + +Run from Gen2Recomped: + +```sh +python3 tools/mod-input-contract-check/check.py --gen1 /path/to/gen1recomp --lua luajit --report /tmp/mod-input.json +``` + +The Gen1 checkout must be clean at +`79ea62f45fb2ea8f65d081a109431e4eb1bbdb36`. No source is patched. Each native +loader executes `examples/portable_mod_input/main.lua` unchanged for two +independent mods, on Red, Gold, FireRed and Emerald. The checker verifies +the eight logical buttons, fixed-step edges/holds, independent sources, +foreign/idempotent token release, rebinding, invalid arguments and recovery, +then compares held/edge journals across the engines. + +Use `--format unified` for unified API 2, `--api 3 --format unified` for API 3, +and `--gen2` to select an integration checkout. Unsupported manifests fail; +there is no silent downgrade. No ROM or native window is required. diff --git a/tools/mod-input-contract-check/check.py b/tools/mod-input-contract-check/check.py new file mode 100644 index 000000000..7981cdd22 --- /dev/null +++ b/tools/mod-input-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged logical input mod against both engines' native Input state.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/mod_input/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("MOD_INPUT_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("MOD_INPUT_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"logical input state mismatch: {version}" + print(f"PASS {version}: matching native input state across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_mod_input/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/modkit.py b/tools/modkit.py index fb0b91499..91c23ffa4 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -323,10 +323,9 @@ def check_requires(repo, mod_dir, manifest): "id": "{{id}}", "name": "{{name}}", "version": "0.1.0", - "api": 2, - "entry": "main.lua", - "profile": "{{profile}}", - "game_version": ">={{game_version}} <{{next_major}}.0.0", + "api": {{api}}, + "entry": "main.lua",{{games_line}} + "profile": "{{profile}}",{{engine_range_line}} "category": "GAMEPLAY", "priority": 100, "dependencies": [], @@ -378,7 +377,7 @@ def check_github_field(manifest): "MK001", "error", str(err), "manifest.json")) return findings, notes -MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2). +MAIN_CONTENT = """-- {{id}}: a content-profile mod (api {{api}}). -- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat. return function(mod) -- patch, not override: every field you do not name keeps its base value @@ -391,7 +390,16 @@ def check_github_field(manifest): end """ -MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api 2). +MAIN_PORTABLE = """-- {{id}}: portable API {{api}} entry for Gen1Recomp and Gen2Recomped. +return function(mod) + if mod.content.moves:has("POUND") then + local ok, reason = mod.content.moves:patch("POUND", { power = 45 }) + if not ok then mod.log:warn("%s", reason) end + end +end +""" + +MAIN_OVERHAUL = """-- {{id}}: an overhaul-profile mod (api {{api}}). return function(mod) mod.options:define({ { key = "difficulty", label = "DIFFICULTY", kind = "choice", @@ -411,7 +419,7 @@ def check_github_field(manifest): end """ -MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api 2). +MAIN_TC = """-- {{id}}: a total-conversion-profile mod (api {{api}}). return function(mod) -- the new game itself: spawn, names, money (field.boot, D11) -- mod.content.field:patch("boot", { @@ -442,7 +450,7 @@ def check_github_field(manifest): README_TEMPLATE = """# {{name}} -A `{{profile}}` mod for the LOVE2D Pokemon Red engine (mod api 2). +A `{{profile}}` mod for {{target_engine}} (mod api {{api}}). ## Layout @@ -489,6 +497,10 @@ def cmd_scaffold(args, repo): "{{game_version}}": engine, "{{next_major}}": str(next_major), "{{extra}}": extra, "{{github_line}}": github_line, + "{{api}}": str(args.api), + "{{games_line}}": '\n "games": ["gen1", "gen2", "gen3"],' if args.api == 3 else "", + "{{engine_range_line}}": "" if args.api == 3 else f'\n "game_version": ">={engine} <{next_major}.0.0",', + "{{target_engine}}": "Gen1Recomp and Gen2Recomped" if args.api == 3 else "the LOVE2D Pokemon Red engine", "{{experimental}}": "true" if getattr(args, "experimental", False) else "false", } @@ -504,6 +516,8 @@ def emit(rel, template): main = {"content": MAIN_CONTENT, "overhaul": MAIN_OVERHAUL, "total_conversion": MAIN_TC}[profile] + if args.api == 3: + main = MAIN_PORTABLE layout_extra = "" if profile == "total_conversion": layout_extra = "- `transforms.lua` - asset transforms over the player's cache\n" @@ -519,7 +533,7 @@ def emit(rel, template): emit("transforms.lua", TRANSFORMS_TEMPLATE) if not args.quiet: - print(f"created {dest} ({profile} profile, api 2)") + print(f"created {dest} ({profile} profile, api {args.api})") print(f"next: python3 tools/modkit.py validate {args.id}") return 0 @@ -1964,7 +1978,7 @@ def main(argv): p.add_argument("id") p.add_argument("--profile", default="content", choices=["content", "overhaul", "total_conversion"]) - p.add_argument("--api", type=int, default=2) + p.add_argument("--api", type=int, choices=(1, 2, 3), default=2) p.add_argument("--github", default="", help="optional owner/repo (enables launcher auto-update)") p.add_argument("--experimental", action="store_true", diff --git a/tools/native-palettes/fixture.lua b/tools/native-palettes/fixture.lua new file mode 100644 index 000000000..3cd984cbf --- /dev/null +++ b/tools/native-palettes/fixture.lua @@ -0,0 +1,60 @@ +-- Generated native data only; no ROM, mod render DTO, player or file cache. +local F={} +local function le(n,size) + local s={};for i=1,size do s[i]=string.char(n%256);n=math.floor(n/256)end + return table.concat(s) +end +function F.bytes(seed,count) + local out={} + for i=0,count*32-1 do + local value=seed+math.floor(i/32)*7 + out[#out+1]=string.char((value+i*3)%16+((value+i*5+1)%16)*16) + end + return table.concat(out) +end +function F.new(version) + local T=require('tests.modkit') + require('src.core.GameVersion').set(version) + local fr=version=='firered' + local split,palSplit=fr and 640 or 512,fr and 7 or 6 + local data={maps={},map_tilesets={},constants={gen3Layout={tilesInPrimary=split, + metatilesInPrimary=split,palettesInPrimary=palSplit,metatileLayers=2}}} + for index,name in ipairs({'P','S','T'})do + local palettes,entries,attrs={},{},{} + for p=0,15 do + palettes[p+1]={} + for c=0,15 do + local word=(c+p)%32+(p*2%32)*32+c*1024 + local r,g,b=require('src.import.RomGba').bgr555(word) + palettes[p+1][c+1]={(r+index*11)%256,(g+index*23)%256,(b+index*37)%256} + end + end + for mid=0,2 do + for k=0,7 do + entries[#entries+1]=le((k%2==0 and 0 or split)+k%2+(k%4)*1024 + +(k%2==0 and 1 or palSplit)*4096,2) + end + attrs[#attrs+1]=le(mid*4096+0x42,2) + end + data.map_tilesets[name]={address=index*0x1000,metatileCount=3, + tiles=string.rep(string.char(0x21),32)..string.rep(string.char(0x43),32), + palettes=palettes,metatiles=table.concat(entries),attributes=table.concat(attrs)} + end + local records=data.map_tilesets + records.P.animations={{tile=0,count=1,step=8, + frames={F.bytes(1,1),F.bytes(3,1),F.bytes(5,1),F.bytes(7,1)}}} + records.S.animations={{tile=0,count=2,step=12, + frames={F.bytes(2,2),F.bytes(4,2),F.bytes(6,2)}}} + records.T.animations={{tile=1,count=1,step=4,frames={F.bytes(9,1),F.bytes(11,1)}}} + local def={id='PALETTE_TEST',width=3,height=2,blocks=''} + for i,mid in ipairs({0,split,1,2,split+1,split+2})do + def.blocks=def.blocks..le(mid+(i%4)*1024+0x6000,2) + end + data.maps[def.id]=def + local map=require('src.world.Map').new(def,{id='palette-test',blockTiles=2, + blockCells=1,primaryKey='P',secondaryKey='S'}) + local game={data=data} + local run=T.sdk.loadNone({data=T.fixtures.fresh()}) + return {state={map=map,game=game},records=records,run=run} +end +return F diff --git a/tools/party-grid-contract-check/check.py b/tools/party-grid-contract-check/check.py new file mode 100644 index 000000000..b7cddeca7 --- /dev/null +++ b/tools/party-grid-contract-check/check.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Run one unchanged party-grid mod through both engines' native party controllers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/party_grid/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("PARTY_GRID_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("PARTY_GRID_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "callback_format": "unified" if a.api >= 3 else + ("native" if backend == "gen1" else a.format), + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"party navigation mismatch: {version}" + print(f"PASS {version}: matching native party-navigation behavior across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_party_grid/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/qualify_native_http.py b/tools/qualify_native_http.py new file mode 100644 index 000000000..df2c1b318 --- /dev/null +++ b/tools/qualify_native_http.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Exercise the production native HTTP adapters with owned protocol fixtures.""" +# Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import argparse,atexit,hashlib,json,os,re,subprocess,sys,time +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +INPUTS=('mobile/ios/native/GRHTTPBridge.swift', + 'mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java', + 'tests/native_http/android/util/Base64.java','tests/native_http/main.swift', + 'tests/native_http/BoundedHttpCheck.java') +JSON_SHA256='3cf6cd6892e32e2b4c1c39e0f52f5248a2f5b37646fdfbb79a66b46b618414ed' +def main(): + p=argparse.ArgumentParser(description=__doc__) + for name in ['swiftc','sdk','ios-sdk','java-home','json-jar']:p.add_argument('--'+name,type=Path,required=True) + p.add_argument('--output',type=Path);p.add_argument('--qa-tools',type=Path) + a=p.parse_args();swift=a.swiftc.expanduser().absolute();java=a.java_home.resolve(strict=True) + sdk=a.sdk.resolve(strict=True);ios=a.ios_sdk.resolve(strict=True);jar=a.json_jar.resolve(strict=True) + if not swift.is_file() or hashlib.sha256(jar.read_bytes()).hexdigest()!=JSON_SHA256:raise ValueError('compiler or pinned test dependency invalid') + output=(a.output or ROOT/'.local/qa'/('gen2-native-mod-http-'+str(time.time_ns()))/'candidate').resolve() + run=subprocess.run + if not os.getenv('GITHUB_ACTIONS'): + if a.qa_tools:sys.path.insert(0,str(a.qa_tools.resolve(strict=True))) + from qa_storage import Storage + manager=Storage(ROOT);atexit.register(manager.close) + for source in (ROOT,swift,java,sdk,ios,jar):manager.source(source) + manager.cache(output.parent);manager.copy(output);run=manager.run + inputs={name:(ROOT/name).read_bytes() for name in INPUTS} + output.mkdir(parents=True,exist_ok=False) + for name,data in inputs.items():(output/Path(name).name).write_bytes(data) + def command(name,args): + r=run([str(v) for v in args],cwd=ROOT,capture_output=True,text=True,timeout=120) + (output/(name+'.log')).write_text(r.stdout+r.stderr) + if r.returncode:print(r.stdout+r.stderr,flush=True) + r.check_returncode();return r.stdout + command('swift-build',[swift,'-swift-version','5','-sdk',sdk,'-module-cache-path',output/'mac-modules', + output/'GRHTTPBridge.swift',output/'main.swift','-o',output/'http-swift']) + command('ios-typecheck',[swift,'-swift-version','5','-sdk',ios,'-target','arm64-apple-ios14.0', + '-module-cache-path',output/'ios-modules','-typecheck',output/'GRHTTPBridge.swift']) + command('java-build',[java/'bin/javac','-cp',jar,'-d',output/'classes', + output/'BoundedHttp.java',output/'Base64.java',output/'BoundedHttpCheck.java']) + s=command('swift-test',[output/'http-swift']) + j=command('java-test',[java/'bin/java','-cp',str(output/'classes')+os.pathsep+str(jar),'org.love2d.android.BoundedHttpCheck']) + if any((ROOT/name).read_bytes()!=data for name,data in inputs.items()):raise RuntimeError('native HTTP sources changed during qualification') + report={'passed':True,'checks':{'swift':int(re.search(r'Native HTTP Swift: (\d+) PASS',s)[1]), + 'java':int(re.search(r'Native HTTP Java: (\d+) PASS',j)[1])},'iosTypechecked':True, + 'sourceSha256':{name:hashlib.sha256(data).hexdigest() for name,data in inputs.items()}, + 'jsonTestDependencySha256':JSON_SHA256, + 'scope':'Production Foundation/URLConnection adapter logic with owned URLProtocol/URLConnection fixtures; no real mobile TLS, JNI, phone or console proof.'} + (output/'report.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps({'output':str(output),**report}),flush=True) +if __name__=='__main__':main() diff --git a/tools/render-budget-check/conf.lua b/tools/render-budget-check/conf.lua new file mode 100644 index 000000000..88c959095 --- /dev/null +++ b/tools/render-budget-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("RENDER_BUDGET_QA_GUARDED") == "1", "use the guarded render-budget checker") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Render budget qualification" + t.window.width, t.window.height, t.window.vsync = 640, 480, 0 + t.modules.audio = false +end diff --git a/tools/render-budget-check/main.lua b/tools/render-budget-check/main.lua new file mode 100644 index 000000000..48c9a71dd --- /dev/null +++ b/tools/render-budget-check/main.lua @@ -0,0 +1,329 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root = assert(os.getenv("RENDER_BUDGET_QA_ROOT")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Json = require("src.link.Json") +local Profile = require("src.core.FrameProfile") +local Performance = require("src.core.Performance") +Performance.tier = "high" +require("src.core.GameVersion").set("crystal") +local V, libs = { sharedHulls = true }, {} +V.mod = { log = { + warn = function(_, fmt, ...) + print(fmt:format(...)) + end, +} } +V.require = function(name) + if not libs[name] then + libs[name] = assert(loadfile(root .. "/mods/DRAMATIC_SHAPE/lib/" .. name .. ".lua"))(V) + end + return libs[name] +end +V.data = function(name) + return assert(loadfile(root .. "/mods/DRAMATIC_SHAPE/data/" .. name .. ".lua"))() +end +local phase, frame, total, fenced = 1, 0, 0, 0 +libs.TerrainAtlas = { + _animFrame = function() + return frame + end, +} +local Scale = V.require("RenderScale") +local Budget = V.require("WaterBudget") +local Water = V.require("Water") +local G = V.require("Voxel3D") +local Voxel = V.require("VoxelState") +local Scene = V.require("VoxelScene") +local State = V.require("ShaderState") +local Mat = V.require("Mat4") +local checks = 0 +local function check(ok, message) + assert(ok, message) + checks = checks + 1 +end +local function up(fn, name) + for i = 1, 100 do + local n, v = debug.getupvalue(fn, i) + if n == name then + return v + end + if not n then + break + end + end + error("missing upvalue " .. name) +end +local function solid(r, g, b) + local d = love.image.newImageData(2, 2) + d:mapPixel(function() + return r, g, b, 1 + end) + local image = love.graphics.newImage(d) + d:release() + return image +end +local function quad(x0, z0, x1, z1, y) + return love.graphics.newMesh(G.FORMAT, { + { x0, y, z0, 0, 0, 1 }, + { x1, y, z0, 1, 0, 1 }, + { x1, y, z1, 1, 1, 1 }, + { x0, y, z0, 0, 0, 1 }, + { x1, y, z1, 1, 1, 1 }, + { + x0, + y, + z1, + 0, + 1, + 1, + }, + }, "triangles", "static") +end +local water, wall, blue, red +local function draw(read) + check(G.beginScene(640, 480, 400, 400, 160, 144), "scene unavailable") + G.draw(wall, red) + local before + if read then + G.endScene() + before = G.canvas():newImageData() + assert(G.beginScene(640, 480, 400, 400, 160, 144)) + G.draw(wall, red) + end + Scene.drawWater({ { water, blue, nil } }) + G.endScene() + if read then + local after = G.canvas():newImageData() + local count = 0 + for y = 0, 479, 4 do + for x = 0, 639, 4 do + local r, g, b, a = before:getPixel(x, y) + if r > 0.1 and r > g * 2 and r > b * 2 then + local rr, gg, bb = after:getPixel(x, y) + check(r == rr and g == gg and b == bb, "half water covered foreground geometry") + count = count + 1 + end + end + end + check(count > 100, "foreground negative control had no pixels") + local f = assert( + io.open( + assert(os.getenv("RENDER_BUDGET_QA_OUT")) .. "/" .. (V.renderBudget and "budget" or "legacy") .. ".png", + "wb" + ) + ) + f:write(after:encode("png"):getString()) + f:close() + before:release() + local bytes = after:getString() + after:release() + return bytes + end +end +local function qualify() + local osName = love.system.getOS + love.system.getOS = function() + return "Android" + end + check(Scale.factor() == 2, "mobile AUTO is not 2X") + check(Budget.defaultLevel() == 1, "mobile default is not SKY") + love.system.getOS = osName + check(Scale.factor() == 1, "desktop AUTO changed native scale") + Scale.setting:sync(3) + local w, h = Scale.size(641, 481) + check(w == 214 and h == 161, "render scale rounding changed") + V.renderBudget = false + check(Scale.factor() == 1, "P8 rollback still scales") + V.renderBudget = true + Scale.setting:sync("auto") + local source = solid(0.2, 0.6, 0.8) + local out = Scale.present(source, 8, 8) + local d = out:newImageData() + local r, g, b = d:getPixel(7, 7) + check( + math.abs(r - 0.2) < 0.005 and math.abs(g - 0.6) < 0.005 and math.abs(b - 0.8) < 0.005, + "nearest upscale lost extent/color" + ) + d:release() + source:release() + local calls = 0 + local shader = { + send = function(_, name) + calls = calls + 1 + if name == "missing" then + error("unknown uniform") + end + end, + } + local v = { 1, 2, 3 } + State.send(shader, "v", v) + State.send(shader, "v", v) + v[2] = 4 + State.send(shader, "v", v) + check(calls == 2, "uniform vector mutation/reuse changed") + State.send(shader, "missing", 1) + State.send(shader, "missing", 2) + check(calls == 3, "unknown uniform was retried") + V.renderBudget = false + State.send(shader, "v", v) + V.renderBudget = true + State.send(shader, "v", v) + check(calls == 5, "flag changes retained stale uniform state") + local create, info = love.graphics.newCanvas, love.graphics.getRendererInfo + local depthFormat + love.graphics.getRendererInfo = function() + return "OpenGL ES", "OpenGL ES 3.0" + end + love.graphics.newCanvas = function(w, h, opts) + if opts and opts.format and opts.format:find("depth", 1, true) and not depthFormat then + depthFormat = opts.format + end + return create(w, h, opts) + end + check(G.beginScene(32, 32, 0, 0, 160, 144, nil, "gles-policy"), "depth policy target unavailable") + G.endScene() + love.graphics.newCanvas, love.graphics.getRendererInfo = create, info + check(depthFormat == "depth16", "GLES depth16 preference changed") + local neighbor = up(Scene.render, "neighborMatrix") + local nb = { ox = 3, oy = 4 } + local a = neighbor(nb) + check(neighbor(nb) == a, "neighbor matrix was reallocated") + nb.ox = 5 + check(neighbor(nb) ~= a, "edited neighbor offset stayed stale") + Water.setting:sync("auto") + Performance.tier = "balanced" + check(Water.level() == 1, "BALANCED default/cap changed") + Performance.tier = "high" + check(Water.level() == 2, "desktop HIGH default is not FULL") + for _, budget in ipairs({ false, true }) do + V.renderBudget = budget + check(Water.shader(false) ~= nil, "water shader did not compile") + check(Water.shader(true) ~= nil, "water grid shader did not compile") + if love.getVersion() < 12 then + local ok, err = love.graphics.validateShader(true, Water._source(false)) + check(ok, "GLES water validation failed: " .. tostring(err)) + end + end + V.renderBudget = true + local field = assert(Budget.waveTexture(0, Water._waveBakeSource())) + check(field:getFormat() == "r8", "wave field is not R8") + local first = field:newImageData():getString() + check(Budget.waveTexture(0, Water._waveBakeSource()) == field, "stable wave phase reallocated") + Budget.waveTexture(1, Water._waveBakeSource()) + check(field:newImageData():getString() ~= first, "wave phase did not update") + check(Budget.begin(641, 481), "half-resolution layer unavailable") + local c = love.graphics.getCanvas() + check(c:getWidth() == 321 and c:getHeight() == 241, "half layer dimensions changed") + Budget.finish() + Voxel.angle = math.rad(35) + water, wall = quad(144, 144, 656, 656, 0), quad(384, 384, 416, 416, 24) + blue, red = solid(0.1, 0.35, 0.7), solid(0.9, 0.05, 0.03) + assert(G.beginScene(640, 480, 400, 400, 160, 144)) + G.draw(wall, red) + G.draw(water, blue) + G.endScene() + local flat = G.canvas():newImageData():getString() + for _, budget in ipairs({ false, true }) do + V.renderBudget = budget + check(draw(true) ~= flat, "reflective water silently used only the flat fallback") + end + -- Failure must retain a complete full-resolution reflective pass. + Budget.invalidate() + local Pixel = V.require("PixelCanvas") + local make = Pixel.new + Pixel.new = function() + return false, "owned allocation failure" + end + check(not Budget.begin(640, 480), "failed half target was accepted") + Pixel.new = make + check(draw(true) ~= flat, "failed half target lost the full reflective fallback") + Budget.invalidate() + local send = State.send + local rays, planarOn + State.send = function(shader, name, ...) + if name == "rays" then + rays = select(1, ...) + elseif name == "planarOn" then + planarOn = select(1, ...) + end + return send(shader, name, ...) + end + check( + Water.begin({ reflect = blue, depth = blue, planar = blue, vp = Mat.identity(), eye = { 0, 0, 0 }, curve = { 0, 0, 0 }, screen = { + 640, + 480, + } }), + "planar budget did not begin" + ) + Water.finish() + love.graphics.setShader() + State.send = send + check(rays == 0 and planarOn == 1, "planar and ray march ran together") + Budget.invalidate() + local newCanvas = love.graphics.newCanvas + love.graphics.newCanvas = function(w, h, opts) + if opts and opts.format == "r8" then + error("owned R8 refusal") + end + return newCanvas(w, h, opts) + end + check(not Budget.waveTexture(0, Water._waveBakeSource()), "unsupported R8 was accepted") + love.graphics.newCanvas = newCanvas + check(draw(true) ~= flat, "failed R8 target lost reflective water") + Budget.invalidate() +end +local results = {} +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + return function() + return 1 + end +end +function love.run() + qualify() + return function() + love.event.pump() + for event in love.event.poll() do + if event == "quit" then + return 1 + end + end + V.renderBudget = phase == 2 + if frame == 120 then + Profile.setEnabled(true) + end + local close = Profile.section("voxel.water submit (owned geometry)") + local t = love.timer.getTime() + draw(false) + local submitted = love.timer.getTime() - t + close() + -- Equal completion fence outside the CPU-submission section. + local fence = G.canvas():newImageData(1, 1, 0, 0, 1, 1) + fence:release() + if frame >= 120 then + total = total + submitted + fenced = fenced + love.timer.getTime() - t + Profile.frame() + end + frame = frame + 1 + if frame == 240 then + results[phase] = { + mode = phase == 1 and "legacy" or "budget", + frames = 120, + warmupFrames = 120, + meanSubmitMs = total / 120 * 1000, + meanFencedMs = fenced / 120 * 1000, + } + if phase == 2 then + print("RENDER_BUDGET_RESULT " .. Json.encode({ + checks = checks, + phases = results, + renderer = love.graphics.getRendererInfo(), + scope = "Owned water/foreground geometry, native shader/targets/scene producer; CPU submission and pass completion with equal one-pixel GPU fences, no ROM or game FPS", + })) + return 0 + end + Profile.setEnabled(false) + phase, frame, total, fenced = 2, 0, 0, 0 + end + end +end diff --git a/tools/rom_manifest_emerald.json b/tools/rom_manifest_emerald.json index a790baac4..cdea4f54c 100644 --- a/tools/rom_manifest_emerald.json +++ b/tools/rom_manifest_emerald.json @@ -1,4 +1,119 @@ { + "terrainPalettePrograms": { + "version": 1, + "game": "emerald", + "romSha1": "f3ae088181bf583e55daf962a92bb46f4f1d07b7", + "source": "https://github.com/pret/pokeemerald/blob/3ec4d98de95a2aeb12f3c738b3496d0b3a830793/src/tileset_anims.c", + "programs": { + "658288": { + "id": "general", + "kind": "static", + "secondary": false + }, + "658356": { + "id": "building", + "kind": "static", + "secondary": false + }, + "659516": { + "id": "rustboro", + "kind": "static", + "secondary": true + }, + "659640": { + "id": "dewford", + "kind": "static", + "secondary": true + }, + "659664": { + "id": "slateport", + "kind": "static", + "secondary": true + }, + "659688": { + "id": "mauville", + "kind": "static", + "secondary": true + }, + "659804": { + "id": "lavaridge", + "kind": "static", + "secondary": true + }, + "659848": { + "id": "ever-grande", + "kind": "static", + "secondary": true + }, + "659964": { + "id": "pacifidlog", + "kind": "static", + "secondary": true + }, + "660012": { + "id": "sootopolis", + "kind": "static", + "secondary": true + }, + "660036": { + "id": "underwater", + "kind": "static", + "secondary": true + }, + "660064": { + "id": "cave", + "kind": "static", + "secondary": true + }, + "660092": { + "id": "frontier-west", + "kind": "static", + "secondary": true + }, + "660116": { + "id": "frontier-east", + "kind": "static", + "secondary": true + }, + "660928": { + "id": "mauville-gym", + "kind": "static", + "secondary": true + }, + "660952": { + "id": "sootopolis-gym", + "kind": "static", + "secondary": true + }, + "660976": { + "id": "elite-four", + "kind": "static", + "secondary": true + }, + "661020": { + "id": "bike-shop", + "kind": "static", + "secondary": true + }, + "661044": { + "id": "battle-pyramid", + "kind": "static", + "secondary": true + }, + "661080": { + "id": "battle-dome-floor", + "kind": "frames", + "secondary": true, + "table": 5392508, + "frames": 4, + "first": 128, + "count": 16, + "step": 4, + "phase": 0, + "excludes": "cartridge fade and battle-transition task state" + } + } + }, "bagSprite": { "female": 14260736, "frameHeight": 64, diff --git a/tools/rom_manifest_firered.json b/tools/rom_manifest_firered.json index 5cb450f25..bdc7ba0b9 100644 --- a/tools/rom_manifest_firered.json +++ b/tools/rom_manifest_firered.json @@ -1,4 +1,42 @@ { + "terrainPalettePrograms": { + "version": 1, + "game": "firered", + "romSha1": "41cb23d8dccc8ebd7c649cd8fbb58eeace6e2fdc", + "source": "https://github.com/pret/pokefirered/blob/d58bf398b2b83a326d23d14d531de7f45a1058bb/asm/tileset_anims.s", + "programs": { + "459040": { + "id": "general", + "kind": "static", + "secondary": false + }, + "459180": { + "id": "celadon", + "kind": "static", + "secondary": true + }, + "459300": { + "id": "silph", + "kind": "static", + "secondary": true + }, + "459420": { + "id": "ember", + "kind": "static", + "secondary": true + }, + "459524": { + "id": "vermilion-gym", + "kind": "static", + "secondary": true + }, + "459624": { + "id": "celadon-gym", + "kind": "static", + "secondary": true + } + } + }, "bagSprite": { "female": 15220156, "frameHeight": 64, diff --git a/tools/screen-visibility-contract-check/check.py b/tools/screen-visibility-contract-check/check.py new file mode 100644 index 000000000..d364f92b1 --- /dev/null +++ b/tools/screen-visibility-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged visibility mod through both engines' native screen composition.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "crystal"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/screen_visibility/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("SCREEN_VISIBILITY_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("SCREEN_VISIBILITY_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"screen visibility mismatch: {version}" + print(f"PASS {version}: matching native screen composition across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_screen_visibility/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/shader-graph-check/conf.lua b/tools/shader-graph-check/conf.lua new file mode 100644 index 000000000..18f7e58cd --- /dev/null +++ b/tools/shader-graph-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "gen2-slang-import-check" + t.window.title = "Gen2Recomped slang import checks" + t.window.width, t.window.height = 640, 480 + t.window.visible = false + t.modules.audio = false +end diff --git a/tools/shader-graph-check/main.lua b/tools/shader-graph-check/main.lua new file mode 100644 index 000000000..34ee7f6b1 --- /dev/null +++ b/tools/shader-graph-check/main.lua @@ -0,0 +1,182 @@ +function love.errorhandler(message) + io.stderr:write(tostring(message) .. "\n") + return function() + return 1 + end +end +local root = love.filesystem.getSource():match("^(.*)/tools/shader%-graph%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Presets = require("src.render.ShaderPresets") +local g = love.graphics +local checks = 0 +local function check(v, why) + checks = checks + 1 + assert(v, why) +end +local function near(a, b, why) + check(math.abs(a - b) < 2 / 255, why .. " " .. a .. "/" .. b) +end +local function pattern(x, y) + return (11 * x + 7) / 255, (17 * y + 13) / 255, 0.3, y % 2 == 0 and 1 or 0.5 +end +local function run() + local fs = { + getDirectoryItems = function() + return love.filesystem.getDirectoryItems("imports") + end, + getInfo = function(p) + return love.filesystem.getInfo((p:gsub("^shader%-presets", "imports"))) + end, + read = function(p) + return love.filesystem.read((p:gsub("^shader%-presets", "imports"))) + end, + } + local p = Presets.new({ fs = fs }) + local entry + for _, e in ipairs(p:list()) do + if e.name == "GRAPH" then + entry = e + end + end + check(entry ~= nil, "GRAPH import required") + check(p:activate(entry.id), "graph activated: " .. tostring(p.error)) + local data = love.image.newImageData(16, 12) + for y = 0, 11 do + for x = 0, 15 do + data:setPixel(x, y, pattern(x, y)) + end + end + local image = g.newImage(data) + image:setFilter("nearest", "nearest") + local sentinel = g.newCanvas(10, 10) + local sentinelShader = g.newShader("vec4 effect(vec4 c, Image t, vec2 uv,vec2 px){return Texel(t,uv)*c;}") + for _, dpi in ipairs({ 1, 2, 2.75 }) do + for _, factor in ipairs({ 1, 2 }) do + local scale = 5 * factor * dpi + local w, h = 80 * factor, 60 * factor + local input = g.newCanvas(w, h, { dpiscale = dpi }) + input:setFilter("nearest", "nearest") + input:setWrap("repeat", "repeat") + g.push("all") + g.setCanvas(input) + g.origin() + g.setShader() + g.setBlendMode("replace", "premultiplied") + g.setColor(1, 1, 1, 1) + g.draw(image, 0, 0, 0, w / 16, h / 12) + g.pop() + g.setCanvas() + for _, gain in ipairs({ 0.5, 0.8 }) do + check(p:setParameter({}, "gain", gain), "parameter") + g.setCanvas(sentinel) + g.setShader(sentinelShader) + g.setColor(0.4, 0.5, 0.6, 0.7) + g.setScissor(1, 2, 3, 4) + g.translate(2, 3) + local before = g.getStackDepth() + local output = p:apply(input, scale) + check(output ~= input and p:active(), "graph survives") + check(g.getCanvas() == sentinel, "canvas restored") + check(g.getShader() == sentinelShader, "shader restored") + check(g.getStackDepth() == before, "stack restored") + local r, gg, b, a = g.getColor() + near(r, 0.4, "color") + local x, y = g.transformPoint(0, 0) + near(x, 2, "transform x") + near(y, 3, "transform y") + local sx, sy, sw, sh = g.getScissor() + check(sx == 1 and sy == 2 and sw == 3 and sh == 4, "scissor") + g.setCanvas() + g.setShader() + g.setScissor() + g.origin() + g.setColor(1, 1, 1, 1) + local pixels = output:newImageData() + local pw, ph = pixels:getDimensions() + check(pw == 16 * scale and ph == 12 * scale, "physical output") + near(output:getDPIScale(), dpi, "DPI") + check(p.graph.targets[1]:getWidth() == 16 and p.graph.targets[2]:getWidth() == 32, "per pass scale") + check(p.graph.targets[3]:getWidth() == pw, "viewport scale") + for py = 0, ph - 1 do + for px = 0, pw - 1 do + local u, v = (px + 0.5) / scale, (py + 0.5) / scale + -- A pixel exactly on a nearest-sampling boundary can select either texel after GPU rounding. + if + math.abs(u - math.floor(u + 0.5)) > 0.00001 and math.abs(v - math.floor(v + 0.5)) > 0.00001 + then + local pr, pg, pb, pa = pattern(math.floor((px + 0.5) / scale), math.floor((py + 0.5) / scale)) + local ar, ag, ab, aa = pixels:getPixel(px, py) + -- RGBA8 intermediate passes quantize the first multiplication. + near( + ar, + math.floor(pr * gain * 255 + 0.5) / 255 + pr * 0.25, + ("source plus original dpi=%s factor=%s gain=%s px=%s py=%s"):format( + dpi, + factor, + gain, + px, + py + ) + ) + near(ag, pg, "earlier pass, not source") + near(ab, 128 / 255 / 2, "LUT and size semantics") + near(aa, pa, "alpha") + end + end + end + pixels:release() + check(p:apply(output, scale) == output, "feedback prevented") + check(input:getFilter() == "nearest", "borrowed filter") + check(input:getWrap() == "repeat", "borrowed wrap") + end + local targets = p.graph.targets + p:apply(input, scale) + check(p.graph.targets == targets, "reuse") + input:release() + end + end + -- Rejected GPU/decoder candidates preserve the current effect. + local old = p.graph + local original = fs.read + fs.read = function(path) + if path:match("%.glsl$") then + return "invalid glsl" + end + return original(path) + end + check(not p:activate(entry.id), "bad candidate rejected") + check(p.graph == old and p:active(), "working graph preserved") + fs.read = original + p:off() + check(not p:active(), "OFF") + image:release() + data:release() + sentinel:release() + sentinelShader:release() + print( + "shader graph GPU PASS " + .. checks + .. " pixel/state checks | " + .. table.concat({ g.getRendererInfo() }, " | ") + ) +end +function love.load() + local ok, why = pcall(function() + assert( + require("src.import.CacheFs").withMounted( + assert(os.getenv("SHADER_GRAPH_CHECK_DIR")), + "imports", + function() + run() + return true + end + ), + "mount unavailable" + ) + end) + if not ok then + io.stderr:write(tostring(why) .. "\n") + end + love.event.quit(ok and 0 or 1) +end diff --git a/tools/shader-preset-check/conf.lua b/tools/shader-preset-check/conf.lua new file mode 100644 index 000000000..5f3917ef7 --- /dev/null +++ b/tools/shader-preset-check/conf.lua @@ -0,0 +1,8 @@ +function love.conf(t) + t.identity = "gen2-shader-preset-check" + t.window.title = "Gen2Recomped shader preset checks" + t.window.width, t.window.height = 640, 576 + t.window.resizable = true + t.window.highdpi = true + t.modules.audio = false +end diff --git a/tools/shader-preset-check/main.lua b/tools/shader-preset-check/main.lua new file mode 100644 index 000000000..cbc04fb47 --- /dev/null +++ b/tools/shader-preset-check/main.lua @@ -0,0 +1,406 @@ +-- Run from the checkout: love tools/shader-preset-check +-- Synthetic pixels and the ROM-free font fixture; writes no game settings. +local root = love.filesystem.getSource():match("^(.*)/tools/shader%-preset%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +love.filesystem.mount(root, "", true) +local Presets = require("src.render.ShaderPresets") +local Json = require("src.link.Json") +local Renderer = require("src.render.Renderer") +local Pipelines = require("src.render.Pipelines") +local GBCFX = require("src.render.GBCFX") +local Correction = package.loaded["src.render.GbcColorCorrection"] +local Menu = require("src.ui.ShaderPresetMenu") +local Font = require("src.render.Font") +local Palette = require("src.render.PaletteFX") +local Version = require("src.core.GameVersion") +local checks = 0 +local function check(ok, message) + checks = checks + 1 + assert(ok, message) +end +local function near(a, b) + return math.abs(a - b) < 1.1 / 255 +end +local function equal(a, b, label) + check(a:getWidth() == b:getWidth() and a:getHeight() == b:getHeight(), label .. " dimensions") + for y = 0, a:getHeight() - 1 do + for x = 0, a:getWidth() - 1 do + local av, bv = { a:getPixel(x, y) }, { b:getPixel(x, y) } + for i = 1, 4 do + check(near(av[i], bv[i]), label .. " pixel " .. x .. "," .. y .. " channel " .. i) + end + end + end +end +local files = {} +local fs = { + getDirectoryItems = function() + return { "identity.json", "bad.json", "sizes.json" } + end, + getInfo = function(p) + if files[p] then + return { type = "file", size = #files[p] } + end + end, + read = function(p) + return files[p] + end, +} +local identity = "vec4 effect(vec4 c, Image t, vec2 u, vec2 p) { return Texel(t,u)*c; }" +files["shader-presets/identity.json"] = + Json.encode({ format = 1, name = "IDENTITY", shader = "identity.glsl" }) +files["shader-presets/identity.glsl"] = identity +files["shader-presets/bad.json"] = Json.encode({ format = 1, name = "BAD SHADER", shader = "bad.glsl" }) +files["shader-presets/bad.glsl"] = "THIS IS NOT GLSL" +files["shader-presets/sizes.json"] = Json.encode({ format = 1, name = "DIMENSIONS", shader = "sizes.glsl" }) +files["shader-presets/sizes.glsl"] = [[ +extern vec2 sourceSize; +extern number pixelScale; +vec4 effect(vec4 c, Image t, vec2 u, vec2 p) { return vec4(sourceSize / 1024.0,pixelScale / 16.0,1.0); } +]] +Presets.fs = fs +local panels = {} +local function inputCanvas(w, h, dpi) + local pw, ph = math.floor(w * dpi + 0.5), math.floor(h * dpi + 0.5) + local pixels = love.image.newImageData(pw, ph) + for y = 0, ph - 1 do + for x = 0, pw - 1 do + pixels:setPixel(x, y, x / (pw - 1), y / (ph - 1), 0.4, (x % 3 + 1) / 3) + end + end + local image = love.graphics.newImage(pixels) + local canvas = love.graphics.newCanvas(w, h, { dpiscale = dpi }) + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.origin() + love.graphics.setBlendMode("replace", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, 0, 0, 0, 1 / dpi, 1 / dpi) + love.graphics.pop() + image:release() + return canvas, pixels +end +local function run() + -- Exercise the actual mounted game-data filesystem, not only injected fs. + local dir = "shader-presets" + love.filesystem.createDirectory(dir) + local descriptor = dir .. "/qa_disk_roundtrip.json" + local fragment = dir .. "/qa_disk_roundtrip.glsl" + assert( + love.filesystem.write( + descriptor, + Json.encode({ format = 1, name = "DISK CHECK", shader = "qa_disk_roundtrip.glsl" }) + ) + ) + assert(love.filesystem.write(fragment, identity)) + local disk = Presets.new() + disk:refresh() + check(disk.byId["local:qa_disk_roundtrip"] ~= nil, "real filesystem discovery") + check(disk:activate("local:qa_disk_roundtrip"), "real filesystem source read and compile") + disk:off() + love.filesystem.remove(descriptor) + love.filesystem.remove(fragment) + + for _, entry in ipairs(Presets:refresh()) do + if entry.source then + for _, gles in ipairs({ false, true }) do + local ok, why = love.graphics.validateShader(gles, entry.source) + check(ok, "GL/GLES shader validation " .. entry.id .. ": " .. tostring(why)) + end + end + end + for _, dpi in ipairs({ 1, 2, 2.75 }) do + local input, original = inputCanvas(32, 24, dpi) + Presets:off() + check(Presets:apply(input, 4) == input, "OFF returns exact input") + check(Presets.output == nil, "OFF no target") + assert(Presets:activate("local:identity")) + local pixels = Presets:apply(input, 4):newImageData() + equal(pixels, original, "identity DPI " .. dpi) + pixels:release() + local opts = {} + assert(Presets:select(opts, "builtin:warm")) + for _, strength in ipairs({ 0, 0.4, 1 }) do + assert(Presets:setParameter(opts, "strength", strength)) + local out = Presets:apply(input, 4) + check(out:getDPIScale() == input:getDPIScale(), "preserves DPI") + pixels = out:newImageData() + for y = 0, pixels:getHeight() - 1 do + for x = 0, pixels:getWidth() - 1 do + local r, g, b, a = original:getPixel(x, y) + local actual = { pixels:getPixel(x, y) } + local expected = { r, g * (1 - 0.06 * strength), b * (1 - 0.2 * strength), a } + for i = 1, 4 do + check(near(actual[i], expected[i]), "warm RGB/alpha at DPI " .. dpi) + end + end + end + pixels:release() + end + assert(Presets:select(opts, "builtin:soft-lcd")) + assert(Presets:setParameter(opts, "strength", 0.5)) + pixels = Presets:apply(input, 4):newImageData() + for y = 0, pixels:getHeight() - 1 do + for x = 0, pixels:getWidth() - 1 do + local expected = { original:getPixel(x, y) } + local scale = 1 - 0.5 * (math.floor((y + 0.5) / 4) % 2) + local actual = { pixels:getPixel(x, y) } + for i = 1, 3 do + check(near(actual[i], expected[i] * scale), "LCD grid pixel scale at DPI " .. dpi) + end + check(near(actual[4], expected[4]), "LCD alpha preserved") + end + end + pixels:release() + assert(Presets:activate("local:sizes")) + pixels = Presets:apply(input, 6):newImageData() + local r, g, b = pixels:getPixel(0, 0) + check( + near(r, original:getWidth() / 1024) and near(g, original:getHeight() / 1024) and near(b, 6 / 16), + "physical source size uniforms" + ) + pixels:release() + original:release() + input:release() + end + -- Real graphics state is restored, even when a draw throws after setup. + local input, original = inputCanvas(12, 8, 1) + assert(Presets:activate("builtin:warm")) + local previous = love.graphics.newCanvas(4, 4) + local shader = love.graphics.newShader(identity) + love.graphics.setCanvas(previous) + love.graphics.setShader(shader) + love.graphics.translate(3, 5) + love.graphics.setColor(0.2, 0.3, 0.4, 0.5) + love.graphics.setScissor(1, 2, 2, 1) + love.graphics.setBlendMode("add", "premultiplied") + local depth = love.graphics.getStackDepth() + local realDraw = love.graphics.draw + love.graphics.draw = function() + error("injected GPU draw failure") + end + check(Presets:apply(input, 2) == input, "draw failure identity fallback") + love.graphics.draw = realDraw + check(not Presets:active(), "broken effect retired") + check( + love.graphics.getCanvas() == previous and love.graphics.getShader() == shader, + "canvas/shader restored" + ) + check(love.graphics.getStackDepth() == depth, "stack restored") + local r, g, b, a = love.graphics.getColor() + check(near(r, 0.2) and near(g, 0.3) and near(b, 0.4) and near(a, 0.5), "color restored") + local x, y = love.graphics.transformPoint(0, 0) + check(x == 3 and y == 5, "transform restored") + local blend, alpha = love.graphics.getBlendMode() + check(blend == "add" and alpha == "premultiplied", "blend restored") + local sx, sy, sw, sh = love.graphics.getScissor() + check(sx == 1 and sy == 2 and sw == 2 and sh == 1, "scissor restored") + love.graphics.setCanvas() + love.graphics.setShader() + love.graphics.origin() + love.graphics.setScissor() + love.graphics.setBlendMode("alpha") + love.graphics.setColor(1, 1, 1, 1) + previous:release() + shader:release() + input:release() + original:release() + local opts = { shaderPreset = "builtin:warm" } + assert(Presets:applyOptions(opts)) + local old = Presets.shader + check(not Presets:select(opts, "local:bad"), "real compile error rejected") + check( + Presets.shader == old and opts.shaderPreset == "builtin:warm", + "bad selection retains last working effect" + ) + Presets:off() + GBCFX.setLevel(0) + Palette.setMode("dmg") + Version.set("red") + local fixture = require("tests.modkit.fixtures").fresh() + fixture.font.source = "Gen2 scaffold" + Font.load(fixture) + Renderer:init() +end +local phase, capturing, baseline = 1, false, nil +local resizeBaseline +function love.load() + local ok, err = xpcall(run, debug.traceback) + if not ok then + io.stderr:write(err .. "\n") + love.event.quit(1) + end +end +local function drawScene() + Renderer:setUISize( + phase >= 9 and 256 or phase >= 7 and 240 or 160, + phase >= 9 and 192 or phase >= 7 and 160 or 144 + ) + Renderer:beginFrame() + love.graphics.clear(1, 1, 1, 1) + love.graphics.setColor(0.15, 0.35, 0.7, 1) + love.graphics.rectangle("fill", 0, 0, 160, 72) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("SHADER PRESET TEST", 8, 88) + Font.draw("WORLD + DIALOGUE", 8, 104) + love.graphics.setColor(1, 1, 1, 1) +end +function love.draw() + if phase == 11 then + love.graphics.clear(0.2, 0.2, 0.2, 1) + love.graphics.setColor(1, 1, 1, 1) + for i, image in ipairs(panels) do + love.graphics.draw( + image, + ((i - 1) % 3) * 320, + math.floor((i - 1) / 3) * 288, + 0, + 320 / image:getWidth(), + 288 / image:getHeight() + ) + end + if not capturing then + capturing = true + love.graphics.captureScreenshot(function(pixels) + local path = os.getenv("SHADER_PRESET_CHECK_SHOT") + if path then + local png = pixels:encode("png") + local f = assert(io.open(path, "wb")) + f:write(png:getString()) + f:close() + png:release() + end + pixels:release() + print(("SHADER PRESETS GPU: %d checks passed"):format(checks)) + love.event.quit(0) + end) + end + return + end + if capturing then + return + end + local ok, err = xpcall(function() + local trace = {} + if phase == 1 or phase == 4 or phase == 7 or phase == 9 then + Presets:off() + elseif phase == 2 or phase == 8 or phase == 10 then + assert(Presets:activate("local:identity")) + elseif phase == 3 then + assert(Presets:activate("builtin:warm", { strength = 1 })) + elseif phase == 5 then + assert(Presets:activate("builtin:soft-lcd", { strength = 0.3 })) + end + drawScene() + local oldP, oldS, oldF = Pipelines.present, Presets.apply, GBCFX.present + local oldC = Correction and Correction.apply + if Correction then + if phase == 5 then + Version.set("gold") + elseif phase == 6 then + Version.set("red") + end + Palette.setMode(phase == 5 and "gbc" or "dmg") + Correction.setMode(phase == 5 and "accurate" or "off") + if phase == 5 then + check(Correction.active(), "colour correction and preset active together") + end + Correction.apply = function(...) + trace[#trace + 1] = "correction" + return oldC(...) + end + end + Pipelines.present = function(...) + trace[#trace + 1] = "pipeline" + return oldP(...) + end + Presets.apply = function(...) + trace[#trace + 1] = "preset" + return oldS(...) + end + GBCFX.present = function(...) + trace[#trace + 1] = "gbc" + return oldF(...) + end + GBCFX.setLevel(phase == 5 and 1 or 0) + Renderer.shaderPresetBypass = phase == 6 + if phase == 6 then + local game = { + data = require("tests.modkit.fixtures").fresh(), + save = { options = { shaderPreset = "builtin:soft-lcd" } }, + stack = {}, + } + Menu.new(game):draw() + end + Renderer:endFrame() + Pipelines.present, Presets.apply, GBCFX.present = oldP, oldS, oldF + if Correction then + Correction.apply = oldC + end + if phase == 5 then + check( + table.concat(trace, ",") == (Correction and "correction,pipeline,preset,gbc" or "pipeline,preset,gbc"), + "final stage ordering" + ) + end + if phase == 6 then + check(not table.concat(trace, ","):find("preset", 1, true), "shader controls bypass preset") + end + capturing = true + love.graphics.captureScreenshot(function(pixels) + local captured, why = xpcall(function() + if phase == 1 then + baseline = pixels:clone() + elseif phase == 2 or phase == 4 then + equal(pixels, baseline, "Renderer identity/OFF") + elseif phase == 3 then + local changed = false + for y = 0, pixels:getHeight() - 1, 8 do + for x = 0, pixels:getWidth() - 1, 8 do + local _, g, b = pixels:getPixel(x, y) + local _, bg, bb = baseline:getPixel(x, y) + if math.abs(g - bg) > 0.01 or math.abs(b - bb) > 0.01 then + changed = true + end + end + end + check(changed, "Renderer active preset changes final frame") + end + if phase == 7 or phase == 9 then + if resizeBaseline then + resizeBaseline:release() + end + resizeBaseline = pixels:clone() + elseif phase == 8 or phase == 10 then + equal(pixels, resizeBaseline, "Renderer resized native surface identity") + end + if phase <= 6 then + local image = love.graphics.newImage(pixels) + image:setFilter("nearest", "nearest") + panels[#panels + 1] = image + end + pixels:release() + phase = phase + 1 + capturing = false + if phase == 7 then + Version.set("emerald") + love.window.setMode(480, 432, { highdpi = true }) + elseif phase == 9 then + Version.set("platinum") + love.window.setMode(800, 600, { highdpi = true }) + elseif phase == 11 then + love.window.setMode(960, 576, { resizable = false }) + end + end, debug.traceback) + if not captured then + io.stderr:write(why .. "\n") + love.event.quit(1) + end + end) + end, debug.traceback) + if not ok then + io.stderr:write(err .. "\n") + love.event.quit(1) + end +end diff --git a/tools/slang-import-check/conf.lua b/tools/slang-import-check/conf.lua new file mode 100644 index 000000000..18f7e58cd --- /dev/null +++ b/tools/slang-import-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + t.identity = "gen2-slang-import-check" + t.window.title = "Gen2Recomped slang import checks" + t.window.width, t.window.height = 640, 480 + t.window.visible = false + t.modules.audio = false +end diff --git a/tools/slang-import-check/main.lua b/tools/slang-import-check/main.lua new file mode 100644 index 000000000..c0085ca7c --- /dev/null +++ b/tools/slang-import-check/main.lua @@ -0,0 +1,221 @@ +-- Run after importing PROBE and WRAP from tools/slang-import/fixtures. +-- Optional LCD3X and ZFAST LCD imports are compared against independent CPU equations. +local root = love.filesystem.getSource():match("^(.*)/tools/slang%-import%-check$") + or love.filesystem.getWorkingDirectory() +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Presets = require("src.render.ShaderPresets") +local Json = require("src.link.Json") +local CacheFs = require("src.import.CacheFs") +local g = love.graphics +local checks, panels = 0, {} +local function check(v, why) + checks = checks + 1 + assert(v, why) +end +local function near(a, b, why) + check(math.abs(a - b) < 1.6 / 255, why .. (" (%.5f / %.5f)"):format(a, b)) +end +local function pattern(x, y) + return { (x * 11 + 7) / 255, (y * 17 + 13) / 255, ((x + y) * 7 + 3) / 255, y % 2 == 0 and 1 or 0.5 } +end +local function input(w, h, dpi) + local data = love.image.newImageData(16, 12) + for y = 0, 11 do + for x = 0, 15 do + data:setPixel(x, y, unpack(pattern(x, y))) + end + end + local image = g.newImage(data) + image:setFilter("nearest", "nearest") + local canvas = g.newCanvas(w, h, { dpiscale = dpi }) + canvas:setFilter("nearest", "nearest") + g.push("all") + g.setCanvas(canvas) + g.origin() + g.setShader() + g.setColor(1, 1, 1, 1) + g.setBlendMode("replace", "premultiplied") + g.draw(image, 0, 0, 0, w / 16, h / 12) + g.pop() + image:release() + data:release() + return canvas +end +local function oracle(name, x, y, pw, ph, gain) + local u, v = (x + 0.5) / pw, (y + 0.5) / ph + local c = pattern(math.floor(u * 16), math.floor(v * 12)) + if name == "PROBE" then + return { c[1] * gain, 16 / 64, ph * 2 / 512, c[4] } + end + if name == "WRAP" then + return { (pattern(0, 0)[1] + pattern(15, 0)[1]) / 2 } + end + if name == "LCD3X" then + local ay, ax = v * 12 * 2 * math.pi, u * 16 * 2 * math.pi + local yf = (16 + math.sin(ay)) / 17 + for i = 1, 3 do + c[i] = c[i] * yf * (4 + math.sin(ax + math.pi / 2 - (i - 1) * 2 * math.pi / 3)) / 5 + end + c[4] = 1 + return c + end + if name == "ZFAST LCD" then + local dx, dy = math.abs(math.floor(u * 16) + 0.5 - u * 16), math.abs(math.floor(v * 12) + 0.5 - v * 12) + local yy = math.max(dx, dy) ^ 2 + local weight = 1 - 14 * (yy ^ 2 - 2.7 * yy ^ 3) + for i = 1, 3 do + c[i] = c[i] * weight + c[i] = c[i] * (0.6 + 0.4 * c[i]) + end + c[4] = 1 + return c + end +end +local function run() + local fs = { + getDirectoryItems = function() + return love.filesystem.getDirectoryItems("imports") + end, + getInfo = function(p) + return love.filesystem.getInfo((p:gsub("^shader%-presets", "imports"))) + end, + read = function(p) + return love.filesystem.read((p:gsub("^shader%-presets", "imports"))) + end, + } + local p = Presets.new({ fs = fs }) + local entries = {} + for _, e in ipairs(p:list()) do + if e.imported then + entries[e.name] = e + end + end + check(entries.PROBE and entries.WRAP, "import the PROBE and WRAP fixtures first") + for _, name in ipairs({ "PROBE", "WRAP", "LCD3X", "ZFAST LCD" }) do + local entry = entries[name] + if entry then + check(p:activate(entry.id), name .. " activation") + -- Validate exactly the files loaded by the game for both shader dialects. + for _, es in ipairs({ false, true }) do + local fragment = fs.read("shader-presets/" .. (es and entry.esShader or entry.shader)) + local vertex = fs.read("shader-presets/" .. (es and entry.esVertex or entry.vertex)) + local ok, why = g.validateShader(es, fragment, vertex) + check(ok, name .. " dialect: " .. tostring(why)) + end + local gain = 0.5 + for _, dpi in ipairs({ 1, 2, 2.75 }) do + -- Also exercise render-target replacement on resize. + for _, w in ipairs({ 64, 80 }) do + local h = w * 3 / 4 + local canvas = input(w, h, dpi) + local inputFilter = canvas:getFilter() + g.setColor(0.2, 0.3, 0.4, 0.6) + local output = p:apply(canvas, w * dpi / 16) + check(output ~= canvas and p:active(), name .. " processed") + check(output:getWidth() == w and output:getDPIScale() == dpi, "output dimensions/DPI") + check(p.importInput:getWidth() == 16 and p.importInput:getHeight() == 12, "native source grid") + check(canvas:getFilter() == inputFilter, "input texture state unchanged") + local r, green, b, a = g.getColor() + near(r, 0.2, "red state") + near(green, 0.3, "green state") + near(b, 0.4, "blue state") + near(a, 0.6, "alpha state") + local data = output:newImageData() + local pw, ph = data:getDimensions() + for y = 0, ph - 1 do + for x = 0, pw - 1 do + local expected = oracle(name, x, y, pw, ph, gain) + local actual = { data:getPixel(x, y) } + local tx, ty = (x + 0.5) * 16 / pw, (y + 0.5) * 12 / ph + -- Exact nearest-sampling ties can fall on either texel after varying interpolation. + local tie = math.abs(tx - math.floor(tx + 0.5)) < 0.00001 + or math.abs(ty - math.floor(ty + 0.5)) < 0.00001 + if name == "WRAP" or not tie then + for i, n in ipairs(expected) do + near( + actual[i], + n, + name .. " dpi=" .. dpi .. " w=" .. w .. " pixel " .. x .. "," .. y .. " channel " .. i + ) + end + end + end + end + if dpi == 1 and w == 64 then + local preview = g.newImage(data) + preview:setFilter("nearest", "nearest") + panels[#panels + 1] = { name, preview } + end + data:release() + if name == "PROBE" then + local opts = {} + check(p:setParameter(opts, "gain", 0.8), "parameter update") + check(opts.shaderPresetParams[entry.id].gain > 0.79, "parameter persisted by public ID") + gain = 0.8 + end + canvas:release() + end + end + p:off() + check(p.importInput == nil, "private source released on OFF") + end + end + -- A bad imported vertex must preserve the last working effect and saved choice. + check(p:activate("builtin:warm"), "working fallback") + local shader = p.shader + local oldRead = fs.read + fs.read = function(path) + if path:find("-v.glsl", 1, true) then + return "THIS IS NOT A VERTEX SHADER" + end + return oldRead(path) + end + local opts = { shaderPreset = "builtin:warm" } + check(not p:select(opts, entries.PROBE.id), "invalid vertex rejected") + check(p.shader == shader and opts.shaderPreset == "builtin:warm", "working effect and save preserved") + p:off() + local shot = os.getenv("SLANG_IMPORT_CHECK_SHOT") + if shot then + local sheet = g.newCanvas(640, 480) + g.push("all") + g.setCanvas(sheet) + g.origin() + g.setShader() + g.clear(0.08, 0.08, 0.1, 1) + g.setColor(1, 1, 1, 1) + for i, panel in ipairs(panels) do + local x, y = ((i - 1) % 2) * 320 + 16, math.floor((i - 1) / 2) * 240 + 12 + g.print(panel[1], x, y) + g.draw(panel[2], x, y + 24, 0, 4, 4) + end + g.pop() + local png = sheet:newImageData():encode("png") + local f = assert(io.open(shot, "wb")) + f:write(png:getString()) + f:close() + sheet:release() + end + for _, panel in ipairs(panels) do + panel[2]:release() + end + print(checks .. " GPU checks passed (slang import)") + return true +end +function love.load() + local ok, why = pcall(function() + check( + CacheFs.withMounted(assert(os.getenv("SLANG_IMPORT_CHECK_DIR")), "imports", run), + "fixture directory mounted" + ) + end) + if not ok then + io.stderr:write(tostring(why) .. "\n") + end + love.event.quit(ok and 0 or 1) +end +function love.errorhandler(message) + io.stderr:write(tostring(message) .. "\n") + return function() + return 1 + end +end diff --git a/tools/slang-import/.gitignore b/tools/slang-import/.gitignore new file mode 100644 index 000000000..b83d22266 --- /dev/null +++ b/tools/slang-import/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/tools/slang-import/Cargo.lock b/tools/slang-import/Cargo.lock new file mode 100644 index 000000000..f3173b321 --- /dev/null +++ b/tools/slang-import/Cargo.lock @@ -0,0 +1,1073 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "gen2-slang-import" +version = "0.1.0" +dependencies = [ + "librashader-common", + "librashader-preprocess", + "librashader-presets", + "librashader-reflect", + "regex", + "serde_json", + "sha2", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "glslang" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0db73ac0e8bd6f9c253b70205f2bb1c63845b6e263665ac8063316ce16f408c8" +dependencies = [ + "bitflags", + "glslang-sys", + "rustc-hash 2.1.3", + "smartstring", + "thiserror 2.0.21", +] + +[[package]] +name = "glslang-sys" +version = "0.8.1+275822a" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec80baca8831e8414db40c562fd76ca6a32a792c1db990451d4e7d3cb246e1ed" +dependencies = [ + "cc", + "glob", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "serde", + "zerocopy", +] + +[[package]] +name = "halfbrown" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ed2f2edad8a14c8186b847909a41fbb9c3eafa44f88bd891114ed5019da09" +dependencies = [ + "hashbrown 0.16.1", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "librashader-common" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75fcb7b016f592e08241eb627ab94d8b2e435769b99f2705a2ba11c063a1b5fc" +dependencies = [ + "bitflags", + "halfbrown", + "num-traits", + "rustc-hash 2.1.3", + "serde", + "strumbra", +] + +[[package]] +name = "librashader-pack" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e1405d8ae8996621cacbc5d1f9220486eda4cafaf229da02c02eecfbbe2cf" +dependencies = [ + "image", + "librashader-common", + "librashader-preprocess", + "librashader-presets", + "rayon", +] + +[[package]] +name = "librashader-preprocess" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62507ee532a6f3320acac509e3d3b32e8828c8bd1161a74bc576351729c820e2" +dependencies = [ + "encoding_rs", + "librashader-common", + "nom", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "librashader-presets" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d70306212493fbb4be617cdb98b7048cb896b16182951aeb17856bee3d14aeff" +dependencies = [ + "librashader-common", + "librashader-preprocess", + "nom", + "nom_locate", + "num-traits", + "once_cell", + "regex", + "serde", + "thiserror 2.0.21", + "vec_extract_if_polyfill", +] + +[[package]] +name = "librashader-reflect" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923a7ea3724e2fca281a08598217fe2daa5b6a245a278448251c851f19c0b290" +dependencies = [ + "bitflags", + "bytemuck", + "glslang", + "librashader-common", + "librashader-pack", + "librashader-preprocess", + "librashader-presets", + "naga", + "rspirv", + "rustc-hash 2.1.3", + "spirv", + "spirv-cross2", + "thiserror 2.0.21", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "petgraph", + "rustc-hash 1.1.0", + "serde", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rspirv" +version = "0.13.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091dca2e1d6fd3098417b5ec88e77e80d1ba5945750943419dc976858082c296" +dependencies = [ + "rustc-hash 1.1.0", + "spirv", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "spirv-cross-sys" +version = "0.7.4+aa217ae" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4a12082fc9d2414042b776646d6ed9fd650410f500e611dedd3f713cf7bd1d" +dependencies = [ + "bytemuck", + "cc", + "num-derive", + "num-traits", +] + +[[package]] +name = "spirv-cross2" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a95a3933e4d55d029b09cd3b1ce91a6b52a6cec0a7659aa4a4aeffddc260836f" +dependencies = [ + "bitflags", + "bytemuck", + "memchr", + "spirv", + "spirv-cross-sys", + "spirv-cross2-derive", + "thiserror 1.0.69", +] + +[[package]] +name = "spirv-cross2-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18017a288e6ce64dd5d56510166baeabb01849483555c031f573c091b6934a64" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strumbra" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b498044485e2789b38e047fdb4c7ac827bd72d66407af9e5d15626f9c45364e" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "vec_extract_if_polyfill" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c9cb5fb67c2692310b6eb3fce7dd4b6e4c9a75be4f2f46b27f0b2b7799759c" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/tools/slang-import/Cargo.toml b/tools/slang-import/Cargo.toml new file mode 100644 index 000000000..67eeda5b4 --- /dev/null +++ b/tools/slang-import/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "gen2-slang-import" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +librashader-preprocess = "=0.12.0" +librashader-presets = "=0.12.0" +librashader-reflect = "=0.12.0" +librashader-common = "=0.12.0" +regex = "1" +serde_json = "1" +sha2 = "0.10" diff --git a/tools/slang-import/fixtures/graph-first.slang b/tools/slang-import/fixtures/graph-first.slang new file mode 100644 index 000000000..a5421ceaa --- /dev/null +++ b/tools/slang-import/fixtures/graph-first.slang @@ -0,0 +1,14 @@ +#version 450 +#pragma parameter gain "GAIN" 0.5 0.0 1.0 0.1 +layout(push_constant) uniform Push { float gain; } params; +layout(std140,set=0,binding=0) uniform UBO { mat4 MVP; } global; +#pragma stage vertex +layout(location=0) in vec4 Position; +layout(location=1) in vec2 TexCoord; +layout(location=0) out vec2 uv; +void main(){gl_Position=global.MVP*Position;uv=TexCoord;} +#pragma stage fragment +layout(location=0) in vec2 uv; +layout(location=0) out vec4 FragColor; +layout(set=0,binding=2) uniform sampler2D Source; +void main(){vec4 c=texture(Source,uv);FragColor=vec4(c.r*params.gain,c.g,c.b,c.a);} diff --git a/tools/slang-import/fixtures/graph-last.slang b/tools/slang-import/fixtures/graph-last.slang new file mode 100644 index 000000000..a88d535ad --- /dev/null +++ b/tools/slang-import/fixtures/graph-last.slang @@ -0,0 +1,23 @@ +#version 450 +layout(push_constant) uniform Push { + vec4 SourceSize;vec4 OriginalSize;vec4 PassOutputSize0;vec4 TintSize;vec4 OutputSize;vec4 FinalViewportSize; +} params; +layout(std140,set=0,binding=0) uniform UBO { mat4 MVP; } global; +#pragma stage vertex +layout(location=0) in vec4 Position; +layout(location=1) in vec2 TexCoord; +layout(location=0) out vec2 uv; +void main(){gl_Position=global.MVP*Position;uv=TexCoord;} +#pragma stage fragment +layout(location=0) in vec2 uv; +layout(location=0) out vec4 FragColor; +layout(set=0,binding=2) uniform sampler2D Source; +layout(set=0,binding=3) uniform sampler2D Original; +layout(set=0,binding=4) uniform sampler2D PassOutput0; +layout(set=0,binding=5) uniform sampler2D Tint; +void main(){ + vec4 c=texture(Source,uv);vec4 original=texture(Original,uv);vec4 first=texture(PassOutput0,uv); + float sizes=params.SourceSize.x/params.OriginalSize.x+params.PassOutputSize0.x/params.OriginalSize.x; + float outputRatio=params.OutputSize.y/params.FinalViewportSize.y; + FragColor=vec4(c.r+original.r*0.25,first.g,texture(Tint,vec2(0.75,0.5)).b*sizes*outputRatio/6.0,original.a); +} diff --git a/tools/slang-import/fixtures/graph-middle.slang b/tools/slang-import/fixtures/graph-middle.slang new file mode 100644 index 000000000..2266b944c --- /dev/null +++ b/tools/slang-import/fixtures/graph-middle.slang @@ -0,0 +1,12 @@ +#version 450 +layout(std140,set=0,binding=0) uniform UBO { mat4 MVP; } global; +#pragma stage vertex +layout(location=0) in vec4 Position; +layout(location=1) in vec2 TexCoord; +layout(location=0) out vec2 uv; +void main(){gl_Position=global.MVP*Position;uv=TexCoord;} +#pragma stage fragment +layout(location=0) in vec2 uv; +layout(location=0) out vec4 FragColor; +layout(set=0,binding=2) uniform sampler2D Source; +void main(){vec4 c=texture(Source,uv);FragColor=vec4(c.r,c.g*0.5,c.b,c.a);} diff --git a/tools/slang-import/fixtures/graph.slangp b/tools/slang-import/fixtures/graph.slangp new file mode 100644 index 000000000..7c61ee9e5 --- /dev/null +++ b/tools/slang-import/fixtures/graph.slangp @@ -0,0 +1,21 @@ +shaders=3 +shader0=graph-first.slang +shader1=graph-middle.slang +shader2=graph-last.slang +filter_linear0=false +filter_linear1=false +filter_linear2=false +wrap_mode0=clamp_to_edge +wrap_mode1=clamp_to_edge +wrap_mode2=clamp_to_edge +scale_type0=original +scale0=1.0 +scale_type1=source +scale1=2.0 +scale_type2=viewport +scale2=1.0 +textures=Tint +Tint=tint.png +Tint_linear=false +Tint_wrap_mode=repeat +Tint_mipmap=false diff --git a/tools/slang-import/fixtures/probe.inc b/tools/slang-import/fixtures/probe.inc new file mode 100644 index 000000000..14ff0f9a4 --- /dev/null +++ b/tools/slang-import/fixtures/probe.inc @@ -0,0 +1 @@ +float probeGain(float red, float gain) { return red * gain; } diff --git a/tools/slang-import/fixtures/probe.slang b/tools/slang-import/fixtures/probe.slang new file mode 100644 index 000000000..559ae2869 --- /dev/null +++ b/tools/slang-import/fixtures/probe.slang @@ -0,0 +1,33 @@ +#version 450 +// Original ROM-free test shader. Also exercises includes, parameters and a vertex varying. +#include "probe.inc" +#pragma parameter gain "GAIN" 1.0 0.0 1.0 0.1 +#pragma parameter unused "UNUSED PARAMETER" 0.5 0.0 1.0 0.1 +layout(push_constant) uniform Push { + vec4 SourceSize; + vec4 OriginalSize; + vec4 OutputSize; + vec4 FinalViewportSize; + float gain; + float unused; +} params; +layout(std140, set=0, binding=0) uniform UBO { mat4 MVP; } global; +#pragma stage vertex +layout(location=0) in vec4 Position; +layout(location=1) in vec2 TexCoord; +layout(location=0) out vec2 sourcePixel; +void main() { + gl_Position = global.MVP * Position; + sourcePixel = TexCoord * params.SourceSize.xy; +} +#pragma stage fragment +layout(location=0) in vec2 sourcePixel; +layout(location=0) out vec4 FragColor; +layout(set=0, binding=2) uniform sampler2D Source; +layout(set=0, binding=3) uniform sampler2D Original; +void main() { + vec2 uv = sourcePixel * params.OriginalSize.zw; + vec4 c = (texture(Source, uv) + texture(Original, uv)) * 0.5; + FragColor = vec4(probeGain(c.r, params.gain), params.SourceSize.x / 64.0, + (params.OutputSize.y + params.FinalViewportSize.y) / 512.0, c.a); +} diff --git a/tools/slang-import/fixtures/probe.slangp b/tools/slang-import/fixtures/probe.slangp new file mode 100644 index 000000000..6cf32d862 --- /dev/null +++ b/tools/slang-import/fixtures/probe.slangp @@ -0,0 +1,8 @@ +shaders = 1 +shader0 = "probe.slang" +filter_linear0 = false +wrap_mode0 = "clamp_to_edge" +scale_type0 = "viewport" +scale0 = 1.0 +parameters = "gain" +gain = 0.5 diff --git a/tools/slang-import/fixtures/reference.slangp b/tools/slang-import/fixtures/reference.slangp new file mode 100644 index 000000000..6339422bb --- /dev/null +++ b/tools/slang-import/fixtures/reference.slangp @@ -0,0 +1,3 @@ +#reference "probe.slangp" +parameters = "gain" +gain = 0.7 diff --git a/tools/slang-import/fixtures/tint.png b/tools/slang-import/fixtures/tint.png new file mode 100644 index 000000000..8afb523ab Binary files /dev/null and b/tools/slang-import/fixtures/tint.png differ diff --git a/tools/slang-import/fixtures/wrap.slang b/tools/slang-import/fixtures/wrap.slang new file mode 100644 index 000000000..bb61d67b0 --- /dev/null +++ b/tools/slang-import/fixtures/wrap.slang @@ -0,0 +1,13 @@ +#version 450 +// Original ROM-free test shader: a one-pixel-wide linear sample across the repeat seam. +layout(std140, set=0, binding=0) uniform UBO { mat4 MVP; } global; +#pragma stage vertex +layout(location=0) in vec4 Position; +layout(location=1) in vec2 TexCoord; +layout(location=0) out vec2 uv; +void main() { gl_Position = global.MVP * Position; uv = TexCoord; } +#pragma stage fragment +layout(location=0) in vec2 uv; +layout(location=0) out vec4 FragColor; +layout(set=0, binding=2) uniform sampler2D Source; +void main() { FragColor = texture(Source, vec2(0.0, uv.y)); } diff --git a/tools/slang-import/fixtures/wrap.slangp b/tools/slang-import/fixtures/wrap.slangp new file mode 100644 index 000000000..c22c01443 --- /dev/null +++ b/tools/slang-import/fixtures/wrap.slangp @@ -0,0 +1,4 @@ +shaders = 1 +shader0 = "wrap.slang" +filter_linear0 = true +wrap_mode0 = "repeat" diff --git a/tools/slang-import/src/lib.rs b/tools/slang-import/src/lib.rs new file mode 100644 index 000000000..67dca3736 --- /dev/null +++ b/tools/slang-import/src/lib.rs @@ -0,0 +1,547 @@ +//! Offline, bounded RGBA8 slang -> LÖVE importer. No compiler is loaded by the game. +use librashader_common::{FilterMode, ImageFormat, WrapMode}; +use librashader_preprocess::ShaderSource; +use librashader_presets::{ScaleFactor, ScaleType, Scaling, ShaderFeatures, ShaderPreset}; +use librashader_reflect::{ + back::{CompileShader, FromCompilation, glsl::GlslVersion, targets::GLSL}, + front::{Glslang, ShaderInputCompiler}, + reflect::{ + ReflectShader, + semantics::{ + ShaderSemantics, TextureSemanticMap, TextureSemantics, UniqueSemanticMap, + UniqueSemantics, + }, + }, +}; +use regex::Regex; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + error::Error, + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; +pub type Result = std::result::Result>; + +pub struct ConvertedPass { + pub metadata: Value, + pub sources: [String; 4], +} +pub struct ConvertedTexture { + pub metadata: Value, + pub bytes: Vec, +} +pub struct Converted { + pub metadata: Value, + pub passes: Vec, + pub textures: Vec, +} +fn binding( + semantic: TextureSemantics, + index: usize, + pass: usize, + textures: usize, + legacy: bool, +) -> Result { + Ok(match (semantic, index) { + (TextureSemantics::Source, 0) => "g2_Source".into(), + (TextureSemantics::Original, 0) => if legacy { "g2_Source" } else { "g2_Original" }.into(), + (TextureSemantics::PassOutput, n) if n < pass => format!("g2_Pass{n}"), + (TextureSemantics::User, n) if n < textures => format!("g2_Lut{n}"), + _ => { + return Err( + "history, feedback, future or unknown texture binding is unsupported".into(), + ); + } + }) +} +fn rx(pattern: &str) -> Regex { + Regex::new(pattern).expect("internal regex") +} +fn replace_word(source: &str, word: &str, replacement: &str) -> String { + rx(&format!(r"\b{}\b", regex::escape(word))) + .replace_all(source, replacement) + .into_owned() +} +fn printable(s: &str) -> bool { + !s.is_empty() && s.len() <= 48 && s.bytes().all(|b| (32..=126).contains(&b)) +} +fn identifier(s: &str) -> bool { + s.len() <= 32 + && rx(r"^[A-Za-z_][A-Za-z0-9_]*$").is_match(s) + && !s.starts_with("love_") + && !s.starts_with("g2_") + && s != "sourceSize" + && s != "pixelScale" +} + +/// Adapt compiler-generated GLSL, never the original Vulkan shader text. +fn adapt( + input: &str, + vertex: bool, + semantics: &ShaderSemantics, + params: &[Value], + pass: usize, + textures: usize, + legacy: bool, +) -> Result { + if input.contains("g2_") { + return Err("reserved g2_ identifier in shader".into()); + } + let mut s = + rx(r"(?m)^#version[^\n]*\n|^#extension GL_EXT_gpu_shader4[^\n]*\n|^precision [^\n]*\n") + .replace_all(input, "") + .into_owned(); + let blocks = rx( + r"(?s)struct (LIBRA_(?:PUSH|UBO)_(?:VERTEX|FRAGMENT))\s*\{([^}]+)\};\s*uniform\s+\w+\s+(\w+);\s*", + ); + let mut declarations = String::new(); + for cap in blocks.captures_iter(&s.clone()) { + let instance = &cap[3]; + for field in cap[2].split(';').map(str::trim).filter(|v| !v.is_empty()) { + let parts: Vec<_> = field.split_whitespace().collect(); + let [ty, name] = parts.as_slice() else { + // The ES backend can add precision to integer members. + if parts.len() != 3 || !["lowp", "mediump", "highp"].contains(&parts[0]) { + return Err(format!("unsupported uniform field: {field}").into()); + } + continue; + }; + let qualified = format!("{instance}.{name}"); + if !rx(&format!(r"\b{}\b", regex::escape(&qualified))).is_match(&s) { + continue; + } // unused block members have no runtime requirements + let binding = if let Some(p) = params.iter().find(|p| p["id"] == *name) { + if *ty != "float" { + return Err("parameters must be scalar floats".into()); + } + format!("g2_p_{}", p["id"].as_str().unwrap()) + } else if let Some(v) = semantics.uniform_semantics.unique_semantic(name) { + match v.semantics { + UniqueSemantics::MVP if vertex && *ty == "mat4" => { + "transform_projection".into() + } + UniqueSemantics::Output if *ty == "vec4" => "g2_OutputSize".into(), + UniqueSemantics::FinalViewport if *ty == "vec4" => { + "g2_FinalViewportSize".into() + } + // Unsigned arithmetic is deliberately outside GLSL 1.20/ES 1.00 import. + _ => return Err(format!("unsupported uniform: {name}").into()), + } + } else if let Some(v) = semantics.uniform_semantics.texture_semantic(name) { + if *ty != "vec4" { + return Err("texture size must be vec4".into()); + } + let name = binding(v.semantics, v.index, pass, textures, false)?; + format!("{name}Size") + } else { + return Err(format!("unknown uniform: {name}").into()); + }; + if binding != "transform_projection" { + declarations.push_str(&format!("uniform {ty} {binding};\n")); + } + s = replace_word(&s, &qualified, &binding); + } + // Precision-qualified members are supported only when unused, never silently omitted. + if let Some(member) = rx(&format!( + r"\b{}\.([A-Za-z_][A-Za-z0-9_]*)", + regex::escape(instance) + )) + .captures_iter(&s) + .next() + { + return Err(format!("unsupported uniform: {}", &member[1]).into()); + } + } + s = blocks.replace_all(&s, "").into_owned(); + let samplers = rx(r"uniform\s+(?:(?:highp|mediump|lowp)\s+)?sampler2D\s+(\w+);\s*"); + for cap in samplers.captures_iter(&s.clone()) { + let name = &cap[1]; + let logical = name + .strip_prefix("LIBRA_TEXTURE_") + .ok_or("unknown sampler")?; + let semantic = semantics + .texture_semantics + .texture_semantic(logical) + .ok_or("unknown sampler semantic")?; + let target = binding(semantic.semantics, semantic.index, pass, textures, legacy)?; + s = replace_word(&s, name, &target); + } + s = samplers.replace_all(&s, "").into_owned(); + let mut bindings: Vec = vec!["g2_Source".into(), "g2_Original".into()]; + bindings.extend((0..pass).map(|i| format!("g2_Pass{i}"))); + bindings.extend((0..textures).map(|i| format!("g2_Lut{i}"))); + for name in bindings { + if rx(&format!(r"\b{name}\b")).is_match(&s) { + declarations.push_str(&format!("uniform Image {name};\n")); + } + } + if vertex { + s = rx(r"attribute vec4 Position;\s*|attribute vec2 TexCoord;\s*") + .replace_all(&s, "") + .into_owned(); + s = replace_word(&s, "Position", "vertex_position"); + s = replace_word(&s, "TexCoord", "VertexTexCoord.xy"); + s = replace_word(&s, "gl_Position", "g2_result"); + } else { + s = s.replace("gl_FragData[0]", "g2_result"); + } + // This bounded adapter accepts the one main() emitted last by SPIRV-Cross. + let main = rx(r"void main\(\)\s*\{"); + if main.find_iter(&s).count() != 1 || !s.trim_end().ends_with('}') { + return Err("unsupported entry point".into()); + } + let header = if vertex { + "vec4 position(mat4 transform_projection, vec4 vertex_position) { vec4 g2_result = vec4(0.0);" + } else { + "vec4 effect(vec4 g2_unusedColor, Image g2_unusedImage, vec2 g2_unusedUv, vec2 g2_unusedPixel) { vec4 g2_result = vec4(0.0);" + }; + s = main.replace(&s, header).into_owned(); + s = rx(r"\breturn\s*;") + .replace_all(&s, "return g2_result;") + .into_owned(); + let end = s.rfind('}').ok_or("missing main body")?; + s.insert_str(end, "return g2_result;\n"); + // Catch extension-dependent operations and unrecognized interfaces rather than change their meaning. + if rx(r"\b(uint|uvec[234]|gl_FragData|gl_FragColor|attribute|LIBRA_(?:PUSH|UBO|TEXTURE)_\w*)\b|(?m)^#extension").is_match(&s) { + return Err("shader needs an unsupported GLSL interface or extension".into()); + } + // LÖVE supplies stage-specific precision; retain the same varying precision in both stages. + Ok(format!("{declarations}\n{s}")) +} + +pub fn convert(path: &Path, name: &str) -> Result { + if !printable(name) { + return Err("name must contain 1-48 printable ASCII characters".into()); + } + if path.extension().and_then(|e| e.to_str()) != Some("slangp") { + return Err("expected a .slangp preset".into()); + } + let p = ShaderPreset::try_parse(path, ShaderFeatures::empty())?; + if p.passes.is_empty() || p.passes.len() > 8 || p.pass_count as usize != p.passes.len() { + return Err("one to eight shader passes are supported".into()); + } + if p.textures.len() > 8 { + return Err("at most eight LUT textures are supported".into()); + } + let legacy = p.passes.len()==1 && p.textures.is_empty() && (!p.passes[0].meta.scaling.valid || [&p.passes[0].meta.scaling.x,&p.passes[0].meta.scaling.y].iter().all(|s| matches!((&s.scale_type,&s.factor),(ScaleType::Viewport,ScaleFactor::Float(f)) if *f==1.0))); + let mut loaded = Vec::new(); + let mut parameters: Vec = Vec::new(); + for pass in &p.passes { + if pass.meta.srgb_framebuffer || pass.meta.float_framebuffer || pass.meta.mipmap_input { + return Err("special framebuffer/mipmap formats are not supported".into()); + } + let source = ShaderSource::load(&pass.path, p.features)?; + if !matches!( + source.format, + ImageFormat::Unknown | ImageFormat::R8G8B8A8Unorm + ) { + return Err("shader requires an unsupported output format".into()); + } + for v in source.parameters.values() { + let param = json!({"id":v.id.to_string(),"name":v.description,"default":v.initial,"min":v.minimum,"max":v.maximum,"step":v.step}); + if let Some(old) = parameters.iter().find(|old| old["id"] == param["id"]) { + if *old != param { + return Err("conflicting parameter definitions across passes".into()); + } + } else { + parameters.push(param); + } + } + loaded.push(source); + } + parameters.sort_by_key(|v| v["id"].as_str().unwrap_or_default().to_string()); + if parameters.len() > 8 { + return Err("at most eight parameters are supported".into()); + } + for param in &mut parameters { + if let Some(v) = p + .parameters + .iter() + .rev() // referenced presets come first; later overrides take precedence + .find(|v| v.name.as_str() == param["id"].as_str().unwrap()) + { + param["default"] = json!(v.value); + } + let id = param["id"].as_str().unwrap(); + if !identifier(id) || !printable(param["name"].as_str().unwrap_or("")) { + return Err("invalid parameter identifier or label".into()); + } + let nums: Vec<_> = ["min", "max", "step", "default"] + .iter() + .map(|k| param[*k].as_f64().unwrap_or(f64::NAN)) + .collect(); + let (lo, hi, step, def) = (nums[0], nums[1], nums[2], nums[3]); + if nums.iter().any(|v| !v.is_finite()) + || lo >= hi + || step <= 0.0 + || step > hi - lo + || (hi - lo) / step > 1_000_000.0 + || def < lo + || def > hi + { + return Err("invalid parameter range/default".into()); + } + } + let mut passes = Vec::new(); + for (i, pass) in p.passes.iter().enumerate() { + let semantics = ShaderSemantics::create_pass_semantics::>(&p, i)?; + let mut sources = Vec::new(); + for version in [GlslVersion::Glsl120, GlslVersion::Glsl100Es] { + let compiled = Glslang::compile(&loaded[i])?; + let mut target = GLSL::from_compilation(compiled)?; + target.reflect(i, &semantics)?; + let out = target.compile(version)?; + sources.push(adapt( + &out.fragment, + false, + &semantics, + ¶meters, + i, + p.textures.len(), + legacy, + )?); + sources.push(adapt( + &out.vertex, + true, + &semantics, + ¶meters, + i, + p.textures.len(), + legacy, + )?); + } + if sources.iter().any(|s| s.len() > 65536) { + return Err("converted shader exceeds 64 KiB".into()); + } + let (x, y) = if pass.meta.scaling.valid { + ( + scaling(&pass.meta.scaling.x)?, + scaling(&pass.meta.scaling.y)?, + ) + } else { + let kind = if i + 1 == p.passes.len() { + "viewport" + } else { + "source" + }; + ( + json!({"type":kind,"value":1}), + json!({"type":kind,"value":1}), + ) + }; + passes.push(ConvertedPass {metadata:json!({"filter":filter(pass.meta.filter),"wrap":wrap(pass.meta.wrap_mode),"scaleX":x,"scaleY":y}),sources:sources.try_into().unwrap()}); + } + let mut textures = Vec::new(); + let mut pixels = 0u64; + for t in &p.textures { + if t.meta.mipmap || !identifier(t.meta.name.as_str()) { + return Err("unsupported LUT identifier/mipmap".into()); + } + // Bound compressed data and decoded allocation before invoking the image decoder. + if fs::metadata(&t.path)?.len() > 8 * 1024 * 1024 { + return Err("LUT file too large".into()); + } + let bytes = fs::read(&t.path)?; + pixels += png_pixels(&bytes)?; + if pixels > 8 * 1024 * 1024 { + return Err("LUT pixel budget exceeded".into()); + } + textures.push(ConvertedTexture { metadata:json!({"id":t.meta.name.as_str(),"filter":filter(t.meta.filter_mode),"wrap":wrap(t.meta.wrap_mode)}),bytes}); + } + let mut metadata = json!({"format":if legacy {2}else{3},"name":name,"parameters":parameters}); + if legacy { + for key in ["filter", "wrap"] { + metadata[key] = passes[0].metadata[key].clone(); + } + } + Ok(Converted { + metadata, + passes, + textures, + }) +} +fn filter(v: FilterMode) -> &'static str { + match v { + FilterMode::Linear => "linear", + FilterMode::Nearest => "nearest", + } +} +fn wrap(v: WrapMode) -> &'static str { + match v { + WrapMode::ClampToBorder => "clampzero", + WrapMode::ClampToEdge => "clamp", + WrapMode::Repeat => "repeat", + WrapMode::MirroredRepeat => "mirroredrepeat", + } +} +fn scaling(s: &Scaling) -> Result { + let value: f32 = s.factor.into(); + let kind = match s.scale_type { + ScaleType::Input => "source", + ScaleType::Original => "original", + ScaleType::Viewport => "viewport", + ScaleType::Absolute => "absolute", + }; + if !value.is_finite() + || (kind == "absolute" && (value < 1.0 || value > 8192.0 || value.fract() != 0.0)) + || (kind != "absolute" && !(1.0 / 64.0..=8.0).contains(&value)) + { + return Err("unsupported scaling range".into()); + } + Ok(json!({"type":kind,"value":value})) +} +fn png_pixels(bytes: &[u8]) -> Result { + if bytes.len() < 33 + || bytes.len() > 8 * 1024 * 1024 + || &bytes[..16] != b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR" + { + return Err("LUT must be a bounded PNG".into()); + } + let w = u32::from_be_bytes(bytes[16..20].try_into()?); + let h = u32::from_be_bytes(bytes[20..24].try_into()?); + if !(1..=2048).contains(&w) + || !(1..=2048).contains(&h) + || bytes[24] != 8 + || ![0, 2, 3, 4, 6].contains(&bytes[25]) + { + return Err("unsupported LUT dimensions/bit depth".into()); + } + Ok(w as u64 * h as u64) +} + +/// Descriptor and all referenced resources, with a deterministic closed basename namespace. +type Bundle = (Value, Vec<(String, Vec)>); +fn bundle(c: &Converted, stem: &str, force_graph: bool) -> Result { + let mut meta = c.metadata.clone(); + let mut files = Vec::new(); + let mut passes = Vec::new(); + let mut textures = Vec::new(); + for (i, p) in c.passes.iter().enumerate() { + let mut pass = p.metadata.clone(); + for ((key, suffix), source) in ["shader", "vertex", "esShader", "esVertex"] + .iter() + .zip(["f", "v", "ef", "ev"]) + .zip(&p.sources) + { + let name = if c.metadata["format"] == 2 && !force_graph { + format!("{stem}-{suffix}.glsl") + } else { + format!("{stem}-p{i}-{suffix}.glsl") + }; + pass[*key] = json!(name); + files.push((name, source.as_bytes().to_vec())); + } + passes.push(pass); + } + for (i, t) in c.textures.iter().enumerate() { + let mut tex = t.metadata.clone(); + let name = format!("{stem}-t{i}.png"); + tex["image"] = json!(name); + files.push((name, t.bytes.clone())); + textures.push(tex); + } + if force_graph || c.metadata["format"] == 3 { + meta["format"] = json!(3); + meta["passes"] = json!(passes); + meta["textures"] = json!(textures); + } else { + for key in ["shader", "vertex", "esShader", "esVertex"] { + meta[key] = passes[0][key].clone(); + } + } + Ok((meta, files)) +} + +/// Validate both dialects and compile the current GPU's pair BEFORE publication. +/// Runs LÖVE directly with argument boundaries, never through a shell. +pub fn preflight(converted: &Converted, love: &Path) -> Result<()> { + let dir = std::env::temp_dir().join(format!( + "gen2-slang-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + fs::create_dir(&dir)?; + let result = (|| -> Result<()> { + fs::write( + dir.join("conf.lua"), + "function love.conf(t) t.identity='gen2-slang-preflight'; t.window.visible=false; t.window.width=32; t.window.height=32; t.modules.audio=false; t.modules.joystick=false end", + )?; + fs::write(dir.join("main.lua"), include_str!("validate.lua"))?; + fs::write( + dir.join("ShaderGraph.lua"), + include_str!("../../../src/render/ShaderGraph.lua"), + )?; + fs::write( + dir.join("Json.lua"), + include_str!("../../../src/link/Json.lua"), + )?; + let (meta, files) = bundle(converted, "probe", true)?; + for (name, bytes) in files { + fs::write(dir.join(name), bytes)?; + } + fs::write(dir.join("graph.json"), serde_json::to_vec(&meta)?)?; + let output = Command::new(love).arg(&dir).output()?; + if !output.status.success() { + return Err(format!( + "LÖVE preflight failed:\n{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + Ok(()) + })(); + let _ = fs::remove_dir_all(&dir); + result +} + +/// Content-addressed, create-new-only files, descriptor published last. +/// An interrupted import cannot expose a descriptor with partial source files. +pub fn publish(converted: &Converted, destination: &Path) -> Result { + let mut hash = Sha256::new(); + hash.update(converted.metadata.to_string()); + for p in &converted.passes { + hash.update(p.metadata.to_string()); + for source in &p.sources { + hash.update((source.len() as u64).to_le_bytes()); + hash.update(source); + } + } + for t in &converted.textures { + hash.update(t.metadata.to_string()); + hash.update((t.bytes.len() as u64).to_le_bytes()); + hash.update(&t.bytes); + } + let stem = format!("slang-{:x}", hash.finalize()); + let stem = &stem[..38]; + let (meta, mut files) = bundle(converted, stem, false)?; + let descriptor = serde_json::to_vec_pretty(&meta)?; + if descriptor.len() > 16384 { + return Err("descriptor exceeds 16 KiB".into()); + } + files.push((format!("{stem}.json"), descriptor)); + fs::create_dir_all(destination)?; + let path = destination.join(format!("{stem}.json")); + let mut created = Vec::new(); + let result = (|| -> Result { + for (name, contents) in &files { + let p = destination.join(name); + let mut f = OpenOptions::new().write(true).create_new(true).open(&p)?; + created.push(p); + f.write_all(contents)?; + f.sync_all()?; + } + Ok(path) + })(); + if result.is_err() { + for p in created { + let _ = fs::remove_file(p); + } + } + result +} diff --git a/tools/slang-import/src/main.rs b/tools/slang-import/src/main.rs new file mode 100644 index 000000000..1bea30399 --- /dev/null +++ b/tools/slang-import/src/main.rs @@ -0,0 +1,38 @@ +use std::path::PathBuf; +fn run() -> gen2_slang_import::Result<()> { + let mut args = std::env::args_os().skip(1); + let preset = PathBuf::from(args.next().ok_or( + "usage: gen2-slang-import PRESET.slangp DESTINATION --name NAME [--love EXECUTABLE]", + )?); + let destination = PathBuf::from( + args.next() + .ok_or("missing destination shader-presets directory")?, + ); + let mut name = None; + let mut love = PathBuf::from("love"); + while let Some(key) = args.next() { + match key.to_str() { + Some("--name") => { + name = Some( + args.next() + .ok_or("missing name")? + .into_string() + .map_err(|_| "name must be UTF-8")?, + ) + } + Some("--love") => love = PathBuf::from(args.next().ok_or("missing LÖVE executable")?), + _ => return Err("unknown argument; expected --name NAME or --love EXECUTABLE".into()), + } + } + let converted = gen2_slang_import::convert(&preset, &name.ok_or("--name NAME is required")?)?; + gen2_slang_import::preflight(&converted, &love)?; + let descriptor = gen2_slang_import::publish(&converted, &destination)?; + println!("Imported {}", descriptor.display()); + Ok(()) +} +fn main() { + if let Err(error) = run() { + eprintln!("Import failed: {error}"); + std::process::exit(1); + } +} diff --git a/tools/slang-import/src/validate.lua b/tools/slang-import/src/validate.lua new file mode 100644 index 000000000..9597a92e7 --- /dev/null +++ b/tools/slang-import/src/validate.lua @@ -0,0 +1,29 @@ +function love.errorhandler(message) + io.stderr:write(tostring(message) .. "\n") + return function() + return 1 + end +end +function love.load() + local ok, why = pcall(function() + local Graph, Json = require("ShaderGraph"), require("Json") + local raw = Json.decode(assert(love.filesystem.read("graph.json"))) + local spec = assert(Graph.validate(raw)) + local values = {} + for _, p in ipairs(raw.parameters) do + values[p.id] = p.default + end + local graph, err = Graph.new(spec, function(name, limit) + local info = assert(love.filesystem.getInfo(name)) + assert(info.size <= limit, "oversized graph resource") + return assert(love.filesystem.read(name)) + end, love.graphics, values, love.filesystem.newFileData) + assert(graph, err) + graph:release() + print("GL+GLES graph validation and native GPU readback PASS") + end) + if not ok then + io.stderr:write(tostring(why) .. "\n") + end + love.event.quit(ok and 0 or 1) +end diff --git a/tools/slang-import/tests/import.rs b/tools/slang-import/tests/import.rs new file mode 100644 index 000000000..deb586a9b --- /dev/null +++ b/tools/slang-import/tests/import.rs @@ -0,0 +1,251 @@ +use gen2_slang_import::{convert, publish}; +use std::{ + fs, + path::{Path, PathBuf}, +}; +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("fixtures") + .join(name) +} +struct Scratch(PathBuf); +impl Scratch { + fn new() -> Self { + let p = std::env::temp_dir().join(format!( + "gen2-import-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir(&p).unwrap(); + Self(p) + } + fn preset(&self, text: &str) -> PathBuf { + let p = self.0.join("test.slangp"); + let shader = fixture("probe.slang") + .display() + .to_string() + .replace('\\', "/"); + fs::write( + &p, + format!( + "{}shader0=\"{shader}\"\n{text}\n", + if text.contains("shaders=") { + "" + } else { + "shaders=1\n" + } + ), + ) + .unwrap(); + p + } +} +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +#[test] +fn compiles_both_stages_and_dialects_with_includes_and_overrides() { + let c = convert(&fixture("probe.slangp"), "PROBE").unwrap(); + assert_eq!(c.metadata["parameters"][0]["default"], 0.5); + assert_eq!(c.metadata["parameters"][1]["id"], "unused"); + assert_eq!(c.metadata["filter"], "nearest"); + assert_eq!(c.metadata["wrap"], "clamp"); + for pair in c.passes[0].sources.as_chunks::<2>().0 { + assert!(pair[0].contains("vec4 effect(")); + assert!(pair[1].contains("vec4 position(")); + assert!(pair[0].contains("g2_p_gain")); + assert!(pair[1].contains("g2_SourceSize")); + assert!(!pair[0].contains("LIBRA_TEXTURE_")); + assert!(!pair[1].contains("LIBRA_UBO_")); + } +} +#[test] +fn referenced_presets_resolve_relative_sources_and_override_parameters() { + let c = convert(&fixture("reference.slangp"), "REFERENCE").unwrap(); + assert!((c.metadata["parameters"][0]["default"].as_f64().unwrap() - 0.7).abs() < 0.00001); +} +#[test] +fn rejects_unsupported_pass_graph_formats_scaling_and_bad_metadata() { + let t = Scratch::new(); + fs::write(t.0.join("probe.png"), "").unwrap(); + for (extra, error) in [ + ("shaders=9", "eight"), + ("textures=lut\nlut=probe.png", "LUT"), + ("float_framebuffer0=true", "framebuffer"), + ("srgb_framebuffer0=true", "framebuffer"), + ("mipmap_input0=true", "mipmap"), + ("scale_type0=source\nscale0=9.0", "scaling"), + ("scale_type0=absolute\nscale0=9000", "scaling"), + ("parameters=gain\ngain=2.0", "parameter range"), + ] { + assert!( + convert(&t.preset(extra), "TEST") + .err() + .unwrap() + .to_string() + .contains(error), + "{extra}" + ); + } + assert!(convert(&fixture("probe.slangp"), "\nINVALID").is_err()); + assert!(convert(&fixture("probe.slang"), "TEST").is_err()); + assert!(convert(&t.0.join("missing.slangp"), "TEST").is_err()); +} +#[test] +fn rejects_history_feedback_time_and_source_format_requirements() { + let t = Scratch::new(); + let original = fs::read_to_string(fixture("probe.slang")).unwrap().replace( + "#include \"probe.inc\"", + "float probeGain(float r,float g){return r*g;}", + ); + for text in [ + original + .replace("sampler2D Original", "sampler2D OriginalHistory1") + .replace("texture(Original,", "texture(OriginalHistory1,"), + original + .replace("sampler2D Original", "sampler2D PassFeedback0") + .replace("texture(Original,", "texture(PassFeedback0,"), + original + .replace("float unused;", "float unused; uint FrameCount;") + .replace("params.gain)", "float(params.FrameCount))"), + original.replace( + "#version 450", + "#version 450\n#pragma format R16G16B16A16_SFLOAT", + ), + ] { + fs::write(t.0.join("bad.slang"), text).unwrap(); + fs::write(t.0.join("bad.slangp"), "shaders=1\nshader0=bad.slang").unwrap(); + assert!(convert(&t.0.join("bad.slangp"), "BAD").is_err()); + } +} +#[test] +fn publication_never_overwrites_and_rolls_back_only_its_own_files() { + let t = Scratch::new(); + let c = convert(&fixture("probe.slangp"), "TEST").unwrap(); + let path = publish(&c, &t.0).unwrap(); + let snapshot = fs::read(&path).unwrap(); + assert_eq!(fs::read_dir(&t.0).unwrap().count(), 5); + assert!(publish(&c, &t.0).is_err()); + assert_eq!(fs::read(&path).unwrap(), snapshot); + let json: serde_json::Value = serde_json::from_slice(&snapshot).unwrap(); + for key in ["shader", "vertex", "esShader", "esVertex"] { + let file = json[key].as_str().unwrap(); + assert!(file.len() <= 64 && t.0.join(file).is_file()); + fs::remove_file(t.0.join(file)).unwrap(); + } + // A collision at the descriptor is reached after four new source writes. + assert!(publish(&c, &t.0).is_err()); + assert_eq!(fs::read_dir(&t.0).unwrap().count(), 1); + assert_eq!(fs::read(&path).unwrap(), snapshot); +} +#[test] +fn failed_cli_preflight_preserves_existing_destination() { + let t = Scratch::new(); + let destination = t.0.join("shader presets with spaces"); + fs::create_dir(&destination).unwrap(); + fs::write(destination.join("keep.json"), "existing preset").unwrap(); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_gen2-slang-import")) + .arg(fixture("probe.slangp")) + .arg(&destination) + .args(["--name", "TEST ; $()", "--love", "/missing/love/executable"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Import failed:")); + assert_eq!(fs::read_dir(&destination).unwrap().count(), 1); + assert_eq!( + fs::read_to_string(destination.join("keep.json")).unwrap(), + "existing preset" + ); +} + +#[test] +fn graph_converts_source_original_earlier_pass_and_lut_in_both_dialects() { + let c = convert(&fixture("graph.slangp"), "GRAPH").unwrap(); + assert_eq!(c.metadata["format"], 3); + assert_eq!(c.passes.len(), 3); + assert_eq!(c.textures.len(), 1); + for i in [0, 2] { + let s = &c.passes[2].sources[i]; + for name in [ + "g2_Source", + "g2_Original", + "g2_Pass0", + "g2_Lut0", + "g2_SourceSize", + "g2_OriginalSize", + "g2_Pass0Size", + "g2_OutputSize", + "g2_FinalViewportSize", + ] { + assert!(s.contains(name), "{name}"); + } + } + assert_eq!(c.passes[1].metadata["scaleX"]["type"], "source"); + assert_eq!(c.passes[1].metadata["scaleX"]["value"], 2.0); + let t = Scratch::new(); + let path = publish(&c, &t.0).unwrap(); + let meta: serde_json::Value = serde_json::from_slice(&fs::read(path).unwrap()).unwrap(); + assert_eq!(fs::read_dir(&t.0).unwrap().count(), 14); + for p in meta["passes"].as_array().unwrap() { + for key in ["shader", "vertex", "esShader", "esVertex"] { + assert!(t.0.join(p[key].as_str().unwrap()).is_file()); + } + } + assert_eq!( + fs::read(t.0.join(meta["textures"][0]["image"].as_str().unwrap())).unwrap(), + c.textures[0].bytes + ); +} +#[test] +fn bounded_scaling_and_unsupported_lut_and_parameter_conflicts() { + let t = Scratch::new(); + for extra in [ + "scale_type0=source\nscale0=2", + "scale_type0=original\nscale0=0.5", + "scale_type0=absolute\nscale0=64", + ] { + let c = convert(&t.preset(extra), "SCALE").unwrap(); + assert_eq!(c.metadata["format"], 3); + } + let text = fs::read_to_string(fixture("graph.slangp")).unwrap(); + for name in [ + "graph-first.slang", + "graph-middle.slang", + "graph-last.slang", + "tint.png", + ] { + fs::copy(fixture(name), t.0.join(name)).unwrap(); + } + for bad in [ + text.replace("Tint_mipmap=false", "Tint_mipmap=true"), + text.replace("shader2=graph-last.slang", "shader2=bad.slang"), + ] { + let shader = fs::read_to_string(fixture("graph-last.slang")) + .unwrap() + .replace("PassOutput0", "PassOutput2"); + fs::write(t.0.join("bad.slang"), shader).unwrap(); + fs::write(t.0.join("bad.slangp"), bad).unwrap(); + assert!(convert(&t.0.join("bad.slangp"), "BAD").is_err()); + } + fs::write( + t.0.join("graph-middle.slang"), + fs::read_to_string(fixture("graph-first.slang")) + .unwrap() + .replace("0.5 0.0", "0.8 0.0"), + ) + .unwrap(); + fs::write(t.0.join("bad.slangp"), text).unwrap(); + assert!( + convert(&t.0.join("bad.slangp"), "BAD") + .err() + .unwrap() + .to_string() + .contains("conflicting") + ); +} diff --git a/tools/status-hud-contract-check/check.py b/tools/status-hud-contract-check/check.py new file mode 100644 index 000000000..def52b3db --- /dev/null +++ b/tools/status-hud-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged status HUD mod through both engines' native battle UI renderers.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/status_hud/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("STATUS_HUD_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("STATUS_HUD_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"battle status HUD mismatch: {version}" + print(f"PASS {version}: matching native battle UI renderers across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_status_hud/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/storage-contract-check/check.py b/tools/storage-contract-check/check.py new file mode 100644 index 000000000..144ef075e --- /dev/null +++ b/tools/storage-contract-check/check.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Compare unchanged API-3 storage consumers through real SDK/save lifecycles.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" +VERSIONS = ["red", "blue", "yellow", "gold", "silver", "crystal", "firered", "emerald"] + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in VERSIONS: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([a.lua, str(gen2/"tests/playthrough_storage/worker.lua"), + str(gen2), backend, version], cwd=engine, + text=True, capture_output=True, timeout=30) + lines = [line[len("STORAGE_CONTRACT "):] for line in run.stdout.splitlines() + if line.startswith("STORAGE_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"storage mismatch: {version}" + print(f"PASS {version}: {len(observations[0])} matching API-3 storage/lifecycle observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": 3, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_storage/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/trainer-preparation-contract-check/check.py b/tools/trainer-preparation-contract-check/check.py new file mode 100644 index 000000000..059bd545f --- /dev/null +++ b/tools/trainer-preparation-contract-check/check.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Run one unchanged trainer preparation mod through both engines' native trainer admission paths.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/trainer_preparation/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("TRAINER_PREPARATION_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("TRAINER_PREPARATION_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + assert observations[0] == observations[1], f"trainer preparation mismatch: {version}" + print(f"PASS {version}: matching native trainer admission paths across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_trainer_preparation/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main() diff --git a/tools/world-shadow-check/conf.lua b/tools/world-shadow-check/conf.lua new file mode 100644 index 000000000..384f4fb72 --- /dev/null +++ b/tools/world-shadow-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("WORLD_SHADOW_QA_GUARDED") == "1", "use the guarded shadow checker") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "World anchored shadow qualification" + t.window.width, t.window.height, t.window.vsync = 640, 480, 0 + t.modules.audio = false +end diff --git a/tools/world-shadow-check/main.lua b/tools/world-shadow-check/main.lua new file mode 100644 index 000000000..0c38f758c --- /dev/null +++ b/tools/world-shadow-check/main.lua @@ -0,0 +1,297 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local root = assert(os.getenv("WORLD_SHADOW_QA_ROOT")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +require("src.core.GameVersion").set("crystal") +require("src.core.Performance").tier = "high" +local V, libs = { sharedHulls = true }, {} +V.require = function(name) + if not libs[name] then + libs[name] = assert(loadfile(root .. "/mods/DRAMATIC_SHAPE/lib/" .. name .. ".lua"))(V) + end + return libs[name] +end +V.data = function(name) + return assert(loadfile(root .. "/mods/DRAMATIC_SHAPE/data/" .. name .. ".lua"))() +end +-- Geometry is owned. Resource/mesher seams supply it; actual scene casting, +-- shadow shaders, matrices, canvases and the lit scene shader stay native. +libs.ChunkMesher = { + flowers = function(map) + return map.flowers + end, + figures = function(map) + return map.figures + end, +} +local Mat = V.require("Mat4") +local Shadow = V.require("ShadowMap") +local Voxel = V.require("VoxelState") +local G = V.require("Voxel3D") +local Perf = V.require("Perf") +Perf.enabled = true +local Scene = V.require("VoxelScene") +local function up(fn, name) + for i = 1, 100 do + local key, value = debug.getupvalue(fn, i) + if key == name then + return value + end + if not key then + break + end + end + error("missing scene producer " .. name) +end +local cast = up(Scene.render, "castShadows") +local tex, terrain, poses, state, atlas +local results = {} +local phase, frame, total, frameTotal, submitTotal = 1, 0, 0, 0, 0 +local checks = 0 +local function check(ok, message) + assert(ok, message) + checks = checks + 1 +end +local function setup() + local pixels = love.image.newImageData(16, 96) + pixels:mapPixel(function(x, y) + return 0.3, 0.7, 0.4, (x > 2 and x < 13) and 1 or 0 + end) + tex = love.graphics.newImage(pixels) + local verts = {} + -- Many visible ground triangles exercise the repeated static pass. + for z = -16, 31 do + for x = -16, 31 do + local a, b = x * 16, z * 16 + for _, p in ipairs({ + { a, 0, b }, + { a + 16, 0, b }, + { a + 16, 0, b + 16 }, + { a, 0, b }, + { a + 16, 0, b + 16 }, + { + a, + 0, + b + 16, + }, + }) do + verts[#verts + 1] = { p[1], p[2], p[3], 0.5, 0.5, 1 } + end + end + end + terrain = love.graphics.newMesh(G.FORMAT, verts, "triangles", "static") + state = { map = { id = "OWN_SHADOW_MAP", tileset = {} }, neighbors = {} } + local def = + { image = "owned-sprite", billboardTexture = tex, frameWidth = 16, frameHeight = 16, frames = 6, walker = true } + poses = { + { + sprite = { + def = def, + resolveImage = function() + return tex + end, + }, + px = 400, + py = 400, + gh = 0, + facing = "down", + phase = 0, + flip = false, + }, + } + atlas = function() + return tex + end + Voxel.angle = math.rad(35) + check(G.shader(false) ~= nil, "lit scene shader did not compile") + check(G.shader(false, true) ~= nil, "instanced lit scene shader did not compile") + check(Shadow.available() and Shadow.dynamicAvailable(), "native shadow targets unavailable") +end +local function castAt(cx, cy) + cast(state, terrain, {}, poses, cx, cy, 160, 144, atlas, nil, nil, nil, nil) +end +local function qualify() + local Card = V.require("WorldShadows") + local box = Card.bounds(-1, 257, -257, 1) + check(box[1] == -256 and box[2] == 512 and box[3] == -512 and box[4] == 256, "negative world snapping changed") + V.shadowWorldAnchor = true + castAt(400, 400) + local first = Shadow.texture():newImageData():getString() + local vp = Json.encode(Shadow.clipVP) + poses[1].px = 412 + poses[1].phase = 1 + castAt(401, 401) + check(Shadow.texture():newImageData():getString() == first, "walking redrew/changed static texture") + check(Json.encode(Shadow.clipVP) == vp, "walking moved the anchored light matrix") + check(Shadow.dynamicTexture():getWidth() == 256, "moving target is not 256 square") + local moving = Shadow.dynamicTexture():newImageData():getString() + poses[1].px = 448 + castAt(401, 401) + check(Shadow.dynamicTexture():newImageData():getString() ~= moving, "moving actor shadow stayed stale") + local before = Perf.counters["shadow.static.cast"] or 0 + castAt(801, 400) + check( + (Perf.counters["shadow.static.cast"] or 0) == before + 1, + "visible tile-set change did not redraw static shadows" + ) + before = Perf.counters["shadow.static.cast"] or 0 + state.map.flowers = terrain + castAt(801, 400) + check((Perf.counters["shadow.static.cast"] or 0) == before + 1, "arriving geometry did not redraw static shadows") + state.map.flowers = nil + V.shadowWorldAnchor = false + castAt(400, 400) + check(not Shadow.dynamicActive() and not Shadow.isAnchored(), "P7 disable did not restore legacy map") + V.shadowWorldAnchor = true + poses[1].px = 400 + castAt(400, 400) + local function litFloor() + check(G.beginScene(320, 240, 400, 400, 160, 120), "native lit scene did not begin") + G.draw(terrain, tex) + G.endScene() + local image = G.canvas():newImageData() + local tag = Shadow.dynamicActive() and "with-actor" or "without-actor" + local f = assert(io.open(assert(os.getenv("WORLD_SHADOW_QA_OUT")) .. "/" .. tag .. ".png", "wb")) + f:write(image:encode("png"):getString()) + f:close() + local bytes = image:getString() + image:release() + return bytes + end + local withActor = litFloor() + V.shadowWorldAnchor = false + check(litFloor() ~= withActor, "lit shader did not sample the dynamic shadow texture") + V.shadowWorldAnchor = true + castAt(400, 400) + local staticBefore = Perf.counters["shadow.static.cast"] or 0 + local movingBefore = Perf.counters["shadow.dynamic.cast"] or 0 + castAt(400, 400) + check( + (Perf.counters["shadow.static.cast"] or 0) == staticBefore + and (Perf.counters["shadow.dynamic.cast"] or 0) == movingBefore, + "idle layers were redrawn" + ) + local kx = Shadow.KX + Shadow.KX = kx + 0.1 + castAt(400, 400) + check((Perf.counters["shadow.static.cast"] or 0) == staticBefore + 1, "sun change left static shadows stale") + Shadow.KX = kx + castAt(400, 400) + staticBefore = Perf.counters["shadow.static.cast"] or 0 + state.neighbors = { { map = { tileset = {} }, ox = 16, oy = 0 } } + castAt(400, 400) + state.neighbors[1].ox = 32 + castAt(400, 400) + check((Perf.counters["shadow.static.cast"] or 0) == staticBefore + 2, "neighbor arrival/offset was not invalidated") + state.neighbors = {} + require("src.core.Performance").tier = "balanced" + castAt(400, 400) + check(Shadow.res <= 1024, "existing balanced resolution cap changed") + movingBefore = Perf.counters["shadow.dynamic.cast"] or 0 + for i = 1, 6 do + poses[1].px = 400 + i + castAt(400, 400) + end + local casts = (Perf.counters["shadow.dynamic.cast"] or 0) - movingBefore + check(casts == 2, "existing balanced moving-shadow cadence changed") + require("src.core.Performance").tier = "high" + -- An unavailable secondary target must retain the complete legacy pass. + Shadow.invalidate() + V.shadowWorldAnchor = true + local Pixel = V.require("PixelCanvas") + local make = Pixel.new + local denied = 0 + Pixel.new = function(w, h) + if w == 256 and h == 256 then + denied = denied + 1 + return false, "owned failure" + end + return make(w, h) + end + castAt(400, 400) + poses[1].phase = 1 - poses[1].phase + castAt(401, 400) + check( + not Shadow.isAnchored() and not Shadow.dynamicActive() and Shadow.active(), + "missing secondary target lost legacy shadows" + ) + check(denied == 1, "failed secondary target was retried every frame") + Pixel.new = make + Shadow.invalidate() + Perf.counters = {} +end +function love.errorhandler(message) + print(debug.traceback(tostring(message), 2)) + return function() + return 1 + end +end +function love.run() + setup() + qualify() + return function() + local frameBegan = love.timer.getTime() + love.event.pump() + for event in love.event.poll() do + if event == "quit" then + return 1 + end + end + V.shadowWorldAnchor = phase == 2 + poses[1].px = 400 + math.sin(frame * 0.07) * 16 + poses[1].phase = frame % 2 + if frame == 120 then + Profile.setEnabled(true) + Perf.counters = {} + end + local close = Profile.section("voxel.sun submit (owned geometry)") + local t = love.timer.getTime() + castAt(400 + frame % 60 / 4, 400) + local submitted = love.timer.getTime() - t + close() + local closeFence = Profile.section("voxel.sun GPU fence (owned geometry)") + -- Complete this offscreen pass before timing it. Avoid drawable/present + -- throttling; one pixel is read in both modes and no full-frame copy is timed. + local target = Shadow.dynamicActive() and Shadow.dynamicTexture() or Shadow.texture() + local fence = target:newImageData(1, 1, 0, 0, 1, 1) + fence:release() + if frame >= 120 then + total = total + love.timer.getTime() - t + submitTotal = submitTotal + submitted + end + closeFence() + if frame >= 120 then + frameTotal = frameTotal + love.timer.getTime() - frameBegan + Profile.frame() + end + frame = frame + 1 + if frame == 240 then + results[phase] = { + mode = phase == 1 and "legacy" or "world anchored", + frames = 120, + meanMs = total / 120 * 1000, + meanSubmitMs = submitTotal / 120 * 1000, + meanFrameMs = frameTotal / 120 * 1000, + warmupFrames = 120, + staticPasses = Perf.counters["shadow.static.cast"] or 0, + dynamicPasses = Perf.counters["shadow.dynamic.cast"] or 0, + legacyPasses = Perf.counters["shadow.cast"] or 0, + } + if phase == 2 then + check(results[2].staticPasses < results[1].legacyPasses, "static pass was not reused") + print("WORLD_SHADOW_RESULT " .. Json.encode({ + phases = results, + checks = checks, + renderer = love.graphics.getRendererInfo(), + scope = "Offscreen owned geometry, actual scene casting/shaders/targets, one-pixel GPU completion fence; no presentation, ROM or full-game FPS", + })) + return 0 + end + Profile.setEnabled(false) + Shadow.invalidate() + Perf.counters = {} + phase, frame, total, frameTotal, submitTotal = 2, 0, 0, 0, 0 + end + end +end diff --git a/tools/world-talk-contract-check/check.py b/tools/world-talk-contract-check/check.py new file mode 100644 index 000000000..cab9ec430 --- /dev/null +++ b/tools/world-talk-contract-check/check.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Run one unchanged NPC talk mod through both engines' native NPC interaction paths.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +GEN1 = "79ea62f45fb2ea8f65d081a109431e4eb1bbdb36" + + +def git(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--gen1", type=Path, required=True) + p.add_argument("--gen2", type=Path, default=Path(__file__).resolve().parents[2]) + p.add_argument("--lua", default=os.environ.get("LUA", "luajit")) + p.add_argument("--api", type=int, choices=[1, 2, 3], default=2) + p.add_argument("--format", choices=["native", "unified"], default="native") + p.add_argument("--report", type=Path) + a = p.parse_args() + gen1, gen2 = a.gen1.resolve(), a.gen2.resolve() + if git(gen1, "rev-parse", "HEAD") != GEN1 or git(gen1, "status", "--porcelain"): + p.error("Gen1 must be clean at " + GEN1) + results = [] + for version in ["red", "yellow", "gold", "firered", "emerald"]: + observations = [] + for backend, engine in [("gen1", gen1), ("gen2", gen2)]: + run = subprocess.run([ + a.lua, str(gen2/"tests/world_talk/worker.lua"), str(gen2), + backend, version, str(a.api), a.format, + ], cwd=engine, text=True, capture_output=True, timeout=30) + lines = [x[len("WORLD_TALK_CONTRACT "):] for x in run.stdout.splitlines() + if x.startswith("WORLD_TALK_CONTRACT ")] + if run.returncode or len(lines) != 1: + raise RuntimeError(f"{backend}/{version}: {run.stdout}{run.stderr}") + rows = json.loads(lines[0]) + observations.append(rows) + results.append({"backend": backend, "version": version, + "observations": rows, + "checks": run.stdout.splitlines()[-1]}) + if version == "gold": + assert observations[0] == [{"label": "reference boundary", "native": 1}] + print("PASS gold: reference has no native producer; Gen2 runs the shared cases") + continue + assert observations[0] == observations[1], f"NPC talk mismatch: {version}" + print(f"PASS {version}: matching native NPC interaction paths across {len(observations[0])} observations") + assert not git(gen1, "status", "--porcelain"), "reference changed" + report = {"gen1_ref": GEN1, "gen2_ref": git(gen2, "rev-parse", "HEAD"), + "api": a.api, "format": a.format, "results": results, + "consumer_sha256": hashlib.sha256( + (gen2/"examples/portable_world_talk/main.lua").read_bytes()).hexdigest()} + if a.report: + a.report.write_text(json.dumps(report, indent=2)+"\n") + + +if __name__ == "__main__": + main()